@lateos/npm-scan 0.18.3 → 1.1.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 (149) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +864 -826
  3. package/VALIDATION.md +92 -0
  4. package/backend/cra.js +113 -21
  5. package/backend/db/pg-schema.sql +155 -0
  6. package/backend/db.js +18 -10
  7. package/backend/detectors/atk-001-lifecycle.js +5 -5
  8. package/backend/detectors/atk-002-obfusc.js +126 -47
  9. package/backend/detectors/atk-003-creds.js +8 -4
  10. package/backend/detectors/atk-004-persist.js +3 -3
  11. package/backend/detectors/atk-005-exfil.js +8 -4
  12. package/backend/detectors/atk-006-depconf.js +3 -3
  13. package/backend/detectors/atk-007-typosquat.js +64 -10
  14. package/backend/detectors/atk-008-tarball-tamper.js +6 -6
  15. package/backend/detectors/atk-009-dormant-trigger.js +9 -5
  16. package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
  17. package/backend/detectors/atk-011-transitive-prop.js +14 -13
  18. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
  19. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
  20. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
  21. package/backend/detectors/axios-poisoning/index.js +77 -60
  22. package/backend/detectors/config/thresholds.js +111 -0
  23. package/backend/detectors/config/whitelist.json +74 -0
  24. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
  25. package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
  26. package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
  27. package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
  28. package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
  29. package/backend/detectors/hf-impersonation/index.js +94 -31
  30. package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
  31. package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
  32. package/backend/detectors/hf-impersonation/simhash.js +2 -2
  33. package/backend/detectors/index.js +184 -31
  34. package/backend/detectors/lib/ast-patterns.js +24 -0
  35. package/backend/detectors/lib/entropy-analyzer.js +32 -0
  36. package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
  37. package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
  38. package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
  39. package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
  40. package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
  41. package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
  42. package/backend/detectors/megalodon/index.js +35 -25
  43. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
  44. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
  45. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
  46. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
  47. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
  48. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
  49. package/backend/detectors/mini-shai-hulud/index.js +63 -26
  50. package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
  51. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
  52. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
  53. package/backend/detectors/msh-supplement/index.js +78 -63
  54. package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
  55. package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
  56. package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
  57. package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
  58. package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
  59. package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
  60. package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
  61. package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
  62. package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
  63. package/backend/detectors/node-ipc-compromise/index.js +21 -15
  64. package/backend/detectors/tier1-binary-embed.js +138 -41
  65. package/backend/detectors/tier1-cloud-imds.js +57 -37
  66. package/backend/detectors/tier1-encrypted-c2.js +198 -0
  67. package/backend/detectors/tier1-infostealer.js +121 -68
  68. package/backend/detectors/tier1-lifecycle-hook.js +63 -23
  69. package/backend/detectors/tier1-maintainer-compromise.js +157 -0
  70. package/backend/detectors/tier1-metadata-spoof.js +92 -42
  71. package/backend/detectors/tier1-multistage-postinstall.js +46 -19
  72. package/backend/detectors/tier1-obfuscation-heuristics.js +184 -0
  73. package/backend/detectors/tier1-self-propagation.js +115 -0
  74. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  75. package/backend/detectors/tier1-transitive-deps.js +182 -0
  76. package/backend/detectors/tier1-typosquat.js +129 -50
  77. package/backend/detectors/tier1-version-anomaly.js +223 -0
  78. package/backend/detectors/tier1-version-confusion.js +79 -59
  79. package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
  80. package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
  81. package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
  82. package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
  83. package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
  84. package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
  85. package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
  86. package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
  87. package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
  88. package/backend/detectors/trapdoor/index.js +19 -14
  89. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
  90. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
  91. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
  92. package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
  93. package/backend/detectors.test.js +147 -0
  94. package/backend/fetch.js +37 -29
  95. package/backend/index.js +1 -1
  96. package/backend/license.js +20 -4
  97. package/backend/lockfile.js +60 -36
  98. package/backend/pdf.js +107 -28
  99. package/backend/policy.js +183 -56
  100. package/backend/provenance.js +28 -3
  101. package/backend/report.js +136 -70
  102. package/backend/sbom.js +33 -27
  103. package/backend/scripts/analyze-false-positives.js +152 -0
  104. package/backend/scripts/analyze-validation.js +157 -0
  105. package/backend/scripts/detect-false-positives.js +103 -0
  106. package/backend/scripts/fetch-top-packages.js +277 -0
  107. package/backend/scripts/validate-d10-d13.js +103 -0
  108. package/backend/scripts/validate-detectors.js +151 -0
  109. package/backend/siem/cef.js +23 -21
  110. package/backend/siem/ecs.js +3 -3
  111. package/backend/siem/index.js +1 -1
  112. package/backend/siem/qradar.js +3 -3
  113. package/backend/siem/sentinel.js +2 -2
  114. package/backend/tests-d5-enhanced.test.js +47 -0
  115. package/backend/tests-d6-version-anomaly.test.js +67 -0
  116. package/backend/tests-d6.test.js +126 -0
  117. package/backend/tests-d6c.test.js +119 -0
  118. package/backend/tests-d7-obfuscation.test.js +88 -0
  119. package/backend/tests.test.js +997 -0
  120. package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
  121. package/backend/vsix-scan/detectors/burst-publish.js +14 -7
  122. package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
  123. package/backend/vsix-scan/detectors/known-ioc.js +23 -8
  124. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
  125. package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
  126. package/backend/vsix-scan/index.js +97 -41
  127. package/backend/vsix-scan/marketplace-client.js +29 -13
  128. package/cli/cli.js +154 -64
  129. package/package.json +36 -10
  130. package/.dockerignore +0 -20
  131. package/.husky/pre-commit +0 -1
  132. package/SECURITY.md +0 -73
  133. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  134. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  135. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  136. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  137. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  138. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  139. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  140. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  141. package/deploy/helm/npm-scan/values.yaml +0 -103
  142. package/scripts/download-corpus.js +0 -30
  143. package/scripts/gen-mal-corpus.js +0 -35
  144. package/scripts/generate-campaign-fixtures.js +0 -170
  145. package/src/config/top-5000.json +0 -87
  146. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  147. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  148. package/test/fixtures/lockfiles/yarn.lock +0 -104
  149. package/test/fixtures/mock-data.js +0 -69
