@clear-capabilities/agentic-security-scanner 0.195.28 → 0.195.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/agentic-security.js +13 -4
  3. package/dist/1340.index.js +11 -6
  4. package/dist/3500.index.js +943 -0
  5. package/dist/5616.index.js +146 -19
  6. package/dist/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
  7. package/dist/9560.index.js +29 -5
  8. package/dist/agentic-security.mjs +2 -2
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +7 -6
  11. package/src/egress/policy.js +79 -1
  12. package/src/engine.js +1 -1
  13. package/src/language/CLAUDE.md +1 -1
  14. package/src/language/haskell-guards.js +26 -5
  15. package/src/lineage/CLAUDE.md +3 -1
  16. package/src/lineage/deployment/adapter-cloud.js +5 -2
  17. package/src/lineage/deployment/attack-paths.js +107 -30
  18. package/src/lineage/deployment/boundaries-run.js +2 -2
  19. package/src/lineage/deployment/iam-eval.js +105 -0
  20. package/src/lineage/deployment/projection.js +10 -4
  21. package/src/lineage/deployment/proof-binding.js +79 -0
  22. package/src/llm-validator/bounded-context.js +115 -8
  23. package/src/llm-validator/index.js +1 -1
  24. package/src/mcp/CLAUDE.md +6 -0
  25. package/src/mcp/tools.js +27 -3
  26. package/src/pipeline/evidence-record.js +1 -1
  27. package/src/posture/CLAUDE.md +2 -0
  28. package/src/posture/assurance/candidate-qualification.js +155 -0
  29. package/src/posture/assurance/release-package-cli.js +44 -2
  30. package/src/posture/auditor-walkthrough.js +2 -1
  31. package/src/posture/dnr/detection-ir.js +2 -2
  32. package/src/posture/dnr/hunt.js +22 -6
  33. package/src/posture/dnr/log-reader.js +47 -3
  34. package/src/posture/dnr/matchers.js +43 -3
  35. package/src/posture/dnr/outputs.js +16 -2
  36. package/src/posture/dnr/payload-classes.js +12 -2
  37. package/src/posture/dnr/validate.js +17 -4
  38. package/src/posture/evaluation/report.js +2 -1
  39. package/src/posture/evaluation/score.js +24 -7
  40. package/src/posture/logic-claims.js +15 -14
  41. package/src/posture/sibling-queue.js +69 -0
  42. package/src/posture/triage-memory.js +106 -26
  43. package/src/posture/verification-separation.js +55 -7
  44. package/src/report/index.js +4 -0
  45. package/src/runScan.js +5 -0
  46. package/src/scope/flags.js +1 -0
  47. package/src/scope/inventory.js +17 -1
  48. package/src/scope/release-scope.js +80 -0
@@ -11,7 +11,7 @@
11
11
  // and in a sentence a person reads.
12
12
  import path from 'node:path';
13
13
  import { readLogs } from './log-reader.js';
14
- import { evaluateEvent, requiredFields } from './matchers.js';
14
+ import { evaluateEvent, requiredFields, pathMatches } from './matchers.js';
15
15
  import { redactValue, fingerprint } from './redact.js';
16
16
 
17
17
  export const HUNT_VERDICTS = Object.freeze(['OBSERVED', 'NOT_OBSERVED', 'INCONCLUSIVE', 'TELEMETRY_UNAVAILABLE']);
@@ -25,7 +25,10 @@ const STATEMENTS = {
25
25
  TELEMETRY_UNAVAILABLE: () => 'There were no readable request records to search, so nothing can be said about past exploitation.',
26
26
  };
27
27
 
28
- const FIELD_COUNT_KEY = { method: 'method', path: 'path', query: 'query', body: 'body', headers: 'headers', status: 'status', auth: 'auth' };
28
+ const FIELD_COUNT_KEY = { method: 'method', path: 'path', query: 'query', body: 'body', headers: 'headers', status: 'status', auth: 'auth', authOutcome: 'authOutcome', tenant: 'tenant' };
29
+
30
+ // A matched line shows the RESPONSE indicated success; delivery of the data is an effect that needs a separately bound observer.
31
+ const EFFECT = Object.freeze({ corroborated: false, basis: 'log-signal', reason: 'a matched log line is a detection signal; delivery or mutation needs a separately bound observer (runtime corroboration), which a log hunt does not have' });
29
32
 
