diff --git a/config.yaml b/config.yaml index 6015566..07e3644 100644 --- a/config.yaml +++ b/config.yaml @@ -182,7 +182,7 @@ oracle: # Retry the GET this many times before flagging. Covers the UI-success-vs- # committed-state race on eventually-consistent backends (read replicas, async # commit paths). Total wait ≈ (pollAttempts - 1) × pollDelayMs. - pollAttempts: 3 + pollAttempts: 3 # (increase to 6-8 for geo-replicated or multi-region deployments) pollDelayMs: 500 # ms between retries # What "deleted" looks like for this target. RFC 9110 allows 404 or 410. # Add 403 if the target hides deleted resources behind a permission check. diff --git a/src/agent/expectations.js b/src/agent/expectations.js index f586d92..23bedad 100644 --- a/src/agent/expectations.js +++ b/src/agent/expectations.js @@ -29,7 +29,8 @@ export const HARD_SIGNALS = { // PERF_BREACH decommissioned (DECISION_LOG 013): per-action wall-clock latency is // environment noise, not reproducible across machines, and never indicated a real bug. // first-party but app-intentional patterns (error boundaries, fetch guards) are indistinguishable from crashes. - CONSOLE_ERROR: { score: 0.7, severity: 'medium', tier: 'flag-for-review' }, + CONSOLE_ERROR: { score: 0.55, severity: 'medium', tier: 'flag-for-review' }, + // Score intentionally below STATE_WRONG_VALUE (0.6) — console warning is ambiguous; a persisted wrong value is a data integrity fault. // Demoted to flag-for-review (DECISION_LOG 013): a fixed-delay empty-DOM check // false-fires on slow SPA hydration and legitimately-empty states; a genuine // crash-to-blank-screen is already auto-asserted via the co-firing PAGEERROR/ diff --git a/src/agent/oracles/authzReplay.js b/src/agent/oracles/authzReplay.js index 2f6a663..5639f4f 100644 --- a/src/agent/oracles/authzReplay.js +++ b/src/agent/oracles/authzReplay.js @@ -40,7 +40,7 @@ function isCapabilityUrl(url) { // Collect id/uuid-like values from a response body (bounded depth/width) so the // replay body can be checked for the SAME owned record the authed arm saw. -const ID_FIELDS = new Set(['id', 'uuid', 'user_id', 'owner_id', 'account_id']); +const ID_FIELDS = new Set(['id', 'uuid', 'user_id', 'owner_id', 'account_id', '_id', 'userId', 'ownerId', 'accountId', 'itemId', 'entryId', 'recordId', 'resourceId', 'createdBy', 'authorId']); function collectIds(value, out = new Set(), depth = 0) { if (depth > 4 || out.size > 200 || value == null) return out; @@ -50,7 +50,7 @@ function collectIds(value, out = new Set(), depth = 0) { } if (typeof value === 'object') { for (const [k, v] of Object.entries(value)) { - if (ID_FIELDS.has(k.toLowerCase()) && (typeof v === 'string' || typeof v === 'number')) { + if ((ID_FIELDS.has(k) || ID_FIELDS.has(k.toLowerCase())) && (typeof v === 'string' || typeof v === 'number')) { out.add(String(v)); } else if (v && typeof v === 'object') { collectIds(v, out, depth + 1); diff --git a/src/agent/oracles/crossLayer.js b/src/agent/oracles/crossLayer.js index 98199ec..692a1d3 100644 --- a/src/agent/oracles/crossLayer.js +++ b/src/agent/oracles/crossLayer.js @@ -139,6 +139,8 @@ function resolveVerify(capture) { // fetchOptions is forwarded to client.fetch on every attempt — carries auth headers // captured from the original mutation (e.g. Supabase apikey + Authorization). // Returns { satisfied: boolean, last: {status, body} | null }. +// Default 1.5s window (3 x 500ms) covers single-region backends. +// For geo-replicated or multi-region deployments (e.g. Supabase Pro cross-AZ), increase pollAttempts to 6-8 in config.yaml. async function pollUntil(client, url, predicate, { maxAttempts, delayMs, fetchOptions = {} }) { let last = null; for (let i = 0; i < maxAttempts; i++) { diff --git a/tests/unit/authzReplay.test.js b/tests/unit/authzReplay.test.js index 1309db5..a054629 100644 --- a/tests/unit/authzReplay.test.js +++ b/tests/unit/authzReplay.test.js @@ -232,6 +232,39 @@ describe('checkAuthzReplay — bookkeeping', () => { }); }); +// --------------------------------------------------------------------------- +// collectIds — camelCase and extended field coverage +// --------------------------------------------------------------------------- + +describe('collectIds — camelCase and extended field coverage', () => { + // collectIds is not exported; test it indirectly through checkAuthzReplay: + // give the authenticated read a body with one of the new field names as the + // owned id, then replay it back — AUTHZ_UNCERTAIN fires only if collectIds + // recognised the field name. + const EXTENDED_FIELDS = [ + ['_id', '_id'], + ['userId', 'userId'], + ['ownerId', 'ownerId'], + ['accountId', 'accountId'], + ['itemId', 'itemId'], + ['entryId', 'entryId'], + ['recordId', 'recordId'], + ['resourceId', 'resourceId'], + ['createdBy', 'createdBy'], + ['authorId', 'authorId'], + ]; + + for (const [field] of EXTENDED_FIELDS) { + it(`recognises ${field} as an owned-id field`, async () => { + const idValue = 'test-id-123'; + const read = ownedRead({ responseBody: [{ [field]: idValue }] }); + const replay = makeReplay([{ status: 200, body: [{ [field]: idValue }] }]); + const result = await checkAuthzReplay({ reads: [read], replay, ...DEFAULTS }); + expect(result.signal).toBe('AUTHZ_UNCERTAIN'); + }); + } +}); + // --------------------------------------------------------------------------- // Sentinel-grounded upgrade: CROSS_ACCOUNT_LEAK // --------------------------------------------------------------------------- diff --git a/tests/unit/expectations.test.js b/tests/unit/expectations.test.js index 6e0091f..e8a0f9e 100644 --- a/tests/unit/expectations.test.js +++ b/tests/unit/expectations.test.js @@ -53,6 +53,12 @@ describe('expectations.scoreState — bug detection (hard signals)', () => { }); }); +describe('HARD_SIGNALS score ordering invariants', () => { + it('CONSOLE_ERROR scores below STATE_WRONG_VALUE (data integrity > console noise)', () => { + expect(HARD_SIGNALS.CONSOLE_ERROR.score).toBeLessThan(HARD_SIGNALS.STATE_WRONG_VALUE.score); + }); +}); + describe('expectations.scoreState — two-tier authz verdicts (adversarial A1/A3)', () => { it('CROSS_ACCOUNT_LEAK is flag-for-review: even sentinel-grounded leaks need human confirmation', () => { const out = scoreState({ observed: tree(), hardSignals: ['CROSS_ACCOUNT_LEAK'] });