@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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 +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Accuracy scorecard (roadmap R3) — aggregation + rendering.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS LIVES IN src/posture/ RATHER THAN ENTIRELY IN scripts/:
|
|
4
|
+
// every figure this project publishes has to be re-derivable and testable.
|
|
5
|
+
// The aggregation and the rendering are pure functions of their inputs, so
|
|
6
|
+
// they belong where the unit-test suite already runs (tests execute against
|
|
7
|
+
// `src/` directly). The thin driver at `scripts/scorecard.mjs` does the
|
|
8
|
+
// impure half — running the corpus, running the self-scan, reading the
|
|
9
|
+
// bundle hash and the git commit — and hands the results here. That split is
|
|
10
|
+
// what makes the "hand-computed fixture" test in
|
|
11
|
+
// `test/accuracy-scorecard.test.js` possible at all.
|
|
12
|
+
//
|
|
13
|
+
// INTEGRITY CONTRACT (the reason the item exists — read before editing):
|
|
14
|
+
// · Nothing here invents a rate. Every rate is carried as {n, d} and
|
|
15
|
+
// rendered through formatRate(), which always prints the numerator and
|
|
16
|
+
// the denominator alongside the percentage. A denominator of 0 renders
|
|
17
|
+
// "0/0 (n/a)" — never 0% and never 100%.
|
|
18
|
+
// · Entries the run could not score (parser unavailable, scan error) are
|
|
19
|
+
// removed from every denominator AND disclosed by name. Scoring them
|
|
20
|
+
// either way would manufacture a number out of an environment fault.
|
|
21
|
+
// · No F1 is emitted. F1 needs a precision measured over a labelled
|
|
22
|
+
// real-world population; this corpus is curated known-vulnerable
|
|
23
|
+
// fixtures and their fixed counterparts, so its "precision" would be
|
|
24
|
+
// computed over a denominator that does not mean what the word implies.
|
|
25
|
+
// See renderScorecardMarkdown()'s methodology section.
|
|
26
|
+
// · Output is a deterministic function of the inputs apart from one
|
|
27
|
+
// timestamp line (TIMESTAMP_MARKER), so the document regenerates
|
|
28
|
+
// byte-identically on an unchanged tree.
|
|
29
|
+
|
|
30
|
+
// The single line of the rendered document that is permitted to vary between
|
|
31
|
+
// two runs on an unchanged tree. Tests and the regeneration check key on it.
|
|
32
|
+
export const TIMESTAMP_MARKER = 'Generated (UTC)';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render a rate with its raw counts. This is the ONLY sanctioned way to put a
|
|
36
|
+
* percentage into the scorecard: a bare percentage with no visible
|
|
37
|
+
* denominator is exactly the kind of unfalsifiable claim this document exists
|
|
38
|
+
* to avoid.
|
|
39
|
+
*/
|
|
40
|
+
export function formatRate(n, d) {
|
|
41
|
+
if (!d) return `${n}/${d} (n/a)`;
|
|
42
|
+
return `${n}/${d} (${((n / d) * 100).toFixed(1)}%)`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyBucket(key) {
|
|
46
|
+
return { key, entries: 0, detection: { n: 0, d: 0 }, silence: { n: 0, d: 0 } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function accumulate(bucket, status) {
|
|
50
|
+
bucket.entries++;
|
|
51
|
+
if (status.includes('pre:TP')) { bucket.detection.n++; bucket.detection.d++; }
|
|
52
|
+
else if (status.includes('pre:FN')) { bucket.detection.d++; }
|
|
53
|
+
if (status.includes('post:TN')) { bucket.silence.n++; bucket.silence.d++; }
|
|
54
|
+
else if (status.includes('post:FP')) { bucket.silence.d++; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sliceBy(scored, key) {
|
|
58
|
+
const map = new Map();
|
|
59
|
+
for (const d of scored) {
|
|
60
|
+
const k = d[key] || 'unknown';
|
|
61
|
+
if (!map.has(k)) map.set(k, emptyBucket(k));
|
|
62
|
+
accumulate(map.get(k), d.status);
|
|
63
|
+
}
|
|
64
|
+
// Sorted by key so the document is stable across runs.
|
|
65
|
+
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Aggregate the per-entry detail records emitted by the corpus runner.
|
|
70
|
+
*
|
|
71
|
+
* Input records: { cve, tier, cwe, language, status, error? } where status is
|
|
72
|
+
* the runner's own string form, e.g. "pre:TP post:TN", "pre:FN", "env-error".
|
|
73
|
+
*/
|
|
74
|
+
export function aggregateCorpus(detail) {
|
|
75
|
+
const all = [...(detail || [])];
|
|
76
|
+
const notScored = all
|
|
77
|
+
.filter(d => !d.status || !d.status.startsWith('pre:'))
|
|
78
|
+
.map(d => ({ cve: d.cve, tier: d.tier || null, language: d.language || null, status: d.status || 'unknown', error: d.error || null }))
|
|
79
|
+
.sort((a, b) => String(a.cve).localeCompare(String(b.cve)));
|
|
80
|
+
const scored = all.filter(d => d.status && d.status.startsWith('pre:'));
|
|
81
|
+
|
|
82
|
+
const overall = emptyBucket('overall');
|
|
83
|
+
for (const d of scored) accumulate(overall, d.status);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
totalEntries: all.length,
|
|
87
|
+
scoredEntries: scored.length,
|
|
88
|
+
notScored,
|
|
89
|
+
overall: { detection: overall.detection, silence: overall.silence },
|
|
90
|
+
byLanguage: sliceBy(scored, 'language'),
|
|
91
|
+
byCwe: sliceBy(scored, 'cwe'),
|
|
92
|
+
byTier: sliceBy(scored, 'tier'),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the machine-readable scorecard model. `inputs`:
|
|
98
|
+
* provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt }
|
|
99
|
+
* corpusDetail per-entry records from a corpus run performed THIS run
|
|
100
|
+
* selfScan { targets: {name:{total,byFile}}, polyglot: {total,byLanguage} }
|
|
101
|
+
* — measured THIS run
|
|
102
|
+
* committed { corpusBaseline, proofCorpus } — read from committed files,
|
|
103
|
+
* labelled as such in the output, never used to derive a rate
|
|
104
|
+
*/
|
|
105
|
+
export function buildScorecard(inputs) {
|
|
106
|
+
const corpus = aggregateCorpus(inputs.corpusDetail);
|
|
107
|
+
const selfScan = inputs.selfScan || {};
|
|
108
|
+
const targets = {};
|
|
109
|
+
for (const k of Object.keys(selfScan.targets || {}).sort()) {
|
|
110
|
+
targets[k] = { total: selfScan.targets[k].total, byFile: selfScan.targets[k].byFile || {} };
|
|
111
|
+
}
|
|
112
|
+
const committed = inputs.committed || {};
|
|
113
|
+
const proof = committed.proofCorpus || null;
|
|
114
|
+
return {
|
|
115
|
+
schema: 'agentic-security/accuracy-scorecard@1',
|
|
116
|
+
provenance: { ...inputs.provenance },
|
|
117
|
+
methodology: {
|
|
118
|
+
population: 'curated known-vulnerable fixtures (pre/) and their fixed counterparts (post/)',
|
|
119
|
+
detectionRateMeans: 'share of known-vulnerable fixtures where the expected finding fired',
|
|
120
|
+
silenceRateMeans: 'share of fixed counterparts where the same finding correctly did not fire',
|
|
121
|
+
notGeneralRecall: true,
|
|
122
|
+
notGeneralFalsePositiveRate: true,
|
|
123
|
+
f1Emitted: false,
|
|
124
|
+
f1OmissionReason: 'no labelled real-world population is available to measure precision over, so any F1 would combine denominators that do not describe the same population',
|
|
125
|
+
},
|
|
126
|
+
corpus: {
|
|
127
|
+
measuredThisRun: true,
|
|
128
|
+
totalEntries: corpus.totalEntries,
|
|
129
|
+
scoredEntries: corpus.scoredEntries,
|
|
130
|
+
notScored: corpus.notScored,
|
|
131
|
+
overall: corpus.overall,
|
|
132
|
+
byLanguage: corpus.byLanguage,
|
|
133
|
+
byCwe: corpus.byCwe,
|
|
134
|
+
byTier: corpus.byTier,
|
|
135
|
+
},
|
|
136
|
+
selfScan: { measuredThisRun: true, targets, polyglot: selfScan.polyglot || { total: 0, byLanguage: {} } },
|
|
137
|
+
committedInputs: {
|
|
138
|
+
corpusBaseline: committed.corpusBaseline
|
|
139
|
+
? { source: 'bench/cve-replay/corpus-baseline.json', generatedAt: committed.corpusBaseline.generatedAt, total: committed.corpusBaseline.total, passing: committed.corpusBaseline.passing }
|
|
140
|
+
: null,
|
|
141
|
+
proofCorpus: proof
|
|
142
|
+
? {
|
|
143
|
+
source: 'bench/proof-corpus/results/summary.json',
|
|
144
|
+
bundleSha: proof.bundleSha || null,
|
|
145
|
+
targetCount: proof.targetCount ?? null,
|
|
146
|
+
ok: proof.ok ?? null,
|
|
147
|
+
failed: proof.failed ?? null,
|
|
148
|
+
targets: (proof.targets || []).map(t => ({
|
|
149
|
+
id: t.id, commit: t.commit, status: t.status,
|
|
150
|
+
filesInScope: t.coverage?.totals?.inScope ?? null,
|
|
151
|
+
filesParsed: t.coverage?.totals?.parsed ?? null,
|
|
152
|
+
determinismChecked: t.determinism?.checked ?? null,
|
|
153
|
+
determinismIdentical: t.determinism?.identical ?? null,
|
|
154
|
+
resultsEmitted: t.determinism?.results ?? null,
|
|
155
|
+
})).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
|
156
|
+
}
|
|
157
|
+
: null,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rateRow(r) {
|
|
163
|
+
return `| ${r.key} | ${r.entries} | ${formatRate(r.detection.n, r.detection.d)} | ${formatRate(r.silence.n, r.silence.d)} |`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Render the human-readable scorecard. Deterministic apart from one line. */
|
|
167
|
+
export function renderScorecardMarkdown(m) {
|
|
168
|
+
const p = m.provenance || {};
|
|
169
|
+
const c = m.corpus;
|
|
170
|
+
const L = [];
|
|
171
|
+
L.push('# Accuracy scorecard');
|
|
172
|
+
L.push('');
|
|
173
|
+
L.push('Generated by `npm run scorecard`. Every figure below comes from a run');
|
|
174
|
+
L.push('performed by that command, except where a line is explicitly labelled');
|
|
175
|
+
L.push('*committed artifact* — those carry the timestamp and commit of the run');
|
|
176
|
+
L.push('that produced them.');
|
|
177
|
+
L.push('');
|
|
178
|
+
L.push('## Provenance');
|
|
179
|
+
L.push('');
|
|
180
|
+
L.push('| Field | Value |');
|
|
181
|
+
L.push('| --- | --- |');
|
|
182
|
+
L.push(`| Engine version | ${p.engineVersion || 'unknown'} |`);
|
|
183
|
+
L.push(`| Bundle SHA-256 | \`${p.bundleSha256 || 'unknown'}\` |`);
|
|
184
|
+
L.push(`| Commit | \`${p.commit || 'unknown'}\` |`);
|
|
185
|
+
if (p.worktreeClean !== undefined) {
|
|
186
|
+
L.push(`| Worktree at measurement time | ${p.worktreeClean ? 'clean' : 'DIRTY — the commit above does not fully describe what was measured'} |`);
|
|
187
|
+
}
|
|
188
|
+
L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
|
|
189
|
+
L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
|
|
190
|
+
L.push(`| ${TIMESTAMP_MARKER} | ${p.generatedAt || 'unknown'} |`);
|
|
191
|
+
L.push('');
|
|
192
|
+
L.push('## What these numbers are, and what they are not');
|
|
193
|
+
L.push('');
|
|
194
|
+
L.push('The population is a curated corpus of known-vulnerable code fixtures');
|
|
195
|
+
L.push('(`pre/`) each paired with its fixed counterpart (`post/`). Two rates are');
|
|
196
|
+
L.push('reported, always with their raw numerator and denominator:');
|
|
197
|
+
L.push('');
|
|
198
|
+
L.push('- **Detection rate** — of the vulnerable fixtures, the share where the');
|
|
199
|
+
L.push(' expected finding fired. This is **not** general-purpose recall against');
|
|
200
|
+
L.push(' real-world code: the corpus is curated, each entry was added because it');
|
|
201
|
+
L.push(' represents a class worth covering, and nothing here samples the');
|
|
202
|
+
L.push(' distribution of vulnerabilities in arbitrary repositories.');
|
|
203
|
+
L.push('- **Correct-silence rate** — of the fixed counterparts, the share where');
|
|
204
|
+
L.push(' the same finding correctly did not fire. This is **not** a general');
|
|
205
|
+
L.push(' false-positive rate: the denominator is one narrowly-scoped fixed file');
|
|
206
|
+
L.push(' per entry, not a population of real code in which any rule could');
|
|
207
|
+
L.push(' misfire.');
|
|
208
|
+
L.push('');
|
|
209
|
+
L.push('The corpus is also **gated**: an entry is admitted only once it scores');
|
|
210
|
+
L.push('detected-on-`pre` and silent-on-`post`, and a committed baseline fails the');
|
|
211
|
+
L.push('build on any drift. A detection rate at the ceiling is therefore expected');
|
|
212
|
+
L.push('by construction — it is evidence that nothing regressed, not evidence that');
|
|
213
|
+
L.push('there is no headroom. What moves it is adding entries for classes not yet');
|
|
214
|
+
L.push('covered, which is why the corpus size is reported alongside the rate.');
|
|
215
|
+
L.push('');
|
|
216
|
+
L.push('**No F1 is reported, deliberately.** An F1 requires a precision measured');
|
|
217
|
+
L.push('over a labelled real-world population. This project does not have one, so');
|
|
218
|
+
L.push('a precision computed from the corpus alone would divide by a denominator');
|
|
219
|
+
L.push('that does not describe the population the word implies. The precision-side');
|
|
220
|
+
L.push('signal reported instead is the self-scan section below: exact finding');
|
|
221
|
+
L.push('counts on this repository\'s own non-fixture source, where every count is');
|
|
222
|
+
L.push('reviewed by hand and any movement is a real change. Publishing a number we');
|
|
223
|
+
L.push('cannot defend would cost more credibility than the number is worth.');
|
|
224
|
+
L.push('');
|
|
225
|
+
L.push('## Corpus results (measured this run)');
|
|
226
|
+
L.push('');
|
|
227
|
+
L.push('| Population | Detected / correctly silent |');
|
|
228
|
+
L.push('| --- | --- |');
|
|
229
|
+
L.push(`| Vulnerable fixtures (\`pre/\`) — detection | ${formatRate(c.overall.detection.n, c.overall.detection.d)} |`);
|
|
230
|
+
L.push(`| Fixed counterparts (\`post/\`) — correct silence | ${formatRate(c.overall.silence.n, c.overall.silence.d)} |`);
|
|
231
|
+
L.push('');
|
|
232
|
+
if (c.notScored.length) {
|
|
233
|
+
const word = c.notScored.length === 1 ? 'entry' : 'entries';
|
|
234
|
+
L.push('**' + c.notScored.length + ' corpus ' + word + ' could not be scored.** Excluded from every');
|
|
235
|
+
L.push('denominator above rather than counted as a miss:');
|
|
236
|
+
L.push('');
|
|
237
|
+
for (const n of c.notScored) {
|
|
238
|
+
const why = n.error ? ' — ' + n.error : '';
|
|
239
|
+
L.push('- `' + n.cve + '` (' + (n.language || 'unknown') + '): ' + n.status + why);
|
|
240
|
+
}
|
|
241
|
+
L.push('');
|
|
242
|
+
} else {
|
|
243
|
+
L.push('All corpus entries scored; no entry was excluded.');
|
|
244
|
+
L.push('');
|
|
245
|
+
}
|
|
246
|
+
L.push('### By language');
|
|
247
|
+
L.push('');
|
|
248
|
+
L.push('| Language | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
|
|
249
|
+
L.push('| --- | --- | --- | --- |');
|
|
250
|
+
for (const r of c.byLanguage) L.push(rateRow(r));
|
|
251
|
+
L.push('');
|
|
252
|
+
L.push('### By CWE');
|
|
253
|
+
L.push('');
|
|
254
|
+
L.push('| CWE | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
|
|
255
|
+
L.push('| --- | --- | --- | --- |');
|
|
256
|
+
for (const r of c.byCwe) L.push(rateRow(r));
|
|
257
|
+
L.push('');
|
|
258
|
+
L.push('### By corpus tier');
|
|
259
|
+
L.push('');
|
|
260
|
+
L.push('| Tier | Entries | Detection (`pre/`) | Correct silence (`post/`) |');
|
|
261
|
+
L.push('| --- | --- | --- | --- |');
|
|
262
|
+
for (const r of c.byTier) L.push(rateRow(r));
|
|
263
|
+
L.push('');
|
|
264
|
+
L.push('## Precision-side signal: self-scan (measured this run)');
|
|
265
|
+
L.push('');
|
|
266
|
+
L.push('The engine scanned this repository\'s own hand-reviewed source. These are');
|
|
267
|
+
L.push('absolute finding counts, not a rate — there is no labelled ground truth');
|
|
268
|
+
L.push('over this code, so no precision figure is derived from it. What it');
|
|
269
|
+
L.push('supports is a movement claim: any change in these counts between');
|
|
270
|
+
L.push('releases is a real change in what the engine reports on unchanged code.');
|
|
271
|
+
L.push('');
|
|
272
|
+
L.push('| Target | Findings |');
|
|
273
|
+
L.push('| --- | --- |');
|
|
274
|
+
for (const [k, v] of Object.entries(m.selfScan.targets)) L.push(`| \`${k}\` | ${v.total} |`);
|
|
275
|
+
L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
|
|
276
|
+
L.push('');
|
|
277
|
+
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
278
|
+
L.push('');
|
|
279
|
+
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
280
|
+
L.push('');
|
|
281
|
+
const cb = m.committedInputs.corpusBaseline;
|
|
282
|
+
if (cb) {
|
|
283
|
+
L.push(`- **Corpus baseline** (*committed artifact*, \`${cb.source}\`, generated ${cb.generatedAt}):`);
|
|
284
|
+
L.push(` ${cb.passing}/${cb.total} entries recorded as passing. The gate`);
|
|
285
|
+
L.push(' `npm run bench:cve-replay:check` fails the build on any drift from it.');
|
|
286
|
+
L.push(' The rates above are computed from this run, not from this file.');
|
|
287
|
+
}
|
|
288
|
+
const pc = m.committedInputs.proofCorpus;
|
|
289
|
+
if (pc) {
|
|
290
|
+
L.push(`- **Third-party repository run** (*committed artifact*, \`${pc.source}\`,`);
|
|
291
|
+
L.push(` bundle \`${pc.bundleSha}\`): ${pc.ok}/${pc.targetCount} targets completed.`);
|
|
292
|
+
L.push(' Reported for scale and parse coverage only — these repositories have no');
|
|
293
|
+
L.push(' vulnerability ground truth, so no accuracy rate is derived from them.');
|
|
294
|
+
L.push('');
|
|
295
|
+
L.push('| Repository | Commit | Status | Files parsed / in scope | Results emitted | Deterministic re-run |');
|
|
296
|
+
L.push('| --- | --- | --- | --- | --- | --- |');
|
|
297
|
+
for (const t of pc.targets) {
|
|
298
|
+
const det = t.determinismChecked ? (t.determinismIdentical ? 'identical' : 'checked, not byte-identical') : 'not checked';
|
|
299
|
+
L.push(`| ${t.id} | \`${String(t.commit || '').slice(0, 12)}\` | ${t.status} | ${formatRate(t.filesParsed ?? 0, t.filesInScope ?? 0)} | ${t.resultsEmitted ?? 'n/a'} | ${det} |`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
L.push('');
|
|
303
|
+
L.push('## Reproducing any figure here');
|
|
304
|
+
L.push('');
|
|
305
|
+
L.push('| Figure | Command |');
|
|
306
|
+
L.push('| --- | --- |');
|
|
307
|
+
L.push('| Detection / correct-silence, and every slice | `node bench/cve-replay/runner.mjs --json` |');
|
|
308
|
+
L.push('| Corpus drift gate | `npm run bench:cve-replay:check` |');
|
|
309
|
+
L.push('| Self-scan counts | `node bench/self-scan/measure.mjs --json` |');
|
|
310
|
+
L.push('| Self-scan drift gate | `npm run bench:self-scan:check` |');
|
|
311
|
+
L.push('| This whole document | `npm run scorecard` |');
|
|
312
|
+
L.push('');
|
|
313
|
+
L.push('Running `npm run scorecard` twice on an unchanged tree produces an');
|
|
314
|
+
L.push('identical document apart from the generated-timestamp row above.');
|
|
315
|
+
L.push('');
|
|
316
|
+
return L.join('\n');
|
|
317
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
21
|
import * as path from 'node:path';
|
|
22
|
-
import * as yaml from 'js
|
|
22
|
+
import * as yaml from '../util/yaml.js';
|
|
23
23
|
|
|
24
24
|
const CONTRACT_FILE_PATTERNS = [
|
|
25
25
|
{ glob: /(?:openapi|swagger)\.(?:ya?ml|json)$/i, kind: 'openapi' },
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// R4 — determinism as a contract.
|
|
2
|
+
//
|
|
3
|
+
// Determinism has been an implementation property of this engine (stable sorts,
|
|
4
|
+
// no Date.now() in ids, `--deterministic`) with no way for a third party to
|
|
5
|
+
// VERIFY it. This module turns it into something checkable: a stable digest
|
|
6
|
+
// over the finding set, bound to the engine version, ruleset version, and
|
|
7
|
+
// bundle hash that produced it.
|
|
8
|
+
//
|
|
9
|
+
// WHAT THE DIGEST IS INDEPENDENT OF (by construction):
|
|
10
|
+
// - the ORDER findings were emitted in (entries are canonicalised, then sorted)
|
|
11
|
+
// - run ids, timestamps, durations, and every other field not on the
|
|
12
|
+
// identity allowlist below — canonicalisation is an ALLOWLIST, not a
|
|
13
|
+
// denylist, so a new non-deterministic field cannot silently leak in
|
|
14
|
+
// - path separator style, and the absolute prefix when `root` is supplied
|
|
15
|
+
//
|
|
16
|
+
// WHAT IT IS NOT INDEPENDENT OF (all of these are real differences):
|
|
17
|
+
// - a changed severity, file, line, rule id, cwe, or vuln title
|
|
18
|
+
// - a finding appearing or disappearing (including a duplicate — multiplicity
|
|
19
|
+
// is preserved; two identical findings are not collapsed into one)
|
|
20
|
+
// - the engine version, ruleset version, or bundle sha
|
|
21
|
+
//
|
|
22
|
+
// WHAT THIS PROVES, AND WHAT IT DOES NOT (read before quoting it at anyone):
|
|
23
|
+
// PROVES — two finding sets carrying the same digest under the same
|
|
24
|
+
// canonicalisation are the same set of findings, modulo emission order and
|
|
25
|
+
// the excluded volatile fields; and (when signed) that the attestation was
|
|
26
|
+
// produced on an install holding this HMAC key.
|
|
27
|
+
// DOES NOT PROVE — cross-machine reproducibility. Nothing here demonstrates
|
|
28
|
+
// that a different host, OS, Node version, or toolchain produces the same
|
|
29
|
+
// finding set; several detectors are environment-sensitive (e.g. the Python
|
|
30
|
+
// AST path is used when python3 is available and falls back to regex when it
|
|
31
|
+
// is not, which can change what is found). Two runs on two machines agreeing
|
|
32
|
+
// would be evidence FOR reproducibility, but that experiment has not been
|
|
33
|
+
// run here and this module does not assert its outcome. The signature is a
|
|
34
|
+
// symmetric per-install HMAC, so it is tamper-evidence for the operator, not
|
|
35
|
+
// third-party non-repudiation.
|
|
36
|
+
//
|
|
37
|
+
// Identity fields deliberately EXCLUDE `parser` and `family`: `parser` records
|
|
38
|
+
// which analysis engine fired, which is environment-sensitive (see the python3
|
|
39
|
+
// case above), so including it would make the digest report an environment
|
|
40
|
+
// difference as a findings difference. The finding's identity — where it is
|
|
41
|
+
// and what it is — is fully captured without it.
|
|
42
|
+
//
|
|
43
|
+
// SIGNING: reuses `integrity.js`'s per-install HMAC key handling verbatim
|
|
44
|
+
// (`signLastScan`, keyed from $AGENTIC_SECURITY_HMAC_KEY or the 0600 key file
|
|
45
|
+
// at $XDG_CONFIG_HOME/agentic-security/scan-key). No second key mechanism is
|
|
46
|
+
// introduced. `integrity.verifyLastScan` is NOT reused because it verifies a
|
|
47
|
+
// body against a sibling `.sig` FILE; an attestation carries its signature
|
|
48
|
+
// inline, so verification re-signs and compares in constant time here.
|
|
49
|
+
//
|
|
50
|
+
// NO THROWING (posture/CLAUDE.md convention): malformed input yields an empty
|
|
51
|
+
// canonical set or an `{ok:false, reason}` refusal, never an exception.
|
|
52
|
+
|
|
53
|
+
import * as crypto from 'node:crypto';
|
|
54
|
+
import { signLastScan } from './integrity.js';
|
|
55
|
+
|
|
56
|
+
export const ATTESTATION_CANONICALISATION = 'agentic-security/run-attestation-canon-v1';
|
|
57
|
+
|
|
58
|
+
const PROVES =
|
|
59
|
+
'Two finding sets with this digest, under this canonicalisation, are the same findings ' +
|
|
60
|
+
'(same rule id, severity, file, line, cwe, vuln, and multiplicity) produced by the same ' +
|
|
61
|
+
'engine version, ruleset version, and bundle — regardless of emission order.';
|
|
62
|
+
const DOES_NOT_PROVE =
|
|
63
|
+
'It does not prove cross-machine reproducibility: no run on a second machine, OS, or Node ' +
|
|
64
|
+
'version is compared here, and some detectors are environment-sensitive. A signature, when ' +
|
|
65
|
+
'present, is a symmetric per-install HMAC — tamper-evidence for this install, not ' +
|
|
66
|
+
'third-party non-repudiation.';
|
|
67
|
+
|
|
68
|
+
function _str(v) { return v === undefined || v === null ? '' : String(v); }
|
|
69
|
+
|
|
70
|
+
// Normalise a file path: separators to '/', drop a supplied absolute root,
|
|
71
|
+
// drop a leading './'. Everything else is left alone — guessing at a root we
|
|
72
|
+
// were not given would make two genuinely different files collide.
|
|
73
|
+
function _normPath(file, root) {
|
|
74
|
+
let p = _str(file).replace(/\\/g, '/');
|
|
75
|
+
if (root) {
|
|
76
|
+
let r = String(root).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
77
|
+
if (r && p.startsWith(r + '/')) p = p.slice(r.length + 1);
|
|
78
|
+
else if (r && p === r) p = '';
|
|
79
|
+
}
|
|
80
|
+
return p.replace(/^\.\//, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The canonical, order-independent representation of a finding set: one
|
|
85
|
+
* tab-joined record per finding over the identity allowlist, sorted.
|
|
86
|
+
* Multiplicity is preserved (duplicates are NOT deduped).
|
|
87
|
+
*/
|
|
88
|
+
function canonicaliseFindings(findings, { root } = {}) {
|
|
89
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const f of list) {
|
|
92
|
+
if (!f || typeof f !== 'object') continue;
|
|
93
|
+
rows.push([
|
|
94
|
+
_str(f.id),
|
|
95
|
+
_str(f.severity),
|
|
96
|
+
_normPath(f.file, root),
|
|
97
|
+
_str(f.line),
|
|
98
|
+
_str(f.cwe),
|
|
99
|
+
_str(f.vuln),
|
|
100
|
+
].join('\t'));
|
|
101
|
+
}
|
|
102
|
+
return rows.sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compute the run attestation.
|
|
107
|
+
*
|
|
108
|
+
* @param {object[]} findings the finding set (any order)
|
|
109
|
+
* @param {string} engineVersion
|
|
110
|
+
* @param {string} rulesetVersion
|
|
111
|
+
* @param {string} bundleSha
|
|
112
|
+
* @param {string} [root] scan root, used to relativise absolute paths
|
|
113
|
+
* @param {boolean} [sign] attach a per-install HMAC over the digest
|
|
114
|
+
*/
|
|
115
|
+
export function computeRunAttestation({
|
|
116
|
+
findings, engineVersion, rulesetVersion, bundleSha, root, sign = false,
|
|
117
|
+
} = {}) {
|
|
118
|
+
const entries = canonicaliseFindings(findings, { root });
|
|
119
|
+
const payload = JSON.stringify({
|
|
120
|
+
canonicalisation: ATTESTATION_CANONICALISATION,
|
|
121
|
+
engineVersion: _str(engineVersion),
|
|
122
|
+
rulesetVersion: _str(rulesetVersion),
|
|
123
|
+
bundleSha: _str(bundleSha),
|
|
124
|
+
findingCount: entries.length,
|
|
125
|
+
entries,
|
|
126
|
+
});
|
|
127
|
+
const digest = crypto.createHash('sha256').update(payload).digest('hex');
|
|
128
|
+
const att = {
|
|
129
|
+
digest,
|
|
130
|
+
algorithm: 'sha256',
|
|
131
|
+
findingCount: entries.length,
|
|
132
|
+
engineVersion: _str(engineVersion),
|
|
133
|
+
rulesetVersion: _str(rulesetVersion),
|
|
134
|
+
bundleSha: _str(bundleSha),
|
|
135
|
+
canonicalisation: ATTESTATION_CANONICALISATION,
|
|
136
|
+
proves: PROVES,
|
|
137
|
+
doesNotProve: DOES_NOT_PROVE,
|
|
138
|
+
};
|
|
139
|
+
if (sign) {
|
|
140
|
+
try {
|
|
141
|
+
att.signature = signLastScan(digest);
|
|
142
|
+
att.signatureScope = 'per-install-hmac';
|
|
143
|
+
} catch { /* signing is best-effort; an unsigned attestation is still valid */ }
|
|
144
|
+
}
|
|
145
|
+
return att;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Re-derive the attestation from a finding set and compare.
|
|
150
|
+
* @returns {{ok: boolean, reason: string}}
|
|
151
|
+
*/
|
|
152
|
+
export function verifyRunAttestation(attestation, inputs = {}) {
|
|
153
|
+
if (!attestation || typeof attestation !== 'object') {
|
|
154
|
+
return { ok: false, reason: 'no attestation supplied' };
|
|
155
|
+
}
|
|
156
|
+
if (attestation.canonicalisation !== ATTESTATION_CANONICALISATION) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: `unknown canonicalisation "${_str(attestation.canonicalisation)}" — ` +
|
|
160
|
+
`this build verifies "${ATTESTATION_CANONICALISATION}" only`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (typeof attestation.digest !== 'string' || !/^[0-9a-f]{64}$/.test(attestation.digest)) {
|
|
164
|
+
return { ok: false, reason: 'attestation carries no sha256 digest' };
|
|
165
|
+
}
|
|
166
|
+
for (const k of ['engineVersion', 'rulesetVersion', 'bundleSha']) {
|
|
167
|
+
if (_str(inputs[k]) !== _str(attestation[k])) {
|
|
168
|
+
return { ok: false, reason: `${k} mismatch: attested "${_str(attestation[k])}", got "${_str(inputs[k])}"` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const recomputed = computeRunAttestation({
|
|
172
|
+
findings: inputs.findings,
|
|
173
|
+
engineVersion: inputs.engineVersion,
|
|
174
|
+
rulesetVersion: inputs.rulesetVersion,
|
|
175
|
+
bundleSha: inputs.bundleSha,
|
|
176
|
+
root: inputs.root,
|
|
177
|
+
});
|
|
178
|
+
if (recomputed.findingCount !== attestation.findingCount) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason: `finding count mismatch: attested ${attestation.findingCount}, got ${recomputed.findingCount}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (recomputed.digest !== attestation.digest) {
|
|
185
|
+
return { ok: false, reason: 'digest mismatch — the finding set is not the one attested' };
|
|
186
|
+
}
|
|
187
|
+
if (attestation.signature) {
|
|
188
|
+
let expected;
|
|
189
|
+
try { expected = signLastScan(attestation.digest); }
|
|
190
|
+
catch { return { ok: false, reason: 'signature present but no key material available to check it' }; }
|
|
191
|
+
const a = Buffer.from(String(attestation.signature), 'hex');
|
|
192
|
+
const b = Buffer.from(expected, 'hex');
|
|
193
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
194
|
+
return { ok: false, reason: 'signature does not verify under this install key' };
|
|
195
|
+
}
|
|
196
|
+
return { ok: true, reason: 'digest and per-install signature verify' };
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, reason: 'digest verifies (unsigned attestation)' };
|
|
199
|
+
}
|
|
@@ -161,11 +161,20 @@ export function evaluateFramework(scanRoot, fw, scan) {
|
|
|
161
161
|
'mcp-tools': '.../scanner/src/mcp/tools.js',
|
|
162
162
|
};
|
|
163
163
|
const target = ARTIFACT[mod];
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
// A '.../' sentinel marks a source-relative artifact (project source,
|
|
165
|
+
// e.g. a hook or agent file) — resolve it against the scan root itself.
|
|
166
|
+
// Everything else is a runtime artifact under the STATE dir. Without
|
|
167
|
+
// this, `path.join(scanRoot, STATE, '.../x')` never resolves and the
|
|
168
|
+
// control falsely reads "not present" for every project.
|
|
169
|
+
const resolved = !target ? null
|
|
170
|
+
: target.startsWith('.../') ? path.join(scanRoot, target.slice(4))
|
|
171
|
+
: path.join(scanRoot, STATE, target);
|
|
172
|
+
const label = target ? target.replace(/^\.\.\.\//, '') : '(unmapped)';
|
|
173
|
+
if (resolved && fs.existsSync(resolved)) {
|
|
174
|
+
obs.push(`✓ ${mod}: ${label} present.`);
|
|
166
175
|
anySignal = true;
|
|
167
176
|
} else {
|
|
168
|
-
obs.push(`✗ ${mod}: expected ${
|
|
177
|
+
obs.push(`✗ ${mod}: expected ${label} not present.`);
|
|
169
178
|
allCleared = false;
|
|
170
179
|
}
|
|
171
180
|
} else if (m.startsWith('rule:')) {
|
|
@@ -16,7 +16,7 @@ import { isChainWorthy, familyForBoundary } from './cross-lang-meta.js';
|
|
|
16
16
|
// Out of scope (deferred to a follow-up): gRPC .proto introspection, GraphQL
|
|
17
17
|
// resolver-to-resolver tracking, SQL/ORM round-trip, message queues.
|
|
18
18
|
|
|
19
|
-
import * as yaml from 'js
|
|
19
|
+
import * as yaml from '../util/yaml.js';
|
|
20
20
|
|
|
21
21
|
function loadOpenAPI(fileContents) {
|
|
22
22
|
for (const [fp, c] of Object.entries(fileContents || {})) {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import * as fs from 'node:fs';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
|
-
import * as yaml from 'js
|
|
32
|
+
import * as yaml from '../util/yaml.js';
|
|
33
33
|
import fg from 'fast-glob';
|
|
34
34
|
import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
|
|
35
35
|
|