@clear-capabilities/agentic-security-scanner 0.137.0 → 0.139.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 (44) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +10 -6
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
package/src/engine.js CHANGED
@@ -51,7 +51,9 @@ import { isJavaBarProvablySafe } from './sast/java-constant-fold.js';
51
51
  import { findTaintedCollections, extractionFromTaintedCollection } from './sast/java-collection-passthrough.js';
52
52
  import { deadBranchRanges as _deadBranchRanges, isLineInDeadRange as _isLineInDeadRange } from './sast/java-ast-folding.js';
53
53
  import { scanJavaDeserialization } from './sast/java-deserialization.js';
54
+ import { blankComments } from './sast/_comment-strip.js';
54
55
  import { scanJwtExp } from './sast/jwt-exp.js';
56
+ import { scanSiblingGuard } from './sast/sibling-guard.js';
55
57
  import { scanZipSlip } from './sast/zip-slip.js';
56
58
  import { scanFileUpload } from './sast/file-upload.js';
57
59
  import { scanHostHeader } from './sast/host-header.js';
@@ -65,6 +67,11 @@ import { scanRust, extractRustImportMap } from './sast/rust.js';
65
67
  import { scanGoExtended } from './sast/go-extended.js';
66
68
  import { scanDatabaseRLS } from './sast/db-rls.js';
67
69
  import { scanRateLimit } from './sast/rate-limit.js';
70
+ import { scanConventionDeviationProject } from './sast/convention-deviation.js';
71
+ import { scanResourceExhaustion } from './sast/resource-exhaustion.js';
72
+ import { scanRedirectToctou } from './sast/redirect-toctou.js';
73
+ import { scanCodegenSink } from './sast/codegen-sink.js';
74
+ import { scanOwnershipAuthz } from './sast/ownership-authz.js';
68
75
  import { scanAuthProvider } from './sast/auth-provider.js';
69
76
  import { scanEnvHygiene } from './sast/env-hygiene.js';
70
77
  import { scanWebhook } from './sast/webhook.js';
@@ -242,7 +249,59 @@ const localStorage = sessionStorage;
242
249
  const DATA_CLASSES={PII:{label:"PII",color:"#c792ea",patterns:["fname","lname","first_name","last_name","surname","full_name","dob","date_of_birth","ssn","social_security","tax_id","national_id","address_line","postal_code","zip_code","home_address","phone","mobile","email","drivers_license","passport_no","voter_id","geolocation","fingerprint","voice_print","dna_sequence","student_id","salary"]},PHI:{label:"PHI",color:"#ff6b9d",patterns:["ehr_id","mrn","medical_record_number","patient_id","patient_name","diagnosis","icd_code","cpt_code","prescription","blood_type","pregnancy_status","disability","mental_health","medical_history","treatment_plan","lab_result","medication","immunization","insurance_plan","policy_number","hipaa","phi"]},PCI:{label:"PCI",color:"#ff6b35",patterns:["pan","credit_card","card_number","cc_num","cardholder_name","cvv","cvc","cvv2","security_code","card_verification","exp_date","card_expiry","track_1","track_2","magstripe","pin_block","routing_number","bank_account","iban","swift_code","account_number"]},Confidential:{label:"Confidential",color:"#ffb800",patterns:["top_secret","secret","confidential","classified","cui","controlled","attorney_client","privileged","trade_secret","proprietary","internal_use_only","do_not_distribute","export_controlled","itar","cjis","password","passwd","api_key","secret_key","access_token","auth_token","connection_string","private_key"]}};
243
250
  function classifyField(n){const l=n.toLowerCase().replace(/[-\s]/g,"_");const m=[];for(const[c,info]of Object.entries(DATA_CLASSES))for(const p of info.patterns)if(l.includes(p)){m.push(c);break;}return m;}
244
251
  function classifyEndpoint(fields){const c=new Set();for(const f of fields)for(const x of classifyField(f))c.add(x);return[...c];}
