@clear-capabilities/agentic-security-scanner 0.140.0 → 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 (62) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/dist/113.index.js +79 -3
  3. package/dist/178.index.js +1 -1
  4. package/dist/238.index.js +77 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/435.index.js +12 -0
  7. package/dist/526.index.js +79 -3
  8. package/dist/637.index.js +1 -1
  9. package/dist/agentic-security.mjs +14 -14
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/compliance-frameworks/ccpa.json +34 -7
  12. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  13. package/dist/compliance-frameworks/gdpr.json +56 -12
  14. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  15. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  16. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  17. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  18. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  19. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  20. package/package.json +16 -5
  21. package/src/dataflow/catalog.js +61 -0
  22. package/src/engine.js +262 -22
  23. package/src/mcp/tools.js +12 -0
  24. package/src/posture/accuracy-scorecard.js +57 -0
  25. package/src/posture/aibom.js +110 -1
  26. package/src/posture/auditor-walkthrough.js +56 -17
  27. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  28. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  29. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  30. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  31. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  32. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  33. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  34. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  35. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  36. package/src/posture/concurrency-checker.js +3 -3
  37. package/src/posture/coverage-strength.js +182 -0
  38. package/src/posture/epss.js +17 -1
  39. package/src/posture/family-registry.js +103 -0
  40. package/src/posture/family-resolve.js +47 -0
  41. package/src/posture/fix-coverage.js +113 -0
  42. package/src/posture/fix-metrics.js +76 -0
  43. package/src/posture/mcp-rug-pull.js +144 -0
  44. package/src/posture/poc-inprocess.js +217 -1
  45. package/src/posture/proof-coverage.js +162 -0
  46. package/src/posture/reachability-filter.js +44 -0
  47. package/src/posture/sbom.js +12 -3
  48. package/src/runScan.js +56 -5
  49. package/src/sast/CLAUDE.md +2 -2
  50. package/src/sast/claude-md-prompt-injection.js +47 -3
  51. package/src/sast/cloud-iam.js +23 -0
  52. package/src/sast/convention-deviation.js +66 -3
  53. package/src/sast/crypto-protocol.js +23 -0
  54. package/src/sast/dapp-frontend.js +20 -0
  55. package/src/sast/iac-cloud-templates.js +337 -0
  56. package/src/sast/k8s-admission.js +27 -0
  57. package/src/sast/ml-supply-chain.js +22 -0
  58. package/src/sast/ruby.js +132 -0
  59. package/src/sast/web3-advanced.js +26 -0
  60. package/src/sca/CLAUDE.md +21 -4
  61. package/src/sca/container.js +18 -1
  62. package/src/sca/dep-confusion.js +69 -3
