@lateos/npm-scan 0.15.3 → 0.15.4
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/backend/detectors/index.js +2 -0
- package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -0
- package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -0
- package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -0
- package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -0
- package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -0
- package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -0
- package/backend/detectors/mini-shai-hulud/index.js +80 -0
- package/backend/detectors/mini-shai-hulud/iocs.json +47 -0
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import * as atk010 from './atk-010-sandbox-evasion.js';
|
|
|
11
11
|
import * as atk011 from './atk-011-transitive-prop.js';
|
|
12
12
|
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
13
13
|
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
|
+
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
14
15
|
|
|
15
16
|
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
16
17
|
const findings = [];
|
|
@@ -27,5 +28,6 @@ export async function runAll(pkgJson, files = [], registryMeta = null, allFiles
|
|
|
27
28
|
findings.push(...await atk011.scan(pkgJson, files));
|
|
28
29
|
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
29
30
|
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
31
|
+
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
30
32
|
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
31
33
|
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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 : 2;
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const EXFIL_PATTERNS = [
|
|
2
|
+
/NPM_TOKEN|NODE_AUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN|npm_token|node_auth_token/i,
|
|
3
|
+
/~\/(\.npmrc|\.gitconfig|\.aws\/credentials)/,
|
|
4
|
+
/\/run\/secrets\//,
|
|
5
|
+
/\$GITHUB_ENV/,
|
|
6
|
+
/process\.env\.(NPM_TOKEN|NODE_AUTH_TOKEN|GH_TOKEN|GITHUB_TOKEN)/,
|
|
7
|
+
/Buffer\.from\s*\([^)]*\)\s*\.\s*toString\s*\(\s*['"]base64['"]\s*\)/,
|
|
8
|
+
/\batob\s*\(/,
|
|
9
|
+
/\bbtoa\s*\(/,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const SUSPICIOUS_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
13
|
+
|
|
14
|
+
const MAX_SNIPPET_LENGTH = 200;
|
|
15
|
+
|
|
16
|
+
function truncateSnippet(text) {
|
|
17
|
+
if (text.length <= MAX_SNIPPET_LENGTH) return text;
|
|
18
|
+
return text.slice(0, MAX_SNIPPET_LENGTH - 3) + '...';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkTokenExfil(allFiles, pkgJson) {
|
|
22
|
+
const scripts = pkgJson?.scripts || {};
|
|
23
|
+
const snippets = [];
|
|
24
|
+
|
|
25
|
+
for (const hook of SUSPICIOUS_SCRIPTS) {
|
|
26
|
+
const scriptContent = scripts[hook];
|
|
27
|
+
if (!scriptContent) continue;
|
|
28
|
+
|
|
29
|
+
for (const pattern of EXFIL_PATTERNS) {
|
|
30
|
+
if (pattern.test(scriptContent)) {
|
|
31
|
+
snippets.push(truncateSnippet(scriptContent));
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { triggered: snippets.length > 0, snippets };
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { checkBurstPublish } from './d1-burst-publish.js';
|
|
2
|
+
import { checkSiblingCompromise, clearSiblingCache } from './d2-sibling-compromise.js';
|
|
3
|
+
import { checkSlsaMismatch } from './d3-slsa-mismatch.js';
|
|
4
|
+
import { checkMaintainerAnomaly } from './d4-maintainer-anomaly.js';
|
|
5
|
+
import { checkIOC } from './d5-ioc-check.js';
|
|
6
|
+
import { checkTokenExfil } from './d6-token-exfil.js';
|
|
7
|
+
|
|
8
|
+
export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
9
|
+
const config = {};
|
|
10
|
+
|
|
11
|
+
const burstResult = await checkBurstPublish(registryMeta, config);
|
|
12
|
+
const maintainerResult = await checkMaintainerAnomaly(registryMeta, config);
|
|
13
|
+
|
|
14
|
+
const pkgName = pkgJson?.name || '';
|
|
15
|
+
const pkgVersion = pkgJson?.version || '';
|
|
16
|
+
const sha512 = registryMeta?.versions?.[pkgVersion]?.dist?.integrity || null;
|
|
17
|
+
const publisherAccount = registryMeta?.versions?.[pkgVersion]?._npmUser?.name || null;
|
|
18
|
+
const timeMap = registryMeta?.time || {};
|
|
19
|
+
|
|
20
|
+
const iocResult = await checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap);
|
|
21
|
+
const exfilResult = checkTokenExfil(allFiles || files, pkgJson);
|
|
22
|
+
|
|
23
|
+
let siblingResult = { triggered: false };
|
|
24
|
+
let slsaResult = { triggered: false };
|
|
25
|
+
|
|
26
|
+
if (burstResult.triggered) {
|
|
27
|
+
siblingResult = await checkSiblingCompromise(pkgJson, config);
|
|
28
|
+
slsaResult = await checkSlsaMismatch(pkgName, pkgVersion, burstResult, timeMap, config);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const triggeredChecks = [];
|
|
32
|
+
if (burstResult.triggered) triggeredChecks.push('D1_BURST');
|
|
33
|
+
if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
|
|
34
|
+
if (slsaResult.triggered) triggeredChecks.push('D3_SLSA');
|
|
35
|
+
if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
|
|
36
|
+
if (iocResult.triggered) triggeredChecks.push('D5_IOC');
|
|
37
|
+
if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
|
|
38
|
+
|
|
39
|
+
if (triggeredChecks.length === 0) return [];
|
|
40
|
+
|
|
41
|
+
let waveAttribution = 'unknown';
|
|
42
|
+
if (pkgName.startsWith('@tanstack')) {
|
|
43
|
+
waveAttribution = 'wave1-tanstack';
|
|
44
|
+
} else if (pkgName.startsWith('@antv')) {
|
|
45
|
+
waveAttribution = 'wave2-antv';
|
|
46
|
+
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
47
|
+
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
48
|
+
if (waves.length === 1) {
|
|
49
|
+
waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : 'wave2-antv';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isCritical = slsaResult.triggered || iocResult.triggered;
|
|
54
|
+
|
|
55
|
+
const evidence = {
|
|
56
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
57
|
+
waveAttribution,
|
|
58
|
+
triggeredChecks,
|
|
59
|
+
burstWindow: burstResult.triggered
|
|
60
|
+
? { start: burstResult.windowStart, end: burstResult.windowEnd, versionCount: burstResult.versionCount }
|
|
61
|
+
: null,
|
|
62
|
+
siblingPackages: siblingResult.triggered
|
|
63
|
+
? siblingResult.results.flatMap(r => r.siblingPackages)
|
|
64
|
+
: null,
|
|
65
|
+
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
66
|
+
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
67
|
+
installScriptSnippets: exfilResult.triggered ? exfilResult.snippets : null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return [{
|
|
71
|
+
id: 'MINI_SHAI_HULUD',
|
|
72
|
+
severity: isCritical ? 'critical' : 'high',
|
|
73
|
+
title: 'Mini Shai-Hulud worm campaign',
|
|
74
|
+
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
75
|
+
evidence: JSON.stringify(evidence),
|
|
76
|
+
mitigation: '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.',
|
|
77
|
+
}];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { clearSiblingCache } from './d2-sibling-compromise.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lastUpdated": "2026-05-24T00:00:00.000Z",
|
|
3
|
+
"waves": {
|
|
4
|
+
"wave1": {
|
|
5
|
+
"id": "mini-shai-hulud-wave1",
|
|
6
|
+
"description": "TanStack CI/CD hijack (mid-May 2026) — 84 malicious versions across 42 packages in ~6 minutes via compromised GitHub Actions CI. Forged SLSA BL3 provenance attestations.",
|
|
7
|
+
"windowMinutes": 6,
|
|
8
|
+
"iocs": [
|
|
9
|
+
{
|
|
10
|
+
"type": "packageScope",
|
|
11
|
+
"value": "@tanstack",
|
|
12
|
+
"maliciousVersionRanges": [],
|
|
13
|
+
"notes": "Seed IOC — update from threat intel feed. Affected: @tanstack/router, @tanstack/react-router, @tanstack/query, @tanstack/form, @tanstack/store, @tanstack/virtual, @tanstack/ranger, @tanstack/table."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"wave2": {
|
|
18
|
+
"id": "mini-shai-hulud-wave2",
|
|
19
|
+
"description": "AntV/atool maintainer account compromise (late May 2026) — 600+ malicious versions across 300+ packages in ~22 minutes. ~16M weekly download blast radius.",
|
|
20
|
+
"windowMinutes": 22,
|
|
21
|
+
"iocs": [
|
|
22
|
+
{
|
|
23
|
+
"type": "publisherAccount",
|
|
24
|
+
"value": "atool",
|
|
25
|
+
"compromiseWindowStart": "2026-05-20T00:00:00.000Z",
|
|
26
|
+
"compromiseWindowEnd": null,
|
|
27
|
+
"notes": "Seed IOC — compromised @antv/atool maintainer account. Update compromise window from threat intel."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"type": "packageScope",
|
|
31
|
+
"value": "@antv",
|
|
32
|
+
"maliciousVersionRanges": [],
|
|
33
|
+
"notes": "Blast radius: @antv/g2, @antv/g6, @antv/x6, @antv/l7, echarts-for-react, timeago.js. Seed IOC — update from threat intel."
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"iocs": [
|
|
39
|
+
{
|
|
40
|
+
"type": "sha512",
|
|
41
|
+
"value": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
42
|
+
"package": "@antv/g2",
|
|
43
|
+
"wave": 2,
|
|
44
|
+
"notes": "Placeholder sha512 — replace with actual SHA-512 integrity hash from npm dist.integrity of a confirmed malicious version."
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lateos/npm-scan",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "Modern npm supply chain security scanner — detects obfuscated payloads, credential stealers, conditional triggers, sandbox evasion, and worm-like propagation. 11 attack types, SBOM, NIST/EU CRA compliance reporting.",
|
|
5
5
|
"main": "backend/index.js",
|
|
6
6
|
"bin": {
|