@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/bin/agentic-security.js +40 -11
  3. package/dist/113.index.js +79 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/238.index.js +77 -1
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +12 -0
  8. package/dist/526.index.js +79 -3
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +14 -14
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/compliance-frameworks/ccpa.json +34 -7
  13. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  14. package/dist/compliance-frameworks/gdpr.json +56 -12
  15. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  16. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  17. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  18. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  19. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  20. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  21. package/package.json +16 -5
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/engine.js +281 -23
  24. package/src/mcp/tools.js +12 -0
  25. package/src/posture/accuracy-scorecard.js +57 -0
  26. package/src/posture/aibom.js +110 -1
  27. package/src/posture/auditor-walkthrough.js +137 -21
  28. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  29. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  30. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  31. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  32. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  33. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  34. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  35. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  36. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  37. package/src/posture/concurrency-checker.js +42 -5
  38. package/src/posture/coverage-strength.js +182 -0
  39. package/src/posture/epss.js +17 -1
  40. package/src/posture/family-registry.js +103 -0
  41. package/src/posture/family-resolve.js +47 -0
  42. package/src/posture/fix-coverage.js +113 -0
  43. package/src/posture/fix-metrics.js +76 -0
  44. package/src/posture/integrity.js +59 -8
  45. package/src/posture/mcp-rug-pull.js +144 -0
  46. package/src/posture/poc-generator.js +17 -1
  47. package/src/posture/poc-inprocess.js +217 -1
  48. package/src/posture/proof-coverage.js +162 -0
  49. package/src/posture/reachability-filter.js +44 -0
  50. package/src/posture/sbom.js +50 -7
  51. package/src/runScan.js +56 -5
  52. package/src/sast/CLAUDE.md +2 -2
  53. package/src/sast/claude-md-prompt-injection.js +47 -3
  54. package/src/sast/cloud-iam.js +23 -0
  55. package/src/sast/convention-deviation.js +66 -3
  56. package/src/sast/crypto-protocol.js +23 -0
  57. package/src/sast/dapp-frontend.js +20 -0
  58. package/src/sast/iac-cloud-templates.js +337 -0
  59. package/src/sast/k8s-admission.js +27 -0
  60. package/src/sast/ml-supply-chain.js +22 -0
  61. package/src/sast/ruby.js +132 -0
  62. package/src/sast/web3-advanced.js +26 -0
  63. package/src/sca/CLAUDE.md +21 -4
  64. package/src/sca/container.js +18 -1
  65. 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
+ }
@@ -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
- fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
66
- fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
67
- _keySource = 'per-install-new';
68
- return buf;
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
- try {
75
- const hex = fs.readFileSync(fp, 'utf8').trim();
76
- if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
77
- } catch { /* fall through to ephemeral */ }
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_${Math.random().toString(36).slice(2, 8)}</script>`,
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
  },