@clear-capabilities/agentic-security-scanner 0.118.2 → 0.119.2

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 (155) hide show
  1. package/CHANGELOG.md +274 -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 +18 -18
  12. package/dist/agentic-security.mjs.sha256 +1 -1
  13. package/package.json +13 -8
  14. package/src/dataflow/CLAUDE.md +1 -1
  15. package/src/dataflow/catalog.js +50 -11
  16. package/src/dataflow/engine.js +76 -1
  17. package/src/dataflow/implicit-flow.js +4 -2
  18. package/src/dataflow/index.js +9 -4
  19. package/src/dataflow/proof-gate.js +5 -0
  20. package/src/dataflow/summaries.js +19 -1
  21. package/src/engine.js +95 -7
  22. package/src/ir/parser-go.js +8 -0
  23. package/src/mcp/tools.js +6 -2
  24. package/src/posture/fix-history.js +24 -0
  25. package/src/posture/provenance.js +39 -0
  26. package/src/posture/sca-verdict.js +130 -0
  27. package/src/posture/secret-history.js +72 -0
  28. package/src/pr-delta.js +13 -0
  29. package/src/report/index.js +50 -0
  30. package/src/sast/agent-untrusted-flow.js +107 -0
  31. package/src/sast/api-authz.js +71 -0
  32. package/src/sast/cross-service.js +98 -0
  33. package/src/sast/event-entrypoint.js +90 -0
  34. package/src/sast/iac-terraform.js +107 -0
  35. package/src/sast/rbac-consistency.js +113 -0
  36. package/src/sast/wrong-context-sanitizer.js +55 -0
  37. package/src/sca/image-packages.js +74 -0
  38. package/src/sca/import-reachability.js +271 -0
  39. package/src/sca/install-script-analysis.js +82 -0
  40. package/bin/.agentic-security/findings.json +0 -1907
  41. package/bin/.agentic-security/last-scan.json +0 -1907
  42. package/bin/.agentic-security/last-scan.json.sig +0 -1
  43. package/bin/.agentic-security/scan-history.json +0 -166
  44. package/bin/.agentic-security/streak.json +0 -20
  45. package/src/.agentic-security/dpia.md +0 -26
  46. package/src/.agentic-security/findings.json +0 -117732
  47. package/src/.agentic-security/last-scan.json +0 -117732
  48. package/src/.agentic-security/last-scan.json.sig +0 -1
  49. package/src/.agentic-security/pqc-migration-plan.json +0 -80
  50. package/src/.agentic-security/pqc-migration-plan.md +0 -32
  51. package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +0 -6
  52. package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +0 -6
  53. package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
  54. package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +0 -6
  55. package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +0 -6
  56. package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +0 -6
  57. package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +0 -6
  58. package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
  59. package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +0 -6
  60. package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +0 -6
  61. package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +0 -6
  62. package/src/.agentic-security/scan-history.json +0 -12946
  63. package/src/.agentic-security/streak.json +0 -21
  64. package/src/.agentic-security/threat-model.json +0 -3702
  65. package/src/.agentic-security/threat-model.md +0 -118
  66. package/src/dataflow/.agentic-security/dpia.md +0 -26
  67. package/src/dataflow/.agentic-security/findings.json +0 -6086
  68. package/src/dataflow/.agentic-security/last-scan.json +0 -6086
  69. package/src/dataflow/.agentic-security/last-scan.json.sig +0 -1
  70. package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +0 -6
  71. package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
  72. package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +0 -6
  73. package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +0 -6
  74. package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +0 -6
  75. package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +0 -6
  76. package/src/dataflow/.agentic-security/scan-history.json +0 -250
  77. package/src/dataflow/.agentic-security/streak.json +0 -21
  78. package/src/dataflow/.agentic-security/threat-model.json +0 -161
  79. package/src/dataflow/.agentic-security/threat-model.md +0 -43
  80. package/src/ir/.agentic-security/dpia.md +0 -26
  81. package/src/ir/.agentic-security/exploit-bundles.json +0 -77
  82. package/src/ir/.agentic-security/findings.json +0 -4011
  83. package/src/ir/.agentic-security/last-scan.json +0 -4011
  84. package/src/ir/.agentic-security/last-scan.json.sig +0 -1
  85. package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +0 -6
  86. package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +0 -6
  87. package/src/ir/.agentic-security/scan-history.json +0 -193
  88. package/src/ir/.agentic-security/streak.json +0 -20
  89. package/src/ir/.agentic-security/threat-model.json +0 -143
  90. package/src/ir/.agentic-security/threat-model.md +0 -39
  91. package/src/mcp/.agentic-security/findings.json +0 -8632
  92. package/src/mcp/.agentic-security/last-scan.json +0 -8632
  93. package/src/mcp/.agentic-security/last-scan.json.sig +0 -1
  94. package/src/mcp/.agentic-security/scan-history.json +0 -331
  95. package/src/mcp/.agentic-security/streak.json +0 -20
  96. package/src/posture/.agentic-security/dpia.md +0 -26
  97. package/src/posture/.agentic-security/findings.json +0 -77181
  98. package/src/posture/.agentic-security/last-scan.json +0 -77181
  99. package/src/posture/.agentic-security/last-scan.json.sig +0 -1
  100. package/src/posture/.agentic-security/pqc-migration-plan.json +0 -65
  101. package/src/posture/.agentic-security/pqc-migration-plan.md +0 -30
  102. package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +0 -6
  103. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  104. package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
  105. package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +0 -6
  106. package/src/posture/.agentic-security/scan-history.json +0 -8904
  107. package/src/posture/.agentic-security/streak.json +0 -21
  108. package/src/posture/.agentic-security/threat-model.json +0 -2038
  109. package/src/posture/.agentic-security/threat-model.md +0 -73
  110. package/src/posture/compliance-frameworks/.agentic-security/findings.json +0 -80
  111. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +0 -80
  112. package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +0 -1
  113. package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +0 -90
  114. package/src/posture/compliance-frameworks/.agentic-security/streak.json +0 -22
  115. package/src/report/.agentic-security/dpia.md +0 -26
  116. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +0 -6
  117. package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +0 -6
  118. package/src/report/.agentic-security/threat-model.json +0 -7
  119. package/src/report/.agentic-security/threat-model.md +0 -22
  120. package/src/sast/.agentic-security/dpia.md +0 -26
  121. package/src/sast/.agentic-security/findings.json +0 -6154
  122. package/src/sast/.agentic-security/last-scan.json +0 -6154
  123. package/src/sast/.agentic-security/last-scan.json.sig +0 -1
  124. package/src/sast/.agentic-security/pqc-migration-plan.json +0 -65
  125. package/src/sast/.agentic-security/pqc-migration-plan.md +0 -30
  126. package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +0 -6
  127. package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +0 -6
  128. package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +0 -6
  129. package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +0 -6
  130. package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +0 -6
  131. package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +0 -6
  132. package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +0 -6
  133. package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +0 -6
  134. package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +0 -6
  135. package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +0 -6
  136. package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +0 -6
  137. package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +0 -6
  138. package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +0 -6
  139. package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +0 -6
  140. package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +0 -6
  141. package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +0 -6
  142. package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +0 -6
  143. package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +0 -6
  144. package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +0 -6
  145. package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +0 -6
  146. package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +0 -6
  147. package/src/sast/.agentic-security/scan-history.json +0 -941
  148. package/src/sast/.agentic-security/streak.json +0 -22
  149. package/src/sast/.agentic-security/threat-model.json +0 -816
  150. package/src/sast/.agentic-security/threat-model.md +0 -91
  151. package/src/sca/.agentic-security/findings.json +0 -7460
  152. package/src/sca/.agentic-security/last-scan.json +0 -7460
  153. package/src/sca/.agentic-security/last-scan.json.sig +0 -1
  154. package/src/sca/.agentic-security/scan-history.json +0 -113
  155. package/src/sca/.agentic-security/streak.json +0 -21
