@lateos/npm-scan 0.16.4 → 0.16.5
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/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +199 -199
- 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 +826 -826
- package/README.zh.md +708 -708
- package/SECURITY.md +72 -72
- package/backend/cra.js +68 -68
- 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/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 +75 -44
- 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 +219 -0
- package/backend/detectors/tier1-infostealer.js +280 -0
- package/backend/detectors/tier1-lifecycle-hook.js +176 -0
- package/backend/detectors/tier1-metadata-spoof.js +180 -0
- package/backend/detectors/tier1-typosquat.js +219 -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 -176
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- 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/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/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/scripts/generate-campaign-fixtures.js +170 -0
- package/src/config/top-5000.json +87 -0
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- package/test/fixtures/mock-data.js +69 -69
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
import { MegalodonSignal } from './types.js';
|
|
2
|
-
import { scan as scanD1 } from './d1-workflow-scan.js';
|
|
3
|
-
import { scan as scanD2 } from './d2-credential-harvest.js';
|
|
4
|
-
import { scan as scanD3 } from './d3-publish-velocity.js';
|
|
5
|
-
import { scan as scanD4 } from './d4-publisher-drift.js';
|
|
6
|
-
import { scan as scanD5 } from './d5-bot-commit-identity.js';
|
|
7
|
-
import { scan as scanD6 } from './d6-date-anachronism.js';
|
|
8
|
-
|
|
9
|
-
const SIGNAL_SEVERITY = {
|
|
10
|
-
[MegalodonSignal.WORKFLOW_C2_EXFIL]: 5,
|
|
11
|
-
[MegalodonSignal.WORKFLOW_DECODE_CHAIN]: 4,
|
|
12
|
-
[MegalodonSignal.PUBLISH_VELOCITY]: 4,
|
|
13
|
-
[MegalodonSignal.PUBLISHER_DRIFT]: 4,
|
|
14
|
-
[MegalodonSignal.CREDENTIAL_HARVEST]: 3,
|
|
15
|
-
[MegalodonSignal.BOT_COMMIT_IDENTITY]: 2,
|
|
16
|
-
[MegalodonSignal.DATE_ANACHRONISM]: 2,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical', 'critical'];
|
|
20
|
-
|
|
21
|
-
function resolveSeverity(signals, d4Evidence) {
|
|
22
|
-
let maxScore = 0;
|
|
23
|
-
for (const s of signals) {
|
|
24
|
-
maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const d4Hint = d4Evidence.find(e => e._severityHint);
|
|
28
|
-
if (d4Hint) {
|
|
29
|
-
const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
|
|
30
|
-
maxScore = Math.max(maxScore, hintScore);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return SEVERITY_LABELS[maxScore] || 'none';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
|
|
37
|
-
const allEvidence = [];
|
|
38
|
-
|
|
39
|
-
const d1Ev = await scanD1(allFiles);
|
|
40
|
-
allEvidence.push(...d1Ev);
|
|
41
|
-
|
|
42
|
-
const d2Ev = await scanD2(allFiles);
|
|
43
|
-
allEvidence.push(...d2Ev);
|
|
44
|
-
|
|
45
|
-
const d3Ev = await scanD3(registryMeta);
|
|
46
|
-
allEvidence.push(...d3Ev);
|
|
47
|
-
|
|
48
|
-
const velocityResult = d3Ev.length > 0 ? {
|
|
49
|
-
triggered: true,
|
|
50
|
-
windowStartISO: d3Ev[0]._windowStartISO || null,
|
|
51
|
-
versionsInWindow: d3Ev[0].excerpt || '',
|
|
52
|
-
_allVersions: d3Ev[0]._allVersions || [],
|
|
53
|
-
} : { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
54
|
-
|
|
55
|
-
const d4Ev = await scanD4(registryMeta, velocityResult);
|
|
56
|
-
allEvidence.push(...d4Ev);
|
|
57
|
-
|
|
58
|
-
allEvidence.push(...await scanD5(registryMeta));
|
|
59
|
-
allEvidence.push(...await scanD6(pkgJson, registryMeta));
|
|
60
|
-
|
|
61
|
-
const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
|
|
62
|
-
|
|
63
|
-
if (signals.length === 0) return [];
|
|
64
|
-
|
|
65
|
-
const severity = resolveSeverity(signals, d4Ev);
|
|
66
|
-
|
|
67
|
-
const cleaned = allEvidence.map(({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest);
|
|
68
|
-
|
|
69
|
-
return [{
|
|
70
|
-
id: 'MEGALODON',
|
|
71
|
-
severity,
|
|
72
|
-
title: 'Megalodon CI/CD attack campaign',
|
|
73
|
-
description: `${signals.length} signal(s): ${signals.join(', ')}`,
|
|
74
|
-
evidence: JSON.stringify({
|
|
75
|
-
campaign: 'MEGALODON',
|
|
76
|
-
signals,
|
|
77
|
-
evidence: cleaned,
|
|
78
|
-
}),
|
|
79
|
-
}];
|
|
80
|
-
}
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
import { scan as scanD1 } from './d1-workflow-scan.js';
|
|
3
|
+
import { scan as scanD2 } from './d2-credential-harvest.js';
|
|
4
|
+
import { scan as scanD3 } from './d3-publish-velocity.js';
|
|
5
|
+
import { scan as scanD4 } from './d4-publisher-drift.js';
|
|
6
|
+
import { scan as scanD5 } from './d5-bot-commit-identity.js';
|
|
7
|
+
import { scan as scanD6 } from './d6-date-anachronism.js';
|
|
8
|
+
|
|
9
|
+
const SIGNAL_SEVERITY = {
|
|
10
|
+
[MegalodonSignal.WORKFLOW_C2_EXFIL]: 5,
|
|
11
|
+
[MegalodonSignal.WORKFLOW_DECODE_CHAIN]: 4,
|
|
12
|
+
[MegalodonSignal.PUBLISH_VELOCITY]: 4,
|
|
13
|
+
[MegalodonSignal.PUBLISHER_DRIFT]: 4,
|
|
14
|
+
[MegalodonSignal.CREDENTIAL_HARVEST]: 3,
|
|
15
|
+
[MegalodonSignal.BOT_COMMIT_IDENTITY]: 2,
|
|
16
|
+
[MegalodonSignal.DATE_ANACHRONISM]: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical', 'critical'];
|
|
20
|
+
|
|
21
|
+
function resolveSeverity(signals, d4Evidence) {
|
|
22
|
+
let maxScore = 0;
|
|
23
|
+
for (const s of signals) {
|
|
24
|
+
maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const d4Hint = d4Evidence.find(e => e._severityHint);
|
|
28
|
+
if (d4Hint) {
|
|
29
|
+
const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
|
|
30
|
+
maxScore = Math.max(maxScore, hintScore);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return SEVERITY_LABELS[maxScore] || 'none';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
|
|
37
|
+
const allEvidence = [];
|
|
38
|
+
|
|
39
|
+
const d1Ev = await scanD1(allFiles);
|
|
40
|
+
allEvidence.push(...d1Ev);
|
|
41
|
+
|
|
42
|
+
const d2Ev = await scanD2(allFiles);
|
|
43
|
+
allEvidence.push(...d2Ev);
|
|
44
|
+
|
|
45
|
+
const d3Ev = await scanD3(registryMeta);
|
|
46
|
+
allEvidence.push(...d3Ev);
|
|
47
|
+
|
|
48
|
+
const velocityResult = d3Ev.length > 0 ? {
|
|
49
|
+
triggered: true,
|
|
50
|
+
windowStartISO: d3Ev[0]._windowStartISO || null,
|
|
51
|
+
versionsInWindow: d3Ev[0].excerpt || '',
|
|
52
|
+
_allVersions: d3Ev[0]._allVersions || [],
|
|
53
|
+
} : { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
54
|
+
|
|
55
|
+
const d4Ev = await scanD4(registryMeta, velocityResult);
|
|
56
|
+
allEvidence.push(...d4Ev);
|
|
57
|
+
|
|
58
|
+
allEvidence.push(...await scanD5(registryMeta));
|
|
59
|
+
allEvidence.push(...await scanD6(pkgJson, registryMeta));
|
|
60
|
+
|
|
61
|
+
const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
|
|
62
|
+
|
|
63
|
+
if (signals.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const severity = resolveSeverity(signals, d4Ev);
|
|
66
|
+
|
|
67
|
+
const cleaned = allEvidence.map(({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest);
|
|
68
|
+
|
|
69
|
+
return [{
|
|
70
|
+
id: 'MEGALODON',
|
|
71
|
+
severity,
|
|
72
|
+
title: 'Megalodon CI/CD attack campaign',
|
|
73
|
+
description: `${signals.length} signal(s): ${signals.join(', ')}`,
|
|
74
|
+
evidence: JSON.stringify({
|
|
75
|
+
campaign: 'MEGALODON',
|
|
76
|
+
signals,
|
|
77
|
+
evidence: cleaned,
|
|
78
|
+
}),
|
|
79
|
+
}];
|
|
80
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export const MegalodonSignal = Object.freeze({
|
|
2
|
-
WORKFLOW_C2_EXFIL: 'D1_WORKFLOW_C2_EXFIL',
|
|
3
|
-
WORKFLOW_DECODE_CHAIN: 'D1_WORKFLOW_DECODE_CHAIN',
|
|
4
|
-
CREDENTIAL_HARVEST: 'D2_CREDENTIAL_HARVEST',
|
|
5
|
-
PUBLISH_VELOCITY: 'D3_PUBLISH_VELOCITY',
|
|
6
|
-
PUBLISHER_DRIFT: 'D4_PUBLISHER_DRIFT',
|
|
7
|
-
BOT_COMMIT_IDENTITY: 'D5_BOT_COMMIT_IDENTITY',
|
|
8
|
-
DATE_ANACHRONISM: 'D6_DATE_ANACHRONISM',
|
|
9
|
-
});
|
|
1
|
+
export const MegalodonSignal = Object.freeze({
|
|
2
|
+
WORKFLOW_C2_EXFIL: 'D1_WORKFLOW_C2_EXFIL',
|
|
3
|
+
WORKFLOW_DECODE_CHAIN: 'D1_WORKFLOW_DECODE_CHAIN',
|
|
4
|
+
CREDENTIAL_HARVEST: 'D2_CREDENTIAL_HARVEST',
|
|
5
|
+
PUBLISH_VELOCITY: 'D3_PUBLISH_VELOCITY',
|
|
6
|
+
PUBLISHER_DRIFT: 'D4_PUBLISHER_DRIFT',
|
|
7
|
+
BOT_COMMIT_IDENTITY: 'D5_BOT_COMMIT_IDENTITY',
|
|
8
|
+
DATE_ANACHRONISM: 'D6_DATE_ANACHRONISM',
|
|
9
|
+
});
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
export async function checkBurstPublish(registryMeta, config = {}) {
|
|
2
|
-
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
3
|
-
const threshold = config.burstVersionThreshold ?? 3;
|
|
4
|
-
|
|
5
|
-
const times = registryMeta?.time || {};
|
|
6
|
-
const entries = Object.entries(times)
|
|
7
|
-
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
8
|
-
.filter(([, t]) => t)
|
|
9
|
-
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
10
|
-
.filter(([, ts]) => !Number.isNaN(ts))
|
|
11
|
-
.sort((a, b) => a[1] - b[1]);
|
|
12
|
-
|
|
13
|
-
if (entries.length === 0) return { triggered: false };
|
|
14
|
-
|
|
15
|
-
const windowMs = windowMinutes * 60 * 1000;
|
|
16
|
-
|
|
17
|
-
for (let i = 0; i < entries.length; i++) {
|
|
18
|
-
const windowStart = entries[i][1];
|
|
19
|
-
const windowEnd = windowStart + windowMs;
|
|
20
|
-
const inWindow = [];
|
|
21
|
-
|
|
22
|
-
for (let j = i; j < entries.length; j++) {
|
|
23
|
-
if (entries[j][1] <= windowEnd) {
|
|
24
|
-
inWindow.push(entries[j][0]);
|
|
25
|
-
} else {
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (inWindow.length >= threshold) {
|
|
31
|
-
return {
|
|
32
|
-
triggered: true,
|
|
33
|
-
windowStart: new Date(windowStart).toISOString(),
|
|
34
|
-
windowEnd: new Date(windowEnd).toISOString(),
|
|
35
|
-
versionCount: inWindow.length,
|
|
36
|
-
versions: inWindow,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return { triggered: false };
|
|
42
|
-
}
|
|
1
|
+
export async function checkBurstPublish(registryMeta, config = {}) {
|
|
2
|
+
const windowMinutes = config.burstWindowMinutes ?? 30;
|
|
3
|
+
const threshold = config.burstVersionThreshold ?? 3;
|
|
4
|
+
|
|
5
|
+
const times = registryMeta?.time || {};
|
|
6
|
+
const entries = Object.entries(times)
|
|
7
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
8
|
+
.filter(([, t]) => t)
|
|
9
|
+
.map(([v, t]) => [v, new Date(t).getTime()])
|
|
10
|
+
.filter(([, ts]) => !Number.isNaN(ts))
|
|
11
|
+
.sort((a, b) => a[1] - b[1]);
|
|
12
|
+
|
|
13
|
+
if (entries.length === 0) return { triggered: false };
|
|
14
|
+
|
|
15
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < entries.length; i++) {
|
|
18
|
+
const windowStart = entries[i][1];
|
|
19
|
+
const windowEnd = windowStart + windowMs;
|
|
20
|
+
const inWindow = [];
|
|
21
|
+
|
|
22
|
+
for (let j = i; j < entries.length; j++) {
|
|
23
|
+
if (entries[j][1] <= windowEnd) {
|
|
24
|
+
inWindow.push(entries[j][0]);
|
|
25
|
+
} else {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (inWindow.length >= threshold) {
|
|
31
|
+
return {
|
|
32
|
+
triggered: true,
|
|
33
|
+
windowStart: new Date(windowStart).toISOString(),
|
|
34
|
+
windowEnd: new Date(windowEnd).toISOString(),
|
|
35
|
+
versionCount: inWindow.length,
|
|
36
|
+
versions: inWindow,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { triggered: false };
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|