@@ -3,38 +3,54 @@ import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
3
3
 
4
4
  const require = createRequire(import.meta.url);
5
5
  const TOP_PACKAGES = require('../../src/config/top-5000.json');
6
- const TOP_SET = new Set(TOP_PACKAGES);
6
+ const _TOP_SET = new Set(TOP_PACKAGES);
7
7
 
8
8
  function levenshtein(a, b) {
9
- if (Math.abs(a.length - b.length) > 2) return 3;
10
- const m = a.length, n = b.length;
11
- if (m === 0) return n;
12
- if (n === 0) return m;
9
+ if (Math.abs(a.length - b.length) > 2) {
10
+ return 3;
11
+ }
12
+ const m = a.length,
13
+ n = b.length;
14
+ if (m === 0) {
15
+ return n;
16
+ }
17
+ if (n === 0) {
18
+ return m;
19
+ }
13
20
  let prev = new Int32Array(n + 1);
14
21
  let curr = new Int32Array(n + 1);
15
- for (let j = 0; j <= n; j++) prev[j] = j;
22
+ for (let j = 0; j <= n; j++) {
23
+ prev[j] = j;
24
+ }
16
25
  for (let i = 1; i <= m; i++) {
17
26
  curr[0] = i;
18
27
  let rowMin = curr[0];
19
28
  for (let j = 1; j <= n; j++) {
20
29
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
21
- curr[j] = Math.min(
22
- prev[j] + 1,
23
- curr[j - 1] + 1,
24
- prev[j - 1] + cost
25
- );
26
- if (curr[j] < rowMin) rowMin = curr[j];
30
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
31
+ if (curr[j] < rowMin) {
32
+ rowMin = curr[j];
33
+ }
27
34
  }
28
- if (rowMin > 2) return 3;
29
- const tmp = prev; prev = curr; curr = tmp;
35
+ if (rowMin > 2) {
36
+ return 3;
37
+ }
38
+ const tmp = prev;
39
+ prev = curr;
40
+ curr = tmp;
30
41
  }
31
42
  return prev[n];
32
43
  }
