@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
|
@@ -7,7 +7,7 @@ const RATE_LIMIT_MS = 6000;
|
|
|
7
7
|
let _lastFetchTime = 0;
|
|
8
8
|
|
|
9
9
|
function sleep(ms) {
|
|
10
|
-
return new Promise(r => setTimeout(r, ms));
|
|
10
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
async function rateLimitedFetch(url) {
|
|
@@ -44,20 +44,22 @@ async function rateLimitedFetch(url) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function
|
|
47
|
+
function _parseExtensionId(id) {
|
|
48
48
|
const parts = id.split('.');
|
|
49
|
-
if (parts.length < 2)
|
|
49
|
+
if (parts.length < 2) {
|
|
50
|
+
throw new Error(`Invalid extension ID: ${id}`);
|
|
51
|
+
}
|
|
50
52
|
return { publisherId: parts[0], extensionName: parts.slice(1).join('.') };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export async function getExtensionMetadata(publisherId, extensionName) {
|
|
54
56
|
const url = `${MARKETPLACE_API}/extensionquery`;
|
|
55
57
|
const body = {
|
|
56
|
-
filters: [
|
|
57
|
-
|
|
58
|
-
{ filterType: 8, value: `${publisherId}.${extensionName}` },
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
filters: [
|
|
59
|
+
{
|
|
60
|
+
criteria: [{ filterType: 8, value: `${publisherId}.${extensionName}` }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
61
63
|
flags: 914,
|
|
62
64
|
};
|
|
63
65
|
|
|
@@ -77,13 +79,23 @@ export async function getExtensionMetadata(publisherId, extensionName) {
|
|
|
77
79
|
try {
|
|
78
80
|
res = await fetch(url, {
|
|
79
81
|
method: 'POST',
|
|
80
|
-
headers: {
|
|
82
|
+
headers: {
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
Accept: 'application/json;api-version=3.0-preview.1',
|
|
85
|
+
},
|
|
81
86
|
body: JSON.stringify(body),
|
|
82
87
|
});
|
|
83
88
|
if (res.status === 429) {
|
|
84
89
|
const retryAfter = parseInt(res.headers.get('Retry-After') || '10', 10);
|
|
85
90
|
await sleep(retryAfter * 1000);
|
|
86
|
-
res = await fetch(url, {
|
|
91
|
+
res = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
Accept: 'application/json;api-version=3.0-preview.1',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
87
99
|
}
|
|
88
100
|
if (!res.ok) {
|
|
89
101
|
console.debug(`Marketplace API warning: ${url} returned ${res.status}`);
|
|
@@ -100,12 +112,14 @@ export async function getExtensionMetadata(publisherId, extensionName) {
|
|
|
100
112
|
|
|
101
113
|
export async function getVersionHistory(publisherId, extensionName) {
|
|
102
114
|
const data = await getExtensionMetadata(publisherId, extensionName);
|
|
103
|
-
if (!data?.results?.[0]?.extensions?.[0])
|
|
115
|
+
if (!data?.results?.[0]?.extensions?.[0]) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
104
118
|
|
|
105
119
|
const extension = data.results[0].extensions[0];
|
|
106
120
|
const versions = extension.versions || [];
|
|
107
121
|
|
|
108
|
-
return versions.map(v => ({
|
|
122
|
+
return versions.map((v) => ({
|
|
109
123
|
version: v.version,
|
|
110
124
|
publishedAt: v.lastUpdated || v.publishedDate,
|
|
111
125
|
publishedBy: extension.publisher?.publisherName || publisherId,
|
|
@@ -126,7 +140,9 @@ export async function getOpenVsxMetadata(namespace, name) {
|
|
|
126
140
|
|
|
127
141
|
export async function getOpenVsxVersionHistory(namespace, name) {
|
|
128
142
|
const data = await getOpenVsxMetadata(namespace, name);
|
|
129
|
-
if (!data)
|
|
143
|
+
if (!data) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
130
146
|
const versions = data.allVersions || {};
|
|
131
147
|
const files = data.files || {};
|
|
132
148
|
|
package/cli/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { watch } from 'fs';
|
|
|
5
5
|
import { statSync } from 'fs';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import { glob } from 'glob';
|
|
8
|
-
import { isFeatureEnabled, generateKey } from '../backend/license.js';
|
|
8
|
+
import { isFeatureEnabled, generateKey as _generateKey } from '../backend/license.js';
|
|
9
9
|
|
|
10
10
|
function requirePremium(feature, licenseKey) {
|
|
11
11
|
if (!isFeatureEnabled(feature, licenseKey)) {
|
|
@@ -29,7 +29,11 @@ program
|
|
|
29
29
|
.option('-l, --license-key <key>', 'Premium license')
|
|
30
30
|
.option('--sbom [format]', 'Generate SBOM (json/xml/spdx)')
|
|
31
31
|
.option('-p, --policy <path>', 'Policy file (YAML/JSON)')
|
|
32
|
-
.option(
|
|
32
|
+
.option(
|
|
33
|
+
'--fail-on <level>',
|
|
34
|
+
'Exit with code 1 if findings >= level (low|medium|high|critical)',
|
|
35
|
+
'none'
|
|
36
|
+
)
|
|
33
37
|
.option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
|
|
34
38
|
.option('--csv [file]', 'Output CSV format to file or stdout')
|
|
35
39
|
.option('--score-only', 'Output only the risk score (0-10)')
|
|
@@ -48,7 +52,7 @@ program
|
|
|
48
52
|
const fetchOptions = {
|
|
49
53
|
cacheDir: options.cacheDir,
|
|
50
54
|
cacheTTL: parseInt(options.cacheTtl || '604800'),
|
|
51
|
-
cacheMaxSize: parseInt(options.cacheSize || '1000000000')
|
|
55
|
+
cacheMaxSize: parseInt(options.cacheSize || '1000000000'),
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
if (!target && !options.file && !options.vsix) {
|
|
@@ -61,28 +65,41 @@ program
|
|
|
61
65
|
const vsixFindings = await vsixScan(options.vsix);
|
|
62
66
|
const { saveScan } = await import('../backend/db.js');
|
|
63
67
|
const scanId = await saveScan(options.vsix, 'latest', vsixFindings);
|
|
64
|
-
const vsixOutput = JSON.stringify(
|
|
68
|
+
const vsixOutput = JSON.stringify(
|
|
69
|
+
{ scanId, findings: vsixFindings, blocked: false, riskScore: 0, vsix: true },
|
|
70
|
+
null,
|
|
71
|
+
2
|
|
72
|
+
);
|
|
65
73
|
console.log(vsixOutput);
|
|
66
74
|
return;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
const policy = options.policy
|
|
70
|
-
? await import('../backend/policy.js').then(m => m.loadPolicy(options.policy))
|
|
78
|
+
? await import('../backend/policy.js').then((m) => m.loadPolicy(options.policy))
|
|
71
79
|
: null;
|
|
72
80
|
|
|
73
81
|
if (policy) {
|
|
74
82
|
const { isAllowed } = await import('../backend/policy.js');
|
|
75
83
|
if (target && isAllowed(target, policy)) {
|
|
76
|
-
console.log(
|
|
84
|
+
console.log(
|
|
85
|
+
JSON.stringify({
|
|
86
|
+
scanId: null,
|
|
87
|
+
findings: [],
|
|
88
|
+
skipped: true,
|
|
89
|
+
reason: `Package '${target}' is in policy allowlist`,
|
|
90
|
+
})
|
|
91
|
+
);
|
|
77
92
|
return;
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
const { pkgJson, jsFiles, allFiles, tmpDir, meta } = options.file
|
|
82
|
-
? await import('../backend/fetch.js').then(m => m.scanLocalTarball(options.file))
|
|
83
|
-
: await import('../backend/fetch.js').then(m => m.fetchPackage(target, fetchOptions));
|
|
97
|
+
? await import('../backend/fetch.js').then((m) => m.scanLocalTarball(options.file))
|
|
98
|
+
: await import('../backend/fetch.js').then((m) => m.fetchPackage(target, fetchOptions));
|
|
84
99
|
const pkgName = target || pkgJson.name || 'unknown';
|
|
85
|
-
const findings = await import('../backend/detectors/index.js').then(m =>
|
|
100
|
+
const findings = await import('../backend/detectors/index.js').then((m) =>
|
|
101
|
+
m.runAll(pkgJson, jsFiles, meta, allFiles)
|
|
102
|
+
);
|
|
86
103
|
let vsixFindings = [];
|
|
87
104
|
if (options.vsix) {
|
|
88
105
|
const { vsixScan } = await import('../backend/vsix-scan/index.js');
|
|
@@ -107,13 +124,17 @@ program
|
|
|
107
124
|
|
|
108
125
|
if (options.scoreOnly) {
|
|
109
126
|
console.log(riskScore);
|
|
110
|
-
import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
|
|
127
|
+
import('../backend/fetch.js').then((m) => m.cleanup(tmpDir));
|
|
111
128
|
return;
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
if (options.sarif) {
|
|
115
132
|
const { generateSARIF } = await import('../backend/report.js');
|
|
116
|
-
const scan = {
|
|
133
|
+
const scan = {
|
|
134
|
+
package_name: pkgName,
|
|
135
|
+
version: pkgJson.version || 'latest',
|
|
136
|
+
findings: outputFindings,
|
|
137
|
+
};
|
|
117
138
|
const sarifOutput = generateSARIF(scan);
|
|
118
139
|
if (options.sarif === true || !options.sarif) {
|
|
119
140
|
console.log(sarifOutput);
|
|
@@ -124,7 +145,11 @@ program
|
|
|
124
145
|
}
|
|
125
146
|
} else if (options.csv) {
|
|
126
147
|
const { generateCSV } = await import('../backend/report.js');
|
|
127
|
-
const scan = {
|
|
148
|
+
const scan = {
|
|
149
|
+
package_name: pkgName,
|
|
150
|
+
version: pkgJson.version || 'latest',
|
|
151
|
+
findings: outputFindings,
|
|
152
|
+
};
|
|
128
153
|
const csvOutput = generateCSV([scan]);
|
|
129
154
|
if (options.csv === true || !options.csv) {
|
|
130
155
|
console.log(csvOutput);
|
|
@@ -136,14 +161,20 @@ program
|
|
|
136
161
|
} else if (options.sbom) {
|
|
137
162
|
const { generateSBOM } = await import('../backend/sbom.js');
|
|
138
163
|
const pkg = { name: pkgName, version: pkgJson.version || 'latest' };
|
|
139
|
-
const sbom = generateSBOM(
|
|
164
|
+
const sbom = generateSBOM(
|
|
165
|
+
pkg,
|
|
166
|
+
outputFindings,
|
|
167
|
+
options.sbom === true ? 'json' : options.sbom
|
|
168
|
+
);
|
|
140
169
|
console.log(sbom);
|
|
141
170
|
} else {
|
|
142
|
-
console.log(
|
|
171
|
+
console.log(
|
|
172
|
+
JSON.stringify({ scanId, findings: outputFindings, blocked, riskScore }, null, 2)
|
|
173
|
+
);
|
|
143
174
|
}
|
|
144
175
|
|
|
145
176
|
if (options.auditLog) {
|
|
146
|
-
const { writeFileSync, appendFileSync } = await import('fs');
|
|
177
|
+
const { writeFileSync: _writeFileSync, appendFileSync } = await import('fs');
|
|
147
178
|
const entry = {
|
|
148
179
|
timestamp: new Date().toISOString(),
|
|
149
180
|
command: `scan ${target || options.file}`,
|
|
@@ -151,7 +182,7 @@ program
|
|
|
151
182
|
version: pkgJson.version || 'latest',
|
|
152
183
|
riskScore,
|
|
153
184
|
findingsCount: outputFindings.length,
|
|
154
|
-
exitCode: 0
|
|
185
|
+
exitCode: 0,
|
|
155
186
|
};
|
|
156
187
|
appendFileSync(options.auditLog, JSON.stringify(entry) + '\n');
|
|
157
188
|
}
|
|
@@ -164,14 +195,16 @@ program
|
|
|
164
195
|
if (options.failOn !== 'none') {
|
|
165
196
|
const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
166
197
|
const failLevel = severityLevels[options.failOn] || 0;
|
|
167
|
-
const hasBlockingFindings = outputFindings.some(
|
|
198
|
+
const hasBlockingFindings = outputFindings.some(
|
|
199
|
+
(f) => (severityLevels[f.severity] || 0) >= failLevel
|
|
200
|
+
);
|
|
168
201
|
if (hasBlockingFindings) {
|
|
169
202
|
console.error(`Fail: findings with severity >= ${options.failOn} detected`);
|
|
170
203
|
process.exit(1);
|
|
171
204
|
}
|
|
172
205
|
}
|
|
173
206
|
|
|
174
|
-
import('../backend/fetch.js').then(m => m.cleanup(tmpDir));
|
|
207
|
+
import('../backend/fetch.js').then((m) => m.cleanup(tmpDir));
|
|
175
208
|
} catch (e) {
|
|
176
209
|
console.error(e.message);
|
|
177
210
|
process.exit(1);
|
|
@@ -182,7 +215,11 @@ program
|
|
|
182
215
|
.command('scan-lockfile')
|
|
183
216
|
.description('Scan package lockfile (npm/yarn/pnpm)')
|
|
184
217
|
.option('-f, --file <path>', 'lockfile path', 'package-lock.json')
|
|
185
|
-
.option(
|
|
218
|
+
.option(
|
|
219
|
+
'--fail-on <level>',
|
|
220
|
+
'Exit with code 1 if findings >= level (low|medium|high|critical)',
|
|
221
|
+
'none'
|
|
222
|
+
)
|
|
186
223
|
.option('--csv [file]', 'Output CSV format to file or stdout')
|
|
187
224
|
.option('--sarif [file]', 'Output SARIF v2.1 format to file or stdout')
|
|
188
225
|
.option('--watch', 'Watch for changes and re-scan automatically')
|
|
@@ -197,41 +234,62 @@ program
|
|
|
197
234
|
const isWatch = options.watch;
|
|
198
235
|
const isMonorepo = options.monorepo;
|
|
199
236
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
237
|
+
if (isWatch) {
|
|
238
|
+
if (isMonorepo) {
|
|
239
|
+
const lockfiles = await glob('**/{package-lock.json,yarn.lock,pnpm-lock.yaml}', {
|
|
240
|
+
ignore: 'node_modules/**',
|
|
241
|
+
});
|
|
203
242
|
|
|
243
|
+
if (!silent) {
|
|
244
|
+
console.log(
|
|
245
|
+
`\x1b[32m✔\x1b[0m npm-scan watch mode (monorepo) — ${lockfiles.length} lockfiles`
|
|
246
|
+
);
|
|
247
|
+
console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const timers = {};
|
|
251
|
+
for (const lf of lockfiles) {
|
|
204
252
|
if (!silent) {
|
|
205
|
-
console.log(
|
|
206
|
-
console.log(` Debounce: ${debounce}ms | Press Ctrl+C to stop\n`);
|
|
253
|
+
console.log(` Watching: ${lf}`);
|
|
207
254
|
}
|
|
255
|
+
const _watcher = watch(lf, (eventType) => {
|
|
256
|
+
if (eventType !== 'change') {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
clearTimeout(timers[lf]);
|
|
260
|
+
timers[lf] = setTimeout(() => {
|
|
261
|
+
if (!silent) {
|
|
262
|
+
console.log(
|
|
263
|
+
`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const lockType = lf.includes('yarn') ? '--yarn' : lf.includes('pnpm') ? '--pnpm' : '';
|
|
267
|
+
try {
|
|
268
|
+
execSync(
|
|
269
|
+
`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent ${lockType}`,
|
|
270
|
+
{ stdio: silent ? 'ignore' : 'inherit' }
|
|
271
|
+
);
|
|
272
|
+
} catch {
|
|
273
|
+
/* ignore */
|
|
274
|
+
}
|
|
275
|
+
}, debounce);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
208
278
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const watcher = watch(lf, (eventType) => {
|
|
213
|
-
if (eventType !== 'change') return;
|
|
214
|
-
clearTimeout(timers[lf]);
|
|
215
|
-
timers[lf] = setTimeout(() => {
|
|
216
|
-
if (!silent) {
|
|
217
|
-
console.log(`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lf} changed — scanning...`);
|
|
218
|
-
}
|
|
219
|
-
const lockType = lf.includes('yarn') ? '--yarn' : lf.includes('pnpm') ? '--pnpm' : '';
|
|
220
|
-
try {
|
|
221
|
-
execSync(`node cli/cli.js scan-lockfile -f "${lf}" --fail-on ${options.failOn || 'high'} --silent ${lockType}`, { stdio: silent ? 'ignore' : 'inherit' });
|
|
222
|
-
} catch (e) {}
|
|
223
|
-
}, debounce);
|
|
224
|
-
});
|
|
279
|
+
process.on('SIGINT', () => {
|
|
280
|
+
if (!silent) {
|
|
281
|
+
console.log('\n\x1b[33m✖\x1b[0m Stopped.');
|
|
225
282
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (!silent) console.log('\n\x1b[33m✖\x1b[0m Stopped.');
|
|
229
|
-
process.exit(0);
|
|
230
|
-
});
|
|
283
|
+
process.exit(0);
|
|
284
|
+
});
|
|
231
285
|
} else {
|
|
232
286
|
const lockfile = options.file;
|
|
233
287
|
let lastSize = 0;
|
|
234
|
-
try {
|
|
288
|
+
try {
|
|
289
|
+
lastSize = statSync(lockfile).size;
|
|
290
|
+
} catch {
|
|
291
|
+
/* ignore */
|
|
292
|
+
}
|
|
235
293
|
|
|
236
294
|
if (!silent) {
|
|
237
295
|
console.log(`\x1b[32m✔\x1b[0m npm-scan watch mode — ${lockfile}`);
|
|
@@ -239,19 +297,34 @@ program
|
|
|
239
297
|
}
|
|
240
298
|
|
|
241
299
|
const watcher = watch(lockfile, (eventType) => {
|
|
242
|
-
if (eventType !== 'change')
|
|
300
|
+
if (eventType !== 'change') {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
243
303
|
const size = statSync(lockfile).size;
|
|
244
|
-
if (size === lastSize)
|
|
304
|
+
if (size === lastSize) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
245
307
|
lastSize = size;
|
|
246
|
-
if (!silent)
|
|
308
|
+
if (!silent) {
|
|
309
|
+
console.log(
|
|
310
|
+
`\n\x1b[90m[${new Date().toLocaleTimeString()}]\x1b[0m ${lockfile} changed — rescanning...`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
247
313
|
try {
|
|
248
|
-
execSync(
|
|
249
|
-
|
|
314
|
+
execSync(
|
|
315
|
+
`node cli/cli.js scan-lockfile --fail-on ${options.failOn || 'high'} --silent`,
|
|
316
|
+
{ stdio: silent ? 'ignore' : 'inherit' }
|
|
317
|
+
);
|
|
318
|
+
} catch {
|
|
319
|
+
/* ignore */
|
|
320
|
+
}
|
|
250
321
|
});
|
|
251
322
|
|
|
252
323
|
process.on('SIGINT', () => {
|
|
253
324
|
watcher.close();
|
|
254
|
-
if (!silent)
|
|
325
|
+
if (!silent) {
|
|
326
|
+
console.log('\n\x1b[33m✖\x1b[0m Stopped.');
|
|
327
|
+
}
|
|
255
328
|
process.exit(0);
|
|
256
329
|
});
|
|
257
330
|
}
|
|
@@ -260,9 +333,13 @@ program
|
|
|
260
333
|
try {
|
|
261
334
|
const { parseLockfile, generateLockfileReport } = await import('../backend/lockfile.js');
|
|
262
335
|
|
|
263
|
-
if (!silent)
|
|
336
|
+
if (!silent) {
|
|
337
|
+
console.log(`\x1b[32m✔\x1b[0m Scanning lockfile: ${lockfile}`);
|
|
338
|
+
}
|
|
264
339
|
|
|
265
|
-
const lockfileData = parseLockfile(lockfile, {
|
|
340
|
+
const lockfileData = parseLockfile(lockfile, {
|
|
341
|
+
autoDetect: !options.yarn && !options.pnpm,
|
|
342
|
+
});
|
|
266
343
|
const results = generateLockfileReport(lockfileData);
|
|
267
344
|
|
|
268
345
|
if (!silent) {
|
|
@@ -271,8 +348,17 @@ program
|
|
|
271
348
|
if (results.findings.length > 0) {
|
|
272
349
|
console.log(`\n\x1b[31m🔴\x1b[0m ${results.findings.length} finding(s) found:\n`);
|
|
273
350
|
for (const f of results.findings) {
|
|
274
|
-
const color =
|
|
275
|
-
|
|
351
|
+
const color =
|
|
352
|
+
f.severity === 'critical'
|
|
353
|
+
? '\x1b[31m'
|
|
354
|
+
: f.severity === 'high'
|
|
355
|
+
? '\x1b[91m'
|
|
356
|
+
: f.severity === 'medium'
|
|
357
|
+
? '\x1b[33m'
|
|
358
|
+
: '\x1b[32m';
|
|
359
|
+
console.log(
|
|
360
|
+
` ${color}${f.severity.toUpperCase().padEnd(8)}\x1b[0m ${f.id}: ${f.title}`
|
|
361
|
+
);
|
|
276
362
|
console.log(` ${f.description}`);
|
|
277
363
|
}
|
|
278
364
|
} else {
|
|
@@ -287,9 +373,11 @@ program
|
|
|
287
373
|
const failOn = options.failOn || 'none';
|
|
288
374
|
if (failOn !== 'none') {
|
|
289
375
|
const weights = { critical: 5, high: 4, medium: 3, low: 2, info: 1 };
|
|
290
|
-
const maxWeight = Math.max(...results.findings.map(f => weights[f.severity] || 0));
|
|
376
|
+
const maxWeight = Math.max(...results.findings.map((f) => weights[f.severity] || 0));
|
|
291
377
|
const failThreshold = weights[failOn] || 0;
|
|
292
|
-
if (maxWeight >= failThreshold)
|
|
378
|
+
if (maxWeight >= failThreshold) {
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
293
381
|
}
|
|
294
382
|
}
|
|
295
383
|
} catch (e) {
|
|
@@ -300,7 +388,7 @@ program
|
|
|
300
388
|
});
|
|
301
389
|
|
|
302
390
|
program
|
|
303
|
-
.command('report')
|
|
391
|
+
.command('report')
|
|
304
392
|
.description('Generate report')
|
|
305
393
|
.option('-i, --id <id>', 'Scan ID')
|
|
306
394
|
.option('--sbom [format]', 'SBOM format (json/xml/spdx)')
|
|
@@ -339,7 +427,7 @@ program
|
|
|
339
427
|
const { generatePDF } = await import('../backend/pdf.js');
|
|
340
428
|
const pdfBytes = await generatePDF(scan ? [scan] : []);
|
|
341
429
|
const outPath = options.output || `${pkgName}-${options.id}-report.pdf`;
|
|
342
|
-
await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
|
|
430
|
+
await import('fs').then((m) => m.writeFileSync(outPath, pdfBytes));
|
|
343
431
|
console.log(`PDF report written to ${outPath}`);
|
|
344
432
|
} else if (options.text) {
|
|
345
433
|
const { generateText } = await import('../backend/report.js');
|
|
@@ -361,7 +449,9 @@ program
|
|
|
361
449
|
}
|
|
362
450
|
} else {
|
|
363
451
|
const scans = await getRecentScans();
|
|
364
|
-
const scansWithFindings = await Promise.all(
|
|
452
|
+
const scansWithFindings = await Promise.all(
|
|
453
|
+
scans.map(async (s) => ({ ...s, findings: await getFindings(s.id) }))
|
|
454
|
+
);
|
|
365
455
|
|
|
366
456
|
if (options.siem) {
|
|
367
457
|
requirePremium('siem', licenseKey);
|
|
@@ -377,7 +467,7 @@ program
|
|
|
377
467
|
const pdfBytes = await generatePDF(scansWithFindings);
|
|
378
468
|
const date = new Date().toISOString().slice(0, 10);
|
|
379
469
|
const outPath = options.output || `npm-scan-report-${date}.pdf`;
|
|
380
|
-
await import('fs').then(m => m.writeFileSync(outPath, pdfBytes));
|
|
470
|
+
await import('fs').then((m) => m.writeFileSync(outPath, pdfBytes));
|
|
381
471
|
console.log(`PDF report written to ${outPath}`);
|
|
382
472
|
} else if (options.text) {
|
|
383
473
|
const { generateText } = await import('../backend/report.js');
|
|
@@ -417,7 +507,7 @@ program
|
|
|
417
507
|
|
|
418
508
|
if (req.url === '/scan' && req.method === 'POST') {
|
|
419
509
|
let body = '';
|
|
420
|
-
req.on('data', chunk => body += chunk);
|
|
510
|
+
req.on('data', (chunk) => (body += chunk));
|
|
421
511
|
req.on('end', async () => {
|
|
422
512
|
try {
|
|
423
513
|
const { package: pkg, options: scanOpts } = JSON.parse(body);
|
|
@@ -456,4 +546,4 @@ program
|
|
|
456
546
|
});
|
|
457
547
|
});
|
|
458
548
|
|
|
459
|
-
program.parse();
|
|
549
|
+
program.parse();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Production-grade npm supply chain vulnerability scanner. Detects 100% of 3 real May 2026 supply chain campaigns (dependency confusion, obfuscation, impersonation) with 0% false positive rate on top 1,000 npm packages.",
|
|
5
5
|
"main": "backend/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"npm-scan": "cli/cli.js"
|
|
@@ -17,30 +17,51 @@
|
|
|
17
17
|
"npm",
|
|
18
18
|
"security",
|
|
19
19
|
"supply-chain",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
20
|
+
"malware-detection",
|
|
21
|
+
"typosquat",
|
|
22
|
+
"dependency-confusion",
|
|
23
|
+
"obfuscation-detection",
|
|
24
|
+
"validated-detectors"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"dev": "node cli/cli.js",
|
|
28
|
-
"lint": "
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"lint:fix": "eslint . --fix",
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check .",
|
|
32
|
+
"validate": "npm run lint && npm run format:check && npm test",
|
|
29
33
|
"test": "node --test",
|
|
30
34
|
"test:coverage": "node --experimental-test-coverage --test",
|
|
31
35
|
"test:verbose": "node --test --test-reporter spec",
|
|
32
36
|
"prepare": "husky",
|
|
33
37
|
"build": "echo 'Build stub'",
|
|
34
|
-
"corpus": "node tests/corpus/run.js"
|
|
38
|
+
"corpus": "node tests/corpus/run.js",
|
|
39
|
+
"validate:campaigns": "node backend/scripts/validate-detectors.js all",
|
|
40
|
+
"validate:analyze": "node backend/scripts/analyze-validation.js",
|
|
41
|
+
"calibrate:fetch": "node backend/scripts/fetch-top-packages.js",
|
|
42
|
+
"calibrate:fps": "node backend/scripts/detect-false-positives.js",
|
|
43
|
+
"calibrate:analyze": "node backend/scripts/analyze-false-positives.js"
|
|
35
44
|
},
|
|
36
45
|
"lint-staged": {
|
|
37
46
|
"**/package{,-lock}.json": "sh -c 'node cli/cli.js scan-lockfile --fail-on high'",
|
|
38
47
|
"**/yarn.lock": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --yarn'",
|
|
39
48
|
"**/pnpm-lock.yaml": "sh -c 'node cli/cli.js scan-lockfile --fail-on high --pnpm'"
|
|
40
49
|
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.0.0"
|
|
52
|
+
},
|
|
41
53
|
"publishConfig": {
|
|
54
|
+
"registry": "https://registry.npmjs.org/",
|
|
42
55
|
"access": "public"
|
|
43
56
|
},
|
|
57
|
+
"files": [
|
|
58
|
+
"backend/",
|
|
59
|
+
"cli/",
|
|
60
|
+
"README.md",
|
|
61
|
+
"VALIDATION.md",
|
|
62
|
+
"CHANGELOG.md",
|
|
63
|
+
"LICENSING.md"
|
|
64
|
+
],
|
|
44
65
|
"dependencies": {
|
|
45
66
|
"acorn": "^8.16.0",
|
|
46
67
|
"commander": "^14.0.3",
|
|
@@ -51,7 +72,12 @@
|
|
|
51
72
|
"tar": "^7.5.15"
|
|
52
73
|
},
|
|
53
74
|
"devDependencies": {
|
|
75
|
+
"@eslint/js": "^9.39.4",
|
|
76
|
+
"eslint": "^9.39.4",
|
|
77
|
+
"eslint-config-prettier": "^10.1.8",
|
|
78
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
54
79
|
"husky": "^9.1.7",
|
|
55
|
-
"lint-staged": "^16.4.0"
|
|
80
|
+
"lint-staged": "^16.4.0",
|
|
81
|
+
"prettier": "^3.8.3"
|
|
56
82
|
}
|
|
57
83
|
}
|
package/.dockerignore
DELETED
package/.husky/pre-commit
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
npx lint-staged
|
package/SECURITY.md
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
# Security Policy
|
|
2
|
-
|
|
3
|
-
## Supported Versions
|
|
4
|
-
|
|
5
|
-
Only the **latest published minor version** on npm receives security patches. Keep `@lateos/npm-scan` up to date:
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm update -g @lateos/npm-scan
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
| Version | Supported |
|
|
12
|
-
|---------|-----------|
|
|
13
|
-
| 0.9.x | ✅ Active |
|
|
14
|
-
| < 0.9 | ❌ |
|
|
15
|
-
|
|
16
|
-
## Reporting a Vulnerability
|
|
17
|
-
|
|
18
|
-
Use **GitHub Private Vulnerability Reporting**:
|
|
19
|
-
|
|
20
|
-
1. Go to [github.com/lateos-ai/npm-scan/security/advisories/new](https://github.com/lateos-ai/npm-scan/security/advisories/new)
|
|
21
|
-
2. Describe the vulnerability in detail (ideally with a proof of concept)
|
|
22
|
-
3. Allow **72 hours** for an initial acknowledgment
|
|
23
|
-
|
|
24
|
-
For encrypted follow-up outside of GitHub, use our PGP key:
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
Fingerprint: 1BC6 998B 879B BDE0 D778 629E D9CF F5EF 1F7C 557B
|
|
28
|
-
Key ID: 1F7C557B
|
|
29
|
-
Email: leo@lateos.ai
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Scope
|
|
33
|
-
|
|
34
|
-
**In scope:**
|
|
35
|
-
- Detector logic (ATK-001 through ATK-011)
|
|
36
|
-
- Code execution in the scanner engine (`backend/fetch.js`, `cli/cli.js`)
|
|
37
|
-
- CI/CD pipeline and publish process (provenance bypass, supply chain)
|
|
38
|
-
- Configuration injection via `policy.yaml` or command-line flags
|
|
39
|
-
|
|
40
|
-
**Out of scope:**
|
|
41
|
-
- CVEs in third-party dependencies — report upstream
|
|
42
|
-
- Vulnerabilities in the npm registry itself — report to npm
|
|
43
|
-
- Malicious packages detected by the scanner (that's working as designed)
|
|
44
|
-
|
|
45
|
-
## Security Practices
|
|
46
|
-
|
|
47
|
-
`@lateos/npm-scan` follows these practices to protect its own supply chain:
|
|
48
|
-
|
|
49
|
-
- **Sigstore provenance** on every npm publish — verifiable via `npm view @lateos/npm-scan provenance`
|
|
50
|
-
- **Self-scanning in CI** — every commit scans the project's own `package-lock.json` for the full ATK taxonomy
|
|
51
|
-
- **SBOM per release** — CycloneDX and SPDX 2.3 Bill of Materials published with every version
|
|
52
|
-
- **2FA** enforced on the npm publisher account
|
|
53
|
-
- **Docker multi-arch images** signed and pushed via CI, not manually
|
|
54
|
-
- **All code public** — no security-by-obscurity
|
|
55
|
-
|
|
56
|
-
## Self-Scanning
|
|
57
|
-
|
|
58
|
-
As a supply chain security scanner, `@lateos/npm-scan` dogfoods its own detectors. Every CI run executes:
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
npx @lateos/npm-scan scan-lockfile --fail-on medium
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
If a future update to a dependency triggers one of our detectors (e.g., typosquat, obfuscated lifecycle script), the build **fails** before the change reaches npm.
|
|
65
|
-
|
|
66
|
-
## Safe Harbor
|
|
67
|
-
|
|
68
|
-
We consider security research conducted under this policy as authorized and will not pursue legal action against researchers who:
|
|
69
|
-
|
|
70
|
-
- Report vulnerabilities through GitHub Private Vulnerability Reporting
|
|
71
|
-
- Do not access or modify user data beyond what's necessary to demonstrate the vulnerability
|
|
72
|
-
- Do not exploit the vulnerability beyond demonstrating it
|
|
73
|
-
- Act in good faith to improve the security of the project
|