@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.
- package/CHANGELOG.md +32 -0
- package/README.md +864 -826
- package/VALIDATION.md +92 -0
- package/backend/cra.js +113 -21
- package/backend/db/pg-schema.sql +155 -0
- package/backend/db.js +18 -10
- package/backend/detectors/atk-001-lifecycle.js +5 -5
- package/backend/detectors/atk-002-obfusc.js +126 -47
- package/backend/detectors/atk-003-creds.js +8 -4
- package/backend/detectors/atk-004-persist.js +3 -3
- package/backend/detectors/atk-005-exfil.js +8 -4
- package/backend/detectors/atk-006-depconf.js +3 -3
- package/backend/detectors/atk-007-typosquat.js +64 -10
- package/backend/detectors/atk-008-tarball-tamper.js +6 -6
- package/backend/detectors/atk-009-dormant-trigger.js +9 -5
- package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
- package/backend/detectors/atk-011-transitive-prop.js +14 -13
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
- package/backend/detectors/axios-poisoning/index.js +77 -60
- package/backend/detectors/config/thresholds.js +111 -0
- package/backend/detectors/config/whitelist.json +74 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
- package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
- package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
- package/backend/detectors/hf-impersonation/index.js +94 -31
- package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
- package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
- package/backend/detectors/hf-impersonation/simhash.js +2 -2
- package/backend/detectors/index.js +184 -31
- package/backend/detectors/lib/ast-patterns.js +24 -0
- package/backend/detectors/lib/entropy-analyzer.js +32 -0
- package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
- package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
- package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
- package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
- package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
- package/backend/detectors/megalodon/index.js +35 -25
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
- package/backend/detectors/mini-shai-hulud/index.js +63 -26
- package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
- package/backend/detectors/msh-supplement/index.js +78 -63
- package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
- package/backend/detectors/node-ipc-compromise/index.js +21 -15
- package/backend/detectors/tier1-binary-embed.js +138 -41
- package/backend/detectors/tier1-cloud-imds.js +57 -37
- package/backend/detectors/tier1-encrypted-c2.js +198 -0
- package/backend/detectors/tier1-infostealer.js +121 -68
- package/backend/detectors/tier1-lifecycle-hook.js +63 -23
- package/backend/detectors/tier1-maintainer-compromise.js +157 -0
- package/backend/detectors/tier1-metadata-spoof.js +92 -42
- package/backend/detectors/tier1-multistage-postinstall.js +46 -19
- package/backend/detectors/tier1-obfuscation-heuristics.js +184 -0
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +223 -0
- package/backend/detectors/tier1-version-confusion.js +79 -59
- package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
- package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
- package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
- package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
- package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
- package/backend/detectors/trapdoor/index.js +19 -14
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
- package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
- package/backend/detectors.test.js +147 -0
- package/backend/fetch.js +37 -29
- package/backend/index.js +1 -1
- package/backend/license.js +20 -4
- package/backend/lockfile.js +60 -36
- package/backend/pdf.js +107 -28
- package/backend/policy.js +183 -56
- package/backend/provenance.js +28 -3
- package/backend/report.js +136 -70
- package/backend/sbom.js +33 -27
- package/backend/scripts/analyze-false-positives.js +152 -0
- package/backend/scripts/analyze-validation.js +157 -0
- package/backend/scripts/detect-false-positives.js +103 -0
- package/backend/scripts/fetch-top-packages.js +277 -0
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +151 -0
- package/backend/siem/cef.js +23 -21
- package/backend/siem/ecs.js +3 -3
- package/backend/siem/index.js +1 -1
- package/backend/siem/qradar.js +3 -3
- package/backend/siem/sentinel.js +2 -2
- package/backend/tests-d5-enhanced.test.js +47 -0
- package/backend/tests-d6-version-anomaly.test.js +67 -0
- package/backend/tests-d6.test.js +126 -0
- package/backend/tests-d6c.test.js +119 -0
- package/backend/tests-d7-obfuscation.test.js +88 -0
- package/backend/tests.test.js +997 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
- package/backend/vsix-scan/detectors/burst-publish.js +14 -7
- package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
- package/backend/vsix-scan/detectors/known-ioc.js +23 -8
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
- package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
- package/backend/vsix-scan/index.js +97 -41
- package/backend/vsix-scan/marketplace-client.js +29 -13
- package/cli/cli.js +154 -64
- package/package.json +36 -10
- package/.dockerignore +0 -20
- package/.husky/pre-commit +0 -1
- package/SECURITY.md +0 -73
- package/deploy/helm/npm-scan/Chart.yaml +0 -22
- package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
- package/deploy/helm/npm-scan/templates/api.yaml +0 -94
- package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
- package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
- package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
- package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
- package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
- package/deploy/helm/npm-scan/values.yaml +0 -103
- package/scripts/download-corpus.js +0 -30
- package/scripts/gen-mal-corpus.js +0 -35
- package/scripts/generate-campaign-fixtures.js +0 -170
- package/src/config/top-5000.json +0 -87
- package/test/fixtures/lockfiles/npm-lock.json +0 -69
- package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
- package/test/fixtures/lockfiles/yarn.lock +0 -104
- 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
|
|
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)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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++)
|
|
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
|
-
|
|
23
|
-
curr[j
|
|
24
|
-
|
|
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)
|
|
29
|
-
|
|
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)
|
|
36
|
-
|
|
37
|
-
|
|
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])
|
|
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)
|
|
54
|
-
|
|
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])
|
|
57
|
-
|
|
58
|
-
|
|
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])
|
|
66
|
-
|
|
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
|
|
72
|
-
if (!s)
|
|
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
|
|
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)
|
|
89
|
-
|
|
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])
|
|
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)
|
|
96
|
-
|
|
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)
|
|
123
|
-
|
|
124
|
-
|
|
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)
|
|
132
|
-
|
|
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)
|
|
138
|
-
|
|
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,
|
|
198
|
+
export async function scan(pkgJson, jsFiles, registryMeta, _allFiles) {
|
|
145
199
|
const findings = [];
|
|
146
200
|
const pkgName = pkgJson?.name;
|
|
147
|
-
if (!pkgName)
|
|
201
|
+
if (!pkgName) {
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
148
204
|
|
|
149
|
-
if (KNOWN_REPUTABLE_PACKAGES.has(pkgName))
|
|
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 (
|
|
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)
|
|
174
|
-
|
|
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 = {
|
|
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)
|
|
257
|
+
if (Math.abs(checkName.length - target.length) > 2) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
185
260
|
const dist = levenshtein(checkName, target);
|
|
186
|
-
if (dist > 2)
|
|
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)
|
|
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)
|
|
8
|
-
|
|
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)
|
|
14
|
-
|
|
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')
|
|
27
|
+
if (!version || typeof version !== 'string') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
20
30
|
const parts = version.split('.');
|
|
21
|
-
if (parts.length !== 3)
|
|
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))
|
|
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,
|
|
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)
|
|
38
|
-
|
|
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)
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`version: ${vStr}`,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
`version: ${vStr}`,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 [];
|