@clear-capabilities/agentic-security-scanner 0.139.1 → 0.141.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 +221 -0
  2. package/bin/agentic-security.js +40 -11
  3. package/dist/113.index.js +79 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/238.index.js +77 -1
  6. package/dist/384.index.js +1 -1
  7. package/dist/435.index.js +12 -0
  8. package/dist/526.index.js +79 -3
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +14 -14
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/compliance-frameworks/ccpa.json +34 -7
  13. package/dist/compliance-frameworks/eu-ai-act.json +65 -14
  14. package/dist/compliance-frameworks/gdpr.json +56 -12
  15. package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
  16. package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
  17. package/dist/compliance-frameworks/nist-csf-2.json +78 -16
  18. package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
  19. package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
  20. package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
  21. package/package.json +16 -5
  22. package/src/dataflow/catalog.js +61 -0
  23. package/src/engine.js +281 -23
  24. package/src/mcp/tools.js +12 -0
  25. package/src/posture/accuracy-scorecard.js +57 -0
  26. package/src/posture/aibom.js +110 -1
  27. package/src/posture/auditor-walkthrough.js +137 -21
  28. package/src/posture/compliance-frameworks/ccpa.json +34 -7
  29. package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
  30. package/src/posture/compliance-frameworks/gdpr.json +56 -12
  31. package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
  32. package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
  33. package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
  34. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
  35. package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
  36. package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
  37. package/src/posture/concurrency-checker.js +42 -5
  38. package/src/posture/coverage-strength.js +182 -0
  39. package/src/posture/epss.js +17 -1
  40. package/src/posture/family-registry.js +103 -0
  41. package/src/posture/family-resolve.js +47 -0
  42. package/src/posture/fix-coverage.js +113 -0
  43. package/src/posture/fix-metrics.js +76 -0
  44. package/src/posture/integrity.js +59 -8
  45. package/src/posture/mcp-rug-pull.js +144 -0
  46. package/src/posture/poc-generator.js +17 -1
  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 +50 -7
  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 +337 -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
@@ -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
 
@@ -31,7 +31,16 @@ const _DOCKERFILE_RE = /(?:^|\/)(?:[Dd]ockerfile|[^/]+\.dockerfile)$/i;
31
31
  const _FROM_RE = /^\s*FROM\s+(?:--platform=\S+\s+)?([\w./-]+?)(?::([\w.\-]+))?(?:@sha256:[a-f0-9]{64})?(?:\s+AS\s+\S+)?\s*$/im;
32
32
 
33
33
  // FROM <image>:<tag> covering all FROM lines in the file
34
- const _ALL_FROM_RE = /^\s*FROM\s+(?:--platform=\S+\s+)?([\w./-]+?)(?::([\w.\-]+))?(?:@sha256:[a-f0-9]{64})?(?:\s+AS\s+\S+)?\s*$/img;
34
+ // The digest is CAPTURED, not merely tolerated. Discarding it made
35
+ // `FROM ubuntu@sha256:…` parse as image=ubuntu with no tag, which `_scoreTag`
36
+ // then treats as `latest` — so the most tightly pinned form a Dockerfile can
37
+ // use was reported as "ubuntu:latest (floating tag)". A false positive on the
38
+ // hardened configuration is worse than a miss: it tells the people who did the
39
+ // right thing that they did the wrong one.
40
+ //
41
+ // Found by bench/iac-coverage, whose verdict-flip scoring exists precisely to
42
+ // catch a rule that fires on both variants of a control.
43
+ const _ALL_FROM_RE = /^\s*FROM\s+(?:--platform=\S+\s+)?([\w./-]+?)(?::([\w.\-]+))?(?:@sha256:([a-f0-9]{64}))?(?:\s+AS\s+\S+)?\s*$/img;
35
44
 
36
45
  // `apt-get install -y pkg pkg pkg` / `apk add pkg pkg`
37
46
  const _APT_INSTALL_RE = /\bapt(?:-get)?\s+install\b[^\n]*?(?:--?[\w-]+\s+)*((?:[a-z0-9][\w.+-]*(?:=[\w.+:-]+)?\s*)+)/gi;
