@clear-capabilities/agentic-security-scanner 0.140.0 → 0.142.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/dist/113.index.js +79 -3
  3. package/dist/178.index.js +1 -1
  4. package/dist/238.index.js +77 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/435.index.js +12 -0
  7. package/dist/526.index.js +79 -3
  8. package/dist/637.index.js +1 -1
  9. package/dist/agentic-security.mjs +14 -14
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/compliance-frameworks/ccpa.json +34 -7
  12. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  13. package/dist/compliance-frameworks/gdpr.json +56 -12
  14. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  15. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  16. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  17. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  18. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  19. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  20. package/package.json +19 -5
  21. package/src/dataflow/CLAUDE.md +9 -0
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/dataflow/engine.js +95 -0
  24. package/src/dataflow/sanitizer-gate.js +61 -0
  25. package/src/engine.js +353 -31
  26. package/src/mcp/tools.js +12 -0
  27. package/src/posture/accuracy-scorecard.js +57 -0
  28. package/src/posture/aibom.js +110 -1
  29. package/src/posture/auditor-walkthrough.js +56 -17
  30. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  31. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  32. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  33. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  34. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  35. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  36. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  37. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  38. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  39. package/src/posture/concurrency-checker.js +3 -3
  40. package/src/posture/coverage-strength.js +182 -0
  41. package/src/posture/epss.js +17 -1
  42. package/src/posture/family-registry.js +103 -0
  43. package/src/posture/family-resolve.js +47 -0
  44. package/src/posture/fix-coverage.js +113 -0
  45. package/src/posture/fix-metrics.js +76 -0
  46. package/src/posture/mcp-rug-pull.js +144 -0
  47. package/src/posture/poc-inprocess.js +217 -1
  48. package/src/posture/proof-coverage.js +162 -0
  49. package/src/posture/reachability-filter.js +44 -0
  50. package/src/posture/sbom.js +12 -3
  51. package/src/runScan.js +56 -5
  52. package/src/sast/CLAUDE.md +2 -2
  53. package/src/sast/claude-md-prompt-injection.js +47 -3
  54. package/src/sast/cloud-iam.js +23 -0
  55. package/src/sast/convention-deviation.js +66 -3
  56. package/src/sast/crypto-protocol.js +23 -0
  57. package/src/sast/dapp-frontend.js +20 -0
  58. package/src/sast/iac-cloud-templates.js +346 -0
  59. package/src/sast/k8s-admission.js +27 -0
  60. package/src/sast/ml-supply-chain.js +22 -0
  61. package/src/sast/ruby.js +132 -0
  62. package/src/sast/web3-advanced.js +26 -0
  63. package/src/sca/CLAUDE.md +21 -4
  64. package/src/sca/container.js +18 -1
  65. package/src/sca/dep-confusion.js +69 -3
