@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 +221 -0
- package/bin/agentic-security.js +40 -11
- package/dist/113.index.js +79 -3
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +77 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +12 -0
- package/dist/526.index.js +79 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +34 -7
- package/dist/compliance-frameworks/eu-ai-act.json +65 -14
- package/dist/compliance-frameworks/gdpr.json +56 -12
- package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/dist/compliance-frameworks/nist-csf-2.json +78 -16
- package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/package.json +16 -5
- package/src/dataflow/catalog.js +61 -0
- package/src/engine.js +281 -23
- package/src/mcp/tools.js +12 -0
- package/src/posture/accuracy-scorecard.js +57 -0
- package/src/posture/aibom.js +110 -1
- package/src/posture/auditor-walkthrough.js +137 -21
- package/src/posture/compliance-frameworks/ccpa.json +34 -7
- package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
- package/src/posture/compliance-frameworks/gdpr.json +56 -12
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/src/posture/concurrency-checker.js +42 -5
- package/src/posture/coverage-strength.js +182 -0
- package/src/posture/epss.js +17 -1
- package/src/posture/family-registry.js +103 -0
- package/src/posture/family-resolve.js +47 -0
- package/src/posture/fix-coverage.js +113 -0
- package/src/posture/fix-metrics.js +76 -0
- package/src/posture/integrity.js +59 -8
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-generator.js +17 -1
- package/src/posture/poc-inprocess.js +217 -1
- package/src/posture/proof-coverage.js +162 -0
- package/src/posture/reachability-filter.js +44 -0
- package/src/posture/sbom.js +50 -7
- package/src/runScan.js +56 -5
- package/src/sast/CLAUDE.md +2 -2
- package/src/sast/claude-md-prompt-injection.js +47 -3
- package/src/sast/cloud-iam.js +23 -0
- package/src/sast/convention-deviation.js +66 -3
- package/src/sast/crypto-protocol.js +23 -0
- package/src/sast/dapp-frontend.js +20 -0
- package/src/sast/iac-cloud-templates.js +337 -0
- package/src/sast/k8s-admission.js +27 -0
- package/src/sast/ml-supply-chain.js +22 -0
- package/src/sast/ruby.js +132 -0
- package/src/sast/web3-advanced.js +26 -0
- package/src/sca/CLAUDE.md +21 -4
- package/src/sca/container.js +18 -1
- package/src/sca/dep-confusion.js +69 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// PRD F6.5 — publish the proportion of findings this engine DECLINES to fix.
|
|
2
|
+
//
|
|
3
|
+
// A remediation feature that silently attempts everything is less trustworthy
|
|
4
|
+
// than one that declines 40% and says so. Before this, the only visible number
|
|
5
|
+
// was about fixes that were attempted; a finding for which synthesis was never
|
|
6
|
+
// even tried simply did not appear, so the denominator quietly excluded every
|
|
7
|
+
// hard case.
|
|
8
|
+
//
|
|
9
|
+
// THE BUCKETS
|
|
10
|
+
//
|
|
11
|
+
// deterministic — a context-independent literal swap exists (md5 -> sha256,
|
|
12
|
+
// TLS verify off -> on). Highest confidence: the patch does
|
|
13
|
+
// not depend on reading intent.
|
|
14
|
+
// model — no deterministic patch, but the finding is a shape a model
|
|
15
|
+
// can be asked to fix. Counted as ATTEMPTABLE, not as fixed:
|
|
16
|
+
// whether the attempt succeeds is fix-metrics.js's question.
|
|
17
|
+
// declined — synthesis refuses, with a reason. Not a failure; a limit
|
|
18
|
+
// stated up front.
|
|
19
|
+
//
|
|
20
|
+
// `declined` and `model` are kept apart for the same reason proof-coverage
|
|
21
|
+
// separates `indeterminate` from `unclassified`: "we will not try" and "we will
|
|
22
|
+
// try and might fail" are different promises, and merging them lets the weaker
|
|
23
|
+
// one borrow the stronger one's credibility.
|
|
24
|
+
import { synthesizeDeterministicPatch } from './deterministic-fix.js';
|
|
25
|
+
|
|
26
|
+
// Families where a patch cannot be synthesised from the finding alone, with the
|
|
27
|
+
// reason. Stated as DATA so a report can print why, rather than leaving a reader
|
|
28
|
+
// to assume the engine simply has not got round to it.
|
|
29
|
+
export const DECLINED_TO_FIX = Object.freeze({
|
|
30
|
+
'broken-access-control': 'the correct authorisation rule is a product decision — a scanner that invents one is guessing at intent, and a wrong authz patch fails open.',
|
|
31
|
+
'idor': 'same as broken-access-control: which identity may read which record is not recoverable from the code.',
|
|
32
|
+
'broken-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
|
|
33
|
+
'business-logic': 'by definition the defect is a mismatch with intent, and intent is not in the file.',
|
|
34
|
+
'concurrency-bug': 'the correct lock discipline depends on the whole call graph; a local patch can deadlock rather than fix.',
|
|
35
|
+
'license-graph': 'a licence conflict is resolved by a policy or a dependency decision, not by editing code.',
|
|
36
|
+
'vulnerable-dep': 'resolved by an upgrade, which is apply_sca_upgrade\'s job, not a source patch.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Bucket a finding: 'deterministic' | 'model' | 'declined'. */
|
|
40
|
+
export function fixBucketOf(finding, fileContent) {
|
|
41
|
+
const fam = (finding && finding.family) || '';
|
|
42
|
+
for (const key of Object.keys(DECLINED_TO_FIX)) {
|
|
43
|
+
if (fam === key || fam.startsWith(`${key}-`)) return 'declined';
|
|
44
|
+
}
|
|
45
|
+
if (typeof fileContent === 'string' && fileContent) {
|
|
46
|
+
try {
|
|
47
|
+
const p = synthesizeDeterministicPatch(finding, fileContent);
|
|
48
|
+
if (p && p.ok !== false && (p.patch || p.replacement)) return 'deterministic';
|
|
49
|
+
} catch { /* fall through — an erroring synthesiser is not a fix */ }
|
|
50
|
+
}
|
|
51
|
+
return 'model';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fix coverage over a finding set.
|
|
56
|
+
*
|
|
57
|
+
* Every share carries {n, d}. `fileContents` is optional: without it the
|
|
58
|
+
* deterministic check cannot run, and rather than guessing, those findings fall
|
|
59
|
+
* to `model` and `deterministicChecked` reports false so a reader knows the
|
|
60
|
+
* split is a lower bound on deterministic coverage.
|
|
61
|
+
*/
|
|
62
|
+
export function fixCoverage(findings, fileContents = null) {
|
|
63
|
+
const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
|
|
64
|
+
const d = list.length;
|
|
65
|
+
const buckets = { deterministic: [], model: [], declined: [] };
|
|
66
|
+
for (const f of list) {
|
|
67
|
+
const src = fileContents && f.file ? fileContents[f.file] : null;
|
|
68
|
+
buckets[fixBucketOf(f, src)].push(f);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const declinedByFamily = {};
|
|
72
|
+
for (const f of buckets.declined) {
|
|
73
|
+
const fam = f.family || '(none)';
|
|
74
|
+
const key = Object.keys(DECLINED_TO_FIX).find(k => fam === k || fam.startsWith(`${k}-`)) || fam;
|
|
75
|
+
if (!declinedByFamily[key]) declinedByFamily[key] = { n: 0, reason: DECLINED_TO_FIX[key] || 'declined' };
|
|
76
|
+
declinedByFamily[key].n += 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
total: d,
|
|
81
|
+
deterministic: { n: buckets.deterministic.length, d },
|
|
82
|
+
model: { n: buckets.model.length, d },
|
|
83
|
+
declined: { n: buckets.declined.length, d, byFamily: declinedByFamily },
|
|
84
|
+
deterministicChecked: !!fileContents,
|
|
85
|
+
meaning: 'deterministic = a context-independent patch exists; model = attemptable by a model, NOT known to succeed; declined = synthesis refuses with a stated reason.',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Markdown for the scorecard. Denominators always attached. */
|
|
90
|
+
export function renderFixCoverage(cov) {
|
|
91
|
+
if (!cov || !cov.total) return '_No findings to report fix coverage over._\n';
|
|
92
|
+
const pct = (n) => `${n}/${cov.total} (${Math.round((n / cov.total) * 100)}%)`;
|
|
93
|
+
const lines = [
|
|
94
|
+
'| Bucket | Share | Meaning |',
|
|
95
|
+
'|---|---|---|',
|
|
96
|
+
`| Deterministic patch | ${pct(cov.deterministic.n)} | context-independent literal swap |`,
|
|
97
|
+
`| Model-attemptable | ${pct(cov.model.n)} | can be attempted; success not claimed here |`,
|
|
98
|
+
`| Declined | ${pct(cov.declined.n)} | synthesis refuses — reasons below |`,
|
|
99
|
+
'',
|
|
100
|
+
];
|
|
101
|
+
if (!cov.deterministicChecked) {
|
|
102
|
+
lines.push('_Source was not supplied, so the deterministic check could not run: the'
|
|
103
|
+
+ ' deterministic share is a LOWER bound and those findings are counted as'
|
|
104
|
+
+ ' model-attemptable._', '');
|
|
105
|
+
}
|
|
106
|
+
const entries = Object.entries(cov.declined.byFamily).sort((a, b) => b[1].n - a[1].n);
|
|
107
|
+
if (entries.length) {
|
|
108
|
+
lines.push('**Why each family is declined**', '');
|
|
109
|
+
for (const [fam, { n, reason }] of entries) lines.push(`- \`${fam}\` (${n}): ${reason}`);
|
|
110
|
+
lines.push('');
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
@@ -195,3 +195,79 @@ export function renderFixDurationSummary(sum) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
export const _internals = { _dist, _pct, RELIABLE_N };
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
// ── PRD F6.1 — score fixes on THREE AXES, not one ──────────────────────────
|
|
201
|
+
//
|
|
202
|
+
// The three axes the PRD names:
|
|
203
|
+
// (a) does the finding disappear — the rescan leg
|
|
204
|
+
// (b) does the project's own suite pass — the tests leg
|
|
205
|
+
// (c) does an independent verifier agree — the PoC re-check leg
|
|
206
|
+
//
|
|
207
|
+
// All three were already computed by verifyFixCore and then collapsed into one
|
|
208
|
+
// boolean, which is the problem: **(a) alone is satisfiable by deleting code.**
|
|
209
|
+
// A patch that removes the vulnerable function passes the rescan, has nothing
|
|
210
|
+
// left to fail, and — on a project with no detectable test suite — reaches
|
|
211
|
+
// ok:true having proven only that the detector went quiet.
|
|
212
|
+
//
|
|
213
|
+
// Reporting the axes separately makes that visible. `aOnly` is the number that
|
|
214
|
+
// matters most and the one nobody was publishing: attempts that satisfied ONLY
|
|
215
|
+
// the disappearance axis. A high aOnly with a high headline is the shape of a
|
|
216
|
+
// remediation feature that is deleting code and calling it a fix.
|
|
217
|
+
export function summarizeFixAxes(attempts) {
|
|
218
|
+
const list = Array.isArray(attempts) ? attempts.filter(Boolean) : [];
|
|
219
|
+
const d = list.length;
|
|
220
|
+
|
|
221
|
+
const rate = (pred) => ({ n: list.filter(pred).length, d });
|
|
222
|
+
|
|
223
|
+
// Each axis is judged INDEPENDENTLY of the overall verdict, so a leg that
|
|
224
|
+
// passed inside a failed attempt still counts for its own axis. Reading them
|
|
225
|
+
// off `ok` would make the three axes three copies of the same number.
|
|
226
|
+
const findingDisappeared = rate((a) => a.rescanOk === true || (a.ok === true && a.rescanOk !== false));
|
|
227
|
+
const testsStillPass = rate((a) => a.testsRan === true && a.testsOk !== false);
|
|
228
|
+
const verifierAgrees = rate((a) => a.pocOk === true);
|
|
229
|
+
|
|
230
|
+
const satisfiesAll = rate((a) =>
|
|
231
|
+
(a.rescanOk === true || (a.ok === true && a.rescanOk !== false))
|
|
232
|
+
&& a.testsRan === true && a.testsOk !== false
|
|
233
|
+
&& a.pocOk === true);
|
|
234
|
+
|
|
235
|
+
// The honesty number: disappearance WITHOUT either corroborating axis.
|
|
236
|
+
const aOnly = rate((a) => {
|
|
237
|
+
const disappeared = a.rescanOk === true || (a.ok === true && a.rescanOk !== false);
|
|
238
|
+
const corroborated = (a.testsRan === true && a.testsOk !== false) || a.pocOk === true;
|
|
239
|
+
return disappeared && !corroborated;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
total: d,
|
|
244
|
+
findingDisappeared,
|
|
245
|
+
testsStillPass,
|
|
246
|
+
verifierAgrees,
|
|
247
|
+
satisfiesAll,
|
|
248
|
+
aOnly,
|
|
249
|
+
meaning:
|
|
250
|
+
'findingDisappeared = the detector went quiet; testsStillPass = the project suite ran AND passed; '
|
|
251
|
+
+ 'verifierAgrees = an independent PoC re-check confirmed the hole is shut. '
|
|
252
|
+
+ 'aOnly counts attempts that satisfied ONLY disappearance — the shape a code-deleting "fix" produces.',
|
|
253
|
+
caveat: d === 0
|
|
254
|
+
? 'no attempts recorded; every rate is 0/0 and means nothing'
|
|
255
|
+
: 'rates carry {n,d}; a small d is indicative, not settled',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Markdown for a report. Denominators always attached. */
|
|
260
|
+
export function renderFixAxes(sum) {
|
|
261
|
+
if (!sum || !sum.total) return '_No fix attempts recorded._\n';
|
|
262
|
+
const row = (label, r, note) => `| ${label} | ${r.n}/${r.d} | ${note} |`;
|
|
263
|
+
return [
|
|
264
|
+
'| Axis | Rate | Meaning |',
|
|
265
|
+
'|---|---|---|',
|
|
266
|
+
row('(a) finding disappeared', sum.findingDisappeared, 'the detector went quiet'),
|
|
267
|
+
row('(b) project tests pass', sum.testsStillPass, 'the suite RAN and passed'),
|
|
268
|
+
row('(c) verifier agrees', sum.verifierAgrees, 'an independent PoC re-check confirmed it'),
|
|
269
|
+
row('all three', sum.satisfiesAll, 'the only row that means "fixed"'),
|
|
270
|
+
row('(a) ALONE', sum.aOnly, 'satisfiable by deleting code — watch this number'),
|
|
271
|
+
'',
|
|
272
|
+
].join('\n');
|
|
273
|
+
}
|
package/src/posture/integrity.js
CHANGED
|
@@ -39,6 +39,46 @@ function _keyPath() { return path.join(_keyDir(), 'scan-key'); }
|
|
|
39
39
|
let _keySource = null;
|
|
40
40
|
export function keyProvenance() { return _keySource || 'unresolved'; }
|
|
41
41
|
|
|
42
|
+
// `writeFileSync(fp, …, {flag:'wx'})` is exclusive-CREATE, not atomic
|
|
43
|
+
// create-with-content: it creates the file and THEN writes it. A concurrent
|
|
44
|
+
// process that opens the path in that window reads an empty or partial file,
|
|
45
|
+
// fails the hex check, and falls through to an ephemeral key — whose signatures
|
|
46
|
+
// verify nowhere, forever, indistinguishable from real tampering. That is the
|
|
47
|
+
// same failure the `wx` flag was added to prevent, just through a narrower
|
|
48
|
+
// window, and CI caught it: 1 of 8 concurrently-generated signatures failed to
|
|
49
|
+
// verify under the install key.
|
|
50
|
+
//
|
|
51
|
+
// Writing the full content to a temp file and hard-LINKING it into place closes
|
|
52
|
+
// the window. link(2) is atomic and fails with EEXIST rather than clobbering, so
|
|
53
|
+
// the destination path only ever appears with complete content, and the
|
|
54
|
+
// first-writer-wins guarantee is preserved. Some filesystems (and Windows in
|
|
55
|
+
// places) refuse hard links, so an unsupported link degrades to the previous
|
|
56
|
+
// exclusive-create behaviour rather than failing the scan.
|
|
57
|
+
function _publishKeyAtomically(fp, contents) {
|
|
58
|
+
const dir = _keyDir();
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
const tmp = path.join(dir, `scan-key.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
61
|
+
try {
|
|
62
|
+
fs.writeFileSync(tmp, contents, { mode: 0o600 });
|
|
63
|
+
try {
|
|
64
|
+
fs.linkSync(tmp, fp);
|
|
65
|
+
return 'created';
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e.code === 'EEXIST') return 'exists';
|
|
68
|
+
// Hard links unsupported here — fall back, accepting the narrower race.
|
|
69
|
+
try {
|
|
70
|
+
fs.writeFileSync(fp, contents, { mode: 0o600, flag: 'wx' });
|
|
71
|
+
return 'created';
|
|
72
|
+
} catch (e2) {
|
|
73
|
+
if (e2.code === 'EEXIST') return 'exists';
|
|
74
|
+
throw e2;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
function _readOrGenerateKey() {
|
|
43
83
|
const fromEnv = process.env.AGENTIC_SECURITY_HMAC_KEY;
|
|
44
84
|
if (fromEnv && /^[0-9a-fA-F]{32,}$/.test(fromEnv.trim())) {
|
|
@@ -62,19 +102,30 @@ function _readOrGenerateKey() {
|
|
|
62
102
|
// forever after, indistinguishable from real tampering.
|
|
63
103
|
const buf = crypto.randomBytes(32);
|
|
64
104
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
const outcome = _publishKeyAtomically(fp, buf.toString('hex') + '\n');
|
|
106
|
+
if (outcome === 'created') { _keySource = 'per-install-new'; return buf; }
|
|
107
|
+
// Another process published first — fall through to the EEXIST path and
|
|
108
|
+
// adopt ITS key, exactly as before.
|
|
109
|
+
const e = new Error('key already published'); e.code = 'EEXIST'; throw e;
|
|
69
110
|
} catch (e) {
|
|
70
111
|
if (e.code === 'EEXIST') {
|
|
71
112
|
// Another process won the race and persisted its key first — use
|
|
72
113
|
// THAT key instead of the one we generated, or we'd return a key
|
|
73
114
|
// that matches nothing on disk.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
115
|
+
// Bounded retry, defence-in-depth. With the atomic link publish above the
|
|
116
|
+
// winner's key is complete the instant the path exists, so one read is
|
|
117
|
+
// enough. It is NOT enough when the link fell back to exclusive-create
|
|
118
|
+
// (filesystems without hard links), or when an older version of this file
|
|
119
|
+
// left a torn key behind — there the content can still be arriving. A few
|
|
120
|
+
// short retries cost nothing and the alternative is an ephemeral key whose
|
|
121
|
+
// signatures never verify again.
|
|
122
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
const hex = fs.readFileSync(fp, 'utf8').trim();
|
|
125
|
+
if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
|
|
126
|
+
} catch { /* not readable yet */ }
|
|
127
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2); } catch { /* no sleep available */ }
|
|
128
|
+
}
|
|
78
129
|
}
|
|
79
130
|
// Could not persist (or the winner's key was unreadable/malformed) —
|
|
80
131
|
// this key lives for this process only, so nothing signed with it will
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// PRD F5.2 — rug-pull detection for MCP tool definitions.
|
|
2
|
+
//
|
|
3
|
+
// THE ATTACK
|
|
4
|
+
// ----------
|
|
5
|
+
// A user approves an MCP server by reading what its tools claim to do. The
|
|
6
|
+
// agent then loads those tool DESCRIPTIONS into its context on every session,
|
|
7
|
+
// and acts on them. If a description changes after approval — new instructions
|
|
8
|
+
// appended, the schema widened to accept a path it never took, the stated
|
|
9
|
+
// purpose rewritten — the agent obeys the new text while the human still
|
|
10
|
+
// believes they approved the old one.
|
|
11
|
+
//
|
|
12
|
+
// Nothing in mcp-audit.js could see this. Every rule there judges a definition
|
|
13
|
+
// on its CURRENT content, so a description that is innocuous today and hostile
|
|
14
|
+
// tomorrow passes both times. Rug-pull is a property of the CHANGE, not of any
|
|
15
|
+
// single snapshot, which is why it needs its own mechanism.
|
|
16
|
+
//
|
|
17
|
+
// WHAT IS AND IS NOT A RUG-PULL
|
|
18
|
+
// -----------------------------
|
|
19
|
+
// A NEW tool is not a rug-pull — nobody approved it yet, and mcp-audit judges it
|
|
20
|
+
// on content like any other. A REMOVED tool is not a rug-pull either; it can no
|
|
21
|
+
// longer instruct anything. The finding is specifically: this exact tool name
|
|
22
|
+
// was seen before, and what it says has changed since.
|
|
23
|
+
//
|
|
24
|
+
// The FIRST run records and reports nothing. There is no prior state to compare
|
|
25
|
+
// against, and inventing a finding on first sight would make every new project
|
|
26
|
+
// noisy while teaching people to ignore the rule that matters.
|
|
27
|
+
import crypto from 'node:crypto';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { statePath, stateWritesEnabled, isSafeStateDir } from './state-dir.js';
|
|
31
|
+
|
|
32
|
+
const BASELINE_FILE = 'mcp-tool-baseline.json';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fingerprint the parts of a tool definition an agent actually ACTS on.
|
|
36
|
+
*
|
|
37
|
+
* Description and input schema, not the name — the name is the identity being
|
|
38
|
+
* tracked, so folding it in would make every tool its own fingerprint and the
|
|
39
|
+
* comparison vacuous. Ordering inside the schema is normalised so a formatting
|
|
40
|
+
* change is not reported as a behavioural one; a reader chasing a false
|
|
41
|
+
* rug-pull alert stops reading them.
|
|
42
|
+
*/
|
|
43
|
+
export function fingerprintTool(tool) {
|
|
44
|
+
const payload = JSON.stringify({
|
|
45
|
+
description: String((tool && tool.description) || ''),
|
|
46
|
+
inputSchema: _canonical((tool && (tool.inputSchema || tool.input_schema)) || null),
|
|
47
|
+
});
|
|
48
|
+
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 32);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _canonical(v) {
|
|
52
|
+
if (Array.isArray(v)) return v.map(_canonical);
|
|
53
|
+
if (v && typeof v === 'object') {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const k of Object.keys(v).sort()) out[k] = _canonical(v[k]);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return v;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** { serverName: { toolName: fingerprint } } from an MCP config object. */
|
|
62
|
+
export function fingerprintConfig(config) {
|
|
63
|
+
const out = {};
|
|
64
|
+
const servers = (config && (config.mcpServers || config.servers)) || {};
|
|
65
|
+
for (const [server, def] of Object.entries(servers)) {
|
|
66
|
+
const tools = (def && def.tools) || [];
|
|
67
|
+
if (!Array.isArray(tools) || !tools.length) continue;
|
|
68
|
+
out[server] = {};
|
|
69
|
+
for (const t of tools) {
|
|
70
|
+
if (t && t.name) out[server][t.name] = fingerprintTool(t);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function loadBaseline(scanRoot) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(statePath(scanRoot, BASELINE_FILE), 'utf8'));
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function saveBaseline(scanRoot, fingerprints) {
|
|
83
|
+
// Same discipline as every other state writer here: decline rather than
|
|
84
|
+
// create a stray state dir outside a real project.
|
|
85
|
+
if (!stateWritesEnabled() || !isSafeStateDir(path.dirname(statePath(scanRoot, BASELINE_FILE)))) return false;
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(path.dirname(statePath(scanRoot, BASELINE_FILE)), { recursive: true });
|
|
88
|
+
fs.writeFileSync(statePath(scanRoot, BASELINE_FILE),
|
|
89
|
+
JSON.stringify({ schema: 'mcp-tool-baseline/v1', recordedAt: new Date().toISOString(), fingerprints }, null, 1));
|
|
90
|
+
return true;
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compare current tool definitions against the recorded baseline.
|
|
96
|
+
*
|
|
97
|
+
* Returns { findings, firstRun, changed, added, removed }. `findings` is empty
|
|
98
|
+
* on a first run by design.
|
|
99
|
+
*/
|
|
100
|
+
export function detectRugPull(scanRoot, config, { file = '.mcp.json' } = {}) {
|
|
101
|
+
const current = fingerprintConfig(config);
|
|
102
|
+
const prior = loadBaseline(scanRoot);
|
|
103
|
+
const findings = [];
|
|
104
|
+
const changed = [], added = [], removed = [];
|
|
105
|
+
|
|
106
|
+
if (!prior || !prior.fingerprints) {
|
|
107
|
+
return { findings, firstRun: true, changed, added, removed, current };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [server, tools] of Object.entries(current)) {
|
|
111
|
+
const before = prior.fingerprints[server] || {};
|
|
112
|
+
for (const [name, fp] of Object.entries(tools)) {
|
|
113
|
+
if (!(name in before)) { added.push(`${server}/${name}`); continue; }
|
|
114
|
+
if (before[name] === fp) continue;
|
|
115
|
+
changed.push(`${server}/${name}`);
|
|
116
|
+
findings.push({
|
|
117
|
+
id: `mcp-rug-pull:${file}:${server}:${name}`,
|
|
118
|
+
file,
|
|
119
|
+
line: 1,
|
|
120
|
+
vuln: `MCP: tool "${name}" definition CHANGED after approval (rug-pull)`,
|
|
121
|
+
severity: 'high',
|
|
122
|
+
cwe: 'CWE-494',
|
|
123
|
+
family: 'mcp-rug-pull',
|
|
124
|
+
parser: 'MCP-RUGPULL',
|
|
125
|
+
confidence: 0.9,
|
|
126
|
+
description:
|
|
127
|
+
`The tool "${name}" on server "${server}" was approved with one definition and now has another. `
|
|
128
|
+
+ 'An agent loads tool descriptions into its context and acts on them, so a changed description is a '
|
|
129
|
+
+ 'changed instruction — the human still believes they approved the previous text. This is the '
|
|
130
|
+
+ 'documented rug-pull shape: benign at review time, hostile afterwards.',
|
|
131
|
+
remediation:
|
|
132
|
+
`Re-review "${name}" against what it claimed when approved. If the change is legitimate, refresh the `
|
|
133
|
+
+ `baseline at .agentic-security/${BASELINE_FILE}; if it is not, remove the server before the next agent run.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [server, tools] of Object.entries(prior.fingerprints)) {
|
|
138
|
+
for (const name of Object.keys(tools)) {
|
|
139
|
+
if (!current[server] || !(name in current[server])) removed.push(`${server}/${name}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { findings, firstRun: false, changed, added, removed, current };
|
|
144
|
+
}
|
|
@@ -23,8 +23,24 @@
|
|
|
23
23
|
// - Per-language template variants beyond the primary host language.
|
|
24
24
|
// Those land in P1.2.
|
|
25
25
|
|
|
26
|
+
import * as crypto from 'node:crypto';
|
|
26
27
|
import { CWE_TO_FAMILY, FAMILY_TO_PRIMARY_CWE } from './poc-cwe-map.js';
|
|
27
28
|
|
|
29
|
+
// The XSS marker was `Math.random()`, evaluated at GENERATION time, so the
|
|
30
|
+
// same finding produced a different PoC on every scan and `--deterministic`
|
|
31
|
+
// could not make the JSON report byte-identical. (The XXE sentinel further
|
|
32
|
+
// down also calls Math.random(), but that call is literal text inside the
|
|
33
|
+
// EMITTED PoC — it runs when the PoC runs, so the artifact is already stable.
|
|
34
|
+
// Leave it alone.)
|
|
35
|
+
//
|
|
36
|
+
// Derived from the finding instead: still distinct per finding, which is what
|
|
37
|
+
// the marker is for — a token that will not collide with content already in
|
|
38
|
+
// the response — but now reproducible.
|
|
39
|
+
function _stableToken(f, n = 6) {
|
|
40
|
+
const seed = String((f && (f.stableId || f.id)) || `${f && f.file}:${f && f.line}`);
|
|
41
|
+
return crypto.createHash('sha256').update(seed).digest('hex').slice(0, n);
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
// ─── Template selectors ─────────────────────────────────────────────────────
|
|
29
45
|
//
|
|
30
46
|
// Each entry: { cwe, family, vulnContains, lang, render(finding, ctx) → code }
|
|
@@ -64,7 +80,7 @@ const TEMPLATES = [
|
|
|
64
80
|
kind: 'http-payload',
|
|
65
81
|
render: (f, ctx) => _httpPocNode(ctx, {
|
|
66
82
|
header: 'Demonstrates reflected XSS by checking the script payload appears unencoded.',
|
|
67
|
-
payload: `"><script>__POC_XSS_${
|
|
83
|
+
payload: `"><script>__POC_XSS_${_stableToken(f)}</script>`,
|
|
68
84
|
expect: 'response body contains the literal <script> payload (proves no HTML encoding)',
|
|
69
85
|
}),
|
|
70
86
|
},
|