@lateos/npm-scan 0.18.2 → 1.0.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 +265 -233
- package/LICENSING.md +19 -19
- package/README.de.md +708 -708
- package/README.fr.md +707 -707
- package/README.ja.md +704 -704
- package/README.md +861 -826
- package/README.zh.md +708 -708
- package/VALIDATION.md +92 -0
- package/backend/cra.js +68 -68
- package/backend/db/pg-schema.sql +155 -0
- package/backend/db/schema.sql +32 -32
- package/backend/db.js +88 -88
- package/backend/detectors/atk-001-lifecycle.js +17 -17
- package/backend/detectors/atk-002-obfusc.js +261 -261
- package/backend/detectors/atk-003-creds.js +13 -13
- package/backend/detectors/atk-004-persist.js +13 -13
- package/backend/detectors/atk-005-exfil.js +13 -13
- package/backend/detectors/atk-006-depconf.js +14 -14
- package/backend/detectors/atk-007-typosquat.js +34 -34
- package/backend/detectors/atk-008-tarball-tamper.js +91 -91
- package/backend/detectors/atk-009-dormant-trigger.js +62 -62
- package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
- package/backend/detectors/atk-011-transitive-prop.js +76 -76
- package/backend/detectors/config/thresholds.js +66 -0
- package/backend/detectors/config/whitelist.json +74 -0
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
- package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
- package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
- package/backend/detectors/hf-impersonation/index.js +396 -396
- package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
- package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
- package/backend/detectors/hf-impersonation/simhash.js +46 -46
- package/backend/detectors/index.js +87 -81
- package/backend/detectors/lib/ast-patterns.js +21 -0
- package/backend/detectors/lib/entropy-analyzer.js +24 -0
- package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
- package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
- package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
- package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
- package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
- package/backend/detectors/megalodon/index.js +80 -80
- package/backend/detectors/megalodon/types.js +9 -9
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
- package/backend/detectors/mini-shai-hulud/index.js +118 -118
- package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
- package/backend/detectors/tier1-binary-embed.js +34 -5
- package/backend/detectors/tier1-obfuscation-heuristics.js +156 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-version-anomaly.js +187 -0
- package/backend/detectors.test.js +88 -0
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -193
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/scripts/analyze-false-positives.js +146 -0
- package/backend/scripts/analyze-validation.js +151 -0
- package/backend/scripts/detect-false-positives.js +93 -0
- package/backend/scripts/fetch-top-packages.js +129 -0
- package/backend/scripts/validate-detectors.js +142 -0
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/tests-d5-enhanced.test.js +46 -0
- package/backend/tests-d6-version-anomaly.test.js +58 -0
- package/backend/tests-d6.test.js +116 -0
- package/backend/tests-d6c.test.js +106 -0
- package/backend/tests-d7-obfuscation.test.js +91 -0
- package/backend/tests.test.js +898 -0
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/package.json +74 -57
- 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
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
const siblingCache = new Map();
|
|
2
|
-
|
|
3
|
-
export function clearSiblingCache() {
|
|
4
|
-
siblingCache.clear();
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
|
|
8
|
-
const entries = Object.entries(timeMap)
|
|
9
|
-
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
10
|
-
.filter(([, t]) => t)
|
|
11
|
-
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
12
|
-
.filter(([, ts]) => !Number.isNaN(ts))
|
|
13
|
-
.sort((a, b) => a[1] - b[1]);
|
|
14
|
-
|
|
15
|
-
if (entries.length === 0) return null;
|
|
16
|
-
|
|
17
|
-
const windowMs = windowMinutes * 60 * 1000;
|
|
18
|
-
|
|
19
|
-
for (let i = 0; i < entries.length; i++) {
|
|
20
|
-
const wStart = entries[i][1];
|
|
21
|
-
const wEnd = wStart + windowMs;
|
|
22
|
-
const inWindow = [];
|
|
23
|
-
|
|
24
|
-
for (let j = i; j < entries.length; j++) {
|
|
25
|
-
if (entries[j][1] <= wEnd) {
|
|
26
|
-
inWindow.push(entries[j][0]);
|
|
27
|
-
} else {
|
|
28
|
-
break;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (inWindow.length >= threshold) {
|
|
33
|
-
return {
|
|
34
|
-
windowStart: new Date(wStart).toISOString(),
|
|
35
|
-
windowEnd: new Date(wEnd).toISOString(),
|
|
36
|
-
versionCount: inWindow.length,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
45
|
-
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
46
|
-
const threshold = config.burstVersionThreshold ?? 3;
|
|
47
|
-
|
|
48
|
-
const deps = {
|
|
49
|
-
...pkgJson.dependencies,
|
|
50
|
-
...pkgJson.devDependencies,
|
|
51
|
-
...pkgJson.peerDependencies,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const scopedDeps = {};
|
|
55
|
-
for (const name of Object.keys(deps)) {
|
|
56
|
-
if (name.startsWith('@')) {
|
|
57
|
-
const scope = name.split('/')[0];
|
|
58
|
-
if (!scopedDeps[scope]) scopedDeps[scope] = [];
|
|
59
|
-
scopedDeps[scope].push(name);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (Object.keys(scopedDeps).length === 0) return { triggered: false };
|
|
64
|
-
|
|
65
|
-
const results = [];
|
|
66
|
-
|
|
67
|
-
for (const [scope, packages] of Object.entries(scopedDeps)) {
|
|
68
|
-
if (packages.length < 2) continue;
|
|
69
|
-
|
|
70
|
-
const burstSiblings = [];
|
|
71
|
-
|
|
72
|
-
for (const pkg of packages) {
|
|
73
|
-
let timeData = siblingCache.get(pkg);
|
|
74
|
-
if (!timeData) {
|
|
75
|
-
try {
|
|
76
|
-
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
|
|
77
|
-
const res = await fetch(url);
|
|
78
|
-
if (!res.ok) continue;
|
|
79
|
-
const data = await res.json();
|
|
80
|
-
timeData = data.time || {};
|
|
81
|
-
siblingCache.set(pkg, timeData);
|
|
82
|
-
} catch {
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const burstInfo = checkBurstOnTimeMap(timeData, windowMinutes, threshold);
|
|
88
|
-
if (burstInfo) {
|
|
89
|
-
burstSiblings.push({ name: pkg, ...burstInfo });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (burstSiblings.length >= 2) {
|
|
94
|
-
const windows = burstSiblings.map(s => ({
|
|
95
|
-
start: new Date(s.windowStart).getTime(),
|
|
96
|
-
end: new Date(s.windowEnd).getTime(),
|
|
97
|
-
}));
|
|
98
|
-
|
|
99
|
-
const overlapStart = Math.max(...windows.map(w => w.start));
|
|
100
|
-
const overlapEnd = Math.min(...windows.map(w => w.end));
|
|
101
|
-
|
|
102
|
-
if (overlapStart < overlapEnd) {
|
|
103
|
-
results.push({
|
|
104
|
-
triggered: true,
|
|
105
|
-
scope,
|
|
106
|
-
siblingPackages: burstSiblings.map(s => s.name),
|
|
107
|
-
windowStart: new Date(overlapStart).toISOString(),
|
|
108
|
-
windowEnd: new Date(overlapEnd).toISOString(),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (results.length === 0) return { triggered: false };
|
|
115
|
-
return { triggered: true, results };
|
|
116
|
-
}
|
|
1
|
+
const siblingCache = new Map();
|
|
2
|
+
|
|
3
|
+
export function clearSiblingCache() {
|
|
4
|
+
siblingCache.clear();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
|
|
8
|
+
const entries = Object.entries(timeMap)
|
|
9
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
10
|
+
.filter(([, t]) => t)
|
|
11
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
12
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
13
|
+
.sort((a, b) => a[1] - b[1]);
|
|
14
|
+
|
|
15
|
+
if (entries.length === 0) return null;
|
|
16
|
+
|
|
17
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < entries.length; i++) {
|
|
20
|
+
const wStart = entries[i][1];
|
|
21
|
+
const wEnd = wStart + windowMs;
|
|
22
|
+
const inWindow = [];
|
|
23
|
+
|
|
24
|
+
for (let j = i; j < entries.length; j++) {
|
|
25
|
+
if (entries[j][1] <= wEnd) {
|
|
26
|
+
inWindow.push(entries[j][0]);
|
|
27
|
+
} else {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (inWindow.length >= threshold) {
|
|
33
|
+
return {
|
|
34
|
+
windowStart: new Date(wStart).toISOString(),
|
|
35
|
+
windowEnd: new Date(wEnd).toISOString(),
|
|
36
|
+
versionCount: inWindow.length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
45
|
+
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
46
|
+
const threshold = config.burstVersionThreshold ?? 3;
|
|
47
|
+
|
|
48
|
+
const deps = {
|
|
49
|
+
...pkgJson.dependencies,
|
|
50
|
+
...pkgJson.devDependencies,
|
|
51
|
+
...pkgJson.peerDependencies,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const scopedDeps = {};
|
|
55
|
+
for (const name of Object.keys(deps)) {
|
|
56
|
+
if (name.startsWith('@')) {
|
|
57
|
+
const scope = name.split('/')[0];
|
|
58
|
+
if (!scopedDeps[scope]) scopedDeps[scope] = [];
|
|
59
|
+
scopedDeps[scope].push(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Object.keys(scopedDeps).length === 0) return { triggered: false };
|
|
64
|
+
|
|
65
|
+
const results = [];
|
|
66
|
+
|
|
67
|
+
for (const [scope, packages] of Object.entries(scopedDeps)) {
|
|
68
|
+
if (packages.length < 2) continue;
|
|
69
|
+
|
|
70
|
+
const burstSiblings = [];
|
|
71
|
+
|
|
72
|
+
for (const pkg of packages) {
|
|
73
|
+
let timeData = siblingCache.get(pkg);
|
|
74
|
+
if (!timeData) {
|
|
75
|
+
try {
|
|
76
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
|
|
77
|
+
const res = await fetch(url);
|
|
78
|
+
if (!res.ok) continue;
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
timeData = data.time || {};
|
|
81
|
+
siblingCache.set(pkg, timeData);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const burstInfo = checkBurstOnTimeMap(timeData, windowMinutes, threshold);
|
|
88
|
+
if (burstInfo) {
|
|
89
|
+
burstSiblings.push({ name: pkg, ...burstInfo });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (burstSiblings.length >= 2) {
|
|
94
|
+
const windows = burstSiblings.map(s => ({
|
|
95
|
+
start: new Date(s.windowStart).getTime(),
|
|
96
|
+
end: new Date(s.windowEnd).getTime(),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const overlapStart = Math.max(...windows.map(w => w.start));
|
|
100
|
+
const overlapEnd = Math.min(...windows.map(w => w.end));
|
|
101
|
+
|
|
102
|
+
if (overlapStart < overlapEnd) {
|
|
103
|
+
results.push({
|
|
104
|
+
triggered: true,
|
|
105
|
+
scope,
|
|
106
|
+
siblingPackages: burstSiblings.map(s => s.name),
|
|
107
|
+
windowStart: new Date(overlapStart).toISOString(),
|
|
108
|
+
windowEnd: new Date(overlapEnd).toISOString(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (results.length === 0) return { triggered: false };
|
|
115
|
+
return { triggered: true, results };
|
|
116
|
+
}
|
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
|
|
2
|
-
if (!burstWindow?.triggered) return { triggered: false };
|
|
3
|
-
|
|
4
|
-
const anomalies = [];
|
|
5
|
-
const publishTime = timeMap?.[version];
|
|
6
|
-
if (!publishTime) return { triggered: false };
|
|
7
|
-
|
|
8
|
-
try {
|
|
9
|
-
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
|
10
|
-
const res = await fetch(url);
|
|
11
|
-
if (!res.ok) return { triggered: false };
|
|
12
|
-
|
|
13
|
-
const data = await res.json();
|
|
14
|
-
const attestations = data?.attestations || [];
|
|
15
|
-
if (attestations.length === 0) return { triggered: false };
|
|
16
|
-
|
|
17
|
-
const publishMs = new Date(publishTime).getTime();
|
|
18
|
-
if (Number.isNaN(publishMs)) return { triggered: false };
|
|
19
|
-
|
|
20
|
-
// Check if this is the first-ever attested version for this package
|
|
21
|
-
const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
|
|
22
|
-
const currentIdx = allVersions.indexOf(version);
|
|
23
|
-
let prevHadAttestation = false;
|
|
24
|
-
|
|
25
|
-
if (currentIdx > 0) {
|
|
26
|
-
const priorVersions = allVersions.slice(0, currentIdx).slice(-2);
|
|
27
|
-
for (const pv of priorVersions) {
|
|
28
|
-
try {
|
|
29
|
-
const purl = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(pv)}`;
|
|
30
|
-
const pres = await fetch(purl);
|
|
31
|
-
if (pres.ok) {
|
|
32
|
-
const pdata = await pres.json();
|
|
33
|
-
if (pdata?.attestations?.length > 0) {
|
|
34
|
-
prevHadAttestation = true;
|
|
35
|
-
break;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// skip prior version check
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!prevHadAttestation && priorVersions.length > 0) {
|
|
44
|
-
anomalies.push(`First-ever SLSA attestation for ${packageName}, published in burst window`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const att of attestations) {
|
|
49
|
-
const ts = att?.timestamp;
|
|
50
|
-
if (ts) {
|
|
51
|
-
const attMs = new Date(ts).getTime();
|
|
52
|
-
if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
|
|
53
|
-
const gapMs = attMs - publishMs;
|
|
54
|
-
anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const builderId = att?.predicate?.runDetails?.builder?.id;
|
|
59
|
-
if (builderId) {
|
|
60
|
-
const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
|
|
61
|
-
const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
|
|
62
|
-
if (!isKnown) {
|
|
63
|
-
anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
return { triggered: false };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return { triggered: anomalies.length > 0, anomalies };
|
|
72
|
-
}
|
|
1
|
+
export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
|
|
2
|
+
if (!burstWindow?.triggered) return { triggered: false };
|
|
3
|
+
|
|
4
|
+
const anomalies = [];
|
|
5
|
+
const publishTime = timeMap?.[version];
|
|
6
|
+
if (!publishTime) return { triggered: false };
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
|
10
|
+
const res = await fetch(url);
|
|
11
|
+
if (!res.ok) return { triggered: false };
|
|
12
|
+
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
const attestations = data?.attestations || [];
|
|
15
|
+
if (attestations.length === 0) return { triggered: false };
|
|
16
|
+
|
|
17
|
+
const publishMs = new Date(publishTime).getTime();
|
|
18
|
+
if (Number.isNaN(publishMs)) return { triggered: false };
|
|
19
|
+
|
|
20
|
+
// Check if this is the first-ever attested version for this package
|
|
21
|
+
const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
|
|
22
|
+
const currentIdx = allVersions.indexOf(version);
|
|
23
|
+
let prevHadAttestation = false;
|
|
24
|
+
|
|
25
|
+
if (currentIdx > 0) {
|
|
26
|
+
const priorVersions = allVersions.slice(0, currentIdx).slice(-2);
|
|
27
|
+
for (const pv of priorVersions) {
|
|
28
|
+
try {
|
|
29
|
+
const purl = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(pv)}`;
|
|
30
|
+
const pres = await fetch(purl);
|
|
31
|
+
if (pres.ok) {
|
|
32
|
+
const pdata = await pres.json();
|
|
33
|
+
if (pdata?.attestations?.length > 0) {
|
|
34
|
+
prevHadAttestation = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// skip prior version check
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!prevHadAttestation && priorVersions.length > 0) {
|
|
44
|
+
anomalies.push(`First-ever SLSA attestation for ${packageName}, published in burst window`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const att of attestations) {
|
|
49
|
+
const ts = att?.timestamp;
|
|
50
|
+
if (ts) {
|
|
51
|
+
const attMs = new Date(ts).getTime();
|
|
52
|
+
if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
|
|
53
|
+
const gapMs = attMs - publishMs;
|
|
54
|
+
anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const builderId = att?.predicate?.runDetails?.builder?.id;
|
|
59
|
+
if (builderId) {
|
|
60
|
+
const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
|
|
61
|
+
const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
|
|
62
|
+
if (!isKnown) {
|
|
63
|
+
anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return { triggered: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { triggered: anomalies.length > 0, anomalies };
|
|
72
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
export async function checkMaintainerAnomaly(registryMeta, config = {}) {
|
|
2
|
-
const versions = registryMeta?.versions || {};
|
|
3
|
-
const timeMap = registryMeta?.time || {};
|
|
4
|
-
|
|
5
|
-
const sorted = Object.entries(timeMap)
|
|
6
|
-
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
7
|
-
.filter(([, t]) => t)
|
|
8
|
-
.map(([v, t]) => ({
|
|
9
|
-
version: v,
|
|
10
|
-
time: new Date(t).getTime(),
|
|
11
|
-
user: versions[v]?._npmUser?.name,
|
|
12
|
-
}))
|
|
13
|
-
.filter(e => !Number.isNaN(e.time) && e.user)
|
|
14
|
-
.sort((a, b) => a.time - b.time);
|
|
15
|
-
|
|
16
|
-
if (sorted.length < 2) return { triggered: false };
|
|
17
|
-
|
|
18
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
19
|
-
const prev = sorted[i - 1];
|
|
20
|
-
const curr = sorted[i];
|
|
21
|
-
|
|
22
|
-
if (curr.user !== prev.user) {
|
|
23
|
-
const gapMinutes = (curr.time - prev.time) / (1000 * 60);
|
|
24
|
-
if (gapMinutes <= 10) {
|
|
25
|
-
const newUserVersions = sorted.filter(e => e.user === curr.user);
|
|
26
|
-
if (newUserVersions.length >= 2) {
|
|
27
|
-
return {
|
|
28
|
-
triggered: true,
|
|
29
|
-
signals: [{
|
|
30
|
-
type: 'PUBLISHER_DRIFT_RAPID',
|
|
31
|
-
previousPublisher: prev.user,
|
|
32
|
-
newPublisher: curr.user,
|
|
33
|
-
gapMinutes,
|
|
34
|
-
newUserVersionCount: newUserVersions.length,
|
|
35
|
-
driftVersion: curr.version,
|
|
36
|
-
driftWindowStart: new Date(curr.time).toISOString(),
|
|
37
|
-
}],
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return { triggered: false };
|
|
45
|
-
}
|
|
1
|
+
export async function checkMaintainerAnomaly(registryMeta, config = {}) {
|
|
2
|
+
const versions = registryMeta?.versions || {};
|
|
3
|
+
const timeMap = registryMeta?.time || {};
|
|
4
|
+
|
|
5
|
+
const sorted = Object.entries(timeMap)
|
|
6
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
7
|
+
.filter(([, t]) => t)
|
|
8
|
+
.map(([v, t]) => ({
|
|
9
|
+
version: v,
|
|
10
|
+
time: new Date(t).getTime(),
|
|
11
|
+
user: versions[v]?._npmUser?.name,
|
|
12
|
+
}))
|
|
13
|
+
.filter(e => !Number.isNaN(e.time) && e.user)
|
|
14
|
+
.sort((a, b) => a.time - b.time);
|
|
15
|
+
|
|
16
|
+
if (sorted.length < 2) return { triggered: false };
|
|
17
|
+
|
|
18
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
19
|
+
const prev = sorted[i - 1];
|
|
20
|
+
const curr = sorted[i];
|
|
21
|
+
|
|
22
|
+
if (curr.user !== prev.user) {
|
|
23
|
+
const gapMinutes = (curr.time - prev.time) / (1000 * 60);
|
|
24
|
+
if (gapMinutes <= 10) {
|
|
25
|
+
const newUserVersions = sorted.filter(e => e.user === curr.user);
|
|
26
|
+
if (newUserVersions.length >= 2) {
|
|
27
|
+
return {
|
|
28
|
+
triggered: true,
|
|
29
|
+
signals: [{
|
|
30
|
+
type: 'PUBLISHER_DRIFT_RAPID',
|
|
31
|
+
previousPublisher: prev.user,
|
|
32
|
+
newPublisher: curr.user,
|
|
33
|
+
gapMinutes,
|
|
34
|
+
newUserVersionCount: newUserVersions.length,
|
|
35
|
+
driftVersion: curr.version,
|
|
36
|
+
driftWindowStart: new Date(curr.time).toISOString(),
|
|
37
|
+
}],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { triggered: false };
|
|
45
|
+
}
|
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
2
|
-
import { fileURLToPath } from 'url';
|
|
3
|
-
import { dirname, join } from 'path';
|
|
4
|
-
|
|
5
|
-
let iocsData = null;
|
|
6
|
-
let iocsLoaded = false;
|
|
7
|
-
let iocLoadError = null;
|
|
8
|
-
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname = dirname(__filename);
|
|
11
|
-
const IOC_PATH = join(__dirname, 'iocs.json');
|
|
12
|
-
|
|
13
|
-
function loadIOCData() {
|
|
14
|
-
if (iocsLoaded) return iocsData;
|
|
15
|
-
iocsLoaded = true;
|
|
16
|
-
try {
|
|
17
|
-
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
18
|
-
} catch (err) {
|
|
19
|
-
iocLoadError = err;
|
|
20
|
-
iocsData = null;
|
|
21
|
-
}
|
|
22
|
-
return iocsData;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function getIOCLoadError() {
|
|
26
|
-
return iocLoadError;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function reloadIOCData() {
|
|
30
|
-
iocsLoaded = false;
|
|
31
|
-
iocLoadError = null;
|
|
32
|
-
return loadIOCData();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
|
|
36
|
-
const data = loadIOCData();
|
|
37
|
-
if (!data) return { triggered: false, matches: [] };
|
|
38
|
-
|
|
39
|
-
const matches = [];
|
|
40
|
-
const allIOCs = [];
|
|
41
|
-
|
|
42
|
-
allIOCs.push(...(data.iocs || []));
|
|
43
|
-
|
|
44
|
-
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
|
-
const wave = data.waves[waveKey];
|
|
46
|
-
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
|
-
for (const ioc of (wave.iocs || [])) {
|
|
48
|
-
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
for (const ioc of allIOCs) {
|
|
53
|
-
switch (ioc.type) {
|
|
54
|
-
case 'packageName': {
|
|
55
|
-
if (ioc.value === pkgName) {
|
|
56
|
-
if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(pkgVersion)) {
|
|
57
|
-
matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
case 'packageScope': {
|
|
64
|
-
if (pkgName.startsWith(ioc.value)) {
|
|
65
|
-
matches.push({ type: 'packageScope', value: ioc.value, wave: ioc.wave });
|
|
66
|
-
}
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
case 'sha512': {
|
|
71
|
-
if (ioc.value === sha512 && ioc.package === pkgName) {
|
|
72
|
-
matches.push({ type: 'sha512', value: sha512, wave: ioc.wave, package: pkgName });
|
|
73
|
-
}
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
case 'publisherAccount': {
|
|
78
|
-
if (ioc.value === publisherAccount) {
|
|
79
|
-
const pubTime = new Date(timeMap?.[pkgVersion]).getTime();
|
|
80
|
-
const windowStart = new Date(ioc.compromiseWindowStart).getTime();
|
|
81
|
-
const windowEnd = ioc.compromiseWindowEnd
|
|
82
|
-
? new Date(ioc.compromiseWindowEnd).getTime()
|
|
83
|
-
: Infinity;
|
|
84
|
-
|
|
85
|
-
if (!Number.isNaN(pubTime) && pubTime >= windowStart && pubTime <= windowEnd) {
|
|
86
|
-
matches.push({ type: 'publisherAccount', value: publisherAccount, wave: ioc.wave });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return { triggered: matches.length > 0, matches };
|
|
95
|
-
}
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
|
|
5
|
+
let iocsData = null;
|
|
6
|
+
let iocsLoaded = false;
|
|
7
|
+
let iocLoadError = null;
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const IOC_PATH = join(__dirname, 'iocs.json');
|
|
12
|
+
|
|
13
|
+
function loadIOCData() {
|
|
14
|
+
if (iocsLoaded) return iocsData;
|
|
15
|
+
iocsLoaded = true;
|
|
16
|
+
try {
|
|
17
|
+
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
iocLoadError = err;
|
|
20
|
+
iocsData = null;
|
|
21
|
+
}
|
|
22
|
+
return iocsData;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getIOCLoadError() {
|
|
26
|
+
return iocLoadError;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function reloadIOCData() {
|
|
30
|
+
iocsLoaded = false;
|
|
31
|
+
iocLoadError = null;
|
|
32
|
+
return loadIOCData();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
|
|
36
|
+
const data = loadIOCData();
|
|
37
|
+
if (!data) return { triggered: false, matches: [] };
|
|
38
|
+
|
|
39
|
+
const matches = [];
|
|
40
|
+
const allIOCs = [];
|
|
41
|
+
|
|
42
|
+
allIOCs.push(...(data.iocs || []));
|
|
43
|
+
|
|
44
|
+
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
|
+
const wave = data.waves[waveKey];
|
|
46
|
+
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
|
+
for (const ioc of (wave.iocs || [])) {
|
|
48
|
+
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const ioc of allIOCs) {
|
|
53
|
+
switch (ioc.type) {
|
|
54
|
+
case 'packageName': {
|
|
55
|
+
if (ioc.value === pkgName) {
|
|
56
|
+
if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(pkgVersion)) {
|
|
57
|
+
matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'packageScope': {
|
|
64
|
+
if (pkgName.startsWith(ioc.value)) {
|
|
65
|
+
matches.push({ type: 'packageScope', value: ioc.value, wave: ioc.wave });
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'sha512': {
|
|
71
|
+
if (ioc.value === sha512 && ioc.package === pkgName) {
|
|
72
|
+
matches.push({ type: 'sha512', value: sha512, wave: ioc.wave, package: pkgName });
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'publisherAccount': {
|
|
78
|
+
if (ioc.value === publisherAccount) {
|
|
79
|
+
const pubTime = new Date(timeMap?.[pkgVersion]).getTime();
|
|
80
|
+
const windowStart = new Date(ioc.compromiseWindowStart).getTime();
|
|
81
|
+
const windowEnd = ioc.compromiseWindowEnd
|
|
82
|
+
? new Date(ioc.compromiseWindowEnd).getTime()
|
|
83
|
+
: Infinity;
|
|
84
|
+
|
|
85
|
+
if (!Number.isNaN(pubTime) && pubTime >= windowStart && pubTime <= windowEnd) {
|
|
86
|
+
matches.push({ type: 'publisherAccount', value: publisherAccount, wave: ioc.wave });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { triggered: matches.length > 0, matches };
|
|
95
|
+
}
|