@clear-capabilities/agentic-security-scanner 0.86.0 → 0.119.1

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 (128) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/bin/agentic-security.js +78 -1
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/415.index.js +90 -0
  6. package/dist/444.index.js +89 -0
  7. package/dist/503.index.js +288 -0
  8. package/dist/637.index.js +15 -1
  9. package/dist/838.index.js +1 -1
  10. package/dist/985.index.js +5 -1
  11. package/dist/agentic-security.mjs +84 -84
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +21 -10
  14. package/src/dataflow/CLAUDE.md +18 -6
  15. package/src/dataflow/catalog.js +59 -11
  16. package/src/dataflow/engine.js +106 -5
  17. package/src/dataflow/implicit-flow.js +4 -2
  18. package/src/dataflow/index.js +17 -4
  19. package/src/dataflow/proof-gate.js +100 -0
  20. package/src/dataflow/summaries.js +63 -6
  21. package/src/engine.js +292 -10
  22. package/src/ir/CLAUDE.md +1 -0
  23. package/src/ir/callgraph.js +12 -1
  24. package/src/ir/parser-go.js +8 -0
  25. package/src/ir/tree-sitter-loader.js +108 -0
  26. package/src/mcp/tools.js +6 -2
  27. package/src/posture/corpus-status.js +74 -0
  28. package/src/posture/coverage-report.js +113 -0
  29. package/src/posture/fix-history.js +24 -0
  30. package/src/posture/holdout-eval.js +57 -0
  31. package/src/posture/provenance.js +39 -0
  32. package/src/posture/router.js +33 -3
  33. package/src/posture/sca-verdict.js +130 -0
  34. package/src/posture/secret-history.js +72 -0
  35. package/src/pr-delta.js +13 -0
  36. package/src/report/index.js +67 -0
  37. package/src/sast/CLAUDE.md +9 -3
  38. package/src/sast/agent-untrusted-flow.js +107 -0
  39. package/src/sast/api-authz.js +71 -0
  40. package/src/sast/code-injection-multilang.js +117 -0
  41. package/src/sast/cross-service.js +98 -0
  42. package/src/sast/crypto-protocol.js +43 -1
  43. package/src/sast/csharp-structural.js +65 -0
  44. package/src/sast/csharp.js +3 -0
  45. package/src/sast/csrf.js +24 -3
  46. package/src/sast/csv-injection.js +84 -0
  47. package/src/sast/event-entrypoint.js +90 -0
  48. package/src/sast/frontend-hygiene.js +106 -0
  49. package/src/sast/go-structural.js +60 -0
  50. package/src/sast/iac-terraform.js +107 -0
  51. package/src/sast/java-structural.js +81 -0
  52. package/src/sast/js-framework-structural.js +115 -0
  53. package/src/sast/kotlin.js +72 -0
  54. package/src/sast/ldap-injection.js +73 -17
  55. package/src/sast/open-redirect.js +47 -5
  56. package/src/sast/php.js +35 -0
  57. package/src/sast/python-structural.js +83 -0
  58. package/src/sast/rbac-consistency.js +113 -0
  59. package/src/sast/redos-nfa.js +75 -0
  60. package/src/sast/response-splitting.js +48 -19
  61. package/src/sast/ruby.js +38 -0
  62. package/src/sast/secret-concat.js +60 -0
  63. package/src/sast/ssrf-cloud-metadata.js +13 -0
  64. package/src/sast/stored-taint.js +88 -0
  65. package/src/sast/tree-sitter-sinks.js +62 -0
  66. package/src/sast/weak-password-hash.js +28 -0
  67. package/src/sast/weak-randomness.js +46 -5
  68. package/src/sast/wrong-context-sanitizer.js +149 -0
  69. package/src/sast/xpath-injection.js +30 -6
  70. package/src/sast/xss-reflected-multilang.js +116 -0
  71. package/src/sast/xxe.js +67 -0
  72. package/src/sca/image-packages.js +74 -0
  73. package/src/sca/import-reachability.js +271 -0
  74. package/src/sca/install-script-analysis.js +82 -0
  75. package/bin/.agentic-security/findings.json +0 -1907
  76. package/bin/.agentic-security/last-scan.json +0 -1907
  77. package/bin/.agentic-security/last-scan.json.sig +0 -1
  78. package/bin/.agentic-security/scan-history.json +0 -166
  79. package/bin/.agentic-security/streak.json +0 -20
  80. package/src/.agentic-security/findings.json +0 -117732
  81. package/src/.agentic-security/last-scan.json +0 -117732
  82. package/src/.agentic-security/last-scan.json.sig +0 -1
  83. package/src/.agentic-security/scan-history.json +0 -12946
  84. package/src/.agentic-security/streak.json +0 -21
  85. package/src/dataflow/.agentic-security/findings.json +0 -6086
  86. package/src/dataflow/.agentic-security/last-scan.json +0 -6086
  87. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  88. package/src/dataflow/.agentic-security/scan-history.json +0 -250
  89. package/src/dataflow/.agentic-security/streak.json +0 -21
  90. package/src/ir/.agentic-security/findings.json +0 -4011
  91. package/src/ir/.agentic-security/last-scan.json +0 -4011
  92. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  93. package/src/ir/.agentic-security/scan-history.json +0 -193
  94. package/src/ir/.agentic-security/streak.json +0 -20
  95. package/src/mcp/.agentic-security/findings.json +0 -8632
  96. package/src/mcp/.agentic-security/last-scan.json +0 -8632
  97. package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
  98. package/src/mcp/.agentic-security/scan-history.json +0 -331
  99. package/src/mcp/.agentic-security/streak.json +0 -20
  100. package/src/posture/.agentic-security/dpia.md +0 -26
  101. package/src/posture/.agentic-security/findings.json +0 -77181
  102. package/src/posture/.agentic-security/last-scan.json +0 -77181
  103. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  104. package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
  105. package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
  106. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  107. package/src/posture/.agentic-security/scan-history.json +0 -8904
  108. package/src/posture/.agentic-security/streak.json +0 -21
  109. package/src/posture/.agentic-security/threat-model.json +0 -2038
  110. package/src/posture/.agentic-security/threat-model.md +0 -73
  111. package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
  112. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
  113. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
  114. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
  115. package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
  116. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  117. package/src/report/.agentic-security/threat-model.json +0 -7
  118. package/src/report/.agentic-security/threat-model.md +0 -22
  119. package/src/sast/.agentic-security/findings.json +0 -6154
  120. package/src/sast/.agentic-security/last-scan.json +0 -6154
  121. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  122. package/src/sast/.agentic-security/scan-history.json +0 -941
  123. package/src/sast/.agentic-security/streak.json +0 -22
  124. package/src/sca/.agentic-security/findings.json +0 -7460
  125. package/src/sca/.agentic-security/last-scan.json +0 -7460
  126. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  127. package/src/sca/.agentic-security/scan-history.json +0 -113
  128. package/src/sca/.agentic-security/streak.json +0 -21
