@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,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
|
+
}
|
package/src/posture/fleet.js
CHANGED
|
@@ -167,6 +167,10 @@ function _provenanceDebtForRepo(findings, now = Date.now()) {
|
|
|
167
167
|
export async function runFleet({
|
|
168
168
|
repos = [], concurrency = 4, stateFile, runScan,
|
|
169
169
|
resume = true, previous = null, onProgress = () => {},
|
|
170
|
+
// X-704: repositories whose every durable portfolio work unit is verified and current
|
|
171
|
+
// (portfolio/work-units.js `fullyVerifiedRepositories`). They are skipped like a resumed completed repo,
|
|
172
|
+
// and, as with a resumed repo, the skip is reported in `skipped` rather than counted as scanned.
|
|
173
|
+
portfolioVerified = [],
|
|
170
174
|
} = {}) {
|
|
171
175
|
if (typeof runScan !== 'function') {
|
|
172
176
|
return { ok: false, reason: 'no runScan supplied' };
|
|
@@ -176,7 +180,7 @@ export async function runFleet({
|
|
|
176
180
|
const skipped = [];
|
|
177
181
|
|
|
178
182
|
const queue = repos.filter((r) => {
|
|
179
|
-
if (resume && state.completed[r]) { skipped.push(r); return false; }
|
|
183
|
+
if ((resume && state.completed[r]) || portfolioVerified.includes(r)) { skipped.push(r); return false; }
|
|
180
184
|
return true;
|
|
181
185
|
});
|
|
182
186
|
|
|
@@ -408,8 +412,13 @@ export function renderFleetSummary(rollup) {
|
|
|
408
412
|
if (prov.reposWithNoProvenDebt.length) bits.push(`${prov.reposWithNoProvenDebt.length} repo(s) with no complete-status provenance`);
|
|
409
413
|
provClause = ` PROVENANCE: ${bits.join('; ')}.`;
|
|
410
414
|
}
|
|
415
|
+
// X-707: portfolio progress is its own clause, present only when the `portfolio-assurance` feature attached it
|
|
416
|
+
// (portfolio/progress.js `attachPortfolioProgress`), so flag-off output is byte-identical. It states verified units and
|
|
417
|
+
// repositories separately from the scan counts above: a finished controller is not a passing portfolio.
|
|
418
|
+
const pp = rollup.portfolioProgress;
|
|
419
|
+
const portfolioClause = pp ? ` PORTFOLIO: ${pp.units.verified}/${pp.units.total} unit(s) verified, ${pp.coverage.fullyVerified}/${pp.coverage.total} repo(s) fully verified, ${pp.review.pending} pending human review; ${pp.completion.statement}` : '';
|
|
411
420
|
return `${head}${rollup.scanned} repo(s) scanned, ${rollup.total} finding(s)`
|
|
412
|
-
+ `${sev ? ` (${sev})` : ''}, ${rollup.proven} execution-proven.${nf}${govClause}${provClause}`;
|
|
421
|
+
+ `${sev ? ` (${sev})` : ''}, ${rollup.proven} execution-proven.${nf}${govClause}${provClause}${portfolioClause}`;
|
|
413
422
|
}
|
|
414
423
|
|
|
415
424
|
/**
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `agentic-security invariants <export|coverage|regress>` (X-405 to X-407 CLI surface).
|
|
2
|
+
//
|
|
3
|
+
// invariants export --invariant <file> --fixture <dir> [--ledger <file>] [--commit <sha>] [--seed <n>] [--output <file>] [--json]
|
|
4
|
+
// The reproducible scenario package for one contract (see export.js). Builds nothing and runs nothing when the
|
|
5
|
+
// `invariant-scenarios` feature is off. Exit 0 ok, 1 refused (disabled, rejected, unsupported, blocked), 2 usage.
|
|
6
|
+
// invariants coverage --invariant <file> | --invariants-dir <dir> [--ledger <file>] [--results <file>] [--json]
|
|
7
|
+
// The bounded business-coverage report (see coverage.js). `--results` is a JSON array of `{ invariantId, result }` where
|
|
8
|
+
// `result` is what `verifyInvariant` returned; without it the report is the static inventory with every contract's
|
|
9
|
+
// transitions listed as untested, which is the honest answer when nothing was run. Exit 0, 1 refused, 2 usage.
|
|
10
|
+
// invariants regress <artifact.json>
|
|
11
|
+
// Re-execute a regression artifact from the file alone (see repair.js). Exit 0 every leg settled as recorded, 1 a leg did
|
|
12
|
+
// not (or the artifact is invalid), 3 not run (a prerequisite is unmet or the feature is off): not-run is not a pass.
|
|
13
|
+
//
|
|
14
|
+
// The logic lives here, not in bin/, so the same functions are exercised by tests without spawning the CLI; bin/ only
|
|
15
|
+
// dispatches. Paths are the operator's own and are resolved against the working directory (the MCP tool, which takes paths from
|
|
16
|
+
// an agent, confines them to its session root instead).
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { resolveAssuranceConfig } from '../assurance/config.js';
|
|
20
|
+
import { exportFromFiles } from './export.js';
|
|
21
|
+
import { businessCoverage } from './coverage.js';
|
|
22
|
+
import { runRegressionArtifact } from './repair.js';
|
|
23
|
+
import { readJsonFile } from './project-input.js';
|
|
24
|
+
|
|
25
|
+
const USAGE = [
|
|
26
|
+
'Usage: agentic-security invariants export --invariant <file> --fixture <dir> [--ledger <file>] [--commit <sha>] [--seed <n>] [--output <file>] [--json]',
|
|
27
|
+
' agentic-security invariants coverage --invariant <file> | --invariants-dir <dir> [--ledger <file>] [--results <file>] [--json]',
|
|
28
|
+
' agentic-security invariants regress <artifact.json>',
|
|
29
|
+
].join('\n');
|
|
30
|
+
|
|
31
|
+
const abs = (cwd, p) => path.resolve(cwd, String(p));
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ _: string[], flags: object }} args parsed arguments; `_[0]` is `invariants`, `_[1]` the sub-command
|
|
35
|
+
* @param {{ cwd?: string, out?: (s: string) => void, err?: (s: string) => void, env?: object }} [io]
|
|
36
|
+
* @returns {Promise<number>} the exit code
|
|
37
|
+
*/
|
|
38
|
+
export async function runInvariantsCommand(args, io = {}) {
|
|
39
|
+
const cwd = io.cwd || process.cwd();
|
|
40
|
+
const out = io.out || ((s) => process.stdout.write(s));
|
|
41
|
+
const err = io.err || ((s) => process.stderr.write(s));
|
|
42
|
+
const sub = args._[1];
|
|
43
|
+
const flags = args.flags || {};
|
|
44
|
+
const config = resolveAssuranceConfig({ scanRoot: cwd, env: io.env || process.env });
|
|
45
|
+
const emit = (value, lines) => out(flags.json ? `${JSON.stringify(value, null, 2)}\n` : `${lines.join('\n')}\n`);
|
|
46
|
+
|
|
47
|
+
if (sub === 'export') {
|
|
48
|
+
if (typeof flags.invariant !== 'string' || typeof flags.fixture !== 'string') { err(`${USAGE}\n`); return 2; }
|
|
49
|
+
const r = exportFromFiles({
|
|
50
|
+
invariantPath: abs(cwd, flags.invariant), fixturePath: abs(cwd, flags.fixture), ledgerPath: typeof flags.ledger === 'string' ? abs(cwd, flags.ledger) : undefined,
|
|
51
|
+
commit: typeof flags.commit === 'string' ? flags.commit : undefined, seed: flags.seed !== undefined ? Number(flags.seed) : undefined, config,
|
|
52
|
+
});
|
|
53
|
+
if (r.status !== 'ok') { err(`agentic-security invariants export: ${r.status}: ${r.reason ?? ''}\n`); return 1; }
|
|
54
|
+
const text = `${JSON.stringify(r.export, null, 2)}\n`;
|
|
55
|
+
if (typeof flags.output === 'string') {
|
|
56
|
+
fs.writeFileSync(abs(cwd, flags.output), text);
|
|
57
|
+
out(`wrote ${r.export.scenarios.length} scenario(s) to ${flags.output}${r.withheld.length ? `; ${r.withheld.length} withheld (secret-looking content)` : ''}\n`);
|
|
58
|
+
} else out(text);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (sub === 'coverage') {
|
|
63
|
+
let files = [];
|
|
64
|
+
if (typeof flags.invariant === 'string') files = [abs(cwd, flags.invariant)];
|
|
65
|
+
else if (typeof flags['invariants-dir'] === 'string') {
|
|
66
|
+
try { files = fs.readdirSync(abs(cwd, flags['invariants-dir'])).filter((f) => f.endsWith('.json')).sort().map((f) => path.join(abs(cwd, flags['invariants-dir']), f)); } catch { err('agentic-security invariants coverage: the invariants directory is unreadable\n'); return 1; }
|
|
67
|
+
} else { err(`${USAGE}\n`); return 2; }
|
|
68
|
+
const docs = [];
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
const j = readJsonFile(f);
|
|
71
|
+
if (!j.ok) { err(`agentic-security invariants coverage: ${path.basename(f)}: ${j.reason}\n`); return 1; }
|
|
72
|
+
docs.push(j.value);
|
|
73
|
+
}
|
|
74
|
+
let ledger; let results = [];
|
|
75
|
+
if (typeof flags.ledger === 'string') { const l = readJsonFile(abs(cwd, flags.ledger)); if (!l.ok) { err(`agentic-security invariants coverage: the ledger: ${l.reason}\n`); return 1; } ledger = l.value; }
|
|
76
|
+
if (typeof flags.results === 'string') { const rr = readJsonFile(abs(cwd, flags.results)); if (!rr.ok || !Array.isArray(rr.value)) { err('agentic-security invariants coverage: --results must be a JSON array of { invariantId, result }\n'); return 1; } results = rr.value; }
|
|
77
|
+
const byId = new Map(results.filter((x) => x && typeof x.invariantId === 'string').map((x) => [x.invariantId, x.result]));
|
|
78
|
+
const cov = businessCoverage({ entries: docs.map((invariant) => ({ invariant, result: byId.get(invariant.id) ?? null })), ledger });
|
|
79
|
+
emit(cov, cov.lines);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (sub === 'regress') {
|
|
84
|
+
const file = args._[2];
|
|
85
|
+
if (!file) { err(`${USAGE}\n`); return 2; }
|
|
86
|
+
const a = readJsonFile(abs(cwd, file));
|
|
87
|
+
if (!a.ok) { err(`agentic-security invariants regress: ${a.reason}\n`); return 1; }
|
|
88
|
+
const r = await runRegressionArtifact(a.value, { config });
|
|
89
|
+
emit(r, [`regression artifact: ${r.status}`, r.summary, ...r.legs.map((l) => ` ${l.matched ? 'ok ' : 'FAIL'} ${l.role}: expected ${l.expected}, got ${l.outcome ?? l.status}`)]);
|
|
90
|
+
return r.status === 'passed' ? 0 : (r.status === 'prerequisite-unmet' || r.status === 'disabled') ? 3 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
err(`agentic-security invariants: unrecognized sub-command "${sub}"\n${USAGE}\n`);
|
|
94
|
+
return 2;
|
|
95
|
+
}
|