@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.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 +283 -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 +19 -5
  21. package/src/dataflow/CLAUDE.md +9 -0
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/dataflow/engine.js +95 -0
  24. package/src/dataflow/sanitizer-gate.js +61 -0
  25. package/src/engine.js +353 -31
  26. package/src/mcp/tools.js +12 -0
  27. package/src/posture/accuracy-scorecard.js +57 -0
  28. package/src/posture/aibom.js +110 -1
  29. package/src/posture/auditor-walkthrough.js +56 -17
  30. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  31. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  32. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  33. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  34. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  35. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  36. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  37. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  38. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  39. package/src/posture/concurrency-checker.js +3 -3
  40. package/src/posture/coverage-strength.js +182 -0
  41. package/src/posture/epss.js +17 -1
  42. package/src/posture/family-registry.js +103 -0
  43. package/src/posture/family-resolve.js +47 -0
  44. package/src/posture/fix-coverage.js +113 -0
  45. package/src/posture/fix-metrics.js +76 -0
  46. package/src/posture/mcp-rug-pull.js +144 -0
  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 +12 -3
  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 +346 -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
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"},
@@ -6547,6 +6607,11 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6547
6607
  if (!sites.length) { sc.functionReachable = 'unknown'; sc.routeReachable = false; continue; }
6548
6608
  let functionReachable = false;
6549
6609
  let routeReachable = false;
6610
+ // Did ANY call site yield enough structure to reason about? If not, the
6611
+ // honest answer is `unknown` — see the verdict assignment below.
6612
+ let analysable = false;
6613
+ // At least one call site sits inside an exported function — see below.
6614
+ let publicApiSite = false;
6550
6615
  for (const site of sites) {
6551
6616
  // Classifier 1: site is inline inside a route handler (within 25 lines
6552
6617
  // of the route def, no intervening function declaration). This is the
@@ -6569,8 +6634,16 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6569
6634
  // If any caller-chain hits a known route-handler function, the site
6570
6635
  // is route-reachable-via-function. If no caller at all, we keep
6571
6636
  // functionReachable=false for this site.
6572
- const enclosing = _enclosingFn(fc[site.file] || '', site.line);
6637
+ const encInfo = _enclosingFnInfo(fc[site.file] || '', site.line);
6638
+ const enclosing = encInfo && encInfo.name;
6639
+ // Not being able to name the enclosing function is INCONCLUSIVE, not
6640
+ // evidence of unreachability. Tracked so the verdict below can say so.
6573
6641
  if (!enclosing) continue;
6642
+ // A public-API function with no in-tree caller is the NORMAL case, not
6643
+ // dead code: its callers are its users. Only a private function can be
6644
+ // shown unreachable by the absence of callers.
6645
+ if (encInfo.exported) { publicApiSite = true; continue; }
6646
+ analysable = true;
6574
6647
  const callers = _reverseCallGraphReachable(callGraph, enclosing, 4);
6575
6648
  if (callers.size > 1) functionReachable = true; // at least one caller exists
6576
6649
  for (const callerFn of callers) {
@@ -6582,18 +6655,76 @@ function _annotateFunctionReachability(supplyChain, routes, callGraph, fc){
6582
6655
  }
6583
6656
  if (routeReachable) break;
6584
6657
  }
6585
- sc.functionReachable = functionReachable ? 'reachable' : 'unreachable';
6658
+ // ABSENCE OF PROOF IS NOT PROOF OF ABSENCE.
6659
+ //
6660
+ // This read `functionReachable ? 'reachable' : 'unreachable'`, so a site the
6661
+ // analysis could not reason about — no recognisable enclosing function, no
6662
+ // routes in the project at all — was reported as UNREACHABLE and the finding
6663
+ // demoted to `info`. For a LIBRARY that is every site: express has no routes,
6664
+ // so `cookie` and `send`, both required at the top of `lib/response.js` and
6665
+ // called directly, were demoted out of the report.
6666
+ //
6667
+ // Measured by bench/sca-replay's reachability scorer (PRD F3.2): of the three
6668
+ // demotions it could adjudicate against an import-level oracle, ALL THREE were
6669
+ // false — express/cookie, express/send, poetry/requests, each genuinely
6670
+ // imported. A false `unreachable` is a MISSED EXPLOIT, the expensive
6671
+ // direction, so inconclusive now yields `unknown`: the finding keeps its
6672
+ // severity and the analysis stops making a claim it cannot support.
6673
+ // A project with NO ROUTES is a library, and "not reachable from any route"
6674
+ // is not a claim that can be made about one: its callers are its users, who
6675
+ // are not in this tree. express requires `cookie` and `send` at the top of
6676
+ // lib/response.js and calls both directly — there is simply no route to
6677
+ // trace them to, and reporting that as `unreachable` demoted two live
6678
+ // dependencies to `info`. Same shape for poetry/requests.
6679
+ //
6680
+ // This is the library-vs-application distinction the PRD already flags for
6681
+ // the taint engine (F2.3, "for a library the caller IS the attacker"),
6682
+ // showing up here as a false demotion instead of a false negative.
6683
+ const projectHasRoutes = Array.isArray(routes) && routes.length > 0;
6684
+ sc.functionReachable = functionReachable
6685
+ ? 'reachable'
6686
+ : (analysable && projectHasRoutes && !publicApiSite ? 'unreachable' : 'unknown');
6586
6687
  sc.routeReachable = routeReachable;
6587
6688
  }
