@lateos/npm-scan 1.0.0 → 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/README.md +864 -861
- package/backend/cra.js +113 -21
- 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 +48 -3
- 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 +181 -34
- package/backend/detectors/lib/ast-patterns.js +4 -1
- package/backend/detectors/lib/entropy-analyzer.js +12 -4
- 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 +109 -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 +45 -17
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +1 -1
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +77 -41
- 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 +78 -19
- 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 +14 -8
- package/backend/scripts/analyze-validation.js +27 -21
- package/backend/scripts/detect-false-positives.js +20 -10
- package/backend/scripts/fetch-top-packages.js +197 -49
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +26 -17
- 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 +13 -12
- package/backend/tests-d6-version-anomaly.test.js +17 -8
- package/backend/tests-d6.test.js +24 -14
- package/backend/tests-d6c.test.js +27 -14
- package/backend/tests-d7-obfuscation.test.js +9 -12
- package/backend/tests.test.js +182 -83
- 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 +12 -3
package/backend/tests.test.js
CHANGED
|
@@ -12,16 +12,18 @@ async function extractTarball(tarPath) {
|
|
|
12
12
|
execSync(`tar xzf "${tarPath}" -C "${tmpDir}"`, { stdio: 'pipe' });
|
|
13
13
|
const globPath = tmpDir.replace(/\\/g, '/') + '/**/package.json';
|
|
14
14
|
const pkgPath = globSync(globPath, { nodir: true })[0];
|
|
15
|
-
if (!pkgPath)
|
|
15
|
+
if (!pkgPath) {
|
|
16
|
+
throw new Error(`No package.json in ${tarPath}`);
|
|
17
|
+
}
|
|
16
18
|
const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
17
19
|
const pkgDir = path.join(pkgPath, '..');
|
|
18
20
|
const allGlobPath = pkgDir.replace(/\\/g, '/') + '/**/*';
|
|
19
|
-
const allFiles = globSync(allGlobPath, { nodir: true }).map(p => ({
|
|
21
|
+
const allFiles = globSync(allGlobPath, { nodir: true }).map((p) => ({
|
|
20
22
|
path: p,
|
|
21
23
|
name: p,
|
|
22
24
|
content: readFileSync(p, 'utf8'),
|
|
23
25
|
}));
|
|
24
|
-
const jsFiles = allFiles.filter(f => f.path.endsWith('.js'));
|
|
26
|
+
const jsFiles = allFiles.filter((f) => f.path.endsWith('.js'));
|
|
25
27
|
return { pkgJson, jsFiles, allFiles, registryMeta: {} };
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -32,8 +34,21 @@ const MOCK_SCANS = [
|
|
|
32
34
|
package_name: 'lodash',
|
|
33
35
|
version: '4.17.21',
|
|
34
36
|
findings: [
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
+
{
|
|
38
|
+
id: 'ATK-003',
|
|
39
|
+
atk_id: 'ATK-003',
|
|
40
|
+
severity: 'high',
|
|
41
|
+
title: 'Credential harvest',
|
|
42
|
+
description: 'Scrapes env vars',
|
|
43
|
+
evidence: 'process.env.NPM_TOKEN',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'ATK-009',
|
|
47
|
+
severity: 'medium',
|
|
48
|
+
title: 'Time trigger',
|
|
49
|
+
description: 'Conditional trigger (time-based)',
|
|
50
|
+
evidence: 'time-based trigger detected',
|
|
51
|
+
},
|
|
37
52
|
],
|
|
38
53
|
},
|
|
39
54
|
];
|
|
@@ -67,8 +82,8 @@ test('SIEM ECS output format', async () => {
|
|
|
67
82
|
assert.equal(e.observer.vendor, 'Lateos', 'ECS observer');
|
|
68
83
|
assert(['high', 'medium'].includes(e.log.level), 'ECS log level');
|
|
69
84
|
assert(e.vulnerability.enumeration === 'ATK', 'ECS enumeration');
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
assert(e['@timestamp'], 'ECS timestamp');
|
|
86
|
+
assert(['high', 'medium'].includes(e.log.level), 'ECS log level');
|
|
72
87
|
}
|
|
73
88
|
});
|
|
74
89
|
|
|
@@ -100,12 +115,19 @@ test('SIEM QRadar output format', async () => {
|
|
|
100
115
|
test('SIEM QRadar severity QID mapping', async () => {
|
|
101
116
|
const { generateQRadar } = await import('./siem/qradar.js');
|
|
102
117
|
const scans = [
|
|
103
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
{
|
|
119
|
+
package_name: 't',
|
|
120
|
+
version: '1',
|
|
121
|
+
findings: [
|
|
122
|
+
{ id: 'ATK-001', severity: 'critical', title: 'c' },
|
|
123
|
+
{ id: 'ATK-002', severity: 'low', title: 'l' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
107
126
|
];
|
|
108
|
-
const out = generateQRadar(scans)
|
|
127
|
+
const out = generateQRadar(scans)
|
|
128
|
+
.split('\n')
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((l) => JSON.parse(l));
|
|
109
131
|
assert.equal(out[0].qid, 90050001, 'critical QID');
|
|
110
132
|
assert.equal(out[1].qid, 90050004, 'low QID');
|
|
111
133
|
});
|
|
@@ -159,7 +181,13 @@ test('SBOM CycloneDX output', async () => {
|
|
|
159
181
|
const { generateSBOM } = await import('./sbom.js');
|
|
160
182
|
const pkg = { name: 'test-pkg', version: '1.0.0' };
|
|
161
183
|
const findings = [
|
|
162
|
-
{
|
|
184
|
+
{
|
|
185
|
+
id: 'ATK-001',
|
|
186
|
+
atk_id: 'ATK-001',
|
|
187
|
+
severity: 'high',
|
|
188
|
+
title: 'Lifecycle script',
|
|
189
|
+
description: 'preinstall hook',
|
|
190
|
+
},
|
|
163
191
|
];
|
|
164
192
|
const out = JSON.parse(generateSBOM(pkg, findings, 'json'));
|
|
165
193
|
assert.equal(out.bomFormat, 'CycloneDX');
|
|
@@ -173,7 +201,13 @@ test('SBOM SPDX output', async () => {
|
|
|
173
201
|
const { generateSBOM } = await import('./sbom.js');
|
|
174
202
|
const pkg = { name: 'spdx-pkg', version: '2.0.0' };
|
|
175
203
|
const findings = [
|
|
176
|
-
{
|
|
204
|
+
{
|
|
205
|
+
id: 'ATK-002',
|
|
206
|
+
atk_id: 'ATK-002',
|
|
207
|
+
severity: 'medium',
|
|
208
|
+
title: 'Obfuscation',
|
|
209
|
+
description: 'eval detected',
|
|
210
|
+
},
|
|
177
211
|
];
|
|
178
212
|
const out = JSON.parse(generateSBOM(pkg, findings, 'spdx'));
|
|
179
213
|
assert.equal(out.spdxVersion, 'SPDX-2.3');
|
|
@@ -262,8 +296,11 @@ test('license isFeatureEnabled returns true for valid community scan', async ()
|
|
|
262
296
|
const prev = process.env.NPM_SCAN_LICENSE_KEY;
|
|
263
297
|
process.env.NPM_SCAN_LICENSE_KEY = '';
|
|
264
298
|
const result = m.isFeatureEnabled('scan', '');
|
|
265
|
-
if (prev)
|
|
266
|
-
|
|
299
|
+
if (prev) {
|
|
300
|
+
process.env.NPM_SCAN_LICENSE_KEY = prev;
|
|
301
|
+
} else {
|
|
302
|
+
delete process.env.NPM_SCAN_LICENSE_KEY;
|
|
303
|
+
}
|
|
267
304
|
assert.equal(result, true);
|
|
268
305
|
});
|
|
269
306
|
|
|
@@ -273,8 +310,11 @@ test('license validateLicense community features via isFeatureEnabled', async ()
|
|
|
273
310
|
process.env.NPM_SCAN_LICENSE_KEY = '';
|
|
274
311
|
assert.equal(m.isFeatureEnabled('scan', ''), true);
|
|
275
312
|
assert.equal(m.isFeatureEnabled('nist-html', ''), true);
|
|
276
|
-
if (prev)
|
|
277
|
-
|
|
313
|
+
if (prev) {
|
|
314
|
+
process.env.NPM_SCAN_LICENSE_KEY = prev;
|
|
315
|
+
} else {
|
|
316
|
+
delete process.env.NPM_SCAN_LICENSE_KEY;
|
|
317
|
+
}
|
|
278
318
|
});
|
|
279
319
|
|
|
280
320
|
test('license isFeatureEnabled returns false for missing premium key', async () => {
|
|
@@ -282,7 +322,9 @@ test('license isFeatureEnabled returns false for missing premium key', async ()
|
|
|
282
322
|
const prev = process.env.NPM_SCAN_LICENSE_KEY;
|
|
283
323
|
delete process.env.NPM_SCAN_LICENSE_KEY;
|
|
284
324
|
assert.equal(m.isFeatureEnabled('siem', null), false);
|
|
285
|
-
if (prev)
|
|
325
|
+
if (prev) {
|
|
326
|
+
process.env.NPM_SCAN_LICENSE_KEY = prev;
|
|
327
|
+
}
|
|
286
328
|
});
|
|
287
329
|
|
|
288
330
|
test('license reject tampered key', async () => {
|
|
@@ -487,14 +529,18 @@ test('report with no findings shows clean', async () => {
|
|
|
487
529
|
test('NIST table maps all ATK-001 through ATK-011', async () => {
|
|
488
530
|
const { generateHTML } = await import('./report.js');
|
|
489
531
|
const allAtkScans = [
|
|
490
|
-
{
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
532
|
+
{
|
|
533
|
+
package_name: 'p',
|
|
534
|
+
version: '1',
|
|
535
|
+
findings: [
|
|
536
|
+
...Array.from({ length: 11 }, (_, i) => ({
|
|
537
|
+
id: `ATK-${String(i + 1).padStart(3, '0')}`,
|
|
538
|
+
atk_id: `ATK-${String(i + 1).padStart(3, '0')}`,
|
|
539
|
+
severity: 'medium',
|
|
540
|
+
title: `ATK-${i + 1}`,
|
|
541
|
+
})),
|
|
542
|
+
],
|
|
543
|
+
},
|
|
498
544
|
];
|
|
499
545
|
const html = generateHTML(allAtkScans);
|
|
500
546
|
for (let i = 1; i <= 11; i++) {
|
|
@@ -537,7 +583,12 @@ test('policy loadPolicy loads JSON', async () => {
|
|
|
537
583
|
|
|
538
584
|
test('policy isAllowed matches package name', async () => {
|
|
539
585
|
const { isAllowed } = await import('./policy.js');
|
|
540
|
-
const policy = {
|
|
586
|
+
const policy = {
|
|
587
|
+
allow: { packages: ['lodash', 'chalk@5.0.0'] },
|
|
588
|
+
severity_overrides: {},
|
|
589
|
+
fail_on: 'none',
|
|
590
|
+
suppress: [],
|
|
591
|
+
};
|
|
541
592
|
assert.equal(isAllowed('lodash', policy), true);
|
|
542
593
|
assert.equal(isAllowed('lodash@4.17.21', policy), true);
|
|
543
594
|
assert.equal(isAllowed('chalk@5.0.0', policy), true);
|
|
@@ -556,7 +607,12 @@ test('policy applyPolicy suppresses findings by atk_id', async () => {
|
|
|
556
607
|
{ id: 'ATK-003', atk_id: 'ATK-003', severity: 'high', title: 'Creds' },
|
|
557
608
|
{ id: 'ATK-009', atk_id: 'ATK-009', severity: 'medium', title: 'Trigger' },
|
|
558
609
|
];
|
|
559
|
-
const policy = {
|
|
610
|
+
const policy = {
|
|
611
|
+
allow: { packages: [] },
|
|
612
|
+
severity_overrides: {},
|
|
613
|
+
fail_on: 'none',
|
|
614
|
+
suppress: [{ atk_id: 'ATK-003', package: '*', reason: 'FP' }],
|
|
615
|
+
};
|
|
560
616
|
const { findings: filtered } = applyPolicy(findings, 'lodash', policy);
|
|
561
617
|
assert.equal(filtered.length, 1);
|
|
562
618
|
assert.equal(filtered[0].id, 'ATK-009');
|
|
@@ -564,30 +620,39 @@ test('policy applyPolicy suppresses findings by atk_id', async () => {
|
|
|
564
620
|
|
|
565
621
|
test('policy applyPolicy suppresses findings by package name', async () => {
|
|
566
622
|
const { applyPolicy } = await import('./policy.js');
|
|
567
|
-
const findings = [
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
623
|
+
const findings = [{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'low', title: 'Lifecycle' }];
|
|
624
|
+
const policy = {
|
|
625
|
+
allow: { packages: [] },
|
|
626
|
+
severity_overrides: {},
|
|
627
|
+
fail_on: 'none',
|
|
628
|
+
suppress: [{ atk_id: 'ATK-001', package: 'lodash', reason: 'Fixture' }],
|
|
629
|
+
};
|
|
571
630
|
const { findings: filtered } = applyPolicy(findings, 'lodash', policy);
|
|
572
631
|
assert.equal(filtered.length, 0);
|
|
573
632
|
});
|
|
574
633
|
|
|
575
634
|
test('policy applyPolicy preserves findings when suppress rule targets different package', async () => {
|
|
576
635
|
const { applyPolicy } = await import('./policy.js');
|
|
577
|
-
const findings = [
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
636
|
+
const findings = [{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'low', title: 'Lifecycle' }];
|
|
637
|
+
const policy = {
|
|
638
|
+
allow: { packages: [] },
|
|
639
|
+
severity_overrides: {},
|
|
640
|
+
fail_on: 'none',
|
|
641
|
+
suppress: [{ atk_id: 'ATK-001', package: 'express', reason: 'Fixture' }],
|
|
642
|
+
};
|
|
581
643
|
const { findings: filtered } = applyPolicy(findings, 'lodash', policy);
|
|
582
644
|
assert.equal(filtered.length, 1);
|
|
583
645
|
});
|
|
584
646
|
|
|
585
647
|
test('policy applyPolicy overrides severity', async () => {
|
|
586
648
|
const { applyPolicy } = await import('./policy.js');
|
|
587
|
-
const findings = [
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
649
|
+
const findings = [{ id: 'ATK-003', atk_id: 'ATK-003', severity: 'high', title: 'Creds' }];
|
|
650
|
+
const policy = {
|
|
651
|
+
allow: { packages: [] },
|
|
652
|
+
severity_overrides: { 'ATK-003': 'low' },
|
|
653
|
+
fail_on: 'none',
|
|
654
|
+
suppress: [],
|
|
655
|
+
};
|
|
591
656
|
const { findings: filtered } = applyPolicy(findings, 'lodash', policy);
|
|
592
657
|
assert.equal(filtered[0].severity, 'low');
|
|
593
658
|
assert.equal(filtered[0]._severityOverridden, true);
|
|
@@ -595,22 +660,23 @@ test('policy applyPolicy overrides severity', async () => {
|
|
|
595
660
|
|
|
596
661
|
test('policy checkFailOn blocks at threshold', async () => {
|
|
597
662
|
const { applyPolicy } = await import('./policy.js');
|
|
598
|
-
const findings = [
|
|
599
|
-
{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'medium', title: 'Test' },
|
|
600
|
-
];
|
|
663
|
+
const findings = [{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'medium', title: 'Test' }];
|
|
601
664
|
const policy = { allow: { packages: [] }, severity_overrides: {}, fail_on: 'high', suppress: [] };
|
|
602
665
|
const { blocked } = applyPolicy(findings, 'test', policy);
|
|
603
666
|
assert.equal(blocked, false);
|
|
604
|
-
const policyLow = {
|
|
667
|
+
const policyLow = {
|
|
668
|
+
allow: { packages: [] },
|
|
669
|
+
severity_overrides: {},
|
|
670
|
+
fail_on: 'medium',
|
|
671
|
+
suppress: [],
|
|
672
|
+
};
|
|
605
673
|
const { blocked: b2 } = applyPolicy(findings, 'test', policyLow);
|
|
606
674
|
assert.equal(b2, true);
|
|
607
675
|
});
|
|
608
676
|
|
|
609
677
|
test('policy checkFailOn none never blocks', async () => {
|
|
610
678
|
const { applyPolicy } = await import('./policy.js');
|
|
611
|
-
const findings = [
|
|
612
|
-
{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'critical', title: 'Critical' },
|
|
613
|
-
];
|
|
679
|
+
const findings = [{ id: 'ATK-001', atk_id: 'ATK-001', severity: 'critical', title: 'Critical' }];
|
|
614
680
|
const policy = { allow: { packages: [] }, severity_overrides: {}, fail_on: 'none', suppress: [] };
|
|
615
681
|
const { blocked } = applyPolicy(findings, 'test', policy);
|
|
616
682
|
assert.equal(blocked, false);
|
|
@@ -696,14 +762,18 @@ test('text report clean package', async () => {
|
|
|
696
762
|
|
|
697
763
|
test('text report severity counts', async () => {
|
|
698
764
|
const { generateText } = await import('./report.js');
|
|
699
|
-
const scans = [
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
765
|
+
const scans = [
|
|
766
|
+
{
|
|
767
|
+
package_name: 'multi-sev',
|
|
768
|
+
version: '1.0.0',
|
|
769
|
+
findings: [
|
|
770
|
+
{ id: 'ATK-001', severity: 'critical', title: 'C' },
|
|
771
|
+
{ id: 'ATK-002', severity: 'high', title: 'H' },
|
|
772
|
+
{ id: 'ATK-003', severity: 'medium', title: 'M' },
|
|
773
|
+
{ id: 'ATK-004', severity: 'low', title: 'L' },
|
|
774
|
+
],
|
|
775
|
+
},
|
|
776
|
+
];
|
|
707
777
|
const out = generateText(scans);
|
|
708
778
|
assert(out.includes('critical: 1'), 'critical count');
|
|
709
779
|
assert(out.includes('high: 1'), 'high count');
|
|
@@ -751,19 +821,28 @@ test('PDF report with no findings still valid', async () => {
|
|
|
751
821
|
describe('Tier 1 Detectors: Campaign Detection', () => {
|
|
752
822
|
test('Campaign 1: 95%+ of 33 dependency confusion packages detected', async () => {
|
|
753
823
|
const { runAll } = await import('./detectors/index.js');
|
|
754
|
-
const campaignTarballs = fs
|
|
755
|
-
.
|
|
824
|
+
const campaignTarballs = fs
|
|
825
|
+
.readdirSync('tests/corpus/malicious/')
|
|
826
|
+
.filter((f) => f.startsWith('campaign-1-'))
|
|
756
827
|
.slice(0, 33);
|
|
757
828
|
|
|
758
829
|
let detected = 0;
|
|
759
830
|
for (const tarball of campaignTarballs) {
|
|
760
|
-
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
831
|
+
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
832
|
+
`tests/corpus/malicious/${tarball}`
|
|
833
|
+
);
|
|
761
834
|
const result = await runAll(pkgJson, jsFiles, registryMeta, allFiles);
|
|
762
835
|
|
|
763
|
-
const lifecycleHookFinding = result.find(
|
|
764
|
-
|
|
836
|
+
const lifecycleHookFinding = result.find(
|
|
837
|
+
(f) => f.detector === 'tier1-lifecycle-hook' && f.confidenceScore >= 80
|
|
838
|
+
);
|
|
839
|
+
const metadataSpoofFinding = result.find(
|
|
840
|
+
(f) => f.detector === 'tier1-metadata-spoof' && f.confidenceScore >= 70
|
|
841
|
+
);
|
|
765
842
|
|
|
766
|
-
if (lifecycleHookFinding || metadataSpoofFinding)
|
|
843
|
+
if (lifecycleHookFinding || metadataSpoofFinding) {
|
|
844
|
+
detected++;
|
|
845
|
+
}
|
|
767
846
|
}
|
|
768
847
|
|
|
769
848
|
assert(detected / campaignTarballs.length >= 0.95);
|
|
@@ -771,20 +850,31 @@ describe('Tier 1 Detectors: Campaign Detection', () => {
|
|
|
771
850
|
|
|
772
851
|
test('Campaign 2: 85%+ of 14 typosquatting packages detected', async () => {
|
|
773
852
|
const { runAll } = await import('./detectors/index.js');
|
|
774
|
-
const campaignTarballs = fs
|
|
775
|
-
.
|
|
853
|
+
const campaignTarballs = fs
|
|
854
|
+
.readdirSync('tests/corpus/malicious/')
|
|
855
|
+
.filter((f) => f.startsWith('campaign-2-'))
|
|
776
856
|
.slice(0, 14);
|
|
777
857
|
|
|
778
858
|
let detected = 0;
|
|
779
859
|
for (const tarball of campaignTarballs) {
|
|
780
|
-
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
860
|
+
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
861
|
+
`tests/corpus/malicious/${tarball}`
|
|
862
|
+
);
|
|
781
863
|
const result = await runAll(pkgJson, jsFiles, registryMeta, allFiles);
|
|
782
864
|
|
|
783
|
-
const typosquatFinding = result.find(
|
|
784
|
-
|
|
785
|
-
|
|
865
|
+
const typosquatFinding = result.find(
|
|
866
|
+
(f) => f.detector === 'tier1-typosquat' && f.confidenceScore >= 70
|
|
867
|
+
);
|
|
868
|
+
const infostealerFinding = result.find(
|
|
869
|
+
(f) => f.detector === 'tier1-infostealer' && f.confidenceScore >= 75
|
|
870
|
+
);
|
|
871
|
+
const binaryFinding = result.find(
|
|
872
|
+
(f) => f.detector === 'tier1-binary-embed' && f.confidenceScore >= 70
|
|
873
|
+
);
|
|
786
874
|
|
|
787
|
-
if (typosquatFinding || infostealerFinding || binaryFinding)
|
|
875
|
+
if (typosquatFinding || infostealerFinding || binaryFinding) {
|
|
876
|
+
detected++;
|
|
877
|
+
}
|
|
788
878
|
}
|
|
789
879
|
|
|
790
880
|
assert(detected / campaignTarballs.length >= 0.85);
|
|
@@ -796,7 +886,7 @@ describe('Tier 1 Detectors: Campaign Detection', () => {
|
|
|
796
886
|
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(tarball);
|
|
797
887
|
const result = await runAll(pkgJson, jsFiles, registryMeta, allFiles);
|
|
798
888
|
|
|
799
|
-
const infostealerFinding = result.find(f => f.detector === 'tier1-infostealer');
|
|
889
|
+
const infostealerFinding = result.find((f) => f.detector === 'tier1-infostealer');
|
|
800
890
|
assert(infostealerFinding !== undefined);
|
|
801
891
|
assert(infostealerFinding.confidenceScore >= 95);
|
|
802
892
|
});
|
|
@@ -807,21 +897,28 @@ describe('Tier 1 Detectors: Campaign Detection', () => {
|
|
|
807
897
|
describe('Tier 1 Detectors: False Positive Regression', () => {
|
|
808
898
|
test('FP rate <5% on clean corpus (high/critical only)', async () => {
|
|
809
899
|
const { runAll } = await import('./detectors/index.js');
|
|
810
|
-
const cleanTarballs = fs
|
|
811
|
-
.
|
|
900
|
+
const cleanTarballs = fs
|
|
901
|
+
.readdirSync('tests/corpus/clean/')
|
|
902
|
+
.filter((f) => f.endsWith('.tgz'))
|
|
812
903
|
.slice(0, 1000);
|
|
813
904
|
|
|
814
905
|
let fpCount = 0;
|
|
815
906
|
for (const tarball of cleanTarballs) {
|
|
816
|
-
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
907
|
+
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(
|
|
908
|
+
`tests/corpus/clean/${tarball}`
|
|
909
|
+
);
|
|
817
910
|
const result = await runAll(pkgJson, jsFiles, registryMeta, allFiles);
|
|
818
911
|
|
|
819
|
-
const tier1HighSeverity = result.filter(
|
|
820
|
-
f
|
|
821
|
-
|
|
912
|
+
const tier1HighSeverity = result.filter(
|
|
913
|
+
(f) =>
|
|
914
|
+
f.detector &&
|
|
915
|
+
f.detector.startsWith('tier1-') &&
|
|
916
|
+
(f.severity === 'high' || f.severity === 'critical')
|
|
822
917
|
);
|
|
823
918
|
|
|
824
|
-
if (tier1HighSeverity.length > 0)
|
|
919
|
+
if (tier1HighSeverity.length > 0) {
|
|
920
|
+
fpCount++;
|
|
921
|
+
}
|
|
825
922
|
}
|
|
826
923
|
|
|
827
924
|
const fpRate = fpCount / (cleanTarballs.length || 1);
|
|
@@ -837,9 +934,11 @@ describe('Tier 1 Detectors: False Positive Regression', () => {
|
|
|
837
934
|
const { pkgJson, jsFiles, allFiles, registryMeta } = await extractTarball(tarball);
|
|
838
935
|
const result = await runAll(pkgJson, jsFiles, registryMeta, allFiles);
|
|
839
936
|
|
|
840
|
-
const tier1HighSeverity = result.filter(
|
|
841
|
-
f
|
|
842
|
-
|
|
937
|
+
const tier1HighSeverity = result.filter(
|
|
938
|
+
(f) =>
|
|
939
|
+
f.detector &&
|
|
940
|
+
f.detector.startsWith('tier1-') &&
|
|
941
|
+
(f.severity === 'high' || f.severity === 'critical')
|
|
843
942
|
);
|
|
844
943
|
|
|
845
944
|
assert.equal(tier1HighSeverity.length, 0);
|
|
@@ -860,7 +959,7 @@ describe('Tier 1 Detectors: Confidence Scoring', () => {
|
|
|
860
959
|
const registryMeta = { age: 5, weeklyDownloads: 100 };
|
|
861
960
|
|
|
862
961
|
const result = await runAll(pkgJson, jsFiles, registryMeta, []);
|
|
863
|
-
const typosquatFinding = result.find(f => f.detector === 'tier1-typosquat');
|
|
962
|
+
const typosquatFinding = result.find((f) => f.detector === 'tier1-typosquat');
|
|
864
963
|
|
|
865
964
|
assert(typosquatFinding !== undefined);
|
|
866
965
|
assert(typosquatFinding.confidenceScore >= 90);
|
|
@@ -876,7 +975,7 @@ describe('Tier 1 Detectors: Confidence Scoring', () => {
|
|
|
876
975
|
const pkgJson = {};
|
|
877
976
|
|
|
878
977
|
const result = await runAll(pkgJson, jsFiles, {}, jsFiles);
|
|
879
|
-
const infostealerFinding = result.find(f => f.detector === 'tier1-infostealer');
|
|
978
|
+
const infostealerFinding = result.find((f) => f.detector === 'tier1-infostealer');
|
|
880
979
|
|
|
881
980
|
assert(infostealerFinding !== undefined);
|
|
882
981
|
assert(infostealerFinding.confidenceScore >= 80);
|
|
@@ -891,7 +990,7 @@ describe('Tier 1 Detectors: Confidence Scoring', () => {
|
|
|
891
990
|
}));
|
|
892
991
|
|
|
893
992
|
const result = await runAll({}, jsFiles, jsFiles, []);
|
|
894
|
-
const lifecycleHookFindings = result.filter(f => f.detector === 'tier1-lifecycle-hook');
|
|
993
|
+
const lifecycleHookFindings = result.filter((f) => f.detector === 'tier1-lifecycle-hook');
|
|
895
994
|
|
|
896
995
|
assert.equal(lifecycleHookFindings.length, 0);
|
|
897
996
|
});
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
const ACTIVATION_RISK_MATRIX = {
|
|
2
2
|
'*': { base: 'critical', label: 'Wildcard (all files)' },
|
|
3
|
-
|
|
3
|
+
onStartupFinished: { base: 'high', label: 'Startup finished' },
|
|
4
4
|
'workspaceContains:**/*': { base: 'high', label: 'Workspace contains wildcard' },
|
|
5
|
-
|
|
5
|
+
workspaceContains: { base: 'high', label: 'Workspace contains' },
|
|
6
6
|
'onCommand:*': { base: 'low', label: 'Any command' },
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
const DEFAULT_BASE_RISK = 'medium';
|
|
10
10
|
|
|
11
11
|
const ESCALATION_KEYWORDS = [
|
|
12
|
-
'npx',
|
|
13
|
-
'
|
|
14
|
-
'
|
|
12
|
+
'npx',
|
|
13
|
+
'bun',
|
|
14
|
+
'curl',
|
|
15
|
+
'wget',
|
|
16
|
+
'fetch(',
|
|
17
|
+
'exec(',
|
|
18
|
+
'spawn(',
|
|
19
|
+
'execSync',
|
|
20
|
+
'spawnSync',
|
|
21
|
+
'child_process',
|
|
22
|
+
'shell: true',
|
|
23
|
+
'detached: true',
|
|
15
24
|
];
|
|
16
25
|
|
|
17
26
|
const BUNDLED_BUN_PATTERN = /bun|runtime/;
|
|
@@ -20,7 +29,11 @@ const SIZE_DELTA_THRESHOLD = 400 * 1024;
|
|
|
20
29
|
|
|
21
30
|
const SHELL_CMDS = ['npx', 'bun', 'curl', 'wget', 'exec', 'spawn', 'execSync'];
|
|
22
31
|
|
|
23
|
-
export async function checkActivationEventRisk(
|
|
32
|
+
export async function checkActivationEventRisk(
|
|
33
|
+
extensionManifest,
|
|
34
|
+
versionHistory = [],
|
|
35
|
+
priorVersions = []
|
|
36
|
+
) {
|
|
24
37
|
const signals = [];
|
|
25
38
|
|
|
26
39
|
const activationEvents = extensionManifest?.activationEvents || [];
|
|
@@ -32,7 +45,7 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
32
45
|
const riskLabels = ['none', 'low', 'medium', 'high', 'critical'];
|
|
33
46
|
const riskValues = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
34
47
|
|
|
35
|
-
let
|
|
48
|
+
let _worstEvent = null;
|
|
36
49
|
const why = [];
|
|
37
50
|
|
|
38
51
|
for (const event of activationEvents) {
|
|
@@ -41,29 +54,31 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
41
54
|
const baseIdx = riskValues[risk.base] || riskValues[DEFAULT_BASE_RISK];
|
|
42
55
|
if (baseIdx > maxBaseRisk) {
|
|
43
56
|
maxBaseRisk = baseIdx;
|
|
44
|
-
|
|
57
|
+
_worstEvent = event;
|
|
45
58
|
}
|
|
46
59
|
} else if (event.includes('*') && event !== 'onCommand:*') {
|
|
47
60
|
const baseIdx = riskValues['high'];
|
|
48
61
|
if (baseIdx > maxBaseRisk) {
|
|
49
62
|
maxBaseRisk = baseIdx;
|
|
50
|
-
|
|
63
|
+
_worstEvent = event;
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
const contributes = extensionManifest?.contributes || {};
|
|
56
69
|
const commands = contributes?.commands || [];
|
|
57
|
-
const cmdTitles = commands.map(c => (c.title || '').toLowerCase()).join(' ');
|
|
70
|
+
const cmdTitles = commands.map((c) => (c.title || '').toLowerCase()).join(' ');
|
|
58
71
|
|
|
59
72
|
const bundledDeps = extensionManifest?.bundledDependencies || [];
|
|
60
73
|
const bundledStr = Array.isArray(bundledDeps) ? bundledDeps.join(' ') : '';
|
|
61
74
|
|
|
62
|
-
const hasShellKeyword = SHELL_CMDS.some(cmd => cmdTitles.includes(cmd));
|
|
75
|
+
const hasShellKeyword = SHELL_CMDS.some((cmd) => cmdTitles.includes(cmd));
|
|
63
76
|
const hasBunBundled = BUNDLED_BUN_PATTERN.test(bundledStr);
|
|
64
77
|
|
|
65
78
|
const activationEventsStr = activationEvents.join(' ');
|
|
66
|
-
const hasShellInActivationContext = ESCALATION_KEYWORDS.some(kw =>
|
|
79
|
+
const hasShellInActivationContext = ESCALATION_KEYWORDS.some((kw) =>
|
|
80
|
+
activationEventsStr.toLowerCase().includes(kw.toLowerCase())
|
|
81
|
+
);
|
|
67
82
|
|
|
68
83
|
let escalateToCritical = false;
|
|
69
84
|
|
|
@@ -74,22 +89,22 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
74
89
|
|
|
75
90
|
if (versionHistory.length >= 2) {
|
|
76
91
|
const sizes = versionHistory
|
|
77
|
-
.filter(v => v.assetSize)
|
|
78
|
-
.map(v => v.assetSize)
|
|
92
|
+
.filter((v) => v.assetSize)
|
|
93
|
+
.map((v) => v.assetSize)
|
|
79
94
|
.sort((a, b) => b - a);
|
|
80
95
|
|
|
81
|
-
if (sizes.length >= 2 &&
|
|
96
|
+
if (sizes.length >= 2 && sizes[0] - sizes[sizes.length - 1] > SIZE_DELTA_THRESHOLD) {
|
|
82
97
|
escalateToCritical = true;
|
|
83
98
|
why.push(`HIGH activation event + version size delta > ${SIZE_DELTA_THRESHOLD} bytes`);
|
|
84
99
|
}
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
const priorActivationEvents = priorVersions
|
|
88
|
-
.filter(v => v.activationEvents)
|
|
89
|
-
.flatMap(v => v.activationEvents);
|
|
103
|
+
.filter((v) => v.activationEvents)
|
|
104
|
+
.flatMap((v) => v.activationEvents);
|
|
90
105
|
|
|
91
106
|
if (priorActivationEvents.length > 0) {
|
|
92
|
-
const newEvents = activationEvents.filter(e => !priorActivationEvents.includes(e));
|
|
107
|
+
const newEvents = activationEvents.filter((e) => !priorActivationEvents.includes(e));
|
|
93
108
|
if (newEvents.length > 0) {
|
|
94
109
|
why.push(`First-time activation event(s) added: ${newEvents.join(', ')}`);
|
|
95
110
|
if (!escalateToCritical && maxBaseRisk >= riskValues['high']) {
|
|
@@ -103,7 +118,9 @@ export async function checkActivationEventRisk(extensionManifest, versionHistory
|
|
|
103
118
|
riskLevel = 'critical';
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
if (!riskLevel)
|
|
121
|
+
if (!riskLevel) {
|
|
122
|
+
return { triggered: false, signals: [], riskLevel: null, why: [] };
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
signals.push({
|
|
109
126
|
type: 'ACTIVATION_EVENT_RISK',
|
|
@@ -4,12 +4,14 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
4
4
|
const hotPullMinutes = config.hotPullMinutes ?? 20;
|
|
5
5
|
|
|
6
6
|
const entries = versionHistory
|
|
7
|
-
.filter(v => v.publishedAt)
|
|
8
|
-
.map(v => ({ version: v.version, time: new Date(v.publishedAt).getTime() }))
|
|
9
|
-
.filter(e => !Number.isNaN(e.time))
|
|
7
|
+
.filter((v) => v.publishedAt)
|
|
8
|
+
.map((v) => ({ version: v.version, time: new Date(v.publishedAt).getTime() }))
|
|
9
|
+
.filter((e) => !Number.isNaN(e.time))
|
|
10
10
|
.sort((a, b) => a.time - b.time);
|
|
11
11
|
|
|
12
|
-
if (entries.length < threshold)
|
|
12
|
+
if (entries.length < threshold) {
|
|
13
|
+
return { triggered: false };
|
|
14
|
+
}
|
|
13
15
|
|
|
14
16
|
const windowMs = windowMinutes * 60 * 1000;
|
|
15
17
|
let burstFound = false;
|
|
@@ -21,14 +23,14 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
21
23
|
for (let i = 0; i < entries.length; i++) {
|
|
22
24
|
const start = entries[i].time;
|
|
23
25
|
const end = start + windowMs;
|
|
24
|
-
const inWindow = entries.filter(e => e.time >= start && e.time <= end);
|
|
26
|
+
const inWindow = entries.filter((e) => e.time >= start && e.time <= end);
|
|
25
27
|
|
|
26
28
|
if (inWindow.length >= threshold) {
|
|
27
29
|
burstFound = true;
|
|
28
30
|
burstWindowStart = new Date(start).toISOString();
|
|
29
31
|
burstWindowEnd = new Date(end).toISOString();
|
|
30
32
|
burstVersionCount = inWindow.length;
|
|
31
|
-
burstVersions = inWindow.map(e => e.version);
|
|
33
|
+
burstVersions = inWindow.map((e) => e.version);
|
|
32
34
|
break;
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -45,7 +47,12 @@ export async function checkBurstPublish(versionHistory, config = {}) {
|
|
|
45
47
|
return {
|
|
46
48
|
triggered: burstFound || hotPullDetected,
|
|
47
49
|
burstWindow: burstFound
|
|
48
|
-
? {
|
|
50
|
+
? {
|
|
51
|
+
start: burstWindowStart,
|
|
52
|
+
end: burstWindowEnd,
|
|
53
|
+
versionCount: burstVersionCount,
|
|
54
|
+
versions: burstVersions,
|
|
55
|
+
}
|
|
49
56
|
: null,
|
|
50
57
|
hotPullDetected,
|
|
51
58
|
};
|