@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
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 +34 -0
- package/bin/agentic-security.js +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Held-out scenario ablation: what does an executable business-logic contract add? (X-408)
|
|
2
|
+
//
|
|
3
|
+
// Three arms are run over ONE frozen set of cases, with one denominator and one budget:
|
|
4
|
+
//
|
|
5
|
+
// source-only the deterministic engine in deep mode (the layer the CLI uses) over the case's source file, as an
|
|
6
|
+
// unmodified `scan-child` run: no contract, nothing executed. FLAGGED means a finding at medium or above
|
|
7
|
+
// in that file, of ANY family: deliberately generous to the baseline, so a gap against it is not an artefact
|
|
8
|
+
// of a strict matching rule.
|
|
9
|
+
// inferred-contract a contract a miner could plausibly propose from the code (`inferredInvariant`), run as scenarios through
|
|
10
|
+
// the business-state oracle with NO approval: a violation is a CANDIDATE (advisory), never a confirmed
|
|
11
|
+
// finding. A class with no skeleton is a miss, not an exclusion.
|
|
12
|
+
// approved-contract the reviewer-authored contract, approved in a signed ledger by a local policy, run the same way: a
|
|
13
|
+
// violation is CONFIRMED (executed, receipted, approved).
|
|
14
|
+
//
|
|
15
|
+
// Rules that keep the comparison honest:
|
|
16
|
+
// - Same cases, same labels, same denominators. A case an arm could not evaluate (no skeleton, unsupported scenario, nothing
|
|
17
|
+
// settled, a crash) stays in that arm's denominator as "not flagged"; it is counted and named, never dropped. An arm cannot
|
|
18
|
+
// look better by skipping what it cannot do.
|
|
19
|
+
// - Same budgets: one `bounds`, one seed and one source timeout are applied to every arm and recorded in the report.
|
|
20
|
+
// - Positives and negatives are fixed in the frozen manifest; the runner never sees a label it could tune against (the
|
|
21
|
+
// labels are read only by the scorer, after every arm has produced its flags).
|
|
22
|
+
// - Precision and recall carry Wilson 95% intervals, and every arm is compared with the baseline on the SAME cases with
|
|
23
|
+
// paired counts (found by both, only by the arm, only by the baseline, by neither; and the same for false positives).
|
|
24
|
+
// - Unique findings are defects an arm flags that NO other arm flags; unique CONFIRMED ones additionally need execution and an
|
|
25
|
+
// approved contract. Scenario cost is reported (scenarios run, requests, wall time); wall time is reported but never gated
|
|
26
|
+
// and is excluded from the report fingerprint.
|
|
27
|
+
// - A class is called SUPPORTED only from executed evidence: every case of the class ran under the approved arm and settled,
|
|
28
|
+
// the registered policy thresholds were met, and no baseline-found defect was lost. Anything not executed (the trust boundary
|
|
29
|
+
// could not run) is UNMEASURED, never unsupported-by-absence and never supported-by-assumption.
|
|
30
|
+
//
|
|
31
|
+
// WHAT THIS IS NOT. The cases are SYNTHETIC: small applications written by this tooling's developers, labelled by them, in the
|
|
32
|
+
// factory shape the business-state oracle can run. No independent adjudication exists. Every report is flagged
|
|
33
|
+
// `synthetic: true` and `realWorldClaim: false`; a figure from it describes the machinery on these fixtures and must not be
|
|
34
|
+
// quoted as engine accuracy or as a real-world benefit. The baseline arm sees framework-free factories, which is not the code it
|
|
35
|
+
// is built for, so its recall here says nothing about its recall on applications.
|
|
36
|
+
//
|
|
37
|
+
// The benchmark is frozen: `manifest.json` lists the sha256 of every file, `pin.json` pins the manifest's own digest, and
|
|
38
|
+
// `loadBenchmark` fails closed on any mismatch. Changing a case is a new version with a new pin, deliberately (`pinBenchmark`).
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as os from 'node:os';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { digestOf, digestOfBytes } from '../assurance/identity.js';
|
|
43
|
+
import { verifyInvariant } from '../invariants/run.js';
|
|
44
|
+
import { emptyLedger, recordTransition, inferredInvariant } from '../invariants/lifecycle.js';
|
|
45
|
+
import { validateInvariant } from '../invariants/schema.js';
|
|
46
|
+
import { defaultScanFn } from './runner.js';
|
|
47
|
+
import { engineEnvironment } from './custody.js';
|
|
48
|
+
|
|
49
|
+
const ABLATION_SCHEMA = 'agentic-security/invariant-ablation';
|
|
50
|
+
export const ARMS = Object.freeze(['source-only', 'inferred-contract', 'approved-contract']);
|
|
51
|
+
export const PIN_FILE = 'pin.json';
|
|
52
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
53
|
+
const FLAG_SEVERITIES = new Set(['medium', 'high', 'critical']);
|
|
54
|
+
const REVIEWER = Object.freeze({ id: 'benchmark-fixture-reviewer', kind: 'human' });
|
|
55
|
+
const POLICY = Object.freeze({ id: 'benchmark-fixture-policy', reviewers: [REVIEWER.id] });
|
|
56
|
+
const SYNTHETIC_NOTE = 'SYNTHETIC: authored and labelled by the tooling developers; no independent adjudication. These figures exercise the comparison and are not engine accuracy or evidence of real-world benefit.';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- the frozen benchmark
|
|
59
|
+
|
|
60
|
+
export function manifestHashOf(manifest) { return digestOf(manifest); }
|
|
61
|
+
|
|
62
|
+
const sha256 = (text) => digestOfBytes(Buffer.from(text, 'utf8'));
|
|
63
|
+
|
|
64
|
+
/** Write the pin for the benchmark in `dir`. Deliberate: run this only when a new version of the benchmark is meant. */
|
|
65
|
+
export function pinBenchmark(dir) {
|
|
66
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'));
|
|
67
|
+
const pin = { manifestHash: manifestHashOf(manifest), version: manifest.version, files: Object.keys(manifest.files).length };
|
|
68
|
+
fs.writeFileSync(path.join(dir, PIN_FILE), `${JSON.stringify(pin, null, 2)}\n`);
|
|
69
|
+
return pin;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load and verify the frozen benchmark. Fails closed: a missing pin, a changed manifest, or any file whose digest differs from the
|
|
74
|
+
* manifest is `{ ok: false, errors }` and nothing is returned to run.
|
|
75
|
+
*/
|
|
76
|
+
export function loadBenchmark(dir) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
let manifest; let pin;
|
|
79
|
+
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8')); } catch { return { ok: false, errors: ['the benchmark manifest is missing or unreadable'] }; }
|
|
80
|
+
try { pin = JSON.parse(fs.readFileSync(path.join(dir, PIN_FILE), 'utf8')); } catch { return { ok: false, errors: ['the benchmark has no pin: it is not frozen'] }; }
|
|
81
|
+
if (manifest.schema !== 'agentic-security/invariant-benchmark' || manifest.synthetic !== true) errors.push('the manifest is not a synthetic invariant benchmark');
|
|
82
|
+
if (pin.manifestHash !== manifestHashOf(manifest)) errors.push('the manifest no longer matches its pin: the benchmark was changed after it was frozen');
|
|
83
|
+
const cases = [];
|
|
84
|
+
for (const [rel, want] of Object.entries(manifest.files || {})) {
|
|
85
|
+
let text;
|
|
86
|
+
try { text = fs.readFileSync(path.join(dir, rel), 'utf8'); } catch { errors.push(`${rel}: missing`); continue; }
|
|
87
|
+
if (sha256(text) !== want) errors.push(`${rel}: digest differs from the frozen manifest`);
|
|
88
|
+
}
|
|
89
|
+
if (errors.length) return { ok: false, errors };
|
|
90
|
+
for (const c of manifest.cases) {
|
|
91
|
+
const base = path.join(dir, 'cases', c.id);
|
|
92
|
+
const read = (f) => fs.readFileSync(path.join(base, f), 'utf8');
|
|
93
|
+
const contract = JSON.parse(read('contract.json'));
|
|
94
|
+
if (!validateInvariant(contract).ok) { errors.push(`${c.id}: the approved contract is not valid`); continue; }
|
|
95
|
+
cases.push({ ...c, files: { 'app.mjs': read('app.mjs') }, contract, hint: JSON.parse(read('hint.json')) });
|
|
96
|
+
}
|
|
97
|
+
if (errors.length) return { ok: false, errors };
|
|
98
|
+
return { ok: true, benchmark: { dir, manifest, manifestHash: pin.manifestHash, version: manifest.version, claimPolicy: manifest.claimPolicy, cases } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------- statistics
|
|
102
|
+
|
|
103
|
+
/** Wilson score interval for k successes in n trials (95%). `null` bounds when n is 0: no interval is invented. */
|
|
104
|
+
export function wilson(k, n, z = 1.959964) {
|
|
105
|
+
if (!Number.isInteger(n) || n <= 0) return { low: null, high: null };
|
|
106
|
+
const p = k / n; const z2 = z * z;
|
|
107
|
+
const denom = 1 + z2 / n;
|
|
108
|
+
const centre = (p + z2 / (2 * n)) / denom;
|
|
109
|
+
const half = (z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n))) / denom;
|
|
110
|
+
return { low: Math.max(0, centre - half), high: Math.min(1, centre + half) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rate = (k, n) => ({ k, n, value: n > 0 ? k / n : null, ...wilson(k, n) });
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------- arms
|
|
116
|
+
|
|
117
|
+
function cost(scenarios, requests, ms) { return { scenarios, requests, ms }; }
|
|
118
|
+
|
|
119
|
+
function fromInvariantRun(res, ms) {
|
|
120
|
+
const results = Array.isArray(res?.results) ? res.results : [];
|
|
121
|
+
const executed = results.filter((r) => r.status === 'completed');
|
|
122
|
+
const decided = executed.filter((r) => r.outcome === 'confirmed' || r.outcome === 'refuted');
|
|
123
|
+
const violated = results.filter((r) => r.outcome === 'confirmed');
|
|
124
|
+
const approved = results.filter((r) => r.classification?.kind === 'approved-violation');
|
|
125
|
+
return {
|
|
126
|
+
flagged: violated.length > 0, confirmed: approved.length > 0, executed: executed.length > 0, decided: decided.length > 0,
|
|
127
|
+
complete: results.length > 0 && decided.length === results.length && (res?.unsupported || []).length === 0,
|
|
128
|
+
status: res?.status === 'ok' ? (decided.length ? 'decided' : executed.length ? 'inconclusive' : 'not-executed') : (res?.status ?? 'error'),
|
|
129
|
+
reason: res?.reason ?? (results.find((r) => r.reason)?.reason ?? null),
|
|
130
|
+
kinds: results.map((r) => r.kind), unsupported: (res?.unsupported || []).map((u) => u.kind ?? u.reason),
|
|
131
|
+
cost: cost(executed.length, executed.reduce((n, r) => n + (r.limits?.requests ?? 0), 0), ms),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The default baseline: one deep-mode engine scan of the case's file in a child process, through the evaluation runner. */
|
|
136
|
+
export async function defaultSourceAnalyzer({ files, tmpRoot, timeoutMs }) {
|
|
137
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot || os.tmpdir(), 'inv-bench-src-'));
|
|
138
|
+
try {
|
|
139
|
+
for (const [rel, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), text);
|
|
140
|
+
const r = await defaultScanFn(dir, { layer: 'deep-taint', timeoutMs, env: engineEnvironment(process.env) });
|
|
141
|
+
return { findings: r.findings || [] };
|
|
142
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sourceArm(c, o) {
|
|
146
|
+
const t0 = Date.now();
|
|
147
|
+
try {
|
|
148
|
+
const r = await o.sourceAnalyzer({ files: c.files, caseId: c.id, tmpRoot: o.tmpRoot, timeoutMs: o.sourceTimeoutMs });
|
|
149
|
+
const hit = (r.findings || []).filter((f) => FLAG_SEVERITIES.has(f.severity) && (!f.file || String(f.file).endsWith('app.mjs')));
|
|
150
|
+
return { flagged: hit.length > 0, confirmed: false, executed: true, decided: true, complete: true, status: 'analyzed', reason: hit.length ? `${hit.length} finding(s) at medium or above` : null, findings: hit.map((f) => ({ family: f.family ?? null, cwe: f.cwe ?? null, severity: f.severity })), cost: cost(0, 0, Date.now() - t0) };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return { flagged: false, confirmed: false, executed: false, decided: false, complete: false, status: 'error', reason: String(e?.message || e).slice(0, 200), cost: cost(0, 0, Date.now() - t0) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function inferredArm(c, o) {
|
|
157
|
+
const t0 = Date.now();
|
|
158
|
+
const inv = inferredInvariant({ ...c.hint });
|
|
159
|
+
if (!inv) return { flagged: false, confirmed: false, executed: false, decided: false, complete: false, status: 'no-contract', reason: `no inferred skeleton exists for the '${c.hint.class}' class`, cost: cost(0, 0, 0) };
|
|
160
|
+
const res = await verifyInvariant({ invariant: inv, fixture: { files: c.files }, commit: o.commit, config: o.config, seed: o.seed, bounds: o.bounds, runOptions: o.runOptions });
|
|
161
|
+
const out = fromInvariantRun(res, Date.now() - t0);
|
|
162
|
+
out.confirmed = false; // an unapproved contract can never confirm anything
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function approvedArm(c, o) {
|
|
167
|
+
const t0 = Date.now();
|
|
168
|
+
const inv = c.contract;
|
|
169
|
+
const proposed = recordTransition(emptyLedger(), { action: 'propose', invariant: inv, actor: { id: 'benchmark-loader', kind: 'code' }, reason: 'frozen benchmark fixture' }, { signer: o.signer });
|
|
170
|
+
const approved = proposed.ok ? recordTransition(proposed.ledger, { action: 'approve', invariantId: inv.id, actor: REVIEWER, reason: 'reviewer-approved contract of the frozen benchmark' }, { policy: POLICY, signer: o.signer }) : proposed;
|
|
171
|
+
if (!approved.ok) return { flagged: false, confirmed: false, executed: false, decided: false, complete: false, status: 'error', reason: approved.errors?.[0]?.message ?? 'the fixture contract could not be recorded', cost: cost(0, 0, 0) };
|
|
172
|
+
const res = await verifyInvariant({ invariant: inv, fixture: { files: c.files }, commit: o.commit, config: o.config, seed: o.seed, bounds: o.bounds, ledger: approved.ledger, signer: o.signer, runOptions: o.runOptions });
|
|
173
|
+
return fromInvariantRun(res, Date.now() - t0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- scoring
|
|
177
|
+
|
|
178
|
+
function scoreArm(cases, outcomes) {
|
|
179
|
+
let tp = 0; let fp = 0; let fn = 0; let tn = 0; let confirmed = 0;
|
|
180
|
+
const notEvaluated = [];
|
|
181
|
+
for (const c of cases) {
|
|
182
|
+
const o = outcomes[c.id];
|
|
183
|
+
if (o.flagged) { if (c.defective) tp++; else fp++; } else if (c.defective) fn++; else tn++;
|
|
184
|
+
if (o.flagged && o.confirmed && c.defective) confirmed++;
|
|
185
|
+
if (!o.executed || !o.decided || o.complete === false) notEvaluated.push({ caseId: c.id, status: o.status, reason: o.reason ?? null });
|
|
186
|
+
}
|
|
187
|
+
const positives = tp + fn; const negatives = fp + tn;
|
|
188
|
+
return {
|
|
189
|
+
counts: { tp, fp, fn, tn, positives, negatives, cases: cases.length },
|
|
190
|
+
precision: rate(tp, tp + fp), recall: rate(tp, positives), falsePositiveRate: rate(fp, negatives),
|
|
191
|
+
confirmedTruePositives: confirmed, notEvaluated,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function paired(cases, baseline, arm) {
|
|
196
|
+
const out = { defects: { both: 0, armOnly: 0, baselineOnly: 0, neither: 0 }, valid: { bothFlagged: 0, armOnly: 0, baselineOnly: 0, neither: 0 } };
|
|
197
|
+
for (const c of cases) {
|
|
198
|
+
const b = baseline[c.id].flagged; const a = arm[c.id].flagged;
|
|
199
|
+
if (c.defective) out.defects[a && b ? 'both' : a ? 'armOnly' : b ? 'baselineOnly' : 'neither']++;
|
|
200
|
+
else out.valid[a && b ? 'bothFlagged' : a ? 'armOnly' : b ? 'baselineOnly' : 'neither']++;
|
|
201
|
+
}
|
|
202
|
+
return { ...out, lostBaselineDefects: out.defects.baselineOnly, falsePositivesRemoved: out.valid.baselineOnly, falsePositivesAdded: out.valid.armOnly };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function uniques(cases, outcomes) {
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const arm of ARMS) {
|
|
208
|
+
const others = ARMS.filter((a) => a !== arm);
|
|
209
|
+
const ids = cases.filter((c) => c.defective && outcomes[arm][c.id].flagged && !others.some((o) => outcomes[o][c.id].flagged)).map((c) => c.id);
|
|
210
|
+
out[arm] = { uniqueTruePositives: ids.length, uniqueConfirmed: ids.filter((id) => outcomes[arm][id].confirmed).length, caseIds: ids };
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Per-class claims from executed evidence only. `supported` needs: every case of the class executed and settled under the approved
|
|
219
|
+
* arm, at least the registered number of defect and valid cases, precision and recall at the registered thresholds, and no
|
|
220
|
+
* defect the baseline found lost. `unmeasured` when anything was not executed. Otherwise `unsupported`, with the reasons.
|
|
221
|
+
*/
|
|
222
|
+
export function releaseClaims({ cases, outcomes, policy }) {
|
|
223
|
+
const pol = policy || {};
|
|
224
|
+
const classes = [...new Set(cases.map((c) => c.class))].sort();
|
|
225
|
+
const claims = {};
|
|
226
|
+
for (const cls of classes) {
|
|
227
|
+
const mine = cases.filter((c) => c.class === cls);
|
|
228
|
+
const d = mine.filter((c) => c.defective); const v = mine.filter((c) => !c.defective);
|
|
229
|
+
const ap = outcomes['approved-contract']; const src = outcomes['source-only'];
|
|
230
|
+
const reasons = [];
|
|
231
|
+
const notRun = mine.filter((c) => !ap[c.id].executed);
|
|
232
|
+
const unsettled = mine.filter((c) => ap[c.id].executed && (!ap[c.id].decided || ap[c.id].complete === false));
|
|
233
|
+
let status;
|
|
234
|
+
if (notRun.length) { status = 'unmeasured'; reasons.push(`not executed: ${notRun.map((c) => c.id).join(', ')}`); }
|
|
235
|
+
else {
|
|
236
|
+
const tp = d.filter((c) => ap[c.id].flagged).length; const fp = v.filter((c) => ap[c.id].flagged).length;
|
|
237
|
+
const precision = tp + fp ? tp / (tp + fp) : null; const recall = d.length ? tp / d.length : null;
|
|
238
|
+
if (d.length < (pol.minDefectCasesPerClass ?? 1)) reasons.push(`fewer than ${pol.minDefectCasesPerClass ?? 1} defect case(s)`);
|
|
239
|
+
if (v.length < (pol.minValidCasesPerClass ?? 1)) reasons.push(`fewer than ${pol.minValidCasesPerClass ?? 1} valid case(s)`);
|
|
240
|
+
if (unsettled.length) reasons.push(`did not settle: ${unsettled.map((c) => c.id).join(', ')}`);
|
|
241
|
+
if (precision === null || precision < (pol.minPrecision ?? 1)) reasons.push(`precision ${precision === null ? 'undefined' : precision.toFixed(2)} below ${pol.minPrecision ?? 1}`);
|
|
242
|
+
if (recall === null || recall < (pol.minRecall ?? 1)) reasons.push(`recall ${recall === null ? 'undefined' : recall.toFixed(2)} below ${pol.minRecall ?? 1}`);
|
|
243
|
+
const lost = d.filter((c) => src[c.id].flagged && !ap[c.id].flagged);
|
|
244
|
+
if (pol.noLossVersusSourceOnly !== false && lost.length) reasons.push(`lost baseline-found defect(s): ${lost.map((c) => c.id).join(', ')}`);
|
|
245
|
+
status = reasons.length ? 'unsupported' : 'supported';
|
|
246
|
+
}
|
|
247
|
+
const tpN = d.filter((c) => ap[c.id].flagged).length; const fpN = v.filter((c) => ap[c.id].flagged).length;
|
|
248
|
+
claims[cls] = {
|
|
249
|
+
status, reasons, defectCases: d.length, validCases: v.length,
|
|
250
|
+
precision: rate(tpN, tpN + fpN), recall: rate(tpN, d.length),
|
|
251
|
+
uniqueConfirmed: d.filter((c) => ap[c.id].confirmed && !src[c.id].flagged && !outcomes['inferred-contract'][c.id].flagged).length,
|
|
252
|
+
scenarioCost: { scenarios: mine.reduce((n, c) => n + ap[c.id].cost.scenarios, 0), requests: mine.reduce((n, c) => n + ap[c.id].cost.requests, 0) },
|
|
253
|
+
scope: 'frozen synthetic fixtures only; no real-world claim',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return claims;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------- the run
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Run all three arms over the frozen benchmark and score them. Never throws.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} o
|
|
265
|
+
* @param {object} o.benchmark from `loadBenchmark`
|
|
266
|
+
* @param {string} o.commit exact commit the fixtures are treated as belonging to (a decided verdict needs one)
|
|
267
|
+
* @param {object} o.config assurance config with `invariant-scenarios` and `verification-oracles` enabled
|
|
268
|
+
* @param {object} [o.bounds] scenario bounds, identical for every arm
|
|
269
|
+
* @param {number} [o.seed]
|
|
270
|
+
* @param {Function} [o.sourceAnalyzer] ({ files, caseId, tmpRoot, timeoutMs }) => { findings }; default is a real deep-mode scan
|
|
271
|
+
* @param {number} [o.sourceTimeoutMs]
|
|
272
|
+
* @param {object} [o.signer] ledger signer (test seam); default is the per-install key
|
|
273
|
+
* @param {object} [o.runOptions] oracle runner seams (test)
|
|
274
|
+
*/
|
|
275
|
+
export async function runInvariantAblation(o = {}) {
|
|
276
|
+
const b = o.benchmark;
|
|
277
|
+
const opts = {
|
|
278
|
+
commit: o.commit, config: o.config, bounds: o.bounds, seed: o.seed ?? 1, signer: o.signer, runOptions: o.runOptions,
|
|
279
|
+
sourceAnalyzer: o.sourceAnalyzer || defaultSourceAnalyzer, tmpRoot: o.tmpRoot, sourceTimeoutMs: o.sourceTimeoutMs ?? 90_000,
|
|
280
|
+
};
|
|
281
|
+
// every arm is run before any label is consulted
|
|
282
|
+
const outcomes = { 'source-only': {}, 'inferred-contract': {}, 'approved-contract': {} };
|
|
283
|
+
for (const c of b.cases) {
|
|
284
|
+
const safe = async (fn) => { try { return await fn(c, opts); } catch (e) { return { flagged: false, confirmed: false, executed: false, decided: false, complete: false, status: 'error', reason: String(e?.message || e).slice(0, 200), cost: cost(0, 0, 0) }; } };
|
|
285
|
+
outcomes['source-only'][c.id] = await safe(sourceArm);
|
|
286
|
+
outcomes['inferred-contract'][c.id] = await safe(inferredArm);
|
|
287
|
+
outcomes['approved-contract'][c.id] = await safe(approvedArm);
|
|
288
|
+
}
|
|
289
|
+
const labelled = b.cases.map((c) => ({ id: c.id, class: c.class, defective: c.defective }));
|
|
290
|
+
const arms = {};
|
|
291
|
+
for (const arm of ARMS) {
|
|
292
|
+
const ms = b.cases.map((c) => outcomes[arm][c.id].cost.ms);
|
|
293
|
+
arms[arm] = {
|
|
294
|
+
...scoreArm(labelled, outcomes[arm]),
|
|
295
|
+
cost: { scenarios: b.cases.reduce((n, c) => n + outcomes[arm][c.id].cost.scenarios, 0), requests: b.cases.reduce((n, c) => n + outcomes[arm][c.id].cost.requests, 0), medianMsPerCase: median(ms), totalMs: ms.reduce((a, x) => a + x, 0) },
|
|
296
|
+
outcomes: b.cases.map((c) => ({ caseId: c.id, ...outcomes[arm][c.id] })),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const base = outcomes['source-only'];
|
|
300
|
+
const report = {
|
|
301
|
+
schema: ABLATION_SCHEMA, schemaVersion: '1.0.0', synthetic: true, realWorldClaim: false, note: SYNTHETIC_NOTE,
|
|
302
|
+
benchmark: { version: b.version, manifestHash: b.manifestHash, counts: b.manifest.counts, claimPolicy: b.claimPolicy },
|
|
303
|
+
budgets: { bounds: o.bounds ?? 'defaults', seed: opts.seed, sourceTimeoutMs: opts.sourceTimeoutMs, identicalForEveryArm: true },
|
|
304
|
+
arms,
|
|
305
|
+
paired: { 'inferred-contract': paired(labelled, base, outcomes['inferred-contract']), 'approved-contract': paired(labelled, base, outcomes['approved-contract']) },
|
|
306
|
+
unique: uniques(labelled, outcomes),
|
|
307
|
+
claims: releaseClaims({ cases: labelled, outcomes, policy: b.claimPolicy }),
|
|
308
|
+
};
|
|
309
|
+
const stable = JSON.parse(JSON.stringify(report, (k, v) => (k === 'ms' || k === 'medianMsPerCase' || k === 'totalMs' ? undefined : v)));
|
|
310
|
+
report.fingerprint = digestOf(stable);
|
|
311
|
+
return report;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function renderAblation(report) {
|
|
315
|
+
const pct = (r) => (r.value === null ? 'n/a' : `${(r.value * 100).toFixed(0)}% [${(r.low * 100).toFixed(0)}-${(r.high * 100).toFixed(0)}] (${r.k}/${r.n})`);
|
|
316
|
+
const lines = [report.note, `benchmark v${report.benchmark.version} ${report.benchmark.manifestHash.slice(0, 19)} cases: ${report.benchmark.counts.cases} (${report.benchmark.counts.defective} defective, ${report.benchmark.counts.valid} valid)`, ''];
|
|
317
|
+
lines.push('arm precision recall unique TP unique confirmed scenarios requests');
|
|
318
|
+
for (const arm of ARMS) {
|
|
319
|
+
const a = report.arms[arm]; const u = report.unique[arm];
|
|
320
|
+
lines.push(`${arm.padEnd(20)} ${pct(a.precision).padEnd(27)} ${pct(a.recall).padEnd(27)} ${String(u.uniqueTruePositives).padEnd(10)} ${String(u.uniqueConfirmed).padEnd(17)} ${String(a.cost.scenarios).padEnd(10)} ${a.cost.requests}`);
|
|
321
|
+
}
|
|
322
|
+
lines.push('', 'class claims (executed evidence only):');
|
|
323
|
+
for (const [cls, c] of Object.entries(report.claims)) lines.push(` ${cls.padEnd(22)} ${c.status.toUpperCase().padEnd(11)} ${c.reasons.join('; ') || 'policy met on the frozen synthetic fixtures'}`);
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Adjudicated defect labels, reviewed negatives and finding-to-label matching
|
|
2
|
+
// (QA-002.AC01, QA-002.AC02).
|
|
3
|
+
//
|
|
4
|
+
// Advisory records are CANDIDATE ground truth. A label becomes scoreable only
|
|
5
|
+
// after adjudication: at least two distinct reviewers other than whoever
|
|
6
|
+
// proposed it, with evidence produced by a human or an independent verifier. A
|
|
7
|
+
// CWE alone never identifies a defect: a label needs a reviewed location range,
|
|
8
|
+
// and a finding matches only by location (plus family or CWE agreement).
|
|
9
|
+
//
|
|
10
|
+
// This module can validate that a label SAYS it was independently adjudicated.
|
|
11
|
+
// It cannot establish that it was. `synthetic: true` labels are accepted for
|
|
12
|
+
// tooling tests and are excluded from every real-code population count
|
|
13
|
+
// (`populationCounts`, gates.js).
|
|
14
|
+
//
|
|
15
|
+
// Pure: no fs, no clock, no network.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
SCHEMA_VERSION, result, guardObject, checkHeader, checkFields, checkEnum, checkString, checkId,
|
|
19
|
+
isPlainObject, checkCommit,
|
|
20
|
+
} from '../assurance/schema-kit.js';
|
|
21
|
+
import { semanticId } from '../assurance/identity.js';
|
|
22
|
+
import { EVIDENCE_PRODUCERS } from '../assurance/verification-record.js';
|
|
23
|
+
|
|
24
|
+
const DEFECT_LABEL_SCHEMA = 'agentic-security/adjudicated-defect-label';
|
|
25
|
+
const NEGATIVE_LABEL_SCHEMA = 'agentic-security/adjudicated-negative';
|
|
26
|
+
const ADJUDICATION_STATUSES = Object.freeze(['candidate', 'adjudicated', 'disputed', 'rejected']);
|
|
27
|
+
const NEGATIVE_KINDS = Object.freeze(['safe-real', 'patched', 'near-miss']);
|
|
28
|
+
const VARIANTS = Object.freeze(['pre', 'post']);
|
|
29
|
+
const INDEPENDENT_PRODUCERS = ['human', 'independent-verifier'];
|
|
30
|
+
|
|
31
|
+
const DEFECT_ID_FIELDS = Object.freeze(['targetId', 'rootCauseId', 'language', 'family', 'location', 'affected']);
|
|
32
|
+
const NEGATIVE_ID_FIELDS = Object.freeze(['targetId', 'variant', 'kind', 'language', 'family', 'scope', 'pairedDefectId']);
|
|
33
|
+
|
|
34
|
+
const defectLabelId = (l) => semanticId('dlab', l, DEFECT_ID_FIELDS);
|
|
35
|
+
const negativeLabelId = (l) => semanticId('nlab', l, NEGATIVE_ID_FIELDS);
|
|
36
|
+
|
|
37
|
+
const DL_ALLOWED = ['schema', 'schemaVersion', 'id', 'targetId', 'rootCauseId', 'affected', 'language', 'family', 'cwe', 'location', 'evidence', 'adjudication', 'proposedBy', 'advisoryRefs', 'synthetic', 'createdAt'];
|
|
38
|
+
const DL_REQUIRED = ['schema', 'schemaVersion', 'id', 'targetId', 'rootCauseId', 'affected', 'language', 'family', 'location', 'evidence', 'adjudication', 'proposedBy', 'synthetic'];
|
|
39
|
+
const NL_ALLOWED = ['schema', 'schemaVersion', 'id', 'targetId', 'variant', 'kind', 'language', 'family', 'scope', 'pairedDefectId', 'rationale', 'adjudication', 'proposedBy', 'synthetic', 'createdAt'];
|
|
40
|
+
const NL_REQUIRED = ['schema', 'schemaVersion', 'id', 'targetId', 'variant', 'kind', 'language', 'family', 'scope', 'rationale', 'adjudication', 'proposedBy', 'synthetic'];
|
|
41
|
+
|
|
42
|
+
function checkAdjudication(ctx, label) {
|
|
43
|
+
const a = label.adjudication;
|
|
44
|
+
if (!isPlainObject(a)) { ctx.err('BAD_TYPE', 'adjudication', 'must be an object'); return; }
|
|
45
|
+
if (!checkEnum(ctx, 'adjudication.status', a.status, ADJUDICATION_STATUSES)) return;
|
|
46
|
+
if (!Array.isArray(a.reviewers)) { ctx.err('BAD_TYPE', 'adjudication.reviewers', 'must be an array'); return; }
|
|
47
|
+
if (a.status !== 'adjudicated') return;
|
|
48
|
+
const ids = new Set();
|
|
49
|
+
a.reviewers.forEach((r, i) => {
|
|
50
|
+
if (!isPlainObject(r) || !r.reviewerId) { ctx.err('BAD_TYPE', `adjudication.reviewers[${i}]`, 'a reviewer record names reviewerId and verdict'); return; }
|
|
51
|
+
ids.add(r.reviewerId);
|
|
52
|
+
if (r.verdict !== 'confirmed') ctx.err('RULE_VIOLATION', `adjudication.reviewers[${i}].verdict`, 'an adjudicated label needs every reviewer verdict to be confirmed');
|
|
53
|
+
if (isPlainObject(label.proposedBy) && r.reviewerId === label.proposedBy.id) ctx.err('RULE_VIOLATION', `adjudication.reviewers[${i}]`, 'a proposer cannot adjudicate its own label');
|
|
54
|
+
});
|
|
55
|
+
if (ids.size < 2) ctx.err('RULE_VIOLATION', 'adjudication.reviewers', 'an adjudicated label needs at least two distinct reviewers');
|
|
56
|
+
if (a.independent !== true) ctx.err('RULE_VIOLATION', 'adjudication.independent', 'adjudication must be recorded as independent of the label proposer and the detector authors');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkEvidence(ctx, label) {
|
|
60
|
+
if (!Array.isArray(label.evidence)) { ctx.err('BAD_TYPE', 'evidence', 'must be an array'); return; }
|
|
61
|
+
label.evidence.forEach((e, i) => {
|
|
62
|
+
if (!isPlainObject(e)) { ctx.err('BAD_TYPE', `evidence[${i}]`, 'must be an object'); return; }
|
|
63
|
+
checkString(ctx, `evidence[${i}].ref`, e.ref);
|
|
64
|
+
checkEnum(ctx, `evidence[${i}].producer`, e.producer, EVIDENCE_PRODUCERS);
|
|
65
|
+
});
|
|
66
|
+
if (label.adjudication?.status === 'adjudicated' && !label.evidence.some((e) => INDEPENDENT_PRODUCERS.includes(e?.producer))) {
|
|
67
|
+
ctx.err('RULE_VIOLATION', 'evidence', 'an adjudicated label needs evidence produced by a human or an independent verifier; model output alone is not adjudication');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateDefectLabel(l) {
|
|
72
|
+
const g = guardObject(l);
|
|
73
|
+
const ctx = g.ctx;
|
|
74
|
+
if (!g.ok) return result(ctx);
|
|
75
|
+
if (!checkHeader(ctx, l, DEFECT_LABEL_SCHEMA)) return result(ctx);
|
|
76
|
+
checkFields(ctx, l, DL_ALLOWED, DL_REQUIRED);
|
|
77
|
+
checkString(ctx, 'targetId', l.targetId);
|
|
78
|
+
checkString(ctx, 'rootCauseId', l.rootCauseId);
|
|
79
|
+
checkString(ctx, 'language', l.language);
|
|
80
|
+
checkString(ctx, 'family', l.family);
|
|
81
|
+
if (typeof l.synthetic !== 'boolean') ctx.err('BAD_TYPE', 'synthetic', 'must be a boolean');
|
|
82
|
+
if (!isPlainObject(l.affected)) ctx.err('BAD_TYPE', 'affected', 'must name the affected build/commit');
|
|
83
|
+
else checkCommit(ctx, 'affected.commit', l.affected.commit, { nullable: false });
|
|
84
|
+
// The reviewed vulnerable location is mandatory: a CWE-only label cannot exist.
|
|
85
|
+
const loc = l.location;
|
|
86
|
+
if (!isPlainObject(loc)) ctx.err('MISSING_FIELD', 'location', 'a reviewed vulnerable location range is required; a CWE alone does not identify a defect');
|
|
87
|
+
else {
|
|
88
|
+
checkString(ctx, 'location.file', loc.file);
|
|
89
|
+
if (!Number.isInteger(loc.startLine) || !Number.isInteger(loc.endLine) || loc.startLine < 1 || loc.endLine < loc.startLine) {
|
|
90
|
+
ctx.err('BAD_TYPE', 'location', 'startLine/endLine must be positive integers with endLine >= startLine');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
checkAdjudication(ctx, l);
|
|
94
|
+
checkEvidence(ctx, l);
|
|
95
|
+
if (!isPlainObject(l.proposedBy) || !l.proposedBy.id) ctx.err('MISSING_FIELD', 'proposedBy', 'the proposer must be recorded');
|
|
96
|
+
checkId(ctx, l, defectLabelId(l));
|
|
97
|
+
return result(ctx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateNegativeLabel(l) {
|
|
101
|
+
const g = guardObject(l);
|
|
102
|
+
const ctx = g.ctx;
|
|
103
|
+
if (!g.ok) return result(ctx);
|
|
104
|
+
if (!checkHeader(ctx, l, NEGATIVE_LABEL_SCHEMA)) return result(ctx);
|
|
105
|
+
checkFields(ctx, l, NL_ALLOWED, NL_REQUIRED);
|
|
106
|
+
checkString(ctx, 'targetId', l.targetId);
|
|
107
|
+
checkString(ctx, 'language', l.language);
|
|
108
|
+
checkString(ctx, 'family', l.family);
|
|
109
|
+
checkString(ctx, 'rationale', l.rationale);
|
|
110
|
+
checkEnum(ctx, 'variant', l.variant, VARIANTS);
|
|
111
|
+
const kindOk = checkEnum(ctx, 'kind', l.kind, NEGATIVE_KINDS);
|
|
112
|
+
if (typeof l.synthetic !== 'boolean') ctx.err('BAD_TYPE', 'synthetic', 'must be a boolean');
|
|
113
|
+
if (!isPlainObject(l.scope) || !Array.isArray(l.scope.files) || l.scope.files.length === 0) ctx.err('MISSING_FIELD', 'scope', 'a negative must name the files it is negative for');
|
|
114
|
+
if (kindOk && l.kind === 'patched') {
|
|
115
|
+
if (l.variant !== 'post') ctx.err('RULE_VIOLATION', 'variant', 'a patched negative is the fixed (post) variant');
|
|
116
|
+
checkString(ctx, 'pairedDefectId', l.pairedDefectId);
|
|
117
|
+
}
|
|
118
|
+
checkAdjudication(ctx, l);
|
|
119
|
+
if (!isPlainObject(l.proposedBy) || !l.proposedBy.id) ctx.err('MISSING_FIELD', 'proposedBy', 'the proposer must be recorded');
|
|
120
|
+
checkId(ctx, l, negativeLabelId(l));
|
|
121
|
+
return result(ctx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildDefectLabel(fields) {
|
|
125
|
+
const l = { schema: DEFECT_LABEL_SCHEMA, schemaVersion: SCHEMA_VERSION, synthetic: false, evidence: [], ...fields };
|
|
126
|
+
l.id = defectLabelId(l);
|
|
127
|
+
return l;
|
|
128
|
+
}
|
|
129
|
+
export function buildNegativeLabel(fields) {
|
|
130
|
+
const l = { schema: NEGATIVE_LABEL_SCHEMA, schemaVersion: SCHEMA_VERSION, synthetic: false, ...fields };
|
|
131
|
+
l.id = negativeLabelId(l);
|
|
132
|
+
return l;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Scoreable = structurally valid AND adjudicated. A candidate (advisory-only) label is never scored. */
|
|
136
|
+
export function isScoreable(label) {
|
|
137
|
+
return !!label && label.adjudication?.status === 'adjudicated' && validateAny(label).ok;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function validateAny(label) {
|
|
141
|
+
return label?.schema === NEGATIVE_LABEL_SCHEMA ? validateNegativeLabel(label) : validateDefectLabel(label);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------- matching
|
|
145
|
+
|
|
146
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Does a finding identify the labelled defect? It must (1) sit in the labelled
|
|
150
|
+
* file, (2) carry a line inside the reviewed range widened by `lineWindow`, and
|
|
151
|
+
* (3) agree on family or CWE. CWE agreement alone, a right-file-wrong-line
|
|
152
|
+
* finding and a finding with no line all fail, each with a distinct reason.
|
|
153
|
+
*/
|
|
154
|
+
export function matchFinding(finding, label, policy) {
|
|
155
|
+
const lineWindow = Number.isInteger(policy?.lineWindow) ? policy.lineWindow : 3;
|
|
156
|
+
const sameFile = norm(finding?.file) === norm(label?.location?.file);
|
|
157
|
+
const familyOk = !!finding?.family && finding.family === label?.family;
|
|
158
|
+
const cweOk = !!finding?.cwe && !!label?.cwe && String(finding.cwe).toUpperCase() === String(label.cwe).toUpperCase();
|
|
159
|
+
if (!sameFile) return { matched: false, reason: cweOk ? 'cwe-only' : 'wrong-file' };
|
|
160
|
+
const line = Number(finding.line);
|
|
161
|
+
if (!Number.isFinite(line) || line <= 0) return { matched: false, reason: 'no-line' };
|
|
162
|
+
const { startLine, endLine } = label.location;
|
|
163
|
+
if (line < startLine - lineWindow || line > endLine + lineWindow) return { matched: false, reason: cweOk || familyOk ? 'outside-range' : 'wrong-location' };
|
|
164
|
+
if (!familyOk && !cweOk) return { matched: false, reason: 'family-mismatch' };
|
|
165
|
+
return { matched: true, reason: 'location-and-family' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Does a finding fall in a negative's scope for the family it is negative for? */
|
|
169
|
+
export function matchNegative(finding, negative) {
|
|
170
|
+
const inScope = (negative?.scope?.files || []).map(norm).includes(norm(finding?.file));
|
|
171
|
+
if (!inScope) return { matched: false, reason: 'out-of-scope' };
|
|
172
|
+
if (!finding?.family || finding.family !== negative.family) return { matched: false, reason: 'other-family' };
|
|
173
|
+
return { matched: true, reason: 'family-in-negative-scope' };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Findings on a target that no adjudicated label accounts for. These are NOT
|
|
178
|
+
* false positives: a genuine, previously unlabelled defect would be hiding
|
|
179
|
+
* among them. They go to a review queue and stay out of precision.
|
|
180
|
+
*/
|
|
181
|
+
export function reviewQueue(entries) {
|
|
182
|
+
return entries.map((e) => ({ targetId: e.targetId, variant: e.variant, findingId: e.finding?.id || null, file: e.finding?.file || null, line: e.finding?.line ?? null, family: e.finding?.family || null, status: 'needs-adjudication', reason: e.reason }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Counts of scoreable, real (non-synthetic) labels by language and family, for population gates. */
|
|
186
|
+
export function populationCounts(defects, negatives) {
|
|
187
|
+
const out = { positivesByLanguage: {}, negativesByLanguage: {}, positivesByFamily: {}, syntheticPositives: 0, syntheticNegatives: 0, unadjudicated: 0 };
|
|
188
|
+
for (const l of defects || []) {
|
|
189
|
+
if (!isScoreable(l)) { out.unadjudicated++; continue; }
|
|
190
|
+
if (l.synthetic) { out.syntheticPositives++; continue; }
|
|
191
|
+
out.positivesByLanguage[l.language] = (out.positivesByLanguage[l.language] || 0) + 1;
|
|
192
|
+
out.positivesByFamily[l.family] = (out.positivesByFamily[l.family] || 0) + 1;
|
|
193
|
+
}
|
|
194
|
+
for (const n of negatives || []) {
|
|
195
|
+
if (!isScoreable(n)) { out.unadjudicated++; continue; }
|
|
196
|
+
if (n.synthetic) { out.syntheticNegatives++; continue; }
|
|
197
|
+
out.negativesByLanguage[n.language] = (out.negativesByLanguage[n.language] || 0) + 1;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|