@@ -163,6 +163,41 @@ export const CATALOG = [
163
163
  // `dict.get(...)`/`config.get(...)` elsewhere in the file does not also
164
164
  // fire.
165
165
  { kind: 'source', id: 'py-flask-args-get', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values/headers/cookies/json/data.get() (Flask/Django)', provenance: 'url-param' },
166
+ // PRD F2.2 — the MULTI-VALUE accessors, which were missing entirely.
167
+ //
168
+ // `getlist` is the standard Flask/Werkzeug and Django QueryDict API for a
169
+ // repeated query parameter (`?host=a&host=b`), and `getall` is its
170
+ // multidict equivalent. Only `get` was modelled, so every repeated-parameter
171
+ // flow was invisible to the taint engine.
172
+ //
173
+ // This is worth recording because the PRD attributed the miss to Python
174
+ // COMPREHENSIONS (`[x for x in request.args.getlist(...)]`) and proposed
175
+ // modelling them. Comprehensions already flow — verified with the same shape
176
+ // over `request.args.get()`, which the engine tracks end to end. The example
177
+ // failed on its SOURCE, not on its loop, and modelling comprehensions would
178
+ // have changed nothing while looking like a fix.
179
+ { kind: 'source', id: 'py-flask-args-getlist', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getlist', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getlist() (Flask/Django)', provenance: 'url-param' },
180
+ { kind: 'source', id: 'py-flask-args-getall', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getall', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getall() (multidict)', provenance: 'url-param' },
181
+
182
+ // PRD F2.3 — NETWORK-RESPONSE sources.
183
+ //
184
+ // Only two existed, both C++ (`recv`, `recvfrom`), so a response body from an
185
+ // external service was trusted input everywhere else. It is not: the upstream
186
+ // may be compromised, attacker-influenced (the far end of an SSRF), or simply
187
+ // a third party whose output this code renders, executes or shells out with.
188
+ // This is the same trust boundary as an HTTP request arriving — the direction
189
+ // is reversed, not the trust.
190
+ //
191
+ // Kept under its OWN provenance ('network') rather than folded into
192
+ // http-body, so a report can say where the value came from and a team that
193
+ // genuinely trusts its own internal API can filter on it. Collapsing them
194
+ // would remove exactly the fact needed to triage these.
195
+ { kind: 'source', id: 'js-fetch-json', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'json', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .json() [fetch]', provenance: 'network' },
196
+ { kind: 'source', id: 'js-fetch-text', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'text', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .text() [fetch]', provenance: 'network' },
197
+ { kind: 'source', id: 'js-axios-data', language: 'js', framework: 'axios', match: { type: 'member', object: 'response', prop: 'data' }, label: 'axios response.data', provenance: 'network' },
198
+ { kind: 'source', id: 'py-requests-text', language: 'py', framework: 'requests', match: { type: 'member', object: 'resp', prop: 'text' }, label: 'requests response .text', provenance: 'network' },
199
+ { kind: 'source', id: 'py-requests-json', language: 'py', framework: 'requests', match: { type: 'call', callee: 'json', receiverBase: '^(?:resp|response|r)$' }, label: 'requests response .json()', provenance: 'network' },
200
+ { kind: 'source', id: 'py-urlopen-read', language: 'py', framework: 'urllib', match: { type: 'call', callee: 'read', receiverBase: '^(?:resp|response|r|f)$' }, label: 'urlopen read() [urllib]', provenance: 'network' },
166
201
  { kind: 'source', id: 'py-fastapi-request-query',language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Query' }, label: 'fastapi.Query()' },
167
202
  { kind: 'source', id: 'py-fastapi-request-body', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Body' }, label: 'fastapi.Body()' },
168
203
  { kind: 'source', id: 'py-fastapi-form', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Form' }, label: 'fastapi.Form()' },
@@ -228,6 +263,32 @@ export const CATALOG = [
228
263
  { kind: 'source', id: 'py-mcp-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'mcp.tool' }, label: '@mcp.tool() parameter', provenance: 'agent-tool' },
229
264
  { kind: 'source', id: 'py-mcp-server-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'server.tool' }, label: '@server.tool() parameter', provenance: 'agent-tool' },
230
265
 
266
+ // PRD F5.3 — the SAME trust boundary in JavaScript/TypeScript.
267
+ //
268
+ // The agent-tool boundary was modelled for Python only, while the
269
+ // TypeScript SDK (@modelcontextprotocol/sdk) is the dominant implementation.
270
+ // A tool argument is attacker-influenced in exactly the way an HTTP body is:
271
+ // whatever the model was persuaded to pass, by a web page it read, a file it
272
+ // opened, or another tool's output. Treating it as trusted because "the model
273
+ // sent it" is the confused-deputy assumption this whole feature exists to
274
+ // reject.
275
+ //
276
+ // `request.params.arguments` is the CallToolRequest shape every SDK server
277
+ // handler receives; `extra.arguments` covers the newer callback signature.
278
+ { kind: 'source', id: 'js-mcp-call-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'params', prop: 'arguments' }, label: 'MCP tool call arguments', provenance: 'agent-tool' },
279
+ { kind: 'source', id: 'js-mcp-request-params', language: 'js', framework: 'mcp', match: { type: 'member', object: 'request', prop: 'params' }, label: 'MCP request.params', provenance: 'agent-tool' },
280
+ { kind: 'source', id: 'js-mcp-extra-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'extra', prop: 'arguments' }, label: 'MCP tool callback arguments', provenance: 'agent-tool' },
281
+
282
+ // TOOL OUTPUT is the other half of F5.3's shape (tool output -> model context
283
+ // -> tool invocation). Content returned by ANOTHER tool or an MCP resource is
284
+ // not the agent's own reasoning — it is third-party text that reached the
285
+ // context window. A server that reads a resource and passes it onward is the
286
+ // indirect-injection path, and it was invisible while only tool INPUT was a
287
+ // source.
288
+ { kind: 'source', id: 'js-mcp-tool-result', language: 'js', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
289
+ { kind: 'source', id: 'js-mcp-resource-contents', language: 'js', framework: 'mcp', match: { type: 'member', object: 'resource', prop: 'contents' }, label: 'MCP resource contents', provenance: 'agent-tool' },
290
+ { kind: 'source', id: 'py-mcp-tool-result', language: 'py', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
291
+
231
292
  // ─── SOURCES (Go) ─────────────────────────────────────────────────────────
232
293
  { kind: 'source', id: 'go-r-form', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Form' }, label: 'r.Form' },
233
294
  { kind: 'source', id: 'go-r-postform', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'PostForm' }, label: 'r.PostForm' },
package/src/engine.js CHANGED
@@ -15,6 +15,7 @@ import { scanLlmCost } from './sast/llm-cost-advisor.js';
15
15
  import { scanBusinessLogic } from './sast/logic.js';
16
16
  import { scanPipeline } from './sast/pipeline.js';
17
17
  import { scanMCP } from './sast/mcp-audit.js';
18
+ import { detectRugPull as _detectRugPull, saveBaseline as _saveMcpBaseline, fingerprintConfig as _fingerprintMcp } from './posture/mcp-rug-pull.js';
18
19
  import { scanClaudeSettings } from './sast/claude-settings.js';
19
20
  import { scanClaudeMdPromptInjection } from './sast/claude-md-prompt-injection.js';
20
21
  import { scanClaudeHookInjection } from './sast/claude-hook-injection.js';
@@ -36,8 +37,10 @@ import { scanMobileManifest } from './sast/mobile-manifest.js';
36
37
  import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
37
38
  import { scanFastapiHardening } from './sast/fastapi-hardening.js';
38
39
  import { isDeterministic } from './posture/deterministic.js';
40
+ import { proofCoverage } from './posture/proof-coverage.js';
39
41
  import { scanAuthZ } from './sast/authz.js';
40
42
  import { scanApiBrokenAuthz } from './sast/api-authz.js';
43
+ import { scanCloudTemplates, isCloudFormationTemplate } from './sast/iac-cloud-templates.js';
41
44
  import { scanTerraform } from './sast/iac-terraform.js';
42
45
  import { scanCrossService } from './sast/cross-service.js';
43
46
  import { scanRbacConsistency } from './sast/rbac-consistency.js';
@@ -125,7 +128,7 @@ import { scanMutationXSS } from './sast/mutation-xss.js';
125
128
  import { scanDeserializationGadgets, _detectGadgets } from './sast/deserialization-gadgets.js';
126
129
  // Phase 2 — Kotlin / Ruby / PHP coverage.
127
130
  import { scanKotlin } from './sast/kotlin.js';
128
- import { scanRuby } from './sast/ruby.js';
131
+ import { scanRuby, scanRubyPathJoin } from './sast/ruby.js';
129
132
  import { scanPhp } from './sast/php.js';
130
133
  import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
131
134
  // Phase 1 — precision-engineering posture modules.
@@ -665,6 +668,7 @@ function _isIaCFile(p){
665
668
  if (IAC_FILENAMES.has(base)) return true;
666
669
  if (/\.dockerfile$/i.test(base)) return true;
667
670
  if (/\.tf$|\.tfvars$/i.test(base)) return true;
671
+ if (/\.bicep$/i.test(base)) return true;
668
672
  // K8s YAML heuristic: under k8s/ — the CONTENT half of this rule lives in
669
673
  // `isKubernetesManifest` below, because a path predicate cannot see content.
670
674
  if (/(?:^|\/)k8s(?:\/|$)/.test(p) && /\.ya?ml$/i.test(base)) return true;
@@ -710,6 +714,27 @@ export function isKubernetesManifest(relPath, content) {
710
714
  return _K8S_API_VERSION_RE.test(head) && _K8S_KIND_RE.test(head);
711
715
  }
712
716
  function getExt(n){const p=n.split(".");return p.length>1?p.pop().toLowerCase():"";}
717
+
718
+ // Agent instruction files (CLAUDE.md, AGENTS.md, .cursorrules, …) are admitted
719
+ // by CONTENT-INDEPENDENT NAME, the same way isKubernetesManifest admits a
720
+ // manifest a path predicate cannot recognise.
721
+ //
722
+ // They fail shouldScan() because they are markdown, so `fileContents` never held
723
+ // one, so scanClaudeMdPromptInjection / scanClaudeHookInjection / scanMCP at the
724
+ // per-file dispatch could never run on them. The detectors were CORRECT and
725
+ // fully tested when called directly — they were simply never called. That is the
726
+ // same dark-detector shape as k8s-admission and install-script, and it is worse
727
+ // here: for an agentic security tool a poisoned instruction file loaded into
728
+ // every session is a flagship threat, and a normal scan could not see it.
729
+ //
730
+ // Matched on the naming convention only. A repository's ordinary docs stay out
731
+ // of scope, so this admits a handful of files rather than every .md in the tree.
732
+ const _INSTRUCTION_FILE_ADMIT_RE = /(?:^|[\\/])(?:CLAUDE|AGENTS|GEMINI|CURSOR|CODEX|KIRO|QWEN|TRAE|OPENCODE|SYSTEM_PROMPT)\.(?:md|markdown|txt|prompt|system\.md)$|(?:^|[\\/])\.(?:cursorrules|windsurfrules|aiderrules)$|(?:^|[\\/])\.(?:claude|cursor|codex|gemini)[\\/].*\.(?:md|json|ya?ml)$/i;
733
+
734
+ export function isInstructionFile(relPath) {
735
+ return typeof relPath === 'string' && _INSTRUCTION_FILE_ADMIT_RE.test(relPath);
736
+ }
737
+
713
738
  function shouldScan(p){if(/\.(test|spec|mock)\./i.test(p))return false;if(/_test\.go$/i.test(p))return false;if(/_spec\.rb$/i.test(p))return false;if(/Test\.(?:java|cs|kt|scala)$/i.test(p))return false;if(/\.min\.[mc]?js$/i.test(p))return false;for(const x of p.split("/"))if(IGNORE_DIRS.has(x))return false;
714
739
  // Mobile + framework manifest files needed by the v4 detectors.
715
740
  const base=p.split('/').pop();
@@ -1402,6 +1427,10 @@ function _sinkLineIdentifiers(ctx) {
1402
1427
  // (excluding the sink line itself, which trivially contains its own
1403
1428
  // argument). Tries every match, not just the first, since a window can
1404
1429
  // contain several guard-shaped lines and only one need actually correlate.
1430
+ // A function/method declaration line, in every language this engine reads.
1431
+ // Matching a guard-shaped NAME here means a method is being defined, not called.
1432
+ const _DECL_LINE_RE = /^\s*(?:@\w+\s*)?(?:(?:public|private|protected|internal|static|final|abstract|override|async|export|default|func|fun|fn|def|sub|function)\s+)+[\w.<>\[\]]+\s*\(|^\s*def\s+\w|^\s*(?:async\s+)?function\s+\w|^\s*func\s+(?:\([^)]*\)\s*)?\w+\s*\(|^\s*(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/;
1433
+
1405
1434
  function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1406
1435
  const w = _guardWindow(ctx);
1407
1436
  const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
@@ -1412,6 +1441,19 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
1412
1441
  let m;
1413
1442
  while ((m = re.exec(w))) {
1414
1443
  const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
1444
+ // A DECLARATION is not a guard. The shape alternative deliberately matches a
1445
+ // project-local validator by name (`[Cc]heck\w+\(`, `[Ee]nsure\w+\(`, …), and
1446
+ // that also matches the line that DEFINES such a method — so
1447
+ // `def check_static_cache(request)` was read as containment being applied,
1448
+ // and every path-traversal finding inside a method whose own name begins
1449
+ // "check"/"validate"/"ensure"/"verify"/"assert"/"require" was dropped.
1450
+ //
1451
+ // Found by test/detector-liveness.test.js on the ruby-path-join fixture: the
1452
+ // rule fired in isolation and produced nothing through a scan — the same
1453
+ // signature as rate-limit.js and sibling-guard-omission. Narrow by design:
1454
+ // it skips declaration LINES, and does not touch the window heuristic that
1455
+ // every other CWE-22 and CWE-918 emitter depends on.
1456
+ if (_DECL_LINE_RE.test(wLines[guardLineIdx] || '')) continue;
1415
1457
  const lo = Math.max(0, guardLineIdx - span);
1416
1458
  const hi = Math.min(wLines.length, guardLineIdx + span + 1);
1417
1459
  const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
@@ -1850,9 +1892,27 @@ const STRUCTURAL_VULN_PATTERNS=[
1850
1892
  type:"File Serve",vuln:"Path Traversal (sendFile with User Input)",severity:"high",cwe:"CWE-22",stride:"Information Disclosure",
1851
1893
  fix:"Allowlist file paths; never pass raw user input to sendFile"},
1852
1894
  // ── Command Injection ──────────────────────────────────────────────────────
1853
- {regex:/(?:exec|spawn|execSync|execFile)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
1895
+ // SHELL-INVOKING forms only. `exec`/`execSync` hand the whole string to a
1896
+ // shell, so user input anywhere in the command is injection.
1897
+ //
1898
+ // `execFile`/`spawn` are DELIBERATELY NOT HERE. They take an argv array and
1899
+ // do not spawn a shell, so a tainted ARGUMENT cannot inject a command — they
1900
+ // are the canonical FIX for this very finding. The previous rule matched them
1901
+ // and rated it critical, while its own remediation text read "Use execFile
1902
+ // with argument array": following the advice could not clear the finding.
1903
+ // Flagging the fix as the bug is how a team learns to ignore a scanner.
1904
+ {regex:/(?:exec|execSync)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
1854
1905
  type:"OS Command",vuln:"Command Injection (User-Controlled Input)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
1855
1906
  fix:"Use execFile with argument array; never interpolate user input into shell commands"},
1907
+ // The two ways an argv-form call IS still injectable:
1908
+ // 1. the tainted value is the COMMAND (first argument), not an argument to it
1909
+ // 2. `shell: true` is passed, which re-introduces the shell the argv form avoids
1910
+ {regex:/(?:execFile|spawn)(?:Sync)?\s*\(\s*[^,;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^,;)]{0,120}[,)]/g,
1911
+ type:"OS Command",vuln:"Command Injection (User-Controlled Command Name)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
1912
+ fix:"The COMMAND itself is user-controlled — an argv array does not help. Resolve the binary from a fixed allowlist."},
1913
+ {regex:/(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}shell\s*:\s*true[^;]{0,200}(?:req\.|\.body\.|\.query\.|\.params\.)|(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}(?:req\.|\.body\.|\.query\.|\.params\.)[^;]{0,200}shell\s*:\s*true/g,
1914
+ type:"OS Command",vuln:"Command Injection (argv form with shell:true)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
1915
+ fix:"`shell: true` re-introduces the shell that the argv array exists to avoid. Drop the option, or escape the input."},
1856
1916
  {regex:/(?:vm\.runInContext|vm\.runInNewContext|new\s+vm\.Script)\s*\(/g,
1857
1917
  type:"VM Sandbox",vuln:"VM Sandbox Execution (RCE Risk)",severity:"critical",cwe:"CWE-94",stride:"Elevation of Privilege",
1858
1918
  fix:"Never execute user-supplied code in vm.runInContext; use a strict AST sandbox"},
@@ -6851,6 +6911,19 @@ const CIPHER_TRANSIT_PATTERNS=[
6851
6911
  function classifyCipherStrength(cipher){const c=cipher.toUpperCase();if(/\bRC4\b|\bRC2\b|\bARCFOUR\b|SSLV2|SSLV3|\bNULL\b|\bEXPORT\b|\bANULL\b|\bENULL\b|\bECB\b|\bMD5\b|\bSHA1\b(?![\d_])/.test(c))return"weak";if(/\bDES\b/.test(c)&&!/3DES|EDE|TRIPLE/.test(c))return"weak";if(/3DES|TRIPLE.?DES|DES.EDE|TLS.?1.?1|TLSV1\.1/.test(c))return"weak";if(/BCRYPT|ARGON2|SCRYPT|PBKDF2|FERNET|CHACHA20|CHACHAPOLY|PASSWORD_BCRYPT|PASSWORD_ARGON/.test(c))return"strong";if(/\bAES\b|SHA256|SHA384|SHA512|SHA3|BLAKE2|HMACSHA256|HMACSHA512|\bGCM\b|\bCCM\b|ECDHE|DHE|TLS.?1.?[23]|TLSV1\.[23]|HTTPS.SERVER|TLS.SERVER|TLS.CERTIF|HS256|RS256|ES256/.test(c))return"strong";return"unknown";}
6852
6912
  function scanCiphers(fp,raw){const cleaned=stripNoise(raw,fp);const lines=raw.split("\n");const atRest=[],inTransit=[];for(const pat of CIPHER_REST_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);atRest.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}for(const pat of CIPHER_TRANSIT_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);inTransit.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}const uniq=(a)=>a.filter((v,i,arr)=>arr.findIndex(x=>x.cipher===v.cipher&&x.file===v.file&&x.line===v.line)===i);return{atRest:uniq(atRest),inTransit:uniq(inTransit)};}
6853
6913
 
6914
+ // True for the JWT specimen published in the standard's own documentation.
6915
+ // Decodes the payload rather than matching the encoded string, so a token that
6916
+ // merely shares a prefix is not suppressed.
6917
+ function _isSpecimenJwt(token){
6918
+ try{
6919
+ const parts=String(token).split('.');
6920
+ if(parts.length!==3)return false;
6921
+ const payload=Buffer.from(parts[1].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString('utf8');
6922
+ const d=JSON.parse(payload);
6923
+ return d&&d.sub==='1234567890'&&typeof d.name==='string'&&d.name==='John Doe';
6924
+ }catch(_){return false;}
6925
+ }
6926
+
6854
6927
  function scanCredentials(fp,raw){
6855
6928
  if(!CRED_PREFILTER.test(raw))return[];
6856
6929
  const lines=raw.split("\n");const results=[];const seen=new Set();
@@ -6860,11 +6933,23 @@ function scanCredentials(fp,raw){
6860
6933
  while((m=re.exec(raw))!=null){
6861
6934
  const val=m[0];
6862
6935
  if(/placeholder|example|xxx+|your_|changeme|<[A-Z_]+>|MY_|INSERT_|REPLACE_|TODO|test_key|fake_|sample_|dummy_/i.test(val))continue;
6936
+ // The published specimen token, which appears verbatim in essentially
6937
+ // every piece of JWT documentation and in most auth tutorials. Its
6938
+ // payload decodes to {"sub":"1234567890","name":"John Doe",…} — a
6939
+ // documented example value in exactly the sense AKIAIOSFODNN7EXAMPLE is,
6940
+ // and suppressed for the same reason and just as narrowly: the check is
6941
+ // on the DECODED payload, so a real token that merely resembles it is
6942
+ // unaffected. Found by bench/secrets-precision as the single false
6943
+ // positive in its negative set.
6944
+ if(pat.n==="Exposed JWT Token"&&_isSpecimenJwt(val))continue;
6863
6945
  const line=raw.substring(0,m.index).split("\n").length;
6864
6946
  const snippet=lines[line-1]?.trim()||"";
6865
6947
  // Per-pattern line-context gate: if ctx is set, the matched line must satisfy it
6866
6948
  if(pat.ctx&&!pat.ctx.test(snippet))continue;
6867
- if(pat.n==="Password in URL"&&/localhost|127\.0\.|0\.0\.0\.0|example\.com|test\.com|::1|user:pass|admin:admin|foo:bar|user:password|username:password|admin:password|root:password|test:test|john:doe|demo:demo|myuser:mypass|guest:guest/i.test(val))continue;
6949
+ // The same placeholder-credential guard now covers every URI-with-inline-
6950
+ // credentials pattern, not just the one it was written for. A connection
6951
+ // string pointing at localhost with `user:pass` is a README, not a leak.
6952
+ if((pat.n==="Password in URL"||pat.urlCreds)&&/localhost|127\.0\.|0\.0\.0\.0|example\.com|test\.com|::1|user:pass|admin:admin|foo:bar|user:password|username:password|admin:password|root:password|test:test|john:doe|demo:demo|myuser:mypass|guest:guest/i.test(val))continue;
6868
6953
  const key=`${fp}:${line}:${pat.n}`;
6869
6954
  if(seen.has(key))continue;seen.add(key);
6870
6955
  const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
@@ -6873,10 +6958,18 @@ function scanCredentials(fp,raw){
6873
6958
  // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
6874
6959
  // the raw source line (full credential value) straight through to
6875
6960
  // every report format. Redact the exact matched value here too.
6876
- results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6961
+ results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
6877
6962
  }
6878
6963
  }
6879
- return results;
6964
+ // One secret, one finding. A `postgres://user:pass@host/db` matches both the
6965
+ // specific PostgreSQL pattern and the generic "Password in URL" one, and
6966
+ // reporting the same credential on the same line twice is noise that makes a
6967
+ // secrets report look padded. The specific name wins: it tells the reader
6968
+ // which system to go and rotate.
6969
+ const specificUrlLines=new Set(results.filter(r=>r._urlCreds).map(r=>`${r.file}:${r.line}`));
6970
+ const deduped=results.filter(r=>!(r.vuln==="Password in URL"&&specificUrlLines.has(`${r.file}:${r.line}`)));
6971
+ for(const r of deduped)delete r._urlCreds;
6972
+ return deduped;
6880
6973
  }
6881
6974
 
6882
6975
  /* ── OSV-backed SCA Engine ───────────────────────────────────────────────── */
@@ -6977,16 +7070,60 @@ async function _enrichWithEPSS(supplyChainResults){
6977
7070
  const _KEV_FEED_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
6978
7071
  const _KEV_TTL_MS = 24 * 60 * 60 * 1000;
6979
7072
 
7073
+ // PRD F3.4 — a KEV catalog has no meaning without its age.
7074
+ //
7075
+ // The refresh TTL above only decides when to TRY the network. Every failure
7076
+ // path below falls back to `cached?.byCve` with NO age bound, so an offline
7077
+ // machine, a blocked egress rule or a CISA outage silently serves a catalog of
7078
+ // any age. A six-month-old catalog does not fail loudly — it quietly omits
7079
+ // every vulnerability added since, which UNDERSTATES risk. That is the worst
7080
+ // direction for this particular signal: KEV membership is used to escalate.
7081
+ //
7082
+ // The catalog is still used when stale (dropping it would understate risk even
7083
+ // harder), but its age is recorded and surfaced on the scan so a report can
7084
+ // state it, and `staleness` is a first-class value rather than an inference.
7085
+ const _KEV_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
7086
+
7087
+ // Populated by _loadKEVCatalog and read when the scan result is assembled.
7088
+ let _kevCatalogMeta = { source: 'not-loaded', fetchedAt: null, ageDays: null, stale: null, entries: 0 };
7089
+ export function kevCatalogMeta() { return { ..._kevCatalogMeta }; }
7090
+
7091
+ function _setKevMeta(source, ts, entries) {
7092
+ const ageMs = ts ? Date.now() - ts : null;
7093
+ _kevCatalogMeta = {
7094
+ source,
7095
+ fetchedAt: ts ? new Date(ts).toISOString() : null,
7096
+ ageDays: ageMs == null ? null : Math.floor(ageMs / 86400000),
7097
+ stale: ageMs == null ? null : ageMs > _KEV_STALE_AFTER_MS,
7098
+ entries: entries || 0,
7099
+ meaning: 'KEV membership escalates severity. A stale catalog omits recently-added CVEs, so it understates risk rather than overstating it.',
7100
+ };
7101
+ }
7102
+
6980
7103
  async function _loadKEVCatalog(){
6981
- if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return null;
7104
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
7105
+ _setKevMeta('offline-skipped', null, 0);
7106
+ return null;
7107
+ }
6982
7108
  // Cached blob: { ts, byCve: { 'CVE-XXXX-YYYY': { dateAdded, ransomwareCampaign, vendor, product, vuln, action } } }
6983
7109
  const cached = _osvCacheGet('kev:catalog');
6984
- if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) return cached.byCve || null;
7110
+ if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) {
7111
+ _setKevMeta('cache-fresh', cached.ts, Object.keys(cached.byCve || {}).length);
7112
+ return cached.byCve || null;
7113
+ }
7114
+ const fallback = () => {
7115
+ if (cached && cached.byCve) {
7116
+ _setKevMeta('cache-stale', cached.ts || null, Object.keys(cached.byCve).length);
7117
+ return cached.byCve;
7118
+ }
7119
+ _setKevMeta('unavailable', null, 0);
7120
+ return null;
7121
+ };
6985
7122
  try {
6986
7123
  const res = await fetch(_KEV_FEED_URL, {
6987
7124
  headers: { 'User-Agent': 'agentic-security/0.1' },
6988
7125
  });
6989
- if (!res.ok) return cached?.byCve || null;
7126
+ if (!res.ok) return fallback();
6990
7127
  const j = await res.json();
6991
7128
  const byCve = {};
6992
7129
  for (const v of (j.vulnerabilities || [])) {
@@ -7001,9 +7138,11 @@ async function _loadKEVCatalog(){
7001
7138
  dueDate: v.dueDate || null,
7002
7139
  };
7003
7140
  }
7004
- _osvCacheSet('kev:catalog', { ts: Date.now(), byCve });
7141
+ const ts = Date.now();
7142
+ _osvCacheSet('kev:catalog', { ts, byCve });
7143
+ _setKevMeta('network', ts, Object.keys(byCve).length);
7005
7144
  return byCve;
7006
- } catch { return cached?.byCve || null; }
7145
+ } catch { return fallback(); }
7007
7146
  }
7008
7147
 
7009
7148
  async function _enrichWithKEV(supplyChainResults){
@@ -7155,7 +7294,14 @@ function _parseGoMod(text,filePath){
7155
7294
  if(t===')'){inReq=false;continue;}
7156
7295
  let m=inReq?t.match(/^([^\s]+)\s+v([^\s/]+)/):t.match(/^require\s+([^\s]+)\s+v([^\s/]+)/);
7157
7296
  if(m){
7158
- const name=m[1];const ver=m[2].replace(/-.*$/,'');
7297
+ // Keep the version VERBATIM. This used to do `.replace(/-.*$/,'')`,
7298
+ // which turns every Go pseudo-version — v0.0.0-20210903162142-ad29c8ab022f
7299
+ // — into a bare `0.0.0`. That is not a shorter version, it is a different
7300
+ // and nonexistent one, and it made every pseudo-versioned module in a tree
7301
+ // collapse onto the same key. Normalisation for an advisory query belongs
7302
+ // at the query, where _osvQueryVersion does it; a component's recorded
7303
+ // version is also what lands in the SBOM, where truncating it is worse.
7304
+ const name=m[1];const ver=m[2];
7159
7305
  const isIndirect=t.includes('// indirect');
7160
7306
  out.push({name,version:ver,group:name.split('/').slice(0,2).join('/'),
7161
7307
  scope:isIndirect?'optional':'required',purl:_makePurl('golang',name,ver,''),
@@ -7550,8 +7696,10 @@ function _parseGoSum(text, filePath){
7550
7696
  const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
7551
7697
  if (!m) continue;
7552
7698
  const name = m[1];
7553
- // Strip +incompatible / -timestamp-sha suffixes that aren't useful for OSV matching.
7554
- const ver = m[2].replace(/\+incompatible$/, '').replace(/^v?/, '');
7699
+ // Verbatim, minus the leading `v`. The suffixes this used to strip
7700
+ // (`+incompatible`, the pseudo-version timestamp+sha) are part of the
7701
+ // module version the advisory database matches on — see _parseGoMod.
7702
+ const ver = m[2].replace(/^v/, '');
7555
7703
  const dedupKey = `${name}@${ver}`;
7556
7704
  if (seen.has(dedupKey)) continue;
7557
7705
  seen.add(dedupKey);
@@ -7646,11 +7794,20 @@ function parseManifests(allFileContents){
7646
7794
  // R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
7647
7795
  'gradle-dependencies.txt':_parseGradleDependencies,
7648
7796
  };
7797
+ // Requirements files are named a dozen ways and the basename table can only
7798
+ // hold one of them. `requirements/dev.txt`, `requirements-dev.txt` and
7799
+ // `requirements/base.txt` are all ordinary; matched by SHAPE so a new variant
7800
+ // does not need a new table entry. Kept narrow on purpose — an arbitrary
7801
+ // `.txt` reaching this parser would invent dependencies out of prose.
7802
+ const _REQ_FILE=/^requirements(?:[._-][\w.-]+)?\.txt$/i;
7803
+ const _REQ_DIR=/(?:^|\/)requirements\/[\w.-]+\.txt$/i;
7804
+ const _pick=(fp,base)=>PARSERS[base]||((_REQ_FILE.test(base)||_REQ_DIR.test(fp))?_parseRequirementsTxt:null);
7649
7805
  const out=[],seen=new Set();
7650
7806
  for(const[fp,content]of Object.entries(allFileContents)){
7651
7807
  const base=fp.split('/').pop();
7652
- if(!PARSERS[base])continue;
7653
- for(const comp of PARSERS[base](content,fp)){
7808
+ const parser=_pick(fp.split('\\').join('/'),base);
7809
+ if(!parser)continue;
7810
+ for(const comp of parser(content,fp)){
7654
7811
  const key=`${comp.ecosystem}:${comp.name}:${comp.version}`;
7655
7812
  if(!seen.has(key)){seen.add(key);out.push(comp);}
7656
7813
  }
@@ -7728,6 +7885,34 @@ function computeAttackPathComponents(findings,components,byFile){
7728
7885
  return{flagged,pathsByKey};
7729
7886
  }
7730
7887
 
7888
+ // The version string an advisory database can actually match on.
7889
+ //
7890
+ // This used to be `version.match(/(\d+\.\d+(?:\.\d+)*)/)`, which takes the
7891
+ // first dotted-number run and throws the rest away. For most ecosystems that is
7892
+ // harmless; for Go it is destructive. A Go pseudo-version is
7893
+ //
7894
+ // v0.0.0-20210903162142-ad29c8ab022f
7895
+ //
7896
+ // and the leading `0.0.0` is a placeholder, not a version — every pseudo-version
7897
+ // in the tree collapsed to the same meaningless `0.0.0`, so the query asked
7898
+ // about a release that does not exist and the real one was never checked.
7899
+ // bench/sca-replay attributed nearly every remaining Go miss to exactly this.
7900
+ // `+incompatible` builds lost their suffix the same way.
7901
+ //
7902
+ // A WILDCARD is refused outright rather than truncated. `2.0.*` is a range; it
7903
+ // has no single version to be affected, and reporting "phpseclib 2.0.* is
7904
+ // vulnerable" names something that was never installed.
7905
+ function _osvQueryVersion(raw){
7906
+ const s=String(raw||'').trim();
7907
+ if(!s)return null;
7908
+ if(/[*x]/i.test(s.replace(/^[\^~>=<\s]+/,'').replace(/[-+][\w.-]+$/,'')))return null;
7909
+ // Strip only a leading range operator or `v`; keep the whole version after it.
7910
+ const m=s.match(/^[\^~>=<\s]*v?(\d[\w.+-]*)$/);
7911
+ if(m)return m[1];
7912
+ const fallback=s.match(/(\d+\.\d+(?:\.\d+)*)/);
7913
+ return fallback?fallback[1]:null;
7914
+ }
7915
+
7731
7916
  async function queryOSV(components,allFileContents){
7732
7917
  const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
7733
7918
  const results=[];
@@ -7742,7 +7927,7 @@ async function queryOSV(components,allFileContents){
7742
7927
  const queries=[],uncached=[],vulnAffects={};
7743
7928
  for(const comp of queryable){
7744
7929
  const eco=OSV_ECO[comp.ecosystem];
7745
- const cleanVer=(comp.version.match(/(\d+\.\d+(?:\.\d+)*)/)||[])[1];
7930
+ const cleanVer=_osvQueryVersion(comp.version);
7746
7931
  if(!cleanVer)continue;
7747
7932
  const ck=`comp:${eco}:${comp.name}:${cleanVer}`;
7748
7933
  const cached=_osvCacheGet(ck);
@@ -8039,7 +8224,7 @@ function _deterministicFileTimings(timings) {
8039
8224
 
8040
8225
  const _fileTimings = [];
8041
8226
  let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
8042
- const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f])) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
8227
+ const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
8043
8228
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8044
8229
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8045
8230
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
@@ -8107,7 +8292,7 @@ function _deterministicFileTimings(timings) {
8107
8292
  let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
8108
8293
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8109
8294
  const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
8110
- try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));
8295
+ try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));aF.push(...scanCloudTemplates(p,c));
8111
8296
  aF.push(...scanLLM(p,c));
8112
8297
  aF.push(...scanLLMOwasp(p,c));
8113
8298
  aF.push(...scanLlmCost(p,c));
@@ -8116,6 +8301,31 @@ function _deterministicFileTimings(timings) {
8116
8301
  aF.push(...scanContainer(p,cc));
8117
8302
  aF.push(...scanInstallScripts(p,cc));
8118
8303
  aF.push(...scanMCP(p,c));
8304
+ // PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
8305
+ // Every scanMCP rule judges the CURRENT content, so a description that is
8306
+ // innocuous today and hostile tomorrow passes both scans. This compares
8307
+ // against a recorded baseline, which is the only way to see a change.
8308
+ // Wired here rather than left as a tested module: a detector with no call
8309
+ // site is a dark detector, which is the exact class this session keeps
8310
+ // finding.
8311
+ if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
8312
+ try {
8313
+ const _cfg = JSON.parse(c);
8314
+ const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
8315
+ aF.push(..._rp.findings);
8316
+ // Record on first sight so the NEXT scan has something to compare
8317
+ // against; refresh after reporting so a reviewed change is not
8318
+ // re-reported forever.
8319
+ _saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
8320
+ } catch (e) {
8321
+ // Only a malformed config is tolerated here — scanMCP already reports
8322
+ // what it can from one. Anything else is a programmer error and must
8323
+ // not be swallowed: a bare `catch {}` around this block hid a
8324
+ // ReferenceError (`root` vs `scanRoot`) that silently disabled the
8325
+ // whole detector while every unit test still passed.
8326
+ if (!(e instanceof SyntaxError)) throw e;
8327
+ }
8328
+ }
8119
8329
  aF.push(...scanClaudeSettings(p,c));
8120
8330
  aF.push(...scanClaudeMdPromptInjection(p,c));
8121
8331
  aF.push(...scanClaudeHookInjection(p,c));
@@ -8200,7 +8410,7 @@ function _deterministicFileTimings(timings) {
8200
8410
  aF.push(...scanSSRFCloudMetadata(p,cc));
8201
8411
  aF.push(...scanMutationXSS(p,cc));
8202
8412
  aF.push(...scanKotlin(p,cc));
8203
- aF.push(...scanRuby(p,cc));
8413
+ aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
8204
8414
  aF.push(...scanPhp(p,cc));
8205
8415
  // Integration block: scaffolded SAST scanners. Gated by env var.
8206
8416
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
@@ -9557,7 +9767,13 @@ function _deterministicFileTimings(timings) {
9557
9767
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
9558
9768
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
9559
9769
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
9560
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
9770
+ // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
9771
+ // computed over the provable subset makes a narrow subset look like strength;
9772
+ // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
9773
+ // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
9774
+ let _proofCoverage = null;
9775
+ try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
9776
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
9561
9777
 
9562
9778
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
9563
9779
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -9767,6 +9983,30 @@ const CREDENTIAL_PATTERNS=[
9767
9983
  // Database / Infrastructure
9768
9984
  // ctx gate: JDBC URLs in docs/test configs without credentials are not findings; require @ or password= evidence
9769
9985
  {n:"Database Connection String",r:"jdbc:[a-z:]+://[A-Za-z0-9\\.\\-_:;=/@?,&]+",s:"h",ctx:/@|password=|passwd=|pwd=/i},
9986
+ // PRD F4.1. bench/secrets-precision measured format coverage at 83% and every
9987
+ // one of these five was a genuine absence, not a tuning problem. Four are
9988
+ // among the most common real leaks there are — a database URI with the
9989
+ // password inline is what a connection string looks like when someone pastes
9990
+ // one into a config file.
9991
+ //
9992
+ // `jdbc:` was the ONLY database URI shape covered. `postgres://` and
9993
+ // `mongodb+srv://` are far more common in the ecosystems this tool is aimed
9994
+ // at, and the generic "Password in URL" pattern could not reach them: it is
9995
+ // gated behind CRED_PREFILTER, which had no token for either scheme.
9996
+ {n:"PostgreSQL Connection URI",r:"postgres(?:ql)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
9997
+ {n:"MongoDB Connection URI",r:"mongodb(?:\\+srv)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
9998
+ {n:"Azure Storage Account Key",r:"AccountKey=[A-Za-z0-9+/]{86}==",s:"c"},
9999
+ {n:"GitLab Personal Access Token",r:"glpat-[0-9A-Za-z_-]{20}",s:"c"},
10000
+ {n:"DigitalOcean Personal Access Token",r:"dop_v1_[a-f0-9]{64}",s:"c"},
10001
+ {n:"Supabase Service Key",r:"sbp_[a-f0-9]{40}",s:"c"},
10002
+ {n:"HubSpot Private App Token",r:"pat-(?:na|eu)[0-9]-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",s:"c"},
10003
+ // NOT added, deliberately: Datadog, Vercel and Algolia keys are a bare run of
10004
+ // hex or alphanumerics with no prefix. bench/secrets-precision reports them as
10005
+ // misses and they should stay reported. A pattern for "32 hex characters"
10006
+ // would fire on every content digest, Cargo checksum, test vector and build
10007
+ // hash in the negative set — trading five detections for thousands of false
10008
+ // positives, in the feature most prone to alert fatigue. Closing this needs
10009
+ // variable-name context, not another regex.
9770
10010
  // Downgraded to medium; scanner also skips localhost/example hosts (see scanCredentials)
9771
10011
  {n:"Password in URL",r:"[a-zA-Z]{3,10}://[^/\\s:@]{3,20}:[^/\\s:@]{3,20}@.{1,100}[\"'\\s]",s:"m"},
9772
10012
  {n:"WordPress Secret Key",r:"define(.{0,20})?(DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|AUTH_SALT|NONCE_KEY).{0,20}['\"].{10,120}['\"]",s:"h"},
@@ -9778,7 +10018,7 @@ const CREDENTIAL_PATTERNS=[
9778
10018
  // ctx gate: only report when the line contains a storage/assignment keyword, filters standalone examples in comments
9779
10019
  {n:"Exposed JWT Token",r:"eyJ[a-zA-Z0-9]{10,}\\.eyJ[a-zA-Z0-9]{10,}\\.[a-zA-Z0-9_\\-]{10,}",s:"m",ctx:/token|jwt|auth|bearer|secret|key|credential|sign|=|:/i},
9780
10020
  ];
9781
- const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ/i;
10021
+ const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ|postgres(?:ql)?:\/\/|mongodb(?:\+srv)?:\/\/|AccountKey=|glpat-|dop_v1_|sbp_[a-f0-9]{10}|pat-(?:na|eu)[0-9]-|[a-z][a-z0-9+.-]{2,15}:\/\/[^\s:@\/]{3,64}:[^\s:@\/]{3,64}@/i;
9782
10022
  const SECRET_IMPACT_MAP={
9783
10023
  "AWS Access Key ID":"Provides programmatic access to AWS resources. With the paired secret key, an attacker can enumerate S3 buckets, exfiltrate databases, spin up EC2 instances for cryptomining, or pivot to any service the role permits. If the key belongs to an admin role, this is full cloud account takeover.",
9784
10024
  "AWS AppSync GraphQL Key":"Allows unauthenticated queries and mutations against your AppSync GraphQL API. Attackers can read application data, trigger mutations to corrupt records, or enumerate the schema to map further attack surface.",
@@ -10000,7 +10240,7 @@ export {
10000
10240
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
10001
10241
  _isFalsePositiveCredential, _detectSafeSinkShape,
10002
10242
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,
10003
- scanIaC, IAC_PATTERNS, _isIaCFile,
10243
+ scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
10004
10244
  payloadsForFinding, buildProofObligation,
10005
10245
  DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,
10006
10246
  ROUTE_PATTERNS, AUTH_PATTERNS, IGNORE_DIRS, CODE_EXTS,
package/src/mcp/tools.js CHANGED
@@ -330,6 +330,18 @@ function _maybeOffload(sessionRoot, toolName, items) {
330
330
  }
331
331
 
332
332
  // ─── scan_diff ───────────────────────────────────────────────────────────────
333
+ // Test seam for the write boundary (PRD F6.4).
334
+ //
335
+ // `_confine` and `isReservedWrite` ARE the confinement contract in
336
+ // agents/_CONFINEMENT.md. A boundary is only worth what its refusals are worth,
337
+ // and refusals cannot be adversarially tested through the public tools without
338
+ // also exercising a real scan, a real patch and a real filesystem write — so
339
+ // the check would be measuring four things and attributing failure to one.
340
+ //
341
+ // Exported under the `_internals` convention this codebase already uses
342
+ // (see posture/poc-inprocess.js). Not part of the MCP tool surface.
343
+ export const _internals = { _confine, isReservedWrite: _isReservedWritePath };
344
+
333
345
  export const scan_diff = {
334
346
  name: 'scan_diff',
335
347
  description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',