30
33
  export async function huntLogs({ ir, logsPath, since = null, until = null, signal, limits } = {}) {
31
34
  const base = { schema: 'agentic-security/hunt-result', version: 1, findingId: ir.findingId, detectionId: ir.id, detectionDigest: ir.digest, notExploitedClaim: false, egress: 'none' };
@@ -38,6 +41,10 @@ export async function huntLogs({ ir, logsPath, since = null, until = null, signa
38
41
  let matchCount = 0; let firstSeen = null; let lastSeen = null;
39
42
  let undecidable = 0; const undecidableFields = new Set();
40
43
  let endpointEvents = 0; let outOfWindow = 0; let undated = 0;
44
+ // Threat-model FR-07.3: the target counter uses the SAME method and route template as the matcher (a different route with the same
45
+ // method used to count), and separates the relevant events from those that could be judged and those missing a required field.
46
+ const target = { relevant: 0, adjudicable: 0, missingRequired: 0, deniedAttempts: 0, authorizedDelegations: 0 };
47
+ const atEndpoint = (ev) => !!ir.endpoint && typeof ev.method === 'string' && ev.method.toUpperCase() === ir.endpoint.method && pathMatches(ir.endpoint.path, ev.path);
41
48
 
42
49
  const stats = await readLogs(logsPath, {
43
50
  signal, limits,
@@ -48,19 +55,27 @@ export async function huntLogs({ ir, logsPath, since = null, until = null, signa
48
55
  if ((sinceMs != null && t < sinceMs) || (untilMs != null && t > untilMs)) { outOfWindow++; return; }
49
56
  }
50
57
  const r = evaluateEvent(ir, ev);
51
- if (r.undecidable) { undecidable++; r.missing.forEach((m) => undecidableFields.add(m)); return; }
52
- if (ir.endpoint && ev.method === ir.endpoint.method) endpointEvents++;
58
+ const here = atEndpoint(ev);
59
+ if (here) target.relevant++;
60
+ if (r.undecidable) { undecidable++; if (here) target.missingRequired++; r.missing.forEach((m) => undecidableFields.add(m)); return; }
61
+ if (here) { endpointEvents++; target.adjudicable++; }
62
+ if (r.attempt === 'denied') target.deniedAttempts++;
63
+ if (r.attempt === 'authorized-delegation') target.authorizedDelegations++;
53
64
  if (!r.match) return;
54
65
  matchCount++;
55
66
  if (ev.time) { if (!firstSeen || ev.time < firstSeen) firstSeen = ev.time; if (!lastSeen || ev.time > lastSeen) lastSeen = ev.time; }
56
- if (sampleList.length < MAX_SAMPLES) sampleList.push({ time: ev.time, method: ev.method, path: redactValue(ev.path, 160), matchedOn: r.matchedOn, valueFingerprint: fingerprint(r.value), valuePreview: redactValue(r.value, 80), status: ev.status });
67
+ if (sampleList.length < MAX_SAMPLES) sampleList.push({ time: ev.time, method: ev.method, path: redactValue(ev.path, 160), matchedOn: r.matchedOn, valueFingerprint: fingerprint(r.value), valuePreview: redactValue(r.value, 80), status: ev.status, ...(r.signal ? { signal: r.signal } : {}) });
57
68
  },
58
69
  });
59
70
 
60
71
  const logs = { source: path.basename(String(logsPath)), files: stats.files.length, filesSkipped: stats.filesSkipped, lines: stats.lines, parsed: stats.parsed, failed: stats.failed, truncated: stats.truncated };
61
72
  const window = { start: stats.windowStart, end: stats.windowEnd, datedEvents: stats.eventsWithTime };
62
73
  const caveats = [];
63
- const result = (verdict) => ({ ...base, verdict, logs, window, coverage: { requestedWindow: { since, until }, outOfWindow, undated, endpointEvents, requiredFields: requiredFields(ir), fieldAvailability: Object.fromEntries(Object.entries(FIELD_COUNT_KEY).map(([k, key]) => [k, stats.parsed ? Number((stats.fieldCounts[key] / stats.parsed).toFixed(3)) : 0])) }, matches: { count: matchCount, firstSeen, lastSeen, samples: sampleList }, caveats, statement: STATEMENTS[verdict](matchCount) });
74
+ // A requested window the logs do not reach: the time before the first event or after the last cannot be searched, so it is a gap.
75
+ const windowGaps = [];
76
+ if (sinceMs != null && stats.windowStart && Date.parse(stats.windowStart) > sinceMs) windowGaps.push({ edge: 'start', from: new Date(sinceMs).toISOString(), to: stats.windowStart });
77
+ if (untilMs != null && stats.windowEnd && Date.parse(stats.windowEnd) < untilMs) windowGaps.push({ edge: 'end', from: stats.windowEnd, to: new Date(untilMs).toISOString() });
78
+ const result = (verdict) => ({ ...base, verdict, logs, window, effect: EFFECT, coverage: { requestedWindow: { since, until }, outOfWindow, undated, endpointEvents, target: { ...target, samples: sampleList.length, samplesCapped: matchCount > sampleList.length }, windowGaps, requiredFields: requiredFields(ir), fieldAvailability: Object.fromEntries(Object.entries(FIELD_COUNT_KEY).map(([k, key]) => [k, stats.parsed ? Number((stats.fieldCounts[key] / stats.parsed).toFixed(3)) : 0])) }, matches: { count: matchCount, firstSeen, lastSeen, samples: sampleList }, caveats, statement: STATEMENTS[verdict](matchCount) });
64
79
 
65
80
  if (stats.unreadable || stats.parsed === 0) {
66
81
  caveats.push(stats.unreadable ? 'the log path could not be read' : stats.lines === 0 ? 'the logs contain no lines' : 'no line was recognised as a request record (supported: common/combined access logs, JSON lines)');
@@ -78,6 +93,7 @@ export async function huntLogs({ ir, logsPath, since = null, until = null, signa
78
93
  if (!stats.eventsWithTime) caveats.push('no event carries a timestamp, so the searched time window is unknown');
79
94
  if (stats.filesSkipped.some((f) => f.reason !== 'not-a-log-extension')) caveats.push(`${stats.filesSkipped.filter((f) => f.reason !== 'not-a-log-extension').length} file(s) were skipped (${[...new Set(stats.filesSkipped.map((f) => f.reason))].join(', ')})`);
80
95
  if (undated > 0) caveats.push(`${undated} event(s) had no timestamp and were excluded by the requested window`);
96
+ for (const g of windowGaps) caveats.push(`the logs do not reach the ${g.edge} of the requested window (${g.from} to ${g.to} is not covered), so nothing is known about that time`);
81
97
  if (!caveats.length && ir.endpoint && endpointEvents === 0) caveats.push('no request to the endpoint appears in the logs at all (informational: nothing to exploit was seen either)');
82
98
  const blocking = caveats.some((c) => !c.startsWith('no request to the endpoint'));
83
99
  return result(blocking ? 'INCONCLUSIVE' : 'NOT_OBSERVED');
@@ -100,19 +100,61 @@ export function parseLine(line) {
100
100
  }
101
101
  const authHeader = headers.authorization ?? pick(f, ['authorization']);
102
102
  const authFlag = pick(f, ['authenticated', 'is_authenticated', 'auth.present']);
103
+ // Legacy: one boolean where a header wins over everything. Kept for existing callers; detections read the two facts below.
103
104
  const authPresent = authHeader != null ? true : typeof authFlag === 'boolean' ? authFlag : (sawHeaders ? false : null);
104
105
  const statusRaw = pick(f, ['status', 'status_code', 'response_code', 'http.response.status_code', 'sc-status', 'response.status', 'res.statuscode']);
105
106
  const status = Number.isInteger(Number(statusRaw)) && statusRaw !== undefined ? Number(statusRaw) : null;
106
- return { time: parseTime(pick(f, ['@timestamp', 'timestamp', 'time', 'ts', 'start_time', 'datetime', 'date'])), method: typeof m === 'string' ? m.toUpperCase() : null, path: tgt.path ?? null, query: query ?? null, bodyParams, headers: sawHeaders ? headers : null, status, authPresent };
107
+ return { time: parseTime(pick(f, ['@timestamp', 'timestamp', 'time', 'ts', 'start_time', 'datetime', 'date'])), method: typeof m === 'string' ? m.toUpperCase() : null, path: tgt.path ?? null, query: query ?? null, bodyParams, headers: sawHeaders ? headers : null, status, authPresent, ...authFacts(f, authHeader, sawHeaders) };
107
108
  }
108
109
  const c = CLF.exec(text);
109
110
  if (c) {
110
111
  const { path: p, query } = fromRequestTarget(c[5]);
111
- return { time: parseTime(c[3]), method: c[4], path: p, query, bodyParams: null, headers: null, status: c[6] === '-' ? null : Number(c[6]), authPresent: null };
112
+ return { time: parseTime(c[3]), method: c[4], path: p, query, bodyParams: null, headers: null, status: c[6] === '-' ? null : Number(c[6]), authPresent: null, ...UNKNOWN_AUTH };
112
113
  }
113
114
  return null;
114
115
  }
115
116
 
117
+ // ── authentication and authorization facts (PRD threat-model FR-07.1, FR-07.5) ──────────────────────────────────────────────────────
118
+ // Credential PRESENCE and the verified authentication OUTCOME are separate facts. A header only says a credential was sent; an
119
+ // authoritative `authenticated: false` (or an auth result of "rejected") says it was refused, and survives a redacted header. Missing or
120
+ // conflicting outcome fields leave the outcome `unknown`. Identity and policy fields (actor tenant, object tenant, a cross-tenant grant,
121
+ // the authorization decision) are read only from these mapped field names, never from request headers, query or body, which the client
122
+ // controls.
123
+ const OUTCOME_FIELDS = ['authenticated', 'is_authenticated', 'auth.authenticated', 'auth.outcome', 'auth.result', 'authn.outcome', 'authn.result'];
124
+ const ACTOR_TENANT_FIELDS = ['identity.tenant', 'actor.tenant', 'auth.tenant', 'principal.tenant'];
125
+ const OBJECT_TENANT_FIELDS = ['object.tenant', 'resource.tenant', 'record.tenant'];
126
+ const GRANT_FIELDS = ['authz.cross_tenant_grant', 'authz.delegated', 'authz.crossTenantGrant'];
127
+ const DECISION_FIELDS = ['authz.decision', 'authz.result'];
128
+ const UNKNOWN_AUTH = Object.freeze({ credentialPresent: null, authOutcome: 'unknown', actorTenant: null, objectTenant: null, crossTenantGrant: null, authzDecision: null });
129
+
130
+ function outcomeOf(v) {
131
+ if (v === true) return 'authenticated';
132
+ if (v === false) return 'unauthenticated';
133
+ const s = String(v ?? '').trim().toLowerCase();
134
+ if (/^(?:true|success|succeeded|ok|authenticated|allow|allowed|valid)$/.test(s)) return 'authenticated';
135
+ if (/^(?:false|failure|failed|rejected|denied|invalid|unauthenticated|anonymous|none|expired)$/.test(s)) return 'unauthenticated';
136
+ return null;
137
+ }
138
+ const ident = (v) => (v === undefined || v === null || v === '' ? null : String(v).slice(0, 128));
139
+
140
+ function authFacts(f, authHeader, sawHeaders) {
141
+ const outcomes = new Set(OUTCOME_FIELDS.map((k) => f[k]).filter((v) => v !== undefined).map(outcomeOf).filter(Boolean));
142
+ const authOutcome = outcomes.size === 1 ? [...outcomes][0] : 'unknown';
143
+ const presentFlag = f['auth.present'];
144
+ const credentialPresent = authHeader != null ? true : typeof presentFlag === 'boolean' ? presentFlag : (sawHeaders ? false : null);
145
+ const grant = GRANT_FIELDS.map((k) => f[k]).find((v) => v !== undefined);
146
+ const decision = DECISION_FIELDS.map((k) => f[k]).find((v) => v !== undefined);
147
+ const d = String(decision ?? '').toLowerCase();
148
+ return {
149
+ credentialPresent,
150
+ authOutcome: outcomes.size > 1 ? 'conflict' : authOutcome,
151
+ actorTenant: ident(pick(f, ACTOR_TENANT_FIELDS)),
152
+ objectTenant: ident(pick(f, OBJECT_TENANT_FIELDS)),
153
+ crossTenantGrant: grant === true || grant === 'true' ? true : grant === false || grant === 'false' ? false : null,
154
+ authzDecision: /^(?:allow|allowed|permit|granted)$/.test(d) ? 'allow' : /^(?:deny|denied|forbidden|refused)$/.test(d) ? 'deny' : null,
155
+ };
156
+ }
157
+
116
158
  function collectFiles(root, limits, skipped) {
117
159
  const out = [];
118
160
  const walk = (p, depth) => {
@@ -142,7 +184,7 @@ function collectFiles(root, limits, skipped) {
142
184
  */
143
185
  export async function readLogs(target, { onEvent, signal, limits: over = {} } = {}) {
144
186
  const limits = { ...LIMITS, ...over };
145
- const stats = { files: [], filesSkipped: [], lines: 0, parsed: 0, failed: 0, oversizeLines: 0, windowStart: null, windowEnd: null, eventsWithTime: 0, fieldCounts: { method: 0, path: 0, query: 0, body: 0, headers: 0, status: 0, auth: 0 }, truncated: [], budgetExhausted: false, aborted: false, unreadable: false };
187
+ const stats = { files: [], filesSkipped: [], lines: 0, parsed: 0, failed: 0, oversizeLines: 0, windowStart: null, windowEnd: null, eventsWithTime: 0, fieldCounts: { method: 0, path: 0, query: 0, body: 0, headers: 0, status: 0, auth: 0, authOutcome: 0, tenant: 0 }, truncated: [], budgetExhausted: false, aborted: false, unreadable: false };
146
188
  let st;
147
189
  try { st = fs.lstatSync(target); } catch { stats.unreadable = true; return stats; }
148
190
  if (st.isSymbolicLink()) { stats.unreadable = true; stats.filesSkipped.push({ file: path.basename(target), reason: 'symlink' }); return stats; }
@@ -178,6 +220,8 @@ export async function readLogs(target, { onEvent, signal, limits: over = {} } =
178
220
  if (ev.headers != null) stats.fieldCounts.headers++;
179
221
  if (ev.status != null) stats.fieldCounts.status++;
180
222
  if (ev.authPresent != null) stats.fieldCounts.auth++;
223
+ if (ev.authOutcome === 'authenticated' || ev.authOutcome === 'unauthenticated') stats.fieldCounts.authOutcome++;
224
+ if (ev.actorTenant != null && ev.objectTenant != null) stats.fieldCounts.tenant++;
181
225
  if (ev.time) { stats.eventsWithTime++; if (!stats.windowStart || ev.time < stats.windowStart) stats.windowStart = ev.time; if (!stats.windowEnd || ev.time > stats.windowEnd) stats.windowEnd = ev.time; }
182
226
  onEvent?.(ev);
183
227
  }
@@ -3,7 +3,13 @@
3
3
  // searches the logs. An event is the NORMALIZED request record produced by log-reader.js or by the validator.
4
4
  //
5
5
  // event = { time, method, path, query: {name: [values]}, bodyParams: {name: [values]} | null, headers: {name: value} | null,
6
- // status, authPresent: boolean | null }
6
+ // status, authPresent: boolean | null,
7
+ // credentialPresent: boolean | null, authOutcome: 'authenticated'|'unauthenticated'|'unknown'|'conflict',
8
+ // actorTenant, objectTenant, crossTenantGrant: boolean | null, authzDecision: 'allow'|'deny'|null }
9
+ //
10
+ // Authentication (threat-model FR-07.1): the outcome decides, not the header. A request whose credential was REJECTED is
11
+ // unauthenticated even though a header was sent; a header with no recorded outcome is undecidable, not "authenticated". An event
12
+ // that carries only the legacy `authPresent` (an older caller) is judged as before.
7
13
  //
8
14
  // A missing field is `undefined`/`null`, never an empty string: "the log did not record this" and "the value was empty" are
9
15
  // different facts, and only the first makes an event undecidable.
@@ -20,11 +26,25 @@ export function pathMatches(template, actual) {
20
26
  return t.every((seg, i) => (/^(?::[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/.test(seg) ? a[i].length > 0 : seg === a[i]));
21
27
  }
22
28
 
29
+ /**
30
+ * The authentication fact a detection may rely on: 'authenticated', 'unauthenticated' or 'unknown'. An explicit outcome wins; with
31
+ * no outcome, a request that carried NO credential is unauthenticated, and one that carried a credential is unknown (it may have
32
+ * been accepted or rejected). An event with only the legacy `authPresent` keeps its legacy reading.
33
+ */
34
+ export function authenticationOf(event) {
35
+ const legacyOnly = event.authOutcome === undefined && event.credentialPresent === undefined;
36
+ if (legacyOnly) return event.authPresent === false ? 'unauthenticated' : event.authPresent === true ? 'authenticated' : 'unknown';
37
+ if (event.authOutcome === 'authenticated' || event.authOutcome === 'unauthenticated') return event.authOutcome;
38
+ if (event.authOutcome === 'conflict') return 'unknown';
39
+ return event.credentialPresent === false ? 'unauthenticated' : 'unknown';
40
+ }
41
+
23
42
  /** Fields an event must have for a detection to be able to say "no" about it. */
24
43
  export function requiredFields(ir) {
25
44
  const need = new Set();
26
45
  if (ir.endpoint) { need.add('method'); need.add('path'); }
27
46
  if (ir.payloadClass === 'auth-bypass') { need.add('status'); need.add('auth'); return [...need]; }
47
+ if (ir.payloadClass === 'object-authz') { need.add('status'); need.add('auth'); need.add('tenant'); return [...need]; }
28
48
  const loc = ir.parameter?.location;
29
49
  if (loc === 'body') need.add('body');
30
50
  else if (loc === 'header' || loc === 'cookie') need.add('headers');
@@ -73,10 +93,30 @@ export function evaluateEvent(ir, event) {
73
93
  } else if (typeof event.path !== 'string') { res.missing.push('path'); res.undecidable = true; return res; }
74
94
 
75
95
  if (ir.payloadClass === 'auth-bypass') {
96
+ const auth = authenticationOf(event);
97
+ if (event.status == null) res.missing.push('status');
98
+ if (auth === 'unknown') res.missing.push('auth');
99
+ if (res.missing.length) { res.undecidable = true; return res; }
100
+ // a matched line is a SIGNAL that the response indicated success; delivery needs a separately bound observer
101
+ if (event.status >= 200 && event.status < 300 && auth === 'unauthenticated') { res.match = true; res.matchedOn = 'unauthenticated-success'; res.value = `${event.status}`; res.signal = 'response-indication'; }
102
+ return res;
103
+ }
104
+
105
+ if (ir.payloadClass === 'object-authz') {
106
+ // FR-07.2: an AUTHENTICATED actor whose tenant differs from the object's, with no explicit cross-tenant grant, and a success
107
+ // response. A denial (401/403 or a recorded deny) is an attempt, never a delivery. An unauthenticated success is the auth-bypass
108
+ // rule's case, not this one.
109
+ const auth = authenticationOf(event);
76
110
  if (event.status == null) res.missing.push('status');
77
- if (event.authPresent == null) res.missing.push('auth');
111
+ if (auth === 'unknown') res.missing.push('auth');
112
+ // an unauthenticated request has no actor tenant to compare: it is decided (not this rule's case), not undecidable
113
+ if (auth !== 'unauthenticated' && (event.actorTenant == null || event.objectTenant == null)) res.missing.push('tenant');
78
114
  if (res.missing.length) { res.undecidable = true; return res; }
79
- if (event.status >= 200 && event.status < 300 && event.authPresent === false) { res.match = true; res.matchedOn = 'unauthenticated-success'; res.value = `${event.status}`; }
115
+ if (auth !== 'authenticated' || event.actorTenant === event.objectTenant) return res;
116
+ const denied = event.status === 401 || event.status === 403 || event.authzDecision === 'deny';
117
+ if (denied) { res.attempt = 'denied'; return res; }
118
+ if (event.crossTenantGrant === true) { res.attempt = 'authorized-delegation'; return res; }
119
+ if (event.status >= 200 && event.status < 300) { res.match = true; res.matchedOn = 'cross-tenant-success'; res.value = `${event.status}`; res.signal = 'response-indication'; }
80
120
  return res;
81
121
  }
82
122
 
@@ -32,6 +32,7 @@ const unavailable = (reason) => ({ applicable: false, reason });
32
32
  const fpNotes = (ir) => [
33
33
  'A legitimate client that sends this payload shape to the same endpoint (a security scanner, a penetration test).',
34
34
  ...(ir.payloadClass === 'auth-bypass' ? ['Public endpoints that are intentionally reachable without a credential.'] : []),
35
+ ...(ir.payloadClass === 'object-authz' ? ['A cross-tenant read that an operator or support role is allowed to make but whose grant is not recorded in the event.'] : []),
35
36
  ];
36
37
 
37
38
  /** Sigma rule as YAML text. JSON string syntax is used for every scalar; it is valid YAML and cannot be broken out of. */
@@ -72,7 +73,8 @@ function http(ir) {
72
73
  schema: 'agentic-security/http-detection', version: 1, id: ir.id, findingId: ir.findingId, level: levelFor(ir), mode: 'detect',
73
74
  request: { method: ir.endpoint.method, pathTemplate: ir.endpoint.path },
74
75
  parameter: ir.parameter,
75
- payload: ir.payloadClass === 'auth-bypass' ? { class: 'auth-bypass', behavior: 'status 2xx with no credential' }
76
+ payload: ir.payloadClass === 'auth-bypass' ? { class: 'auth-bypass', behavior: 'status 2xx for a request that is not authenticated (no credential, or a rejected one)' }
77
+ : ir.payloadClass === 'object-authz' ? { class: 'object-authz', behavior: 'status 2xx for an authenticated actor whose tenant differs from the object\'s, with no cross-tenant grant', requiredFields: ['auth.outcome', 'actor.tenant', 'object.tenant', 'authz.cross_tenant_grant'] }
76
78
  : { class: ir.payloadClass, matcher: def.matcher, minLength: def.minLength ?? null, regex: classRegex(ir.payloadClass), flags: 'i' },
77
79
  limitations: ir.limitations,
78
80
  };
@@ -104,6 +106,7 @@ function logQuery(ir) {
104
106
  if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
105
107
  const where = [{ field: 'http.request.method', op: 'eq', value: ir.endpoint.method }, hasTemplate(ir.endpoint.path) ? { field: 'url.path', op: 'regex', value: pathRegex(ir.endpoint.path) } : { field: 'url.path', op: 'eq', value: ir.endpoint.path }];
106
108
  if (ir.payloadClass === 'auth-bypass') where.push({ field: 'http.response.status_code', op: 'range', value: [200, 299] }, { field: 'http.request.headers.authorization', op: 'absent' });
109
+ else if (ir.payloadClass === 'object-authz') where.push({ field: 'http.response.status_code', op: 'range', value: [200, 299] }, { field: 'auth.outcome', op: 'eq', value: 'authenticated' }, { field: 'actor.tenant', op: 'ne-field', value: 'object.tenant' }, { field: 'authz.cross_tenant_grant', op: 'ne', value: true });
107
110
  else where.push({ field: ir.parameter?.location === 'body' ? 'http.request.body.content' : ir.parameter?.location === 'query' ? `url.query.${ir.parameter.name}` : 'url.query', op: 'regex', value: classRegex(ir.payloadClass), flags: 'i' });
108
111
  return { applicable: true, format: 'log-query', content: `${JSON.stringify({ schema: 'agentic-security/structured-log-query', version: 1, id: ir.id, findingId: ir.findingId, where }, null, 2)}\n` };
109
112
  }
@@ -152,8 +155,19 @@ const RENDERERS = { sigma, http, waf, 'log-query': logQuery, yara, spl, kql, clo
152
155
  export function renderDetection(ir, format) {
153
156
  const fn = RENDERERS[format];
154
157
  if (!fn) return { applicable: false, reason: `unknown format: ${format}` };
155
- return { format, ...fn(ir) };
158
+ // Threat-model FR-07.4: a rule is published to a destination only when its field mapping carries what the rule needs. The
159
+ // object-authorization rule needs the actor's tenant, the object's tenant and any cross-tenant grant, which only this tool's own
160
+ // structured formats name; elsewhere it is reported as unavailable rather than rendered as a lossy predicate.
161
+ if (ir.payloadClass === 'object-authz' && !OBJECT_AUTHZ_FORMATS.has(format)) {
162
+ return { format, ...unavailable(`the ${format} destination's default fields carry no actor tenant, object tenant or cross-tenant grant, so the object-authorization rule cannot be expressed; use the http or log-query output with mapped fields`) };
163
+ }
164
+ const out = { format, ...fn(ir) };
165
+ // The rule languages see whether an Authorization header was sent, not whether it was accepted: the rendered rule covers the
166
+ // no-credential case only, and says so.
167
+ if (ir.payloadClass === 'auth-bypass' && out.applicable && format !== 'http') out.coverage = { covers: 'requests that sent no credential', notCovered: 'a credential that was sent and rejected: this destination has no authentication-outcome field' };
168
+ return out;
156
169
  }
170
+ const OBJECT_AUTHZ_FORMATS = new Set(['http', 'log-query']);
157
171
 
158
172
  export function renderAll(ir) {
159
173
  return Object.fromEntries(FORMATS.map((f) => [f, renderDetection(ir, f)]));
@@ -80,13 +80,23 @@ export const PAYLOAD_CLASSES = Object.freeze({
80
80
  effects: { process: 'memory-corruption-possible', network: 'none', db: 'none' },
81
81
  },
82
82
  'auth-bypass': {
83
- cwe: ['CWE-287', 'CWE-306', 'CWE-285', 'CWE-862', 'CWE-863', 'CWE-639', 'CWE-284'],
84
- behavior: 'a protected operation succeeds for a request that carries no credential',
83
+ cwe: ['CWE-287', 'CWE-306', 'CWE-285', 'CWE-862', 'CWE-863', 'CWE-284'],
84
+ behavior: 'a protected operation succeeds for a request that is not authenticated (no credential, or a credential that was rejected)',
85
85
  matcher: 'behavior',
86
86
  indicators: [],
87
87
  exemplars: { positive: [''], benign: [''] },
88
88
  effects: { process: 'none', network: 'none', db: 'none', access: 'protected-operation-without-credential' },
89
89
  },
90
+ // Threat-model FR-07.2: an authenticated actor receives another tenant's object. CWE-639 (authorization through a user-controlled
91
+ // key) moved here from auth-bypass, which could only see "no credential" and so never fired on the wrong-tenant case.
92
+ 'object-authz': {
93
+ cwe: ['CWE-639'],
94
+ behavior: 'an authenticated actor receives an object that belongs to another tenant, with no explicit cross-tenant grant',
95
+ matcher: 'behavior',
96
+ indicators: [],
97
+ exemplars: { positive: [''], benign: [''] },
98
+ effects: { process: 'none', network: 'none', db: 'none', access: 'object-of-another-tenant-delivered' },
99
+ },
90
100
  });
91
101
 
92
102
  export const PAYLOAD_CLASS_NAMES = Object.freeze(Object.keys(PAYLOAD_CLASSES));
@@ -10,10 +10,10 @@ import { classRegex } from './outputs.js';
10
10
 
11
11
  const PROBE = 'probe';
12
12
 
13
- function eventFor(ir, { method, path, values, extraParam = null, status = 200, authPresent = null }) {
13
+ function eventFor(ir, { method, path, values, extraParam = null, status = 200, authPresent = null, facts = null }) {
14
14
  const p = ir.parameter;
15
15
  const bag = {};
16
- const ev = { time: '2024-01-01T00:00:00.000Z', method, path, query: {}, bodyParams: {}, headers: {}, status, authPresent };
16
+ const ev = { time: '2024-01-01T00:00:00.000Z', method, path, query: {}, bodyParams: {}, headers: {}, status, authPresent, ...(facts || {}) };
17
17
  const put = (loc, name, vals) => {
18
18
  if (loc === 'query') ev.query[name] = vals;
19
19
  else if (loc === 'body') ev.bodyParams[name] = vals;
@@ -39,14 +39,27 @@ export function validateDetection(ir) {
39
39
  if (!ir.endpoint) return { schema: 'agentic-security/detection-validation', version: 1, detectionId: ir.id, status: 'NOT_VALIDATED', reason: 'no endpoint is known, so there is no request to synthesize', cases, render, counts: { positives: 0, negatives: 0, passed: 0, failed: 0 } };
40
40
  const concretePath = ir.endpoint.path.replace(/(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})/g, '42');
41
41
  const { method } = ir.endpoint;
42
- const add = (kind, name, ev) => { const r = evaluateEvent(ir, ev); cases.push({ kind, name, expected: kind === 'positive', fired: r.match, undecidable: r.undecidable, ok: kind === 'positive' ? r.match : !r.match }); };
42
+ // `undecidable` cases (threat-model FR-07.4): the rule must refuse to answer, neither fire nor clear, when a required fact is missing
43
+ const add = (kind, name, ev) => { const r = evaluateEvent(ir, ev); cases.push({ kind, name, expected: kind === 'positive' ? true : kind === 'undecidable' ? 'undecidable' : false, fired: r.match, undecidable: r.undecidable, ok: kind === 'positive' ? r.match : kind === 'undecidable' ? r.undecidable && !r.match : !r.match }); };
44
+ const auth = (credentialPresent, authOutcome, extra = {}) => ({ credentialPresent, authOutcome, actorTenant: null, objectTenant: null, crossTenantGrant: null, authzDecision: null, ...extra });
43
45
 
44
46
  if (ir.payloadClass === 'auth-bypass') {
45
47
  add('positive', 'success with no credential', eventFor(ir, { method, path: concretePath, status: 200, authPresent: false }));
48
+ add('positive', 'success after a credential was sent and rejected', eventFor(ir, { method, path: concretePath, status: 200, facts: auth(true, 'unauthenticated') }));
46
49
  add('negative', 'success with a credential', eventFor(ir, { method, path: concretePath, status: 200, authPresent: true }));
50
+ add('negative', 'success with an accepted credential', eventFor(ir, { method, path: concretePath, status: 200, facts: auth(true, 'authenticated') }));
47
51
  add('negative', 'refusal without a credential (401)', eventFor(ir, { method, path: concretePath, status: 401, authPresent: false }));
48
52
  add('negative', 'refusal without a credential (403)', eventFor(ir, { method, path: concretePath, status: 403, authPresent: false }));
49
53
  add('negative', 'same request on another path', eventFor(ir, { method, path: '/other/unrelated', status: 200, authPresent: false }));
54
+ add('undecidable', 'a credential header with no recorded outcome', eventFor(ir, { method, path: concretePath, status: 200, facts: auth(true, 'unknown') }));
55
+ } else if (ir.payloadClass === 'object-authz') {
56
+ const t = (actorTenant, objectTenant, extra = {}) => auth(true, 'authenticated', { actorTenant, objectTenant, ...extra });
57
+ add('positive', 'an authenticated actor receives another tenant\'s object', eventFor(ir, { method, path: concretePath, status: 200, facts: t('tenant-a', 'tenant-b') }));
58
+ add('negative', 'the rightful tenant reads its own object', eventFor(ir, { method, path: concretePath, status: 200, facts: t('tenant-b', 'tenant-b') }));
59
+ add('negative', 'a cross-tenant read under an explicit grant', eventFor(ir, { method, path: concretePath, status: 200, facts: t('tenant-a', 'tenant-b', { crossTenantGrant: true }) }));
60
+ add('negative', 'a cross-tenant attempt that was denied (403)', eventFor(ir, { method, path: concretePath, status: 403, facts: t('tenant-a', 'tenant-b', { authzDecision: 'deny' }) }));
61
+ add('negative', 'the same cross-tenant read on another path', eventFor(ir, { method, path: '/other/unrelated', status: 200, facts: t('tenant-a', 'tenant-b') }));
62
+ add('undecidable', 'no object tenant recorded', eventFor(ir, { method, path: concretePath, status: 200, facts: t('tenant-a', null) }));
50
63
  } else {
51
64
  for (const [i, v] of def.exemplars.positive.entries()) add('positive', `exploit payload #${i + 1}`, eventFor(ir, { method, path: concretePath, values: [v] }));
52
65
  for (const [i, v] of def.exemplars.benign.entries()) add('negative', `benign value #${i + 1} on the same endpoint`, eventFor(ir, { method, path: concretePath, values: [v] }));
@@ -57,7 +70,7 @@ export function validateDetection(ir) {
57
70
  add('negative', 'same endpoint, no parameter at all', eventFor(ir, { method, path: concretePath }));
58
71
  if (ir.parameter && ir.parameter.location !== 'path') add('negative', 'exploit payload in an unrelated parameter', eventFor(ir, { method, path: concretePath, values: exploit, extraParam: 'unrelated_field' }));
59
72
  }
60
- const positives = cases.filter((c) => c.kind === 'positive'); const negatives = cases.filter((c) => c.kind === 'negative');
73
+ const positives = cases.filter((c) => c.kind === 'positive'); const negatives = cases.filter((c) => c.kind !== 'positive');
61
74
  const failed = cases.filter((c) => !c.ok);
62
75
  const status = failed.length === 0 && positives.length && negatives.length && render.regexCompiles ? 'VALIDATED' : 'FAILED';
63
76
  return { schema: 'agentic-security/detection-validation', version: 1, detectionId: ir.id, status, cases, render, counts: { positives: positives.length, negatives: negatives.length, passed: cases.length - failed.length, failed: failed.length }, failures: failed.map((c) => c.name) };
@@ -107,8 +107,9 @@ export function buildAccuracyReport({ score, protocol, population, run = null, r
107
107
  unsupported: { outcomesUnavailable: statusCounts.unavailable ?? 0, outcomesQuarantined: statusCounts.quarantined ?? 0 },
108
108
  unscored: {
109
109
  unlabeledTargets: score.unscored.unlabeledTargets.length, nonAdjudicatedLabels: score.unscored.nonAdjudicatedLabels,
110
- unlabeledFindings: score.unscored.unlabeledFindings.count, positivesOutsideRun: score.unscored.positivesOutsideRun,
110
+ unlabeledFindings: score.unscored.unlabeledFindings.count, positivesOutsideRun: score.unscored.positivesOutsideRun, negativesOutsideRun: score.unscored.negativesOutsideRun ?? 0,
111
111
  },
112
+ reconciliation: score.reconciliation ? { enrolled: score.reconciliation.enrolled, reconciled: score.reconciliation.reconciled, byStatus: score.reconciliation.byStatus } : null,
112
113
  unknownLabels: { count: score.unscored.nonAdjudicatedLabels, note: 'candidate, disputed or rejected labels are never scored' },
113
114
  trueNegativeFailures: { count: score.negativeFailures.length, note: 'a negative whose scan did not complete is neither a true negative nor a false positive', items: score.negativeFailures },
114
115
  reviewQueue: { count: score.reviewQueue.length, note: 'findings no adjudicated label accounts for; they may be genuine and are not counted as false positives' },
@@ -17,6 +17,10 @@
17
17
  // (`clusterAlerts`) score once and are reported in `alertsCollapsed`; the operational
18
18
  // alert burden is report.js's job and is never hidden by this. The score also carries
19
19
  // `perTarget` tallies, which is what the grouped bootstrap resamples. Pure: no fs, no clock.
20
+ //
21
+ // Every enrolled label is reconciled to exactly one status (threat-model PRD FR-09.2), so the original denominator survives whatever the
22
+ // run did: a positive is tp, fn, outside-run or not-adjudicated; a negative is tp-free by construction and is tn, fp, scan-failed,
23
+ // outside-run or not-adjudicated. `reconciliation.reconciled` is true only when the statuses add up to the enrolled count.
20
24
 
21
25
  import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
22
26
  import { digestOf } from '../assurance/identity.js';
@@ -82,7 +86,9 @@ export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
82
86
 
83
87
  const e2e = { lang: {}, family: {} }; const cond = { lang: {}, family: {} };
84
88
  const misses = []; const falsePositives = []; const negativeFailures = []; const matchedFinding = new Set();
85
- const unscored = { unlabeledTargets: [], nonAdjudicatedLabels: 0, unlabeledFindings: { count: 0, byReason: {} }, positivesOutsideRun: 0 };
89
+ const unscored = { unlabeledTargets: [], nonAdjudicatedLabels: 0, unlabeledFindings: { count: 0, byReason: {} }, positivesOutsideRun: 0, negativesOutsideRun: 0 };
90
+ const recon = [];
91
+ const settle = (l, kind, status, reason = null) => recon.push({ labelId: l?.id ?? null, kind, status, ...(reason ? { reason } : {}) });
86
92
  const queue = [];
87
93
 
88
94
  const labelledTargets = new Set();
@@ -92,14 +98,15 @@ export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
92
98
  if (!perTarget[targetId]) perTarget[targetId] = { language, tp: 0, fn: 0, fp: 0, tn: 0 };
93
99
  perTarget[targetId][field] += n;
94
100
  };
95
- const scoreable = (l) => {
101
+ const scoreable = (kind) => (l) => {
96
102
  if (isScoreable(l)) return true;
97
103
  unscored.nonAdjudicatedLabels++;
104
+ settle(l, kind, 'not-adjudicated');
98
105
  return false;
99
106
  };
100
107
 
101
- for (const l of defects.filter(scoreable)) {
102
- if (!inRun.has(l.targetId)) { unscored.positivesOutsideRun++; continue; }
108
+ for (const l of defects.filter(scoreable('positive'))) {
109
+ if (!inRun.has(l.targetId)) { unscored.positivesOutsideRun++; settle(l, 'positive', 'outside-run'); continue; }
103
110
  labelledTargets.add(l.targetId);
104
111
  const o = outcomeOf.get(`${l.targetId}|pre`);
105
112
  const completed = o?.status === 'completed';
@@ -108,21 +115,25 @@ export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
108
115
  matchedFinding.add(`${l.targetId}|pre|${findingKey(hit)}`);
109
116
  for (const [m, k] of [[e2e.lang, l.language], [e2e.family, l.family], [cond.lang, l.language], [cond.family, l.family]]) bump(m, k, 'tp');
110
117
  tally(l.targetId, l.language, 'tp');
118
+ settle(l, 'positive', 'tp');
111
119
  } else {
112
120
  const reason = !o ? 'no-outcome' : completed ? 'not-detected' : o.status;
113
121
  misses.push({ labelId: l.id, targetId: l.targetId, reason, detail: o?.failureReason || null });
122
+ settle(l, 'positive', 'fn', reason);
114
123
  for (const [m, k] of [[e2e.lang, l.language], [e2e.family, l.family]]) bump(m, k, 'fn');
115
124
  tally(l.targetId, l.language, 'fn');
116
125
  if (completed) for (const [m, k] of [[cond.lang, l.language], [cond.family, l.family]]) bump(m, k, 'fn');
117
126
  }
118
127
  }
119
128
 
120
- for (const n of negatives.filter(scoreable)) {
121
- if (!inRun.has(n.targetId)) continue;
129
+ for (const n of negatives.filter(scoreable('negative'))) {
130
+ // a negative the run never reached is counted, not dropped: it leaves the denominator only with its reason
131
+ if (!inRun.has(n.targetId)) { unscored.negativesOutsideRun++; settle(n, 'negative', 'outside-run'); continue; }
122
132
  labelledTargets.add(n.targetId);
123
133
  const o = outcomeOf.get(`${n.targetId}|${n.variant}`);
124
134
  if (o?.status !== 'completed') {
125
135
  negativeFailures.push({ labelId: n.id, targetId: n.targetId, variant: n.variant, reason: o?.status || 'no-outcome', detail: o?.failureReason || null });
136
+ settle(n, 'negative', 'scan-failed', o?.status || 'no-outcome');
126
137
  continue;
127
138
  }
128
139
  const hits = new Map();
@@ -138,9 +149,11 @@ export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
138
149
  for (const c of clusters) falsePositives.push({ labelId: n.id, targetId: n.targetId, kind: n.kind, finding: { file: c.members[0].file, line: c.members[0].line, family: c.family }, alertsCollapsed: c.members.length });
139
150
  for (const [m, k] of [[e2e.lang, n.language], [e2e.family, n.family], [cond.lang, n.language], [cond.family, n.family]]) { for (let i = 0; i < clusters.length; i++) bump(m, k, 'fp'); }
140
151
  tally(n.targetId, n.language, 'fp', clusters.length);
152
+ settle(n, 'negative', 'fp');
141
153
  } else {
142
154
  for (const [m, k] of [[e2e.lang, n.language], [e2e.family, n.family], [cond.lang, n.language], [cond.family, n.family]]) bump(m, k, 'tn');
143
155
  tally(n.targetId, n.language, 'tn');
156
+ settle(n, 'negative', 'tn');
144
157
  }
145
158
  }
146
159
 
@@ -160,12 +173,16 @@ export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
160
173
  const endToEnd = { byLanguage: finalize(e2e.lang), byFamily: finalize(e2e.family) };
161
174
  const conditional = { byLanguage: finalize(cond.lang), byFamily: finalize(cond.family) };
162
175
  const completed = run.outcomes.filter((o) => o.status === 'completed').length;
176
+ const byStatus = {};
177
+ for (const r of recon) { const k = `${r.kind}:${r.status}`; byStatus[k] = (byStatus[k] || 0) + 1; }
178
+ const enrolled = defects.length + negatives.length;
179
+ const reconciliation = { enrolled, settled: recon.length, reconciled: recon.length === enrolled, byStatus: Object.fromEntries(Object.keys(byStatus).sort().map((k) => [k, byStatus[k]])), cases: recon };
163
180
  const score = {
164
181
  schema: SCORE_SCHEMA, schemaVersion: SCHEMA_VERSION, runId: run.runId, protocolHash: run.protocolHash, split: run.split,
165
182
  endToEnd: { ...endToEnd, ...aggregate(endToEnd.byLanguage), label: 'end-to-end: failures on known positives are misses' },
166
183
  conditional: { ...conditional, ...aggregate(conditional.byLanguage), label: 'conditional on completion: additional, never a replacement' },
167
184
  completion: { outcomes: run.outcomes.length, completed, rate: rate(completed, run.outcomes.length), totals: run.totals },
168
- misses, falsePositives, negativeFailures, unscored, perTarget,
185
+ misses, falsePositives, negativeFailures, unscored, perTarget, reconciliation,
169
186
  reviewQueue: reviewQueue(queue),
170
187
  population: populationCounts(defects, negatives),
171
188
  };
@@ -40,7 +40,7 @@
40
40
  // lenses live here in deterministic code rather than in the agent's prompt: a
41
41
  // reviewer asked to double-check itself is the same party voting twice.
42
42
 
43
- import { recordProducer, recordVerdict, consensusOf } from './verification-separation.js';
43
+ import { recordProducer, recordVerdict, consensusOf, refutationRemoves } from './verification-separation.js';
44
44
  import { inferredInvariant } from './invariants/lifecycle.js';
45
45
 
46
46
  export const PRODUCER = 'agent:logic-reviewer';
@@ -132,7 +132,7 @@ export function verifyLogicClaim(claim, fileContents) {
132
132
  // ── citation ──────────────────────────────────────────────────────────────
133
133
  if (content === null) {
134
134
  recordVerdict(finding, {
135
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
135
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
136
136
  reason: `no file '${claim.file}' was scanned, so the cited location does not exist`,
137
137
  });
138
138
  finding.consensus = consensusOf(finding);
@@ -143,12 +143,12 @@ export function verifyLogicClaim(claim, fileContents) {
143
143
  const line = Number(claim.line);
144
144
  if (!Number.isInteger(line) || line < 1 || line > total) {
145
145
  recordVerdict(finding, {
146
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
146
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted', evidence: { kind: 'deterministic' },
147
147
  reason: `cited line ${claim.line} is outside ${claim.file} (${total} lines)`,
148
148
  });
149
149
  } else {
150
150
  recordVerdict(finding, {
151
- verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld',
151
+ verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld', evidence: { kind: 'deterministic' },
152
152
  reason: `${claim.file}:${line} exists`,
153
153
  });
154
154
  }
@@ -158,7 +158,7 @@ export function verifyLogicClaim(claim, fileContents) {
158
158
  // snippet is UNDECIDED here, not upheld — silence is not corroboration.
159
159
  if (!claim.snippet || !String(claim.snippet).trim()) {
160
160
  recordVerdict(finding, {
161
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
161
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
162
162
  reason: 'the claim quotes no source, so there is nothing to check it against',
163
163
  });
164
164
  } else {
@@ -170,19 +170,19 @@ export function verifyLogicClaim(claim, fileContents) {
170
170
  const anywhere = _normalize(content);
171
171
  if (window.includes(want)) {
172
172
  recordVerdict(finding, {
173
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld',
173
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld', evidence: { kind: 'deterministic' },
174
174
  reason: 'the quoted source appears at the cited line',
175
175
  });
176
176
  } else if (anywhere.includes(want)) {
177
177
  // Right file, wrong line. Not a fabrication, but the location is not
178
178
  // usable as-is, so it is not corroboration either.
179
179
  recordVerdict(finding, {
180
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
180
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided', evidence: { kind: 'deterministic' },
181
181
  reason: 'the quoted source is in the file but not at the cited line',
182
182
  });
183
183
  } else {
184
184
  recordVerdict(finding, {
185
- verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted',
185
+ verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted', evidence: { kind: 'deterministic' },
186
186
  reason: 'the quoted source does not appear in the cited file',
187
187
  });
188
188
  }
@@ -193,30 +193,31 @@ export function verifyLogicClaim(claim, fileContents) {
193
193
  if (finding.kind === 'missing-authentication') {
194
194
  const hit = AUTH_HINTS.find((re) => re.test(block));
195
195
  recordVerdict(finding, hit
196
- ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
196
+ ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
197
197
  reason: `the handler around this line does authenticate (${hit.source})` }
198
- : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
198
+ : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
199
199
  reason: 'no authentication marker anywhere in the enclosing handler' });
200
200
  } else if (finding.kind === 'missing-authorization' || finding.kind === 'missing-ownership-check') {
201
201
  const hit = OWNERSHIP_HINTS.find((re) => re.test(block));
202
202
  recordVerdict(finding, hit
203
- ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
203
+ ? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted', evidence: { kind: 'deterministic' },
204
204
  reason: `the handler around this line does scope the record to a user (${hit.source})` }
205
- : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
205
+ : { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld', evidence: { kind: 'deterministic' },
206
206
  reason: 'no ownership scoping in the enclosing handler' });
207
207
  } else {
208
208
  // No deterministic lens exists for this kind. Said out loud rather than
209
209
  // counted as agreement — an unchecked claim and a corroborated one must
210
210
  // not read the same in the consensus.
211
211
  recordVerdict(finding, {
212
- verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided',
212
+ verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided', evidence: { kind: 'deterministic' },
213
213
  reason: `no deterministic lens covers claim kind '${finding.kind}'`,
214
214
  });
215
215
  }
216
216
 
217
217
  finding.consensus = consensusOf(finding);
218
218
  // Quarantine, not deletion — the same contract falsification uses.
219
- finding.quarantined = finding.consensus.verdict === 'refuted';
219
+ // threat-model FR-04.3: a critical claim is quarantined only on a refutation with sufficient independent authority
220
+ finding.quarantined = refutationRemoves(finding, finding.consensus);
220
221
  return finding;
221
222
  }
222
223