33
44
 
34
45
  function jaroWinkler(a, b) {
35
- if (a === b) return 1;
36
- const m = a.length, n = b.length;
37
- if (m === 0 || n === 0) return 0;
46
+ if (a === b) {
47
+ return 1;
48
+ }
49
+ const m = a.length,
50
+ n = b.length;
51
+ if (m === 0 || n === 0) {
52
+ return 0;
53
+ }
38
54
  const matchDist = Math.floor(Math.max(m, n) / 2) - 1;
39
55
  const aMatch = new Array(m).fill(false);
40
56
  const bMatch = new Array(n).fill(false);
@@ -43,36 +59,53 @@ function jaroWinkler(a, b) {
43
59
  const start = Math.max(0, i - matchDist);
44
60
  const end = Math.min(n, i + matchDist + 1);
45
61
  for (let j = start; j < end; j++) {
46
- if (bMatch[j] || a[i] !== b[j]) continue;
62
+ if (bMatch[j] || a[i] !== b[j]) {
63
+ continue;
64
+ }
47
65
  aMatch[i] = true;
48
66
  bMatch[j] = true;
49
67
  matches++;
50
68
  break;
51
69
  }
52
70
  }
53
- if (matches === 0) return 0;
54
- let t = 0, k = 0;
71
+ if (matches === 0) {
72
+ return 0;
73
+ }
74
+ let t = 0,
75
+ k = 0;
55
76
  for (let i = 0; i < m; i++) {
56
- if (!aMatch[i]) continue;
57
- while (!bMatch[k]) k++;
58
- if (a[i] !== b[k]) t++;
77
+ if (!aMatch[i]) {
78
+ continue;
79
+ }
80
+ while (!bMatch[k]) {
81
+ k++;
82
+ }
83
+ if (a[i] !== b[k]) {
84
+ t++;
85
+ }
59
86
  k++;
60
87
  }
61
88
  const jaro = (matches / m + matches / n + (matches - t / 2) / matches) / 3;
62
89
  let prefix = 0;
63
90
  const limit = Math.min(4, m, n);
64
91
  for (let i = 0; i < limit; i++) {
65
- if (a[i] === b[i]) prefix++;
66
- else break;
92
+ if (a[i] === b[i]) {
93
+ prefix++;
94
+ } else {
95
+ break;
96
+ }
67
97
  }
68
98
  return jaro + prefix * 0.1 * (1 - jaro);
69
99
  }
70
100
 