@@ -0,0 +1,346 @@
1
+ // PRD F4.3 — the IaC formats `bench/iac-coverage` measured at zero.
2
+ //
3
+ // That bench scores VERDICT FLIP: a control counts as covered only when the
4
+ // misconfigured variant fires and the hardened variant stays silent. On its
5
+ // first run the engine scored 8/14, and the six failures were not spread
6
+ // evenly — they were whole formats:
7
+ //
8
+ // terraform 4/4 kubernetes 3/4 dockerfile 1/2
9
+ // cloudformation 0/2 bicep 0/1 helm 0/1
10
+ //
11
+ // Terraform had `iac-terraform.js` and Kubernetes had `k8s-admission.js`.
12
+ // CloudFormation, Bicep and Helm values had nothing at all, in either the rule
13
+ // set or the file walker — and a CloudFormation template is a `.yaml` that no
14
+ // path predicate recognises, so it was never even read.
15
+ //
16
+ // Every rule here is written to flip. The hardened variant of each control was
17
+ // written first and each rule was checked against it, because a rule that fires
18
+ // on `AccessControl: Private` as well as `PublicRead` is not detecting the
19
+ // control, it is detecting the resource — and a recall-only bench cannot see the
20
+ // difference.
21
+ //
22
+ // Regex over the raw template text, consistent with the rest of this directory:
23
+ // no YAML or Bicep parser is added, because `fast-xml-parser` was already
24
+ // rejected here on bundle-size and audit-surface grounds and the same argument
25
+ // applies. The cost is honest and bounded — deeply nested or heavily
26
+ // intrinsic-function'd templates will be missed, and the bench is where that
27
+ // shows up.
28
+
29
+ const ADMIN_PORTS = new Set([22, 23, 3389, 3306, 5432, 6379, 27017, 9200, 1433, 5984]);
30
+ const OPEN_CIDRS = /^(?:0\.0\.0\.0\/0|::\/0)$/;
31
+
32
+ function _line(text, index) { return text.slice(0, index).split('\n').length; }
33
+
34
+ function _finding(fp, text, index, over) {
35
+ return {
36
+ file: fp,
37
+ line: _line(text, index),
38
+ parser: 'IAC',
39
+ ...over,
40
+ };
41
+ }
42
+
43
+ // ── CloudFormation ──────────────────────────────────────────────────────────
44
+
45
+ export function isCloudFormationTemplate(relPath, content) {
46
+ if (typeof content !== 'string') return false;
47
+ if (!/\.(?:ya?ml|json)$/i.test(relPath || '')) return false;
48
+ const head = content.length > 65536 ? content.slice(0, 65536) : content;
49
+ if (/AWSTemplateFormatVersion/.test(head)) return true;
50
+ // A template without the (optional) version key is still a template if it
51
+ // declares resources by AWS type. Both signals are required so an ordinary
52
+ // config file mentioning "Resources" is not swept in.
53
+ return /(?:^|\n)\s*Resources\s*:/.test(head) && /Type\s*:\s*["']?AWS::/.test(head);
54
+ }
55
+
56
+ function scanCloudFormation(fp, raw) {
57
+ const out = [];
58
+
59
+ // Unrestricted ingress. The port and the CIDR are read from the same ingress
60
+ // block rather than matched independently, so `CidrIp: 0.0.0.0/0` on port 443
61
+ // — which is what a public web listener looks like and is not a finding —
62
+ // does not match.
63
+ // Split the ingress LIST into items, rather than anchoring on `- IpProtocol`.
64
+ //
65
+ // YAML mappings are unordered, so `- CidrIp:` first and `- IpProtocol:` first
66
+ // are the same template — and the anchored form only matched the second.
67
+ // bench/mutation's `cfn-metamorphic-property-order` case is exactly that
68
+ // rewrite, and it failed: the rule was keyed on the author's key order, which
69
+ // is syntax, not meaning.
70
+ const ingressBlock = /(?:^|\n)([ \t]*)-[ \t]+(?=[\w"']+[ \t]*:)([\s\S]*?)(?=\n[ \t]*-[ \t]|\n[ \t]{0,8}\w[\w.]*[ \t]*:|$)/g;
71
+ let m;
72
+ while ((m = ingressBlock.exec(raw))) {
73
+ const block = m[0];
74
+ // Only list items that actually describe an ingress rule.
75
+ if (!/IpProtocol\s*:/.test(block) || !/Cidr(?:Ip|Ipv6)\s*:/.test(block)) continue;
76
+ const cidr = block.match(/Cidr(?:Ip|Ipv6)\s*:\s*["']?([^\s"',]+)/);
77
+ if (!cidr || !OPEN_CIDRS.test(cidr[1])) continue;
78
+ const from = block.match(/FromPort\s*:\s*["']?(\d+)/);
79
+ const to = block.match(/ToPort\s*:\s*["']?(\d+)/);
80
+ if (!from || !to) continue;
81
+ const lo = Number(from[1]), hi = Number(to[1]);
82
+ const hitsAdmin = [...ADMIN_PORTS].some((p) => p >= lo && p <= hi);
83
+ if (!hitsAdmin) continue;
84
+ out.push(_finding(fp, raw, m.index, {
85
+ id: `cfn-open-ingress:${fp}:${_line(raw, m.index)}`,
86
+ vuln: `CloudFormation security group allows ${cidr[1]} to port ${lo === hi ? lo : `${lo}-${hi}`}`,
87
+ severity: 'high', cwe: 'CWE-284', family: 'iac-network-exposure',
88
+ description: `A SecurityGroupIngress rule opens an administrative port to the whole internet. Anyone who can reach the instance can attempt authentication against it, continuously and from anywhere.`,
89
+ remediation: `Restrict CidrIp to the VPC or office range, or front the port with a bastion / session manager. If public access is genuinely required, say so in the template with a comment so the next reader does not have to guess.`,
90
+ snippet: block.split('\n').slice(0, 6).join('\n').trim().slice(0, 200),
91
+ }));
92
+ }
93
+
94
+ // Public object storage. `AccessControl` is the property that grants it; the
95
+ // hardened form sets Private and usually adds a PublicAccessBlockConfiguration.
96
+ const acl = /AccessControl\s*:\s*["']?(PublicRead|PublicReadWrite|AuthenticatedRead)\b/g;
97
+ while ((m = acl.exec(raw))) {
98
+ out.push(_finding(fp, raw, m.index, {
99
+ id: `cfn-public-bucket:${fp}:${_line(raw, m.index)}`,
100
+ vuln: `CloudFormation bucket grants ${m[1]} access`,
101
+ severity: m[1] === 'PublicReadWrite' ? 'critical' : 'high',
102
+ cwe: 'CWE-732', family: 'iac-public-storage',
103
+ description: `The bucket's canned ACL makes its objects readable${m[1] === 'PublicReadWrite' ? ' AND writable' : ''} by anyone. Public buckets are the single most common cause of accidental data exposure in cloud estates.`,
104
+ remediation: `Set AccessControl: Private and add a PublicAccessBlockConfiguration with BlockPublicAcls and BlockPublicPolicy set to true. Serve public assets through a CDN with an origin access identity instead.`,
105
+ snippet: m[0],
106
+ }));
107
+ }
108
+
109
+ // Publicly reachable managed database. The property is unambiguous and the
110
+ // hardened form is the single-word opposite, which is what makes it a good
111
+ // control: there is no judgement call for the rule to get wrong.
112
+ const publicDb = /PubliclyAccessible\s*:\s*["']?true\b/g;
113
+ while ((m = publicDb.exec(raw))) {
114
+ out.push(_finding(fp, raw, m.index, {
115
+ id: `cfn-public-db:${fp}:${_line(raw, m.index)}`,
116
+ vuln: 'CloudFormation database instance is publicly accessible',
117
+ severity: 'high', cwe: 'CWE-284', family: 'iac-network-exposure',
118
+ description: 'The instance gets a public endpoint, so its authentication is the only thing between the internet and the data. Encryption at rest does not help here — the attacker arrives as a client.',
119
+ remediation: 'Set PubliclyAccessible: false and reach the database from inside the VPC, through a bastion or a private endpoint.',
120
+ snippet: m[0],
121
+ }));
122
+ }
123
+
124
+ // Wildcard IAM. Matched on the STATEMENT so that `Action: '*'` with a scoped
125
+ // Resource, or a scoped Action with `Resource: '*'`, do not both have to be
126
+ // present on the same line — YAML puts them on separate ones.
127
+ const stmt = /-\s*Effect\s*:\s*["']?Allow["']?[\s\S]{0,300}?(?=\n\s*-\s*Effect|\n\s{0,6}\w+\s*:\s*\n|$)/g;
128
+ while ((m = stmt.exec(raw))) {
129
+ const block = m[0];
130
+ const wildcardAction = /Action\s*:\s*(?:\[\s*)?["']\*["']/.test(block);
131
+ const wildcardResource = /Resource\s*:\s*(?:\[\s*)?["']\*["']/.test(block);
132
+ if (!wildcardAction || !wildcardResource) continue;
133
+ out.push(_finding(fp, raw, m.index, {
134
+ id: `cfn-iam-wildcard:${fp}:${_line(raw, m.index)}`,
135
+ vuln: 'CloudFormation IAM statement allows Action "*" on Resource "*"',
136
+ severity: 'high', cwe: 'CWE-732', family: 'iac-excessive-privilege',
137
+ description: 'This grants every action on every resource in the account. Any compromise of a principal holding it is a full account compromise, and nothing downstream can constrain it.',
138
+ remediation: 'Enumerate the actions the workload actually calls and scope Resource to the specific ARNs. If the policy is for a break-glass role, keep it out of the default deployment path.',
139
+ snippet: block.split('\n').slice(0, 5).join('\n').trim().slice(0, 200),
140
+ }));
141
+ }
142
+
143
+ return out;
144
+ }
145
+
146
+ // ── Bicep ───────────────────────────────────────────────────────────────────
147
+
148
+ function scanBicep(fp, raw) {
149
+ const out = [];
150
+ let m;
151
+
152
+ const publicBlob = /allowBlobPublicAccess\s*:\s*true\b/g;
153
+ while ((m = publicBlob.exec(raw))) {
154
+ out.push(_finding(fp, raw, m.index, {
155
+ id: `bicep-public-blob:${fp}:${_line(raw, m.index)}`,
156
+ vuln: 'Bicep storage account allows anonymous public blob access',
157
+ severity: 'high', cwe: 'CWE-732', family: 'iac-public-storage',
158
+ description: `allowBlobPublicAccess: true lets containers in this account be configured for anonymous read. The account-level flag is the last line of defence — with it enabled, a single container-level mistake exposes data to the internet.`,
159
+ remediation: `Set allowBlobPublicAccess: false. Grant access with SAS tokens or a managed identity instead; if truly public content is needed, serve it through a CDN endpoint.`,
160
+ snippet: m[0],
161
+ }));
162
+ }
163
+
164
+ const plaintextTransit = /supportsHttpsTrafficOnly\s*:\s*false\b/g;
165
+ while ((m = plaintextTransit.exec(raw))) {
166
+ out.push(_finding(fp, raw, m.index, {
167
+ id: `bicep-http-allowed:${fp}:${_line(raw, m.index)}`,
168
+ vuln: 'Bicep storage account permits unencrypted HTTP traffic',
169
+ severity: 'medium', cwe: 'CWE-319', family: 'iac-transit-encryption',
170
+ description: `supportsHttpsTrafficOnly: false allows clients to reach the account over plain HTTP, so credentials and data can be read by anything on the path.`,
171
+ remediation: 'Set supportsHttpsTrafficOnly: true. There is no compatible client left that requires plain HTTP for this service.',
172
+ snippet: m[0],
173
+ }));
174
+ }
175
+
176
+ // An inbound Allow rule whose source is `*` (or the Internet service tag) on
177
+ // an administrative port. Read from the whole rule block so a wildcard source
178
+ // on port 443 — an ordinary public web listener — does not match.
179
+ const nsgRule = /\{[^{}]*?direction\s*:\s*'Inbound'[^{}]*?\}|\{[^{}]*?sourceAddressPrefix[\s\S]{0,400}?\}/g;
180
+ while ((m = nsgRule.exec(raw))) {
181
+ const block = m[0];
182
+ if (!/direction\s*:\s*'Inbound'/i.test(block)) continue;
183
+ if (!/access\s*:\s*'Allow'/i.test(block)) continue;
184
+ const src = block.match(/sourceAddressPrefix\s*:\s*'([^']*)'/);
185
+ if (!src || !/^(?:\*|0\.0\.0\.0\/0|Internet)$/i.test(src[1])) continue;
186
+ const portRange = block.match(/destinationPortRanges?\s*:\s*'?\[?\s*'?([^'\]]*)/);
187
+ const ports = portRange ? portRange[1] : '';
188
+ const hitsAdmin = [...ADMIN_PORTS].some((pnum) => {
189
+ if (new RegExp(`(?:^|[,\\s])${pnum}(?:$|[,\\s])`).test(ports)) return true;
190
+ const range = ports.match(/(\d+)\s*-\s*(\d+)/);
191
+ return !!range && pnum >= Number(range[1]) && pnum <= Number(range[2]);
192
+ });
193
+ if (!hitsAdmin && ports !== '*') continue;
194
+ out.push(_finding(fp, raw, m.index, {
195
+ id: `bicep-nsg-world:${fp}:${_line(raw, m.index)}`,
196
+ vuln: `Bicep network security rule allows inbound from "${src[1]}" to port ${ports || '*'}`,
197
+ severity: 'high', cwe: 'CWE-284', family: 'iac-network-exposure',
198
+ description: 'An inbound Allow rule with a wildcard source exposes an administrative port to the whole internet.',
199
+ remediation: "Set sourceAddressPrefix to the VNet or an office range, or reach the port through a bastion instead.",
200
+ snippet: block.split('\n').slice(0, 6).join('\n').trim().slice(0, 200),
201
+ }));
202
+ }
203
+
204
+ const minTls = /minimumTlsVersion\s*:\s*['"]TLS1_0['"]/g;
205
+ while ((m = minTls.exec(raw))) {
206
+ out.push(_finding(fp, raw, m.index, {
207
+ id: `bicep-weak-tls:${fp}:${_line(raw, m.index)}`,
208
+ vuln: 'Bicep resource accepts TLS 1.0',
209
+ severity: 'medium', cwe: 'CWE-327', family: 'iac-transit-encryption',
210
+ description: 'TLS 1.0 is deprecated and vulnerable to several downgrade and padding-oracle attacks.',
211
+ remediation: "Set minimumTlsVersion: 'TLS1_2'.",
212
+ snippet: m[0],
213
+ }));
214
+ }
215
+
216
+ return out;
217
+ }
218
+
219
+ // ── Helm values ─────────────────────────────────────────────────────────────
220
+
221
+ // A chart's values.yaml is where a workload's defaults live, and a default is
222
+ // what most installs actually run. `templates/*.yaml` is deliberately NOT
223
+ // handled: it is Go template source, not YAML, and matching text inside `{{ }}`
224
+ // would report the template rather than the configuration.
225
+ function scanHelmValues(fp, raw) {
226
+ if (!/(?:^|\/)values(?:\.[\w-]+)?\.ya?ml$/i.test(fp)) return [];
227
+ const out = [];
228
+ let m;
229
+
230
+ const privileged = /(?:^|\n)\s*privileged\s*:\s*true\b/g;
231
+ while ((m = privileged.exec(raw))) {
232
+ out.push(_finding(fp, raw, m.index, {
233
+ id: `helm-privileged:${fp}:${_line(raw, m.index)}`,
234
+ vuln: 'Helm chart defaults the workload to privileged execution',
235
+ severity: 'high', cwe: 'CWE-250', family: 'iac-privileged-workload',
236
+ description: `A privileged container has all capabilities and effectively full access to the host. Because this is a chart DEFAULT, every install that does not deliberately override it runs privileged.`,
237
+ remediation: 'Set privileged: false in values.yaml and let an operator opt in explicitly if a workload genuinely needs it.',
238
+ snippet: m[0].trim(),
239
+ }));
240
+ }
241
+
242
+ const hostNet = /(?:^|\n)\s*hostNetwork\s*:\s*true\b/g;
243
+ while ((m = hostNet.exec(raw))) {
244
+ out.push(_finding(fp, raw, m.index, {
245
+ id: `helm-host-network:${fp}:${_line(raw, m.index)}`,
246
+ vuln: 'Helm chart defaults the workload onto the host network',
247
+ severity: 'high', cwe: 'CWE-668', family: 'iac-privileged-workload',
248
+ description: 'A pod on the host network sees every interface on the node, can bind privileged ports, and bypasses NetworkPolicy entirely. As a chart DEFAULT it applies to every install that does not override it.',
249
+ remediation: 'Set hostNetwork: false and expose the workload through a Service.',
250
+ snippet: m[0].trim(),
251
+ }));
252
+ }
253
+
254
+ const rootUser = /(?:^|\n)\s*runAsUser\s*:\s*0\b/g;
255
+ while ((m = rootUser.exec(raw))) {
256
+ out.push(_finding(fp, raw, m.index, {
257
+ id: `helm-run-as-root:${fp}:${_line(raw, m.index)}`,
258
+ vuln: 'Helm chart defaults the workload to UID 0 (root)',
259
+ severity: 'medium', cwe: 'CWE-250', family: 'iac-privileged-workload',
260
+ description: 'Running as UID 0 means a container escape starts with root on the node, and any host path mounted into the container is writable.',
261
+ remediation: 'Set runAsNonRoot: true and a non-zero runAsUser, and make the image support it.',
262
+ snippet: m[0].trim(),
263
+ }));
264
+ }
265
+
266
+ return out;
267
+ }
268
+
269
+ // ── Dockerfile: base-image pinning ──────────────────────────────────────────
270
+
271
+ // The bench's `docker-unpinned-base` control. Separate from the existing
272
+ // container rules because it is about REPRODUCIBILITY of the supply chain
273
+ // rather than what the image contains: `FROM ubuntu:latest` resolves to
274
+ // different bytes on different days, so a scan result has no shelf life and a
275
+ // compromised upstream tag arrives silently on the next build.
276
+ function scanDockerfileBase(fp, raw) {
277
+ const base = (fp.split('/').pop() || '');
278
+ if (!/^(?:Dockerfile|Containerfile)(?:\.[\w.-]+)?$/i.test(base) && !/\.dockerfile$/i.test(base)) return [];
279
+ const out = [];
280
+ const from = /(?:^|\n)\s*FROM\s+(\S+)/gi;
281
+ let m;
282
+ while ((m = from.exec(raw))) {
283
+ const ref = m[1];
284
+ if (/^\$\{?\w/.test(ref)) continue; // build-arg indirection
285
+ if (ref.includes('@sha256:')) continue; // pinned by digest — the hardened form
286
+ if (/^scratch$/i.test(ref)) continue; // the empty image has no tag
287
+ const tag = ref.includes(':') ? ref.slice(ref.lastIndexOf(':') + 1) : '';
288
+ // A named build stage (`FROM builder`) is an internal reference, not a
289
+ // registry pull.
290
+ if (!tag && !ref.includes('/') && /^[a-z][\w-]*$/i.test(ref) && new RegExp(`AS\\s+${ref}\\b`, 'i').test(raw)) continue;
291
+ const unpinned = !tag || /^latest$/i.test(tag);
292
+ if (!unpinned) continue;
293
+ out.push(_finding(fp, raw, m.index, {
294
+ id: `docker-unpinned-base:${fp}:${_line(raw, m.index)}`,
295
+ vuln: `Base image "${ref}" is not pinned to an immutable reference`,
296
+ severity: 'medium', cwe: 'CWE-1104', family: 'iac-unpinned-base',
297
+ description: `A mutable tag resolves to different bytes over time, so this build is not reproducible and a scan of it has no shelf life. If the upstream tag is ever republished — accidentally or maliciously — the change arrives on the next build with nothing to notice it.`,
298
+ remediation: `Pin by digest: FROM ${ref.split(':')[0]}@sha256:<digest>. Keep the human-readable tag in a comment so the next reader knows which release it is.`,
299
+ snippet: m[0].trim(),
300
+ }));
301
+ }
302
+ return out;
303
+ }
304
+
305
+ // ── Kubernetes: a literal credential in an env value ────────────────────────
306
+
307
+ // `k8s-admission.js` covers the pod security surface; this is the one control
308
+ // the bench found it silent on. A manifest is committed to a repository and
309
+ // copied into CI logs and cluster state, so a literal value here is exposed
310
+ // several times over.
311
+ function scanK8sLiteralSecret(fp, raw) {
312
+ if (!/\.ya?ml$/i.test(fp)) return [];
313
+ if (!/(?:^|\n)\s*kind\s*:/.test(raw)) return [];
314
+ const out = [];
315
+ // `- name: DB_PASSWORD` followed by `value:` — as opposed to `valueFrom:`,
316
+ // which is the hardened form and must not match.
317
+ const envPair = /-\s*name\s*:\s*["']?([A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|PRIVATE_KEY|CREDENTIAL)[A-Z0-9_]*)["']?\s*\n\s*value\s*:\s*["']?([^\s"'#]{6,})/g;
318
+ let m;
319
+ while ((m = envPair.exec(raw))) {
320
+ const value = m[2];
321
+ // A reference or an obvious placeholder is not a leak.
322
+ if (/^\$[({]/.test(value)) continue;
323
+ if (/^(?:changeme|placeholder|example|your[-_]|todo|replace)/i.test(value)) continue;
324
+ out.push(_finding(fp, raw, m.index, {
325
+ id: `k8s-literal-secret:${fp}:${_line(raw, m.index)}`,
326
+ vuln: `Kubernetes manifest sets ${m[1]} to a literal value`,
327
+ severity: 'high', cwe: 'CWE-798', family: 'iac-literal-credential',
328
+ description: `The credential is committed to the repository, copied into CI logs, and stored in cluster state in plaintext. Rotating it means editing and redeploying the manifest, which is why it usually does not happen.`,
329
+ remediation: 'Use valueFrom.secretKeyRef and keep the value in a Secret managed outside the repository. Rotate the exposed credential — treat it as compromised.',
330
+ snippet: `- name: ${m[1]}\n value: ${value.slice(0, 4)}••••`,
331
+ }));
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /** All template-format IaC rules for one file. */
337
+ export function scanCloudTemplates(fp, raw) {
338
+ if (!fp || typeof raw !== 'string' || !raw) return [];
339
+ const out = [];
340
+ if (/\.bicep$/i.test(fp)) out.push(...scanBicep(fp, raw));
341
+ if (isCloudFormationTemplate(fp, raw)) out.push(...scanCloudFormation(fp, raw));
342
+ out.push(...scanHelmValues(fp, raw));
343
+ out.push(...scanDockerfileBase(fp, raw));
344
+ out.push(...scanK8sLiteralSecret(fp, raw));
345
+ return out;
346
+ }
@@ -38,6 +38,33 @@
38
38
 
39
39
  import { blankComments } from './_comment-strip.js';
40
40
 
41
+ // The finding families this module can emit (F10.2 producer registry).
42
+ //
43
+ // Declared HERE, next to the rules, because no external method enumerates them:
44
+ // this module passes `family` POSITIONALLY (`_shape(file, line, ruleId, vuln,
45
+ // fam, ...)`), so a textual search for `family:` finds nothing, and a corpus
46
+ // sweep only ever reports a LOWER BOUND -- it sees whichever families a fixture
47
+ // happened to trigger. This list is the union of both, and
48
+ // `test/family-registry.test.js` fails if a scan produces a family from this
49
+ // module that is not listed.
50
+ //
51
+ // Add the family here in the same edit that adds the rule.
52
+ export const EMITS = [
53
+ 'k8s-pod-security-allow-privesc',
54
+ 'k8s-pod-security-capabilities-broad',
55
+ 'k8s-pod-security-hostnetwork',
56
+ 'k8s-pod-security-hostpath',
57
+ 'k8s-pod-security-hostpid',
58
+ 'k8s-pod-security-privileged',
59
+ 'k8s-pod-security-run-as-root',
60
+ 'k8s-rbac-anonymous',
61
+ 'k8s-rbac-cluster-admin',
62
+ 'k8s-rbac-overbroad-binding',
63
+ 'k8s-rbac-wildcard',
64
+ 'k8s-webhook-bypass',
65
+ 'k8s-webhook-sideeffects',
66
+ ];
67
+
41
68
  const _IS_K8S_FILE = /\.(?:yaml|yml)$/i;
42
69
 
43
70
  function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
@@ -30,6 +30,28 @@
30
30
 
31
31
  import { blankComments } from './_comment-strip.js';
32
32
 
33
+ // The finding families this module can emit (F10.2 producer registry).
34
+ //
35
+ // Declared HERE, next to the rules, because no external method enumerates them:
36
+ // this module passes `family` POSITIONALLY (`_shape(file, line, ruleId, vuln,
37
+ // fam, ...)`), so a textual search for `family:` finds nothing, and a corpus
38
+ // sweep only ever reports a LOWER BOUND -- it sees whichever families a fixture
39
+ // happened to trigger. This list is the union of both, and
40
+ // `test/family-registry.test.js` fails if a scan produces a family from this
41
+ // module that is not listed.
42
+ //
43
+ // Add the family here in the same edit that adds the rule.
44
+ export const EMITS = [
45
+ 'gradio-auth',
46
+ 'hf-datasets-rce',
47
+ 'hf-endpoint-override',
48
+ 'mlflow-untrusted-uri',
49
+ 'model-format',
50
+ 'onnx-providers',
51
+ 'prompt-integrity',
52
+ 'streaming-dataset-url',
53
+ ];
54
+
33
55
  const _SCAN_EXT_RE = /\.(?:py|ipynb)$/i;
34
56
  const _NONPROD_PATH_RE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|examples?|docs?|stories|codefixes|node_modules)\//i;
35
57
 
package/src/sast/ruby.js CHANGED
@@ -144,3 +144,135 @@ export function scanRuby(fp, raw) {
144
144
  }
145
145
  return findings;
146
146
  }
147
+
148
+ // ── PRD F1.3 — `File.join(<root>, …, <untrusted>)` ──────────────────────────
149
+ //
150
+ // The dominant Ruby CWE-22 shape on real code, and the one
151
+ // `pathTraversalStructural` above cannot reach: that rule needs a STRING
152
+ // LITERAL as the first component (`File.read("/data/" + name)`), and the real
153
+ // advisories join variables.
154
+ //
155
+ // File.join(adapter.document_root, request.path_info.sub(/\.html$/,'') + '.html')
156
+ // — lsegal/yard, GHSA-pxcc-8665-phx8; the fix rejects `..` segments
157
+ // File.join(root, tenant, folder_for(key), key)
158
+ // — basecamp/activerecord-tenanted, GHSA-pmwx-rm49-xv39; the fix raises on
159
+ // `key.split("/").intersect?(%w[. ..])`
160
+ //
161
+ // Measured baseline before this rule: 23 cached Ruby CWE-22/CWE-79 entries,
162
+ // 0 localized hits, 18 of them producing no finding of any kind.
163
+ //
164
+ // PRECISION IS THE WHOLE DESIGN. The F1.2 attempt at Ruby resource-exhaustion
165
+ // was reverted because it fired on `File.read(File.join(__dir__, "…/data.json"))`
166
+ // — a path built entirely from constants. So:
167
+ //
168
+ // · the LAST component must be a variable-ish expression, never a literal;
169
+ // · a join rooted at `__dir__` / `Rails.root` / `File.dirname(__FILE__)` /
170
+ // `Dir.pwd` is a project-relative constant path and is skipped outright;
171
+ // · the join must actually reach a filesystem operation, either wrapped
172
+ // directly or through a variable used by one nearby;
173
+ // · any containment guard in the enclosing window silences it — that is the
174
+ // whole vulnerability, so a guard means there is nothing to report.
175
+ //
176
+ // A single `if path.include?("..")` silences this, which is exactly the fix
177
+ // each of these advisories shipped.
178
+
179
+ // Trailing (?!\w) rather than \b: Ruby predicate methods end in \, and a
180
+ // word boundary after a non-word character never matches, so \
181
+ // silently failed to count as a filesystem operation — the miss that made this
182
+ // rule silent on lsegal/yard's static_caching.rb, one of the two advisories it
183
+ // was written from.
184
+ // Trailing (?!\w) rather than \b. Ruby predicate methods end in `?`, and a word
185
+ // boundary after a non-word character can never match — so `File.file?(x)` did
186
+ // not count as a filesystem operation, and this rule was silent on
187
+ // lsegal/yard's static_caching.rb, one of the two advisories it was written
188
+ // from. The rule looked correct in isolation and found nothing; the bug was one
189
+ // character of regex.
190
+ const RB_FS_OP = /\b(?:File|IO|FileUtils|Dir)\s*\.\s*(?:read|open|new|readlines|binread|binwrite|write|foreach|delete|unlink|mkdir_p|rm_rf|cp|mv|file\?|exist\?|directory\?|entries|glob)(?!\w)/;
191
+ // Constant roots: a path assembled from these is not attacker-reachable.
192
+ const RB_CONST_ROOT = /\b(?:__dir__|__FILE__|Rails\.root|Dir\.pwd|Gem\.dir|File\.dirname\s*\(\s*__FILE__)/;
193
+ // Any of these in the enclosing window means containment was considered.
194
+ const RB_PATH_GUARD = /\b(?:expand_path[\s\S]{0,200}?start_with\?|start_with\?[\s\S]{0,200}?expand_path|include\?\s*\(\s*['"]\.\.|\.\.\s*['"]\s*\)|intersect\?\s*\(\s*%w\[|cleanpath|realpath|File\s*\.\s*basename|sanitize_filename|secure_filename|ValidPath|absolute_path\?)/;
195
+ // A literal argument — the thing that must NOT be the last component.
196
+ const RB_LITERAL_ARG = /^\s*(?:['"][^'"]*['"]|:[A-Za-z_]\w*)\s*$/;
197
+
198
+ function _splitArgs(s) {
199
+ const out = [];
200
+ let depth = 0, cur = '';
201
+ for (const ch of s) {
202
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
203
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
204
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
205
+ cur += ch;
206
+ }
207
+ if (cur.trim()) out.push(cur);
208
+ return out;
209
+ }
210
+
211
+ /** File.join(...) whose last component is variable and which reaches the filesystem. */
212
+ export function scanRubyPathJoin(fp, raw) {
213
+ if (!/\.rb$/i.test(fp)) return [];
214
+ if (!raw || raw.length > 500_000) return [];
215
+ const code = blankComments(raw, 'py');
216
+ const lines = code.split('\n');
217
+ const out = [];
218
+ const seen = new Set();
219
+
220
+ const JOIN = /\bFile\s*\.\s*join\s*\(/g;
221
+ let m;
222
+ while ((m = JOIN.exec(code))) {
223
+ // Balanced scan for the closing paren of this call.
224
+ let i = m.index + m[0].length, depth = 1;
225
+ for (; i < code.length && depth > 0; i++) {
226
+ if (code[i] === '(') depth++;
227
+ else if (code[i] === ')') depth--;
228
+ }
229
+ if (depth !== 0) continue;
230
+ const inner = code.slice(m.index + m[0].length, i - 1);
231
+ const args = _splitArgs(inner);
232
+ if (args.length < 2) continue;
233
+ const last = args[args.length - 1];
234
+ if (RB_LITERAL_ARG.test(last)) continue; // File.join(root, "index.html")
235
+ if (RB_CONST_ROOT.test(inner)) continue; // project-relative constant path
236
+
237
+ const line = code.slice(0, m.index).split('\n').length;
238
+
239
+ // The join must reach the filesystem: wrapped directly, or assigned to a
240
+ // variable that a nearby filesystem call uses.
241
+ const before = code.slice(Math.max(0, m.index - 120), m.index);
242
+ let reaches = RB_FS_OP.test(before);
243
+ let assigned = null;
244
+ if (!reaches) {
245
+ const am = before.match(/([A-Za-z_@][\w]*)\s*=\s*$/);
246
+ if (am) {
247
+ assigned = am[1];
248
+ const after = lines.slice(line, line + 12).join('\n');
249
+ const use = new RegExp(`${assigned.replace('@', '@')}\\b`);
250
+ reaches = RB_FS_OP.test(after) && use.test(after);
251
+ }
252
+ }
253
+ if (!reaches) continue;
254
+
255
+ // Containment guard anywhere in the enclosing window — that IS the fix.
256
+ const windowText = lines.slice(Math.max(0, line - 15), line + 15).join('\n');
257
+ if (RB_PATH_GUARD.test(windowText)) continue;
258
+
259
+ const id = `ruby-pathJoinUnguarded:${fp}:${line}`;
260
+ if (seen.has(id)) continue;
261
+ seen.add(id);
262
+ out.push({
263
+ id, file: fp, line,
264
+ vuln: 'Path Traversal: File.join builds a filesystem path from a variable component with no containment check',
265
+ severity: 'high', cwe: 'CWE-22', family: 'path-traversal',
266
+ parser: 'RUBY', confidence: 0.7,
267
+ description:
268
+ `The last component of this File.join is a variable, the result reaches a filesystem operation, and nothing in ` +
269
+ `the surrounding code rejects \`..\` segments or asserts the resolved path stays under the base. A value ` +
270
+ `containing \`../\` walks out of the intended directory.`,
271
+ remediation:
272
+ 'Reject traversal segments before joining — `raise if key.split("/").intersect?(%w[. ..])` — or canonicalize ' +
273
+ 'and assert containment: `path = File.expand_path(File.join(base, name)); raise unless path.start_with?(base)`.',
274
+ snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
275
+ });
276
+ }
277
+ return out;
278
+ }
@@ -39,6 +39,32 @@
39
39
 
40
40
  import { blankComments } from './_comment-strip.js';
41
41
 
42
+ // The finding families this module can emit (F10.2 producer registry).
43
+ //
44
+ // Declared HERE, next to the rules, because no external method enumerates them:
45
+ // this module passes `family` POSITIONALLY (`_shape(file, line, ruleId, vuln,
46
+ // fam, ...)`), so a textual search for `family:` finds nothing, and a corpus
47
+ // sweep only ever reports a LOWER BOUND -- it sees whichever families a fixture
48
+ // happened to trigger. This list is the union of both, and
49
+ // `test/family-registry.test.js` fails if a scan produces a family from this
50
+ // module that is not listed.
51
+ //
52
+ // Add the family here in the same edit that adds the rule.
53
+ export const EMITS = [
54
+ 'ecdsa-malleability',
55
+ 'erc4337-validation',
56
+ 'fee-on-transfer-vault',
57
+ 'multicall-delegatecall',
58
+ 'nft-receiver-reentrancy',
59
+ 'oracle-staleness',
60
+ 'read-only-reentrancy',
61
+ 'signature-replay',
62
+ 'solana-anchor-no-owner',
63
+ 'upgradeable-init',
64
+ 'upgradeable-storage',
65
+ 'vyper-raw-call',
66
+ ];
67
+
42
68
  function _line(raw, idx) { return raw.slice(0, idx).split('\n').length; }
43
69
  function _snip(raw, line) { return (raw.split('\n')[line - 1] || '').trim().slice(0, 200); }
44
70
 
package/src/sca/CLAUDE.md CHANGED
@@ -16,7 +16,7 @@ This directory holds the seven specialized modules called from there.
16
16
  | `index.js` | Re-exports six public symbols from `../engine.js` so external consumers can `import { parseManifests, queryOSV, … } from '@…/sca'`. |
17
17
  | `binary-metadata.js` | **Opt-in via `AGENTIC_SECURITY_BINARY_SCA=1`.** Reads dependency metadata from compiled artifacts: JAR `META-INF/MANIFEST.MF` + `pom.properties`, Go binary `go.buildinfo`. Never executes the binary. JAR extraction uses `fs.mkdtemp` for an isolated scratch dir (premortem-derived: shared `/tmp` lets a hostile JAR plant a symlinked manifest that escapes the scratch). |
18
18
  | `container.js` | Dockerfile parser. Detects EOL `FROM` base images (alpine/debian/ubuntu/node/python) against `base-images.json`, and synthesizes lightweight SCA components from `apt-get install` / `apk add` package lists. No Docker daemon required. |
19
- | `dep-confusion.js` | Two related detectors. **Typosquat:** Levenshtein distance ≤ 2 against `popular-packages.json` — **188 packages (115 npm + 73 pypi), not "top-1000"** as this row previously said; re-derive the count from the file rather than trusting a hardcoded number here again. **Dependency confusion:** internal-scoped names (declared in `.agentic-security/internal-scopes.yml`) appearing on the public registry. Local-first; **this module does not itself call OSV** — it reads a flag set by an earlier, separate OSV/queryRegistries pass upstream (whether a dep "resolved by OSV"), which is different from "OSV consulted [by this module] to confirm confusion findings" as previously stated. |
19
+ | `dep-confusion.js` | Two related detectors. **Typosquat:** Damerau-Levenshtein distance against `popular-packages.json`, accepted only when `distance / min(nameLen, popularLen) ≤ 0.25` — **188 packages (115 npm + 73 pypi), not "top-1000"** as this row previously said; re-derive the count from the file rather than trusting a hardcoded number here again. **Dependency confusion:** internal-scoped names (declared in `.agentic-security/internal-scopes.yml`) appearing on the public registry. Local-first; **this module does not itself call OSV** — it reads a flag set by an earlier, separate OSV/queryRegistries pass upstream (whether a dep "resolved by OSV"), which is different from "OSV consulted [by this module] to confirm confusion findings" as previously stated. |
20
20
  | `llm-function-extract.js` | **Opt-in via `AGENTIC_SECURITY_LLM_SCA=1`.** LLM-assisted extraction of vulnerable function names for CVEs that lack OSV `ecosystem_specific.vulnerable_functions` data. Cached per CVE under `~/.config/agentic-security/llm-sca-cache/`. Endpoint-dependent — degrades to no-op when unreachable. |
21
21
  | `py-package-functions.js` | **Opt-in via `AGENTIC_SECURITY_DEEP=1`** (Python only). Locates installed Python packages via `site-packages` and parses them with the CPython `ast` module (subprocess) to *validate* that an OSV-named vulnerable function exists in the installed version. Closes the "OSV says this function is vulnerable, but the version you installed actually removed it" false-positive class. |
22
22
  | `vendor-detect.js` | Detects libraries copied into `src/` (lodash, jQuery, Angular, React, etc.) via characteristic version strings and function signatures. Catches the case where a vulnerable library bypasses the lockfile because someone vendored it directly. |
@@ -130,9 +130,26 @@ if a detector forgets to set them.
130
130
  - **EOL base-image detection has a hand-curated cutoff.** `base-images.json`
131
131
  is updated periodically; an alpine-3.16 today might not appear EOL until
132
132
  the file is refreshed. Bias is toward false negatives.
133
- - **Typosquat threshold is a single distance.** Levenshtein 2 against
134
- the popular-packages.json list (188 entries, not top-1000). Increasing the threshold blows up the FP rate;
135
- decreasing it loses real typosquats. This is the calibrated default.
133
+ - **Typosquat similarity is RELATIVE, and that is load-bearing.** The rule is
134
+ Damerau-Levenshtein 2 *and* `distance / min(len) 0.25` against
135
+ popular-packages.json (188 entries, not top-1000).
136
+
137
+ The absolute `Levenshtein ≤ 2` this used to be was measured by
138
+ `bench/sca-replay` over 13 real repositories and produced **166 findings at
139
+ critical/high, of which zero were typosquats** — `ms ~ ws`, `acorn ~ cors`,
140
+ `ajv ~ ava`, `six ~ tox`, `arg ~ yargs`, `bail ~ babel`. All short names: two
141
+ edits on a four-character name changes half of it, and every two-character
142
+ package is one edit from every other. The ratio gate is what removes them.
143
+
144
+ Damerau rather than plain Levenshtein because a TRANSPOSITION (`lodahs` for
145
+ `lodash`) is the most common real typo, and plain distance scores it 2 — the
146
+ same as two unrelated substitutions. Under the ratio gate that would have
147
+ thrown the genuine cases out along with the noise.
148
+
149
+ The FP budget is pinned in `test/dep-confusion.test.js` using the actual
150
+ names the bench surfaced. Widening the reference list is safe *because* of
151
+ the ratio gate; widening it under the old rule would have multiplied the
152
+ noise.
136
153
 
137
154
  ## Adding a new detector here
138
155