@lateos/npm-scan 0.18.1 → 0.18.2
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 +233 -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 +81 -75
- 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-cloud-imds.js +124 -0
- package/backend/detectors/tier1-infostealer.js +36 -0
- package/backend/detectors/tier1-multistage-postinstall.js +81 -0
- package/backend/detectors/tier1-version-confusion.js +107 -0
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -193
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/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/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,44 +1,44 @@
|
|
|
1
|
-
export function jaroWinkler(s1, s2) {
|
|
2
|
-
if (s1 === s2) return 1;
|
|
3
|
-
const len1 = s1.length, len2 = s2.length;
|
|
4
|
-
if (len1 === 0 || len2 === 0) return 0;
|
|
5
|
-
|
|
6
|
-
const matchDist = Math.floor(Math.max(len1, len2) / 2) - 1;
|
|
7
|
-
const matches1 = new Array(len1).fill(false);
|
|
8
|
-
const matches2 = new Array(len2).fill(false);
|
|
9
|
-
let matches = 0;
|
|
10
|
-
|
|
11
|
-
for (let i = 0; i < len1; i++) {
|
|
12
|
-
const start = Math.max(0, i - matchDist);
|
|
13
|
-
const end = Math.min(len2, i + matchDist + 1);
|
|
14
|
-
for (let j = start; j < end; j++) {
|
|
15
|
-
if (matches2[j]) continue;
|
|
16
|
-
if (s1[i] !== s2[j]) continue;
|
|
17
|
-
matches1[i] = true;
|
|
18
|
-
matches2[j] = true;
|
|
19
|
-
matches++;
|
|
20
|
-
break;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (matches === 0) return 0;
|
|
25
|
-
|
|
26
|
-
let transpositions = 0, k = 0;
|
|
27
|
-
for (let i = 0; i < len1; i++) {
|
|
28
|
-
if (!matches1[i]) continue;
|
|
29
|
-
while (!matches2[k]) k++;
|
|
30
|
-
if (s1[i] !== s2[k]) transpositions++;
|
|
31
|
-
k++;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const jaro = (matches / len1 + matches / len2 + (matches - transpositions / 2) / matches) / 3;
|
|
35
|
-
|
|
36
|
-
let prefix = 0;
|
|
37
|
-
const maxPrefix = Math.min(4, len1, len2);
|
|
38
|
-
for (let i = 0; i < maxPrefix; i++) {
|
|
39
|
-
if (s1[i] === s2[i]) prefix++;
|
|
40
|
-
else break;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return jaro + prefix * 0.1 * (1 - jaro);
|
|
44
|
-
}
|
|
1
|
+
export function jaroWinkler(s1, s2) {
|
|
2
|
+
if (s1 === s2) return 1;
|
|
3
|
+
const len1 = s1.length, len2 = s2.length;
|
|
4
|
+
if (len1 === 0 || len2 === 0) return 0;
|
|
5
|
+
|
|
6
|
+
const matchDist = Math.floor(Math.max(len1, len2) / 2) - 1;
|
|
7
|
+
const matches1 = new Array(len1).fill(false);
|
|
8
|
+
const matches2 = new Array(len2).fill(false);
|
|
9
|
+
let matches = 0;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < len1; i++) {
|
|
12
|
+
const start = Math.max(0, i - matchDist);
|
|
13
|
+
const end = Math.min(len2, i + matchDist + 1);
|
|
14
|
+
for (let j = start; j < end; j++) {
|
|
15
|
+
if (matches2[j]) continue;
|
|
16
|
+
if (s1[i] !== s2[j]) continue;
|
|
17
|
+
matches1[i] = true;
|
|
18
|
+
matches2[j] = true;
|
|
19
|
+
matches++;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (matches === 0) return 0;
|
|
25
|
+
|
|
26
|
+
let transpositions = 0, k = 0;
|
|
27
|
+
for (let i = 0; i < len1; i++) {
|
|
28
|
+
if (!matches1[i]) continue;
|
|
29
|
+
while (!matches2[k]) k++;
|
|
30
|
+
if (s1[i] !== s2[k]) transpositions++;
|
|
31
|
+
k++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const jaro = (matches / len1 + matches / len2 + (matches - transpositions / 2) / matches) / 3;
|
|
35
|
+
|
|
36
|
+
let prefix = 0;
|
|
37
|
+
const maxPrefix = Math.min(4, len1, len2);
|
|
38
|
+
for (let i = 0; i < maxPrefix; i++) {
|
|
39
|
+
if (s1[i] === s2[i]) prefix++;
|
|
40
|
+
else break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
44
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export const KNOWN_HF_ORGS = [
|
|
2
|
-
'openai', 'meta-llama', 'mistralai', 'google', 'microsoft',
|
|
3
|
-
'stabilityai', 'EleutherAI', 'huggingface', 'tiiuae', 'cohere',
|
|
4
|
-
'anthropic', 'deepseek-ai', 'Qwen', 'NousResearch', 'teknium',
|
|
5
|
-
];
|
|
1
|
+
export const KNOWN_HF_ORGS = [
|
|
2
|
+
'openai', 'meta-llama', 'mistralai', 'google', 'microsoft',
|
|
3
|
+
'stabilityai', 'EleutherAI', 'huggingface', 'tiiuae', 'cohere',
|
|
4
|
+
'anthropic', 'deepseek-ai', 'Qwen', 'NousResearch', 'teknium',
|
|
5
|
+
];
|
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
function hashToken(str) {
|
|
2
|
-
let hash = 5381;
|
|
3
|
-
for (let i = 0; i < str.length; i++) {
|
|
4
|
-
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
5
|
-
hash = hash & hash;
|
|
6
|
-
}
|
|
7
|
-
return hash >>> 0;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function simhash(text) {
|
|
11
|
-
const v = new Array(64).fill(0);
|
|
12
|
-
const tokens = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
13
|
-
|
|
14
|
-
for (const token of tokens) {
|
|
15
|
-
const h = hashToken(token);
|
|
16
|
-
for (let i = 0; i < 64; i++) {
|
|
17
|
-
if ((h >> i) & 1) {
|
|
18
|
-
v[i] += 1;
|
|
19
|
-
} else {
|
|
20
|
-
v[i] -= 1;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let fingerprint = 0n;
|
|
26
|
-
for (let i = 0; i < 64; i++) {
|
|
27
|
-
if (v[i] > 0) {
|
|
28
|
-
fingerprint |= (1n << BigInt(i));
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return fingerprint;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function hammingDistance(a, b) {
|
|
35
|
-
let xor = a ^ b;
|
|
36
|
-
let count = 0;
|
|
37
|
-
while (xor > 0n) {
|
|
38
|
-
count += Number(xor & 1n);
|
|
39
|
-
xor >>= 1n;
|
|
40
|
-
}
|
|
41
|
-
return count;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function similarity(a, b) {
|
|
45
|
-
return 1 - hammingDistance(a, b) / 64;
|
|
46
|
-
}
|
|
1
|
+
function hashToken(str) {
|
|
2
|
+
let hash = 5381;
|
|
3
|
+
for (let i = 0; i < str.length; i++) {
|
|
4
|
+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
5
|
+
hash = hash & hash;
|
|
6
|
+
}
|
|
7
|
+
return hash >>> 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function simhash(text) {
|
|
11
|
+
const v = new Array(64).fill(0);
|
|
12
|
+
const tokens = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
13
|
+
|
|
14
|
+
for (const token of tokens) {
|
|
15
|
+
const h = hashToken(token);
|
|
16
|
+
for (let i = 0; i < 64; i++) {
|
|
17
|
+
if ((h >> i) & 1) {
|
|
18
|
+
v[i] += 1;
|
|
19
|
+
} else {
|
|
20
|
+
v[i] -= 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let fingerprint = 0n;
|
|
26
|
+
for (let i = 0; i < 64; i++) {
|
|
27
|
+
if (v[i] > 0) {
|
|
28
|
+
fingerprint |= (1n << BigInt(i));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return fingerprint;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hammingDistance(a, b) {
|
|
35
|
+
let xor = a ^ b;
|
|
36
|
+
let count = 0;
|
|
37
|
+
while (xor > 0n) {
|
|
38
|
+
count += Number(xor & 1n);
|
|
39
|
+
xor >>= 1n;
|
|
40
|
+
}
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function similarity(a, b) {
|
|
45
|
+
return 1 - hammingDistance(a, b) / 64;
|
|
46
|
+
}
|
|
@@ -1,76 +1,82 @@
|
|
|
1
|
-
import * as atk001 from './atk-001-lifecycle.js';
|
|
2
|
-
import * as atk002 from './atk-002-obfusc.js';
|
|
3
|
-
import * as atk003 from './atk-003-creds.js';
|
|
4
|
-
import * as atk004 from './atk-004-persist.js';
|
|
5
|
-
import * as atk005 from './atk-005-exfil.js';
|
|
6
|
-
import * as atk006 from './atk-006-depconf.js';
|
|
7
|
-
import * as atk007 from './atk-007-typosquat.js';
|
|
8
|
-
import * as atk008 from './atk-008-tarball-tamper.js';
|
|
9
|
-
import * as atk009 from './atk-009-dormant-trigger.js';
|
|
10
|
-
import * as atk010 from './atk-010-sandbox-evasion.js';
|
|
11
|
-
import * as atk011 from './atk-011-transitive-prop.js';
|
|
12
|
-
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
13
|
-
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
|
-
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
15
|
-
import { scan as badhostScan } from './cve-2026-48710-badhost/index.js';
|
|
16
|
-
import { scan as trapdoorScan } from './trapdoor/index.js';
|
|
17
|
-
import { scan as nodeIpcScan } from './node-ipc-compromise/index.js';
|
|
18
|
-
import { scan as mshSupplementScan } from './msh-supplement/index.js';
|
|
19
|
-
import { scan as typosquatScan } from './typosquat-vpmdhaj/index.js';
|
|
20
|
-
import { scan as axiosPoisoningScan } from './axios-poisoning/index.js';
|
|
21
|
-
import { scan as tier1TyposquatScan } from './tier1-typosquat.js';
|
|
22
|
-
import { scan as tier1InfostealerScan } from './tier1-infostealer.js';
|
|
23
|
-
import { scan as tier1LifecycleHookScan } from './tier1-lifecycle-hook.js';
|
|
24
|
-
import { scan as tier1BinaryEmbedScan } from './tier1-binary-embed.js';
|
|
25
|
-
import { scan as tier1MetadataSpoofScan } from './tier1-metadata-spoof.js';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
findings
|
|
53
|
-
findings.push(...await
|
|
54
|
-
findings.push(...await
|
|
55
|
-
findings.push(...await
|
|
56
|
-
findings.push(...await
|
|
57
|
-
findings.push(...await
|
|
58
|
-
findings.push(...await
|
|
59
|
-
findings.push(...await
|
|
60
|
-
findings.push(...await
|
|
61
|
-
findings.push(...await
|
|
62
|
-
findings.push(...await
|
|
63
|
-
findings.push(...await
|
|
64
|
-
findings.push(...await
|
|
65
|
-
findings.push(...await
|
|
66
|
-
findings.push(...await
|
|
67
|
-
findings.push(...await
|
|
68
|
-
findings.push(...await
|
|
69
|
-
findings.push(...await
|
|
70
|
-
findings.push(...await
|
|
71
|
-
findings.push(...await
|
|
72
|
-
findings.push(...await
|
|
73
|
-
findings.push(...await runTier1('tier1-
|
|
74
|
-
findings.push(...await runTier1('tier1-
|
|
75
|
-
|
|
1
|
+
import * as atk001 from './atk-001-lifecycle.js';
|
|
2
|
+
import * as atk002 from './atk-002-obfusc.js';
|
|
3
|
+
import * as atk003 from './atk-003-creds.js';
|
|
4
|
+
import * as atk004 from './atk-004-persist.js';
|
|
5
|
+
import * as atk005 from './atk-005-exfil.js';
|
|
6
|
+
import * as atk006 from './atk-006-depconf.js';
|
|
7
|
+
import * as atk007 from './atk-007-typosquat.js';
|
|
8
|
+
import * as atk008 from './atk-008-tarball-tamper.js';
|
|
9
|
+
import * as atk009 from './atk-009-dormant-trigger.js';
|
|
10
|
+
import * as atk010 from './atk-010-sandbox-evasion.js';
|
|
11
|
+
import * as atk011 from './atk-011-transitive-prop.js';
|
|
12
|
+
import { scanAll as megalodonScan } from './megalodon/index.js';
|
|
13
|
+
import { scan as hfScan } from './hf-impersonation/index.js';
|
|
14
|
+
import { scan as miniShaiHuludScan } from './mini-shai-hulud/index.js';
|
|
15
|
+
import { scan as badhostScan } from './cve-2026-48710-badhost/index.js';
|
|
16
|
+
import { scan as trapdoorScan } from './trapdoor/index.js';
|
|
17
|
+
import { scan as nodeIpcScan } from './node-ipc-compromise/index.js';
|
|
18
|
+
import { scan as mshSupplementScan } from './msh-supplement/index.js';
|
|
19
|
+
import { scan as typosquatScan } from './typosquat-vpmdhaj/index.js';
|
|
20
|
+
import { scan as axiosPoisoningScan } from './axios-poisoning/index.js';
|
|
21
|
+
import { scan as tier1TyposquatScan } from './tier1-typosquat.js';
|
|
22
|
+
import { scan as tier1InfostealerScan } from './tier1-infostealer.js';
|
|
23
|
+
import { scan as tier1LifecycleHookScan } from './tier1-lifecycle-hook.js';
|
|
24
|
+
import { scan as tier1BinaryEmbedScan } from './tier1-binary-embed.js';
|
|
25
|
+
import { scan as tier1MetadataSpoofScan } from './tier1-metadata-spoof.js';
|
|
26
|
+
import { scan as tier1VersionConfusionScan } from './tier1-version-confusion.js';
|
|
27
|
+
import { scan as tier1CloudImdsScan } from './tier1-cloud-imds.js';
|
|
28
|
+
import { scan as tier1MultistagePostinstallScan } from './tier1-multistage-postinstall.js';
|
|
29
|
+
|
|
30
|
+
function timeout(ms) {
|
|
31
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runTier1(name, scanFn, pkgJson, files, registryMeta, allFiles) {
|
|
35
|
+
try {
|
|
36
|
+
const result = await Promise.race([
|
|
37
|
+
scanFn(pkgJson, files, registryMeta, allFiles),
|
|
38
|
+
timeout(800),
|
|
39
|
+
]);
|
|
40
|
+
const fileCount = allFiles && allFiles.length > 0 ? allFiles.length : files.length;
|
|
41
|
+
if (fileCount >= 10 && result.length > 0) {
|
|
42
|
+
const hitRate = result.length / fileCount;
|
|
43
|
+
if (hitRate > 0.8) return [];
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runAll(pkgJson, files = [], registryMeta = null, allFiles = null) {
|
|
52
|
+
const findings = [];
|
|
53
|
+
findings.push(...await atk001.scan(pkgJson, files));
|
|
54
|
+
findings.push(...await atk002.scan(pkgJson, files));
|
|
55
|
+
findings.push(...await atk003.scan(pkgJson, files));
|
|
56
|
+
findings.push(...await atk004.scan(pkgJson, files));
|
|
57
|
+
findings.push(...await atk005.scan(pkgJson, files));
|
|
58
|
+
findings.push(...await atk006.scan(pkgJson, files));
|
|
59
|
+
findings.push(...await atk007.scan(pkgJson, files));
|
|
60
|
+
findings.push(...await atk008.scan(pkgJson, files));
|
|
61
|
+
findings.push(...await atk009.scan(pkgJson, files));
|
|
62
|
+
findings.push(...await atk010.scan(pkgJson, files));
|
|
63
|
+
findings.push(...await atk011.scan(pkgJson, files));
|
|
64
|
+
findings.push(...await megalodonScan(pkgJson, allFiles || files, registryMeta));
|
|
65
|
+
findings.push(...await hfScan(pkgJson, files, registryMeta, allFiles || files));
|
|
66
|
+
findings.push(...await miniShaiHuludScan(pkgJson, files, registryMeta, allFiles || files));
|
|
67
|
+
findings.push(...await badhostScan(pkgJson, files, registryMeta, allFiles || files));
|
|
68
|
+
findings.push(...await trapdoorScan(pkgJson, files, registryMeta, allFiles || files));
|
|
69
|
+
findings.push(...await nodeIpcScan(pkgJson, files, registryMeta, allFiles || files));
|
|
70
|
+
findings.push(...await mshSupplementScan(pkgJson, files, registryMeta, allFiles || files));
|
|
71
|
+
findings.push(...await typosquatScan(pkgJson, files, registryMeta, allFiles || files));
|
|
72
|
+
findings.push(...await axiosPoisoningScan(pkgJson, files, registryMeta, allFiles || files));
|
|
73
|
+
findings.push(...await runTier1('tier1-typosquat', tier1TyposquatScan, pkgJson, files, registryMeta, allFiles || files));
|
|
74
|
+
findings.push(...await runTier1('tier1-infostealer', tier1InfostealerScan, pkgJson, files, registryMeta, allFiles || files));
|
|
75
|
+
findings.push(...await runTier1('tier1-lifecycle-hook', tier1LifecycleHookScan, pkgJson, files, registryMeta, allFiles || files));
|
|
76
|
+
findings.push(...await runTier1('tier1-binary-embed', tier1BinaryEmbedScan, pkgJson, files, registryMeta, allFiles || files));
|
|
77
|
+
findings.push(...await runTier1('tier1-metadata-spoof', tier1MetadataSpoofScan, pkgJson, files, registryMeta, allFiles || files));
|
|
78
|
+
findings.push(...await runTier1('tier1-version-confusion', tier1VersionConfusionScan, pkgJson, files, registryMeta, allFiles || files));
|
|
79
|
+
findings.push(...await runTier1('tier1-cloud-imds', tier1CloudImdsScan, pkgJson, files, registryMeta, allFiles || files));
|
|
80
|
+
findings.push(...await runTier1('tier1-multistage-postinstall', tier1MultistagePostinstallScan, pkgJson, files, registryMeta, allFiles || files));
|
|
81
|
+
return findings.sort((a, b) => b.severity.localeCompare(a.severity));
|
|
76
82
|
}
|
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
import { MegalodonSignal } from './types.js';
|
|
2
|
-
import yaml from 'js-yaml';
|
|
3
|
-
|
|
4
|
-
const C2_EXFIL_RE = /curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
|
|
5
|
-
const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
|
|
6
|
-
const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
|
|
7
|
-
|
|
8
|
-
function isWorkflowFile(f) {
|
|
9
|
-
const p = f.path.replace(/\\/g, '/');
|
|
10
|
-
return /\.github\/workflows\/.+\.(yml|yaml)$/i.test(p);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function countExecutableLines(text) {
|
|
14
|
-
return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function extractRunBlocks(parsed) {
|
|
18
|
-
const runs = [];
|
|
19
|
-
if (!parsed || typeof parsed !== 'object') return runs;
|
|
20
|
-
|
|
21
|
-
const walk = (obj) => {
|
|
22
|
-
if (!obj || typeof obj !== 'object') return;
|
|
23
|
-
if (Array.isArray(obj)) { obj.forEach(walk); return; }
|
|
24
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
25
|
-
if (k === 'run' && typeof v === 'string') {
|
|
26
|
-
runs.push(v);
|
|
27
|
-
}
|
|
28
|
-
if (k === 'env' && typeof v === 'object' && v !== null) {
|
|
29
|
-
runs.push({ _env: v });
|
|
30
|
-
}
|
|
31
|
-
walk(v);
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
walk(parsed);
|
|
35
|
-
return runs;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function extractRunBlocksRaw(text) {
|
|
39
|
-
const runs = [];
|
|
40
|
-
const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
|
|
41
|
-
if (runMatch) runs.push(...runMatch.map(m => m.replace(/^run:\s*[|>]\s*\n/, '')));
|
|
42
|
-
|
|
43
|
-
const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
|
|
44
|
-
let m;
|
|
45
|
-
while ((m = inlineRe.exec(text)) !== null) runs.push(m[1]);
|
|
46
|
-
|
|
47
|
-
const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
|
|
48
|
-
let em;
|
|
49
|
-
while ((em = envRe.exec(text)) !== null) runs.push({ _env: em[1] });
|
|
50
|
-
return runs;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function runInStepHasBoth(step, signal) {
|
|
54
|
-
const runVal = step.run;
|
|
55
|
-
const envVals = step.env ? Object.values(step.env).filter(v => typeof v === 'string').join(' ') : '';
|
|
56
|
-
const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
|
|
57
|
-
|
|
58
|
-
if (signal === 'exfil') {
|
|
59
|
-
return C2_EXFIL_RE.test(combined) && SECRETS_REF_RE.test(combined);
|
|
60
|
-
}
|
|
61
|
-
if (signal === 'decode') {
|
|
62
|
-
return B64_DECODE_CHAIN_RE.test(combined);
|
|
63
|
-
}
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function scan(allFiles) {
|
|
68
|
-
const evidence = [];
|
|
69
|
-
const workflowFiles = allFiles.filter(isWorkflowFile);
|
|
70
|
-
|
|
71
|
-
for (const f of workflowFiles) {
|
|
72
|
-
if (f.content.length > 512 * 1024) continue;
|
|
73
|
-
|
|
74
|
-
let parsed = null;
|
|
75
|
-
let parseError = null;
|
|
76
|
-
try {
|
|
77
|
-
parsed = yaml.load(f.content);
|
|
78
|
-
} catch (e) {
|
|
79
|
-
parseError = e;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
|
|
83
|
-
const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
|
|
84
|
-
const envBlocks = rawRunBlocks.filter(r => typeof r === 'object' && r._env);
|
|
85
|
-
|
|
86
|
-
let exfilTriggered = false;
|
|
87
|
-
let decodeTriggered = false;
|
|
88
|
-
|
|
89
|
-
for (const runStr of runStrings) {
|
|
90
|
-
if (!exfilTriggered && C2_EXFIL_RE.test(runStr) && SECRETS_REF_RE.test(runStr)) {
|
|
91
|
-
exfilTriggered = true;
|
|
92
|
-
evidence.push({
|
|
93
|
-
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
94
|
-
file: f.path,
|
|
95
|
-
excerpt: runStr.slice(0, 120),
|
|
96
|
-
detail: 'C2 outbound call co-occurs with credentials reference in run block',
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!decodeTriggered && B64_DECODE_CHAIN_RE.test(runStr)) {
|
|
101
|
-
decodeTriggered = true;
|
|
102
|
-
evidence.push({
|
|
103
|
-
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
104
|
-
file: f.path,
|
|
105
|
-
excerpt: runStr.slice(0, 120),
|
|
106
|
-
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
112
|
-
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
|
|
113
|
-
for (const step of steps) {
|
|
114
|
-
if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
|
|
115
|
-
exfilTriggered = true;
|
|
116
|
-
const runVal = step.run || '';
|
|
117
|
-
evidence.push({
|
|
118
|
-
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
119
|
-
file: f.path,
|
|
120
|
-
excerpt: runVal.slice(0, 120),
|
|
121
|
-
detail: 'C2 outbound call co-occurs with secrets reference in same step',
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
if (!decodeTriggered && runInStepHasBoth(step, 'decode')) {
|
|
125
|
-
decodeTriggered = true;
|
|
126
|
-
const runVal = step.run || '';
|
|
127
|
-
evidence.push({
|
|
128
|
-
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
129
|
-
file: f.path,
|
|
130
|
-
excerpt: runVal.slice(0, 120),
|
|
131
|
-
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const lineCount = countExecutableLines(f.content);
|
|
138
|
-
if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
|
|
139
|
-
const found = evidence.find(e => e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL || e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN);
|
|
140
|
-
if (found) {
|
|
141
|
-
found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return evidence;
|
|
147
|
-
}
|
|
1
|
+
import { MegalodonSignal } from './types.js';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
const C2_EXFIL_RE = /curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
|
|
5
|
+
const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
|
|
6
|
+
const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
|
|
7
|
+
|
|
8
|
+
function isWorkflowFile(f) {
|
|
9
|
+
const p = f.path.replace(/\\/g, '/');
|
|
10
|
+
return /\.github\/workflows\/.+\.(yml|yaml)$/i.test(p);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function countExecutableLines(text) {
|
|
14
|
+
return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractRunBlocks(parsed) {
|
|
18
|
+
const runs = [];
|
|
19
|
+
if (!parsed || typeof parsed !== 'object') return runs;
|
|
20
|
+
|
|
21
|
+
const walk = (obj) => {
|
|
22
|
+
if (!obj || typeof obj !== 'object') return;
|
|
23
|
+
if (Array.isArray(obj)) { obj.forEach(walk); return; }
|
|
24
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
25
|
+
if (k === 'run' && typeof v === 'string') {
|
|
26
|
+
runs.push(v);
|
|
27
|
+
}
|
|
28
|
+
if (k === 'env' && typeof v === 'object' && v !== null) {
|
|
29
|
+
runs.push({ _env: v });
|
|
30
|
+
}
|
|
31
|
+
walk(v);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
walk(parsed);
|
|
35
|
+
return runs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractRunBlocksRaw(text) {
|
|
39
|
+
const runs = [];
|
|
40
|
+
const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
|
|
41
|
+
if (runMatch) runs.push(...runMatch.map(m => m.replace(/^run:\s*[|>]\s*\n/, '')));
|
|
42
|
+
|
|
43
|
+
const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = inlineRe.exec(text)) !== null) runs.push(m[1]);
|
|
46
|
+
|
|
47
|
+
const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
|
|
48
|
+
let em;
|
|
49
|
+
while ((em = envRe.exec(text)) !== null) runs.push({ _env: em[1] });
|
|
50
|
+
return runs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runInStepHasBoth(step, signal) {
|
|
54
|
+
const runVal = step.run;
|
|
55
|
+
const envVals = step.env ? Object.values(step.env).filter(v => typeof v === 'string').join(' ') : '';
|
|
56
|
+
const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
|
|
57
|
+
|
|
58
|
+
if (signal === 'exfil') {
|
|
59
|
+
return C2_EXFIL_RE.test(combined) && SECRETS_REF_RE.test(combined);
|
|
60
|
+
}
|
|
61
|
+
if (signal === 'decode') {
|
|
62
|
+
return B64_DECODE_CHAIN_RE.test(combined);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function scan(allFiles) {
|
|
68
|
+
const evidence = [];
|
|
69
|
+
const workflowFiles = allFiles.filter(isWorkflowFile);
|
|
70
|
+
|
|
71
|
+
for (const f of workflowFiles) {
|
|
72
|
+
if (f.content.length > 512 * 1024) continue;
|
|
73
|
+
|
|
74
|
+
let parsed = null;
|
|
75
|
+
let parseError = null;
|
|
76
|
+
try {
|
|
77
|
+
parsed = yaml.load(f.content);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
parseError = e;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
|
|
83
|
+
const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
|
|
84
|
+
const envBlocks = rawRunBlocks.filter(r => typeof r === 'object' && r._env);
|
|
85
|
+
|
|
86
|
+
let exfilTriggered = false;
|
|
87
|
+
let decodeTriggered = false;
|
|
88
|
+
|
|
89
|
+
for (const runStr of runStrings) {
|
|
90
|
+
if (!exfilTriggered && C2_EXFIL_RE.test(runStr) && SECRETS_REF_RE.test(runStr)) {
|
|
91
|
+
exfilTriggered = true;
|
|
92
|
+
evidence.push({
|
|
93
|
+
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
94
|
+
file: f.path,
|
|
95
|
+
excerpt: runStr.slice(0, 120),
|
|
96
|
+
detail: 'C2 outbound call co-occurs with credentials reference in run block',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!decodeTriggered && B64_DECODE_CHAIN_RE.test(runStr)) {
|
|
101
|
+
decodeTriggered = true;
|
|
102
|
+
evidence.push({
|
|
103
|
+
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
104
|
+
file: f.path,
|
|
105
|
+
excerpt: runStr.slice(0, 120),
|
|
106
|
+
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
112
|
+
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
|
|
113
|
+
for (const step of steps) {
|
|
114
|
+
if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
|
|
115
|
+
exfilTriggered = true;
|
|
116
|
+
const runVal = step.run || '';
|
|
117
|
+
evidence.push({
|
|
118
|
+
signal: MegalodonSignal.WORKFLOW_C2_EXFIL,
|
|
119
|
+
file: f.path,
|
|
120
|
+
excerpt: runVal.slice(0, 120),
|
|
121
|
+
detail: 'C2 outbound call co-occurs with secrets reference in same step',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!decodeTriggered && runInStepHasBoth(step, 'decode')) {
|
|
125
|
+
decodeTriggered = true;
|
|
126
|
+
const runVal = step.run || '';
|
|
127
|
+
evidence.push({
|
|
128
|
+
signal: MegalodonSignal.WORKFLOW_DECODE_CHAIN,
|
|
129
|
+
file: f.path,
|
|
130
|
+
excerpt: runVal.slice(0, 120),
|
|
131
|
+
detail: 'Base64 decode pipe to shell — obfuscated payload execution',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const lineCount = countExecutableLines(f.content);
|
|
138
|
+
if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
|
|
139
|
+
const found = evidence.find(e => e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL || e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN);
|
|
140
|
+
if (found) {
|
|
141
|
+
found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return evidence;
|
|
147
|
+
}
|