245
- function stripNoise(code){let c=code.replace(/\/\*[\s\S]*?\*\//g,m=>m.replace(/[^\n]/g,' '));c=c.replace(/\/\/[^\n]*/g,m=>' '.repeat(m.length));return c;}
252
+ // Which comment syntax a file uses. This has to be language-aware rather than
253
+ // one regex for every file, because the same two characters mean OPPOSITE
254
+ // things across languages: `#` opens a comment in Python/Ruby/shell but is a
255
+ // preprocessor or region directive in C/C++/C# (`#include`, `#region`), and
256
+ // `//` is a comment in the C family but FLOOR DIVISION in Python. Guessing
257
+ // wrong does not merely leak a false positive — it deletes real code and
258
+ // silently destroys every finding on the rest of the line.
259
+ const _HASH_COMMENT_EXTS = new Set([
260
+ '.py', '.pyw', '.pyi', '.sh', '.bash', '.zsh',
261
+ '.yml', '.yaml', '.pl', '.pm', '.ex', '.exs', '.cr', '.nim', '.toml', '.cfg', '.ini',
262
+ ]);
263
+ // Ruby: `#` lines plus `=begin`/`=end` blocks.
264
+ const _RUBY_COMMENT_EXTS = new Set(['.rb', '.rake', '.gemspec', '.ru']);
265
+ // PHP and HCL/Terraform accept `//`, `/* */` AND `#` simultaneously.
266
+ const _ALL_COMMENT_EXTS = new Set(['.php', '.php5', '.phtml', '.tf', '.tfvars', '.hcl']);
267
+ function _commentLangFor(fp) {
268
+ const base = (String(fp || '').toLowerCase().split(/[\\/]/).pop()) || '';
269
+ if (base === 'dockerfile' || base.startsWith('dockerfile.') || base === 'makefile') return 'py';
270
+ const dot = base.lastIndexOf('.');
271
+ const ext = dot > 0 ? base.slice(dot) : '';
272
+ if (_ALL_COMMENT_EXTS.has(ext)) return 'php';
273
+ if (_RUBY_COMMENT_EXTS.has(ext)) return 'rb';
274
+ if (_HASH_COMMENT_EXTS.has(ext)) return 'py';
275
+ return undefined; // C-family default: `//` and `/* */` only, `#` left alone.
276
+ }
277
+ // Single-entry memo for the comment-blanked view.
278
+ //
279
+ // `stripNoise` is called ~15 times for the SAME file within one pass
280
+ // (performRegexAnalysis, scanRoutes, scanStructuralVulns, scanLogicVulns,
281
+ // scanJavaSAST, scanCiphers, …), and the per-file dispatch computes `cc` on top
282
+ // of that. The old implementation was two native regex replaces, so recomputing
283
+ // was nearly free; `blankComments` is an interpreted character loop, so it is
284
+ // not. Measured on a 307-file entry: 13.4 s → 16.0 s, a 19.7% regression,
285
+ // before this memo.
286
+ //
287
+ // One slot is enough because the engine processes one file at a time, and
288
+ // `fileContents[p]` hands back the SAME string reference on every call — so the
289
+ // `===` guard is a pointer comparison, not a content comparison, and cannot
290
+ // return a stale result for different content.
291
+ let _blankMemoSrc = null, _blankMemoLang, _blankMemoOut = null;
292
+ function _blankCached(src, lang) {
293
+ if (src === _blankMemoSrc && lang === _blankMemoLang) return _blankMemoOut;
294
+ const out = blankComments(src, lang);
295
+ _blankMemoSrc = src; _blankMemoLang = lang; _blankMemoOut = out;
296
+ return out;
297
+ }
298
+
299
+ // `fp` is optional for backwards compatibility with call sites that have no
300
+ // path in scope. WITH a path this delegates to the shared, string-literal-aware
301
+ // `blankComments`; without one it keeps the original C-family-only behaviour.
302
+ function stripNoise(code, fp){
303
+ if (fp !== undefined && fp !== null) return _blankCached(code, _commentLangFor(fp));
304
+ let c=code.replace(/\/\*[\s\S]*?\*\//g,m=>m.replace(/[^\n]/g,' '));c=c.replace(/\/\/[^\n]*/g,m=>' '.repeat(m.length));return c;}
246
305
  // File-context inference. Used to gate rules that only apply in a given runtime
247
306
  // context — e.g. "Synchronous Blocking I/O (DoS Risk in Server Context)" should
248
307
  // not fire on CLI scripts / hooks / VS Code extensions.
@@ -605,7 +664,8 @@ function _isIaCFile(p){
605
664
  if (IAC_FILENAMES.has(base)) return true;
606
665
  if (/\.dockerfile$/i.test(base)) return true;
607
666
  if (/\.tf$|\.tfvars$/i.test(base)) return true;
608
- // K8s YAML heuristic: under k8s/ or contains "kind:" caller checks content
667
+ // K8s YAML heuristic: under k8s/ the CONTENT half of this rule lives in
668
+ // `isKubernetesManifest` below, because a path predicate cannot see content.
609
669
  if (/(?:^|\/)k8s(?:\/|$)/.test(p) && /\.ya?ml$/i.test(base)) return true;
610
670
  if (/(?:^|\/)\.github\/workflows\/.*\.ya?ml$/.test(p)) return true;
611
671
  if (/(?:^|\/)\.gitlab-ci\.ya?ml$/.test(p)) return true;
@@ -620,6 +680,34 @@ function _isIaCFile(p){
620
680
  if (/(?:^|\/)(?:prompts?|templates?\/prompts?)\//i.test(p)) return true;
621
681
  return false;
622
682
  }
683
+ // PRD F11.4 — the content half of the Kubernetes-manifest rule.
684
+ //
685
+ // `_isIaCFile` admitted YAML as Kubernetes only when the path contained a
686
+ // directory literally named `k8s/`, while its own comment claimed the caller
687
+ // also checked for `kind:`. Nothing ever did. Measured consequence: the
688
+ // k8s-admission detector returned 3 findings when called directly on its own
689
+ // fixture and 0 through a scan, because `shouldScan()` never let the walker
690
+ // keep the file — real manifests under `deploy/`, `manifests/`, `charts/`,
691
+ // `kubernetes/`, `overlays/` or the repository root were invisible, and that is
692
+ // where most projects actually put them.
693
+ //
694
+ // Keyed on CONTENT rather than on more directory names, because the set of
695
+ // directory names people use is unbounded and the file format is not. A
696
+ // Kubernetes object is defined by carrying BOTH `apiVersion:` and `kind:` at
697
+ // the start of a line — requiring both, at line-start, is what keeps this from
698
+ // matching ordinary YAML that merely mentions the word "kind" in a value.
699
+ //
700
+ // Only the head of the file is examined: a manifest declares these in its first
701
+ // few lines, and reading further would put an unbounded regex on the hot path
702
+ // of every YAML file in a repository.
703
+ const _K8S_HEAD_BYTES = 2048;
704
+ const _K8S_API_VERSION_RE = /^\s*apiVersion\s*:/m;
705
+ const _K8S_KIND_RE = /^\s*kind\s*:\s*\S/m;
706
+ export function isKubernetesManifest(relPath, content) {
707
+ if (typeof content !== 'string' || !/\.ya?ml$/i.test(relPath || '')) return false;
708
+ const head = content.length > _K8S_HEAD_BYTES ? content.slice(0, _K8S_HEAD_BYTES) : content;
709
+ return _K8S_API_VERSION_RE.test(head) && _K8S_KIND_RE.test(head);
710
+ }
623
711
  function getExt(n){const p=n.split(".");return p.length>1?p.pop().toLowerCase():"";}
624
712
  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;
625
713
  // Mobile + framework manifest files needed by the v4 detectors.
@@ -1338,7 +1426,14 @@ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSR
1338
1426
  // A path-traversal containment guard near the file sink: a basename/strip
1339
1427
  // helper that removes directory components, a framework safe-join, or a
1340
1428
  // canonicalize-then-startsWith containment check.
1341
- const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b/;
1429
+ // The trailing alternative is a SHAPE, not a name: a project-local throwing
1430
+ // validator invoked on the value that reaches the sink. A closed list of
1431
+ // blessed helpers can never contain a codebase's own validator, so
1432
+ // GHSA-q939-rpr3-3284's fix (`EnsureValidLocalName(filename);` immediately
1433
+ // before `Path.Combine(..., filename)`) left the finding firing on the FIXED
1434
+ // revision. Safe to match on shape because _guardMatchNearSinkIdentifier
1435
+ // already requires the guard to name the same identifier as the sink.
1436
+ const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
1342
1437
  function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
1343
1438
 
1344
1439
  // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
@@ -1394,6 +1489,20 @@ export function dropGuardedFindings(findings, fileContents) {
1394
1489
  // sanitizer-dataflow pipeline, which records them in scan.suppressions.
1395
1490
  // Don't hard-drop them here or that bookkeeping is lost (smoke FP-3).
1396
1491
  if (f.isSanitized) return true;
1492
+ // A finding whose CLAIM IS the guard's presence must survive guard
1493
+ // recognition, or the evidence is read as the refutation.
1494
+ //
1495
+ // `sibling-guard-omission` reports "this file applies <guard> to X and not
1496
+ // to its sibling Y". The window around Y therefore ALWAYS contains a
1497
+ // containment guard — that is the whole point — so the generic check below
1498
+ // dropped 100% of these findings, structurally, and no amount of detector
1499
+ // work could ever have surfaced one. Found while writing the rule: it fired
1500
+ // correctly in isolation and produced nothing through a scan, the same
1501
+ // signature as the rate-limit.js defect.
1502
+ //
1503
+ // Scoped to this family rather than loosening the window heuristic, which
1504
+ // is doing its job for every other CWE-22 emitter.
1505
+ if (f.family === 'sibling-guard-omission') return true;
1397
1506
  const cwe = f.cwe || '';
1398
1507
  const isXss = cwe === 'CWE-79' && /reflected xss/i.test(f.vuln || '');
1399
1508
  if (cwe !== 'CWE-918' && cwe !== 'CWE-22' && !isXss) return true;
@@ -1567,7 +1676,7 @@ function _detectAllowlistGuard(lines, varName, srcLine, sinkLine) {
1567
1676
  return null;
1568
1677
  }
1569
1678
 
1570
- function performRegexAnalysis(fp,raw){if(_INTENTIONAL_VULN_PATH_RE.test(fp.replace(/\\/g,'/')))return{findings:[],sources:[],sinks:[],sanitizers:[]};const cleaned=stripNoise(raw);const cleanedNoStrings=stripNoiseAndStrings(raw);const lines=raw.split("\n");const findings=[],sources=[],sinks=[],sanitizers=[];
1679
+ function performRegexAnalysis(fp,raw){if(_INTENTIONAL_VULN_PATH_RE.test(fp.replace(/\\/g,'/')))return{findings:[],sources:[],sinks:[],sanitizers:[]};const cleaned=stripNoise(raw,fp);const cleanedNoStrings=stripNoiseAndStrings(raw);const lines=raw.split("\n");const findings=[],sources=[],sinks=[],sanitizers=[];
1571
1680
  for(const sp of SOURCE_PATTERNS){const re=new RegExp(sp.regex.source,sp.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const lt=lines[line-1]||"";let _srcVar=null;if(typeof sp.getVar==='function'){try{_srcVar=sp.getVar(m,lt)||null;}catch(_){_srcVar=null;}}if(!_srcVar){const am=lt.match(/(?:const|let|var|)\s*(\w+)\s*=/)||lt.match(/(\w+)\s*=/);_srcVar=am?am[1]:null;}const _itype=sp.inputType(m);if(_srcVar&&(_itype==='cookies'||_itype==='headers')){const _mc=lt.indexOf(m[0]);if(_mc>=0){const _before=lt.substring(0,_mc);if((_before.match(/\(/g)||[]).length>(_before.match(/\)/g)||[]).length)_srcVar=null;}}sources.push({label:sp.getLabel(m),category:sp.category,inputType:_itype,variable:_srcVar,line,file:fp,snippet:lt.trim()});}}
1572
1681
  // Feat-1: in-file Python helper-taint propagation. Pushes synthetic sources
1573
1682
  // for function parameters that are tainted via call-site argument flow.
@@ -1647,7 +1756,7 @@ function performAnalysis(fp, raw) {
1647
1756
  return performRegexAnalysis(fp, raw);
1648
1757
  }
1649
1758
 
1650
- function scanRoutes(fp,raw){const cleaned=stripNoise(raw);const lines=raw.split("\n");const routes=[];const mwAuth=detectMiddlewareAuth(raw);for(const p of ROUTE_PATTERNS){const re=new RegExp(p.regex.source,p.regex.flags);let m;while((m=re.exec(cleaned))){let method="GET";if(p.mI&&m[p.mI]){const r=m[p.mI].toUpperCase();method=["GET","POST","PUT","PATCH","DELETE","ALL","OPTIONS","HEAD"].includes(r)?r:"GET";}if(p.mtI&&m[p.mtI]){const fm=m[p.mtI].replace(/['"]/g,"").split(",").map(s=>s.trim().toUpperCase());if(fm.length)method=fm[0];}const path=p.pI&&m[p.pI]?m[p.pI]:"(file-based)";const line=lineAt(cleaned,m.index);let hasAuth=false;const nearby=lines.slice(Math.max(0,line-3),line+3).join(" ");if(/(?:authenticate|auth|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize|isAuthorized|expressJwt|denyAll)\s*[\(,]/i.test(nearby))hasAuth=true;if(!hasAuth)for(const mw of mwAuth)if(line>mw.line&&(mw.scope==="/"||path.startsWith(mw.scope)))hasAuth=true;const rp=[];for(const sp of SOURCE_PATTERNS){const re2=new RegExp(sp.regex.source,sp.regex.flags);const block=lines.slice(Math.max(0,line-2),Math.min(lines.length,line+30)).join("\n");let m2;while((m2=re2.exec(block))){const v=m2[2]||m2[3]||m2[1];if(v)rp.push(v);}}const cls=classifyEndpoint(rp);const classifiedFields={};rp.forEach(field=>{const fc=classifyField(field);if(fc.length)classifiedFields[field]=fc;});const uploadBlock2=lines.slice(Math.max(0,line-5),Math.min(lines.length,line+40)).join("\n");const hasFileUpload=/(?:multer|busboy|formidable|upload\.(?:single|array|fields|any|none)|req\.files?\b|request\.files?\b|request\.FILES|\$_FILES|\$request->(?:file|hasFile)|multipart\/form-data|r\.FormFile|r\.MultipartForm)/i.test(uploadBlock2);routes.push({method,path,framework:p.fw,file:fp,line,hasAuth,hasFileUpload,params:rp,classifications:cls,classifiedFields});}}return routes;}
1759
+ function scanRoutes(fp,raw){const cleaned=stripNoise(raw,fp);const lines=raw.split("\n");const routes=[];const mwAuth=detectMiddlewareAuth(raw);for(const p of ROUTE_PATTERNS){const re=new RegExp(p.regex.source,p.regex.flags);let m;while((m=re.exec(cleaned))){let method="GET";if(p.mI&&m[p.mI]){const r=m[p.mI].toUpperCase();method=["GET","POST","PUT","PATCH","DELETE","ALL","OPTIONS","HEAD"].includes(r)?r:"GET";}if(p.mtI&&m[p.mtI]){const fm=m[p.mtI].replace(/['"]/g,"").split(",").map(s=>s.trim().toUpperCase());if(fm.length)method=fm[0];}const path=p.pI&&m[p.pI]?m[p.pI]:"(file-based)";const line=lineAt(cleaned,m.index);let hasAuth=false;const nearby=lines.slice(Math.max(0,line-3),line+3).join(" ");if(/(?:authenticate|auth|jwt\.verify|verifyToken|authMiddleware|checkAuth|protect|authorize|isAuthorized|expressJwt|denyAll|checkPermission|checkAnyPermission|hasAnyPermission|hasPermission|requirePermission)\s*[\(,]/i.test(nearby))hasAuth=true;if(!hasAuth)for(const mw of mwAuth)if(line>mw.line&&(mw.scope==="/"||path.startsWith(mw.scope)))hasAuth=true;const rp=[];for(const sp of SOURCE_PATTERNS){const re2=new RegExp(sp.regex.source,sp.regex.flags);const block=lines.slice(Math.max(0,line-2),Math.min(lines.length,line+30)).join("\n");let m2;while((m2=re2.exec(block))){const v=m2[2]||m2[3]||m2[1];if(v)rp.push(v);}}const cls=classifyEndpoint(rp);const classifiedFields={};rp.forEach(field=>{const fc=classifyField(field);if(fc.length)classifiedFields[field]=fc;});const uploadBlock2=lines.slice(Math.max(0,line-5),Math.min(lines.length,line+40)).join("\n");const hasFileUpload=/(?:multer|busboy|formidable|upload\.(?:single|array|fields|any|none)|req\.files?\b|request\.files?\b|request\.FILES|\$_FILES|\$request->(?:file|hasFile)|multipart\/form-data|r\.FormFile|r\.MultipartForm)/i.test(uploadBlock2);routes.push({method,path,framework:p.fw,file:fp,line,hasAuth,hasFileUpload,params:rp,classifications:cls,classifiedFields});}}return routes;}
1651
1760
 
1652
1761
  const LOGIC_PATTERNS=[
1653
1762
  {regex:/Math\.random\s*\(\s*\)/g,vuln:"Weak Randomness",severity:"medium",cwe:"CWE-330",stride:"Spoofing",fix:"Use crypto.randomBytes or crypto.randomUUID for security-sensitive values.",code:"// BEFORE\nconst token = Math.random().toString(36);\n\n// AFTER\nconst token = crypto.randomBytes(32).toString('hex');"},
@@ -1678,7 +1787,7 @@ const LOGIC_PATTERNS=[
1678
1787
  // ── Missing Bounds on Financial/Quantity Fields ──────────────────────────────
1679
1788
  {regex:/(?:req|request|ctx)\s*(?:\.\s*body|\[\s*['"]body['"]\s*\])\s*[.[\s]*(?:quantity|amount|price|units|count|qty)\b(?![^;]{0,200}(?:Number\.isInteger|isNaN|Math\.abs|>=\s*1|>\s*0|>0|>=1|max\s*:))/g,vuln:"Missing Positive-Integer Validation on Financial Field",severity:"medium",cwe:"CWE-20",stride:"Tampering",fix:"Validate that financial/quantity fields are positive integers before processing. Negative values can create credit or reverse transactions.",code:"// BEFORE\nawait Order.create({ quantity: req.body.quantity, price: product.price });\n\n// AFTER\nconst qty = req.body.quantity;\nif (!Number.isInteger(qty) || qty < 1 || qty > 10000)\n return res.status(400).json({ error: 'quantity must be 1-10000' });\nawait Order.create({ quantity: qty, price: product.price });"},
1680
1789
  // ── #22: Missing timeout on outbound HTTP requests (DoS) ─────────────────────
1681
- {regex:/(?:await\s+)?(?:fetch|axios\.(?:get|post|put|patch|delete|request)|http\.(?:get|request)|https\.(?:get|request)|got)\s*\(/gi,vuln:"Missing Timeout on Outbound HTTP Request (DoS)",severity:"medium",cwe:"CWE-400",stride:"Denial of Service",appliesTo:["server"],fix:"Set a timeout on all outbound requests to prevent event-loop starvation from stalled upstreams.",code:"// fetch (Node 18+)\nconst resp = await fetch(url, { signal: AbortSignal.timeout(5000) });\n\n// axios\nawait axios.get(url, { timeout: 5000 });\n\n// node http\nconst req = http.get(url, cb);\nreq.setTimeout(5000, () => req.destroy());"},
1790
+ {regex:/(?:await\s+)?\b(?:fetch|axios\.(?:get|post|put|patch|delete|request)|http\.(?:get|request)|https\.(?:get|request)|got)\s*\(/gi,vuln:"Missing Timeout on Outbound HTTP Request (DoS)",severity:"medium",cwe:"CWE-400",stride:"Denial of Service",appliesTo:["server"],fix:"Set a timeout on all outbound requests to prevent event-loop starvation from stalled upstreams.",code:"// fetch (Node 18+)\nconst resp = await fetch(url, { signal: AbortSignal.timeout(5000) });\n\n// axios\nawait axios.get(url, { timeout: 5000 });\n\n// node http\nconst req = http.get(url, cb);\nreq.setTimeout(5000, () => req.destroy());"},
1682
1791
  // ── #24: ORM collection queries without pagination limit (DoS) ───────────────
1683
1792
  {regex:/\.\s*(?:findAll|findMany|findAndCountAll)\s*\(\s*\{[^}]{0,500}\}/g,vuln:"ORM Collection Query Without Pagination Limit (DoS)",severity:"medium",cwe:"CWE-400",stride:"Denial of Service",appliesTo:["server"],fix:"Always set limit/take on collection queries to bound memory and DB load.",code:"const items = await Model.findAll({\n where: { userId: req.user.id },\n limit: Math.min(Number(req.query.limit) || 50, 100),\n offset: Number(req.query.offset) || 0,\n});"},
1684
1793
  // ── #27: Missing audit log on sensitive mutations (Repudiation) ──────────────
@@ -2149,7 +2258,7 @@ function scanStructuralVulns(fp, raw) {
2149
2258
  // Default cleaned view strips strings; if any pattern needs the literal-aware
2150
2259
  // view, we fall back to stripNoise for that one pattern.
2151
2260
  const cleaned = stripNoiseAndStrings(raw);
2152
- const cleanedNoise = stripNoise(raw);
2261
+ const cleanedNoise = stripNoise(raw,fp);
2153
2262
  const lines = raw.split('\n');
2154
2263
  const findings = [];
2155
2264
  const ctx = inferFileContext(fp, raw);
@@ -2332,6 +2441,28 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
2332
2441
  const pathLC = fp.replace(/\\\\/g,'/').toLowerCase();
2333
2442
  if (_CRED_PATH_RE.test(pathLC) || _CRED_FILE_RE.test(pathLC)) return {skip:true, reason:'path-filter'};
2334
2443
  // Extract LHS identifier from the snippet (handles `const x =`, `x =`, `{ x:`, etc.)
2444
+ // Well-known protocol / spec vocabulary that is public by definition: OAuth
2445
+ // and OIDC grant, token and response types, URN token-type identifiers, HTTP
2446
+ // auth schemes, JWT alg names, and common enum-ish method names. Anchored to
2447
+ // the WHOLE value so a real secret that merely contains one of these words is
2448
+ // unaffected.
2449
+ const _PROTOCOL_CONSTANT_VAL_RE = new RegExp('^(?:'
2450
+ + 'urn:[\\w.:-]+' // urn:ietf:params:oauth:...
2451
+ + '|(?:refresh|access|id|device|bearer|mac|dpop)_token'
2452
+ + '|authorization_code|client_credentials|implicit|password_grant'
2453
+ + '|urn:ietf:params:oauth:grant-type:[\\w-]+'
2454
+ + '|(?:client_secret|private_key)_(?:basic|post|jwt)'
2455
+ + '|(?:Bearer|Basic|Digest|Negotiate|Hawk)'
2456
+ + '|(?:HS|RS|ES|PS)(?:256|384|512)|none|EdDSA'
2457
+ + '|(?:openid|profile|email|offline_access|address|phone)'
2458
+ + '|code|token id_token|id_token token|query|fragment|form_post'
2459
+ // Enum-ish auth vocabulary, NOT all snake_case: the value must end in a
2460
+ // protocol-vocabulary word. A first draft allowed any snake_case string,
2461
+ // which would have suppressed a genuine lowercase secret.
2462
+ + '|[a-z][a-z0-9_]{0,40}_(?:auth|token|grant|type|method|scope|mode|flow)'
2463
+ + '|(?:auth|token|grant|scope)_[a-z][a-z0-9_]{0,40}'
2464
+ + ')$', 'i');
2465
+
2335
2466
  const am = snippet.match(/(?:^|\W)(\w{2,})\s*[:=]\s*['"]/);
2336
2467
  const varName = am ? am[1] : '';
2337
2468
  if (varName && _CRED_VARNAME_RE.test(varName)) return {skip:true, reason:'var-name-placeholder'};
@@ -2340,6 +2471,19 @@ function _isFalsePositiveCredential(fp, snippet, fullMatch){
2340
2471
  const val = valM ? valM[1] : '';
2341
2472
  if (val.length < 8) return {skip:true, reason:'value-too-short'};
2342
2473
  if (_CRED_PLACEHOLDER_VAL_RE.test(val)) return {skip:true, reason:'placeholder-value'};
2474
+ // T2.1 audit — PROTOCOL CONSTANTS are not secrets.
2475
+ //
2476
+ // "Hardcoded Secret" was the 2nd-highest-volume rule over the independent
2477
+ // population (70 findings in a 24-entry sample), and the sampled instances
2478
+ // were all OAuth/OIDC vocabulary declared as named constants:
2479
+ // GrantTypeRefreshToken = "refresh_token"
2480
+ // TokenTypeURNAccessToken = "urn:ietf:params:oauth:token-type:access_token"
2481
+ // AuthRecipeMethodMobileBasicAuth = "mobile_basic_auth"
2482
+ // These are PUBLIC identifiers from a published spec — every conforming
2483
+ // implementation ships the identical string. The rule fired only because the
2484
+ // constant's NAME contains token/auth. Flagging them teaches readers to
2485
+ // ignore the whole class, which is what a noisy critical-severity rule costs.
2486
+ if (_PROTOCOL_CONSTANT_VAL_RE.test(val)) return {skip:true, reason:'protocol-constant'};
2343
2487
  // Shannon-entropy + dictionary-word filter (Recommendation #1 from the
2344
2488
  // SCA/SAST improvement plan). The Juliet Java hardcoded-secret detector
2345
2489
  // was producing 468 FPs / 1 TP because test fixtures use short
@@ -2671,7 +2815,7 @@ function _buildProjectIndex(fileContents){
2671
2815
  if (typeof c !== 'string') continue;
2672
2816
  // Strip comments before checking — keywords in JSDoc / TODO / docstrings shouldn't
2673
2817
  // count as evidence of an e-commerce model. Only real code does.
2674
- const cleaned = stripNoise(c);
2818
+ const cleaned = stripNoise(c,fp);
2675
2819
  if (_ECOMMERCE_MODEL_RE.test(cleaned)) hasEcommerce = true;
2676
2820
  const fileConsts = {};
2677
2821
  let m;
@@ -2802,7 +2946,7 @@ function scanLogicVulns(fp,raw){
2802
2946
  // string-literal route paths and key names, so the comment-stripped (but
2803
2947
  // string-preserving) view is the right default. Rules that explicitly only
2804
2948
  // describe a code shape can opt in via `stripsStrings: true`.
2805
- const cleaned=stripNoise(raw);
2949
+ const cleaned=stripNoise(raw,fp);
2806
2950
  const cleanedFull=stripNoiseAndStrings(raw);
2807
2951
  const lines=raw.split("\n");const results=[];
2808
2952
  const ctx = inferFileContext(fp, raw);
@@ -2868,8 +3012,24 @@ const STORED_TAINT_SINK_PATTERNS=[
2868
3012
 
2869
3013
  function buildStoredTaintRegistry(fc){
2870
3014
  const registry={};
3015
+ // PRD T3.2 — this used to REQUIRE the field name to be one of a closed list
3016
+ // (bio|description|about|…). That is the same closed-enumeration bug found
3017
+ // four separate times in this engine during the same audit (blessed auth
3018
+ // dependencies, `id`/`userId`-only object ids, snake_case-only guards): real
3019
+ // schemas name their fields whatever they like, and a field called `payload`,
3020
+ // `src` or `mobiledoc` carries stored user input exactly as well as `bio`.
3021
+ // The list is kept only as a CONFIDENCE signal now; admission is decided by
3022
+ // whether the value side actually references a request source.
2871
3023
  const STRING_FIELDS=/(?:bio|description|about|message|content|body|note|comment|title|summary|text|review|feedback|username|displayName)/i;
2872
- for(const[fp,code] of Object.entries(fc)){
3024
+ // Fields that are never user prose — registering them produces noise, not flows.
3025
+ const NON_CONTENT_FIELDS=/^(?:id|_id|uuid|createdAt|updatedAt|deletedAt|version|type|status|role|password|hash|salt|token)$/i;
3026
+ for(const[fp,rawCode] of Object.entries(fc)){
3027
+ // Comments are not code. sast/CLAUDE.md's first gotcha is exactly this, and
3028
+ // skipping it here registered phantom fields from ILLUSTRATIVE EXAMPLES in
3029
+ // detector docblocks (`// Sequelize Model.create({ field: req.body.x })`),
3030
+ // which then matched in unrelated files and produced 8 cross-file findings
3031
+ // between this project's own source files.
3032
+ const code=stripNoise(rawCode,fp); // engine-local, line-preserving
2873
3033
  const lines=code.split('\n');
2874
3034
  // Find ORM writes that include string-type fields likely to hold user input
2875
3035
  const ormWrite=/(?:create|update|save|upsert|findOrCreate)\s*\(\s*\{([^}]{0,800})\}/g;
@@ -2879,14 +3039,15 @@ function buildStoredTaintRegistry(fc){
2879
3039
  const fieldMatches=inner.match(/(\w+)\s*:/g)||[];
2880
3040
  for(const f of fieldMatches){
2881
3041
  const fn=f.replace(/\s*:/,'');
2882
- if(STRING_FIELDS.test(fn)){
2883
- // Only register if the value side references a request input
2884
- const fieldRe=new RegExp(fn+'\\s*:\\s*(?:req\\.|request\\.|ctx\\.|\\$_(?:POST|GET)|body\\[|params\\[|query\\[)','i');
2885
- if(fieldRe.test(inner)||/req\.|request\.|ctx\./i.test(inner)){
2886
- const line=code.substring(0,m.index).split('\n').length;
2887
- if(!registry[fn])registry[fn]=[];
2888
- registry[fn].push({file:fp,line,snippet:lines[line-1]?.trim()||''});
2889
- }
3042
+ if(NON_CONTENT_FIELDS.test(fn))continue;
3043
+ // Admission is by PROVENANCE, not by name: the value side must
3044
+ // reference a request source. A recognised prose-ish name only raises
3045
+ // confidence.
3046
+ const fieldRe=new RegExp(fn+'\\s*:\\s*(?:req\\.|request\\.|ctx\\.|\\$_(?:POST|GET)|body\\[|params\\[|query\\[)','i');
3047
+ if(fieldRe.test(inner)||/req\.|request\.|ctx\./i.test(inner)){
3048
+ const line=code.substring(0,m.index).split('\n').length;
3049
+ if(!registry[fn])registry[fn]=[];
3050
+ registry[fn].push({file:fp,line,snippet:lines[line-1]?.trim()||'',named:STRING_FIELDS.test(fn)});
2890
3051
  }
2891
3052
  }
2892
3053
  }
@@ -2905,37 +3066,56 @@ function crossStoredTaint(fc, storedRegistry){
2905
3066
  if(writes.some(w=>w.file===fp))continue;
2906
3067
  const lines=code.split('\n');
2907
3068
  // Check if this file renders the stored field in a dangerous context
2908
- const sinkRe=new RegExp(
2909
- `(?:\`[^\`]*\\$\\{[^}]*\\b${field}\\b[^}]*\\}\`|(?:innerHTML|outerHTML)\\s*=[^;]{0,200}\\b${field}\\b|res\\s*\\.\\s*(?:send|write|json)\\s*\\([^;]{0,200}\\b${field}\\b)`,
2910
- 'g'
2911
- );
3069
+ // PRD T3.2 — sink families beyond XSS.
3070
+ //
3071
+ // This used to recognise ONLY render sinks (innerHTML / res.send /
3072
+ // template literal), so a stored value flowing to a network, filesystem
3073
+ // or shell sink was invisible. The evidence entry that forced this is
3074
+ // Ghost's mobiledoc re-render (GHSA-g366-23fw-ggp6): stored post content
3075
+ // is walked on a later render pass and its image `src` handed to an
3076
+ // unguarded fetch — a stored SSRF, not stored XSS. Each family carries
3077
+ // its own CWE so the finding is not mislabelled as XSS.
3078
+ const F = field;
3079
+ const STORED_SINK_FAMILIES = [
3080
+ { re: `(?:\`[^\`]*\\$\\{[^}]*\\b${F}\\b[^}]*\\}\`|(?:innerHTML|outerHTML)\\s*=[^;]{0,200}\\b${F}\\b|res\\s*\\.\\s*(?:send|write|json)\\s*\\([^;]{0,200}\\b${F}\\b)`,
3081
+ vuln: 'Stored XSS / Second-Order Injection', cwe: 'CWE-79', sanit: /(?:escape|DOMPurify|sanitize|encodeURIComponent|escapeHtml|textContent)\s*\(/ },
3082
+ { re: `(?:fetch|axios(?:\\.\\w{1,10})?|got|request|urlopen|requests\\.\\w{1,10}|httpx\\.\\w{1,10}|getImageSize\\w{0,20})\\s*\\([^;]{0,200}\\b${F}\\b`,
3083
+ vuln: 'Stored SSRF (second-order)', cwe: 'CWE-918', sanit: /(?:isAllowed|allowlist|allowList|denyList|isPrivate|ssrf|validateUrl|is_?internal)\s*\(/i },
3084
+ { re: `(?:readFile|readFileSync|createReadStream|open|sendFile|path\\.join)\\s*\\([^;]{0,200}\\b${F}\\b`,
3085
+ vuln: 'Stored path traversal (second-order)', cwe: 'CWE-22', sanit: /(?:basename|resolve|realpath|secure_filename|normalize)\s*\(/i },
3086
+ { re: `(?:exec|execSync|spawn|system|popen)\\s*\\([^;]{0,200}\\b${F}\\b`,
3087
+ vuln: 'Stored command injection (second-order)', cwe: 'CWE-78', sanit: /(?:shell-?escape|shlex\.quote|execFile)\s*\(/i },
3088
+ ];
3089
+ for (const fam of STORED_SINK_FAMILIES) {
3090
+ const sinkRe=new RegExp(fam.re,'g');
2912
3091
  let m2;
2913
3092
  while((m2=sinkRe.exec(code))!==null){
2914
3093
  const sinkLine=code.substring(0,m2.index).split('\n').length;
2915
3094
  const snippet=lines[sinkLine-1]?.trim()||'';
2916
3095
  // Check for sanitizer in the vicinity
2917
3096
  const nearby=lines.slice(Math.max(0,sinkLine-5),sinkLine+2).join(' ');
2918
- const isSanitized=/(?:escape|DOMPurify|sanitize|encodeURIComponent|escapeHtml|textContent)\s*\(/.test(nearby);
3097
+ const isSanitized=fam.sanit.test(nearby);
2919
3098
  if(!isSanitized){
2920
3099
  for(const write of writes){
2921
3100
  const id=`stored:${write.file}:${write.line}:${fp}:${sinkLine}:${field}`;
2922
3101
  findings.push({
2923
3102
  id,
2924
3103
  source:{label:`Stored field: ${field} (written at ${write.file.split('/').pop()}:${write.line})`,category:'Stored Taint',inputType:'stored',variable:field,line:write.line,file:write.file,snippet:write.snippet},
2925
- sink:{type:'Stored Sink',severity:'high',vuln:'Stored XSS / Second-Order Injection',cwe:'CWE-79',stride:'Tampering',line:sinkLine,file:fp,snippet,args:snippet},
3104
+ sink:{type:'Stored Sink',severity:'high',vuln:fam.vuln,cwe:fam.cwe,stride:'Tampering',line:sinkLine,file:fp,snippet,args:snippet},
2926
3105
  path:[
2927
3106
  {type:'source',label:`ORM write: ${field} = user_input`,line:write.line,snippet:write.snippet},
2928
3107
  {type:'propagation',label:`Stored in database (${field})`,line:write.line,snippet:''},
2929
3108
  {type:'sink',label:`Rendered unsanitized in ${fp.split('/').pop()}:${sinkLine}`,line:sinkLine,snippet}
2930
3109
  ],
2931
3110
  isSanitized:false,sanitizerType:null,
2932
- severity:'high',vuln:'Stored XSS / Second-Order Injection',cwe:'CWE-79',stride:'Tampering',
3111
+ severity:'high',vuln:fam.vuln,cwe:fam.cwe,stride:'Tampering',
2933
3112
  file:`${write.file} -> ${fp}`,isCrossFile:true,parser:'STORED_TAINT'
2934
3113
  });
2935
3114
  break; // one finding per (field,sinkFile,sinkLine) is enough
2936
3115
  }
2937
3116
  }
2938
3117
  }
3118
+ }
2939
3119
  }
2940
3120
  }
2941
3121
  return findings;
@@ -3097,6 +3277,19 @@ const JAVA_FAMILY_RULES = [
3097
3277
  sinkRe: /\bnew\s+(?:java\s*\.\s*util\s*\.\s*)?Random\s*\(|\bMath\s*\.\s*random\s*\(\s*\)|\bThreadLocalRandom\s*\.\s*current\s*\(\s*\)\s*\.\s*next/,
3098
3278
  sanitizerRe: /\bSecureRandom\b/,
3099
3279
  requiresSource: false,
3280
+ // T2.1 audit — a weak PRNG is only a vulnerability when its output is used
3281
+ // for something that must be UNPREDICTABLE. This rule had no such
3282
+ // requirement, so it fired on netty's leak-detector sampling
3283
+ // (`ThreadLocalRandom.current().nextInt(samplingInterval)`), round-robin
3284
+ // address selection, and a `new Random(0L)` inside a microbenchmark — 36
3285
+ // findings in a 24-entry sample, none of them security-relevant.
3286
+ //
3287
+ // The JS sibling rule (sast/weak-randomness.js) has always required a
3288
+ // security-context keyword; this is the same gate, applied file-wide
3289
+ // because Java's randomness helpers are usually a few lines from what they
3290
+ // seed. Returning truthy SUPPRESSES, so this reads "no security context
3291
+ // anywhere in the file".
3292
+ fileSafePredicate: (cleaned) => !/\b(?:token|secret|password|passwd|nonce|salt|iv\b|session|cookie|csrf|otp|mfa|api_?key|apikey|credential|auth|jwt|sign|cipher|encrypt|decrypt|random_?id|uuid|guid|reset|verif|captcha|challenge|ticket|voucher|coupon)\w*/i.test(cleaned), // NOT `seed`: benchmarks seed RNGs deterministically on purpose
3100
3293
  },
3101
3294
  {
3102
3295
  family: 'sql-injection',
@@ -3114,7 +3307,16 @@ const JAVA_FAMILY_RULES = [
3114
3307
  // SqlSession.{selectList,selectOne,selectMap,update,insert,delete} (MyBatis),
3115
3308
  // JdbcTemplate.{queryForStream,queryForRowSet}, R2DBC DatabaseClient.sql,
3116
3309
  // JdbcOperations / JdbcAggregateOperations.* (Spring Data JDBC).
3117
- sinkRe: /\.\s*(?:executeQuery|executeUpdate|execute|executeBatch|prepareStatement|prepareCall|createQuery|createNativeQuery|createSQLQuery|createCriteriaQuery|createSqlQuery|createStatement|addBatch|queryForObject|queryForList|queryForMap|queryForLong|queryForInt|queryForRowSet|queryForStream|batchUpdate|find_by_sql|sqlRestriction|selectList|selectOne|selectMap|selectCursor|sql)\s*\(/,
3310
+ // T2.1 audit: bare `execute` was in this list, and in Java that is
3311
+ // overwhelmingly java.util.concurrent.Executor.execute(Runnable) — a
3312
+ // THREAD POOL, not a database. Measured on the independent population
3313
+ // it produced high-severity SQL-injection findings on netty's
3314
+ // DefaultChannelPipeline, NonStickyEventExecutorGroup and
3315
+ // ThreadExecutorMap (`executor.execute(command)`), and on Appium's
3316
+ // AppiumCommandExecutor. Statement.execute(sql) IS real JDBC, so the
3317
+ // verb is kept but now requires a database-shaped receiver. This is the
3318
+ // same correction the comment above records for update/insert/delete.
3319
+ sinkRe: /(?:\.\s*(?:executeQuery|executeUpdate|executeBatch|prepareStatement|prepareCall|createQuery|createNativeQuery|createSQLQuery|createCriteriaQuery|createSqlQuery|createStatement|addBatch|queryForObject|queryForList|queryForMap|queryForLong|queryForInt|queryForRowSet|queryForStream|batchUpdate|find_by_sql|sqlRestriction|selectList|selectOne|selectMap|selectCursor|sql)\s*\(|\b(?:stmt|statement|st|ps|pstmt|preparedStatement|cstmt|conn|connection|jdbc\w{0,20}|jdbcTemplate|template|session|entityManager|em|db|database|dataSource|sqlSession)\s*\.\s*execute\s*\()/i,
3118
3320
  sanitizerRe: null,
3119
3321
  useTaint: true,
3120
3322
  },
@@ -3804,7 +4006,7 @@ function _buildGlobalJavaTaintedMethodIndex(fileContents) {
3804
4006
  for (const [path, content] of Object.entries(fileContents)) {
3805
4007
  if (!/\.java$/i.test(path) || !content || content.length > 500_000) continue;
3806
4008
  if (!_GLOBAL_JAVA_SOURCE_RE_FOR_INDEX.test(content)) continue;
3807
- const cleaned = stripNoise(content);
4009
+ const cleaned = stripNoise(content,path);
3808
4010
  // Same method-extraction pattern as _javaFindSanitizerMethods.
3809
4011
  const re = /\b(?:public|private|protected|static|final|\s)+\s*[\w.<>\[\]]+\s+([A-Za-z_]\w*)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?\s*\{/g;
3810
4012
  let m;
@@ -4512,7 +4714,7 @@ function _javaArgWrappedBySanitizer(argStr, family) {
4512
4714
 
4513
4715
  function scanJavaSAST(fp, raw) {
4514
4716
  if (!/\.java$/i.test(fp)) return [];
4515
- const cleaned = stripNoise(raw);
4717
+ const cleaned = stripNoise(raw,fp);
4516
4718
  const lines = raw.split('\n');
4517
4719
  const findings = [];
4518
4720
  // hasSource: file has a direct user-input source OR calls a globally-known
@@ -4968,7 +5170,7 @@ function scanMiddlewareOrdering(fp, raw){
4968
5170
  if (!ctx.isServer) return [];
4969
5171
  // Need literal mount-path strings — keep comments stripped but preserve
4970
5172
  // string contents so the path argument is readable.
4971
- const cleaned = stripNoise(raw);
5173
+ const cleaned = stripNoise(raw,fp);
4972
5174
  const findings = [];
4973
5175
  const lines = raw.split('\n');
4974
5176
  let firstAuthAt = Infinity;
@@ -5037,7 +5239,7 @@ function scanAliasedSinks(fp, raw){
5037
5239
  if (!/\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return [];
5038
5240
  // Comments-only strip; preserve string literals so bracket-access shapes
5039
5241
  // like cp['exec'](...) — which need to see the literal 'exec' — match.
5040
- const cleaned = stripNoise(raw);
5242
+ const cleaned = stripNoise(raw,fp);
5041
5243
  const lines = raw.split('\n');
5042
5244
  const findings = [];
5043
5245
  // 1. Property-assigned aliases: const X = cp.exec
@@ -5175,10 +5377,129 @@ function scanAliasedSinks(fp, raw){
5175
5377
  // path on patterns that lack any star/plus quantifier altogether.
5176
5378
  let _safeRegex;
5177
5379
  try { _safeRegex = _require('safe-regex'); } catch (_) { _safeRegex = null; }
5380
+ /**
5381
+ * Is the pattern anchored at the start (`^`, or `\A`)?
5382
+ *
5383
+ * An UNANCHORED exec() retries from every start offset, so a greedy leading
5384
+ * quantifier that fails late costs O(n) per offset — O(n^2) overall. That
5385
+ * polynomial blow-up, not intrinsic backtracking, is the defect in
5386
+ * GHSA-29g2-3rmr-qm68 (sveltejs/kit), whose entire fix was to prepend `^`.
5387
+ * A start anchor leaves exactly one start position and removes the multiplier.
5388
+ *
5389
+ * Deliberately conservative: `^` inside an alternation (`^a|b`) does NOT
5390
+ * anchor the whole pattern, since the `b` branch is still free-floating.
5391
+ */
5392
+ function _isAnchoredRegex(body){
5393
+ const s = String(body || '');
5394
+ if (!/^(?:\^|\\A)/.test(s)) return false;
5395
+ // A top-level `|` means some branch is unanchored — not a real anchor.
5396
+ let depth = 0, cls = false;
5397
+ for (let i = 0; i < s.length; i++) {
5398
+ const ch = s[i];
5399
+ if (ch === '\\') { i++; continue; }
5400
+ if (cls) { if (ch === ']') cls = false; continue; }
5401
+ if (ch === '[') { cls = true; continue; }
5402
+ if (ch === '(') depth++;
5403
+ else if (ch === ')') depth--;
5404
+ else if (ch === '|' && depth === 0) return false;
5405
+ }
5406
+ return true;
5407
+ }
5408
+ /**
5409
+ * A quantified group that itself contains a quantifier — `(a+)+`, `(?:\w+\s?)*`.
5410
+ * This is the intrinsic, exponential form of catastrophic backtracking, and it
5411
+ * is NOT cured by anchoring, which is why the anchor exemption below refuses
5412
+ * to apply when this is present.
5413
+ */
5414
+ function _hasNestedQuantifier(body){
5415
+ const s = String(body || '');
5416
+ for (let i = 0; i < s.length; i++) {
5417
+ if (s[i] === '\\') { i++; continue; }
5418
+ if (s[i] !== '(') continue;
5419
+ let depth = 0, cls = false, j = i;
5420
+ for (; j < s.length; j++) {
5421
+ const ch = s[j];
5422
+ if (ch === '\\') { j++; continue; }
5423
+ if (cls) { if (ch === ']') cls = false; continue; }
5424
+ if (ch === '[') { cls = true; continue; }
5425
+ if (ch === '(') depth++;
5426
+ else if (ch === ')') { depth--; if (depth === 0) break; }
5427
+ }
5428
+ if (j >= s.length) continue;
5429
+ const inner = s.slice(i + 1, j);
5430
+ const after = s.slice(j + 1);
5431
+ const groupQuantified = /^(?:[*+]|\{\d+,\d*\})/.test(after);
5432
+ if (groupQuantified && /[*+]|\{\d+,/.test(inner)) return true;
5433
+ }
5434
+ return false;
5435
+ }
5436
+ /**
5437
+ * Does an UNBOUNDED quantifier appear before the first REQUIRED literal?
5438
+ *
5439
+ * This is the mechanism behind polynomial (scan-and-retry) ReDoS. When a
5440
+ * greedy `+`/`*` runs before the engine has any literal to discriminate on,
5441
+ * an unanchored match must re-run that scan from every start offset — O(n^2).
5442
+ * Once a required literal comes first, the engine can reject most offsets in
5443
+ * O(1) and the blow-up disappears.
5444
+ *
5445
+ * /([^/ \t]+)\/…/ → `+` before the required `/` → true (the
5446
+ * real defect in GHSA-29g2-3rmr-qm68)
5447
+ * /(?:^|[\\/])\.env(?:…)?$/ → required `.` first → false
5448
+ *
5449
+ * Needed because the two analysers available here have opposite blind spots:
5450
+ * redos-nfa.js models exponential backtracking only and clears BOTH revisions
5451
+ * of the sveltejs pattern, while safe-regex's star-height heuristic flags
5452
+ * plain `(?:^|[\\/])\.env…` file-matchers that cannot backtrack at all.
5453
+ */
5454
+ function _hasLeadingUnboundedQuantifier(body){
5455
+ const s = String(body || '');
5456
+ const unboundedAt = (k) => s[k] === '+' || s[k] === '*'
5457
+ || (s[k] === '{' && /^\{\d+,\s*\}/.test(s.slice(k)));
5458
+ for (let i = 0; i < s.length; i++){
5459
+ const ch = s[i];
5460
+ if (ch === '\\'){
5461
+ const nxt = s[i + 2];
5462
+ if (unboundedAt(i + 2)) return true;
5463
+ if (nxt !== '?' && nxt !== '*') return false; // required escaped literal
5464
+ i++; continue;
5465
+ }
5466
+ if (ch === '['){
5467
+ let j = i + 1;
5468
+ for (; j < s.length; j++){
5469
+ if (s[j] === '\\'){ j++; continue; }
5470
+ if (s[j] === ']') break;
5471
+ }
5472
+ if (unboundedAt(j + 1)) return true;
5473
+ i = j; continue;
5474
+ }
5475
+ if (ch === '(' || ch === ')' || ch === '|' || ch === '^' || ch === '?'
5476
+ || ch === ':' || ch === '=' || ch === '!' || ch === '<') continue;
5477
+ if (ch === '.'){ if (unboundedAt(i + 1)) return true; continue; }
5478
+ // A plain literal character: required unless immediately made optional.
5479
+ if (unboundedAt(i + 1)) return true;
5480
+ if (s[i + 1] !== '?') return false;
5481
+ }
5482
+ return false;
5483
+ }
5178
5484
  function _isLikelyUnsafeRegex(body){
5179
5485
  if (!body || body.length < 3) return false;
5180
5486
  // Cheap prefilter: must contain a quantifier *, +, or {n,} for ReDoS to be possible.
5181
5487
  if (!/[*+]|\{\d+,/.test(body)) return false;
5488
+ // safe-regex (star-height heuristic) is the only signal below, and it
5489
+ // over-reports on patterns that cannot backtrack — plain file matchers like
5490
+ // /(?:^|[\\/])\.env(?:\.[\w-]+)?$/ were reported across this project's own
5491
+ // source. Require a mechanism to actually be present: either intrinsic
5492
+ // (a nested quantifier) or scan-and-retry (an unbounded quantifier ahead of
5493
+ // the first required literal). redos-nfa.js is consulted as a second
5494
+ // opinion for the intrinsic case only, since it does not model the
5495
+ // polynomial one.
5496
+ if (!_hasNestedQuantifier(body) && !_hasLeadingUnboundedQuantifier(body)) return false;
5497
+ // FIX-DISCRIMINATION (GHSA-29g2-3rmr-qm68): an anchored pattern with no
5498
+ // nested quantifier has neither failure mode — no start-offset multiplier,
5499
+ // no intrinsic catastrophe. safe-regex's star-height heuristic cannot see
5500
+ // anchoring at all and flagged the fixed revision identically to the
5501
+ // vulnerable one, so the finding survived its own fix.
5502
+ if (_isAnchoredRegex(body) && !_hasNestedQuantifier(body)) return false;
5182
5503
  if (_safeRegex) {
5183
5504
  try { if (!_safeRegex(body)) return true; } catch (_) { /* fall through */ }
5184
5505
  }
@@ -5262,6 +5583,44 @@ function _charSetsIntersect(a, b){
5262
5583
  function _inClass(ch, cls){
5263
5584
  return cls.negate ? !cls.members.has(ch) : cls.members.has(ch);
5264
5585
  }
5586
+ /**
5587
+ * Extract regex-literal bodies, honouring character classes.
5588
+ *
5589
+ * The previous extractor was itself a regex:
5590
+ * /\/((?:\\.|[^\/\n])+)\/[gimsuy]{0,5}/g
5591
+ * which has no notion of `[...]`, so an unescaped `/` inside a character
5592
+ * class — legal, and extremely common in path/URL patterns — TERMINATED the
5593
+ * literal early and emitted two phantom fragments in place of one real regex.
5594
+ *
5595
+ * Found via GHSA-29g2-3rmr-qm68, where
5596
+ * /^[ \t]*([^/ \t]+)\/([^; \t]+)[ \t]*(?:;[ \t]*q=([0-9.]+))?/
5597
+ * was split into `^[ \t]*([^` and `([^; \t]+)[ \t]*(?:;[ \t]*q=([0-9.]+))?`.
5598
+ * The second fragment never existed in the source, and — having lost the
5599
+ * leading `^` to the split — was judged unanchored and reported as ReDoS on
5600
+ * the FIXED revision, so the finding survived its own fix.
5601
+ *
5602
+ * Returns [{body, index}] with `index` at the opening slash.
5603
+ */
5604
+ function _extractRegexLiterals(source){
5605
+ const out = [];
5606
+ const s = String(source || '');
5607
+ for (let i = 0; i < s.length; i++){
5608
+ if (s[i] !== '/') continue;
5609
+ if (s[i + 1] === '/' || s[i + 1] === '*') continue; // comment, not a literal
5610
+ let j = i + 1, cls = false, body = '', closed = false;
5611
+ for (; j < s.length; j++){
5612
+ const ch = s[j];
5613
+ if (ch === '\n') break; // literals never span lines
5614
+ if (ch === '\\'){ body += ch + (s[j + 1] || ''); j++; continue; }
5615
+ if (cls){ if (ch === ']') cls = false; body += ch; continue; }
5616
+ if (ch === '['){ cls = true; body += ch; continue; }
5617
+ if (ch === '/'){ closed = true; break; }
5618
+ body += ch;
5619
+ }
5620
+ if (closed && body.length){ out.push({ body, index: i }); i = j; }
5621
+ }
5622
+ return out;
5623
+ }
5265
5624
  function scanReDoS(fp,raw){
5266
5625
  // ReDoS only applies to languages with regex-literal or RegExp() syntax.
5267
5626
  // Solidity, Java, Go, C/C++, Rust use `/` as the division operator —
@@ -5274,7 +5633,6 @@ function scanReDoS(fp,raw){
5274
5633
  // shapes. Otherwise lines like `// hello /world/` are mis-parsed as regexes
5275
5634
  // and safe-regex chokes on the text content.
5276
5635
  const cleaned = stripNoiseAndStrings(raw);
5277
- const litRe=/\/((?:\\.|[^\/\n])+)\/[gimsuy]{0,5}/g;
5278
5636
  const ctorRe=/new\s+RegExp\s*\(\s*['"`]((?:\\.|[^'"`\n])+)['"`]/g;
5279
5637
  function check(re, source){
5280
5638
  let m;while((m=re.exec(source))!==null){
@@ -5291,13 +5649,25 @@ function scanReDoS(fp,raw){
5291
5649
  }
5292
5650
  }
5293
5651
  // Regex literals: scan against `cleaned` so comment slashes don't fool us.
5294
- check(litRe, cleaned);
5652
+ // Extracted with a character-class-aware walker rather than a regex — see
5653
+ // _extractRegexLiterals for why the regex form was actively harmful.
5654
+ for (const { body, index } of _extractRegexLiterals(cleaned)) {
5655
+ if (!_isLikelyUnsafeRegex(body)) continue;
5656
+ const line = cleaned.substring(0, index).split("\n").length;
5657
+ out.push({
5658
+ vuln:"Regex ReDoS — Catastrophic Backtracking",
5659
+ severity:"medium",cwe:"CWE-1333",stride:"Denial of Service",
5660
+ fix:"Rewrite the regex to avoid nested quantifiers and overlapping alternation. Consider the `re2` library for linear-time matching.",
5661
+ code:"// Use the Google RE2 library which guarantees linear-time evaluation:\nconst RE2 = require('re2');\nconst re = new RE2(pattern);",
5662
+ file:fp,line,snippet:lines[line-1]?.trim()||body
5663
+ });
5664
+ }
5295
5665
  // `new RegExp("...")` form: also via cleaned (comments out, but the literal
5296
5666
  // string pattern is the source we care about — already preserved by
5297
5667
  // stripNoiseAndStrings via the regex-literal carve-out... actually no, our
5298
5668
  // string-stripper blanks "..." contents. Use the comment-stripped view for
5299
5669
  // ctor form so the pattern string survives.).
5300
- check(ctorRe, stripNoise(raw));
5670
+ check(ctorRe, stripNoise(raw,fp));
5301
5671
  return out;
5302
5672
  }
5303
5673
 
@@ -5405,7 +5775,7 @@ const EXTRA_STRUCTURAL_PATTERNS=[
5405
5775
 
5406
5776
  function scanExtraStructural(fp,raw){
5407
5777
  const cleaned=stripNoiseAndStrings(raw);
5408
- const cleanedNoise=stripNoise(raw);
5778
+ const cleanedNoise=stripNoise(raw,fp);
5409
5779
  const lines=raw.split('\n');
5410
5780
  const findings=[];
5411
5781
  const ctx = inferFileContext(fp, raw);
@@ -6478,7 +6848,7 @@ const CIPHER_TRANSIT_PATTERNS=[
6478
6848
  {regex:/OpenSSL::SSL::SSLContext\.new/g,getLabel:()=>"Ruby OpenSSL::SSL::SSLContext",ctx:"Ruby ssl"},
6479
6849
  ];
6480
6850
  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";}
6481
- function scanCiphers(fp,raw){const cleaned=stripNoise(raw);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)};}
6851
+ 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)};}
6482
6852
 
6483
6853
  function scanCredentials(fp,raw){
6484
6854
  if(!CRED_PREFILTER.test(raw))return[];
@@ -7651,7 +8021,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7651
8021
  const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
7652
8022
  const _fileTimings = [];
7653
8023
  let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
7654
- const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
8024
+ 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=[];
7655
8025
  // ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
7656
8026
  // runScan({resume:true})). Default OFF, so existing behaviour is untouched.
7657
8027
  // Only this loop is checkpointed; every cross-file pass below re-runs, so
@@ -7719,112 +8089,112 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7719
8089
  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"});
7720
8090
  if(_ckptDone.has(p)&&_ckptReplay(p))continue;
7721
8091
  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};
7722
- 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;}fc[p]=c;aR.push(...scanRoutes(p,c));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,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
8092
+ 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));
7723
8093
  aF.push(...scanLLM(p,c));
7724
8094
  aF.push(...scanLLMOwasp(p,c));
7725
8095
  aF.push(...scanLlmCost(p,c));
7726
- aLogic.push(...scanBusinessLogic(p,c));
7727
- aF.push(...scanPipeline(p,c));
7728
- aF.push(...scanContainer(p,c));
7729
- aF.push(...scanInstallScripts(p,c));
8096
+ aLogic.push(...scanBusinessLogic(p,cc));
8097
+ aF.push(...scanPipeline(p,cc));
8098
+ aF.push(...scanContainer(p,cc));
8099
+ aF.push(...scanInstallScripts(p,cc));
7730
8100
  aF.push(...scanMCP(p,c));
7731
8101
  aF.push(...scanClaudeSettings(p,c));
7732
8102
  aF.push(...scanClaudeMdPromptInjection(p,c));
7733
8103
  aF.push(...scanClaudeHookInjection(p,c));
7734
- aF.push(...scanDjangoHardening(p,c));
7735
- aF.push(...scanDefiDeep(p,c));
7736
- aF.push(...scanSpringbootHardening(p,c));
7737
- aF.push(...scanLaravelHardening(p,c));
7738
- aF.push(...scanSwift(p,c));
7739
- aF.push(...scanDartFlutter(p,c));
7740
- aF.push(...scanWeakRandomness(p,c));
7741
- aF.push(...scanGraphQLModule(p,c));
7742
- aF.push(...scanSensitiveDataLogging(p,c));
7743
- aF.push(...scanComparisonSafety(p,c));
7744
- aF.push(...scanWeakPasswordHash(p,c));
7745
- aF.push(...scanCachePoisoning(p,c));
7746
- aF.push(...scanNullByteInjection(p,c));
8104
+ aF.push(...scanDjangoHardening(p,cc));
8105
+ aF.push(...scanDefiDeep(p,cc));
8106
+ aF.push(...scanSpringbootHardening(p,cc));
8107
+ aF.push(...scanLaravelHardening(p,cc));
8108
+ aF.push(...scanSwift(p,cc));
8109
+ aF.push(...scanDartFlutter(p,cc));
8110
+ aF.push(...scanWeakRandomness(p,cc));
8111
+ aF.push(...scanGraphQLModule(p,cc));
8112
+ aF.push(...scanSensitiveDataLogging(p,cc));
8113
+ aF.push(...scanComparisonSafety(p,cc));
8114
+ aF.push(...scanWeakPasswordHash(p,cc));
8115
+ aF.push(...scanCachePoisoning(p,cc));
8116
+ aF.push(...scanNullByteInjection(p,cc));
7747
8117
  aF.push(...scanLlmTradingAgent(p,c));
7748
- aF.push(...scanMobileManifest(p,c));
7749
- aF.push(...scanQuarkusHardening(p,c));
7750
- aF.push(...scanFastapiHardening(p,c));
7751
- aF.push(...scanAuthZ(p,c));
7752
- aF.push(...scanModelLoad(p,c));
8118
+ aF.push(...scanMobileManifest(p,cc));
8119
+ aF.push(...scanQuarkusHardening(p,cc));
8120
+ aF.push(...scanFastapiHardening(p,cc));
8121
+ aF.push(...scanAuthZ(p,cc));
8122
+ aF.push(...scanModelLoad(p,cc));
7753
8123
  aF.push(...scanPromptTemplate(p,c));
7754
- aF.push(...scanXXE(p,c));
7755
- aF.push(...scanJNDI(p,c));
7756
- aF.push(...scanJavaDeserialization(p,c));
7757
- aF.push(...scanJwtExp(p,c));
7758
- aF.push(...scanZipSlip(p,c));
7759
- aF.push(...scanFileUpload(p,c));
7760
- aF.push(...scanHostHeader(p,c));
7761
- aF.push(...scanPythonSinks(p,c));
7762
- aF.push(...scanCSharp(p,c));
7763
- aF.push(...scanCpp(p,c));
7764
- aF.push(...scanSolidity(p,c));
7765
- aF.push(...scanRust(p,c));
7766
- aF.push(...scanGoExtended(p,c));
7767
- aF.push(...scanDatabaseRLS(p,c));
7768
- aF.push(...scanRateLimit(p,c));
7769
- aF.push(...scanAuthProvider(p,c));
7770
- aF.push(...scanEnvHygiene(p,c));
7771
- aF.push(...scanWebhook(p,c));
7772
- aF.push(...scanClientSide(p,c));
8124
+ aF.push(...scanXXE(p,cc));
8125
+ aF.push(...scanJNDI(p,cc));
8126
+ aF.push(...scanJavaDeserialization(p,cc));
8127
+ aF.push(...scanJwtExp(p,cc));
8128
+ aF.push(...scanZipSlip(p,cc));aF.push(...scanSiblingGuard(p,cc));
8129
+ aF.push(...scanFileUpload(p,cc));
8130
+ aF.push(...scanHostHeader(p,cc));
8131
+ aF.push(...scanPythonSinks(p,cc));
8132
+ aF.push(...scanCSharp(p,cc));
8133
+ aF.push(...scanCpp(p,cc));
8134
+ aF.push(...scanSolidity(p,cc));
8135
+ aF.push(...scanRust(p,cc));
8136
+ aF.push(...scanGoExtended(p,cc));
8137
+ aF.push(...scanDatabaseRLS(p,cc));
8138
+ aF.push(...scanRateLimit(p,cc));aF.push(...scanResourceExhaustion(p,cc));aF.push(...scanRedirectToctou(p,cc));aF.push(...scanCodegenSink(p,cc));aF.push(...scanOwnershipAuthz(p,cc));
8139
+ aF.push(...scanAuthProvider(p,cc));
8140
+ aF.push(...scanEnvHygiene(p,cc));
8141
+ aF.push(...scanWebhook(p,cc));
8142
+ aF.push(...scanClientSide(p,cc));
7773
8143
  aF.push(...scanPromptFirewall(p,c));
7774
8144
  aF.push(...scanLlmRedteam(p,c));
7775
8145
  aF.push(...scanJulietShape(p,c));
7776
- aF.push(...scanCppDataflow(p,c));
8146
+ aF.push(...scanCppDataflow(p,cc));
7777
8147
  // Phase 1: new detectors.
7778
- aF.push(...scanMassAssignment(p,c));
7779
- aF.push(...scanPrototypePollution(p,c));
7780
- aF.push(...scanCSRF(p,c));
7781
- aF.push(...scanTOCTOU(p,c));
7782
- aF.push(...scanNoSQLInjection(p,c));
7783
- aF.push(...scanLDAPInjection(p,c));
7784
- aF.push(...scanXPathInjection(p,c));
7785
- aF.push(...scanSSTI(p,c));
7786
- aF.push(...scanOpenRedirect(p,c));
7787
- aF.push(...scanWrongContextSanitizer(p,c));
7788
- aF.push(...scanSanitizerContextMismatch(p,c));
7789
- aF.push(...scanFrontendHygiene(p,c));
7790
- aF.push(...scanCsvInjection(p,c));
8148
+ aF.push(...scanMassAssignment(p,cc));
8149
+ aF.push(...scanPrototypePollution(p,cc));
8150
+ aF.push(...scanCSRF(p,cc));
8151
+ aF.push(...scanTOCTOU(p,cc));
8152
+ aF.push(...scanNoSQLInjection(p,cc));
8153
+ aF.push(...scanLDAPInjection(p,cc));
8154
+ aF.push(...scanXPathInjection(p,cc));
8155
+ aF.push(...scanSSTI(p,cc));
8156
+ aF.push(...scanOpenRedirect(p,cc));
8157
+ aF.push(...scanWrongContextSanitizer(p,cc));
8158
+ aF.push(...scanSanitizerContextMismatch(p,cc));
8159
+ aF.push(...scanFrontendHygiene(p,cc));
8160
+ aF.push(...scanCsvInjection(p,cc));
7791
8161
  // R16 — specialist crypto-hygiene classes (constant-time comparison,
7792
8162
  // secret zeroization). Narrow by design: keyed on the secret-ness of the
7793
8163
  // identifier, and silent whenever the correct constant-time or
7794
8164
  // guaranteed-wipe API is already present.
7795
- aF.push(...scanCryptoSpecialist(p,c));
7796
- aF.push(...scanStoredTaint(p,c));
7797
- aF.push(...scanJavaStructural(p,c));
7798
- aF.push(...scanCsharpStructural(p,c));
7799
- aF.push(...scanJsFrameworkStructural(p,c));
7800
- aF.push(...scanPythonStructural(p,c));
7801
- aF.push(...scanGoStructural(p,c));
7802
- aF.push(...scanSecretConcat(p,c));
7803
- aF.push(...scanXssReflectedMultilang(p,c));
7804
- aF.push(...scanCodeInjectionMultilang(p,c));
7805
- aF.push(...scanResponseSplitting(p,c));
8165
+ aF.push(...scanCryptoSpecialist(p,cc));
8166
+ aF.push(...scanStoredTaint(p,cc));
8167
+ aF.push(...scanJavaStructural(p,cc));
8168
+ aF.push(...scanCsharpStructural(p,cc));
8169
+ aF.push(...scanJsFrameworkStructural(p,cc));
8170
+ aF.push(...scanPythonStructural(p,cc));
8171
+ aF.push(...scanGoStructural(p,cc));
8172
+ aF.push(...scanSecretConcat(p,cc));
8173
+ aF.push(...scanXssReflectedMultilang(p,cc));
8174
+ aF.push(...scanCodeInjectionMultilang(p,cc));
8175
+ aF.push(...scanResponseSplitting(p,cc));
7806
8176
  aF.push(...scanStoredPromptInjection(p,c));
7807
8177
  aF.push(...scanRAGPoisoning(p,c));
7808
8178
  aF.push(...scanAgentToolEscalation(p,c));
7809
8179
  aF.push(...scanAgentUntrustedFlow(p,c));
7810
- aF.push(...scanEventEntrypoints(p,c));
7811
- aF.push(...scanDbTaint(p,c));
7812
- aF.push(...scanSSRFCloudMetadata(p,c));
7813
- aF.push(...scanMutationXSS(p,c));
7814
- aF.push(...scanKotlin(p,c));
7815
- aF.push(...scanRuby(p,c));
7816
- aF.push(...scanPhp(p,c));
8180
+ aF.push(...scanEventEntrypoints(p,cc));
8181
+ aF.push(...scanDbTaint(p,cc));
8182
+ aF.push(...scanSSRFCloudMetadata(p,cc));
8183
+ aF.push(...scanMutationXSS(p,cc));
8184
+ aF.push(...scanKotlin(p,cc));
8185
+ aF.push(...scanRuby(p,cc));
8186
+ aF.push(...scanPhp(p,cc));
7817
8187
  // Integration block: scaffolded SAST scanners. Gated by env var.
7818
8188
  if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
7819
8189
  if (process.env.AGENTIC_SECURITY_NO_LLM_APP !== '1') aF.push(...scanLlmApp(p,c));
7820
- if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') aF.push(...scanMobile(p,c));
7821
- if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') aF.push(...scanPqc(p,c));
7822
- if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') aF.push(...scanWeb3Advanced(p,c));
7823
- if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') aF.push(...scanDappFrontend(p,c));
7824
- if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') aF.push(...scanCloudIam(p,c));
7825
- if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') aF.push(...scanK8sAdmission(p,c));
7826
- if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') aF.push(...scanCryptoProtocol(p,c));
7827
- if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') aF.push(...scanMlSupplyChain(p,c));
8190
+ if (process.env.AGENTIC_SECURITY_NO_MOBILE !== '1') aF.push(...scanMobile(p,cc));
8191
+ if (process.env.AGENTIC_SECURITY_NO_PQC !== '1') aF.push(...scanPqc(p,cc));
8192
+ if (process.env.AGENTIC_SECURITY_NO_WEB3_ADV!== '1') aF.push(...scanWeb3Advanced(p,cc));
8193
+ if (process.env.AGENTIC_SECURITY_NO_DAPP !== '1') aF.push(...scanDappFrontend(p,cc));
8194
+ if (process.env.AGENTIC_SECURITY_NO_CLOUD_IAM!== '1') aF.push(...scanCloudIam(p,cc));
8195
+ if (process.env.AGENTIC_SECURITY_NO_K8S_ADM !== '1') aF.push(...scanK8sAdmission(p,cc));
8196
+ if (process.env.AGENTIC_SECURITY_NO_CRYPTO_PROTO !== '1') aF.push(...scanCryptoProtocol(p,cc));
8197
+ if (process.env.AGENTIC_SECURITY_NO_ML_SUPPLY !== '1') aF.push(...scanMlSupplyChain(p,cc));
7828
8198
  }
7829
8199
  const _ftElapsed=Date.now()-_ft0;
7830
8200
  if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
@@ -7918,7 +8288,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7918
8288
  continue;
7919
8289
  }
7920
8290
  // Annotation-based category for OWASP Benchmark: `@WebServlet("/cat-NN/...")`.
7921
- const cat = _javaWebServletCategory(stripNoise(c));
8291
+ const cat = _javaWebServletCategory(stripNoise(c,p));
7922
8292
  if (cat) _benchCategoryByFile.set(p, cat);
7923
8293
  }
7924
8294
  // Pre-compute fileWide safe shapes per file. Used by _shouldKeep below.
@@ -7933,7 +8303,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7933
8303
  const c = fc[p];
7934
8304
  if (!c) continue;
7935
8305
  const safeSet = new Set();
7936
- const cleanedP = stripNoise(c);
8306
+ const cleanedP = stripNoise(c,p);
7937
8307
  for (const fam of Object.keys(_OWASP_SAFE_SHAPES || {})) {
7938
8308
  const cfg = _OWASP_SAFE_SHAPES[fam];
7939
8309
  if (cfg && cfg.fileWide && cfg.fileWide(cleanedP)) safeSet.add(fam);
@@ -7979,6 +8349,9 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7979
8349
  setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
7980
8350
  setProgress({current:i,total:files.length,file:"Call graph...",phase:"Linking"});const callGraph=buildCallGraph(fc);
7981
8351
  // R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
8352
+ // Convention deviation is PROJECT-scoped by design: the convention is a
8353
+ // property of the codebase, not of one file (see convention-deviation.js).
8354
+ try{aF.push(...scanConventionDeviationProject(fc));}catch(_){}
7982
8355
  try{aF.push(...scanApiBrokenAuthz(aR));}catch(_){}
7983
8356
  // R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
7984
8357
  try{aF.push(...scanCrossService(aR,fc));}catch(_){}
@@ -7991,6 +8364,20 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7991
8364
  setProgress({current:i,total:files.length,file:"Config file cross-ref...",phase:"Linking"});aLogic.push(...scanConfigFiles(fc));
7992
8365
  setProgress({current:i,total:files.length,file:"OSV vulnerability database...",phase:"SCA"});
7993
8366
  const allFileContents={...fc, ...depFileContents};
8367
+ // PRD F11.4 — malicious install hooks, scanned where package.json actually
8368
+ // lives. `scanInstallScripts` was wired into the per-file SAST dispatch, but
8369
+ // that loop iterates `files`, which comes from `fileContents` — and
8370
+ // `package.json` is routed to depFileContents and fails `shouldScan()`, so
8371
+ // the detector was never invoked by a real scan. Measured: 1 finding when
8372
+ // called directly on its fixture, 0 through runScan.
8373
+ //
8374
+ // Fixed HERE rather than by admitting package.json into the SAST loop, which
8375
+ // would put a manifest through all ~117 code detectors to satisfy one rule.
8376
+ // A manifest detector belongs on the manifest path.
8377
+ for (const [mp, mc] of Object.entries(depFileContents)) {
8378
+ if (!/(?:^|\/)package\.json$/i.test(mp)) continue;
8379
+ try { aF.push(...scanInstallScripts(mp, mc)); } catch (_) { /* never fail a scan on one manifest */ }
8380
+ }
7994
8381
  const components=parseManifests(allFileContents);
7995
8382
  // R8 (PRD §5): OS packages from an extracted container image's package DBs
7996
8383
  // (dpkg/apk) — baked-in deps the Dockerfile never names. Feed the OSV/SBOM pipeline.
@@ -9606,3 +9993,8 @@ export {
9606
9993
  STORED_TAINT_FIELD_PATTERNS, STORED_TAINT_SINK_PATTERNS,
9607
9994
  FIXES, NEW_FIXES,
9608
9995
  };
9996
+
9997
+ /** Internals exposed for targeted unit tests (not part of the public API). */
9998
+ export const _internals = {
9999
+ _isLikelyUnsafeRegex, _isAnchoredRegex, _hasNestedQuantifier, _extractRegexLiterals, _hasLeadingUnboundedQuantifier,
10000
+ };