@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,6 +1,13 @@
|
|
|
1
1
|
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
2
|
|
|
3
|
-
const HOOK_NAMES = [
|
|
3
|
+
const HOOK_NAMES = [
|
|
4
|
+
'postinstall',
|
|
5
|
+
'preinstall',
|
|
6
|
+
'install',
|
|
7
|
+
'prepare',
|
|
8
|
+
'preuninstall',
|
|
9
|
+
'postuninstall',
|
|
10
|
+
];
|
|
4
11
|
|
|
5
12
|
const CURL_WGET_RE = /\b(?:curl|wget|powershell|bash|sh)\b/i;
|
|
6
13
|
const CHILD_PROC_RE = /\b(?:exec|execSync|spawn|spawnSync|fork)\s*\(/g;
|
|
@@ -17,9 +24,13 @@ const REQUIRE_RE = /\brequire\s*\(/g;
|
|
|
17
24
|
|
|
18
25
|
function shannonEntropy(s) {
|
|
19
26
|
const len = s.length;
|
|
20
|
-
if (len === 0)
|
|
27
|
+
if (len === 0) {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
21
30
|
const freq = {};
|
|
22
|
-
for (const ch of s)
|
|
31
|
+
for (const ch of s) {
|
|
32
|
+
freq[ch] = (freq[ch] || 0) + 1;
|
|
33
|
+
}
|
|
23
34
|
let entropy = 0;
|
|
24
35
|
for (const count of Object.values(freq)) {
|
|
25
36
|
const p = count / len;
|
|
@@ -29,20 +40,32 @@ function shannonEntropy(s) {
|
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
function isObfuscated(content) {
|
|
32
|
-
if (!content)
|
|
43
|
+
if (!content) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
33
46
|
const noWhitespace = !/\s/.test(content.trim());
|
|
34
47
|
const identifiers = content.match(/\b[a-zA-Z_$][\w$]*\b/g);
|
|
35
48
|
let avgIdLen = 0;
|
|
36
49
|
if (identifiers && identifiers.length > 0) {
|
|
37
50
|
avgIdLen = identifiers.reduce((s, id) => s + id.length, 0) / identifiers.length;
|
|
38
51
|
}
|
|
39
|
-
if (noWhitespace && identifiers && identifiers.length > 0 && avgIdLen < 3)
|
|
40
|
-
|
|
52
|
+
if (noWhitespace && identifiers && identifiers.length > 0 && avgIdLen < 3) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (noWhitespace && /^[a-zA-Z_$][\w$]*\([^)]*\)$/.test(content.trim())) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
41
58
|
HEX_STRING_RE.lastIndex = 0;
|
|
42
|
-
if (HEX_STRING_RE.test(content))
|
|
59
|
+
if (HEX_STRING_RE.test(content)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
43
62
|
B64_RE.lastIndex = 0;
|
|
44
|
-
if (B64_RE.test(content))
|
|
45
|
-
|
|
63
|
+
if (B64_RE.test(content)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (shannonEntropy(content) > 5.5) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
46
69
|
return false;
|
|
47
70
|
}
|
|
48
71
|
|
|
@@ -58,9 +81,11 @@ function extractUrls(content) {
|
|
|
58
81
|
|
|
59
82
|
export const name = 'tier1-lifecycle-hook';
|
|
60
83
|
|
|
61
|
-
export async function scan(pkgJson,
|
|
84
|
+
export async function scan(pkgJson, _jsFiles, _registryMeta, _allFiles) {
|
|
62
85
|
const pkgName = pkgJson?.name;
|
|
63
|
-
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName))
|
|
86
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
64
89
|
|
|
65
90
|
const scripts = pkgJson?.scripts || {};
|
|
66
91
|
const hooks = {};
|
|
@@ -71,18 +96,23 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
71
96
|
}
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
if (Object.keys(hooks).length === 0)
|
|
99
|
+
if (Object.keys(hooks).length === 0) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
75
102
|
|
|
76
103
|
const findings = [];
|
|
77
104
|
|
|
78
105
|
for (const [hookName, scriptContent] of Object.entries(hooks)) {
|
|
79
106
|
const content = typeof scriptContent === 'string' ? scriptContent : '';
|
|
80
|
-
if (!content)
|
|
107
|
+
if (!content) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
81
110
|
|
|
82
111
|
const truncated = content.length > 10240 ? content.slice(0, 10240) : content;
|
|
83
112
|
|
|
84
113
|
const obfuscated = isObfuscated(truncated);
|
|
85
|
-
const hasEval =
|
|
114
|
+
const hasEval =
|
|
115
|
+
EVAL_RE.test(truncated) || FUNCTION_CTOR_RE.test(truncated) || ZERO_EVAL_RE.test(truncated);
|
|
86
116
|
const hasNetwork = CURL_WGET_RE.test(truncated) || CHILD_PROC_RE.test(truncated);
|
|
87
117
|
const hasUrls = URL_RE.test(truncated) || IP_RE.test(truncated);
|
|
88
118
|
const urls = extractUrls(truncated);
|
|
@@ -111,7 +141,9 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
111
141
|
}
|
|
112
142
|
const domainInfo = hasInternal ? 'internal domain' : 'external URL';
|
|
113
143
|
evidence.push(`patterns: hardcoded ${domainInfo} in hook`);
|
|
114
|
-
if (urls.length > 0)
|
|
144
|
+
if (urls.length > 0) {
|
|
145
|
+
evidence.push(`target: ${urls[0]}`);
|
|
146
|
+
}
|
|
115
147
|
}
|
|
116
148
|
|
|
117
149
|
if (envExfil) {
|
|
@@ -143,13 +175,19 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
143
175
|
baseScore = Math.min(100, Math.round(baseScore * 2.5));
|
|
144
176
|
}
|
|
145
177
|
|
|
146
|
-
if (baseScore === 0)
|
|
178
|
+
if (baseScore === 0) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
147
181
|
|
|
148
182
|
const confidenceScore = Math.max(50, Math.min(100, baseScore));
|
|
149
183
|
|
|
150
184
|
function confidenceLabel(score) {
|
|
151
|
-
if (score >= 95)
|
|
152
|
-
|
|
185
|
+
if (score >= 95) {
|
|
186
|
+
return 'CRITICAL';
|
|
187
|
+
}
|
|
188
|
+
if (score >= 80) {
|
|
189
|
+
return 'HIGH';
|
|
190
|
+
}
|
|
153
191
|
return 'MEDIUM';
|
|
154
192
|
}
|
|
155
193
|
|
|
@@ -162,11 +200,13 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
162
200
|
subtype,
|
|
163
201
|
message: `Suspicious lifecycle hook "${hookName}"`,
|
|
164
202
|
evidence,
|
|
165
|
-
locations: [
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
203
|
+
locations: [
|
|
204
|
+
{
|
|
205
|
+
file: 'package.json',
|
|
206
|
+
field: `scripts.${hookName}`,
|
|
207
|
+
value: content.length > 200 ? `${content.slice(0, 200)}...` : content,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
170
210
|
crossFiles: [],
|
|
171
211
|
reference: 'Campaign 1',
|
|
172
212
|
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
|
+
|
|
3
|
+
const THRESHOLDS = {
|
|
4
|
+
flag_threshold: 75,
|
|
5
|
+
warn_threshold: 60,
|
|
6
|
+
velocity_burst_multiplier: 5,
|
|
7
|
+
burst_window_hours: 24,
|
|
8
|
+
min_velocity_baseline: 0.5,
|
|
9
|
+
duplicate_version_weight: 40,
|
|
10
|
+
unusual_timing_weight: 25,
|
|
11
|
+
cross_package_burst_weight: 50,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function parseVersionHistory(registryMeta) {
|
|
15
|
+
const timeData = registryMeta?.time;
|
|
16
|
+
if (!timeData || typeof timeData !== 'object') return [];
|
|
17
|
+
|
|
18
|
+
return Object.entries(timeData)
|
|
19
|
+
.map(([ver, ts]) => ({
|
|
20
|
+
version: ver,
|
|
21
|
+
time: new Date(ts).getTime(),
|
|
22
|
+
date: new Date(ts),
|
|
23
|
+
}))
|
|
24
|
+
.filter((e) => !isNaN(e.time))
|
|
25
|
+
.sort((a, b) => a.time - b.time);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getHour(date) {
|
|
29
|
+
return date.getUTCHours();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function calculateVelocity(history) {
|
|
33
|
+
if (history.length < 2) return { perWeek: 0, perDay: 0 };
|
|
34
|
+
const firstTime = history[0].time;
|
|
35
|
+
const lastTime = history[history.length - 1].time;
|
|
36
|
+
const spanMs = lastTime - firstTime;
|
|
37
|
+
const spanDays = spanMs / (1000 * 60 * 60 * 24);
|
|
38
|
+
if (spanDays < 1) return { perWeek: history.length, perDay: history.length };
|
|
39
|
+
const perDay = history.length / Math.max(spanDays, 1);
|
|
40
|
+
const perWeek = perDay * 7;
|
|
41
|
+
return { perWeek, perDay };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detectBursts(history) {
|
|
45
|
+
const bursts = [];
|
|
46
|
+
const windowMs = (THRESHOLDS.burst_window_hours || 24) * 60 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < history.length; i++) {
|
|
49
|
+
const windowEnd = history[i].time + windowMs;
|
|
50
|
+
const group = [];
|
|
51
|
+
for (let j = i; j < history.length && history[j].time <= windowEnd; j++) {
|
|
52
|
+
group.push(history[j]);
|
|
53
|
+
}
|
|
54
|
+
if (group.length >= 3) {
|
|
55
|
+
const groupHour = group.map((e) => getHour(e.date));
|
|
56
|
+
const timings = groupHour.filter((h) => h >= 0 && h <= 5);
|
|
57
|
+
bursts.push({
|
|
58
|
+
count: group.length,
|
|
59
|
+
windowHours: windowMs / (1000 * 60 * 60),
|
|
60
|
+
versions: group.map((e) => e.version),
|
|
61
|
+
unusualTimings: [...new Set(timings)]
|
|
62
|
+
.sort()
|
|
63
|
+
.map((h) => `${String(h).padStart(2, '0')}:00 UTC`),
|
|
64
|
+
startTime: group[0].date.toISOString(),
|
|
65
|
+
endTime: group[group.length - 1].date.toISOString(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return bursts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function computeConfidence(bursts, velocity, unusualTimingsCount) {
|
|
73
|
+
if (bursts.length === 0) return 0;
|
|
74
|
+
let base = 40;
|
|
75
|
+
|
|
76
|
+
const burst = bursts[0];
|
|
77
|
+
const burstMultiplier =
|
|
78
|
+
velocity.perWeek > 0
|
|
79
|
+
? burst.count / Math.max(velocity.perWeek, THRESHOLDS.min_velocity_baseline)
|
|
80
|
+
: burst.count;
|
|
81
|
+
|
|
82
|
+
if (burstMultiplier >= THRESHOLDS.velocity_burst_multiplier) {
|
|
83
|
+
base += Math.min(burstMultiplier * 3, 30);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (unusualTimingsCount > 0) {
|
|
87
|
+
base += Math.min(unusualTimingsCount * THRESHOLDS.unusual_timing_weight, 20);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (burst.count >= 10) {
|
|
91
|
+
base += 15;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Math.min(100, Math.max(0, base));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function severityLabel(score) {
|
|
98
|
+
if (score >= 80) return 'critical';
|
|
99
|
+
if (score >= 60) return 'high';
|
|
100
|
+
return 'medium';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function confidenceLabel(score) {
|
|
104
|
+
if (score >= 80) return 'CRITICAL';
|
|
105
|
+
if (score >= 60) return 'HIGH';
|
|
106
|
+
return 'MEDIUM';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const name = 'tier1-maintainer-compromise';
|
|
110
|
+
|
|
111
|
+
export async function scan(pkgJson, _jsFiles, registryMeta, _allFiles) {
|
|
112
|
+
const pkgName = pkgJson?.name;
|
|
113
|
+
if (!pkgName) return [];
|
|
114
|
+
if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
|
|
115
|
+
|
|
116
|
+
const history = parseVersionHistory(registryMeta);
|
|
117
|
+
if (history.length < 3) return [];
|
|
118
|
+
|
|
119
|
+
const velocity = calculateVelocity(history);
|
|
120
|
+
const bursts = detectBursts(history);
|
|
121
|
+
if (bursts.length === 0) return [];
|
|
122
|
+
|
|
123
|
+
const burst = bursts[0];
|
|
124
|
+
const unusualTimings = burst.unusualTimings;
|
|
125
|
+
const burstMultiplier =
|
|
126
|
+
velocity.perWeek > 0
|
|
127
|
+
? burst.count / Math.max(velocity.perWeek, THRESHOLDS.min_velocity_baseline)
|
|
128
|
+
: burst.count;
|
|
129
|
+
|
|
130
|
+
const crossPackageBurst = registryMeta?.crossPackageBurst || false;
|
|
131
|
+
|
|
132
|
+
const confidenceScore = computeConfidence(bursts, velocity, unusualTimings.length);
|
|
133
|
+
if (confidenceScore < THRESHOLDS.warn_threshold) return [];
|
|
134
|
+
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
detector: 'tier1-maintainer-compromise',
|
|
138
|
+
id: 'TIER1-MAINTAINER-COMPROMISE',
|
|
139
|
+
severity: severityLabel(confidenceScore),
|
|
140
|
+
confidence: confidenceLabel(confidenceScore),
|
|
141
|
+
confidenceScore,
|
|
142
|
+
subtype: 'maintainer_compromise_burst',
|
|
143
|
+
message: `Maintainer compromise detected: ${burst.count} versions in ${burst.windowHours}h window (${burstMultiplier.toFixed(1)}x normal velocity)`,
|
|
144
|
+
evidence: [
|
|
145
|
+
`normal_velocity: ${velocity.perWeek.toFixed(1)}/week (${velocity.perDay.toFixed(1)}/day)`,
|
|
146
|
+
`burst_count: ${burst.count} in ${burst.windowHours}h`,
|
|
147
|
+
`burst_multiplier: ${burstMultiplier.toFixed(1)}x`,
|
|
148
|
+
`unusual_timings: ${unusualTimings.length > 0 ? unusualTimings.join(', ') : 'none'}`,
|
|
149
|
+
`cross_package: ${crossPackageBurst}`,
|
|
150
|
+
`versions: ${burst.versions.slice(0, 5).join(', ')}${burst.versions.length > 5 ? `... (+${burst.versions.length - 5} more)` : ''}`,
|
|
151
|
+
],
|
|
152
|
+
locations: [{ file: 'package.json', line: 1, column: 1 }],
|
|
153
|
+
crossFiles: [],
|
|
154
|
+
reference: 'D13: @redhat-cloud-services maintainer compromise',
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
@@ -1,68 +1,100 @@
|
|
|
1
1
|
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
2
|
|
|
3
3
|
const INTERNAL_SUFFIX_RE = /\.(?:internal|local|corp|intra|priv|lan)(?:[.:/]|$)/i;
|
|
4
|
-
const CORPORATE_RE =
|
|
5
|
-
|
|
4
|
+
const CORPORATE_RE =
|
|
5
|
+
/(?:github-ent|jira-ent|github\.enterprise|internal-gitlab|gitlab\.internal|jenkins\.internal|confluence\.internal)/i;
|
|
6
|
+
const PRIVATE_IP_RE =
|
|
7
|
+
/^(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})/;
|
|
6
8
|
|
|
7
9
|
function extractDomain(url) {
|
|
8
10
|
try {
|
|
9
11
|
const u = new URL(url);
|
|
10
12
|
return u.hostname;
|
|
11
13
|
} catch {
|
|
12
|
-
const m = url.match(/^(?:https?:\/\/)?([
|
|
14
|
+
const m = url.match(/^(?:https?:\/\/)?([^/\s:]+)/);
|
|
13
15
|
return m ? m[1] : null;
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function isInternalUrl(url) {
|
|
18
|
-
if (!url)
|
|
20
|
+
if (!url) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
19
23
|
const domain = extractDomain(url);
|
|
20
|
-
if (!domain)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
+
if (!domain) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (INTERNAL_SUFFIX_RE.test(domain)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (CORPORATE_RE.test(domain)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (PRIVATE_IP_RE.test(domain)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
24
36
|
return false;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function parseSemver(version) {
|
|
28
|
-
if (!version)
|
|
40
|
+
if (!version) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
29
43
|
const parts = version.replace(/^[~^]/, '').split('.');
|
|
30
44
|
const m = parseInt(parts[0], 10);
|
|
31
45
|
const n = parseInt(parts[1], 10);
|
|
32
46
|
const p = parseInt(parts[2], 10);
|
|
33
|
-
if (isNaN(m) || isNaN(n) || isNaN(p))
|
|
47
|
+
if (isNaN(m) || isNaN(n) || isNaN(p)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
34
50
|
return { major: m, minor: n, patch: p };
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
function detectSemverInflation(currentVer, registryMeta) {
|
|
38
|
-
if (!currentVer || !registryMeta)
|
|
54
|
+
if (!currentVer || !registryMeta) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
39
57
|
|
|
40
58
|
const age = registryMeta.age;
|
|
41
|
-
if (age !== undefined && age < 7)
|
|
59
|
+
if (age !== undefined && age < 7) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
const previousVer = registryMeta.previousVersion || null;
|
|
44
|
-
if (!previousVer)
|
|
64
|
+
if (!previousVer) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
45
67
|
|
|
46
68
|
const cur = parseSemver(currentVer);
|
|
47
69
|
const prev = parseSemver(previousVer);
|
|
48
|
-
if (!cur || !prev)
|
|
70
|
+
if (!cur || !prev) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
49
73
|
|
|
50
74
|
const majorJump = cur.major - prev.major;
|
|
51
75
|
const minorJump = cur.minor - prev.minor;
|
|
52
76
|
const patchJump = cur.patch - prev.patch;
|
|
53
77
|
|
|
54
|
-
if (majorJump > 10)
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
if (majorJump > 10) {
|
|
79
|
+
return { type: 'major', from: previousVer, to: currentVer, jump: majorJump };
|
|
80
|
+
}
|
|
81
|
+
if (minorJump > 20) {
|
|
82
|
+
return { type: 'minor', from: previousVer, to: currentVer, jump: minorJump };
|
|
83
|
+
}
|
|
84
|
+
if (patchJump > 50) {
|
|
85
|
+
return { type: 'patch', from: previousVer, to: currentVer, jump: patchJump };
|
|
86
|
+
}
|
|
57
87
|
|
|
58
88
|
return null;
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
export const name = 'tier1-metadata-spoof';
|
|
62
92
|
|
|
63
|
-
export async function scan(pkgJson, jsFiles, registryMeta,
|
|
93
|
+
export async function scan(pkgJson, jsFiles, registryMeta, _allFiles) {
|
|
64
94
|
const pkgName = pkgJson?.name;
|
|
65
|
-
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName))
|
|
95
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
66
98
|
|
|
67
99
|
const fieldUrls = [];
|
|
68
100
|
|
|
@@ -87,7 +119,9 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
87
119
|
if (pkgJson.funding) {
|
|
88
120
|
const arr = Array.isArray(pkgJson.funding) ? pkgJson.funding : [pkgJson.funding];
|
|
89
121
|
for (let i = 0; i < arr.length; i++) {
|
|
90
|
-
if (arr[i] && arr[i].url)
|
|
122
|
+
if (arr[i] && arr[i].url) {
|
|
123
|
+
addField(`funding[${i}].url`, arr[i].url);
|
|
124
|
+
}
|
|
91
125
|
}
|
|
92
126
|
}
|
|
93
127
|
|
|
@@ -95,13 +129,15 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
95
129
|
addField('author.url', pkgJson.author.url);
|
|
96
130
|
}
|
|
97
131
|
|
|
98
|
-
const internalFields = fieldUrls.filter(f => isInternalUrl(f.value));
|
|
132
|
+
const internalFields = fieldUrls.filter((f) => isInternalUrl(f.value));
|
|
99
133
|
const hasInternalUrls = internalFields.length > 0;
|
|
100
134
|
|
|
101
135
|
const currentVersion = pkgJson?.version;
|
|
102
136
|
const semverInflation = detectSemverInflation(currentVersion, registryMeta);
|
|
103
137
|
|
|
104
|
-
if (!hasInternalUrls && !semverInflation)
|
|
138
|
+
if (!hasInternalUrls && !semverInflation) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
105
141
|
|
|
106
142
|
let baseScore = 0;
|
|
107
143
|
let subtype = '';
|
|
@@ -117,10 +153,14 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
117
153
|
const domain = extractDomain(f.value);
|
|
118
154
|
evidence.push(`url: ${f.field} = ${f.value}`);
|
|
119
155
|
|
|
120
|
-
let pattern
|
|
121
|
-
if (PRIVATE_IP_RE.test(domain))
|
|
122
|
-
|
|
123
|
-
else
|
|
156
|
+
let pattern;
|
|
157
|
+
if (PRIVATE_IP_RE.test(domain)) {
|
|
158
|
+
pattern = 'private IP';
|
|
159
|
+
} else if (CORPORATE_RE.test(domain)) {
|
|
160
|
+
pattern = 'corporate domain';
|
|
161
|
+
} else {
|
|
162
|
+
pattern = 'internal domain';
|
|
163
|
+
}
|
|
124
164
|
evidence.push(`pattern: ${domain} (${pattern})`);
|
|
125
165
|
|
|
126
166
|
locations.push({ field: f.field, value: f.value });
|
|
@@ -140,7 +180,9 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
140
180
|
if (hasInternalUrls) {
|
|
141
181
|
baseScore = Math.round(baseScore * 1.3);
|
|
142
182
|
evidence.push(semverMsg);
|
|
143
|
-
evidence.push(
|
|
183
|
+
evidence.push(
|
|
184
|
+
`${semverInflation.type} version jump (${semverInflation.jump}) without changelog`
|
|
185
|
+
);
|
|
144
186
|
locations.push({ field: 'version', old: semverInflation.from, new: semverInflation.to });
|
|
145
187
|
primaryMessage += ' + unjustified semver jump';
|
|
146
188
|
} else {
|
|
@@ -155,26 +197,34 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
155
197
|
const confidenceScore = Math.max(50, Math.min(90, baseScore));
|
|
156
198
|
|
|
157
199
|
function severityLabel(sc) {
|
|
158
|
-
if (sc >= 70)
|
|
200
|
+
if (sc >= 70) {
|
|
201
|
+
return 'high';
|
|
202
|
+
}
|
|
159
203
|
return 'medium';
|
|
160
204
|
}
|
|
161
205
|
|
|
162
206
|
function confidenceLabel(sc) {
|
|
163
|
-
if (sc >= 80)
|
|
164
|
-
|
|
207
|
+
if (sc >= 80) {
|
|
208
|
+
return 'HIGH';
|
|
209
|
+
}
|
|
210
|
+
if (sc >= 60) {
|
|
211
|
+
return 'MEDIUM';
|
|
212
|
+
}
|
|
165
213
|
return 'LOW';
|
|
166
214
|
}
|
|
167
215
|
|
|
168
|
-
return [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
detector: 'tier1-metadata-spoof',
|
|
219
|
+
id: 'TIER1-METADATA-SPOOF',
|
|
220
|
+
severity: severityLabel(confidenceScore),
|
|
221
|
+
confidence: confidenceLabel(confidenceScore),
|
|
222
|
+
confidenceScore,
|
|
223
|
+
subtype,
|
|
224
|
+
message: primaryMessage,
|
|
225
|
+
evidence,
|
|
226
|
+
locations,
|
|
227
|
+
reference: 'Campaign 1',
|
|
228
|
+
},
|
|
229
|
+
];
|
|
180
230
|
}
|
|
@@ -1,34 +1,51 @@
|
|
|
1
1
|
const SCAN_HOOKS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
2
2
|
|
|
3
|
-
const REMOTE_FETCH_RE =
|
|
3
|
+
const REMOTE_FETCH_RE =
|
|
4
|
+
/\b(?:fetch|axios\.get|axios\.post|http\.get|https\.get)\(|\b(?:curl|wget)\s/;
|
|
4
5
|
const BINARY_EXEC_RE = /\b(?:execFile|execFileSync|execSync|exec|spawnSync|spawn)\s*\(/;
|
|
5
6
|
const DETACHED_RE = /detached\s*:\s*true/;
|
|
6
7
|
|
|
7
8
|
function severityLabel(score) {
|
|
8
|
-
if (score >= 95)
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
if (score >= 95) {
|
|
10
|
+
return 'critical';
|
|
11
|
+
}
|
|
12
|
+
if (score >= 80) {
|
|
13
|
+
return 'high';
|
|
14
|
+
}
|
|
15
|
+
if (score >= 60) {
|
|
16
|
+
return 'medium';
|
|
17
|
+
}
|
|
11
18
|
return 'low';
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
function confidenceLabel(score) {
|
|
15
|
-
if (score >= 95)
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
if (score >= 95) {
|
|
23
|
+
return 'CRITICAL';
|
|
24
|
+
}
|
|
25
|
+
if (score >= 80) {
|
|
26
|
+
return 'HIGH';
|
|
27
|
+
}
|
|
28
|
+
if (score >= 60) {
|
|
29
|
+
return 'MEDIUM';
|
|
30
|
+
}
|
|
18
31
|
return 'LOW';
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
export const name = 'tier1-multistage-postinstall';
|
|
22
35
|
|
|
23
|
-
export async function scan(pkgJson,
|
|
36
|
+
export async function scan(pkgJson, _jsFiles, _registryMeta, _allFiles) {
|
|
24
37
|
const scripts = pkgJson?.scripts;
|
|
25
|
-
if (!scripts || typeof scripts !== 'object')
|
|
38
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
26
41
|
|
|
27
42
|
const findings = [];
|
|
28
43
|
|
|
29
44
|
for (const hookName of SCAN_HOOKS) {
|
|
30
45
|
const content = scripts[hookName];
|
|
31
|
-
if (!content || typeof content !== 'string')
|
|
46
|
+
if (!content || typeof content !== 'string') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
32
49
|
|
|
33
50
|
const hasRemoteFetch = REMOTE_FETCH_RE.test(content);
|
|
34
51
|
const hasBinaryExec = BINARY_EXEC_RE.test(content);
|
|
@@ -37,7 +54,9 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
37
54
|
const signalA = hasRemoteFetch && hasBinaryExec;
|
|
38
55
|
const signalB = hasDetached;
|
|
39
56
|
|
|
40
|
-
if (!signalA && !signalB)
|
|
57
|
+
if (!signalA && !signalB) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
41
60
|
|
|
42
61
|
let confidenceScore;
|
|
43
62
|
let subtype;
|
|
@@ -54,9 +73,15 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
const evidence = [`hook: ${hookName}`];
|
|
57
|
-
if (hasRemoteFetch)
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
if (hasRemoteFetch) {
|
|
77
|
+
evidence.push('pattern: remote fetch call');
|
|
78
|
+
}
|
|
79
|
+
if (hasBinaryExec) {
|
|
80
|
+
evidence.push('pattern: binary execution call');
|
|
81
|
+
}
|
|
82
|
+
if (hasDetached) {
|
|
83
|
+
evidence.push('pattern: detached background process');
|
|
84
|
+
}
|
|
60
85
|
|
|
61
86
|
findings.push({
|
|
62
87
|
detector: 'tier1-multistage-postinstall',
|
|
@@ -67,11 +92,13 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
|
67
92
|
subtype,
|
|
68
93
|
message: `Multi-stage install hook detected in "${hookName}" — ${subtype}`,
|
|
69
94
|
evidence,
|
|
70
|
-
locations: [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
locations: [
|
|
96
|
+
{
|
|
97
|
+
file: 'package.json',
|
|
98
|
+
field: `scripts.${hookName}`,
|
|
99
|
+
value: content.length > 200 ? `${content.slice(0, 200)}...` : content,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
75
102
|
crossFiles: [],
|
|
76
103
|
reference: 'Sonatype-2026-003429',
|
|
77
104
|
});
|