@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.
- package/CHANGELOG.md +41 -0
- package/bin/agentic-security.js +13 -4
- package/dist/1340.index.js +11 -6
- package/dist/3500.index.js +943 -0
- package/dist/5616.index.js +146 -19
- package/dist/{3eabd90d6d9c8c8dbbd9.json → 7e58a58b8e6ccdde1659.json} +7 -6
- package/dist/9560.index.js +29 -5
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/egress/policy.js +79 -1
- package/src/engine.js +1 -1
- package/src/language/CLAUDE.md +1 -1
- package/src/language/haskell-guards.js +26 -5
- package/src/lineage/CLAUDE.md +3 -1
- package/src/lineage/deployment/adapter-cloud.js +5 -2
- package/src/lineage/deployment/attack-paths.js +107 -30
- package/src/lineage/deployment/boundaries-run.js +2 -2
- package/src/lineage/deployment/iam-eval.js +105 -0
- package/src/lineage/deployment/projection.js +10 -4
- package/src/lineage/deployment/proof-binding.js +79 -0
- package/src/llm-validator/bounded-context.js +115 -8
- package/src/llm-validator/index.js +1 -1
- package/src/mcp/CLAUDE.md +6 -0
- package/src/mcp/tools.js +27 -3
- package/src/pipeline/evidence-record.js +1 -1
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/assurance/candidate-qualification.js +155 -0
- package/src/posture/assurance/release-package-cli.js +44 -2
- package/src/posture/auditor-walkthrough.js +2 -1
- package/src/posture/dnr/detection-ir.js +2 -2
- package/src/posture/dnr/hunt.js +22 -6
- package/src/posture/dnr/log-reader.js +47 -3
- package/src/posture/dnr/matchers.js +43 -3
- package/src/posture/dnr/outputs.js +16 -2
- package/src/posture/dnr/payload-classes.js +12 -2
- package/src/posture/dnr/validate.js +17 -4
- package/src/posture/evaluation/report.js +2 -1
- package/src/posture/evaluation/score.js +24 -7
- package/src/posture/logic-claims.js +15 -14
- package/src/posture/sibling-queue.js +69 -0
- package/src/posture/triage-memory.js +106 -26
- package/src/posture/verification-separation.js +55 -7
- package/src/report/index.js +4 -0
- package/src/runScan.js +5 -0
- package/src/scope/flags.js +1 -0
- package/src/scope/inventory.js +17 -1
- package/src/scope/release-scope.js +80 -0
package/src/posture/dnr/hunt.js
CHANGED
|
@@ -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
|
-
|
|
52
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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-
|
|
84
|
-
behavior: 'a protected operation succeeds for a request that
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|