6588
6689
  }
6589
- function _enclosingFn(content,line){
6690
+ // The declaration form of the function enclosing `line`, scanning backwards.
6691
+ //
6692
+ // Recognises four shapes, not one. The original matched only `function name(`
6693
+ // and `const name = (`, so an ANONYMOUS function assigned to a member —
6694
+ // `res.cookie = function (name, value, options) {`, which is how most of
6695
+ // express, and most of the JS ecosystem, defines a public method — was invisible.
6696
+ // The scan then walked past it and attributed the call site to whatever
6697
+ // unrelated function appeared further up the file.
6698
+ //
6699
+ // `exported` matters as much as the name: a function assigned to a member or to
6700
+ // module.exports is PUBLIC API, so having no caller inside this repository is
6701
+ // the normal case and says nothing about whether it can be reached. Treating it
6702
+ // as evidence of unreachability is what demoted express's live `cookie`
6703
+ // dependency to `info`.
6704
+ function _enclosingFnInfo(content,line){
6590
6705
  const lines=content.split('\n');
6591
6706
  for(let i=line-2;i>=0;i--){
6592
- const m=lines[i].match(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\()/);
6593
- if(m)return m[1]||m[2]||null;
6707
+ const l=lines[i];
6708
+ let m=l.match(/(?:^|\s)(?:async\s+)?function\s+(\w+)\s*\(/);
6709
+ if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
6710
+ m=l.match(/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/);
6711
+ if(m)return {name:m[1],exported:/^\s*export\b/.test(l)};
6712
+ // `X.y = function (…)` / `X.y = async (…) => ` / `module.exports.y = …`
6713
+ m=l.match(/^\s*(?:module\.)?(\w+)\.(\w+)\s*=\s*(?:async\s*)?(?:function\b|\()/);
6714
+ if(m)return {name:m[2],exported:true};
6715
+ // `module.exports = function name(…)`
6716
+ m=l.match(/^\s*module\.exports\s*=\s*(?:async\s*)?function\s*(\w*)\s*\(/);
6717
+ if(m)return {name:m[1]||'module.exports',exported:true};
6718
+ // Object-literal method / class method: `cookie: function (` or `cookie(a) {`
6719
+ m=l.match(/^\s*(\w+)\s*:\s*(?:async\s*)?(?:function\b|\()/);
6720
+ if(m)return {name:m[1],exported:false};
6594
6721
  }
6595
6722
  return null;
6596
6723
  }
6724
+ function _enclosingFn(content,line){
6725
+ const info=_enclosingFnInfo(content,line);
6726
+ return info?info.name:null;
6727
+ }
6597
6728
 
6598
6729
  // 0.6.0 Feat-2: Toxic-combinations score — composes multi-signal risk into 0–100.
6599
6730
  // Composes existing per-finding signals into a 0–100 toxicity score with a
@@ -6851,6 +6982,19 @@ const CIPHER_TRANSIT_PATTERNS=[
6851
6982
  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
6983
  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
6984
 
6985
+ // True for the JWT specimen published in the standard's own documentation.
6986
+ // Decodes the payload rather than matching the encoded string, so a token that
6987
+ // merely shares a prefix is not suppressed.
6988
+ function _isSpecimenJwt(token){
6989
+ try{
6990
+ const parts=String(token).split('.');
6991
+ if(parts.length!==3)return false;
6992
+ const payload=Buffer.from(parts[1].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString('utf8');
6993
+ const d=JSON.parse(payload);
6994
+ return d&&d.sub==='1234567890'&&typeof d.name==='string'&&d.name==='John Doe';
6995
+ }catch(_){return false;}
6996
+ }
6997
+
6854
6998
  function scanCredentials(fp,raw){
6855
6999
  if(!CRED_PREFILTER.test(raw))return[];
6856
7000
  const lines=raw.split("\n");const results=[];const seen=new Set();
@@ -6860,11 +7004,23 @@ function scanCredentials(fp,raw){
6860
7004
  while((m=re.exec(raw))!=null){
6861
7005
  const val=m[0];
6862
7006
  if(/placeholder|example|xxx+|your_|changeme|<[A-Z_]+>|MY_|INSERT_|REPLACE_|TODO|test_key|fake_|sample_|dummy_/i.test(val))continue;
7007
+ // The published specimen token, which appears verbatim in essentially
7008
+ // every piece of JWT documentation and in most auth tutorials. Its
7009
+ // payload decodes to {"sub":"1234567890","name":"John Doe",…} — a
7010
+ // documented example value in exactly the sense AKIAIOSFODNN7EXAMPLE is,
7011
+ // and suppressed for the same reason and just as narrowly: the check is
7012
+ // on the DECODED payload, so a real token that merely resembles it is
7013
+ // unaffected. Found by bench/secrets-precision as the single false
7014
+ // positive in its negative set.
7015
+ if(pat.n==="Exposed JWT Token"&&_isSpecimenJwt(val))continue;
6863
7016
  const line=raw.substring(0,m.index).split("\n").length;
6864
7017
  const snippet=lines[line-1]?.trim()||"";
6865
7018
  // Per-pattern line-context gate: if ctx is set, the matched line must satisfy it
6866
7019
  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;
7020
+ // The same placeholder-credential guard now covers every URI-with-inline-
7021
+ // credentials pattern, not just the one it was written for. A connection
7022
+ // string pointing at localhost with `user:pass` is a README, not a leak.
7023
+ 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
7024
  const key=`${fp}:${line}:${pat.n}`;
6869
7025
  if(seen.has(key))continue;seen.add(key);
6870
7026
  const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
@@ -6873,10 +7029,18 @@ function scanCredentials(fp,raw){
6873
7029
  // unredacted-snippet leak as scanEntropySecrets — `snippet` carried
6874
7030
  // the raw source line (full credential value) straight through to
6875
7031
  // 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,"_")};`});
7032
+ 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
7033
  }
6878
7034
  }
6879
- return results;
7035
+ // One secret, one finding. A `postgres://user:pass@host/db` matches both the
7036
+ // specific PostgreSQL pattern and the generic "Password in URL" one, and
7037
+ // reporting the same credential on the same line twice is noise that makes a
7038
+ // secrets report look padded. The specific name wins: it tells the reader
7039
+ // which system to go and rotate.
7040
+ const specificUrlLines=new Set(results.filter(r=>r._urlCreds).map(r=>`${r.file}:${r.line}`));
7041
+ const deduped=results.filter(r=>!(r.vuln==="Password in URL"&&specificUrlLines.has(`${r.file}:${r.line}`)));
7042
+ for(const r of deduped)delete r._urlCreds;
7043
+ return deduped;
6880
7044
  }
6881
7045
 
6882
7046
  /* ── OSV-backed SCA Engine ───────────────────────────────────────────────── */
@@ -6977,16 +7141,60 @@ async function _enrichWithEPSS(supplyChainResults){
6977
7141
  const _KEV_FEED_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
6978
7142
  const _KEV_TTL_MS = 24 * 60 * 60 * 1000;
6979
7143
 
7144
+ // PRD F3.4 — a KEV catalog has no meaning without its age.
7145
+ //
7146
+ // The refresh TTL above only decides when to TRY the network. Every failure
7147
+ // path below falls back to `cached?.byCve` with NO age bound, so an offline
7148
+ // machine, a blocked egress rule or a CISA outage silently serves a catalog of
7149
+ // any age. A six-month-old catalog does not fail loudly — it quietly omits
7150
+ // every vulnerability added since, which UNDERSTATES risk. That is the worst
7151
+ // direction for this particular signal: KEV membership is used to escalate.
7152
+ //
7153
+ // The catalog is still used when stale (dropping it would understate risk even
7154
+ // harder), but its age is recorded and surfaced on the scan so a report can
7155
+ // state it, and `staleness` is a first-class value rather than an inference.
7156
+ const _KEV_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
7157
+
7158
+ // Populated by _loadKEVCatalog and read when the scan result is assembled.
7159
+ let _kevCatalogMeta = { source: 'not-loaded', fetchedAt: null, ageDays: null, stale: null, entries: 0 };
7160
+ export function kevCatalogMeta() { return { ..._kevCatalogMeta }; }
7161
+
7162
+ function _setKevMeta(source, ts, entries) {
7163
+ const ageMs = ts ? Date.now() - ts : null;
7164
+ _kevCatalogMeta = {
7165
+ source,
7166
+ fetchedAt: ts ? new Date(ts).toISOString() : null,
7167
+ ageDays: ageMs == null ? null : Math.floor(ageMs / 86400000),
7168
+ stale: ageMs == null ? null : ageMs > _KEV_STALE_AFTER_MS,
7169
+ entries: entries || 0,
7170
+ meaning: 'KEV membership escalates severity. A stale catalog omits recently-added CVEs, so it understates risk rather than overstating it.',
7171
+ };
7172
+ }
7173
+
6980
7174
  async function _loadKEVCatalog(){
6981
- if (process.env.AGENTIC_SECURITY_OFFLINE === '1') return null;
7175
+ if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
7176
+ _setKevMeta('offline-skipped', null, 0);
7177
+ return null;
7178
+ }
6982
7179
  // Cached blob: { ts, byCve: { 'CVE-XXXX-YYYY': { dateAdded, ransomwareCampaign, vendor, product, vuln, action } } }
6983
7180
  const cached = _osvCacheGet('kev:catalog');
6984
- if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) return cached.byCve || null;
7181
+ if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) {
7182
+ _setKevMeta('cache-fresh', cached.ts, Object.keys(cached.byCve || {}).length);
7183
+ return cached.byCve || null;
7184
+ }
7185
+ const fallback = () => {
7186
+ if (cached && cached.byCve) {
7187
+ _setKevMeta('cache-stale', cached.ts || null, Object.keys(cached.byCve).length);
7188
+ return cached.byCve;
7189
+ }
7190
+ _setKevMeta('unavailable', null, 0);
7191
+ return null;
7192
+ };
6985
7193
  try {
6986
7194
  const res = await fetch(_KEV_FEED_URL, {
6987
7195
  headers: { 'User-Agent': 'agentic-security/0.1' },
6988
7196
  });
6989
- if (!res.ok) return cached?.byCve || null;
7197
+ if (!res.ok) return fallback();
6990
7198
  const j = await res.json();
6991
7199
  const byCve = {};
6992
7200
  for (const v of (j.vulnerabilities || [])) {
@@ -7001,9 +7209,11 @@ async function _loadKEVCatalog(){
7001
7209
  dueDate: v.dueDate || null,
7002
7210
  };
7003
7211
  }
7004
- _osvCacheSet('kev:catalog', { ts: Date.now(), byCve });
7212
+ const ts = Date.now();
7213
+ _osvCacheSet('kev:catalog', { ts, byCve });
7214
+ _setKevMeta('network', ts, Object.keys(byCve).length);
7005
7215
  return byCve;
7006
- } catch { return cached?.byCve || null; }
7216
+ } catch { return fallback(); }
7007
7217
  }
7008
7218
 
7009
7219
  async function _enrichWithKEV(supplyChainResults){
@@ -7155,7 +7365,14 @@ function _parseGoMod(text,filePath){
7155
7365
  if(t===')'){inReq=false;continue;}
7156
7366
  let m=inReq?t.match(/^([^\s]+)\s+v([^\s/]+)/):t.match(/^require\s+([^\s]+)\s+v([^\s/]+)/);
7157
7367
  if(m){
7158
- const name=m[1];const ver=m[2].replace(/-.*$/,'');
7368
+ // Keep the version VERBATIM. This used to do `.replace(/-.*$/,'')`,
7369
+ // which turns every Go pseudo-version — v0.0.0-20210903162142-ad29c8ab022f
7370
+ // — into a bare `0.0.0`. That is not a shorter version, it is a different
7371
+ // and nonexistent one, and it made every pseudo-versioned module in a tree
7372
+ // collapse onto the same key. Normalisation for an advisory query belongs
7373
+ // at the query, where _osvQueryVersion does it; a component's recorded
7374
+ // version is also what lands in the SBOM, where truncating it is worse.
7375
+ const name=m[1];const ver=m[2];
7159
7376
  const isIndirect=t.includes('// indirect');
7160
7377
  out.push({name,version:ver,group:name.split('/').slice(0,2).join('/'),
7161
7378
  scope:isIndirect?'optional':'required',purl:_makePurl('golang',name,ver,''),
@@ -7550,8 +7767,10 @@ function _parseGoSum(text, filePath){
7550
7767
  const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
7551
7768
  if (!m) continue;
7552
7769
  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?/, '');
7770
+ // Verbatim, minus the leading `v`. The suffixes this used to strip
7771
+ // (`+incompatible`, the pseudo-version timestamp+sha) are part of the
7772
+ // module version the advisory database matches on — see _parseGoMod.
7773
+ const ver = m[2].replace(/^v/, '');
7555
7774
  const dedupKey = `${name}@${ver}`;
7556
7775
  if (seen.has(dedupKey)) continue;
7557
7776
  seen.add(dedupKey);
@@ -7646,11 +7865,20 @@ function parseManifests(allFileContents){
7646
7865
  // R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
7647
7866
  'gradle-dependencies.txt':_parseGradleDependencies,
7648
7867
  };
7868
+ // Requirements files are named a dozen ways and the basename table can only
7869
+ // hold one of them. `requirements/dev.txt`, `requirements-dev.txt` and
7870
+ // `requirements/base.txt` are all ordinary; matched by SHAPE so a new variant
7871
+ // does not need a new table entry. Kept narrow on purpose — an arbitrary
7872
+ // `.txt` reaching this parser would invent dependencies out of prose.
7873
+ const _REQ_FILE=/^requirements(?:[._-][\w.-]+)?\.txt$/i;
7874
+ const _REQ_DIR=/(?:^|\/)requirements\/[\w.-]+\.txt$/i;
7875
+ const _pick=(fp,base)=>PARSERS[base]||((_REQ_FILE.test(base)||_REQ_DIR.test(fp))?_parseRequirementsTxt:null);
7649
7876
  const out=[],seen=new Set();
7650
7877
  for(const[fp,content]of Object.entries(allFileContents)){
7651
7878
  const base=fp.split('/').pop();
7652
- if(!PARSERS[base])continue;
7653
- for(const comp of PARSERS[base](content,fp)){
7879
+ const parser=_pick(fp.split('\\').join('/'),base);
7880
+ if(!parser)continue;
7881
+ for(const comp of parser(content,fp)){
7654
7882
  const key=`${comp.ecosystem}:${comp.name}:${comp.version}`;
7655
7883
  if(!seen.has(key)){seen.add(key);out.push(comp);}
7656
7884
  }
@@ -7728,6 +7956,34 @@ function computeAttackPathComponents(findings,components,byFile){
7728
7956
  return{flagged,pathsByKey};
7729
7957
  }
7730
7958
 
7959
+ // The version string an advisory database can actually match on.
7960
+ //
7961
+ // This used to be `version.match(/(\d+\.\d+(?:\.\d+)*)/)`, which takes the
7962
+ // first dotted-number run and throws the rest away. For most ecosystems that is
7963
+ // harmless; for Go it is destructive. A Go pseudo-version is
7964
+ //
7965
+ // v0.0.0-20210903162142-ad29c8ab022f
7966
+ //
7967
+ // and the leading `0.0.0` is a placeholder, not a version — every pseudo-version
7968
+ // in the tree collapsed to the same meaningless `0.0.0`, so the query asked
7969
+ // about a release that does not exist and the real one was never checked.
7970
+ // bench/sca-replay attributed nearly every remaining Go miss to exactly this.
7971
+ // `+incompatible` builds lost their suffix the same way.
7972
+ //
7973
+ // A WILDCARD is refused outright rather than truncated. `2.0.*` is a range; it
7974
+ // has no single version to be affected, and reporting "phpseclib 2.0.* is
7975
+ // vulnerable" names something that was never installed.
7976
+ function _osvQueryVersion(raw){
7977
+ const s=String(raw||'').trim();
7978
+ if(!s)return null;
7979
+ if(/[*x]/i.test(s.replace(/^[\^~>=<\s]+/,'').replace(/[-+][\w.-]+$/,'')))return null;
7980
+ // Strip only a leading range operator or `v`; keep the whole version after it.
7981
+ const m=s.match(/^[\^~>=<\s]*v?(\d[\w.+-]*)$/);
7982
+ if(m)return m[1];
7983
+ const fallback=s.match(/(\d+\.\d+(?:\.\d+)*)/);
7984
+ return fallback?fallback[1]:null;
7985
+ }
7986
+
7731
7987
  async function queryOSV(components,allFileContents){
7732
7988
  const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
7733
7989
  const results=[];
@@ -7742,7 +7998,7 @@ async function queryOSV(components,allFileContents){
7742
7998
  const queries=[],uncached=[],vulnAffects={};
7743
7999
  for(const comp of queryable){
7744
8000
  const eco=OSV_ECO[comp.ecosystem];
7745
- const cleanVer=(comp.version.match(/(\d+\.\d+(?:\.\d+)*)/)||[])[1];
8001
+ const cleanVer=_osvQueryVersion(comp.version);
7746
8002
  if(!cleanVer)continue;
7747
8003
  const ck=`comp:${eco}:${comp.name}:${cleanVer}`;
7748
8004
  const cached=_osvCacheGet(ck);
@@ -8039,7 +8295,7 @@ function _deterministicFileTimings(timings) {
8039
8295
 
8040
8296
  const _fileTimings = [];
8041
8297
  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=[];
8298
+ 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
8299
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
8044
8300
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
8045
8301
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
@@ -8107,7 +8363,7 @@ function _deterministicFileTimings(timings) {
8107
8363
  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
8364
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
8109
8365
  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));
8366
+ 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
8367
  aF.push(...scanLLM(p,c));
8112
8368
  aF.push(...scanLLMOwasp(p,c));
8113
8369
  aF.push(...scanLlmCost(p,c));
@@ -8116,6 +8372,31 @@ function _deterministicFileTimings(timings) {
8116
8372
  aF.push(...scanContainer(p,cc));
8117
8373
  aF.push(...scanInstallScripts(p,cc));
8118
8374
  aF.push(...scanMCP(p,c));
8375
+ // PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
8376
+ // Every scanMCP rule judges the CURRENT content, so a description that is
8377
+ // innocuous today and hostile tomorrow passes both scans. This compares
8378
+ // against a recorded baseline, which is the only way to see a change.
8379
+ // Wired here rather than left as a tested module: a detector with no call
8380
+ // site is a dark detector, which is the exact class this session keeps
8381
+ // finding.
8382
+ if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
8383
+ try {
8384
+ const _cfg = JSON.parse(c);
8385
+ const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
8386
+ aF.push(..._rp.findings);
8387
+ // Record on first sight so the NEXT scan has something to compare
8388
+ // against; refresh after reporting so a reviewed change is not
8389
+ // re-reported forever.
8390
+ _saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
8391
+ } catch (e) {
8392
+ // Only a malformed config is tolerated here — scanMCP already reports
8393
+ // what it can from one. Anything else is a programmer error and must
8394
+ // not be swallowed: a bare `catch {}` around this block hid a
8395
+ // ReferenceError (`root` vs `scanRoot`) that silently disabled the
8396
+ // whole detector while every unit test still passed.
8397
+ if (!(e instanceof SyntaxError)) throw e;
8398
+ }
8399
+ }
8119
8400
  aF.push(...scanClaudeSettings(p,c));
8120
8401
  aF.push(...scanClaudeMdPromptInjection(p,c));
8121
8402
  aF.push(...scanClaudeHookInjection(p,c));
@@ -8200,7 +8481,7 @@ function _deterministicFileTimings(timings) {
8200
8481
  aF.push(...scanSSRFCloudMetadata(p,cc));
8201
8482
  aF.push(...scanMutationXSS(p,cc));
8202
8483
  aF.push(...scanKotlin(p,cc));
8203
- aF.push(...scanRuby(p,cc));
8484
+ aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
8204
8485
  aF.push(...scanPhp(p,cc));
8205
8486
  // Integration block: scaffolded SAST scanners. Gated by env var.
8206
8487
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
@@ -8857,13 +9138,24 @@ function _deterministicFileTimings(timings) {
8857
9138
  // sanitizer would then hide a real vulnerability outright, whereas a label
8858
9139
  // only demotes confidence here. Recall-preserving, on purpose.
8859
9140
  const sanitizersOnPath = {};
9141
+ // The same shape for calls that UNDO an encoding. A sanitizer whose effect
9142
+ // is reversed later on the path is not a sanitizer, and the gate could not
9143
+ // see that before: the catalog only models sanitizers, so a decoder was
9144
+ // never recorded at all and `he.decode(escapeHtml(x))` read as clean.
9145
+ const unsanitizersOnPath = {};
8860
9146
  for (const f of finalFindings) {
8861
9147
  const names = f && f._sanitizersOnPath;
8862
- if (!Array.isArray(names) || !names.length) continue;
8863
- if (f.id) sanitizersOnPath[f.id] = names;
8864
- if (f.stableId) sanitizersOnPath[f.stableId] = names;
9148
+ if (Array.isArray(names) && names.length) {
9149
+ if (f.id) sanitizersOnPath[f.id] = names;
9150
+ if (f.stableId) sanitizersOnPath[f.stableId] = names;
9151
+ }
9152
+ const undo = f && f._unsanitizersOnPath;
9153
+ if (Array.isArray(undo) && undo.length) {
9154
+ if (f.id) unsanitizersOnPath[f.id] = undo;
9155
+ if (f.stableId) unsanitizersOnPath[f.stableId] = undo;
9156
+ }
8865
9157
  }
8866
- _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath }); });
9158
+ _runAnnotator("applySanitizerGate", () => { applySanitizerGate(finalFindings, { sanitizersOnPath, unsanitizersOnPath }); });
8867
9159
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
8868
9160
  }
8869
9161
  // Addition #1 — default falsification pass. Actively tries to DISPROVE each
@@ -9557,7 +9849,13 @@ function _deterministicFileTimings(timings) {
9557
9849
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
9558
9850
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
9559
9851
  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};}
9852
+ // PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
9853
+ // computed over the provable subset makes a narrow subset look like strength;
9854
+ // the three-bucket split (provable / declined-on-purpose / not-yet-classified)
9855
+ // is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
9856
+ let _proofCoverage = null;
9857
+ try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
9858
+ 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
9859
 
9562
9860
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
9563
9861
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -9767,6 +10065,30 @@ const CREDENTIAL_PATTERNS=[
9767
10065
  // Database / Infrastructure
9768
10066
  // ctx gate: JDBC URLs in docs/test configs without credentials are not findings; require @ or password= evidence
9769
10067
  {n:"Database Connection String",r:"jdbc:[a-z:]+://[A-Za-z0-9\\.\\-_:;=/@?,&]+",s:"h",ctx:/@|password=|passwd=|pwd=/i},
10068
+ // PRD F4.1. bench/secrets-precision measured format coverage at 83% and every
10069
+ // one of these five was a genuine absence, not a tuning problem. Four are
10070
+ // among the most common real leaks there are — a database URI with the
10071
+ // password inline is what a connection string looks like when someone pastes
10072
+ // one into a config file.
10073
+ //
10074
+ // `jdbc:` was the ONLY database URI shape covered. `postgres://` and
10075
+ // `mongodb+srv://` are far more common in the ecosystems this tool is aimed
10076
+ // at, and the generic "Password in URL" pattern could not reach them: it is
10077
+ // gated behind CRED_PREFILTER, which had no token for either scheme.
10078
+ {n:"PostgreSQL Connection URI",r:"postgres(?:ql)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
10079
+ {n:"MongoDB Connection URI",r:"mongodb(?:\\+srv)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
10080
+ {n:"Azure Storage Account Key",r:"AccountKey=[A-Za-z0-9+/]{86}==",s:"c"},
10081
+ {n:"GitLab Personal Access Token",r:"glpat-[0-9A-Za-z_-]{20}",s:"c"},
10082
+ {n:"DigitalOcean Personal Access Token",r:"dop_v1_[a-f0-9]{64}",s:"c"},
10083
+ {n:"Supabase Service Key",r:"sbp_[a-f0-9]{40}",s:"c"},
10084
+ {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"},
10085
+ // NOT added, deliberately: Datadog, Vercel and Algolia keys are a bare run of
10086
+ // hex or alphanumerics with no prefix. bench/secrets-precision reports them as
10087
+ // misses and they should stay reported. A pattern for "32 hex characters"
10088
+ // would fire on every content digest, Cargo checksum, test vector and build
10089
+ // hash in the negative set — trading five detections for thousands of false
10090
+ // positives, in the feature most prone to alert fatigue. Closing this needs
10091
+ // variable-name context, not another regex.
9770
10092
  // Downgraded to medium; scanner also skips localhost/example hosts (see scanCredentials)
9771
10093
  {n:"Password in URL",r:"[a-zA-Z]{3,10}://[^/\\s:@]{3,20}:[^/\\s:@]{3,20}@.{1,100}[\"'\\s]",s:"m"},
9772
10094
  {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 +10100,7 @@ const CREDENTIAL_PATTERNS=[
9778
10100
  // ctx gate: only report when the line contains a storage/assignment keyword, filters standalone examples in comments
9779
10101
  {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
10102
  ];
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;
10103
+ 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
10104
  const SECRET_IMPACT_MAP={
9783
10105
  "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
10106
  "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 +10322,7 @@ export {
10000
10322
  classifyOrphans, classifyField, classifyEndpoint, shouldScan,
10001
10323
  _isFalsePositiveCredential, _detectSafeSinkShape,
10002
10324
  _loadCustomRules, _isCustomSuppressed, _isPathIgnored,
10003
- scanIaC, IAC_PATTERNS, _isIaCFile,
10325
+ scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
10004
10326
  payloadsForFinding, buildProofObligation,
10005
10327
  DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,
10006
10328
  ROUTE_PATTERNS, AUTH_PATTERNS, IGNORE_DIRS, CODE_EXTS,