@clear-capabilities/agentic-security-scanner 0.132.0 → 0.134.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 +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// PRD Epic 7.2 — head-to-head comparison scoring.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS SHIPS WITHOUT A SINGLE PARTICIPANT NAME IN IT. A benchmark whose
|
|
4
|
+
// competitors are hard-coded by the vendor being measured is marketing with a
|
|
5
|
+
// methodology section. This repository publishes the HARNESS and the answer
|
|
6
|
+
// key; the operator supplies the participants. Nothing here — no constant, no
|
|
7
|
+
// default config, no example — names any tool, and the report renders whatever
|
|
8
|
+
// labels the operator chose. That is not a limitation working around a rule; a
|
|
9
|
+
// comparison anyone can re-run against tools of their own choosing is the only
|
|
10
|
+
// kind worth publishing, and the only kind a reader has reason to believe.
|
|
11
|
+
//
|
|
12
|
+
// THE ONE FAILURE MODE THIS MODULE EXISTS TO PREVENT. Two tools scored over
|
|
13
|
+
// different subsets of a corpus are not comparable, and the difference is
|
|
14
|
+
// invisible in the output: a tool that crashed on the 40 hardest entries and
|
|
15
|
+
// was scored over the remaining 170 looks like it beat one that completed all
|
|
16
|
+
// 210. So every rate here is computed over the INTERSECTION of entries every
|
|
17
|
+
// participant completed, that intersection is reported alongside each
|
|
18
|
+
// participant's own completion count, and a participant that completed nothing
|
|
19
|
+
// in common with the others is refused rather than shown with an empty score.
|
|
20
|
+
//
|
|
21
|
+
// MATCHING IS CWE-ONLY, ON PURPOSE. Our own corpus entries carry a `vuln_match`
|
|
22
|
+
// phrase in this engine's wording; scoring an external tool against our
|
|
23
|
+
// phrasing would score it on vocabulary. CWE is the one identifier every
|
|
24
|
+
// participant can be expected to emit, so it is the only key used, and it is
|
|
25
|
+
// applied identically to every participant including this engine. A participant
|
|
26
|
+
// that reports no CWE at all is scored as reporting nothing — stated in the
|
|
27
|
+
// output rather than silently counted as a miss.
|
|
28
|
+
|
|
29
|
+
/** Verdict for one participant on one corpus entry. */
|
|
30
|
+
export const OUTCOMES = Object.freeze(['tp', 'fn', 'fp', 'tn']);
|
|
31
|
+
|
|
32
|
+
function _cweSet(findings) {
|
|
33
|
+
const s = new Set();
|
|
34
|
+
for (const f of findings || []) {
|
|
35
|
+
const raw = f && (f.cwe ?? f.CWE ?? f.ruleId ?? '');
|
|
36
|
+
for (const m of String(raw).matchAll(/CWE[-_ ]?(\d+)/gi)) s.add(`CWE-${m[1]}`);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Score one participant over the entries it completed.
|
|
43
|
+
*
|
|
44
|
+
* @param {object[]} entries [{id, cwe}]
|
|
45
|
+
* @param {object} results entryId -> {pre: findings[], post: findings[]} | {error}
|
|
46
|
+
*/
|
|
47
|
+
export function scoreParticipant(entries, results) {
|
|
48
|
+
const per = new Map();
|
|
49
|
+
let noCwe = 0;
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const r = results?.[e.id];
|
|
52
|
+
if (!r || r.error || !Array.isArray(r.pre) || !Array.isArray(r.post)) continue;
|
|
53
|
+
|
|
54
|
+
const want = String(e.cwe || '').toUpperCase();
|
|
55
|
+
const pre = _cweSet(r.pre);
|
|
56
|
+
const post = _cweSet(r.post);
|
|
57
|
+
if (!pre.size && (r.pre || []).length) noCwe++;
|
|
58
|
+
|
|
59
|
+
// pre/ is the vulnerable tree: reporting the CWE is a true positive.
|
|
60
|
+
// post/ is the fixed tree: reporting it again is a false positive.
|
|
61
|
+
per.set(e.id, {
|
|
62
|
+
detected: pre.has(want),
|
|
63
|
+
falsePositive: post.has(want),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { per, completed: per.size, noCwe };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _rates(tp, fn, fp, tn) {
|
|
70
|
+
const precision = tp + fp > 0 ? tp / (tp + fp) : null;
|
|
71
|
+
const recall = tp + fn > 0 ? tp / (tp + fn) : null;
|
|
72
|
+
const f1 = precision !== null && recall !== null && precision + recall > 0
|
|
73
|
+
? (2 * precision * recall) / (precision + recall) : null;
|
|
74
|
+
return { tp, fn, fp, tn, precision, recall, f1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compare every participant over the entries ALL of them completed.
|
|
79
|
+
*
|
|
80
|
+
* @param {object[]} entries [{id, cwe}]
|
|
81
|
+
* @param {object[]} participants [{id, results}]
|
|
82
|
+
* @returns {object} {ok, reason?, intersection, scores[], skippedEntries[]}
|
|
83
|
+
*/
|
|
84
|
+
export function compareParticipants(entries, participants) {
|
|
85
|
+
if (!Array.isArray(entries) || !entries.length) return { ok: false, reason: 'no corpus entries' };
|
|
86
|
+
if (!Array.isArray(participants) || participants.length < 2) {
|
|
87
|
+
return { ok: false, reason: 'a comparison needs at least two participants' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const scored = participants.map((p) => ({ ...p, ...scoreParticipant(entries, p.results) }));
|
|
91
|
+
|
|
92
|
+
// The intersection. This is the whole point: rates over anything else are
|
|
93
|
+
// rates over different exams.
|
|
94
|
+
let common = null;
|
|
95
|
+
for (const s of scored) {
|
|
96
|
+
const ids = new Set(s.per.keys());
|
|
97
|
+
common = common === null ? ids : new Set([...common].filter((id) => ids.has(id)));
|
|
98
|
+
}
|
|
99
|
+
if (!common || common.size === 0) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: 'no corpus entry was completed by every participant — there is nothing they can be compared on',
|
|
103
|
+
completion: Object.fromEntries(scored.map((s) => [s.id, s.completed])),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const scores = scored.map((s) => {
|
|
108
|
+
let tp = 0, fn = 0, fp = 0, tn = 0;
|
|
109
|
+
for (const id of common) {
|
|
110
|
+
const v = s.per.get(id);
|
|
111
|
+
if (v.detected) tp++; else fn++;
|
|
112
|
+
if (v.falsePositive) fp++; else tn++;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
id: s.id,
|
|
116
|
+
...(_rates(tp, fn, fp, tn)),
|
|
117
|
+
completed: s.completed,
|
|
118
|
+
notCompleted: entries.length - s.completed,
|
|
119
|
+
noCwe: s.noCwe,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
corpusSize: entries.length,
|
|
126
|
+
intersection: common.size,
|
|
127
|
+
// Named so a reader can check the exam rather than trust the grade.
|
|
128
|
+
scoredEntryIds: [...common].sort(),
|
|
129
|
+
scores: scores.sort((a, b) => (b.f1 ?? -1) - (a.f1 ?? -1)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pct = (v) => (v === null || v === undefined ? 'n/a' : `${(v * 100).toFixed(1)}%`);
|
|
134
|
+
|
|
135
|
+
/** Markdown. Discloses the exam before the grades, never after. */
|
|
136
|
+
export function renderComparison(cmp) {
|
|
137
|
+
if (!cmp || !cmp.ok) {
|
|
138
|
+
return `# Comparison\n\nNOT SCORED: ${cmp?.reason || 'unknown reason'}\n`;
|
|
139
|
+
}
|
|
140
|
+
const out = [];
|
|
141
|
+
out.push('# Head-to-head comparison');
|
|
142
|
+
out.push('');
|
|
143
|
+
out.push(`Scored over the **${cmp.intersection} of ${cmp.corpusSize}** corpus entries that *every*`);
|
|
144
|
+
out.push('participant completed. Entries any participant failed to complete are excluded from');
|
|
145
|
+
out.push('every score, including this engine\'s — a rate computed over a different subset is a');
|
|
146
|
+
out.push('rate for a different exam.');
|
|
147
|
+
out.push('');
|
|
148
|
+
out.push('Matching is by CWE only. Participants report findings in their own vocabulary, so');
|
|
149
|
+
out.push('scoring against any one tool\'s phrasing would measure vocabulary rather than');
|
|
150
|
+
out.push('detection. The same rule is applied to every participant.');
|
|
151
|
+
out.push('');
|
|
152
|
+
out.push('| Participant | F1 | Precision | Recall | TP | FN | FP | Corpus completed |');
|
|
153
|
+
out.push('|---|---|---|---|---|---|---|---|');
|
|
154
|
+
for (const s of cmp.scores) {
|
|
155
|
+
out.push(`| ${s.id} | ${pct(s.f1)} | ${pct(s.precision)} | ${pct(s.recall)} | ${s.tp} | ${s.fn} | ${s.fp} | ${s.completed}/${cmp.corpusSize} |`);
|
|
156
|
+
}
|
|
157
|
+
out.push('');
|
|
158
|
+
const incomplete = cmp.scores.filter((s) => s.notCompleted > 0);
|
|
159
|
+
if (incomplete.length) {
|
|
160
|
+
out.push('## Entries not completed');
|
|
161
|
+
out.push('');
|
|
162
|
+
out.push('A participant that could not run on an entry is UNSCORED there, never scored as a');
|
|
163
|
+
out.push('miss. Counting a crash as a false negative would penalise a tool for a harness');
|
|
164
|
+
out.push('problem; counting it as a pass would reward it for one.');
|
|
165
|
+
out.push('');
|
|
166
|
+
for (const s of incomplete) out.push(`- **${s.id}** — ${s.notCompleted} entr(y/ies) not completed`);
|
|
167
|
+
out.push('');
|
|
168
|
+
}
|
|
169
|
+
const noCwe = cmp.scores.filter((s) => s.noCwe > 0);
|
|
170
|
+
if (noCwe.length) {
|
|
171
|
+
out.push('## Findings carrying no CWE');
|
|
172
|
+
out.push('');
|
|
173
|
+
for (const s of noCwe) {
|
|
174
|
+
out.push(`- **${s.id}** — ${s.noCwe} entr(y/ies) where findings were reported but none carried a CWE,`);
|
|
175
|
+
out.push(' so they could not be matched. This depresses that participant\'s recall for a');
|
|
176
|
+
out.push(' reporting-format reason rather than a detection one.');
|
|
177
|
+
}
|
|
178
|
+
out.push('');
|
|
179
|
+
}
|
|
180
|
+
return out.join('\n') + '\n';
|
|
181
|
+
}
|
|
@@ -7,14 +7,26 @@
|
|
|
7
7
|
// gate uses, it would cheerfully commit entries that fail CI. One
|
|
8
8
|
// implementation, two callers.
|
|
9
9
|
//
|
|
10
|
-
// THE PRE/POST
|
|
11
|
-
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
14
|
-
// `
|
|
15
|
-
// before the corpus grows toward 500.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// THE PRE/POST MATCHERS ARE NOW SYMMETRIC. They were not: `pre` accepted a hit
|
|
11
|
+
// on `vuln` OR `family` and regex-tested `cwe`, while `post` was strict on
|
|
12
|
+
// `vuln` and required an exact `cwe`. An entry therefore faced a LOOSER bar to
|
|
13
|
+
// score a true positive than a false positive, which flatters the corpus —
|
|
14
|
+
// exactly the direction a measurement must not lean. `CONTRIBUTING.md` recorded
|
|
15
|
+
// it as known imprecision to resolve before the corpus grows toward 500.
|
|
16
|
+
//
|
|
17
|
+
// WHICH DIRECTION, AND WHY. The two sides were unified on the LOOSE predicate,
|
|
18
|
+
// not the strict one. The question a corpus entry asks is "does the scanner
|
|
19
|
+
// still report this vulnerability?", and a scanner that reports it under the
|
|
20
|
+
// family name rather than the exact vuln string is still reporting it. Loose-
|
|
21
|
+
// on-both means an entry must genuinely go quiet to score `post:TN` — a HIGHER
|
|
22
|
+
// bar for a pass. Unifying on the strict predicate would instead have made
|
|
23
|
+
// `pre:TP` harder and `post:TN` easier, i.e. it would have made the corpus
|
|
24
|
+
// easier to satisfy. When a symmetry fix can go either way, take the direction
|
|
25
|
+
// that makes the gate harder to pass.
|
|
26
|
+
//
|
|
27
|
+
// This re-verdicts every committed entry, so it is a corpus migration: the full
|
|
28
|
+
// baseline was re-run against it and any entry whose verdict moved was fixed or
|
|
29
|
+
// recorded, never baselined over.
|
|
18
30
|
//
|
|
19
31
|
// The scanner emits into several arrays — `findings` (SAST), `secrets`,
|
|
20
32
|
// `supplyChain` (SCA) and `logicVulns` (business-logic + behavioural) — and a
|
|
@@ -35,18 +47,21 @@ function _any(scan, predicate) {
|
|
|
35
47
|
return false;
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
// One predicate, both sides. See the header for why the symmetric form is the
|
|
51
|
+
// LOOSE one.
|
|
52
|
+
function _matches(f, manifest, matcher) {
|
|
53
|
+
return (matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
|
|
54
|
+
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** Did the vulnerable (`pre/`) tree produce a matching finding? */
|
|
39
58
|
export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
40
|
-
return _any(scan, f =>
|
|
41
|
-
(matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
|
|
42
|
-
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true));
|
|
59
|
+
return _any(scan, f => _matches(f, manifest, matcher));
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/** Did the fixed (`post/`) tree still produce a matching finding? */
|
|
46
63
|
export function postHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
47
|
-
return _any(scan, f =>
|
|
48
|
-
matcher.test(f.vuln || '') &&
|
|
49
|
-
(manifest?.cwe ? f.cwe === manifest.cwe : true));
|
|
64
|
+
return _any(scan, f => _matches(f, manifest, matcher));
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export const _internals = { CHANNELS };
|
|
@@ -51,7 +51,31 @@ function _materialise(root, files) {
|
|
|
51
51
|
* patch: pass the patched contents and a still-`execution-proven` verdict
|
|
52
52
|
* means the fix did not close the hole.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
// How long a proof-of-concept gets to write its marker.
|
|
55
|
+
//
|
|
56
|
+
// WHY THIS IS GENEROUS, AND WHY THAT IS NEARLY FREE. The budget is only ever
|
|
57
|
+
// spent when a PoC is stuck: a working one writes its marker and exits in about
|
|
58
|
+
// a second, so raising the ceiling costs nothing in the common path. What a
|
|
59
|
+
// tight ceiling DOES cost is correctness — the budget covers spawning a
|
|
60
|
+
// confined process and starting a Node runtime inside it, and on a loaded
|
|
61
|
+
// machine that alone can eat several seconds. At 10s this timed out under
|
|
62
|
+
// ordinary parallel test load and reported "re-verification did not execute",
|
|
63
|
+
// which the release gate then surfaced as a failure. The measurement has to be
|
|
64
|
+
// of the proof-of-concept, not of how busy the machine happened to be.
|
|
65
|
+
//
|
|
66
|
+
// A timeout is still never evidence about the finding: `proven` is decided by
|
|
67
|
+
// the marker file, and a timed-out run falls back to the finding's static tier
|
|
68
|
+
// rather than claiming `proof-failed`. This ceiling only decides how long we
|
|
69
|
+
// wait before giving up, not what we conclude.
|
|
70
|
+
//
|
|
71
|
+
// Override on a slow or heavily-loaded runner, matching the convention used by
|
|
72
|
+
// AGENTIC_SECURITY_PY_PROBE_TIMEOUT_MS and AGENTIC_SECURITY_DEEP_TIMEOUT_MS.
|
|
73
|
+
export const DEFAULT_PROOF_TIMEOUT_MS =
|
|
74
|
+
Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS) > 0
|
|
75
|
+
? Number(process.env.AGENTIC_SECURITY_PROOF_TIMEOUT_MS)
|
|
76
|
+
: 45000;
|
|
77
|
+
|
|
78
|
+
export async function proveFinding(finding, { timeoutMs = DEFAULT_PROOF_TIMEOUT_MS, force, files } = {}) {
|
|
55
79
|
const poc = finding?.poc;
|
|
56
80
|
if (!poc?.code) {
|
|
57
81
|
return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
|
|
Binary file
|
package/src/posture/integrity.js
CHANGED
|
@@ -33,16 +33,23 @@ function _keyDir() {
|
|
|
33
33
|
}
|
|
34
34
|
function _keyPath() { return path.join(_keyDir(), 'scan-key'); }
|
|
35
35
|
|
|
36
|
+
// Where the active key came from. A signature is only as meaningful as the key
|
|
37
|
+
// behind it, and `env` means "whoever set the environment could sign this" —
|
|
38
|
+
// a reader of a scan artifact deserves to know which case they are in.
|
|
39
|
+
let _keySource = null;
|
|
40
|
+
export function keyProvenance() { return _keySource || 'unresolved'; }
|
|
41
|
+
|
|
36
42
|
function _readOrGenerateKey() {
|
|
37
43
|
const fromEnv = process.env.AGENTIC_SECURITY_HMAC_KEY;
|
|
38
44
|
if (fromEnv && /^[0-9a-fA-F]{32,}$/.test(fromEnv.trim())) {
|
|
45
|
+
_keySource = 'env';
|
|
39
46
|
return Buffer.from(fromEnv.trim(), 'hex');
|
|
40
47
|
}
|
|
41
48
|
const fp = _keyPath();
|
|
42
49
|
try {
|
|
43
50
|
if (fs.existsSync(fp)) {
|
|
44
51
|
const hex = fs.readFileSync(fp, 'utf8').trim();
|
|
45
|
-
if (/^[0-9a-fA-F]{32,}$/.test(hex)) return Buffer.from(hex, 'hex');
|
|
52
|
+
if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
|
|
46
53
|
}
|
|
47
54
|
} catch { /* fall through to generate */ }
|
|
48
55
|
// Generate, mode 0600.
|
|
@@ -50,13 +57,39 @@ function _readOrGenerateKey() {
|
|
|
50
57
|
try {
|
|
51
58
|
fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
|
|
52
59
|
fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
|
|
53
|
-
|
|
60
|
+
_keySource = 'per-install-new';
|
|
61
|
+
} catch {
|
|
62
|
+
// Could not persist — this key lives for this process only, so nothing
|
|
63
|
+
// signed with it will verify on any later run. Callers must be able to see
|
|
64
|
+
// that, or a permanently-unverifiable signature looks like a valid one.
|
|
65
|
+
_keySource = 'ephemeral';
|
|
66
|
+
}
|
|
54
67
|
return buf;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
// REMOVED (2026-08-08): the legacy hostname-derived key.
|
|
71
|
+
//
|
|
72
|
+
// It was `sha256(_HMAC_SALT + ':' + os.hostname())`. `_HMAC_SALT` is a constant
|
|
73
|
+
// in published, npm-shipped source and a hostname is not a secret — it appears
|
|
74
|
+
// in CI logs, build artifacts and error messages. So the "signature" could be
|
|
75
|
+
// forged by anyone who knew the target's hostname, which is to say by anyone.
|
|
76
|
+
//
|
|
77
|
+
// This was known. The 0.62.0 changelog introduced the per-install key precisely
|
|
78
|
+
// because the old one was "hostname-derived and publicly forgeable in CI /
|
|
79
|
+
// containers", and kept verification of the legacy key "for one release to
|
|
80
|
+
// migrate existing signed scans". The comment here said "Remove after one minor
|
|
81
|
+
// release." It was still accepted at 0.132.0 — SEVENTY minor releases later.
|
|
82
|
+
//
|
|
83
|
+
// What it cost: `rule-overrides.js` gates the `disable:` list on
|
|
84
|
+
// `verifyLastScan`, so a forged signature silently switched off arbitrary
|
|
85
|
+
// detectors and the scan reported clean. Demonstrated end to end before removal:
|
|
86
|
+
// with a hostname-forged `rules.yml.sig`, a command-injection finding went from
|
|
87
|
+
// 1 reported to 0.
|
|
88
|
+
//
|
|
89
|
+
// A migration window that nobody closes is not a migration window; it is the
|
|
90
|
+
// vulnerability, kept on purpose. Signatures made under the legacy key no longer
|
|
91
|
+
// verify — that is the intended consequence. Re-sign with `agentic-security
|
|
92
|
+
// rules sign`.
|
|
60
93
|
|
|
61
94
|
let _cachedKey = null;
|
|
62
95
|
function _key() {
|
|
@@ -85,13 +118,13 @@ export function verifyLastScan(body, sigFile) {
|
|
|
85
118
|
return crypto.timingSafeEqual(Buffer.from(stored, 'hex'), Buffer.from(expected, 'hex'));
|
|
86
119
|
} catch { return false; }
|
|
87
120
|
};
|
|
121
|
+
// ONE key. There is deliberately no fallback: a second accepted key is a
|
|
122
|
+
// second thing that can be forged, and the last one was forgeable by anyone
|
|
123
|
+
// who could read a hostname.
|
|
88
124
|
if (tryKey(_key())) return true;
|
|
89
|
-
// Legacy hostname-key path — accepted for verification only, not for new
|
|
90
|
-
// signatures. Remove after one minor release.
|
|
91
|
-
if (tryKey(_legacyHostnameKey())) return true;
|
|
92
125
|
return false;
|
|
93
126
|
}
|
|
94
127
|
|
|
95
128
|
// Test-only helpers (premortem-tracked):
|
|
96
|
-
export function _resetKeyCacheForTests() { _cachedKey = null; }
|
|
129
|
+
export function _resetKeyCacheForTests() { _cachedKey = null; _keySource = null; }
|
|
97
130
|
export function _keyFilePathForTests() { return _keyPath(); }
|
package/src/posture/learning.js
CHANGED
|
@@ -90,7 +90,14 @@ export function applyFeedback(scanRoot, findings) {
|
|
|
90
90
|
if (process.env.AGENTIC_SECURITY_LEARN !== '1') return { kept: findings, suppressed };
|
|
91
91
|
const data = loadFeedback(scanRoot);
|
|
92
92
|
if (!data.entries || !data.entries.length) return { kept: findings, suppressed };
|
|
93
|
-
|
|
93
|
+
// Floor of 2, not 1. A quorum of 1 means a SINGLE triage verdict suppresses a
|
|
94
|
+
// finding — and because suppression also matches on `family + filePattern`
|
|
95
|
+
// below, one verdict can silence a whole family across a path. That is
|
|
96
|
+
// precisely the case the root CLAUDE.md warns about ("think about what a
|
|
97
|
+
// malicious-PR-author could suppress"), and clamping to 1 let an env var
|
|
98
|
+
// request it. A value below 2 is now raised to 2 rather than honoured.
|
|
99
|
+
const _requested = parseInt(process.env.AGENTIC_SECURITY_LEARN_QUORUM || '2', 10);
|
|
100
|
+
const quorum = Number.isFinite(_requested) ? Math.max(2, _requested) : 2;
|
|
94
101
|
// Keep the most recent 500 entries by `at`.
|
|
95
102
|
const sorted = [...data.entries].sort((a, b) => String(a.at || '').localeCompare(String(b.at || ''))).slice(-500);
|
|
96
103
|
const fpCountById = new Map();
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// PRD Epic 6 — the business-logic tier's missing half.
|
|
2
|
+
//
|
|
3
|
+
// The deterministic side of business logic already exists and is wired:
|
|
4
|
+
// `sast/logic.js` carries the canonical anti-patterns, `posture/business-logic.js`
|
|
5
|
+
// builds the per-route authZ matrix, extracts state machines and finds
|
|
6
|
+
// negative-test gaps. What did NOT exist was any handling of the OTHER
|
|
7
|
+
// producer — the reviewing agent, which is the only party that can read intent
|
|
8
|
+
// and is therefore the only one that can find the flaws patterns cannot.
|
|
9
|
+
//
|
|
10
|
+
// THE PROBLEM WITH THAT PRODUCER. Everything else in this engine can be
|
|
11
|
+
// checked: a taint finding has a path, an execution-proven finding has a marker
|
|
12
|
+
// file, an SCA finding has a version range. A logic claim is prose. It arrives
|
|
13
|
+
// asserting that a handler lets one user act on another's resource, and there
|
|
14
|
+
// is nothing in the finding that a second party could disagree with. An
|
|
15
|
+
// unrefutable claim is the weakest thing this engine emits, and it was the only
|
|
16
|
+
// tier with no way to be wrong.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS MODULE DOES. It takes claims from a reviewing agent and puts them
|
|
19
|
+
// through deterministic lenses that can REFUTE them — cheaply, offline, and
|
|
20
|
+
// without asking a model to grade its own homework:
|
|
21
|
+
//
|
|
22
|
+
// citation — the file exists and the cited line is inside it. A claim about
|
|
23
|
+
// `routes/orders.js:214` in a 90-line file is refuted on the
|
|
24
|
+
// spot; that is the signature of a fabricated location.
|
|
25
|
+
// quotation — the snippet the claim quotes actually appears at the cited
|
|
26
|
+
// line (± a small window). A claim that misquotes the code it is
|
|
27
|
+
// about was not written from the code.
|
|
28
|
+
// corroboration — for the claim kinds that MAKE a checkable assertion about
|
|
29
|
+
// the source ("this route has no authentication"), check it.
|
|
30
|
+
// A route that plainly does authenticate refutes it.
|
|
31
|
+
//
|
|
32
|
+
// RECALL-PRESERVING, same precedent as `falsification.js` and `proof-gate.js`.
|
|
33
|
+
// A refuted claim is marked and kept, never deleted and never severity-touched.
|
|
34
|
+
// Refutation here means "no second party could corroborate this", which is a
|
|
35
|
+
// triage signal, not proof the reviewer was wrong.
|
|
36
|
+
//
|
|
37
|
+
// SEPARATION IS ENFORCED, NOT ASSUMED. The agent is stamped as producer and
|
|
38
|
+
// these lenses record under their own verifier ids, so `assertSeparation`
|
|
39
|
+
// refuses if anything ever tries to verify its own claim. That is why the
|
|
40
|
+
// lenses live here in deterministic code rather than in the agent's prompt: a
|
|
41
|
+
// reviewer asked to double-check itself is the same party voting twice.
|
|
42
|
+
|
|
43
|
+
import { recordProducer, recordVerdict, consensusOf } from './verification-separation.js';
|
|
44
|
+
|
|
45
|
+
export const PRODUCER = 'agent:logic-reviewer';
|
|
46
|
+
|
|
47
|
+
export const VERIFIER_CITATION = 'verifier:citation';
|
|
48
|
+
const VERIFIER_QUOTATION = 'verifier:quotation';
|
|
49
|
+
export const VERIFIER_CORROBORATION = 'verifier:logic-corroboration';
|
|
50
|
+
|
|
51
|
+
// Claim kinds that assert something checkable about the source. Anything else
|
|
52
|
+
// is accepted as unverifiable-but-recorded rather than silently upheld.
|
|
53
|
+
const CLAIM_KINDS = Object.freeze([
|
|
54
|
+
'missing-authentication',
|
|
55
|
+
'missing-authorization',
|
|
56
|
+
'missing-ownership-check',
|
|
57
|
+
'state-transition-bypass',
|
|
58
|
+
'race-condition',
|
|
59
|
+
'other',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Reused deliberately from the same vocabulary the authZ matrix uses, so a
|
|
63
|
+
// corroboration verdict and a matrix finding cannot disagree about what
|
|
64
|
+
// "authenticated" means in this codebase.
|
|
65
|
+
const AUTH_HINTS = [
|
|
66
|
+
/\breq\.user\b/, /\breq\.auth\b/, /\brequest\.user\b/,
|
|
67
|
+
/requireAuth|isAuthenticated|@login_required|@requires_auth|@jwt_required/,
|
|
68
|
+
/authorize|authMiddleware|verifyJWT|jwt\.verify\b/, /\bpassport\b/,
|
|
69
|
+
/\bgetSession\b|\bcurrentUser\b/,
|
|
70
|
+
];
|
|
71
|
+
const OWNERSHIP_HINTS = [
|
|
72
|
+
/\bowner(?:Id)?\b/i, /\buser_?id\s*[=:]/i,
|
|
73
|
+
/\.userId\s*===\s*req\.user/, /\.owner\s*===\s*req\.user/,
|
|
74
|
+
/where\s*:\s*\{[^}]*user/i,
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// How far from the cited line a quoted snippet may appear before the citation
|
|
78
|
+
// is treated as not corroborated. Small on purpose: an agent reading the file
|
|
79
|
+
// is off by a line or two, not by twenty.
|
|
80
|
+
const QUOTE_WINDOW = 3;
|
|
81
|
+
|
|
82
|
+
function _lines(content) { return String(content).split('\n'); }
|
|
83
|
+
|
|
84
|
+
function _normalize(s) {
|
|
85
|
+
return String(s).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The enclosing handler body around a line, bounded by blank-line-separated
|
|
90
|
+
* top-level blocks. Deliberately crude: a corroboration lens that guessed at
|
|
91
|
+
* scope precisely would be a parser, and a wrong guess here REFUTES a real
|
|
92
|
+
* finding. So the window is generous — it errs toward finding the auth check
|
|
93
|
+
* and therefore toward refusing to refute.
|
|
94
|
+
*/
|
|
95
|
+
function _enclosingBlock(content, line) {
|
|
96
|
+
const ls = _lines(content);
|
|
97
|
+
const idx = Math.max(0, Math.min(ls.length - 1, line - 1));
|
|
98
|
+
let start = idx, end = idx;
|
|
99
|
+
while (start > 0 && !/^\s*$/.test(ls[start - 1])) start--;
|
|
100
|
+
while (end < ls.length - 1 && !/^\s*$/.test(ls[end + 1])) end++;
|
|
101
|
+
// Widen by a few lines either side: middleware often sits on the route line
|
|
102
|
+
// above the block the flaw is in.
|
|
103
|
+
start = Math.max(0, start - 5);
|
|
104
|
+
end = Math.min(ls.length - 1, end + 5);
|
|
105
|
+
return ls.slice(start, end + 1).join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Put one claim through the deterministic lenses.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} claim {file, line, vuln, kind, description, snippet?, severity?}
|
|
112
|
+
* @param {object|Map} fileContents file -> source
|
|
113
|
+
* @returns {object} the claim as a finding, carrying `verification`
|
|
114
|
+
*/
|
|
115
|
+
export function verifyLogicClaim(claim, fileContents) {
|
|
116
|
+
const finding = {
|
|
117
|
+
...claim,
|
|
118
|
+
parser: 'LOGIC-AGENT',
|
|
119
|
+
family: claim.family || 'business-logic',
|
|
120
|
+
kind: CLAIM_KINDS.includes(claim.kind) ? claim.kind : 'other',
|
|
121
|
+
};
|
|
122
|
+
recordProducer(finding, claim.producer || PRODUCER);
|
|
123
|
+
|
|
124
|
+
const read = (f) => {
|
|
125
|
+
if (!fileContents) return null;
|
|
126
|
+
if (typeof fileContents.get === 'function') return fileContents.get(f) ?? null;
|
|
127
|
+
return fileContents[f] ?? null;
|
|
128
|
+
};
|
|
129
|
+
const content = claim.file ? read(claim.file) : null;
|
|
130
|
+
|
|
131
|
+
// ── citation ──────────────────────────────────────────────────────────────
|
|
132
|
+
if (content === null) {
|
|
133
|
+
recordVerdict(finding, {
|
|
134
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
|
|
135
|
+
reason: `no file '${claim.file}' was scanned, so the cited location does not exist`,
|
|
136
|
+
});
|
|
137
|
+
finding.consensus = consensusOf(finding);
|
|
138
|
+
finding.quarantined = true;
|
|
139
|
+
return finding;
|
|
140
|
+
}
|
|
141
|
+
const total = _lines(content).length;
|
|
142
|
+
const line = Number(claim.line);
|
|
143
|
+
if (!Number.isInteger(line) || line < 1 || line > total) {
|
|
144
|
+
recordVerdict(finding, {
|
|
145
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'refuted',
|
|
146
|
+
reason: `cited line ${claim.line} is outside ${claim.file} (${total} lines)`,
|
|
147
|
+
});
|
|
148
|
+
} else {
|
|
149
|
+
recordVerdict(finding, {
|
|
150
|
+
verifierId: VERIFIER_CITATION, lens: 'citation', verdict: 'upheld',
|
|
151
|
+
reason: `${claim.file}:${line} exists`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── quotation ─────────────────────────────────────────────────────────────
|
|
156
|
+
// Only a lens when the claim actually quotes something. A claim with no
|
|
157
|
+
// snippet is UNDECIDED here, not upheld — silence is not corroboration.
|
|
158
|
+
if (!claim.snippet || !String(claim.snippet).trim()) {
|
|
159
|
+
recordVerdict(finding, {
|
|
160
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
|
|
161
|
+
reason: 'the claim quotes no source, so there is nothing to check it against',
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
const want = _normalize(claim.snippet);
|
|
165
|
+
const ls = _lines(content);
|
|
166
|
+
const lo = Math.max(0, (line || 1) - 1 - QUOTE_WINDOW);
|
|
167
|
+
const hi = Math.min(ls.length, (line || 1) + QUOTE_WINDOW);
|
|
168
|
+
const window = _normalize(ls.slice(lo, hi).join(' '));
|
|
169
|
+
const anywhere = _normalize(content);
|
|
170
|
+
if (window.includes(want)) {
|
|
171
|
+
recordVerdict(finding, {
|
|
172
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'upheld',
|
|
173
|
+
reason: 'the quoted source appears at the cited line',
|
|
174
|
+
});
|
|
175
|
+
} else if (anywhere.includes(want)) {
|
|
176
|
+
// Right file, wrong line. Not a fabrication, but the location is not
|
|
177
|
+
// usable as-is, so it is not corroboration either.
|
|
178
|
+
recordVerdict(finding, {
|
|
179
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'undecided',
|
|
180
|
+
reason: 'the quoted source is in the file but not at the cited line',
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
recordVerdict(finding, {
|
|
184
|
+
verifierId: VERIFIER_QUOTATION, lens: 'quotation', verdict: 'refuted',
|
|
185
|
+
reason: 'the quoted source does not appear in the cited file',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── corroboration ─────────────────────────────────────────────────────────
|
|
191
|
+
const block = _enclosingBlock(content, line || 1);
|
|
192
|
+
if (finding.kind === 'missing-authentication') {
|
|
193
|
+
const hit = AUTH_HINTS.find((re) => re.test(block));
|
|
194
|
+
recordVerdict(finding, hit
|
|
195
|
+
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
|
|
196
|
+
reason: `the handler around this line does authenticate (${hit.source})` }
|
|
197
|
+
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
|
|
198
|
+
reason: 'no authentication marker anywhere in the enclosing handler' });
|
|
199
|
+
} else if (finding.kind === 'missing-authorization' || finding.kind === 'missing-ownership-check') {
|
|
200
|
+
const hit = OWNERSHIP_HINTS.find((re) => re.test(block));
|
|
201
|
+
recordVerdict(finding, hit
|
|
202
|
+
? { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'refuted',
|
|
203
|
+
reason: `the handler around this line does scope the record to a user (${hit.source})` }
|
|
204
|
+
: { verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'upheld',
|
|
205
|
+
reason: 'no ownership scoping in the enclosing handler' });
|
|
206
|
+
} else {
|
|
207
|
+
// No deterministic lens exists for this kind. Said out loud rather than
|
|
208
|
+
// counted as agreement — an unchecked claim and a corroborated one must
|
|
209
|
+
// not read the same in the consensus.
|
|
210
|
+
recordVerdict(finding, {
|
|
211
|
+
verifierId: VERIFIER_CORROBORATION, lens: 'authz', verdict: 'undecided',
|
|
212
|
+
reason: `no deterministic lens covers claim kind '${finding.kind}'`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
finding.consensus = consensusOf(finding);
|
|
217
|
+
// Quarantine, not deletion — the same contract falsification uses.
|
|
218
|
+
finding.quarantined = finding.consensus.verdict === 'refuted';
|
|
219
|
+
return finding;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Verify a batch. Nothing is dropped: the returned list is the same length as
|
|
224
|
+
* the input, in the same order.
|
|
225
|
+
*/
|
|
226
|
+
export function ingestLogicClaims(claims, { fileContents = null } = {}) {
|
|
227
|
+
const list = Array.isArray(claims) ? claims : [];
|
|
228
|
+
const out = list.map((c) => {
|
|
229
|
+
try { return verifyLogicClaim(c, fileContents); }
|
|
230
|
+
catch (e) {
|
|
231
|
+
// A lens that throws must not swallow the claim.
|
|
232
|
+
const f = { ...c, parser: 'LOGIC-AGENT', family: 'business-logic', quarantined: false };
|
|
233
|
+
f.consensus = { verdict: 'undecided', upheld: 0, refuted: 0, undecided: 0, lenses: [] };
|
|
234
|
+
f.verificationError = String(e?.message || e);
|
|
235
|
+
return f;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return { claims: out, summary: summarizeLogicClaims(out) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function summarizeLogicClaims(claims) {
|
|
242
|
+
const s = { total: claims.length, corroborated: 0, refuted: 0, unverifiable: 0 };
|
|
243
|
+
for (const c of claims) {
|
|
244
|
+
const v = c.consensus?.verdict;
|
|
245
|
+
if (v === 'upheld') s.corroborated++;
|
|
246
|
+
else if (v === 'refuted') s.refuted++;
|
|
247
|
+
else s.unverifiable++;
|
|
248
|
+
}
|
|
249
|
+
return s;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One line. Leads with what could not be corroborated. */
|
|
253
|
+
export function renderLogicClaimSummary(s) {
|
|
254
|
+
if (!s || !s.total) return null;
|
|
255
|
+
const bits = [`${s.total} business-logic claim(s)`];
|
|
256
|
+
if (s.refuted) bits.push(`${s.refuted} REFUTED by a deterministic lens (quarantined, not deleted)`);
|
|
257
|
+
if (s.unverifiable) bits.push(`${s.unverifiable} unverifiable — no lens could agree or disagree`);
|
|
258
|
+
if (s.corroborated) bits.push(`${s.corroborated} corroborated`);
|
|
259
|
+
return bits.join('; ') + '.';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Not exported: the quotation verifier id, the claim-kind vocabulary and the
|
|
263
|
+
// batch summariser have no consumer outside this module. Kept internal rather
|
|
264
|
+
// than exported-and-unused — an export with no call site is how dead code gets
|
|
265
|
+
// shipped and then trusted.
|
|
266
|
+
export const _internals = { AUTH_HINTS, OWNERSHIP_HINTS, _enclosingBlock, QUOTE_WINDOW, CLAIM_KINDS, VERIFIER_QUOTATION, summarizeLogicClaims };
|