@lateos/npm-scan 0.16.0 → 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 -669
- package/README.fr.md +707 -668
- package/README.ja.md +704 -665
- package/README.md +826 -801
- package/README.zh.md +708 -669
- 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/axios-poisoning/d1-version-fingerprint.js +24 -0
- package/backend/detectors/axios-poisoning/d2-decoy-dep.js +24 -0
- package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +90 -0
- package/backend/detectors/axios-poisoning/index.js +94 -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 +75 -38
- 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/msh-supplement/d1-obfuscation.js +18 -0
- package/backend/detectors/msh-supplement/d2-persistence.js +47 -0
- package/backend/detectors/msh-supplement/d3-geo-killswitch.js +35 -0
- package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +33 -0
- package/backend/detectors/msh-supplement/index.js +107 -0
- 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/detectors/typosquat-vpmdhaj/d1-maintainer.js +77 -0
- package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +37 -0
- package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +66 -0
- package/backend/detectors/typosquat-vpmdhaj/index.js +98 -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/provenance.js +79 -0
- 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,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
|
+
}
|
|
@@ -1,38 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,118 +1,118 @@
|
|
|
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 nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
|
|
32
|
-
|
|
33
|
-
const triggeredChecks = [];
|
|
34
|
-
if (burstResult.triggered) triggeredChecks.push('D1_BURST');
|
|
35
|
-
if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
|
|
36
|
-
if (slsaResult.triggered) triggeredChecks.push('D3_SLSA');
|
|
37
|
-
if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
|
|
38
|
-
if (iocResult.triggered) triggeredChecks.push('D5_IOC');
|
|
39
|
-
if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
|
|
40
|
-
if (nxDownstreamResult.triggered) triggeredChecks.push('D7_NX_CONSOLE');
|
|
41
|
-
|
|
42
|
-
if (triggeredChecks.length === 0) return [];
|
|
43
|
-
|
|
44
|
-
let waveAttribution = 'unknown';
|
|
45
|
-
if (pkgName.startsWith('@tanstack')) {
|
|
46
|
-
waveAttribution = 'wave1-tanstack';
|
|
47
|
-
} else if (pkgName.startsWith('@antv')) {
|
|
48
|
-
waveAttribution = 'wave2-antv';
|
|
49
|
-
} else if (nxDownstreamResult.triggered) {
|
|
50
|
-
waveAttribution = 'wave3-nx-console';
|
|
51
|
-
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
52
|
-
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
53
|
-
if (waves.length === 1) {
|
|
54
|
-
waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const isCritical = slsaResult.triggered || iocResult.triggered || nxDownstreamResult.triggered;
|
|
59
|
-
|
|
60
|
-
const evidence = {
|
|
61
|
-
campaign: 'MINI_SHAI_HULUD',
|
|
62
|
-
waveAttribution,
|
|
63
|
-
triggeredChecks,
|
|
64
|
-
burstWindow: burstResult.triggered
|
|
65
|
-
? { start: burstResult.windowStart, end: burstResult.windowEnd, versionCount: burstResult.versionCount }
|
|
66
|
-
: null,
|
|
67
|
-
siblingPackages: siblingResult.triggered
|
|
68
|
-
? siblingResult.results.flatMap(r => r.siblingPackages)
|
|
69
|
-
: null,
|
|
70
|
-
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
71
|
-
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
72
|
-
installScriptSnippets: exfilResult.triggered ? exfilResult.snippets : null,
|
|
73
|
-
nxConsoleDownstream: nxDownstreamResult.triggered
|
|
74
|
-
? { nxDeps: nxDownstreamResult.nxDeps, vsCodeExtensions: nxDownstreamResult.vsCodeExtensions }
|
|
75
|
-
: null,
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
return [{
|
|
79
|
-
id: 'MINI_SHAI_HULUD',
|
|
80
|
-
severity: isCritical ? 'critical' : 'high',
|
|
81
|
-
title: 'Mini Shai-Hulud worm campaign',
|
|
82
|
-
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
83
|
-
evidence: JSON.stringify(evidence),
|
|
84
|
-
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. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
|
|
85
|
-
}];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
89
|
-
const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
|
|
90
|
-
const nxDeps = Object.keys(deps).filter(d => d.startsWith('@nx/') || d.startsWith('nrwl/'));
|
|
91
|
-
if (nxDeps.length === 0) return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
|
|
92
|
-
|
|
93
|
-
let vsCodeExtensions = [];
|
|
94
|
-
if (allFiles && Array.isArray(allFiles)) {
|
|
95
|
-
for (const file of allFiles) {
|
|
96
|
-
if (file.path && (file.path.endsWith('.vscode/extensions.json') || file.path.endsWith('.vscode/extensions.json'))) {
|
|
97
|
-
try {
|
|
98
|
-
const content = typeof file.content === 'string' ? file.content : '';
|
|
99
|
-
const parsed = JSON.parse(content);
|
|
100
|
-
const allExts = [
|
|
101
|
-
...(parsed.recommendations || []),
|
|
102
|
-
...(parsed.unwantedRecommendations || []),
|
|
103
|
-
];
|
|
104
|
-
const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
|
|
105
|
-
if (matched.length > 0) {
|
|
106
|
-
vsCodeExtensions = matched;
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
// non-JSON extensions.json, skip
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return { triggered: true, nxDeps, vsCodeExtensions };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export { clearSiblingCache } from './d2-sibling-compromise.js';
|
|
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 nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
|
|
32
|
+
|
|
33
|
+
const triggeredChecks = [];
|
|
34
|
+
if (burstResult.triggered) triggeredChecks.push('D1_BURST');
|
|
35
|
+
if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
|
|
36
|
+
if (slsaResult.triggered) triggeredChecks.push('D3_SLSA');
|
|
37
|
+
if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
|
|
38
|
+
if (iocResult.triggered) triggeredChecks.push('D5_IOC');
|
|
39
|
+
if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
|
|
40
|
+
if (nxDownstreamResult.triggered) triggeredChecks.push('D7_NX_CONSOLE');
|
|
41
|
+
|
|
42
|
+
if (triggeredChecks.length === 0) return [];
|
|
43
|
+
|
|
44
|
+
let waveAttribution = 'unknown';
|
|
45
|
+
if (pkgName.startsWith('@tanstack')) {
|
|
46
|
+
waveAttribution = 'wave1-tanstack';
|
|
47
|
+
} else if (pkgName.startsWith('@antv')) {
|
|
48
|
+
waveAttribution = 'wave2-antv';
|
|
49
|
+
} else if (nxDownstreamResult.triggered) {
|
|
50
|
+
waveAttribution = 'wave3-nx-console';
|
|
51
|
+
} else if (iocResult.matches && iocResult.matches.length > 0) {
|
|
52
|
+
const waves = [...new Set(iocResult.matches.map(m => m.wave))];
|
|
53
|
+
if (waves.length === 1) {
|
|
54
|
+
waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isCritical = slsaResult.triggered || iocResult.triggered || nxDownstreamResult.triggered;
|
|
59
|
+
|
|
60
|
+
const evidence = {
|
|
61
|
+
campaign: 'MINI_SHAI_HULUD',
|
|
62
|
+
waveAttribution,
|
|
63
|
+
triggeredChecks,
|
|
64
|
+
burstWindow: burstResult.triggered
|
|
65
|
+
? { start: burstResult.windowStart, end: burstResult.windowEnd, versionCount: burstResult.versionCount }
|
|
66
|
+
: null,
|
|
67
|
+
siblingPackages: siblingResult.triggered
|
|
68
|
+
? siblingResult.results.flatMap(r => r.siblingPackages)
|
|
69
|
+
: null,
|
|
70
|
+
attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
|
|
71
|
+
iocMatches: iocResult.triggered ? iocResult.matches : null,
|
|
72
|
+
installScriptSnippets: exfilResult.triggered ? exfilResult.snippets : null,
|
|
73
|
+
nxConsoleDownstream: nxDownstreamResult.triggered
|
|
74
|
+
? { nxDeps: nxDownstreamResult.nxDeps, vsCodeExtensions: nxDownstreamResult.vsCodeExtensions }
|
|
75
|
+
: null,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return [{
|
|
79
|
+
id: 'MINI_SHAI_HULUD',
|
|
80
|
+
severity: isCritical ? 'critical' : 'high',
|
|
81
|
+
title: 'Mini Shai-Hulud worm campaign',
|
|
82
|
+
description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
|
|
83
|
+
evidence: JSON.stringify(evidence),
|
|
84
|
+
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. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkNxConsoleDownstream(pkgJson, allFiles) {
|
|
89
|
+
const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
|
|
90
|
+
const nxDeps = Object.keys(deps).filter(d => d.startsWith('@nx/') || d.startsWith('nrwl/'));
|
|
91
|
+
if (nxDeps.length === 0) return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
|
|
92
|
+
|
|
93
|
+
let vsCodeExtensions = [];
|
|
94
|
+
if (allFiles && Array.isArray(allFiles)) {
|
|
95
|
+
for (const file of allFiles) {
|
|
96
|
+
if (file.path && (file.path.endsWith('.vscode/extensions.json') || file.path.endsWith('.vscode/extensions.json'))) {
|
|
97
|
+
try {
|
|
98
|
+
const content = typeof file.content === 'string' ? file.content : '';
|
|
99
|
+
const parsed = JSON.parse(content);
|
|
100
|
+
const allExts = [
|
|
101
|
+
...(parsed.recommendations || []),
|
|
102
|
+
...(parsed.unwantedRecommendations || []),
|
|
103
|
+
];
|
|
104
|
+
const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
|
|
105
|
+
if (matched.length > 0) {
|
|
106
|
+
vsCodeExtensions = matched;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// non-JSON extensions.json, skip
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { triggered: true, nxDeps, vsCodeExtensions };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { clearSiblingCache } from './d2-sibling-compromise.js';
|