@lateos/npm-scan 0.18.3 → 1.1.0
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/CHANGELOG.md +32 -0
- package/README.md +864 -826
- package/VALIDATION.md +92 -0
- package/backend/cra.js +113 -21
- package/backend/db/pg-schema.sql +155 -0
- 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 +111 -0
- package/backend/detectors/config/whitelist.json +74 -0
- 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 +184 -31
- package/backend/detectors/lib/ast-patterns.js +24 -0
- package/backend/detectors/lib/entropy-analyzer.js +32 -0
- 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 +138 -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 +184 -0
- package/backend/detectors/tier1-self-propagation.js +115 -0
- package/backend/detectors/tier1-slsa-attestation.js +12 -0
- package/backend/detectors/tier1-transitive-deps.js +182 -0
- package/backend/detectors/tier1-typosquat.js +129 -50
- package/backend/detectors/tier1-version-anomaly.js +223 -0
- 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 +147 -0
- 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 +152 -0
- package/backend/scripts/analyze-validation.js +157 -0
- package/backend/scripts/detect-false-positives.js +103 -0
- package/backend/scripts/fetch-top-packages.js +277 -0
- package/backend/scripts/validate-d10-d13.js +103 -0
- package/backend/scripts/validate-detectors.js +151 -0
- 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 +47 -0
- package/backend/tests-d6-version-anomaly.test.js +67 -0
- package/backend/tests-d6.test.js +126 -0
- package/backend/tests-d6c.test.js +119 -0
- package/backend/tests-d7-obfuscation.test.js +88 -0
- package/backend/tests.test.js +997 -0
- 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 +36 -10
- package/.dockerignore +0 -20
- package/.husky/pre-commit +0 -1
- package/SECURITY.md +0 -73
- package/deploy/helm/npm-scan/Chart.yaml +0 -22
- package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
- package/deploy/helm/npm-scan/templates/api.yaml +0 -94
- package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
- package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
- package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
- package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
- package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
- package/deploy/helm/npm-scan/values.yaml +0 -103
- package/scripts/download-corpus.js +0 -30
- package/scripts/gen-mal-corpus.js +0 -35
- package/scripts/generate-campaign-fixtures.js +0 -170
- package/src/config/top-5000.json +0 -87
- package/test/fixtures/lockfiles/npm-lock.json +0 -69
- package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
- package/test/fixtures/lockfiles/yarn.lock +0 -104
- package/test/fixtures/mock-data.js +0 -69
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MegalodonSignal } from './types.js';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
|
|
4
|
-
const C2_EXFIL_RE =
|
|
4
|
+
const C2_EXFIL_RE =
|
|
5
|
+
/curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
|
|
5
6
|
const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
|
|
6
7
|
const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
|
|
7
8
|
|
|
@@ -11,16 +12,23 @@ function isWorkflowFile(f) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function countExecutableLines(text) {
|
|
14
|
-
return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
|
+
return text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function extractRunBlocks(parsed) {
|
|
18
19
|
const runs = [];
|
|
19
|
-
if (!parsed || typeof parsed !== 'object')
|
|
20
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
21
|
+
return runs;
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
const walk = (obj) => {
|
|
22
|
-
if (!obj || typeof obj !== 'object')
|
|
23
|
-
|
|
25
|
+
if (!obj || typeof obj !== 'object') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(obj)) {
|
|
29
|
+
obj.forEach(walk);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
24
32
|
for (const [k, v] of Object.entries(obj)) {
|
|
25
33
|
if (k === 'run' && typeof v === 'string') {
|
|
26
34
|
runs.push(v);
|
|
@@ -38,21 +46,31 @@ function extractRunBlocks(parsed) {
|
|
|
38
46
|
function extractRunBlocksRaw(text) {
|
|
39
47
|
const runs = [];
|
|
40
48
|
const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
|
|
41
|
-
if (runMatch)
|
|
49
|
+
if (runMatch) {
|
|
50
|
+
runs.push(...runMatch.map((m) => m.replace(/^run:\s*[|>]\s*\n/, '')));
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
|
|
44
54
|
let m;
|
|
45
|
-
while ((m = inlineRe.exec(text)) !== null)
|
|
55
|
+
while ((m = inlineRe.exec(text)) !== null) {
|
|
56
|
+
runs.push(m[1]);
|
|
57
|
+
}
|
|
46
58
|
|
|
47
59
|
const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
|
|
48
60
|
let em;
|
|
49
|
-
while ((em = envRe.exec(text)) !== null)
|
|
61
|
+
while ((em = envRe.exec(text)) !== null) {
|
|
62
|
+
runs.push({ _env: em[1] });
|
|
63
|
+
}
|
|
50
64
|
return runs;
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
function runInStepHasBoth(step, signal) {
|
|
54
68
|
const runVal = step.run;
|
|
55
|
-
const envVals = step.env
|
|
69
|
+
const envVals = step.env
|
|
70
|
+
? Object.values(step.env)
|
|
71
|
+
.filter((v) => typeof v === 'string')
|
|
72
|
+
.join(' ')
|
|
73
|
+
: '';
|
|
56
74
|
const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
|
|
57
75
|
|
|
58
76
|
if (signal === 'exfil') {
|
|
@@ -69,19 +87,21 @@ export async function scan(allFiles) {
|
|
|
69
87
|
const workflowFiles = allFiles.filter(isWorkflowFile);
|
|
70
88
|
|
|
71
89
|
for (const f of workflowFiles) {
|
|
72
|
-
if (f.content.length > 512 * 1024)
|
|
90
|
+
if (f.content.length > 512 * 1024) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
73
93
|
|
|
74
94
|
let parsed = null;
|
|
75
|
-
let
|
|
95
|
+
let _parseError = null;
|
|
76
96
|
try {
|
|
77
97
|
parsed = yaml.load(f.content);
|
|
78
98
|
} catch (e) {
|
|
79
|
-
|
|
99
|
+
_parseError = e;
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
|
|
83
|
-
const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
|
|
84
|
-
const
|
|
103
|
+
const runStrings = rawRunBlocks.filter((r) => typeof r === 'string');
|
|
104
|
+
const _envBlocks = rawRunBlocks.filter((r) => typeof r === 'object' && r._env);
|
|
85
105
|
|
|
86
106
|
let exfilTriggered = false;
|
|
87
107
|
let decodeTriggered = false;
|
|
@@ -109,7 +129,7 @@ export async function scan(allFiles) {
|
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
112
|
-
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
|
|
132
|
+
const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap((j) => j.steps || []) : [];
|
|
113
133
|
for (const step of steps) {
|
|
114
134
|
if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
|
|
115
135
|
exfilTriggered = true;
|
|
@@ -136,7 +156,11 @@ export async function scan(allFiles) {
|
|
|
136
156
|
|
|
137
157
|
const lineCount = countExecutableLines(f.content);
|
|
138
158
|
if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
|
|
139
|
-
const found = evidence.find(
|
|
159
|
+
const found = evidence.find(
|
|
160
|
+
(e) =>
|
|
161
|
+
e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL ||
|
|
162
|
+
e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN
|
|
163
|
+
);
|
|
140
164
|
if (found) {
|
|
141
165
|
found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
|
|
142
166
|
}
|
|
@@ -3,7 +3,10 @@ import { MegalodonSignal } from './types.js';
|
|
|
3
3
|
const CRED_PATTERNS = [
|
|
4
4
|
{ pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
|
|
5
5
|
{ pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
|
|
6
|
-
{
|
|
6
|
+
{
|
|
7
|
+
pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/,
|
|
8
|
+
label: 'Azure credential',
|
|
9
|
+
},
|
|
7
10
|
{ pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
|
|
8
11
|
{ pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
|
|
9
12
|
{ pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
|
|
@@ -15,7 +18,8 @@ const CRED_PATTERNS = [
|
|
|
15
18
|
{ pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
|
|
16
19
|
];
|
|
17
20
|
|
|
18
|
-
const OUTBOUND_NET_RE =
|
|
21
|
+
const OUTBOUND_NET_RE =
|
|
22
|
+
/curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
|
|
19
23
|
|
|
20
24
|
const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
|
|
21
25
|
|
|
@@ -37,7 +41,7 @@ export async function scan(allFiles) {
|
|
|
37
41
|
const re = new RegExp(cp.pattern.source, 'gi');
|
|
38
42
|
let m;
|
|
39
43
|
while ((m = re.exec(content)) !== null) {
|
|
40
|
-
if (!matched.some(ex => ex.label === cp.label)) {
|
|
44
|
+
if (!matched.some((ex) => ex.label === cp.label)) {
|
|
41
45
|
matched.push({ label: cp.label, match: m[0] });
|
|
42
46
|
}
|
|
43
47
|
score += 3;
|
|
@@ -50,8 +54,11 @@ export async function scan(allFiles) {
|
|
|
50
54
|
evidence.push({
|
|
51
55
|
signal: MegalodonSignal.CREDENTIAL_HARVEST,
|
|
52
56
|
file: f.path,
|
|
53
|
-
excerpt: matched
|
|
54
|
-
|
|
57
|
+
excerpt: matched
|
|
58
|
+
.map((m) => m.label)
|
|
59
|
+
.join(', ')
|
|
60
|
+
.slice(0, 120),
|
|
61
|
+
detail: `Credential env vars (${matched.map((m) => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
|
|
55
62
|
});
|
|
56
63
|
}
|
|
57
64
|
}
|
|
@@ -3,7 +3,9 @@ import { MegalodonSignal } from './types.js';
|
|
|
3
3
|
export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
4
4
|
const filtered = {};
|
|
5
5
|
for (const [v, t] of Object.entries(times)) {
|
|
6
|
-
if (v === 'created' || v === 'modified')
|
|
6
|
+
if (v === 'created' || v === 'modified') {
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
7
9
|
filtered[v] = t;
|
|
8
10
|
}
|
|
9
11
|
|
|
@@ -33,7 +35,7 @@ export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
if (inWindow.length >= threshold) {
|
|
36
|
-
|
|
38
|
+
const display = inWindow.slice(0, 10);
|
|
37
39
|
let suffix = '';
|
|
38
40
|
if (inWindow.length > 10) {
|
|
39
41
|
suffix = ` +${inWindow.length - 10} more`;
|
|
@@ -54,14 +56,18 @@ export async function scan(registryMeta) {
|
|
|
54
56
|
const times = registryMeta?.time || {};
|
|
55
57
|
const result = detectVelocitySpike(times);
|
|
56
58
|
|
|
57
|
-
if (!result.triggered)
|
|
59
|
+
if (!result.triggered) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
58
62
|
|
|
59
|
-
return [
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
signal: MegalodonSignal.PUBLISH_VELOCITY,
|
|
66
|
+
file: 'registry.npmjs.org',
|
|
67
|
+
excerpt: result.versionsInWindow,
|
|
68
|
+
detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
|
|
69
|
+
_windowStartISO: result.windowStartISO,
|
|
70
|
+
_allVersions: result._allVersions,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
67
73
|
}
|
|
@@ -7,8 +7,12 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
7
7
|
|
|
8
8
|
const filteredTimes = {};
|
|
9
9
|
for (const [v, t] of Object.entries(timeMap)) {
|
|
10
|
-
if (v === 'created' || v === 'modified')
|
|
11
|
-
|
|
10
|
+
if (v === 'created' || v === 'modified') {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (t) {
|
|
14
|
+
filteredTimes[v] = t;
|
|
15
|
+
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const sortedVersions = Object.entries(filteredTimes)
|
|
@@ -16,7 +20,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
16
20
|
.sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
|
|
17
21
|
.map(([v]) => v);
|
|
18
22
|
|
|
19
|
-
if (sortedVersions.length === 0)
|
|
23
|
+
if (sortedVersions.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
if (velocityResult?.triggered) {
|
|
22
28
|
const windowStartISO = velocityResult.windowStartISO;
|
|
@@ -24,14 +30,20 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
24
30
|
|
|
25
31
|
const priorPublishers = new Set();
|
|
26
32
|
for (const v of sortedVersions) {
|
|
27
|
-
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime())
|
|
33
|
+
if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
28
36
|
const user = versions[v]?._npmUser?.name;
|
|
29
|
-
if (user)
|
|
37
|
+
if (user) {
|
|
38
|
+
priorPublishers.add(user);
|
|
39
|
+
}
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
if (priorPublishers.size === 0 && allInWindow.length > 0) {
|
|
33
43
|
const firstUser = versions[allInWindow[0]]?._npmUser?.name;
|
|
34
|
-
if (firstUser)
|
|
44
|
+
if (firstUser) {
|
|
45
|
+
priorPublishers.add(firstUser);
|
|
46
|
+
}
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
const suspiciousPublishers = [];
|
|
@@ -39,15 +51,19 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
39
51
|
for (const v of allInWindow) {
|
|
40
52
|
const user = versions[v]?._npmUser?.name;
|
|
41
53
|
if (user && !priorPublishers.has(user)) {
|
|
42
|
-
if (!suspiciousPublishers.includes(user))
|
|
43
|
-
|
|
54
|
+
if (!suspiciousPublishers.includes(user)) {
|
|
55
|
+
suspiciousPublishers.push(user);
|
|
56
|
+
}
|
|
57
|
+
if (!affectedVersions.includes(v)) {
|
|
58
|
+
affectedVersions.push(v);
|
|
59
|
+
}
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
if (suspiciousPublishers.length > 0) {
|
|
48
64
|
const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
|
|
49
65
|
|
|
50
|
-
const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
|
|
66
|
+
const firstSuspiciousVer = allInWindow.find((v) => affectedVersions.includes(v));
|
|
51
67
|
let ageNote = '';
|
|
52
68
|
if (firstSuspiciousVer && suspiciousPublishers[0]) {
|
|
53
69
|
ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
|
|
@@ -62,7 +78,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
62
78
|
});
|
|
63
79
|
}
|
|
64
80
|
} else {
|
|
65
|
-
if (sortedVersions.length < 4)
|
|
81
|
+
if (sortedVersions.length < 4) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
66
84
|
|
|
67
85
|
const last3 = sortedVersions.slice(-3);
|
|
68
86
|
const prior = sortedVersions.slice(0, -3);
|
|
@@ -70,7 +88,9 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
70
88
|
const priorPublishers = new Set();
|
|
71
89
|
for (const v of prior) {
|
|
72
90
|
const user = versions[v]?._npmUser?.name;
|
|
73
|
-
if (user)
|
|
91
|
+
if (user) {
|
|
92
|
+
priorPublishers.add(user);
|
|
93
|
+
}
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
const suspiciousPublishers = [];
|
|
@@ -78,8 +98,12 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
78
98
|
for (const v of last3) {
|
|
79
99
|
const user = versions[v]?._npmUser?.name;
|
|
80
100
|
if (user && !priorPublishers.has(user)) {
|
|
81
|
-
if (!suspiciousPublishers.includes(user))
|
|
82
|
-
|
|
101
|
+
if (!suspiciousPublishers.includes(user)) {
|
|
102
|
+
suspiciousPublishers.push(user);
|
|
103
|
+
}
|
|
104
|
+
if (!affectedVersions.includes(v)) {
|
|
105
|
+
affectedVersions.push(v);
|
|
106
|
+
}
|
|
83
107
|
}
|
|
84
108
|
}
|
|
85
109
|
|
|
@@ -88,7 +112,10 @@ export async function scan(registryMeta, velocityResult) {
|
|
|
88
112
|
|
|
89
113
|
let ageNote = '';
|
|
90
114
|
if (suspiciousPublishers[0] && affectedVersions[0]) {
|
|
91
|
-
ageNote = await checkAccountAge(
|
|
115
|
+
ageNote = await checkAccountAge(
|
|
116
|
+
suspiciousPublishers[0],
|
|
117
|
+
filteredTimes[affectedVersions[0]]
|
|
118
|
+
);
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
evidence.push({
|
|
@@ -108,10 +135,14 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
|
108
135
|
try {
|
|
109
136
|
const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
|
|
110
137
|
const res = await fetch(url);
|
|
111
|
-
if (!res.ok)
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
112
141
|
const data = await res.json();
|
|
113
142
|
const created = data?.date;
|
|
114
|
-
if (!created)
|
|
143
|
+
if (!created) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
115
146
|
const createdDate = new Date(created).getTime();
|
|
116
147
|
const firstPub = new Date(firstSuspiciousTime).getTime();
|
|
117
148
|
const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
|
|
@@ -119,6 +150,7 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
|
|
|
119
150
|
return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
|
|
120
151
|
}
|
|
121
152
|
} catch {
|
|
153
|
+
/* ignore fetch errors */
|
|
122
154
|
}
|
|
123
155
|
return '';
|
|
124
156
|
}
|
|
@@ -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
|
}
|