@clear-capabilities/agentic-security-scanner 0.161.0 → 0.163.0
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 +46 -0
- package/bin/agentic-security.js +98 -2
- 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 +59 -8
- 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 +38 -2
- package/dist/8798.index.js +1 -1
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +2336 -27
- package/dist/957.index.js +1 -1
- package/dist/{1905.index.js → 9999.index.js} +306 -13
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +39 -7
- 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 +29 -0
- package/src/dataflow/catalog.js +21 -0
- package/src/dataflow/engine.js +20 -0
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +18 -0
- package/src/discovery/index.js +43 -1
- package/src/discovery/lenses.js +18 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +80 -15
- package/src/fix/apply-fix-service.js +33 -3
- 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 +14 -3
- 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 +30 -5
- package/src/posture/CLAUDE.md +505 -0
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- 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/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- 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 +531 -0
- package/src/posture/oracles/conformance.js +276 -0
- package/src/posture/oracles/oracle.js +453 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- 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 +250 -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/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +226 -0
- package/src/posture/verification/patch-promotion.js +113 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +100 -1
- package/src/sandbox/CLAUDE.md +51 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +151 -34
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// End-to-end scoring of an evaluation run (QA-003.AC03).
|
|
2
|
+
//
|
|
3
|
+
// The rule that makes this a measurement and not a flattering one: a known
|
|
4
|
+
// positive that sat behind a timeout, an error, an unavailable tree or a
|
|
5
|
+
// quarantine is a MISS. The end-to-end metrics keep every such case in the
|
|
6
|
+
// denominator. A second set, "conditional on completion", is reported beside
|
|
7
|
+
// them and is labelled as such; it never replaces them.
|
|
8
|
+
//
|
|
9
|
+
// What is NOT scored, and is counted with its reason instead of vanishing:
|
|
10
|
+
// - targets with no adjudicated label (unlabeled-target)
|
|
11
|
+
// - labels that are only candidates / disputed (label-not-adjudicated)
|
|
12
|
+
// - findings no adjudicated label accounts for (no-adjudicated-label), which
|
|
13
|
+
// go to a review queue: they may be genuine, so they are never auto-FPs.
|
|
14
|
+
//
|
|
15
|
+
// False positives are counted ONLY on adjudicated negatives (patched code, safe
|
|
16
|
+
// real code, near-misses), one per ROOT CAUSE: repeated alerts for the same flaw
|
|
17
|
+
// (`clusterAlerts`) score once and are reported in `alertsCollapsed`; the operational
|
|
18
|
+
// alert burden is report.js's job and is never hidden by this. The score also carries
|
|
19
|
+
// `perTarget` tallies, which is what the grouped bootstrap resamples. Pure: no fs, no clock.
|
|
20
|
+
|
|
21
|
+
import { SCHEMA_VERSION } from '../assurance/schema-kit.js';
|
|
22
|
+
import { digestOf } from '../assurance/identity.js';
|
|
23
|
+
import { isScoreable, matchFinding, matchNegative, reviewQueue, populationCounts } from './labels.js';
|
|
24
|
+
|
|
25
|
+
const SCORE_SCHEMA = 'agentic-security/evaluation-score';
|
|
26
|
+
|
|
27
|
+
const rate = (n, d) => (d > 0 ? n / d : null);
|
|
28
|
+
|
|
29
|
+
function countsOf(tp, fn, fp, tn) {
|
|
30
|
+
const precision = rate(tp, tp + fp);
|
|
31
|
+
const recall = rate(tp, tp + fn);
|
|
32
|
+
const f1 = tp + fp + fn === 0 ? null : (2 * tp) / (2 * tp + fp + fn);
|
|
33
|
+
return { tp, fn, fp, tn, precision, recall, f1 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function bump(map, key, field) {
|
|
37
|
+
if (!map[key]) map[key] = { tp: 0, fn: 0, fp: 0, tn: 0 };
|
|
38
|
+
map[key][field]++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const finalize = (m) => Object.fromEntries(Object.keys(m).sort().map((k) => [k, countsOf(m[k].tp, m[k].fn, m[k].fp, m[k].tn)]));
|
|
42
|
+
|
|
43
|
+
/** Micro (pooled counts) and macro (mean of per-group F1 over groups where F1 is defined). */
|
|
44
|
+
export function aggregate(byGroup) {
|
|
45
|
+
const rows = Object.values(byGroup);
|
|
46
|
+
const sum = rows.reduce((s, r) => ({ tp: s.tp + r.tp, fn: s.fn + r.fn, fp: s.fp + r.fp, tn: s.tn + r.tn }), { tp: 0, fn: 0, fp: 0, tn: 0 });
|
|
47
|
+
const defined = rows.filter((r) => r.f1 !== null);
|
|
48
|
+
return { micro: countsOf(sum.tp, sum.fn, sum.fp, sum.tn), macroF1: defined.length ? defined.reduce((s, r) => s + r.f1, 0) / defined.length : null, groups: rows.length };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const findingKey = (f) => `${f.file}|${f.line}|${f.family}|${f.cwe}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Collapse repeated alerts to root causes (QA-004.AC02). Findings in the same file and family whose lines chain within
|
|
55
|
+
* `lineWindow` of one another are one root cause: the same sink reported by two detectors, or a single flaw reported on its
|
|
56
|
+
* source line and its sink line, is one defect, not several. A finding with no line cannot be placed and stays alone. Returns
|
|
57
|
+
* clusters in a stable order; each carries every member so the operational alert count is still available.
|
|
58
|
+
*/
|
|
59
|
+
export function clusterAlerts(findings, lineWindow = 3) {
|
|
60
|
+
const sorted = [...findings].sort((a, b) => String(a.file).localeCompare(String(b.file)) || String(a.family).localeCompare(String(b.family)) || (a.line ?? -1) - (b.line ?? -1));
|
|
61
|
+
const clusters = [];
|
|
62
|
+
for (const f of sorted) {
|
|
63
|
+
const last = clusters[clusters.length - 1];
|
|
64
|
+
const prev = last && last.members[last.members.length - 1];
|
|
65
|
+
const joins = prev && prev.file === f.file && prev.family === f.family && Number.isInteger(prev.line) && Number.isInteger(f.line) && f.line - prev.line <= lineWindow;
|
|
66
|
+
if (joins) last.members.push(f); else clusters.push({ file: f.file, family: f.family, line: f.line, members: [f] });
|
|
67
|
+
}
|
|
68
|
+
return clusters;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} o
|
|
73
|
+
* @param {object} o.run a run record (runner.js)
|
|
74
|
+
* @param {object} o.protocol the protocol the run is bound to (matching policy comes from it)
|
|
75
|
+
* @param {object[]} o.defects defect labels
|
|
76
|
+
* @param {object[]} o.negatives negative labels
|
|
77
|
+
*/
|
|
78
|
+
export function scoreRun({ run, protocol, defects = [], negatives = [] }) {
|
|
79
|
+
const policy = protocol.matching;
|
|
80
|
+
const outcomeOf = new Map(run.outcomes.map((o) => [`${o.targetId}|${o.variant}`, o]));
|
|
81
|
+
const inRun = new Set(run.outcomes.map((o) => o.targetId));
|
|
82
|
+
|
|
83
|
+
const e2e = { lang: {}, family: {} }; const cond = { lang: {}, family: {} };
|
|
84
|
+
const misses = []; const falsePositives = []; const negativeFailures = []; const matchedFinding = new Set();
|
|
85
|
+
const unscored = { unlabeledTargets: [], nonAdjudicatedLabels: 0, unlabeledFindings: { count: 0, byReason: {} }, positivesOutsideRun: 0 };
|
|
86
|
+
const queue = [];
|
|
87
|
+
|
|
88
|
+
const labelledTargets = new Set();
|
|
89
|
+
// Per-target tallies (end to end), so an interval can resample whole targets/groups instead of pretending cases are independent.
|
|
90
|
+
const perTarget = {};
|
|
91
|
+
const tally = (targetId, language, field, n = 1) => {
|
|
92
|
+
if (!perTarget[targetId]) perTarget[targetId] = { language, tp: 0, fn: 0, fp: 0, tn: 0 };
|
|
93
|
+
perTarget[targetId][field] += n;
|
|
94
|
+
};
|
|
95
|
+
const scoreable = (l) => {
|
|
96
|
+
if (isScoreable(l)) return true;
|
|
97
|
+
unscored.nonAdjudicatedLabels++;
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const l of defects.filter(scoreable)) {
|
|
102
|
+
if (!inRun.has(l.targetId)) { unscored.positivesOutsideRun++; continue; }
|
|
103
|
+
labelledTargets.add(l.targetId);
|
|
104
|
+
const o = outcomeOf.get(`${l.targetId}|pre`);
|
|
105
|
+
const completed = o?.status === 'completed';
|
|
106
|
+
const hit = completed ? o.findings.find((f) => matchFinding(f, l, policy).matched) : null;
|
|
107
|
+
if (hit) {
|
|
108
|
+
matchedFinding.add(`${l.targetId}|pre|${findingKey(hit)}`);
|
|
109
|
+
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
|
+
tally(l.targetId, l.language, 'tp');
|
|
111
|
+
} else {
|
|
112
|
+
const reason = !o ? 'no-outcome' : completed ? 'not-detected' : o.status;
|
|
113
|
+
misses.push({ labelId: l.id, targetId: l.targetId, reason, detail: o?.failureReason || null });
|
|
114
|
+
for (const [m, k] of [[e2e.lang, l.language], [e2e.family, l.family]]) bump(m, k, 'fn');
|
|
115
|
+
tally(l.targetId, l.language, 'fn');
|
|
116
|
+
if (completed) for (const [m, k] of [[cond.lang, l.language], [cond.family, l.family]]) bump(m, k, 'fn');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const n of negatives.filter(scoreable)) {
|
|
121
|
+
if (!inRun.has(n.targetId)) continue;
|
|
122
|
+
labelledTargets.add(n.targetId);
|
|
123
|
+
const o = outcomeOf.get(`${n.targetId}|${n.variant}`);
|
|
124
|
+
if (o?.status !== 'completed') {
|
|
125
|
+
negativeFailures.push({ labelId: n.id, targetId: n.targetId, variant: n.variant, reason: o?.status || 'no-outcome', detail: o?.failureReason || null });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const hits = new Map();
|
|
129
|
+
for (const f of o.findings) {
|
|
130
|
+
if (matchNegative(f, n).matched) hits.set(findingKey(f), f);
|
|
131
|
+
else if ((n.scope.files || []).includes(f.file)) queue.push({ targetId: n.targetId, variant: n.variant, finding: f, reason: 'finding-in-negative-scope-other-family' });
|
|
132
|
+
}
|
|
133
|
+
for (const f of hits.values()) matchedFinding.add(`${n.targetId}|${n.variant}|${findingKey(f)}`);
|
|
134
|
+
if (hits.size) {
|
|
135
|
+
// One false positive per ROOT CAUSE, not per alert: repeated alerts for the same flaw are reported in `alertsCollapsed` and
|
|
136
|
+
// in the operational burden, never silently hidden and never multiplied into the precision denominator.
|
|
137
|
+
const clusters = clusterAlerts([...hits.values()], policy.lineWindow);
|
|
138
|
+
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
|
+
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
|
+
tally(n.targetId, n.language, 'fp', clusters.length);
|
|
141
|
+
} else {
|
|
142
|
+
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
|
+
tally(n.targetId, n.language, 'tn');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Findings no adjudicated label accounts for: unscored, queued for review, never auto-FP.
|
|
148
|
+
for (const o of run.outcomes) {
|
|
149
|
+
if (o.status !== 'completed') continue;
|
|
150
|
+
for (const f of o.findings) {
|
|
151
|
+
if (matchedFinding.has(`${o.targetId}|${o.variant}|${findingKey(f)}`)) continue;
|
|
152
|
+
if (queue.some((q) => q.targetId === o.targetId && q.variant === o.variant && q.finding === f)) continue;
|
|
153
|
+
unscored.unlabeledFindings.count++;
|
|
154
|
+
unscored.unlabeledFindings.byReason['no-adjudicated-label'] = (unscored.unlabeledFindings.byReason['no-adjudicated-label'] || 0) + 1;
|
|
155
|
+
queue.push({ targetId: o.targetId, variant: o.variant, finding: f, reason: 'no-adjudicated-label' });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const id of [...inRun].sort()) if (!labelledTargets.has(id)) unscored.unlabeledTargets.push({ targetId: id, reason: 'unlabeled-target' });
|
|
159
|
+
|
|
160
|
+
const endToEnd = { byLanguage: finalize(e2e.lang), byFamily: finalize(e2e.family) };
|
|
161
|
+
const conditional = { byLanguage: finalize(cond.lang), byFamily: finalize(cond.family) };
|
|
162
|
+
const completed = run.outcomes.filter((o) => o.status === 'completed').length;
|
|
163
|
+
const score = {
|
|
164
|
+
schema: SCORE_SCHEMA, schemaVersion: SCHEMA_VERSION, runId: run.runId, protocolHash: run.protocolHash, split: run.split,
|
|
165
|
+
endToEnd: { ...endToEnd, ...aggregate(endToEnd.byLanguage), label: 'end-to-end: failures on known positives are misses' },
|
|
166
|
+
conditional: { ...conditional, ...aggregate(conditional.byLanguage), label: 'conditional on completion: additional, never a replacement' },
|
|
167
|
+
completion: { outcomes: run.outcomes.length, completed, rate: rate(completed, run.outcomes.length), totals: run.totals },
|
|
168
|
+
misses, falsePositives, negativeFailures, unscored, perTarget,
|
|
169
|
+
reviewQueue: reviewQueue(queue),
|
|
170
|
+
population: populationCounts(defects, negatives),
|
|
171
|
+
};
|
|
172
|
+
score.scoreHash = digestOf({ ...score, reviewQueue: undefined });
|
|
173
|
+
return score;
|
|
174
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A SYNTHETIC evaluation suite, for exercising the machinery only.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is authored by the tooling's developers: the code under
|
|
4
|
+
// `test/fixtures/evaluation-synthetic/`, the "reviewers", the commits and the
|
|
5
|
+
// licence. Every protocol and label it produces carries `synthetic: true`, and
|
|
6
|
+
// gates.js refuses to let a synthetic population satisfy any real-code gate. Do
|
|
7
|
+
// not read a score computed from this suite as an accuracy figure for the
|
|
8
|
+
// engine: three tiny files cannot support one.
|
|
9
|
+
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { digestOf } from '../assurance/identity.js';
|
|
12
|
+
import { digestTree, targetDigestOf } from './runner.js';
|
|
13
|
+
import { freezeProtocol, CORE_LANGUAGES, PREREGISTERED_THRESHOLDS, DEFAULT_MATCHING } from './protocol.js';
|
|
14
|
+
import { buildDefectLabel, buildNegativeLabel } from './labels.js';
|
|
15
|
+
|
|
16
|
+
const SYNTHETIC_LICENSE = 'synthetic-test-fixture';
|
|
17
|
+
const SHA = (c) => c.repeat(40);
|
|
18
|
+
|
|
19
|
+
const TARGETS = [
|
|
20
|
+
{ id: 'syn-sqli-js', language: 'javascript', upstream: 'example.invalid/synthetic/sqli-js', preCommit: SHA('1'), postCommit: SHA('2'), split: 'dev' },
|
|
21
|
+
{ id: 'syn-cmd-py', language: 'python', upstream: 'example.invalid/synthetic/cmd-py', preCommit: SHA('3'), postCommit: SHA('4'), split: 'dev' },
|
|
22
|
+
{ id: 'syn-nearmiss-js', language: 'javascript', upstream: 'example.invalid/synthetic/nearmiss-js', preCommit: SHA('5'), postCommit: null, split: 'sealed' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const reviewers = (tag) => [
|
|
26
|
+
{ reviewerId: `synthetic-reviewer-a-${tag}`, verdict: 'confirmed' },
|
|
27
|
+
{ reviewerId: `synthetic-reviewer-b-${tag}`, verdict: 'confirmed' },
|
|
28
|
+
];
|
|
29
|
+
const adjudication = (tag) => ({ status: 'adjudicated', independent: true, reviewers: reviewers(tag) });
|
|
30
|
+
|
|
31
|
+
/** Directory for one variant of a synthetic target. */
|
|
32
|
+
const syntheticTargetDir = (fixturesDir, id, variant) => path.join(fixturesDir, id, variant);
|
|
33
|
+
|
|
34
|
+
export function buildSyntheticSuite({ fixturesDir, thresholds = { ...PREREGISTERED_THRESHOLDS }, limits } = {}) {
|
|
35
|
+
const targets = TARGETS.map((t) => {
|
|
36
|
+
const pre = digestTree(syntheticTargetDir(fixturesDir, t.id, 'pre'));
|
|
37
|
+
const post = t.postCommit ? digestTree(syntheticTargetDir(fixturesDir, t.id, 'post')) : null;
|
|
38
|
+
const { split, ...rest } = t;
|
|
39
|
+
return { ...rest, pairId: `pair-${t.id}`, advisoryIds: [], license: SYNTHETIC_LICENSE, digest: targetDigestOf({ pre, post }) };
|
|
40
|
+
});
|
|
41
|
+
const splits = { dev: TARGETS.filter((t) => t.split === 'dev').map((t) => t.id).sort(), sealed: TARGETS.filter((t) => t.split === 'sealed').map((t) => t.id).sort() };
|
|
42
|
+
const draft = {
|
|
43
|
+
synthetic: true,
|
|
44
|
+
engine: { version: '0.0.0-synthetic', bundleDigest: digestOf('synthetic-engine') },
|
|
45
|
+
measurement: { commit: SHA('b'), cleanTree: true },
|
|
46
|
+
tools: { node: 'synthetic' },
|
|
47
|
+
models: [],
|
|
48
|
+
datasetLicenses: { [SYNTHETIC_LICENSE]: 'authored for tooling tests; not real-world code' },
|
|
49
|
+
scope: { languages: [...CORE_LANGUAGES], families: ['sql-injection', 'command-injection'] },
|
|
50
|
+
matching: { ...DEFAULT_MATCHING },
|
|
51
|
+
limits: { perTargetTimeoutMs: 120000, spendCeilingUsd: 0, replicates: 3, ...(limits || {}) },
|
|
52
|
+
thresholds,
|
|
53
|
+
targets,
|
|
54
|
+
splits,
|
|
55
|
+
grouping: { method: 'union-find', keys: ['pair', 'upstream', 'advisory', 'commit', 'template'], salt: 'synthetic' },
|
|
56
|
+
};
|
|
57
|
+
const frozen = freezeProtocol(draft);
|
|
58
|
+
|
|
59
|
+
const base = (tag) => ({ synthetic: true, adjudication: adjudication(tag), proposedBy: { role: 'worker', id: 'synthetic-proposer' } });
|
|
60
|
+
const defects = [
|
|
61
|
+
buildDefectLabel({
|
|
62
|
+
...base('d1'), targetId: 'syn-sqli-js', rootCauseId: 'synthetic-root-cause-sqli-js', affected: { commit: SHA('1') },
|
|
63
|
+
language: 'javascript', family: 'sql-injection', cwe: 'CWE-89', location: { file: 'app.js', startLine: 8, endLine: 8 },
|
|
64
|
+
evidence: [{ ref: 'synthetic-review-note-sqli', producer: 'human' }],
|
|
65
|
+
}),
|
|
66
|
+
buildDefectLabel({
|
|
67
|
+
...base('d2'), targetId: 'syn-cmd-py', rootCauseId: 'synthetic-root-cause-cmd-py', affected: { commit: SHA('3') },
|
|
68
|
+
language: 'python', family: 'command-injection', cwe: 'CWE-78', location: { file: 'tool.py', startLine: 11, endLine: 11 },
|
|
69
|
+
evidence: [{ ref: 'synthetic-review-note-cmd', producer: 'human' }],
|
|
70
|
+
}),
|
|
71
|
+
];
|
|
72
|
+
const negatives = [
|
|
73
|
+
buildNegativeLabel({
|
|
74
|
+
...base('n1'), targetId: 'syn-sqli-js', variant: 'post', kind: 'patched', language: 'javascript', family: 'sql-injection',
|
|
75
|
+
scope: { files: ['app.js'] }, pairedDefectId: defects[0].id, rationale: 'synthetic: parameterised query, reviewed as not injectable',
|
|
76
|
+
}),
|
|
77
|
+
buildNegativeLabel({
|
|
78
|
+
...base('n2'), targetId: 'syn-cmd-py', variant: 'post', kind: 'patched', language: 'python', family: 'command-injection',
|
|
79
|
+
scope: { files: ['tool.py'] }, pairedDefectId: defects[1].id, rationale: 'synthetic: argument list and allow-list check, reviewed as not injectable',
|
|
80
|
+
}),
|
|
81
|
+
buildNegativeLabel({
|
|
82
|
+
...base('n3'), targetId: 'syn-nearmiss-js', variant: 'pre', kind: 'near-miss', language: 'javascript', family: 'sql-injection',
|
|
83
|
+
scope: { files: ['report.js'] }, rationale: 'synthetic: query assembled from constants only, reviewed as not injectable',
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
return { protocol: frozen.ok ? frozen.protocol : null, freezeErrors: frozen.errors, draft, defects, negatives, fixturesDir };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** resolveTarget for the runner over the synthetic fixtures. */
|
|
90
|
+
export const syntheticResolver = (fixturesDir) => (target, variant) => ({ dir: syntheticTargetDir(fixturesDir, target.id, variant) });
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Why-missed instrumentation across the analysis pipeline (QA-005.AC01, QA-005.AC03).
|
|
2
|
+
//
|
|
3
|
+
// For a labelled DEVELOPMENT miss, name the EARLIEST stage that lost the defect, or say `unknown` and why. The stages, in the
|
|
4
|
+
// order a defect travels through the engine:
|
|
5
|
+
//
|
|
6
|
+
// scan-execution the scan never completed (timeout, error, unavailable, quarantined): nothing downstream ran
|
|
7
|
+
// parser-ir the file did not lower to an IR function, or the parser failed on it
|
|
8
|
+
// source-modelling no untrusted source was recognised anywhere the flow could start
|
|
9
|
+
// propagation a source and a sink were both recognised near the defect, and no flow joined them
|
|
10
|
+
// sink-modelling no sink (or, for non-flow families, no detector rule) was recognised at the defect
|
|
11
|
+
// filters a candidate existed and a precision filter (guard recognition, safe-shape, sanitized) dropped it
|
|
12
|
+
// suppression a candidate existed and policy dropped it (ignore pragma, custom rule, learned feedback, validator)
|
|
13
|
+
// deduplication a candidate existed and was collapsed into a finding that carries a different location
|
|
14
|
+
// attribution a finding for this defect was REPORTED, at a location or under a family that does not match the label
|
|
15
|
+
//
|
|
16
|
+
// The order of DETERMINATION differs from the order of travel, deliberately: a candidate observed downstream (a dropped finding,
|
|
17
|
+
// a deduplicated loser, a mislocated report) PROVES every earlier stage worked, so those are checked first and name the stage that
|
|
18
|
+
// lost it. Only when no candidate was observed anywhere do the early stages explain the miss, and among them the first failed
|
|
19
|
+
// stage wins. Sources are checked before sinks, and propagation is blamed only when BOTH endpoints were recognised.
|
|
20
|
+
//
|
|
21
|
+
// Honesty rules: evidence is the engine's own diagnostics (a suppression ledger entry carries file, line, id, cwe and family), not
|
|
22
|
+
// a guess from the label; when there is no diagnostic evidence the answer is `unknown`, never a plausible-sounding stage. The
|
|
23
|
+
// instrument only runs over the DEVELOPMENT split: the sealed split's per-case outcomes are not available to detector work.
|
|
24
|
+
|
|
25
|
+
import { stageWorkspace, engineEnvironment } from './custody.js';
|
|
26
|
+
import { defaultScanFn } from './runner.js';
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
|
|
31
|
+
export const STAGES = Object.freeze(['scan-execution', 'parser-ir', 'source-modelling', 'propagation', 'sink-modelling', 'filters', 'suppression', 'deduplication', 'attribution']);
|
|
32
|
+
export const UNKNOWN = 'unknown';
|
|
33
|
+
|
|
34
|
+
// Families whose detection is a source-to-sink flow; the rest (weak crypto, hardcoded secrets, misconfiguration) are pattern rules.
|
|
35
|
+
const FLOW_FAMILIES = new Set([
|
|
36
|
+
'sql-injection', 'nosql-injection', 'command-injection', 'path-traversal', 'xss', 'ssrf', 'deserialization', 'insecure-deserialization',
|
|
37
|
+
'open-redirect', 'code-injection', 'xxe', 'ldap-injection', 'xpath-injection', 'ssti', 'prototype-pollution', 'response-splitting', 'log-injection',
|
|
38
|
+
]);
|
|
39
|
+
const IR_EXT = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|kts|go|php|rb|rs|c|cc|cpp|cxx|h|hpp)$/i;
|
|
40
|
+
|
|
41
|
+
// A ledger reason names its mechanism by prefix. Filters judge a candidate not to be a real flow; suppressions are policy.
|
|
42
|
+
const FILTER_REASON = /^(?:guard-recognized:|sanitized:|universal-|bench-safe-shape|bench-category|primary-cwe|safe-sink-shape|sanitizer-gate)/;
|
|
43
|
+
const SUPPRESSION_REASON = /^(?:inline[- ]pragma|custom-rule|llm-validator|learned|rules-disable|no-vuln-name)/;
|
|
44
|
+
|
|
45
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
46
|
+
|
|
47
|
+
function windowOf(label, policy) {
|
|
48
|
+
const w = Number.isInteger(policy?.lineWindow) ? policy.lineWindow : 3;
|
|
49
|
+
return { lo: label.location.startLine - w, hi: label.location.endLine + w };
|
|
50
|
+
}
|
|
51
|
+
const inWindow = (line, win) => Number.isInteger(line) && line >= win.lo && line <= win.hi;
|
|
52
|
+
|
|
53
|
+
function familyOf(entry) { return entry.family ?? null; }
|
|
54
|
+
function sameKind(entry, label) {
|
|
55
|
+
// A ledger entry or finding agrees with the label by family or CWE. With neither recorded the identity is location-only.
|
|
56
|
+
if (familyOf(entry) && familyOf(entry) === label.family) return 'family';
|
|
57
|
+
if (entry.cwe && label.cwe && String(entry.cwe).toUpperCase() === String(label.cwe).toUpperCase()) return 'cwe';
|
|
58
|
+
if (!familyOf(entry) && !entry.cwe) return 'location-only';
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify ONE labelled miss.
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {object} o.label an adjudicated defect label
|
|
66
|
+
* @param {object|null} o.outcome the run outcome for the label's target (variant 'pre')
|
|
67
|
+
* @param {object|null} o.diagnostics the scan child's diagnostics block for that target, or null
|
|
68
|
+
* @param {object} [o.policy] the protocol's matching policy
|
|
69
|
+
* @param {string} [o.layer] the configuration that produced the outcome
|
|
70
|
+
*/
|
|
71
|
+
export function classifyMiss({ label, outcome, diagnostics, policy, layer = 'deep-taint' }) {
|
|
72
|
+
const base = { labelId: label.id, targetId: label.targetId, language: label.language, family: label.family, location: label.location };
|
|
73
|
+
const done = (stage, reason, evidence = {}) => ({ ...base, stage, reason, evidence, deterministic: true });
|
|
74
|
+
if (!outcome) return done('scan-execution', 'no-outcome-recorded');
|
|
75
|
+
if (outcome.status !== 'completed') return done('scan-execution', `scan-${outcome.status}`, { failureReason: outcome.failureReason || null });
|
|
76
|
+
if (!diagnostics) return done(UNKNOWN, 'no-diagnostic-evidence', { note: 'the target was not re-scanned with diagnostics, so no stage can be named' });
|
|
77
|
+
|
|
78
|
+
const win = windowOf(label, policy);
|
|
79
|
+
const file = norm(label.location.file);
|
|
80
|
+
const here = (e) => norm(e.file) === file;
|
|
81
|
+
|
|
82
|
+
// ---- a candidate was REPORTED but does not match: attribution
|
|
83
|
+
const reported = (outcome.findings || []).filter(here);
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const f of reported) {
|
|
86
|
+
const kind = sameKind(f, label);
|
|
87
|
+
if (!kind || kind === 'location-only') continue;
|
|
88
|
+
const inRange = inWindow(f.line, win);
|
|
89
|
+
if (inRange) continue; // would have matched; not a miss caused by attribution
|
|
90
|
+
if (!Number.isInteger(f.line)) { best = best || { f, distance: Infinity, why: 'finding-has-no-line' }; continue; }
|
|
91
|
+
const distance = f.line < win.lo ? win.lo - f.line : f.line - win.hi;
|
|
92
|
+
if (!best || distance < best.distance) best = { f, distance, why: 'right-file-and-family-wrong-line' };
|
|
93
|
+
}
|
|
94
|
+
if (best) return done('attribution', best.why, { reported: { id: best.f.id ?? null, line: best.f.line ?? null, family: best.f.family ?? null, cwe: best.f.cwe ?? null }, distanceLines: Number.isFinite(best.distance) ? best.distance : null });
|
|
95
|
+
|
|
96
|
+
// ---- a candidate was COLLAPSED into a winner elsewhere: deduplication
|
|
97
|
+
// Only the FINAL winner of a bucket counts: a winner that was itself collapsed into a later one is not where the candidate ended up.
|
|
98
|
+
const allDedupes = diagnostics.stageEvidence?.dedupe || [];
|
|
99
|
+
const laterLosers = new Set(allDedupes.map((d) => d.loser?.id).filter(Boolean));
|
|
100
|
+
const dedupes = allDedupes.filter((d) => !laterLosers.has(d.winner?.id) && norm(d.loser?.file || d.file) === file && inWindow(d.loser?.line, win) && (d.family === label.family || !d.family));
|
|
101
|
+
const dedupeHit = dedupes.find((d) => !(norm(d.winner?.file) === file && inWindow(d.winner?.line, win)));
|
|
102
|
+
if (dedupeHit) return done('deduplication', 'collapsed-into-winner-at-other-location', { loser: dedupeHit.loser, winner: dedupeHit.winner });
|
|
103
|
+
|
|
104
|
+
// ---- a candidate was DROPPED with a recorded reason: filters or suppression
|
|
105
|
+
const dropped = (diagnostics.suppressions || [])
|
|
106
|
+
.filter((s) => here(s) && inWindow(s.line, win) && sameKind(s, label))
|
|
107
|
+
.sort((a, b) => String(a.reason).localeCompare(String(b.reason)) || (a.line ?? 0) - (b.line ?? 0));
|
|
108
|
+
if (dropped.length) {
|
|
109
|
+
const d = dropped[0];
|
|
110
|
+
const stage = SUPPRESSION_REASON.test(d.reason) ? 'suppression' : FILTER_REASON.test(d.reason) ? 'filters' : 'filters';
|
|
111
|
+
return done(stage, d.reason.split(':').slice(0, 2).join(':'), { ledger: { id: d.id ?? null, line: d.line, family: d.family ?? null, cwe: d.cwe ?? null, reason: d.reason, identity: sameKind(d, label) }, alsoDropped: dropped.length - 1 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- a finding of ANOTHER family sits at the defect's location and no candidate of this family was observed anywhere: it was reported under the wrong family
|
|
115
|
+
const wrongFamily = reported.find((f) => inWindow(f.line, win) && !sameKind(f, label));
|
|
116
|
+
if (wrongFamily) return done('attribution', 'right-location-wrong-family', { reported: { id: wrongFamily.id ?? null, line: wrongFamily.line, family: wrongFamily.family ?? null, cwe: wrongFamily.cwe ?? null } });
|
|
117
|
+
|
|
118
|
+
// ---- nothing observed anywhere: the early stages explain it
|
|
119
|
+
const flow = FLOW_FAMILIES.has(label.family) && layer !== 'deterministic-only';
|
|
120
|
+
if (!flow) {
|
|
121
|
+
return done('sink-modelling', layer === 'deterministic-only' && FLOW_FAMILIES.has(label.family) ? 'no-pattern-rule-fired-and-taint-layer-not-run' : 'no-detector-rule-fired', { family: label.family });
|
|
122
|
+
}
|
|
123
|
+
const ir = diagnostics.ir || {};
|
|
124
|
+
const entry = ir.files ? ir.files[label.location.file] ?? ir.files[file] : undefined;
|
|
125
|
+
const ext = (/\.[A-Za-z0-9]+$/.exec(file) || [''])[0].replace('.', '').toLowerCase();
|
|
126
|
+
if (IR_EXT.test(file)) {
|
|
127
|
+
if (ir.parseFailures?.byLanguage && ir.parseFailures.byLanguage[ext] > 0) return done('parser-ir', 'ir-parse-failure', { parseFailures: ir.parseFailures.byLanguage[ext], firstError: ir.parseFailures.firstError ?? null });
|
|
128
|
+
if (entry && (!entry.lowered || (entry.functions || []).length === 0)) return done('parser-ir', 'no-function-lowered', { file: label.location.file });
|
|
129
|
+
}
|
|
130
|
+
const sources = (diagnostics.sources || []);
|
|
131
|
+
const sinks = (diagnostics.sinks || []);
|
|
132
|
+
const sourcesInFile = sources.filter(here);
|
|
133
|
+
if (sourcesInFile.length === 0 && sources.length === 0) return done('source-modelling', 'no-source-recognized', { sourcesRecognized: 0 });
|
|
134
|
+
if (sourcesInFile.length === 0) return done('source-modelling', 'no-source-in-defect-file', { sourcesRecognizedElsewhere: sources.length });
|
|
135
|
+
const wide = { lo: win.lo - 10, hi: win.hi + 10 };
|
|
136
|
+
const sinkNear = sinks.some((s) => here(s) && inWindow(s.line, wide));
|
|
137
|
+
if (!sinkNear) return done('sink-modelling', 'no-sink-recognized-near-defect', { sourcesInFile: sourcesInFile.length });
|
|
138
|
+
return done('propagation', 'source-and-sink-recognized-no-flow', { sourcesInFile: sourcesInFile.length });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Classify every miss in a score. `diagnosticsFor(targetId)` returns that target's diagnostics or null. */
|
|
142
|
+
export function classifyMisses({ score, defects, run, diagnosticsFor, policy, layer }) {
|
|
143
|
+
const byId = new Map(defects.map((d) => [d.id, d]));
|
|
144
|
+
const outcomeOf = new Map((run?.outcomes || []).map((o) => [`${o.targetId}|${o.variant}`, o]));
|
|
145
|
+
const classified = [];
|
|
146
|
+
for (const miss of score.misses || []) {
|
|
147
|
+
const label = byId.get(miss.labelId);
|
|
148
|
+
if (!label) { classified.push({ labelId: miss.labelId, targetId: miss.targetId, stage: UNKNOWN, reason: 'label-not-supplied', evidence: {}, deterministic: true }); continue; }
|
|
149
|
+
classified.push(classifyMiss({ label, outcome: outcomeOf.get(`${miss.targetId}|pre`) || null, diagnostics: diagnosticsFor ? diagnosticsFor(miss.targetId) : null, policy, layer }));
|
|
150
|
+
}
|
|
151
|
+
classified.sort((a, b) => String(a.labelId).localeCompare(String(b.labelId)));
|
|
152
|
+
return classified;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function stageCounts(classified) {
|
|
156
|
+
const byStage = Object.fromEntries([...STAGES, UNKNOWN].map((s) => [s, 0]));
|
|
157
|
+
const byReason = {};
|
|
158
|
+
for (const c of classified) {
|
|
159
|
+
byStage[c.stage] = (byStage[c.stage] || 0) + 1;
|
|
160
|
+
byReason[`${c.stage}:${c.reason}`] = (byReason[`${c.stage}:${c.reason}`] || 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
return { total: classified.length, byStage, byReason };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Before/after view of a fix (QA-005.AC03). Recovered defects are labels that were misses before and are not after; each is
|
|
167
|
+
* listed with the stage that lost it and the finding that now matches. New false positives are named, never netted away.
|
|
168
|
+
*/
|
|
169
|
+
export function compareMisses({ before, after, scoreBefore, scoreAfter }) {
|
|
170
|
+
const afterMissed = new Set(after.map((c) => c.labelId));
|
|
171
|
+
const recovered = before.filter((c) => !afterMissed.has(c.labelId));
|
|
172
|
+
const fpKey = (x) => `${x.targetId}|${x.finding?.file}|${x.finding?.family}|${x.finding?.line}`;
|
|
173
|
+
const fpBefore = new Set((scoreBefore.falsePositives || []).map(fpKey));
|
|
174
|
+
const newFalsePositives = (scoreAfter.falsePositives || []).filter((x) => !fpBefore.has(fpKey(x)));
|
|
175
|
+
const verdict = recovered.length === 0 ? 'no-recovery' : newFalsePositives.length ? 'recovered-with-new-false-positives' : 'recovered-without-new-false-positives';
|
|
176
|
+
return {
|
|
177
|
+
verdict,
|
|
178
|
+
stageCounts: { before: stageCounts(before), after: stageCounts(after) },
|
|
179
|
+
recovered: recovered.map((c) => ({ labelId: c.labelId, targetId: c.targetId, lostAtStage: c.stage, reason: c.reason })),
|
|
180
|
+
falsePositives: { before: (scoreBefore.falsePositives || []).length, after: (scoreAfter.falsePositives || []).length, newFalsePositives },
|
|
181
|
+
precisionNote: 'a recovery that adds a false positive is reported as such and does not count as a clean improvement',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-scan the DEVELOPMENT targets that have a miss, with diagnostics on, and return `Map(targetId -> diagnostics)`.
|
|
187
|
+
* The sealed split is refused outright: its per-case outcomes are not material for detector work.
|
|
188
|
+
*/
|
|
189
|
+
export async function collectDiagnostics({ protocol, score, defects, resolveTarget, protectedTerms = [], scanFn = defaultScanFn, layer = 'deep-taint', timeoutMs }) {
|
|
190
|
+
const sealed = new Set(protocol.splits.sealed);
|
|
191
|
+
const missTargets = [...new Set((score.misses || []).map((m) => m.targetId))];
|
|
192
|
+
const refused = missTargets.filter((t) => sealed.has(t));
|
|
193
|
+
if (refused.length) return { ok: false, errors: [{ code: 'SEALED_ACCESS', path: 'score.misses', message: `why-missed analysis is for development misses; ${refused.length} sealed target(s) refused` }], diagnostics: new Map() };
|
|
194
|
+
const byTarget = new Map();
|
|
195
|
+
for (const d of defects) { if (!byTarget.has(d.targetId)) byTarget.set(d.targetId, []); byTarget.get(d.targetId).push(d); }
|
|
196
|
+
const out = new Map();
|
|
197
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'eval-diag-'));
|
|
198
|
+
try {
|
|
199
|
+
for (const targetId of missTargets.sort()) {
|
|
200
|
+
const target = protocol.targets.find((t) => t.id === targetId);
|
|
201
|
+
if (!target) continue;
|
|
202
|
+
const r = await resolveTarget(target, 'pre');
|
|
203
|
+
if (!r?.dir) continue;
|
|
204
|
+
const staged = stageWorkspace({ srcDir: r.dir, destDir: path.join(root, targetId.replace(/[^\w.-]/g, '_')), terms: protectedTerms });
|
|
205
|
+
if (!staged.ok) continue;
|
|
206
|
+
try {
|
|
207
|
+
const files = [...new Set((byTarget.get(targetId) || []).map((d) => d.location.file))];
|
|
208
|
+
const env = engineEnvironment({ PATH: process.env.PATH, LANG: 'C', HOME: root, AGENTIC_SECURITY_DIAG_FILES: JSON.stringify(files) });
|
|
209
|
+
const res = await scanFn(staged.destDir, { layer, timeoutMs: timeoutMs || protocol.limits.perTargetTimeoutMs, env, diagnose: true });
|
|
210
|
+
if (res?.diagnostics) out.set(targetId, res.diagnostics);
|
|
211
|
+
} catch { /* a target that cannot be diagnosed stays `unknown`, which is the honest answer */ } finally {
|
|
212
|
+
fs.rmSync(staged.destDir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, errors: [], diagnostics: out };
|
|
219
|
+
}
|
|
@@ -47,6 +47,61 @@ import * as crypto from 'node:crypto';
|
|
|
47
47
|
|
|
48
48
|
export const BUNDLE_SCHEMA = 'agentic-security/finding-evidence@1';
|
|
49
49
|
|
|
50
|
+
// ---- issuer and trust basis (CORE-003.AC03) --------------------------------
|
|
51
|
+
//
|
|
52
|
+
// A signature says WHO signed and nothing about whether anyone independent
|
|
53
|
+
// vouches for them. This install generates its own key, so every bundle it
|
|
54
|
+
// signs is SELF-ISSUED. The `issuance` block says so inside the signed bytes,
|
|
55
|
+
// so a summariser cannot drop it, and the verifier reports it back. The only
|
|
56
|
+
// trust basis this build can verify is the local one; a third-party basis needs
|
|
57
|
+
// a trust-root policy that does not exist here, so an unknown basis is
|
|
58
|
+
// REJECTED rather than being displayed as if it were certification.
|
|
59
|
+
//
|
|
60
|
+
// Backward compatible: `issuance` is absent from bundles signed before this
|
|
61
|
+
// field existed. Those still verify exactly as before and are reported as
|
|
62
|
+
// having NO DECLARED trust basis (not as trusted, not as invalid).
|
|
63
|
+
export const TRUST_BASES = Object.freeze({
|
|
64
|
+
'self-issued-local-key': Object.freeze({
|
|
65
|
+
independentlyCertified: false,
|
|
66
|
+
statement:
|
|
67
|
+
'Signed by a key this install generated for itself. It proves the bundle is unmodified since ' +
|
|
68
|
+
'this key signed it. It is NOT independent third-party certification: whoever controls this ' +
|
|
69
|
+
'install controls the key.',
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** SHA-256 of the SPKI DER of a public key: the stable identity of an issuer. */
|
|
74
|
+
export function keyFingerprint(publicKeyPem) {
|
|
75
|
+
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
76
|
+
return crypto.createHash('sha256').update(der).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Issuance block for a locally generated key. */
|
|
80
|
+
function buildLocalIssuance(publicKeyPem) {
|
|
81
|
+
const fp = keyFingerprint(publicKeyPem);
|
|
82
|
+
return {
|
|
83
|
+
issuer: { id: `local-install:${fp.slice(0, 16)}`, kind: 'local-install', keyFingerprint: fp },
|
|
84
|
+
trustBasis: 'self-issued-local-key',
|
|
85
|
+
independentlyCertified: false,
|
|
86
|
+
statement: TRUST_BASES['self-issued-local-key'].statement,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Group/world access to key material is a defect, reported by path. */
|
|
91
|
+
export function keyPermissionIssues(dir = keyDir()) {
|
|
92
|
+
const p = keyPaths(dir);
|
|
93
|
+
const issues = [];
|
|
94
|
+
try {
|
|
95
|
+
const d = fs.statSync(dir).mode & 0o077;
|
|
96
|
+
if (d) issues.push(`${dir} is accessible to group/other (mode ${(fs.statSync(dir).mode & 0o777).toString(8)})`);
|
|
97
|
+
} catch { /* absent: nothing to report */ }
|
|
98
|
+
try {
|
|
99
|
+
const m = fs.statSync(p.privateKey).mode & 0o777;
|
|
100
|
+
if (m & 0o077) issues.push(`${p.privateKey} is accessible to group/other (mode ${m.toString(8)})`);
|
|
101
|
+
} catch { /* absent */ }
|
|
102
|
+
return issues;
|
|
103
|
+
}
|
|
104
|
+
|
|
50
105
|
/** Where the Ed25519 signing key lives. Mirrors integrity.js's key handling. */
|
|
51
106
|
function keyDir() {
|
|
52
107
|
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
@@ -142,6 +197,9 @@ export function canonicalBytes(bundle) {
|
|
|
142
197
|
engine: bundle.engine,
|
|
143
198
|
proves: bundle.proves,
|
|
144
199
|
doesNotProve: bundle.doesNotProve,
|
|
200
|
+
// Only when present, so the canonical bytes (and signatures) of every
|
|
201
|
+
// bundle signed before this field existed are unchanged.
|
|
202
|
+
...(bundle.issuance !== undefined ? { issuance: bundle.issuance } : {}),
|
|
145
203
|
};
|
|
146
204
|
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
147
205
|
}
|
|
@@ -226,11 +284,17 @@ export function buildEvidenceBundle(finding, { engineVersion, rulesetVersion, bu
|
|
|
226
284
|
};
|
|
227
285
|
}
|
|
228
286
|
|
|
229
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Sign a bundle. Returns a new object; the input is not mutated. The issuer and
|
|
289
|
+
* trust basis are derived from the signing key and signed with the bundle; a
|
|
290
|
+
* caller cannot supply a stronger basis than the one this build can verify.
|
|
291
|
+
*/
|
|
230
292
|
export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
231
|
-
const
|
|
293
|
+
const publicKeyPem = crypto.createPublicKey(privateKeyPem).export({ type: 'spki', format: 'pem' });
|
|
294
|
+
const withIssuance = { ...bundle, issuance: buildLocalIssuance(publicKeyPem) };
|
|
295
|
+
const sig = crypto.sign(null, canonicalBytes(withIssuance), privateKeyPem);
|
|
232
296
|
return {
|
|
233
|
-
...
|
|
297
|
+
...withIssuance,
|
|
234
298
|
signature: {
|
|
235
299
|
algorithm: 'ed25519',
|
|
236
300
|
canonicalisation: BUNDLE_SCHEMA,
|
|
@@ -256,7 +320,7 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
|
256
320
|
// what buildEvidenceBundle+signEvidenceBundle actually produce, or a
|
|
257
321
|
// legitimate bundle would start failing verification.
|
|
258
322
|
const BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
259
|
-
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
323
|
+
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'issuance', 'signature',
|
|
260
324
|
]);
|
|
261
325
|
|
|
262
326
|
export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
@@ -276,7 +340,29 @@ export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
|
276
340
|
} catch (e) {
|
|
277
341
|
return { ok: false, reason: `verification error: ${e.message}` };
|
|
278
342
|
}
|
|
279
|
-
return ok
|
|
280
|
-
|
|
281
|
-
|
|
343
|
+
if (!ok) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
344
|
+
|
|
345
|
+
// Trust basis. Checked AFTER the signature so the issuance block is already
|
|
346
|
+
// known to be unmodified.
|
|
347
|
+
if (bundle.issuance === undefined) {
|
|
348
|
+
return {
|
|
349
|
+
ok: true, reason: null, trustBasis: 'none-declared', independentlyCertified: false, issuer: null,
|
|
350
|
+
trustNote: 'This bundle predates issuer declarations: no trust basis is declared. Treat it as unattested provenance, not as certification.',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const iss = bundle.issuance;
|
|
354
|
+
const basis = iss && typeof iss === 'object' ? TRUST_BASES[iss.trustBasis] : undefined;
|
|
355
|
+
if (!basis) return { ok: false, reason: `unsupported trust basis: ${JSON.stringify(iss?.trustBasis ?? null)}` };
|
|
356
|
+
if (iss.independentlyCertified !== false) {
|
|
357
|
+
return { ok: false, reason: 'a self-issued bundle may not claim independent certification' };
|
|
358
|
+
}
|
|
359
|
+
let fp;
|
|
360
|
+
try { fp = keyFingerprint(publicKeyPem); } catch (e) { return { ok: false, reason: `verification error: ${e.message}` }; }
|
|
361
|
+
if (iss.issuer?.keyFingerprint !== fp) {
|
|
362
|
+
return { ok: false, reason: 'the declared issuer key does not match the key that verified the signature' };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
ok: true, reason: null, trustBasis: iss.trustBasis, independentlyCertified: false,
|
|
366
|
+
issuer: iss.issuer, trustNote: basis.statement,
|
|
367
|
+
};
|
|
282
368
|
}
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { runConfined, sandboxAvailable, detectBackend } from '../sandbox/index.js';
|
|
10
10
|
import { attachProofTier, proofTierOf } from './proof-tier.js';
|
|
11
|
+
import { emitVerification } from './verification/emit.js';
|
|
11
12
|
|
|
12
13
|
const PROOF_MARKER = 'PROVEN';
|
|
13
14
|
|
|
@@ -75,7 +76,16 @@ export const DEFAULT_PROOF_TIMEOUT_MS =
|
|
|
75
76
|
? Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS)
|
|
76
77
|
: 45000;
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
// X-201: the proof result also carries the one verification record, ADDITIVELY (`proofTier` and `proofEvidence` are
|
|
80
|
+
// unchanged). A PoC run under this older sandbox path is not a trusted runner, so the record says `inconclusive` for a
|
|
81
|
+
// run that demonstrated the effect and for one that did not; `confirmed` takes a trusted oracle adapter.
|
|
82
|
+
export async function proveFinding(finding, opts = {}) {
|
|
83
|
+
const proved = await _proveFinding(finding, opts);
|
|
84
|
+
const emitted = emitVerification('execution-proof', proved, { finding: proved, commit: opts.commit ?? null });
|
|
85
|
+
return emitted.ok ? { ...proved, verificationRecord: emitted.record } : proved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function _proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
|
|
79
89
|
const poc = finding?.poc;
|
|
80
90
|
if (!poc?.code) {
|
|
81
91
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|