@@ -66,9 +75,17 @@ export function scanContainer(fp, raw) {
66
75
  while ((m = _ALL_FROM_RE.exec(raw))) {
67
76
  const image = m[1].split('/').pop(); // strip registry / namespace prefixes
68
77
  const tag = m[2] || '';
78
+ const digest = m[3] || '';
69
79
  const line = raw.substring(0, m.index).split('\n').length;
80
+ // Digest-pinned with no tag: there is nothing to score. The reference is
81
+ // immutable, which is the recommended form, and inventing a `latest` tag
82
+ // for it produces the exact opposite advice.
83
+ if (digest && !tag) continue;
70
84
  const score = _scoreTag(image, tag);
71
85
  if (!score) continue;
86
+ // `image:22.04@sha256:…` — the tag can still be end-of-life, and that is
87
+ // worth saying, but it is not a FLOATING tag: the digest pins it.
88
+ if (digest && !score.eol) continue;
72
89
  findings.push({
73
90
  id: `container-base:${fp}:${line}:${image}:${tag || 'latest'}`,
74
91
  kind: 'container', severity: score.sev,
@@ -1,6 +1,10 @@
1
1
  // 0.9.0 Feat-15: Dependency confusion + typosquat detection (Levenshtein distance against top-1000 npm/PyPI packages).
2
2
  //
3
- // (a) Typosquat: Levenshtein distance 1–2 from a popular package.
3
+ // (a) Typosquat: a small Damerau-Levenshtein distance from a popular package,
4
+ // where "small" is judged RELATIVE to the name length — see SIMILARITY
5
+ // below for why the absolute 1–2 this originally used produced 166
6
+ // critical/high false positives and zero true positives across 13 real
7
+ // dependency trees.
4
8
  // (b) Confusion: internal-scoped names (`@your-org/...`) that also appear on the
5
9
  // public registry — declared via .agentic-security/internal-scopes.yml.
6
10
  //
@@ -50,6 +54,64 @@ export function levenshtein(a, b, maxDistance = 2) {
50
54
  return prev[b.length];
51
55
  }
52
56
 
57
+ // Damerau-Levenshtein (optimal string alignment): a TRANSPOSITION costs 1.
58
+ //
59
+ // Plain Levenshtein charges 2 for a transposition, which is exactly backwards
60
+ // for this problem — swapping two adjacent characters (`lodahs` for `lodash`,
61
+ // `electorn` for `electron`) is the single most common real typo, and it is the
62
+ // one plain distance scores as least similar. `levenshtein` above is kept
63
+ // unchanged and separately tested; this is a second measure for a different
64
+ // question.
65
+ export function damerauLevenshtein(a, b, maxDistance = 2) {
66
+ if (a === b) return 0;
67
+ if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
68
+ const m = a.length, n = b.length;
69
+ if (!m) return n;
70
+ if (!n) return m;
71
+ const d = Array.from({ length: m + 1 }, (_, i) => {
72
+ const row = new Array(n + 1).fill(0);
73
+ row[0] = i;
74
+ return row;
75
+ });
76
+ for (let j = 0; j <= n; j++) d[0][j] = j;
77
+ for (let i = 1; i <= m; i++) {
78
+ let rowMin = Infinity;
79
+ for (let j = 1; j <= n; j++) {
80
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
81
+ let v = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
82
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
83
+ v = Math.min(v, d[i - 2][j - 2] + 1); // transposition
84
+ }
85
+ d[i][j] = v;
86
+ if (v < rowMin) rowMin = v;
87
+ }
88
+ if (rowMin > maxDistance) return maxDistance + 1;
89
+ }
90
+ return d[m][n];
91
+ }
92
+
93
+ // How much of a name may differ before it is a DIFFERENT name rather than a
94
+ // misspelling of this one.
95
+ //
96
+ // Measured, not chosen by taste. At the previous "distance 1–2, any length"
97
+ // rule, bench/sca-replay produced these across 13 real repositories:
98
+ //
99
+ // ms ~ ws (d1) acorn ~ cors (d2) ajv ~ ava (d2) six ~ tox (d2)
100
+ // abab ~ ava (d2) arg ~ yargs (d2) bail ~ babel (d2) aws4 ~ ws (d2)
101
+ //
102
+ // Every one is a legitimate, popular package, and `ms` is a top-50 npm
103
+ // package. The common factor is length: two edits on a four-character name
104
+ // changes half of it. A quarter of the shorter name is the ceiling — it keeps
105
+ // every genuine shape (one transposition, one dropped char, one doubled char
106
+ // on a name of ordinary length) and rejects all of the above.
107
+ const MAX_DIVERGENCE = 0.25;
108
+
109
+ function _isTyposquatCandidate(name, popular, distance) {
110
+ if (distance <= 0) return false;
111
+ const shorter = Math.min(name.length, popular.length);
112
+ return distance / shorter <= MAX_DIVERGENCE;
113
+ }
114
+
53
115
  function _loadInternalScopes(scanRoot) {
54
116
  if (!scanRoot) return [];
55
117
  for (const name of ['internal-scopes.yml', 'internal-scopes.yaml']) {
@@ -84,10 +146,14 @@ export function detectDepConfusion(components, scanRoot) {
84
146
  const lowerName = c.name.toLowerCase();
85
147
  // (1) Typosquat — only run if the dep is NOT itself in the popular set
86
148
  if (!popularSet.has(lowerName)) {
149
+ // Compare the BARE name. `@scope/react` is not a typosquat of `react`;
150
+ // the scope is the thing that identifies the publisher.
151
+ const bare = lowerName.replace(/^@[^/]+\//, '');
87
152
  let bestMatch = null, bestDist = 3;
88
153
  for (const popular of popularSet) {
89
- const d = levenshtein(lowerName, popular, 2);
90
- if (d > 0 && d <= 2 && d < bestDist) { bestMatch = popular; bestDist = d; }
154
+ const d = damerauLevenshtein(bare, popular, 2);
155
+ if (!_isTyposquatCandidate(bare, popular, d)) continue;
156
+ if (d < bestDist) { bestMatch = popular; bestDist = d; }
91
157
  }
92
158
  if (bestMatch) {
93
159
  const id = `dep-confusion:${c.ecosystem}:${c.name}@${c.version}:typosquat`;