71
- function soundex(s) {
72
- if (!s) return '';
101
+ function _soundex(s) {
102
+ if (!s) {
103
+ return '';
104
+ }
73
105
  s = s.toLowerCase();
74
106
  const first = s[0];
75
- const rest = s.slice(1)
107
+ const rest = s
108
+ .slice(1)
76
109
  .replace(/[aeiouyhw]/g, '')
77
110
  .replace(/[bfpv]/g, '1')
78
111
  .replace(/[cgjkqsxz]/g, '2')
@@ -85,15 +118,22 @@ function soundex(s) {
85
118
  }
86
119
 
87
120
  function homoglyphScore(a, b) {
88
- if (a.length !== b.length) return 0;
89
- const map = { '0': 'o', '1': 'l', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't', '8': 'b', '@': 'a' };
121
+ if (a.length !== b.length) {
122
+ return 0;
123
+ }
124
+ const map = { 0: 'o', 1: 'l', 3: 'e', 4: 'a', 5: 's', 6: 'g', 7: 't', 8: 'b', '@': 'a' };
90
125
  let swaps = 0;
91
126
  for (let i = 0; i < a.length; i++) {
92
- if (a[i] === b[i]) continue;
127
+ if (a[i] === b[i]) {
128
+ continue;
129
+ }
93
130
  const na = map[a[i]] || a[i];
94
131
  const nb = map[b[i]] || b[i];
95
- if (na === b[i] || a[i] === nb || na === nb) swaps++;
96
- else return 0;
132
+ if (na === b[i] || a[i] === nb || na === nb) {
133
+ swaps++;
134
+ } else {
135
+ return 0;
136
+ }
97
137
  }
98
138
  return swaps > 0 ? 1 - swaps / a.length : 0;
99
139
  }
@@ -119,34 +159,52 @@ function computeConfidence(dist, phonetic, homoglyph, registryMeta) {
119
159
  if (registryMeta) {
120
160
  const age = registryMeta.age || 0;
121
161
  const downloads = registryMeta.weeklyDownloads || 0;
122
- if (age < 30) score += 15;
123
- if (downloads < 1000) score += 10;
124
- if (age > 365 && downloads > 100000) score -= 30;
162
+ if (age < 30) {
163
+ score += 15;
164
+ }
165
+ if (downloads < 1000) {
166
+ score += 10;
167
+ }
168
+ if (age > 365 && downloads > 100000) {
169
+ score -= 30;
170
+ }
125
171
  }
126
172
  score = Math.max(50, Math.min(100, score));
127
173
  return { subtype, score };
128
174
  }
129
175
 
130
176
  function severityLabel(score) {
131
- if (score >= 80) return 'high';
132
- if (score >= 60) return 'medium';
177
+ if (score >= 80) {
178
+ return 'high';
179
+ }
180
+ if (score >= 60) {
181
+ return 'medium';
182
+ }
133
183
  return 'low';
134
184
  }
135
185
 
136
186
  function confidenceLabel(score) {
137
- if (score >= 80) return 'HIGH';
138
- if (score >= 60) return 'MEDIUM';
187
+ if (score >= 80) {
188
+ return 'HIGH';
189
+ }
190
+ if (score >= 60) {
191
+ return 'MEDIUM';
192
+ }
139
193
  return 'LOW';
140
194
  }
141
195
 
142
196
  export const name = 'tier1-typosquat';
143
197
 
144
- export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
198
+ export async function scan(pkgJson, jsFiles, registryMeta, _allFiles) {
145
199
  const findings = [];
146
200
  const pkgName = pkgJson?.name;
147
- if (!pkgName) return findings;
201
+ if (!pkgName) {
202
+ return findings;
203
+ }
148
204
 
149
- if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return findings;
205
+ if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
206
+ return findings;
207
+ }
150
208
 
151
209
  const namesToCheck = [];
152
210
  let scopedName = null;
@@ -167,27 +225,48 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
167
225
  for (const checkName of namesToCheck) {
168
226
  for (const target of TOP_PACKAGES) {
169
227
  if (checkName === target) {
170
- if (pkgName.startsWith('@') && checkName === scopedName && !KNOWN_REPUTABLE_PACKAGES.has(scopedName)) {
228
+ if (
229
+ pkgName.startsWith('@') &&
230
+ checkName === scopedName &&
231
+ !KNOWN_REPUTABLE_PACKAGES.has(scopedName)
232
+ ) {
171
233
  let score = 80;
172
234
  if (registryMeta) {
173
- if ((registryMeta.age || 0) < 30) score += 15;
174
- if ((registryMeta.weeklyDownloads || 0) < 1000) score += 10;
235
+ if ((registryMeta.age || 0) < 30) {
236
+ score += 15;
237
+ }
238
+ if ((registryMeta.weeklyDownloads || 0) < 1000) {
239
+ score += 10;
240
+ }
175
241
  }
176
242
  score = Math.max(50, Math.min(100, score));
177
243
  if (score > bestScore) {
178
244
  bestScore = score;
179
- best = { target, dist: 0, phonetic: 1, homoglyph: 0, subtype: 'edit_distance_1', isScopeSquat: true };
245
+ best = {
246
+ target,
247
+ dist: 0,
248
+ phonetic: 1,
249
+ homoglyph: 0,
250
+ subtype: 'edit_distance_1',
251
+ isScopeSquat: true,
252
+ };
180
253
  }
181
254
  }
182
255
  continue;
183
256
  }
184
- if (Math.abs(checkName.length - target.length) > 2) continue;
257
+ if (Math.abs(checkName.length - target.length) > 2) {
258
+ continue;
259
+ }
185
260
  const dist = levenshtein(checkName, target);
186
- if (dist > 2) continue;
261
+ if (dist > 2) {
262
+ continue;
263
+ }
187
264
  const phonetic = jaroWinkler(checkName, target);
188
265
  const homoglyph = homoglyphScore(checkName, target);
189
266
  const conf = computeConfidence(dist, phonetic, homoglyph, registryMeta);
190
- if (!conf || conf.score <= bestScore) continue;
267
+ if (!conf || conf.score <= bestScore) {
268
+ continue;
269
+ }
191
270
  bestScore = conf.score;
192
271
  best = { target, dist, phonetic, homoglyph, subtype: conf.subtype, isScopeSquat: false };
193
272
  }
@@ -0,0 +1,223 @@
1
+ import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
2
+
3
+ const SENTINEL_PATTERNS = new Set(['99.99.99', '11.11.11', '10.10.10']);
4
+
5
+ function parseVersion(v) {
6
+ if (!v || typeof v !== 'string') {
7
+ return null;
8
+ }
9
+ const parts = v.split('.');
10
+ if (parts.length !== 3) {
11
+ return null;
12
+ }
13
+ const [major, minor, patch] = parts.map(Number);
14
+ if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
15
+ return null;
16
+ }
17
+ return { major, minor, patch, full: v };
18
+ }
19
+
20
+ function versionScore(v) {
21
+ return v.major * 10000 + v.minor * 100 + v.patch;
22
+ }
23
+
24
+ function extractVersions(registryMeta) {
25
+ if (Array.isArray(registryMeta)) {
26
+ return registryMeta.map((v) => parseVersion(v)).filter(Boolean);
27
+ }
28
+ if (registryMeta && typeof registryMeta === 'object') {
29
+ const versions = registryMeta.versions || registryMeta.time;
30
+ if (versions && typeof versions === 'object') {
31
+ return Object.keys(versions)
32
+ .map((v) => parseVersion(v))
33
+ .filter(Boolean);
34
+ }
35
+ }
36
+ return [];
37
+ }
38
+
39
+ function computeStats(scores) {
40
+ if (scores.length < 2) {
41
+ return null;
42
+ }
43
+ const mean = scores.reduce((s, v) => s + v, 0) / scores.length;
44
+ const variance = scores.reduce((s, v) => s + (v - mean) ** 2, 0) / scores.length;
45
+ const stddev = Math.sqrt(variance);
46
+ return { mean, stddev, max: Math.max(...scores), min: Math.min(...scores) };
47
+ }
48
+
49
+ export function analyzeAnomaly(packageName, versionStr, versionHistory) {
50
+ const current = parseVersion(versionStr);
51
+ if (!current) {
52
+ return null;
53
+ }
54
+
55
+ const historical = extractVersions(versionHistory);
56
+ const currentScore = versionScore(current);
57
+ const isSentinel = SENTINEL_PATTERNS.has(versionStr);
58
+
59
+ if (!historical || historical.length < 2) {
60
+ if (isSentinel) {
61
+ return {
62
+ flagged: true,
63
+ confidenceScore: 60,
64
+ confidence: 'MEDIUM',
65
+ zScore: null,
66
+ baselineMax: 'unknown',
67
+ baselineMean: 'unknown',
68
+ reason: `Version ${versionStr} matches known dependency confusion pattern (no registry data to confirm)`,
69
+ attackPattern: 'SENTINEL_PATTERN_ONLY',
70
+ };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ const scores = historical.map(versionScore).sort((a, b) => a - b);
76
+ const recentScores = scores.slice(-50);
77
+ if (recentScores.length < 2) {
78
+ if (isSentinel) {
79
+ return {
80
+ flagged: true,
81
+ confidenceScore: 60,
82
+ confidence: 'MEDIUM',
83
+ zScore: null,
84
+ baselineMax: 'unknown',
85
+ baselineMean: 'unknown',
86
+ reason: `Version ${versionStr} matches known dependency confusion pattern (insufficient history)`,
87
+ attackPattern: 'SENTINEL_PATTERN_ONLY',
88
+ };
89
+ }
90
+ return null;
91
+ }
92
+
93
+ const stats = computeStats(recentScores);
94
+ if (!stats) {
95
+ return null;
96
+ }
97
+
98
+ const zScore = stats.stddev > 0 ? (currentScore - stats.mean) / stats.stddev : 0;
99
+ const baselineMaxVer = historical.find((v) => versionScore(v) === stats.max)?.full || 'unknown';
100
+ const baselineMeanVal = (stats.mean / 10000).toFixed(1);
101
+ const prevMaxMajor = Math.floor(stats.max / 10000);
102
+ const isNormalMajorBump =
103
+ current.major === prevMaxMajor + 1 && current.minor === 0 && current.patch === 0;
104
+ const isReasonableVersion =
105
+ current.major <= prevMaxMajor + 2 && current.major >= Math.floor(stats.min / 10000);
106
+ const ratio = stats.max > 0 ? currentScore / stats.max : 0;
107
+
108
+ let flagged = false;
109
+ let confidenceScore = 0;
110
+ let attackPattern = '';
111
+ let reason = '';
112
+
113
+ if (isSentinel) {
114
+ flagged = true;
115
+ confidenceScore = 92;
116
+ attackPattern = 'DEPENDENCY_CONFUSION_HIGH_VERSION';
117
+ reason = `Version ${versionStr} matches known dependency confusion sentinel pattern; z-score ${zScore.toFixed(1)} vs baseline mean ${baselineMeanVal}`;
118
+ } else if (zScore > 10 && !isNormalMajorBump) {
119
+ flagged = true;
120
+ confidenceScore = 90;
121
+ attackPattern = 'Z_SCORE_EXTREME';
122
+ reason = `Version ${versionStr} has z-score ${zScore.toFixed(1)} vs baseline mean ${baselineMeanVal} — extreme anomaly`;
123
+ } else if (zScore > 5 && !isNormalMajorBump) {
124
+ flagged = true;
125
+ confidenceScore = 85;
126
+ attackPattern = 'Z_SCORE_ANOMALY';
127
+ reason = `Version ${versionStr} has z-score ${zScore.toFixed(1)} vs baseline mean ${baselineMeanVal} — strong anomaly`;
128
+ } else if (zScore > 3 && !isNormalMajorBump) {
129
+ flagged = true;
130
+ confidenceScore = 72;
131
+ attackPattern = 'Z_SCORE_ELEVATED';
132
+ reason = `Version ${versionStr} has z-score ${zScore.toFixed(1)} vs baseline mean ${baselineMeanVal} — elevated anomaly`;
133
+ } else if (ratio > 10 && !isNormalMajorBump) {
134
+ flagged = true;
135
+ confidenceScore = 75;
136
+ attackPattern = 'MAJOR_VERSION_JUMP';
137
+ reason = `Version ${versionStr} exceeds max historical version (${baselineMaxVer}) by factor of ${ratio.toFixed(1)}`;
138
+ } else if (zScore > 2 && !isReasonableVersion) {
139
+ flagged = true;
140
+ confidenceScore = 55;
141
+ attackPattern = 'SUSPICIOUS_VERSION';
142
+ reason = `Version ${versionStr} has z-score ${zScore.toFixed(1)} and is outside expected version range`;
143
+ }
144
+
145
+ if (!flagged) {
146
+ return null;
147
+ }
148
+
149
+ return {
150
+ flagged,
151
+ confidenceScore: Math.min(100, confidenceScore),
152
+ confidence: confidenceScore >= 80 ? 'HIGH' : confidenceScore >= 60 ? 'MEDIUM' : 'LOW',
153
+ zScore: Math.round(zScore * 10) / 10,
154
+ baselineMax: baselineMaxVer,
155
+ baselineMean: baselineMeanVal,
156
+ reason,
157
+ attackPattern,
158
+ };
159
+ }
160
+
161
+ function severityLabel(sc) {
162
+ if (sc >= 90) {
163
+ return 'critical';
164
+ }
165
+ if (sc >= 70) {
166
+ return 'high';
167
+ }
168
+ if (sc >= 50) {
169
+ return 'medium';
170
+ }
171
+ return 'low';
172
+ }
173
+
174
+ function confidenceLabel(sc) {
175
+ if (sc >= 80) {
176
+ return 'HIGH';
177
+ }
178
+ if (sc >= 60) {
179
+ return 'MEDIUM';
180
+ }
181
+ return 'LOW';
182
+ }
183
+
184
+ export const name = 'tier1-version-anomaly';
185
+
186
+ export async function scan(pkgJson, jsFiles, registryMeta, _allFiles) {
187
+ const pkgName = pkgJson?.name;
188
+ const version = pkgJson?.version;
189
+
190
+ if (!pkgName || !version) {
191
+ return [];
192
+ }
193
+ if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
194
+ return [];
195
+ }
196
+
197
+ const result = analyzeAnomaly(pkgName, version, registryMeta);
198
+ if (!result) {
199
+ return [];
200
+ }
201
+
202
+ return [
203
+ {
204
+ detector: 'tier1-version-anomaly',
205
+ id: 'TIER1-VERSION-ANOMALY',
206
+ severity: severityLabel(result.confidenceScore),
207
+ confidence: confidenceLabel(result.confidenceScore),
208
+ confidenceScore: result.confidenceScore,
209
+ subtype: result.attackPattern.toLowerCase(),
210
+ message: `Version anomaly detected in "${pkgName}": ${result.reason}`,
211
+ evidence: [
212
+ `version: ${version}`,
213
+ `baseline_max: ${result.baselineMax}`,
214
+ `baseline_mean: ${result.baselineMean}`,
215
+ `z_score: ${result.zScore ?? 'N/A'}`,
216
+ `attack_pattern: ${result.attackPattern}`,
217
+ ],
218
+ crossFiles: [],
219
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
220
+ reference: '176-package dependency confusion campaign',
221
+ },
222
+ ];
223
+ }
@@ -4,23 +4,37 @@ const SENTINEL_EXACT = ['99.99.99'];
4
4
  const SENTINEL_FAMILY = ['9.9.9', '9.9.10', '10.10.10', '11.11.11'];
5
5
 
6
6
  function severityLabel(score) {
7
- if (score >= 80) return 'high';
8
- if (score >= 60) return 'medium';
7
+ if (score >= 80) {
8
+ return 'high';
9
+ }
10
+ if (score >= 60) {
11
+ return 'medium';
12
+ }
9
13
  return 'low';
10
14
  }
11
15
 
12
16
  function confidenceLabel(score) {
13
- if (score >= 80) return 'HIGH';
14
- if (score >= 60) return 'MEDIUM';
17
+ if (score >= 80) {
18
+ return 'HIGH';
19
+ }
20
+ if (score >= 60) {
21
+ return 'MEDIUM';
22
+ }
15
23
  return 'LOW';
16
24
  }
17
25
 
18
26
  function parseVersion(version) {
19
- if (!version || typeof version !== 'string') return null;
27
+ if (!version || typeof version !== 'string') {
28
+ return null;
29
+ }
20
30
  const parts = version.split('.');
21
- if (parts.length !== 3) return null;
31
+ if (parts.length !== 3) {
32
+ return null;
33
+ }
22
34
  const [major, minor, patch] = parts.map(Number);
23
- if (isNaN(major) || isNaN(minor) || isNaN(patch)) return null;
35
+ if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
36
+ return null;
37
+ }
24
38
  return { major, minor, patch };
25
39
  }
26
40
 
@@ -30,77 +44,83 @@ function matchesHeuristic(parsed) {
30
44
 
31
45
  export const name = 'tier1-version-confusion';
32
46
 
33
- export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
47
+ export async function scan(pkgJson, _jsFiles, _registryMeta, _allFiles) {
34
48
  const pkgName = pkgJson?.name;
35
49
  const version = pkgJson?.version;
36
50
 
37
- if (!pkgName || !version) return [];
38
- if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
51
+ if (!pkgName || !version) {
52
+ return [];
53
+ }
54
+ if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
55
+ return [];
56
+ }
39
57
 
40
58
  const parsed = parseVersion(version);
41
- if (!parsed) return [];
59
+ if (!parsed) {
60
+ return [];
61
+ }
42
62
 
43
63
  const vStr = version;
44
64
 
45
65
  // Priority: SENTINEL_EXACT > SENTINEL_FAMILY > HEURISTIC
46
66
  if (SENTINEL_EXACT.includes(vStr)) {
47
67
  const score = 85;
48
- return [{
49
- detector: 'tier1-version-confusion',
50
- id: 'TIER1-VERSION-CONFUSION',
51
- severity: severityLabel(score),
52
- confidence: confidenceLabel(score),
53
- confidenceScore: score,
54
- subtype: 'sentinel_exact',
55
- message: `Package "${pkgName}" uses exact sentinel version ${vStr} — dependency confusion indicator`,
56
- evidence: [
57
- `version: ${vStr}`,
58
- `sentinel: exact match`,
59
- ],
60
- crossFiles: [],
61
- locations: [{ file: 'package.json', line: 3, column: 10 }],
62
- reference: 'Sonatype-2026-003429',
63
- }];
68
+ return [
69
+ {
70
+ detector: 'tier1-version-confusion',
71
+ id: 'TIER1-VERSION-CONFUSION',
72
+ severity: severityLabel(score),
73
+ confidence: confidenceLabel(score),
74
+ confidenceScore: score,
75
+ subtype: 'sentinel_exact',
76
+ message: `Package "${pkgName}" uses exact sentinel version ${vStr} — dependency confusion indicator`,
77
+ evidence: [`version: ${vStr}`, `sentinel: exact match`],
78
+ crossFiles: [],
79
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
80
+ reference: 'Sonatype-2026-003429',
81
+ },
82
+ ];
64
83
  }
65
84
 
66
85
  if (SENTINEL_FAMILY.includes(vStr)) {
67
86
  const score = 65;
68
- return [{
69
- detector: 'tier1-version-confusion',
70
- id: 'TIER1-VERSION-CONFUSION',
71
- severity: severityLabel(score),
72
- confidence: confidenceLabel(score),
73
- confidenceScore: score,
74
- subtype: 'sentinel_family',
75
- message: `Package "${pkgName}" uses sentinel family version ${vStr} — dependency confusion indicator`,
76
- evidence: [
77
- `version: ${vStr}`,
78
- `sentinel: family match`,
79
- ],
80
- crossFiles: [],
81
- locations: [{ file: 'package.json', line: 3, column: 10 }],
82
- reference: 'Sonatype-2026-003429',
83
- }];
87
+ return [
88
+ {
89
+ detector: 'tier1-version-confusion',
90
+ id: 'TIER1-VERSION-CONFUSION',
91
+ severity: severityLabel(score),
92
+ confidence: confidenceLabel(score),
93
+ confidenceScore: score,
94
+ subtype: 'sentinel_family',
95
+ message: `Package "${pkgName}" uses sentinel family version ${vStr} — dependency confusion indicator`,
96
+ evidence: [`version: ${vStr}`, `sentinel: family match`],
97
+ crossFiles: [],
98
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
99
+ reference: 'Sonatype-2026-003429',
100
+ },
101
+ ];
84
102
  }
85
103
 
86
104
  if (matchesHeuristic(parsed)) {
87
105
  const score = 62;
88
- return [{
89
- detector: 'tier1-version-confusion',
90
- id: 'TIER1-VERSION-CONFUSION',
91
- severity: severityLabel(score),
92
- confidence: confidenceLabel(score),
93
- confidenceScore: score,
94
- subtype: 'high_version_heuristic',
95
- message: `Package "${pkgName}" version ${vStr} matches high-version heuristic — possible dependency confusion`,
96
- evidence: [
97
- `version: ${vStr}`,
98
- `major: ${parsed.major}, minor: ${parsed.minor}, patch: ${parsed.patch}`,
99
- ],
100
- crossFiles: [],
101
- locations: [{ file: 'package.json', line: 3, column: 10 }],
102
- reference: 'Microsoft Scope Confusion',
103
- }];
106
+ return [
107
+ {
108
+ detector: 'tier1-version-confusion',
109
+ id: 'TIER1-VERSION-CONFUSION',
110
+ severity: severityLabel(score),
111
+ confidence: confidenceLabel(score),
112
+ confidenceScore: score,
113
+ subtype: 'high_version_heuristic',
114
+ message: `Package "${pkgName}" version ${vStr} matches high-version heuristic — possible dependency confusion`,
115
+ evidence: [
116
+ `version: ${vStr}`,
117
+ `major: ${parsed.major}, minor: ${parsed.minor}, patch: ${parsed.patch}`,
118
+ ],
119
+ crossFiles: [],
120
+ locations: [{ file: 'package.json', line: 3, column: 10 }],
121
+ reference: 'Microsoft Scope Confusion',
122
+ },
123
+ ];
104
124
  }
105
125
 
106
126
  return [];