@lateos/npm-scan 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +3 -98
- package/README.fr.md +3 -98
- package/README.ja.md +3 -98
- package/README.md +2 -122
- package/README.zh.md +3 -98
- package/backend/cra.js +113 -21
- package/backend/db.js +18 -10
- package/backend/detectors/atk-001-lifecycle.js +5 -5
- package/backend/detectors/atk-002-obfusc.js +126 -47
- package/backend/detectors/atk-003-creds.js +8 -4
- package/backend/detectors/atk-004-persist.js +3 -3
- package/backend/detectors/atk-005-exfil.js +8 -4
- package/backend/detectors/atk-006-depconf.js +3 -3
- package/backend/detectors/atk-007-typosquat.js +64 -10
- package/backend/detectors/atk-008-tarball-tamper.js +6 -6
- package/backend/detectors/atk-009-dormant-trigger.js +9 -5
- package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
- package/backend/detectors/atk-011-transitive-prop.js +14 -13
- package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
- package/backend/detectors/axios-poisoning/index.js +77 -60
- package/backend/detectors/config/thresholds.js +48 -3
- package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
- package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
- package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
- package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
- package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
- package/backend/detectors/hf-impersonation/index.js +94 -31
- package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
- package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
- package/backend/detectors/hf-impersonation/simhash.js +2 -2
- package/backend/detectors/index.js +181 -34
- package/backend/detectors/lib/ast-patterns.js +4 -1
- package/backend/detectors/lib/entropy-analyzer.js +12 -4
- package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
- package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
- package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
- package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
- package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
- package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
- package/backend/detectors/megalodon/index.js +35 -25
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
- package/backend/detectors/mini-shai-hulud/index.js +63 -26
- package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
- package/backend/detectors/msh-supplement/index.js +78 -63
- package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
- package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
- package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
- package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
- package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
- package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
- package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
- package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
- package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
- package/backend/detectors/node-ipc-compromise/index.js +21 -15
- package/backend/detectors/tier1-binary-embed.js +109 -41
- package/backend/detectors/tier1-cloud-imds.js +57 -37
- package/backend/detectors/tier1-encrypted-c2.js +198 -0
- package/backend/detectors/tier1-infostealer.js +121 -68
- package/backend/detectors/tier1-lifecycle-hook.js +63 -23
- package/backend/detectors/tier1-maintainer-compromise.js +157 -0
- package/backend/detectors/tier1-metadata-spoof.js +92 -42
- package/backend/detectors/tier1-multistage-postinstall.js +46 -19
- package/backend/detectors/tier1-obfuscation-heuristics.js +45 -17
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +1 -1
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +77 -41
- package/backend/detectors/tier1-version-confusion.js +79 -59
- package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
- package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
- package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
- package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
- package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
- package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
- package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
- package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
- package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
- package/backend/detectors/trapdoor/index.js +19 -14
- package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
- package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
- package/backend/detectors.test.js +78 -19
- package/backend/fetch.js +37 -29
- package/backend/index.js +1 -1
- package/backend/license.js +20 -4
- package/backend/lockfile.js +60 -36
- package/backend/pdf.js +107 -28
- package/backend/policy.js +183 -56
- package/backend/provenance.js +28 -3
- package/backend/report.js +136 -70
- package/backend/sbom.js +33 -27
- package/backend/scripts/analyze-false-positives.js +14 -8
- package/backend/scripts/analyze-validation.js +27 -21
- package/backend/scripts/detect-false-positives.js +20 -10
- package/backend/scripts/fetch-top-packages.js +197 -49
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +26 -17
- package/backend/siem/cef.js +23 -21
- package/backend/siem/ecs.js +3 -3
- package/backend/siem/index.js +1 -1
- package/backend/siem/qradar.js +3 -3
- package/backend/siem/sentinel.js +2 -2
- package/backend/tests-d5-enhanced.test.js +13 -12
- package/backend/tests-d6-version-anomaly.test.js +17 -8
- package/backend/tests-d6.test.js +24 -14
- package/backend/tests-d6c.test.js +27 -14
- package/backend/tests-d7-obfuscation.test.js +9 -12
- package/backend/tests.test.js +182 -83
- package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
- package/backend/vsix-scan/detectors/burst-publish.js +14 -7
- package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
- package/backend/vsix-scan/detectors/known-ioc.js +23 -8
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
- package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
- package/backend/vsix-scan/index.js +97 -41
- package/backend/vsix-scan/marketplace-client.js +29 -13
- package/cli/cli.js +154 -64
- package/package.json +12 -3
|
@@ -24,9 +24,10 @@ function resolveSeverity(signals, d4Evidence) {
|
|
|
24
24
|
maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const d4Hint = d4Evidence.find(e => e._severityHint);
|
|
27
|
+
const d4Hint = d4Evidence.find((e) => e._severityHint);
|
|
28
28
|
if (d4Hint) {
|
|
29
|
-
const hintScore =
|
|
29
|
+
const hintScore =
|
|
30
|
+
d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
|
|
30
31
|
maxScore = Math.max(maxScore, hintScore);
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -45,36 +46,45 @@ export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
|
|
|
45
46
|
const d3Ev = await scanD3(registryMeta);
|
|
46
47
|
allEvidence.push(...d3Ev);
|
|
47
48
|
|
|
48
|
-
const velocityResult =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
const velocityResult =
|
|
50
|
+
d3Ev.length > 0
|
|
51
|
+
? {
|
|
52
|
+
triggered: true,
|
|
53
|
+
windowStartISO: d3Ev[0]._windowStartISO || null,
|
|
54
|
+
versionsInWindow: d3Ev[0].excerpt || '',
|
|
55
|
+
_allVersions: d3Ev[0]._allVersions || [],
|
|
56
|
+
}
|
|
57
|
+
: { triggered: false, versionsInWindow: [], windowStartISO: null };
|
|
54
58
|
|
|
55
59
|
const d4Ev = await scanD4(registryMeta, velocityResult);
|
|
56
60
|
allEvidence.push(...d4Ev);
|
|
57
61
|
|
|
58
|
-
allEvidence.push(...await scanD5(registryMeta));
|
|
59
|
-
allEvidence.push(...await scanD6(pkgJson, registryMeta));
|
|
62
|
+
allEvidence.push(...(await scanD5(registryMeta)));
|
|
63
|
+
allEvidence.push(...(await scanD6(pkgJson, registryMeta)));
|
|
60
64
|
|
|
61
|
-
const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
|
|
65
|
+
const signals = [...new Set(allEvidence.map((e) => e.signal).filter(Boolean))];
|
|
62
66
|
|
|
63
|
-
if (signals.length === 0)
|
|
67
|
+
if (signals.length === 0) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
64
70
|
|
|
65
71
|
const severity = resolveSeverity(signals, d4Ev);
|
|
66
72
|
|
|
67
|
-
const cleaned = allEvidence.map(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
signals,
|
|
77
|
-
evidence:
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
const cleaned = allEvidence.map(
|
|
74
|
+
({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
id: 'MEGALODON',
|
|
80
|
+
severity,
|
|
81
|
+
title: 'Megalodon CI/CD attack campaign',
|
|
82
|
+
description: `${signals.length} signal(s): ${signals.join(', ')}`,
|
|
83
|
+
evidence: JSON.stringify({
|
|
84
|
+
campaign: 'MEGALODON',
|
|
85
|
+
signals,
|
|
86
|
+
evidence: cleaned,
|
|
87
|
+
}),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
80
90
|
}
|
|
@@ -10,7 +10,9 @@ export async function checkBurstPublish(registryMeta, config = {}) {
|
|
|
10
10
|
.filter(([, ts]) => !Number.isNaN(ts))
|
|
11
11
|
.sort((a, b) => a[1] - b[1]);
|
|
12
12
|
|
|
13
|
-
if (entries.length === 0)
|
|
13
|
+
if (entries.length === 0) {
|
|
14
|
+
return { triggered: false };
|
|
15
|
+
}
|
|
14
16
|
|
|
15
17
|
const windowMs = windowMinutes * 60 * 1000;
|
|
16
18
|
|
|
@@ -12,7 +12,9 @@ function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
|
|
|
12
12
|
.filter(([, ts]) => !Number.isNaN(ts))
|
|
13
13
|
.sort((a, b) => a[1] - b[1]);
|
|
14
14
|
|
|
15
|
-
if (entries.length === 0)
|
|
15
|
+
if (entries.length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
16
18
|
|
|
17
19
|
const windowMs = windowMinutes * 60 * 1000;
|
|
18
20
|
|
|
@@ -55,17 +57,23 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
|
55
57
|
for (const name of Object.keys(deps)) {
|
|
56
58
|
if (name.startsWith('@')) {
|
|
57
59
|
const scope = name.split('/')[0];
|
|
58
|
-
if (!scopedDeps[scope])
|
|
60
|
+
if (!scopedDeps[scope]) {
|
|
61
|
+
scopedDeps[scope] = [];
|
|
62
|
+
}
|
|
59
63
|
scopedDeps[scope].push(name);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
if (Object.keys(scopedDeps).length === 0)
|
|
67
|
+
if (Object.keys(scopedDeps).length === 0) {
|
|
68
|
+
return { triggered: false };
|
|
69
|
+
}
|
|
64
70
|
|
|
65
71
|
const results = [];
|
|
66
72
|
|
|
67
73
|
for (const [scope, packages] of Object.entries(scopedDeps)) {
|
|
68
|
-
if (packages.length < 2)
|
|
74
|
+
if (packages.length < 2) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
69
77
|
|
|
70
78
|
const burstSiblings = [];
|
|
71
79
|
|
|
@@ -75,7 +83,9 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
|
75
83
|
try {
|
|
76
84
|
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
|
|
77
85
|
const res = await fetch(url);
|
|
78
|
-
if (!res.ok)
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
79
89
|
const data = await res.json();
|
|
80
90
|
timeData = data.time || {};
|
|
81
91
|
siblingCache.set(pkg, timeData);
|
|
@@ -91,19 +101,19 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
if (burstSiblings.length >= 2) {
|
|
94
|
-
const windows = burstSiblings.map(s => ({
|
|
104
|
+
const windows = burstSiblings.map((s) => ({
|
|
95
105
|
start: new Date(s.windowStart).getTime(),
|
|
96
106
|
end: new Date(s.windowEnd).getTime(),
|
|
97
107
|
}));
|
|
98
108
|
|
|
99
|
-
const overlapStart = Math.max(...windows.map(w => w.start));
|
|
100
|
-
const overlapEnd = Math.min(...windows.map(w => w.end));
|
|
109
|
+
const overlapStart = Math.max(...windows.map((w) => w.start));
|
|
110
|
+
const overlapEnd = Math.min(...windows.map((w) => w.end));
|
|
101
111
|
|
|
102
112
|
if (overlapStart < overlapEnd) {
|
|
103
113
|
results.push({
|
|
104
114
|
triggered: true,
|
|
105
115
|
scope,
|
|
106
|
-
siblingPackages: burstSiblings.map(s => s.name),
|
|
116
|
+
siblingPackages: burstSiblings.map((s) => s.name),
|
|
107
117
|
windowStart: new Date(overlapStart).toISOString(),
|
|
108
118
|
windowEnd: new Date(overlapEnd).toISOString(),
|
|
109
119
|
});
|
|
@@ -111,6 +121,8 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
if (results.length === 0)
|
|
124
|
+
if (results.length === 0) {
|
|
125
|
+
return { triggered: false };
|
|
126
|
+
}
|
|
115
127
|
return { triggered: true, results };
|
|
116
128
|
}
|
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
export async function checkSlsaMismatch(
|
|
2
|
-
|
|
1
|
+
export async function checkSlsaMismatch(
|
|
2
|
+
packageName,
|
|
3
|
+
version,
|
|
4
|
+
burstWindow,
|
|
5
|
+
timeMap = {},
|
|
6
|
+
_config = {}
|
|
7
|
+
) {
|
|
8
|
+
if (!burstWindow?.triggered) {
|
|
9
|
+
return { triggered: false };
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
const anomalies = [];
|
|
5
13
|
const publishTime = timeMap?.[version];
|
|
6
|
-
if (!publishTime)
|
|
14
|
+
if (!publishTime) {
|
|
15
|
+
return { triggered: false };
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
try {
|
|
9
19
|
const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
|
|
10
20
|
const res = await fetch(url);
|
|
11
|
-
if (!res.ok)
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
return { triggered: false };
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
const data = await res.json();
|
|
14
26
|
const attestations = data?.attestations || [];
|
|
15
|
-
if (attestations.length === 0)
|
|
27
|
+
if (attestations.length === 0) {
|
|
28
|
+
return { triggered: false };
|
|
29
|
+
}
|
|
16
30
|
|
|
17
31
|
const publishMs = new Date(publishTime).getTime();
|
|
18
|
-
if (Number.isNaN(publishMs))
|
|
32
|
+
if (Number.isNaN(publishMs)) {
|
|
33
|
+
return { triggered: false };
|
|
34
|
+
}
|
|
19
35
|
|
|
20
36
|
// Check if this is the first-ever attested version for this package
|
|
21
|
-
const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
|
|
37
|
+
const allVersions = Object.keys(timeMap).filter((v) => v !== 'created' && v !== 'modified');
|
|
22
38
|
const currentIdx = allVersions.indexOf(version);
|
|
23
39
|
let prevHadAttestation = false;
|
|
24
40
|
|
|
@@ -49,7 +65,7 @@ export async function checkSlsaMismatch(packageName, version, burstWindow, timeM
|
|
|
49
65
|
const ts = att?.timestamp;
|
|
50
66
|
if (ts) {
|
|
51
67
|
const attMs = new Date(ts).getTime();
|
|
52
|
-
if (!Number.isNaN(attMs) && attMs >= publishMs &&
|
|
68
|
+
if (!Number.isNaN(attMs) && attMs >= publishMs && attMs - publishMs < 60000) {
|
|
53
69
|
const gapMs = attMs - publishMs;
|
|
54
70
|
anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
|
|
55
71
|
}
|
|
@@ -57,8 +73,12 @@ export async function checkSlsaMismatch(packageName, version, burstWindow, timeM
|
|
|
57
73
|
|
|
58
74
|
const builderId = att?.predicate?.runDetails?.builder?.id;
|
|
59
75
|
if (builderId) {
|
|
60
|
-
const knownPrefixes = [
|
|
61
|
-
|
|
76
|
+
const knownPrefixes = [
|
|
77
|
+
'https://github.com/',
|
|
78
|
+
'https://gitlab.com/',
|
|
79
|
+
'https://circleci.com/',
|
|
80
|
+
];
|
|
81
|
+
const isKnown = knownPrefixes.some((p) => builderId.startsWith(p));
|
|
62
82
|
if (!isKnown) {
|
|
63
83
|
anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
|
|
64
84
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export async function checkMaintainerAnomaly(registryMeta,
|
|
1
|
+
export async function checkMaintainerAnomaly(registryMeta, _config = {}) {
|
|
2
2
|
const versions = registryMeta?.versions || {};
|
|
3
3
|
const timeMap = registryMeta?.time || {};
|
|
4
4
|
|
|
@@ -10,10 +10,12 @@ export async function checkMaintainerAnomaly(registryMeta, config = {}) {
|
|
|
10
10
|
time: new Date(t).getTime(),
|
|
11
11
|
user: versions[v]?._npmUser?.name,
|
|
12
12
|
}))
|
|
13
|
-
.filter(e => !Number.isNaN(e.time) && e.user)
|
|
13
|
+
.filter((e) => !Number.isNaN(e.time) && e.user)
|
|
14
14
|
.sort((a, b) => a.time - b.time);
|
|
15
15
|
|
|
16
|
-
if (sorted.length < 2)
|
|
16
|
+
if (sorted.length < 2) {
|
|
17
|
+
return { triggered: false };
|
|
18
|
+
}
|
|
17
19
|
|
|
18
20
|
for (let i = 1; i < sorted.length; i++) {
|
|
19
21
|
const prev = sorted[i - 1];
|
|
@@ -22,19 +24,21 @@ export async function checkMaintainerAnomaly(registryMeta, config = {}) {
|
|
|
22
24
|
if (curr.user !== prev.user) {
|
|
23
25
|
const gapMinutes = (curr.time - prev.time) / (1000 * 60);
|
|
24
26
|
if (gapMinutes <= 10) {
|
|
25
|
-
const newUserVersions = sorted.filter(e => e.user === curr.user);
|
|
27
|
+
const newUserVersions = sorted.filter((e) => e.user === curr.user);
|
|
26
28
|
if (newUserVersions.length >= 2) {
|
|
27
29
|
return {
|
|
28
30
|
triggered: true,
|
|
29
|
-
signals: [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
signals: [
|
|
32
|
+
{
|
|
33
|
+
type: 'PUBLISHER_DRIFT_RAPID',
|
|
34
|
+
previousPublisher: prev.user,
|
|
35
|
+
newPublisher: curr.user,
|
|
36
|
+
gapMinutes,
|
|
37
|
+
newUserVersionCount: newUserVersions.length,
|
|
38
|
+
driftVersion: curr.version,
|
|
39
|
+
driftWindowStart: new Date(curr.time).toISOString(),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
38
42
|
};
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -11,7 +11,9 @@ const __dirname = dirname(__filename);
|
|
|
11
11
|
const IOC_PATH = join(__dirname, 'iocs.json');
|
|
12
12
|
|
|
13
13
|
function loadIOCData() {
|
|
14
|
-
if (iocsLoaded)
|
|
14
|
+
if (iocsLoaded) {
|
|
15
|
+
return iocsData;
|
|
16
|
+
}
|
|
15
17
|
iocsLoaded = true;
|
|
16
18
|
try {
|
|
17
19
|
iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
|
|
@@ -34,7 +36,9 @@ export function reloadIOCData() {
|
|
|
34
36
|
|
|
35
37
|
export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
|
|
36
38
|
const data = loadIOCData();
|
|
37
|
-
if (!data)
|
|
39
|
+
if (!data) {
|
|
40
|
+
return { triggered: false, matches: [] };
|
|
41
|
+
}
|
|
38
42
|
|
|
39
43
|
const matches = [];
|
|
40
44
|
const allIOCs = [];
|
|
@@ -44,7 +48,7 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
|
|
|
44
48
|
for (const waveKey of Object.keys(data.waves || {})) {
|
|
45
49
|
const wave = data.waves[waveKey];
|
|
46
50
|
const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
|
|
47
|
-
for (const ioc of
|
|
51
|
+
for (const ioc of wave.iocs || []) {
|
|
48
52
|
allIOCs.push({ ...ioc, wave: waveNum });
|
|
49
53
|
}
|
|
50
54
|
}
|
|
@@ -53,7 +57,11 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
|
|
|
53
57
|
switch (ioc.type) {
|
|
54
58
|
case 'packageName': {
|
|
55
59
|
if (ioc.value === pkgName) {
|
|
56
|
-
if (
|
|
60
|
+
if (
|
|
61
|
+
!ioc.maliciousVersions ||
|
|
62
|
+
ioc.maliciousVersions.length === 0 ||
|
|
63
|
+
ioc.maliciousVersions.includes(pkgVersion)
|
|
64
|
+
) {
|
|
57
65
|
matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
|
|
58
66
|
}
|
|
59
67
|
}
|
|
@@ -14,7 +14,9 @@ const SUSPICIOUS_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
|
14
14
|
const MAX_SNIPPET_LENGTH = 200;
|
|
15
15
|
|
|
16
16
|
function truncateSnippet(text) {
|
|
17
|
-
if (text.length <= MAX_SNIPPET_LENGTH)
|
|
17
|
+
if (text.length <= MAX_SNIPPET_LENGTH) {
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
18
20
|
return text.slice(0, MAX_SNIPPET_LENGTH - 3) + '...';
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -24,7 +26,9 @@ export function checkTokenExfil(allFiles, pkgJson) {
|
|
|
24
26
|
|
|
25
27
|
for (const hook of SUSPICIOUS_SCRIPTS) {
|
|
26
28
|
const scriptContent = scripts[hook];
|
|
27
|
-
if (!scriptContent)
|
|
29
|
+
if (!scriptContent) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
28
32
|
|
|
29
33
|
for (const pattern of EXFIL_PATTERNS) {
|
|
30
34
|
if (pattern.test(scriptContent)) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { checkBurstPublish } from './d1-burst-publish.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
checkSiblingCompromise,
|
|
4
|
+
clearSiblingCache as _clearSiblingCache,
|
|
5
|
+
} from './d2-sibling-compromise.js';
|
|
3
6
|
import { checkSlsaMismatch } from './d3-slsa-mismatch.js';
|
|
4
7
|
import { checkMaintainerAnomaly } from './d4-maintainer-anomaly.js';
|
|
5
8
|
import { checkIOC } from './d5-ioc-check.js';
|
|
@@ -31,15 +34,31 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
31
34
|
const nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
|
|
32
35
|
|
|
33
36
|
const triggeredChecks = [];
|
|
34
|
-
if (burstResult.triggered)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
37
|
+
if (burstResult.triggered) {
|
|
38
|
+
triggeredChecks.push('D1_BURST');
|
|
39
|
+
}
|
|
40
|
+
if (siblingResult.triggered) {
|
|
41
|
+
triggeredChecks.push('D2_SIBLING');
|
|
42
|
+
}
|
|
43
|
+
if (slsaResult.triggered) {
|
|
44
|
+
triggeredChecks.push('D3_SLSA');
|
|
45
|
+
}
|
|
46
|
+
if (maintainerResult.triggered) {
|
|
47
|
+
triggeredChecks.push('D4_MAINTAINER');
|
|
48
|
+
}
|
|
49
|
+
if (iocResult.triggered) {
|
|
50
|
+
triggeredChecks.push('D5_IOC');
|
|
51
|
+
}
|
|
52
|
+
if (exfilResult.triggered) {
|
|
53
|
+
triggeredChecks.push('D6_EXFIL');
|
|
54
|
+
}
|
|
55
|
+
if (nxDownstreamResult.triggered) {
|
|
56
|
+
triggeredChecks.push('D7_NX_CONSOLE');
|
|
57
|
+
}
|
|
41
58
|
|
|
42
|
-
if (triggeredChecks.length === 0)
|
|
59
|
+
if (triggeredChecks.length === 0) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
43
62
|
|
|
44
63
|
let waveAttribution = 'unknown';
|
|
45
64
|
if (pkgName.startsWith('@tanstack')) {
|
|
@@ -49,9 +68,10 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
49
68
|
} else if (nxDownstreamResult.triggered) {
|
|
50
69
|
waveAttribution = 'wave3-nx-console';
|
|
51
70
|
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
52
|
-
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
71
|
+
const waves = [...new Set(iocResult.matches.map((m) => m.wave))];
|
|
53
72
|
if (waves.length === 1) {
|
|
54
|
-
waveAttribution =
|
|
73
|
+
waveAttribution =
|
|
74
|
+
waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
|
|
55
75
|
}
|
|
56
76
|
}
|
|
57
77
|
|
|
@@ -62,10 +82,14 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
62
82
|
waveAttribution,
|
|
63
83
|
triggeredChecks,
|
|
64
84
|
burstWindow: burstResult.triggered
|
|
65
|
-
? {
|
|
85
|
+
? {
|
|
86
|
+
start: burstResult.windowStart,
|
|
87
|
+
end: burstResult.windowEnd,
|
|
88
|
+
versionCount: burstResult.versionCount,
|
|
89
|
+
}
|
|
66
90
|
: null,
|
|
67
91
|
siblingPackages: siblingResult.triggered
|
|
68
|
-
? siblingResult.results.flatMap(r => r.siblingPackages)
|
|
92
|
+
? siblingResult.results.flatMap((r) => r.siblingPackages)
|
|
69
93
|
: null,
|
|
70
94
|
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
71
95
|
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
@@ -75,25 +99,38 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
|
|
|
75
99
|
: null,
|
|
76
100
|
};
|
|
77
101
|
|
|
78
|
-
return [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
id: 'MINI_SHAI_HULUD',
|
|
105
|
+
severity: isCritical ? 'critical' : 'high',
|
|
106
|
+
title: 'Mini Shai-Hulud worm campaign',
|
|
107
|
+
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
108
|
+
evidence: JSON.stringify(evidence),
|
|
109
|
+
mitigation:
|
|
110
|
+
'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
|
|
111
|
+
},
|
|
112
|
+
];
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
89
|
-
const deps = {
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
const deps = {
|
|
117
|
+
...pkgJson?.dependencies,
|
|
118
|
+
...pkgJson?.devDependencies,
|
|
119
|
+
...pkgJson?.peerDependencies,
|
|
120
|
+
};
|
|
121
|
+
const nxDeps = Object.keys(deps).filter((d) => d.startsWith('@nx/') || d.startsWith('nrwl/'));
|
|
122
|
+
if (nxDeps.length === 0) {
|
|
123
|
+
return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
|
|
124
|
+
}
|
|
92
125
|
|
|
93
126
|
let vsCodeExtensions = [];
|
|
94
127
|
if (allFiles && Array.isArray(allFiles)) {
|
|
95
128
|
for (const file of allFiles) {
|
|
96
|
-
if (
|
|
129
|
+
if (
|
|
130
|
+
file.path &&
|
|
131
|
+
(file.path.endsWith('.vscode/extensions.json') ||
|
|
132
|
+
file.path.endsWith('.vscode/extensions.json'))
|
|
133
|
+
) {
|
|
97
134
|
try {
|
|
98
135
|
const content = typeof file.content === 'string' ? file.content : '';
|
|
99
136
|
const parsed = JSON.parse(content);
|
|
@@ -101,7 +138,7 @@ function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
|
101
138
|
...(parsed.recommendations || []),
|
|
102
139
|
...(parsed.unwantedRecommendations || []),
|
|
103
140
|
];
|
|
104
|
-
const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
|
|
141
|
+
const matched = allExts.filter((e) => e.includes('nrwl.angular-console'));
|
|
105
142
|
if (matched.length > 0) {
|
|
106
143
|
vsCodeExtensions = matched;
|
|
107
144
|
}
|
|
@@ -16,30 +16,48 @@ export function scanPersistence(pkgJson, files = []) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
20
|
-
const codeWithScripts = code + '\n' + allScripts.map(s => s.content).join('\n');
|
|
19
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
20
|
+
const codeWithScripts = code + '\n' + allScripts.map((s) => s.content).join('\n');
|
|
21
21
|
|
|
22
22
|
const detectedApis = [];
|
|
23
23
|
let hasCiGuard = false;
|
|
24
24
|
let hasDaemon = false;
|
|
25
25
|
|
|
26
|
-
if (DAEMON_RE.test(codeWithScripts))
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
26
|
+
if (DAEMON_RE.test(codeWithScripts)) {
|
|
27
|
+
detectedApis.push('daemon');
|
|
28
|
+
}
|
|
29
|
+
if (SPAWN_DETACHED_RE.test(codeWithScripts)) {
|
|
30
|
+
detectedApis.push('spawn_detached');
|
|
31
|
+
}
|
|
32
|
+
if (SYSTEMD_RE.test(codeWithScripts)) {
|
|
33
|
+
detectedApis.push('systemd');
|
|
34
|
+
}
|
|
35
|
+
if (CRON_RE.test(codeWithScripts)) {
|
|
36
|
+
detectedApis.push('cron');
|
|
37
|
+
}
|
|
38
|
+
if (LAUNCHD_RE.test(codeWithScripts)) {
|
|
39
|
+
detectedApis.push('launchd');
|
|
40
|
+
}
|
|
41
|
+
if (TASK_SCHED_RE.test(codeWithScripts)) {
|
|
42
|
+
detectedApis.push('task_scheduler');
|
|
43
|
+
}
|
|
44
|
+
if (CI_GUARD_RE.test(codeWithScripts)) {
|
|
45
|
+
hasCiGuard = true;
|
|
46
|
+
}
|
|
33
47
|
|
|
34
|
-
if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts))
|
|
48
|
+
if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts)) {
|
|
49
|
+
hasDaemon = true;
|
|
50
|
+
}
|
|
35
51
|
|
|
36
52
|
if (hasDaemon || detectedApis.length > 0) {
|
|
37
53
|
return {
|
|
38
54
|
triggered: true,
|
|
39
55
|
detectedApis,
|
|
40
56
|
hasCiGuard,
|
|
41
|
-
hooks: allScripts.map(s => s.hook),
|
|
42
|
-
context: hasCiGuard
|
|
57
|
+
hooks: allScripts.map((s) => s.hook),
|
|
58
|
+
context: hasCiGuard
|
|
59
|
+
? 'Spawns background process when CI env var absent'
|
|
60
|
+
: 'Suspicious persistence/detached process detected',
|
|
43
61
|
};
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -9,25 +9,37 @@ const TARGET_LOCALES = /ru_RU|be_BY|uk_UA/;
|
|
|
9
9
|
const SILENT_EXIT_RE = /process\.exit\s*\(\s*0\s*\)/;
|
|
10
10
|
|
|
11
11
|
export function scanGeoKillswitch(files = []) {
|
|
12
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
13
|
-
if (!code)
|
|
12
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
13
|
+
if (!code) {
|
|
14
|
+
return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
15
|
+
}
|
|
14
16
|
|
|
15
|
-
const hasLocaleCheck = LOCALE_CHECKS.some(re => re.test(code));
|
|
16
|
-
if (!hasLocaleCheck)
|
|
17
|
+
const hasLocaleCheck = LOCALE_CHECKS.some((re) => re.test(code));
|
|
18
|
+
if (!hasLocaleCheck) {
|
|
19
|
+
return { triggered: false, targetedLocales: [], triggerBehavior: null };
|
|
20
|
+
}
|
|
17
21
|
|
|
18
22
|
const hasTargetLocale = TARGET_LOCALES.test(code);
|
|
19
23
|
const hasSilentExit = SILENT_EXIT_RE.test(code);
|
|
20
24
|
|
|
21
25
|
if (hasTargetLocale || hasSilentExit) {
|
|
22
26
|
const matchedLocales = [];
|
|
23
|
-
if (/ru_RU/.test(code))
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if (/ru_RU/.test(code)) {
|
|
28
|
+
matchedLocales.push('ru_RU');
|
|
29
|
+
}
|
|
30
|
+
if (/be_BY/.test(code)) {
|
|
31
|
+
matchedLocales.push('be_BY');
|
|
32
|
+
}
|
|
33
|
+
if (/uk_UA/.test(code)) {
|
|
34
|
+
matchedLocales.push('uk_UA');
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
return {
|
|
28
38
|
triggered: true,
|
|
29
39
|
targetedLocales: matchedLocales.length > 0 ? matchedLocales : ['ru_RU', 'be_BY'],
|
|
30
|
-
triggerBehavior: hasSilentExit
|
|
40
|
+
triggerBehavior: hasSilentExit
|
|
41
|
+
? 'Silent exit'
|
|
42
|
+
: 'Locale/timezone match with conditional behavior',
|
|
31
43
|
};
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -5,13 +5,19 @@ const GITHUB_TOKEN_ACCESS_RE = /process\.env\.(?:GH_TOKEN|GITHUB_TOKEN|GITHUB_AC
|
|
|
5
5
|
const COMMIT_PARSE_LOOP_RE = /commits?\s*\.\s*(?:map|filter|forEach|for\s*\(|while\s*\()/;
|
|
6
6
|
|
|
7
7
|
export function scanC2DeadDrop(files = []) {
|
|
8
|
-
const code = files.map(f => f.content || '').join('\n');
|
|
9
|
-
if (!code)
|
|
8
|
+
const code = files.map((f) => f.content || '').join('\n');
|
|
9
|
+
if (!code) {
|
|
10
|
+
return { triggered: false, matches: [] };
|
|
11
|
+
}
|
|
10
12
|
|
|
11
13
|
const matches = [];
|
|
12
14
|
|
|
13
15
|
if (OHNO_WHATS_GOING_ON_RE.test(code)) {
|
|
14
|
-
matches.push({
|
|
16
|
+
matches.push({
|
|
17
|
+
type: 'ioc_keyword',
|
|
18
|
+
value: 'OhNoWhatsGoingOnWithGitHub',
|
|
19
|
+
attackVector: 'GitHub commit scraping for token recovery',
|
|
20
|
+
});
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
const hasTokenAccess = GITHUB_TOKEN_ACCESS_RE.test(code);
|
|
@@ -19,11 +25,19 @@ export function scanC2DeadDrop(files = []) {
|
|
|
19
25
|
const hasCommitParseLoop = COMMIT_PARSE_LOOP_RE.test(code);
|
|
20
26
|
|
|
21
27
|
if (hasTokenAccess && hasGithubApi) {
|
|
22
|
-
matches.push({
|
|
28
|
+
matches.push({
|
|
29
|
+
type: 'token_exfil_github_api',
|
|
30
|
+
value: 'Credential access followed by GitHub API call',
|
|
31
|
+
attackVector: 'Credential/token extraction followed by GitHub API calls',
|
|
32
|
+
});
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
if (hasCommitParseLoop && hasGithubApi) {
|
|
26
|
-
matches.push({
|
|
36
|
+
matches.push({
|
|
37
|
+
type: 'commit_scraping',
|
|
38
|
+
value: 'Commit message parsing with GitHub API',
|
|
39
|
+
attackVector: 'Commit scraping for secret detection',
|
|
40
|
+
});
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
return {
|