@@ -0,0 +1,62 @@
1
+ // Tree-sitter-backed sinks for long-tail languages (roadmap #8).
2
+ //
3
+ // First AST-accurate detector for a language that has no first-class IR parser.
4
+ // Gated behind AGENTIC_SECURITY_TREE_SITTER=1 and the optional tree-sitter
5
+ // dependency; when either is absent it degrades to a no-op (the existing
6
+ // pattern detectors still run). Anchoring on real AST nodes (not regex over
7
+ // raw text) means comments and string literals can't produce false matches.
8
+ //
9
+ // Rust shell-spawn command injection: a `std::process::Command` that launches a
10
+ // shell (sh/bash/cmd/powershell) with -c / -Command AND a non-literal argument.
11
+ // That is the classic "build a shell string from input" injection; spawning a
12
+ // program directly (no shell) is not flagged.
13
+
14
+ import { getParserFor, treeSitterLangOf, walkNamed } from '../ir/tree-sitter-loader.js';
15
+
16
+ const SHELL_PROG = /Command::new\s*\(\s*[bB]?"(?:sh|bash|zsh|dash|ksh|cmd(?:\.exe)?|powershell(?:\.exe)?)"/;
17
+ const SHELL_FLAG = /\.args?\s*\(\s*\[?\s*[bB]?"(?:-c|\/c|\/C|-Command)"/;
18
+ // An .arg()/.args() whose argument does NOT start with a string literal → dynamic.
19
+ const DYNAMIC_ARG = /\.args?\s*\(\s*(?![bB]?["[])[A-Za-z_&*([]/;
20
+
21
+ export async function scanTreeSitterRust(fp, raw) {
22
+ if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
23
+ if (treeSitterLangOf(fp) !== 'rust') return [];
24
+ if (!raw || raw.length > 500_000) return [];
25
+ if (!/Command::new/.test(raw)) return []; // cheap pre-filter
26
+
27
+ const parser = await getParserFor('rust');
28
+ if (!parser) return []; // optional dep absent → degrade
29
+ let tree;
30
+ try { tree = parser.parse(raw); } catch { return []; }
31
+
32
+ const findings = [];
33
+ const seen = new Set();
34
+ walkNamed(tree.rootNode, (n) => {
35
+ if (n.type !== 'call_expression') return;
36
+ const text = n.text;
37
+ if (!SHELL_PROG.test(text) || !SHELL_FLAG.test(text) || !DYNAMIC_ARG.test(text)) return;
38
+ const line = n.startPosition.row + 1;
39
+ if (seen.has(line)) return;
40
+ seen.add(line);
41
+ findings.push({
42
+ id: `ts-rust-cmdi:${fp}:${line}`,
43
+ severity: 'high', file: fp, line,
44
+ vuln: 'Command injection (Rust shell spawn with dynamic argument)',
45
+ cwe: 'CWE-78', family: 'command-injection', parser: 'TREE-SITTER', confidence: 0.55,
46
+ description: 'A std::process::Command spawns a shell (sh/bash/cmd/powershell) with -c/-Command and a non-literal argument. If that argument is attacker-influenced this is command injection.',
47
+ remediation: 'Do not pass input to a shell. Invoke the program directly — Command::new(prog).arg(userArg) without a shell — and validate inputs.',
48
+ });
49
+ });
50
+ return findings;
51
+ }
52
+
53
+ // Convenience: run every tree-sitter sink detector over a file-content map.
54
+ export async function scanTreeSitterSinks(fileContents) {
55
+ if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
56
+ const out = [];
57
+ for (const [fp, c] of Object.entries(fileContents || {})) {
58
+ if (typeof c !== 'string') continue;
59
+ try { out.push(...await scanTreeSitterRust(fp, c)); } catch { /* never fail the scan */ }
60
+ }
61
+ return out;
62
+ }
@@ -32,6 +32,34 @@ const PATTERNS = {
32
32
  { re: /\bsha1\.(?:Sum|New)\s*\(\s*(?:\[\]byte\s*\(\s*)?(\w+)/g, label: 'sha1.Sum/New' },
33
33
  ],
34
34
  },
35
+ ruby: {
36
+ ext: /\.rb$/i,
37
+ rules: [
38
+ { re: /\bDigest::(?:MD5|SHA1)\s*\.\s*(?:hexdigest|digest|base64digest)\s*\(\s*(\w+)/g, label: 'Digest::MD5/SHA1' },
39
+ { re: /\b(?:Digest::)?(?:MD5|SHA1)\.new[\s\S]{0,40}\.update\s*\(\s*(\w+)/g, label: 'Digest MD5/SHA1 new+update' },
40
+ ],
41
+ },
42
+ csharp: {
43
+ ext: /\.cs$/i,
44
+ rules: [
45
+ // MD5.Create()…ComputeHash(<var>) / MD5.HashData(<var>) — capture hashed var.
46
+ { re: /\b(?:MD5|SHA1)\s*\.\s*(?:Create\s*\(\s*\)\s*\.\s*ComputeHash|HashData)\s*\([\s\S]{0,120}?(\w+)\s*\)/g, label: 'MD5/SHA1 ComputeHash/HashData' },
47
+ { re: /\bnew\s+(?:MD5|SHA1)(?:CryptoServiceProvider|Managed)?\s*\(/g, label: 'MD5/SHA1 provider' },
48
+ ],
49
+ },
50
+ kotlin: {
51
+ ext: /\.kt$/i,
52
+ rules: [
53
+ { re: /\bMessageDigest\.getInstance\s*\(\s*"(?:MD5|SHA-?1)"\s*\)[\s\S]{0,80}?\.digest\s*\(\s*(\w+)/g, label: 'MessageDigest MD5/SHA-1' },
54
+ ],
55
+ },
56
+ php: {
57
+ ext: /\.(?:php|phtml)$/i,
58
+ rules: [
59
+ { re: /\b(?:md5|sha1)\s*\(\s*\$?(\w+)/g, label: 'md5()/sha1()' },
60
+ { re: /\bhash\s*\(\s*['"](?:md5|sha1)['"]\s*,\s*\$?(\w+)/g, label: 'hash("md5"/"sha1", …)' },
61
+ ],
62
+ },
35
63
  };
36
64
 
37
65
  export function scanWeakPasswordHash(fp, raw) {
@@ -9,8 +9,18 @@
9
9
  // Go: rand.Intn(), rand.Int(), rand.Float64(), rand.Int31(), rand.Int63()
10
10
  // Ruby: rand(), Random.rand, Random.new.rand
11
11
  // PHP: rand(), mt_rand(), array_rand(), shuffle()
12
+ // Java: new Random(), Math.random(), ThreadLocalRandom.current().nextInt()
13
+ // Kotlin: Random.next*, kotlin.random.Random, java.util.Random
14
+ // C#: new Random()
12
15
 
13
16
  const SECURITY_CONTEXT = /\b(token|session|nonce|key|secret|password|otp|csrf|salt|code|pin|auth|reset|verify|captcha|challenge|ticket)\b/i;
17
+ // camelCase carrier: a security term as the suffix of an identifier
18
+ // (`newToken`, `genSecret`, `sessionId`) — `\bword\b` misses these. Case
19
+ // sensitive so it only fires on the CamelCase boundary, not generic prose.
20
+ const SECURITY_CONTEXT_CAMEL = /[a-z](?:Token|Secret|Nonce|Salt|Otp|Password|Passwd|ApiKey|SessionId|Csrf|Cookie|Jwt|Nonce)\b/;
21
+ // snake_case carrier: same idea for `session_token`, `reset_token`,
22
+ // `api_secret` — `\btoken\b` misses these because `_` is a word char.
23
+ const SECURITY_CONTEXT_SNAKE = /_(?:token|secret|nonce|salt|otp|password|passwd|key|session|csrf|seed|cookie|jwt)\b/i;
14
24
 
15
25
  function _line(raw, idx) {
16
26
  return raw.slice(0, idx).split('\n').length;
@@ -47,6 +57,28 @@ const LANG_PATTERNS = {
47
57
  { re: /\b(?:rand|mt_rand|array_rand)\s*\(/g, label: 'rand() / mt_rand()' },
48
58
  ],
49
59
  },
60
+ java: {
61
+ ext: /\.java$/i,
62
+ patterns: [
63
+ { re: /\bnew\s+Random\s*\(/g, label: 'java.util.Random' },
64
+ { re: /\bMath\.random\s*\(\s*\)/g, label: 'Math.random()' },
65
+ { re: /\bThreadLocalRandom\.current\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'ThreadLocalRandom (non-crypto)' },
66
+ ],
67
+ },
68
+ kotlin: {
69
+ ext: /\.kt$/i,
70
+ patterns: [
71
+ { re: /\bRandom\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'Random() (non-crypto)' },
72
+ { re: /\bRandom\s*\.\s*(?:next\w+|Default)\b/g, label: 'kotlin.random.Random' },
73
+ { re: /\bnew\s+(?:java\.util\.)?Random\s*\(/g, label: 'java.util.Random' },
74
+ ],
75
+ },
76
+ csharp: {
77
+ ext: /\.cs$/i,
78
+ patterns: [
79
+ { re: /\bnew\s+Random\s*\(/g, label: 'System.Random' },
80
+ ],
81
+ },
50
82
  };
51
83
 
52
84
  export function scanWeakRandomness(fp, raw) {
@@ -67,10 +99,16 @@ export function scanWeakRandomness(fp, raw) {
67
99
  const lineStart = raw.lastIndexOf('\n', m.index) + 1;
68
100
  const lineEnd = raw.indexOf('\n', m.index);
69
101
  const lineText = raw.slice(lineStart, lineEnd > 0 ? lineEnd : raw.length);
70
- if (!SECURITY_CONTEXT.test(lineText)) {
71
- const prevLineStart = raw.lastIndexOf('\n', lineStart - 2) + 1;
72
- const prevLine = raw.slice(prevLineStart, lineStart - 1);
73
- if (!SECURITY_CONTEXT.test(prevLine)) continue;
102
+ if (!SECURITY_CONTEXT.test(lineText) && !SECURITY_CONTEXT_CAMEL.test(lineText) && !SECURITY_CONTEXT_SNAKE.test(lineText)) {
103
+ // Widen the lookback to the enclosing block: the security keyword is
104
+ // often on the function signature a few lines up (`function newToken()
105
+ // { … Math.random() … }`), not the line immediately before the call.
106
+ let winStart = lineStart;
107
+ for (let k = 0; k < 6 && winStart > 0; k++) {
108
+ winStart = raw.lastIndexOf('\n', winStart - 2) + 1;
109
+ }
110
+ const window = raw.slice(winStart, lineStart - 1);
111
+ if (!SECURITY_CONTEXT.test(window) && !SECURITY_CONTEXT_CAMEL.test(window) && !SECURITY_CONTEXT_SNAKE.test(window)) continue;
74
112
  }
75
113
  findings.push({
76
114
  id: `weak-rng:${fp}:${line}`,
@@ -79,7 +117,10 @@ export function scanWeakRandomness(fp, raw) {
79
117
  vuln: `Insecure Randomness — ${label} used for security-sensitive value`,
80
118
  severity: 'high',
81
119
  family: 'weak-rng',
82
- cwe: 'CWE-330',
120
+ // CWE-338 (cryptographically weak PRNG) — more specific than the
121
+ // CWE-330 parent; every pattern here (Math.random / random.* / rand /
122
+ // math.rand) is a non-CSPRNG used in a security context.
123
+ cwe: 'CWE-338',
83
124
  parser: 'WEAK-RNG',
84
125
  confidence: 0.80,
85
126
  description: `${label} is not cryptographically secure. An attacker can predict the output and forge tokens, bypass OTP, or guess session identifiers.`,
@@ -0,0 +1,149 @@
1
+ // Wrong-context output encoding (roadmap #1, practical slice) — CWE-79.
2
+ //
3
+ // Context-aware sanitization is the single biggest XSS precision lever, but
4
+ // the full model (context-tagged taint through the lattice) is a core-engine
5
+ // change. This detector ships the most impactful, UNAMBIGUOUS case as a
6
+ // standalone pattern rule:
7
+ //
8
+ // An HTML-ENTITY encoder (escapeHtml / htmlspecialchars / he.encode /
9
+ // lodash.escape …) applied to a value that is then used as a URL
10
+ // (`.href` / `.src` / `location` / a JSX `href=`/`src=` attribute).
11
+ //
12
+ // HTML-entity encoding is the right tool for HTML *body/attribute text*. It
13
+ // does NOT neutralize a dangerous URL SCHEME — `javascript:alert(1)` and
14
+ // `data:text/html,…` survive entity-encoding unchanged, so the value is still
15
+ // XSS-exploitable in a URL context. This is a textbook wrong-context bug and a
16
+ // false sense of safety (the code looks sanitized).
17
+ //
18
+ // Deliberately high precision: only HTML-entity encoders (not generic names
19
+ // like bare `escape`, not `encodeURIComponent`, which is a different — non-XSS
20
+ // — mistake in URLs), and suppressed when a scheme/allow-list check is nearby.
21
+
22
+ import { blankComments } from './_comment-strip.js';
23
+
24
+ // Distinctive HTML-entity encoder callees. Intentionally excludes bare
25
+ // `escape` (too generic) and `encodeURIComponent` (URL-encoding, not XSS).
26
+ const HTML_ENCODER = "(?:escapeHtml|escapeHTML|htmlspecialchars|htmlentities|he\\.encode|he\\.escape|_\\.escape|lodash\\.escape)";
27
+
28
+ const PATTERNS = [
29
+ // Assignment to a URL-bearing property: el.href = …escapeHtml(url)…
30
+ new RegExp("\\b(?:location(?:\\.href)?|[\\w$.]+\\.(?:href|src))\\s*=\\s*([^;\\n]*?\\b" + HTML_ENCODER + "\\s*\\([^;\\n]*)", "g"),
31
+ // JSX / template attribute: href={ escapeHtml(url) } / src="...${he.encode(u)}..."
32
+ new RegExp("\\b(?:href|src)\\s*=\\s*[{\"'`]?[^\"'`>;\\n]*?\\b" + HTML_ENCODER + "\\s*\\(", "g"),
33
+ ];
34
+
35
+ // A nearby URL-scheme allow-list / validation makes the HTML-encoder a
36
+ // belt-and-suspenders choice rather than the sole (wrong) defense → suppress.
37
+ const SCHEME_GUARD = [
38
+ /\bstartsWith\s*\(\s*['"]https?:/i,
39
+ /\^https\?:/i,
40
+ /\bnew\s+URL\s*\(/,
41
+ /\ballow(?:ed|list)?\s*(?:schemes?|protocols?|hosts?)/i,
42
+ /\bprotocol\s*===?\s*['"]https?:/i,
43
+ /\bsanitizeUrl\b|\bisSafeUrl\b|\bvalidateUrl\b/i,
44
+ ];
45
+
46
+ function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
47
+
48
+ function isJsLike(fp) { return /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp); }
49
+ function isPhp(fp) { return /\.php$/i.test(fp); }
50
+
51
+ // R5 (PRD §5) — context-aware sanitizer ADEQUACY. The model: a sanitizer
52
+ // neutralizes a specific context; using it before a sink of a DIFFERENT context
53
+ // is a no-op that looks safe. The highest-signal mismatches beyond the URL case
54
+ // above: an HTML-entity encoder applied to a value that then reaches a SHELL or
55
+ // SQL sink — HTML-escaping does nothing for command/SQL injection.
56
+ const _SHELL_SINK = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|system|popen|shell_exec|child_process\.\w+)\s*\(/;
57
+ const _SQL_SINK = /\.(?:query|execute|raw|exec)\s*\(|\bdb\.raw\s*\(|sequelize\.query\s*\(/i;
58
+ const _HTML_ENC_CALL = new RegExp(HTML_ENCODER + "\\s*\\(", "g");
59
+
60
+ export function scanSanitizerContextMismatch(fp, raw) {
61
+ if (!raw || raw.length > 500_000) return [];
62
+ if (!isJsLike(fp) && !isPhp(fp) && !/\.py$/i.test(fp)) return [];
63
+ if (!/escapeHtml|escapeHTML|htmlspecialchars|htmlentities|he\.encode|he\.escape|_\.escape|lodash\.escape/i.test(raw)) return [];
64
+ if (!_SHELL_SINK.test(raw) && !_SQL_SINK.test(raw)) return [];
65
+ const code = blankComments(raw);
66
+ const lines = code.split('\n');
67
+ const findings = [];
68
+ const seen = new Set();
69
+ // Track vars assigned directly from an HTML encoder (one hop).
70
+ const htmlVars = new Set();
71
+ const VAR_ASSIGN = new RegExp("(?:\\b(?:const|let|var)\\s+)?([A-Za-z_$][\\w$]*)\\s*=\\s*" + HTML_ENCODER + "\\s*\\(", "g");
72
+ let m;
73
+ while ((m = VAR_ASSIGN.exec(code))) htmlVars.add(m[1]);
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i];
76
+ const isShell = _SHELL_SINK.test(line);
77
+ const isSql = !isShell && _SQL_SINK.test(line);
78
+ if (!isShell && !isSql) continue;
79
+ const argRegion = line.slice(line.search(isShell ? _SHELL_SINK : _SQL_SINK));
80
+ _HTML_ENC_CALL.lastIndex = 0;
81
+ const inline = _HTML_ENC_CALL.test(argRegion);
82
+ const viaVar = !inline && [...htmlVars].some((v) => new RegExp(`\\b${v}\\b`).test(argRegion));
83
+ if (!inline && !viaVar) continue;
84
+ if (seen.has(i + 1)) continue;
85
+ seen.add(i + 1);
86
+ const ctx = isShell ? { cwe: 'CWE-78', kind: 'command', vuln: 'Command Injection' }
87
+ : { cwe: 'CWE-89', kind: 'SQL', vuln: 'SQL Injection' };
88
+ findings.push({
89
+ id: `wrong-context-sanitizer:sink:${fp}:${i + 1}`,
90
+ severity: 'high',
91
+ file: fp,
92
+ line: i + 1,
93
+ vuln: `Wrong-context sanitizer (HTML-encoded value used in a ${ctx.kind} sink)`,
94
+ cwe: ctx.cwe,
95
+ family: ctx.kind === 'command' ? 'command-injection' : 'sql-injection',
96
+ parser: 'SAST',
97
+ description: `An HTML-entity encoder was applied to a value that then flows into a ${ctx.kind} sink. HTML escaping neutralizes HTML markup, NOT ${ctx.kind} metacharacters — the value is still ${ctx.vuln}-exploitable while appearing sanitized.`,
98
+ remediation: ctx.kind === 'command'
99
+ ? 'Use an argv-form API (execFile/spawn with an args array), never a shell string; HTML escaping is irrelevant here.'
100
+ : 'Use parameterized queries / prepared statements; HTML escaping does not prevent SQL injection.',
101
+ });
102
+ }
103
+ return findings;
104
+ }
105
+
106
+ export function scanWrongContextSanitizer(fp, raw) {
107
+ if (!raw || raw.length > 500_000) return [];
108
+ if (!isJsLike(fp) && !isPhp(fp)) return [];
109
+ // Cheap pre-filter: must contain both an HTML encoder and a URL sink token.
110
+ if (!/escapeHtml|htmlspecialchars|htmlentities|he\.|_\.escape|lodash\.escape/i.test(raw)) return [];
111
+ if (!/\b(?:href|src)\b|location/i.test(raw)) return [];
112
+
113
+ const code = blankComments(raw, isPhp(fp) ? undefined : undefined);
114
+ const lines = code.split('\n');
115
+ const findings = [];
116
+ const seen = new Set();
117
+
118
+ for (const re of PATTERNS) {
119
+ re.lastIndex = 0;
120
+ let m;
121
+ while ((m = re.exec(code))) {
122
+ const line = lineOf(code, m.index);
123
+ if (seen.has(line)) continue;
124
+ // Suppress when a scheme guard appears within 5 lines above.
125
+ const lo = Math.max(0, line - 6);
126
+ const near = lines.slice(lo, line).join('\n');
127
+ if (SCHEME_GUARD.some(g => g.test(near))) continue;
128
+ seen.add(line);
129
+ findings.push({
130
+ id: `wrong-context-sanitizer:${fp}:${line}`,
131
+ severity: 'medium',
132
+ file: fp,
133
+ line,
134
+ vuln: 'Wrong-context output encoding (HTML-encoded value used as a URL)',
135
+ cwe: 'CWE-79',
136
+ family: 'xss',
137
+ parser: 'SAST',
138
+ description:
139
+ 'An HTML-entity encoder is applied to a value used in a URL context (href/src/location). ' +
140
+ 'HTML-entity encoding does not neutralize dangerous URL schemes such as `javascript:` or ' +
141
+ '`data:`, so the value remains XSS-exploitable — while looking sanitized.',
142
+ remediation:
143
+ 'Validate the URL scheme against an allow-list (e.g. only http/https) or build the URL with ' +
144
+ 'the URL API; reserve HTML-entity encoding for HTML body/attribute text, not URLs.',
145
+ });
146
+ }
147
+ }
148
+ return findings;
149
+ }
@@ -3,14 +3,32 @@ import { blankComments } from './_comment-strip.js';
3
3
  //
4
4
  // Same shape as LDAP injection — string concatenation into a query language
5
5
  // that has its own operators. We catch concatenation patterns into:
6
- // - javax.xml.xpath / org.jaxen / org.dom4j (Java)
6
+ // - javax.xml.xpath / org.jaxen / org.dom4j (Java, Kotlin)
7
7
  // - lxml.etree / xml.etree (Python)
8
8
  // - xpath npm pkg (Node)
9
+ // - DOMXPath::query / ::evaluate (PHP)
10
+ // - XPathNavigator.Select / SelectNodes (C#)
11
+ // - Nokogiri xpath / REXML::XPath (Ruby)
12
+ // - xmlpath / htmlquery / antchfx xpath (Go)
13
+
14
+ // A string literal whose body may contain the OTHER quote char — an XPath
15
+ // expression like `"//user[@name='" + x` embeds a single quote, which a naive
16
+ // `[^"']*` body would stop at. Match each quote style with its own body.
17
+ const STR = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`;
9
18
 
10
19
  const PATTERNS = {
11
- java: /\.\s*(?:compile|evaluate)\s*\(\s*"[^"]*"\s*\+\s*\w+/g,
12
- py: /\.\s*(?:xpath|find|findall)\s*\(\s*["'][^"']*["']\s*[%+]\s*\w+|\.\s*xpath\s*\(\s*f["']/g,
13
- js: /\b(?:xpath|select)\s*\(\s*[`"][^`"]*[`"]\s*\+\s*\w+|\bxpath\.select\s*\(\s*`[^`]*\$\{/g,
20
+ java: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+`, 'g'),
21
+ kt: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+|\.\s*(?:compile|evaluate)\s*\(\s*"[^"\n]*\$\{`, 'g'),
22
+ py: new RegExp(String.raw`\.\s*(?:xpath|find|findall)\s*\(\s*${STR}\s*[%+]\s*\w+|\.\s*xpath\s*\(\s*[fF]["']`, 'g'),
23
+ js: new RegExp(String.raw`\b(?:xpath|select)\s*\(\s*(?:` + '`' + String.raw`[^` + '`' + String.raw`]*` + '`' + String.raw`|${STR})\s*\+\s*\w+|\bxpath\.select\s*\(\s*` + '`' + String.raw`[^` + '`' + String.raw`]*\$\{`, 'g'),
24
+ // PHP DOMXPath: $xpath->query("…" . $u) / ->evaluate("…" . $u)
25
+ php: new RegExp(String.raw`->\s*(?:query|evaluate)\s*\(\s*${STR}\s*\.\s*\$\w+`, 'g'),
26
+ // C#: nav.Select / doc.SelectNodes / SelectSingleNode / XPathSelectElements with concat
27
+ cs: new RegExp(String.raw`\.\s*(?:Select|SelectNodes|SelectSingleNode|XPathSelectElement|XPathSelectElements|Compile)\s*\(\s*\$?${STR}\s*\+\s*\w+|\.\s*(?:Select|SelectNodes|SelectSingleNode)\s*\(\s*\$"[^"\n]*\{`, 'g'),
28
+ // Ruby Nokogiri / REXML: .xpath("…#{u}") / .at_xpath / XPath.match with #{} or concat
29
+ rb: new RegExp(String.raw`\.\s*(?:xpath|at_xpath|search)\s*\(\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)|\.\s*(?:xpath|at_xpath)\s*\(\s*${STR}\s*\+\s*\w+|XPath\s*\.\s*(?:match|first|each)\s*\([^,]+,\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)`, 'g'),
30
+ // Go xmlpath/htmlquery/antchfx: xmlpath.Compile("…"+u) / htmlquery.Find(doc, "…"+u)
31
+ go: new RegExp(String.raw`\b(?:xmlpath\.Compile|htmlquery\.(?:Find|FindOne|QueryAll|Query)|xpath\.Compile|navigator\.Compile)\s*\([^)]*${STR}\s*\+\s*\w+`, 'g'),
14
32
  };
15
33
 
16
34
  function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
@@ -20,11 +38,17 @@ export function scanXPathInjection(fp, raw) {
20
38
  let lang;
21
39
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) lang = 'js';
22
40
  else if (/\.java$/i.test(fp)) lang = 'java';
41
+ else if (/\.kt$/i.test(fp)) lang = 'kt';
23
42
  else if (/\.py$/i.test(fp)) lang = 'py';
43
+ else if (/\.(?:php|phtml)$/i.test(fp)) lang = 'php';
44
+ else if (/\.cs$/i.test(fp)) lang = 'cs';
45
+ else if (/\.rb$/i.test(fp)) lang = 'rb';
46
+ else if (/\.go$/i.test(fp)) lang = 'go';
24
47
  else return [];
25
48
 
26
- const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
27
- if (!/\bxpath|XPath|\.xpath\(/i.test(code)) return [];
49
+ const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
50
+ // Relevance gate: an XPath-ish API or an XPath expression literal must appear.
51
+ if (!/\bxpath|XPath|\.xpath\(|DOMXPath|SelectNodes|SelectSingleNode|XPathNavigator|xmlpath|htmlquery|REXML|\/\/\w/i.test(code)) return [];
28
52
  const re = new RegExp(PATTERNS[lang].source, PATTERNS[lang].flags);
29
53
  const findings = [];
30
54
  const seen = new Set();
@@ -0,0 +1,116 @@
1
+ // Cross-language reflected-XSS structural detector — PRD Tier 1 (recall).
2
+ //
3
+ // JS/Python reflected XSS is handled by the flow engine + framework structural
4
+ // detectors; the second-tier languages had no XSS coverage. This module adds a
5
+ // taint-independent structural rule per language: user input written into an
6
+ // HTML response via concatenation / interpolation, without an output encoder.
7
+ //
8
+ // Precision: each language carries its own escaper exclusion (htmlspecialchars,
9
+ // HtmlEncode, html_escape, template.HTMLEscapeString, …). When an escaper is
10
+ // applied on the sink line the finding is suppressed — the parameterized/encoded
11
+ // form must NOT match. HTML context is required (an HTML tag literal, an HTML
12
+ // content-type, or a raw-superglobal echo) so plain-text responses don't fire.
13
+
14
+ import { blankComments } from './_comment-strip.js';
15
+
16
+ const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
17
+
18
+ const REMEDIATION =
19
+ 'HTML-encode user input before writing it into a response: htmlspecialchars (PHP), ' +
20
+ 'template/html.EscapeString (Go), ERB::Util.html_escape / avoid .html_safe (Ruby), ' +
21
+ 'HttpUtility.HtmlEncode (C#), or an auto-escaping template engine. Never concatenate ' +
22
+ 'request data straight into an HTML response.';
23
+
24
+ // Per-language: { ext, sinks:[RegExp], escaper:RegExp }. A line matches when a
25
+ // sink RegExp matches and the escaper RegExp does NOT.
26
+ const LANGS = {
27
+ go: {
28
+ ext: /\.go$/i,
29
+ // fmt.Fprint*/io.WriteString to the ResponseWriter, or w.Write([]byte(...)),
30
+ // building an HTML string ("<…") by concatenation.
31
+ sinks: [
32
+ /\b(?:fmt\.Fprintf?|fmt\.Fprintln|io\.WriteString)\s*\(\s*w\b[^)\n]*"<[^"\n]*"\s*\+/,
33
+ /\bw\.Write\s*\(\s*\[\]byte\s*\(\s*"<[^"\n]*"\s*\+/,
34
+ ],
35
+ escaper: /\b(?:template\.HTMLEscapeString|html\.EscapeString|template\.HTMLEscaper)\s*\(/,
36
+ },
37
+ php: {
38
+ ext: /\.(?:php|phtml)$/i,
39
+ // echo/print of a request superglobal (directly or concatenated).
40
+ sinks: [
41
+ /\b(?:echo|print)\b[^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
42
+ /\bprintf\s*\([^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
43
+ ],
44
+ escaper: /\b(?:htmlspecialchars|htmlentities|strip_tags|filter_var|urlencode|rawurlencode|json_encode|intval|floatval|htmlspecialchars_decode)\s*\(/,
45
+ },
46
+ ruby: {
47
+ ext: /\.rb$/i,
48
+ // render inline:/html: with #{} interpolation, raw(params…), params….html_safe
49
+ sinks: [
50
+ /\brender\s+(?:inline|html):\s*["'][^"'\n]*#\{/,
51
+ /\braw\s*\(\s*(?:params|request|@\w+\.params)\b/,
52
+ /\b(?:params|request)\b[^\n]*\.\s*html_safe\b/,
53
+ ],
54
+ escaper: /\b(?:ERB::Util\.html_escape|CGI\.escapeHTML|h\s*\(|sanitize\s*\()/,
55
+ },
56
+ csharp: {
57
+ ext: /\.cs$/i,
58
+ // Response.Write of Request data, or an HTML string concatenation.
59
+ sinks: [
60
+ /\bResponse\.Write\s*\([^)\n]*\bRequest\b/,
61
+ /\bResponse\.Write\s*\(\s*"<[^"\n]*"\s*\+/,
62
+ ],
63
+ escaper: /\b(?:HttpUtility\.HtmlEncode|HtmlEncoder\.|Server\.HtmlEncode|WebUtility\.HtmlEncode|AntiXss\.)/,
64
+ },
65
+ kotlin: {
66
+ ext: /\.kt$/i,
67
+ // Ktor respondText building HTML by interpolation/concat.
68
+ sinks: [
69
+ /\brespondText\s*\(\s*"<[^"\n]*\$/,
70
+ /\brespondText\s*\(\s*"<[^"\n]*"\s*\+/,
71
+ /\brespondText\s*\(\s*"[^"\n]*\$[^"\n]*"\s*,\s*ContentType\.Text\.Html/,
72
+ ],
73
+ escaper: /\b(?:htmlEscape|escapeHtml|HtmlUtils\.htmlEscape|encodeHTML)\s*\(/,
74
+ },
75
+ java: {
76
+ ext: /\.java$/i,
77
+ // Servlet: response.getWriter().write/print/println(...) or a PrintWriter
78
+ // `out`, building an HTML string ("<…") by concatenation. The concat (`"<…"
79
+ // +`) is the reflected-XSS shape — a static literal has no `+`.
80
+ sinks: [
81
+ /\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+/,
82
+ /\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+/,
83
+ ],
84
+ escaper: /\b(?:encodeForHTML|forHtml|forHtmlContent|htmlEscape|escapeHtml4?|StringEscapeUtils|HtmlUtils\.htmlEscape|Encode\.forHtml|ESAPI|OWASP)\b/,
85
+ },
86
+ };
87
+
88
+ export function scanXssReflectedMultilang(fp, raw) {
89
+ if (!raw || raw.length > 500_000) return [];
90
+ let lang = null;
91
+ for (const v of Object.values(LANGS)) { if (v.ext.test(fp)) { lang = v; break; } }
92
+ if (!lang) return [];
93
+
94
+ const code = blankComments(raw, /\.(?:rb|php)$/i.test(fp) ? 'py' : undefined);
95
+ const lines = code.split('\n');
96
+ const findings = [];
97
+ const seen = new Set();
98
+
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ if (lang.escaper.test(line)) continue;
102
+ if (!lang.sinks.some((re) => re.test(line))) continue;
103
+ const ln = i + 1;
104
+ const id = `xss-reflected:${fp}:${ln}`;
105
+ if (seen.has(id)) continue;
106
+ seen.add(id);
107
+ findings.push({
108
+ id, file: fp, line: ln,
109
+ vuln: 'Reflected XSS — user input written into an HTML response without output encoding',
110
+ severity: 'high', cwe: 'CWE-79', family: 'xss', parser: 'XSS-ML', confidence: 0.62,
111
+ snippet: (raw.split('\n')[ln - 1] || '').trim().slice(0, 200),
112
+ remediation: REMEDIATION,
113
+ });
114
+ }
115
+ return findings;
116
+ }
package/src/sast/xxe.js CHANGED
@@ -64,6 +64,28 @@ const PYTHON_DEFUSED_RE = /(?:^|\n)\s*(?:from\s+defusedxml\b|import\s+defusedxml
64
64
  // upstream-recommended safe shape.
65
65
  const PYTHON_LXML_SAFE_RE = /XMLParser\s*\([^)]*\bresolve_entities\s*=\s*False\b[^)]*\)/;
66
66
 
67
+ // ── PHP / Go / Ruby ─────────────────────────────────────────────────────────
68
+ // Each of these XML stacks is XXE-SAFE BY DEFAULT in current versions; the
69
+ // vulnerability appears only when the caller explicitly opts INTO external-
70
+ // entity / DTD-loading behavior. So we flag the opt-in flags, not the parse
71
+ // call itself — the plain parse is correctly clean.
72
+ //
73
+ // PHP: loadXML / simplexml_load_string|file with LIBXML_NOENT, LIBXML_DTDLOAD,
74
+ // or LIBXML_DTDVALID. (PHP >= 8.0 disables entity substitution by
75
+ // default; these flags re-enable it.)
76
+ const PHP_XXE_RE =
77
+ /\b(?:loadXML|simplexml_load_string|simplexml_load_file)\s*\([^;]*\bLIBXML_(?:NOENT|DTDLOAD|DTDVALID)\b/g;
78
+ // Go: encoding/xml Decoder made unsafe by `Strict = false` or a custom
79
+ // `Entity` map that defines expansions. Gated on an xml-decoder hint.
80
+ const GO_XXE_RE =
81
+ /\b\w+\s*\.\s*(?:Strict\s*=\s*false|Entity\s*=\s*(?:map\[string\]string|xml\.HTMLEntity))/g;
82
+ const GO_XML_HINT_RE = /\b(?:encoding\/xml|xml\.NewDecoder|xml\.Decoder)\b/;
83
+ // Ruby: Nokogiri parse whose options enable NOENT / DTDLOAD / replace_entities.
84
+ // Default Nokogiri::XML(xml) is safe. Gated on a Nokogiri/LibXML hint.
85
+ const RUBY_XXE_RE =
86
+ /\.\s*(?:noent|dtdload|dtdvalid|replace_entities)\b/g;
87
+ const RUBY_XML_HINT_RE = /\bNokogiri\s*::\s*XML\b|\bLibXML\b|\bXML::Parser\b/;
88
+
67
89
  import { blankComments } from './_comment-strip.js';
68
90
 
69
91
  function _stripLineComment(s, lang) {
@@ -136,5 +158,50 @@ export function scanXXE(fp, raw) {
136
158
  return findings;
137
159
  }
138
160
 
161
+ // ── PHP / Go / Ruby: flag the explicit opt-in to external entities ──────────
162
+ const _emitOptIn = (code, re, gate, mkVuln, mkRem) => {
163
+ if (gate && !gate.test(code)) return;
164
+ const r = new RegExp(re.source, re.flags);
165
+ let m;
166
+ const seen = new Set();
167
+ while ((m = r.exec(code))) {
168
+ const line = _lineOf(raw, m.index);
169
+ if (seen.has(line)) continue;
170
+ seen.add(line);
171
+ findings.push({
172
+ id: `xxe:${fp}:${line}`,
173
+ file: fp, line,
174
+ vuln: mkVuln(), severity: 'high', cwe: 'CWE-611',
175
+ stride: 'Information Disclosure',
176
+ snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
177
+ remediation: mkRem(), confidence: 0.8, parser: 'XXE',
178
+ });
179
+ }
180
+ };
181
+
182
+ if (/\.(?:php|phtml)$/i.test(fp)) {
183
+ const code = blankComments(raw, 'py');
184
+ _emitOptIn(code, PHP_XXE_RE, null,
185
+ () => 'XXE: XML parsed with LIBXML_NOENT / LIBXML_DTDLOAD (external entities enabled)',
186
+ () => 'Drop the LIBXML_NOENT / LIBXML_DTDLOAD flags — PHP >= 8.0 disables entity substitution by default, so plain loadXML($xml) / simplexml_load_string($xml) is safe. If you must accept DTDs, set libxml_set_external_entity_loader to reject network/file access.');
187
+ return findings;
188
+ }
189
+
190
+ if (/\.go$/i.test(fp)) {
191
+ const code = blankComments(raw);
192
+ _emitOptIn(code, GO_XXE_RE, GO_XML_HINT_RE,
193
+ () => 'XXE: encoding/xml Decoder configured with Strict=false or a custom Entity map',
194
+ () => 'Go\'s encoding/xml ignores external entities by default — do not set Strict=false or populate Decoder.Entity with untrusted input. Leave the decoder at its defaults.');
195
+ return findings;
196
+ }
197
+
198
+ if (/\.rb$/i.test(fp)) {
199
+ const code = blankComments(raw, 'py');
200
+ _emitOptIn(code, RUBY_XXE_RE, RUBY_XML_HINT_RE,
201
+ () => 'XXE: Nokogiri parse options enable external entities (NOENT / DTDLOAD / replace_entities)',
202
+ () => 'Remove noent/dtdload/replace_entities — Nokogiri::XML(xml) is safe by default (entities are not expanded, DTDs are not loaded). Never enable these on untrusted XML.');
203
+ return findings;
204
+ }
205
+
139
206
  return [];
140
207
  }
@@ -0,0 +1,74 @@
1
+ // R8 (PRD §5) — container-image OS-package inventory.
2
+ //
3
+ // container.js reads the Dockerfile (FROM + apt/apk install lines). This reads
4
+ // the ACTUAL installed-package databases from an extracted image filesystem
5
+ // (or the host), catching packages baked into the base image that the
6
+ // Dockerfile never names. Components flow into the existing OSV pipeline + SBOM.
7
+ //
8
+ // Pure parsers (unit-tested) + a path-matching extractor. No daemon; reads the
9
+ // package-DB files that appear when an image layer/tarball is unpacked.
10
+
11
+ // Debian/Ubuntu: /var/lib/dpkg/status — RFC822-style blocks separated by blank
12
+ // lines. Only "install ok installed" entries count (not removed/config-files).
13
+ export function parseDpkgStatus(text) {
14
+ const out = [];
15
+ for (const block of String(text || '').split(/\n\s*\n/)) {
16
+ const name = (block.match(/^Package:\s*(.+)$/m) || [])[1];
17
+ const version = (block.match(/^Version:\s*(.+)$/m) || [])[1];
18
+ const status = (block.match(/^Status:\s*(.+)$/m) || [])[1] || '';
19
+ if (!name || !version) continue;
20
+ if (!/\binstalled\b/.test(status)) continue;
21
+ out.push({ name: name.trim(), version: version.trim(), ecosystem: 'Debian' });
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Alpine: /lib/apk/db/installed — blocks separated by blank lines; P:=name V:=version.
27
+ export function parseApkInstalled(text) {
28
+ const out = [];
29
+ for (const block of String(text || '').split(/\n\s*\n/)) {
30
+ const name = (block.match(/^P:(.+)$/m) || [])[1];
31
+ const version = (block.match(/^V:(.+)$/m) || [])[1];
32
+ if (!name || !version) continue;
33
+ out.push({ name: name.trim(), version: version.trim(), ecosystem: 'Alpine' });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ const _PURL = { Debian: (n, v) => `pkg:deb/debian/${n}@${v}`, Alpine: (n, v) => `pkg:apk/alpine/${n}@${v}` };
39
+
40
+ // File-path tells for an unpacked image. Match on path suffix so it works
41
+ // whether the scan root is the rootfs or a subdirectory of it.
42
+ function _dbKind(path) {
43
+ const p = path.replace(/\\/g, '/');
44
+ if (/(?:^|\/)var\/lib\/dpkg\/status$/.test(p)) return 'dpkg';
45
+ if (/(?:^|\/)lib\/apk\/db\/installed$/.test(p)) return 'apk';
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Extract OS-package components from any image package-DBs present in the scanned
51
+ * files. Returns components in the engine's component shape so they ride the
52
+ * existing OSV/SBOM pipeline. De-dups by ecosystem:name:version.
53
+ */
54
+ export function extractImagePackages(fileContents) {
55
+ const comps = [];
56
+ const seen = new Set();
57
+ for (const [file, content] of Object.entries(fileContents || {})) {
58
+ const kind = _dbKind(file);
59
+ if (!kind) continue;
60
+ const pkgs = kind === 'dpkg' ? parseDpkgStatus(content) : parseApkInstalled(content);
61
+ for (const p of pkgs) {
62
+ const key = `${p.ecosystem}:${p.name}:${p.version}`;
63
+ if (seen.has(key)) continue;
64
+ seen.add(key);
65
+ comps.push({
66
+ name: p.name, version: p.version, group: '', ecosystem: p.ecosystem,
67
+ purl: (_PURL[p.ecosystem] || (() => null))(p.name, p.version),
68
+ filePath: file, isUnpinned: false, isTransitive: false, isOsPackage: true,
69
+ scope: 'required', reachable: true,
70
+ });
71
+ }
72
+ }
73
+ return comps;
74
+ }