@@ -0,0 +1,90 @@
1
+ // R6 (PRD §5) — non-HTTP entrypoint taint. Route-rooted analysis goes blind to
2
+ // message-queue consumers, scheduled tasks, and serverless handlers — exactly
3
+ // where high-severity backend bugs live. This recognizes those entrypoints,
4
+ // treats their payload parameter as an untrusted source, and flags when it
5
+ // reaches a code/command/SQL sink without sanitization.
6
+ //
7
+ // Precision-first: gated to files that register such an entrypoint; tracks the
8
+ // handler's payload parameter (+ common member accesses) into a dangerous sink.
9
+
10
+ import { blankComments } from './_comment-strip.js';
11
+
12
+ // Entrypoint registrations → the untrusted payload parameter name they bind.
13
+ const ENTRYPOINTS = [
14
+ // JS: queue/stream consumers — .on('message', (msg)=>…) / .subscribe((msg)=>…)
15
+ { re: /\.(?:on|subscribe|consume|process)\s*\(\s*(?:['"][^'"]*['"]\s*,\s*)?(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g, lang: 'js' },
16
+ // JS serverless: exports.handler = async (event) => … / module.exports.handler = (event)
17
+ { re: /(?:exports\.\w+|module\.exports(?:\.\w+)?)\s*=\s*(?:async\s*)?\(?\s*([A-Za-z_$][\w$]*)/g, lang: 'js' },
18
+ // Python Celery / serverless: @app.task / @shared_task / def handler(event, context)
19
+ { re: /@(?:app|celery|shared)\w*\.task[^\n]*\n\s*def\s+\w+\s*\(\s*([A-Za-z_]\w*)/g, lang: 'py' },
20
+ { re: /\bdef\s+(?:handler|lambda_handler|handle|consume|on_message|process)\s*\(\s*([A-Za-z_]\w*)/g, lang: 'py' },
21
+ ];
22
+
23
+ // Context markers that indicate an event/queue/serverless file.
24
+ const CONTEXT = /(kafka|rabbit|amqp|sqs|sns|pubsub|kinesis|@app\.task|shared_task|lambda_handler|exports\.handler|celery|bull|bullmq|@SqsListener|@KafkaListener|@RabbitListener|@Scheduled|consumer|EventBridge|cloudevent)/i;
25
+
26
+ const SINKS = [
27
+ { re: /\b(?:exec|execSync|spawn|spawnSync)\s*\(/, label: 'command exec', cwe: '78', fam: 'command-injection' },
28
+ { re: /\beval\s*\(|\bnew\s+Function\s*\(/, label: 'eval', cwe: '94', fam: 'code-injection' },
29
+ { re: /\bos\.system\s*\(|\bsubprocess\.(?:run|call|Popen|check_output)\s*\(/, label: 'command exec', cwe: '78', fam: 'command-injection' },
30
+ { re: /\.(?:query|execute|raw)\s*\(\s*[`'"][^`'"]*\$?\{?\s*\+?/, label: 'SQL query (concatenated)', cwe: '89', fam: 'sql-injection' },
31
+ ];
32
+
33
+ const SANITIZER = /\b(sanitiz|validate|escape|allowlist|allow_list|whitelist|schema\.(?:parse|validate)|zod|joi|pydantic|is_safe|verify)/i;
34
+
35
+ const PAYLOAD_MEMBER = /\.(?:body|value|data|message|payload|Records|detail|content|text)\b/;
36
+
37
+ export function scanEventEntrypoints(fp, raw) {
38
+ if (typeof raw !== 'string' || !raw) return [];
39
+ if (!/\.(?:js|jsx|ts|tsx|mjs|cjs|py)$/i.test(fp)) return [];
40
+ const code = blankComments(raw);
41
+ if (!CONTEXT.test(code)) return [];
42
+ const isPy = /\.py$/i.test(fp);
43
+ const lines = code.split('\n');
44
+
45
+ // Collect payload variable names from entrypoint registrations.
46
+ const payloads = new Set();
47
+ for (const ep of ENTRYPOINTS) {
48
+ if (ep.lang === 'py' !== isPy) continue;
49
+ const re = new RegExp(ep.re.source, ep.re.flags);
50
+ let m;
51
+ while ((m = re.exec(code))) {
52
+ const name = m[1];
53
+ if (name && !['async', 'function', 'context', 'self', 'cls'].includes(name)) payloads.add(name);
54
+ }
55
+ }
56
+ if (!payloads.size) return [];
57
+
58
+ const findings = [];
59
+ const seen = new Set();
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i];
62
+ for (const sink of SINKS) {
63
+ if (!sink.re.test(line)) continue;
64
+ const arg = line.slice(line.search(sink.re));
65
+ const hit = [...payloads].some((p) => new RegExp(`\\b${p}\\b`).test(arg));
66
+ if (!hit) continue;
67
+ // Sanitizer anywhere in a small window above suppresses.
68
+ const near = lines.slice(Math.max(0, i - 6), i + 1).join('\n');
69
+ if (SANITIZER.test(near)) continue;
70
+ const key = `${i + 1}:${sink.fam}`;
71
+ if (seen.has(key)) continue;
72
+ seen.add(key);
73
+ findings.push({
74
+ id: `event-entrypoint:${fp}:${i + 1}`,
75
+ severity: 'high',
76
+ file: fp,
77
+ line: i + 1,
78
+ vuln: `Untrusted event/message payload reaches ${sink.label}`,
79
+ cwe: `CWE-${sink.cwe}`,
80
+ family: sink.fam,
81
+ parser: 'EVENT-FLOW',
82
+ description: `A non-HTTP entrypoint (queue consumer / scheduled task / serverless handler) passes its untrusted payload into ${sink.label} without validation. Message/event bodies are attacker-influenceable just like HTTP input.`,
83
+ remediation: 'Validate/parse the event payload against a schema before use, and avoid passing it to a command/code/SQL sink (parameterize, use argv-form exec).',
84
+ });
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+
90
+ export const _internals = { PAYLOAD_MEMBER };
@@ -0,0 +1,107 @@
1
+ // R18 (PRD §5) — semantic IaC (Terraform), variable-resolving.
2
+ //
3
+ // Regex IaC (engine.js scanIaC) matches literals on one line. Its blind spot is
4
+ // indirection: `cidr_blocks = [var.ingress]` where `variable "ingress"` defaults
5
+ // to "0.0.0.0/0" is wide-open, but the literal never appears on the resource
6
+ // line. This resolves `variable` defaults and `local` values, then evaluates a
7
+ // few high-signal misconfigurations on the RESOLVED value — catching what the
8
+ // literal scan misses, and reporting when the exposure arrives via a variable.
9
+ //
10
+ // Regex-based HCL (no new dep, consistent with the repo); bounded to the
11
+ // highest-signal AWS checks. Real `terraform plan` ingestion is the remainder.
12
+
13
+ const VAR_DEFAULT = /variable\s+"([^"]+)"\s*\{[^{}]*?default\s*=\s*(?:"([^"]*)"|(\[[^\]]*\]|true|false|[\w.\-]+))/g;
14
+ const LOCAL_BLOCK = /locals\s*\{([\s\S]*?)\n\}/g;
15
+
16
+ function buildResolver(text) {
17
+ const vars = new Map();
18
+ let m;
19
+ VAR_DEFAULT.lastIndex = 0;
20
+ while ((m = VAR_DEFAULT.exec(text))) vars.set(m[1], (m[2] !== undefined ? m[2] : m[3] || '').trim());
21
+ const locals = new Map();
22
+ LOCAL_BLOCK.lastIndex = 0;
23
+ while ((m = LOCAL_BLOCK.exec(text))) {
24
+ for (const line of m[1].split('\n')) {
25
+ const lm = line.match(/^\s*([A-Za-z_][\w-]*)\s*=\s*(?:"([^"]*)"|([\w.\-]+))/);
26
+ if (lm) locals.set(lm[1], (lm[2] !== undefined ? lm[2] : lm[3] || '').trim());
27
+ }
28
+ }
29
+ // Resolve a raw attribute value (possibly `var.x`, `local.y`, a list, or literal).
30
+ return function resolve(raw) {
31
+ if (raw == null) return { value: '', viaVar: false };
32
+ // Strip bracket/brace/quote noise anywhere (the attr capture can include a
33
+ // trailing `}` / `]`), so a `var.x` token matches the resolver exactly.
34
+ let v = String(raw).replace(/[[\]{}'"]/g, '').trim();
35
+ const vm = v.match(/^var\.([\w-]+)$/);
36
+ if (vm && vars.has(vm[1])) return { value: vars.get(vm[1]), viaVar: true, varName: vm[1] };
37
+ const lm = v.match(/^local\.([\w-]+)$/);
38
+ if (lm && locals.has(lm[1])) return { value: locals.get(lm[1]), viaVar: true, varName: `local.${lm[1]}` };
39
+ return { value: v, viaVar: false };
40
+ };
41
+ }
42
+
43
+ function lineOf(text, idx) { return text.slice(0, idx).split('\n').length; }
44
+
45
+ const CHECKS = [
46
+ {
47
+ id: 'open-ingress', cwe: 'CWE-284', sev: 'high',
48
+ attr: /\b(?:cidr_blocks|ipv6_cidr_blocks)\s*=\s*(\[?[^\n]+)/g,
49
+ bad: (val) => /\b0\.0\.0\.0\/0\b|::\/0\b/.test(val),
50
+ vuln: 'Terraform: security-group ingress open to the world (0.0.0.0/0)',
51
+ fix: 'Restrict cidr_blocks to known networks; never default an ingress CIDR to 0.0.0.0/0.',
52
+ },
53
+ {
54
+ id: 'public-bucket', cwe: 'CWE-732', sev: 'high',
55
+ attr: /\bacl\s*=\s*(\S+)/g,
56
+ bad: (val) => /^public-read(?:-write)?$/.test(val),
57
+ vuln: 'Terraform: S3 bucket ACL is public',
58
+ fix: 'Set acl = "private" and use bucket policies / least-privilege grants instead of a public ACL.',
59
+ },
60
+ {
61
+ id: 'public-db', cwe: 'CWE-668', sev: 'high',
62
+ attr: /\bpublicly_accessible\s*=\s*(\S+)/g,
63
+ bad: (val) => /^true$/i.test(val),
64
+ vuln: 'Terraform: database instance is publicly accessible',
65
+ fix: 'Set publicly_accessible = false; place the DB in a private subnet.',
66
+ },
67
+ ];
68
+
69
+ export function scanTerraform(fp, raw) {
70
+ if (typeof raw !== 'string' || !raw) return [];
71
+ if (!/\.tf$/i.test(fp)) return [];
72
+ const resolve = buildResolver(raw);
73
+ const findings = [];
74
+ const seen = new Set();
75
+ for (const check of CHECKS) {
76
+ const re = new RegExp(check.attr.source, check.attr.flags);
77
+ let m;
78
+ while ((m = re.exec(raw))) {
79
+ const rawVal = m[1];
80
+ // A literal list may hold several entries; resolve each token.
81
+ const tokens = rawVal.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
82
+ let hit = null;
83
+ for (const tok of tokens.length ? tokens : [rawVal]) {
84
+ const r = resolve(tok);
85
+ if (check.bad(r.value)) { hit = r; break; }
86
+ }
87
+ if (!hit) continue;
88
+ const line = lineOf(raw, m.index);
89
+ const key = `${check.id}:${line}`;
90
+ if (seen.has(key)) continue;
91
+ seen.add(key);
92
+ findings.push({
93
+ id: `iac-tf:${check.id}:${fp}:${line}`,
94
+ severity: check.sev,
95
+ file: fp,
96
+ line,
97
+ vuln: check.vuln,
98
+ cwe: check.cwe,
99
+ family: 'iac-misconfig',
100
+ parser: 'IAC-TF',
101
+ description: `${check.vuln}.${hit.viaVar ? ` The unsafe value arrives via \`${hit.varName}\` (resolved to "${hit.value}") — a literal-only scan would miss this.` : ''}`,
102
+ remediation: check.fix,
103
+ });
104
+ }
105
+ }
106
+ return findings;
107
+ }
@@ -0,0 +1,113 @@
1
+ // R21 (PRD §5) — RBAC role-tier authorization model.
2
+ //
3
+ // R19 (api-authz.js) flags missing AUTHENTICATION among authenticated siblings.
4
+ // This goes one level deeper to AUTHORIZATION TIER: it extracts the role/
5
+ // permission a route requires (from middleware/annotations near the handler) and
6
+ // flags two broken-access-control shapes the route inventory alone can't see:
7
+ // 1. A state-changing route that enforces NO role while sibling routes on the
8
+ // same resource DO → missing function-level authorization (BFLA).
9
+ // 2. A state-changing route gated at a STRICTLY LOWER tier than a read sibling
10
+ // → privilege tier inversion (delete allowed below the tier that can read).
11
+ //
12
+ // Pure cross-route analysis (routes + file contents). Precision-first: fires
13
+ // only on inconsistency within a resource group that uses roles at all.
14
+
15
+ // Inline role checks — on the route's OWN registration line (middleware args).
16
+ const INLINE_PATTERNS = [
17
+ /\b(?:requireRole|hasRole|hasAnyRole|checkRole|ensureRole|restrictTo|requireScope|hasAuthority|hasPermission|requirePermission)\s*\(\s*\[?\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
18
+ /\brole\s*(?:===?|==)\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
19
+ ];
20
+ // Decorator/attribute role checks — on the line(s) directly ABOVE the handler.
21
+ const DECORATOR_PATTERNS = [
22
+ /@(?:PreAuthorize|Secured|RolesAllowed)\s*\(\s*[^)]*['"]([A-Za-z0-9_.:-]+)['"]/i,
23
+ /\[Authorize\s*\(\s*Roles\s*=\s*"([A-Za-z0-9_.,:-]+)"/i,
24
+ /@(?:roles_required|permission_required)\s*\(\s*['"]([A-Za-z0-9_.:-]+)['"]/i,
25
+ ];
26
+
27
+ // Coarse tier ranking (higher = more privileged). Unknown roles → tier 1 (mid).
28
+ function tierOf(role) {
29
+ const r = String(role || '').toLowerCase();
30
+ if (/\b(super.?admin|root|owner|sysadmin)\b/.test(r) || r === 'superadmin') return 3;
31
+ if (/\b(admin|administrator|manager|staff|moderator|mod)\b/.test(r)) return 2;
32
+ if (/\b(user|member|authenticated|customer|editor|contributor)\b/.test(r)) return 1;
33
+ if (/\b(guest|public|anon|anonymous|viewer|reader)\b/.test(r)) return 0;
34
+ return 1;
35
+ }
36
+
37
+ const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
38
+
39
+ function roleOf(route, fileContents) {
40
+ const content = fileContents[route.file];
41
+ if (typeof content !== 'string') return { hasRole: false, role: null };
42
+ const lines = content.split('\n');
43
+ const idx = (route.line || 1) - 1;
44
+ // 1. Inline middleware / role check on the route's OWN line (don't look at
45
+ // siblings — that would leak an adjacent route's role into this one).
46
+ const own = lines[idx] || '';
47
+ for (const re of INLINE_PATTERNS) { const m = own.match(re); if (m) return { hasRole: true, role: m[1] }; }
48
+ // 2. Stacked decorators/attributes directly ABOVE the handler; stop at the
49
+ // first non-decorator line so we never cross into the previous route.
50
+ for (let j = idx - 1; j >= 0 && j >= idx - 4; j--) {
51
+ const l = (lines[j] || '').trim();
52
+ if (!l) continue;
53
+ if (/^@\w|^\[Authorize/i.test(l)) {
54
+ for (const re of DECORATOR_PATTERNS) { const m = l.match(re); if (m) return { hasRole: true, role: m[1] }; }
55
+ continue; // an unrecognized decorator — keep scanning the stack upward
56
+ }
57
+ break; // hit a non-decorator line → stop
58
+ }
59
+ return { hasRole: false, role: null };
60
+ }
61
+
62
+ // Resource key: path with dynamic segments collapsed (so /users and /users/:id
63
+ // group together as one resource).
64
+ function resourceKey(p) {
65
+ return String(p || '').split('/').map((s) => (/^(:|\{|<|\d)/.test(s) ? '*' : s)).join('/').replace(/\/\*$/, '') || '/';
66
+ }
67
+
68
+ function mk(route, kind, cwe, why) {
69
+ return {
70
+ id: `rbac:${kind}:${route.file}:${route.line}`,
71
+ severity: 'high',
72
+ file: route.file, line: route.line || 0,
73
+ vuln: kind === 'inversion'
74
+ ? 'Authorization tier inversion (state-change allowed below the read tier)'
75
+ : 'Missing role-based authorization on a state-changing route',
76
+ cwe, family: 'broken-access-control', parser: 'RBAC',
77
+ description: why,
78
+ remediation: 'Enforce a role/permission check on the route at least as strong as its sibling routes; gate state-changing actions at the appropriate privilege tier.',
79
+ };
80
+ }
81
+
82
+ export function scanRbacConsistency(routes, fileContents) {
83
+ if (!Array.isArray(routes) || routes.length < 2) return [];
84
+ const groups = new Map();
85
+ for (const r of routes) {
86
+ if (!r || r.path === '(file-based)' || !r.path) continue;
87
+ const key = `${r.file}::${resourceKey(r.path)}`;
88
+ if (!groups.has(key)) groups.set(key, []);
89
+ groups.get(key).push({ ...r, ...roleOf(r, fileContents) });
90
+ }
91
+ const findings = [];
92
+ const seen = new Set();
93
+ const push = (f) => { const k = `${f.file}:${f.line}`; if (!seen.has(k)) { seen.add(k); findings.push(f); } };
94
+ for (const group of groups.values()) {
95
+ if (group.length < 2) continue;
96
+ const roled = group.filter((r) => r.hasRole);
97
+ if (!roled.length) continue; // resource doesn't use roles at all → not our signal
98
+ const maxReadTier = Math.max(...group.filter((r) => !STATE_CHANGING.has(r.method) && r.hasRole).map((r) => tierOf(r.role)), -1);
99
+ for (const r of group) {
100
+ if (!STATE_CHANGING.has(r.method)) continue;
101
+ if (!r.hasRole) {
102
+ push(mk(r, 'missing', '285',
103
+ `${r.method} ${r.path} enforces no role check, while ${roled.length} sibling route(s) on this resource require one — a state-changing action left at a lower authorization tier (BFLA).`));
104
+ } else if (maxReadTier >= 0 && tierOf(r.role) < maxReadTier) {
105
+ push(mk(r, 'inversion', '269',
106
+ `${r.method} ${r.path} requires role "${r.role}" (tier ${tierOf(r.role)}), but reading this resource requires a higher tier (${maxReadTier}). A lower-privileged user can mutate what they may not even read.`));
107
+ }
108
+ }
109
+ }
110
+ return findings;
111
+ }
112
+
113
+ export const _internals = { tierOf, resourceKey };
@@ -48,6 +48,61 @@ function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
48
48
  function isJsLike(fp) { return /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp); }
49
49
  function isPhp(fp) { return /\.php$/i.test(fp); }
50
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
+
51
106
  export function scanWrongContextSanitizer(fp, raw) {
52
107
  if (!raw || raw.length > 500_000) return [];
53
108
  if (!isJsLike(fp) && !isPhp(fp)) return [];
@@ -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
+ }