@lateos/npm-scan 0.16.4 → 0.17.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/.dockerignore +20 -20
- package/.husky/pre-commit +1 -1
- package/CHANGELOG.md +199 -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 +75 -44
- 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-binary-embed.js +219 -0
- package/backend/detectors/tier1-infostealer.js +280 -0
- package/backend/detectors/tier1-lifecycle-hook.js +176 -0
- package/backend/detectors/tier1-metadata-spoof.js +180 -0
- package/backend/detectors/tier1-typosquat.js +219 -0
- package/backend/fetch.js +175 -175
- package/backend/index.js +4 -4
- package/backend/license.js +89 -89
- package/backend/lockfile.js +379 -379
- package/backend/pdf.js +245 -245
- package/backend/policy.js +193 -176
- package/backend/report.js +254 -254
- package/backend/sbom.js +66 -66
- package/backend/siem/cef.js +32 -32
- package/backend/siem/ecs.js +40 -40
- package/backend/siem/index.js +18 -18
- package/backend/siem/qradar.js +56 -56
- package/backend/siem/sentinel.js +27 -27
- package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
- package/backend/vsix-scan/detectors/burst-publish.js +52 -52
- package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
- package/backend/vsix-scan/detectors/known-ioc.js +105 -105
- package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
- package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
- package/backend/vsix-scan/index.js +183 -183
- package/backend/vsix-scan/marketplace-client.js +145 -145
- package/backend/vsix-scan/vsix-iocs.json +31 -31
- package/cli/cli.js +458 -458
- package/deploy/helm/npm-scan/Chart.yaml +21 -21
- package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
- package/deploy/helm/npm-scan/templates/api.yaml +93 -93
- package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
- package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
- package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
- package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
- package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
- package/deploy/helm/npm-scan/values.yaml +102 -102
- package/package.json +57 -57
- package/scripts/download-corpus.js +30 -30
- package/scripts/gen-mal-corpus.js +34 -34
- package/scripts/generate-campaign-fixtures.js +170 -0
- package/src/config/top-5000.json +87 -0
- package/test/fixtures/lockfiles/npm-lock.json +68 -68
- package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
- package/test/fixtures/lockfiles/yarn.lock +103 -103
- package/test/fixtures/mock-data.js +69 -69
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
|
+
|
|
3
|
+
const HOOK_NAMES = ['postinstall', 'preinstall', 'install', 'prepare', 'preuninstall', 'postuninstall'];
|
|
4
|
+
|
|
5
|
+
const CURL_WGET_RE = /\b(?:curl|wget|powershell|bash|sh)\b/i;
|
|
6
|
+
const CHILD_PROC_RE = /\b(?:exec|execSync|spawn|spawnSync|fork)\s*\(/g;
|
|
7
|
+
const EVAL_RE = /\beval\s*\(/g;
|
|
8
|
+
const FUNCTION_CTOR_RE = /\bFunction\s*\(/g;
|
|
9
|
+
const ZERO_EVAL_RE = /\(0,\s*eval\)\s*\(/g;
|
|
10
|
+
const URL_RE = /https?:\/\/([^'"\s)\]]+)/gi;
|
|
11
|
+
const IP_RE = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
|
|
12
|
+
const INTERNAL_DOMAIN_RE = /(?:github-ent|jira\.internal|docs\.internal)/i;
|
|
13
|
+
const ENV_EXFIL_RE = /process\.env\.(?:AWS_[A-Z_]+|NPM_TOKEN|NPM_AUTH_TOKEN|GIT_TOKEN|SSH_KEY)/g;
|
|
14
|
+
const HEX_STRING_RE = /(?:0x[0-9a-fA-F]{2,}|\\x[0-9a-fA-F]{2})/g;
|
|
15
|
+
const B64_RE = /['"`]([A-Za-z0-9+/]{20,}={0,2})['"`]/g;
|
|
16
|
+
const REQUIRE_RE = /\brequire\s*\(/g;
|
|
17
|
+
|
|
18
|
+
function shannonEntropy(s) {
|
|
19
|
+
const len = s.length;
|
|
20
|
+
if (len === 0) return 0;
|
|
21
|
+
const freq = {};
|
|
22
|
+
for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
|
|
23
|
+
let entropy = 0;
|
|
24
|
+
for (const count of Object.values(freq)) {
|
|
25
|
+
const p = count / len;
|
|
26
|
+
entropy -= p * Math.log2(p);
|
|
27
|
+
}
|
|
28
|
+
return entropy;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isObfuscated(content) {
|
|
32
|
+
if (!content) return false;
|
|
33
|
+
const noWhitespace = !/\s/.test(content.trim());
|
|
34
|
+
const identifiers = content.match(/\b[a-zA-Z_$][\w$]*\b/g);
|
|
35
|
+
let avgIdLen = 0;
|
|
36
|
+
if (identifiers && identifiers.length > 0) {
|
|
37
|
+
avgIdLen = identifiers.reduce((s, id) => s + id.length, 0) / identifiers.length;
|
|
38
|
+
}
|
|
39
|
+
if (noWhitespace && identifiers && identifiers.length > 0 && avgIdLen < 3) return true;
|
|
40
|
+
if (noWhitespace && /^[a-zA-Z_$][\w$]*\([^)]*\)$/.test(content.trim())) return true;
|
|
41
|
+
HEX_STRING_RE.lastIndex = 0;
|
|
42
|
+
if (HEX_STRING_RE.test(content)) return true;
|
|
43
|
+
B64_RE.lastIndex = 0;
|
|
44
|
+
if (B64_RE.test(content)) return true;
|
|
45
|
+
if (shannonEntropy(content) > 5.5) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractUrls(content) {
|
|
50
|
+
const urls = [];
|
|
51
|
+
let match;
|
|
52
|
+
URL_RE.lastIndex = 0;
|
|
53
|
+
while ((match = URL_RE.exec(content)) !== null) {
|
|
54
|
+
urls.push(match[1]);
|
|
55
|
+
}
|
|
56
|
+
return urls;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const name = 'tier1-lifecycle-hook';
|
|
60
|
+
|
|
61
|
+
export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
62
|
+
const pkgName = pkgJson?.name;
|
|
63
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
|
|
64
|
+
|
|
65
|
+
const scripts = pkgJson?.scripts || {};
|
|
66
|
+
const hooks = {};
|
|
67
|
+
|
|
68
|
+
for (const [name, val] of Object.entries(scripts)) {
|
|
69
|
+
if (HOOK_NAMES.includes(name) || /^(pre|post)/.test(name)) {
|
|
70
|
+
hooks[name] = val;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Object.keys(hooks).length === 0) return [];
|
|
75
|
+
|
|
76
|
+
const findings = [];
|
|
77
|
+
|
|
78
|
+
for (const [hookName, scriptContent] of Object.entries(hooks)) {
|
|
79
|
+
const content = typeof scriptContent === 'string' ? scriptContent : '';
|
|
80
|
+
if (!content) continue;
|
|
81
|
+
|
|
82
|
+
const truncated = content.length > 10240 ? content.slice(0, 10240) : content;
|
|
83
|
+
|
|
84
|
+
const obfuscated = isObfuscated(truncated);
|
|
85
|
+
const hasEval = EVAL_RE.test(truncated) || FUNCTION_CTOR_RE.test(truncated) || ZERO_EVAL_RE.test(truncated);
|
|
86
|
+
const hasNetwork = CURL_WGET_RE.test(truncated) || CHILD_PROC_RE.test(truncated);
|
|
87
|
+
const hasUrls = URL_RE.test(truncated) || IP_RE.test(truncated);
|
|
88
|
+
const urls = extractUrls(truncated);
|
|
89
|
+
const hasInternal = INTERNAL_DOMAIN_RE.test(truncated);
|
|
90
|
+
const envExfil = ENV_EXFIL_RE.test(truncated);
|
|
91
|
+
const silent = !REQUIRE_RE.test(truncated);
|
|
92
|
+
|
|
93
|
+
let baseScore = 0;
|
|
94
|
+
let subtype = '';
|
|
95
|
+
let severity = 'medium';
|
|
96
|
+
const evidence = [`hook: ${hookName}`];
|
|
97
|
+
|
|
98
|
+
if (hasEval || (obfuscated && hasNetwork)) {
|
|
99
|
+
baseScore = 90;
|
|
100
|
+
subtype = 'obfuscated_install';
|
|
101
|
+
severity = 'critical';
|
|
102
|
+
evidence.push('patterns: eval, obfuscated code');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (hasUrls) {
|
|
106
|
+
const urlBase = hasInternal ? 90 : 70;
|
|
107
|
+
if (urlBase > baseScore) {
|
|
108
|
+
baseScore = urlBase;
|
|
109
|
+
subtype = hasInternal ? 'obfuscated_install' : 'encoded_payload_postinstall';
|
|
110
|
+
severity = hasInternal ? 'critical' : 'high';
|
|
111
|
+
}
|
|
112
|
+
const domainInfo = hasInternal ? 'internal domain' : 'external URL';
|
|
113
|
+
evidence.push(`patterns: hardcoded ${domainInfo} in hook`);
|
|
114
|
+
if (urls.length > 0) evidence.push(`target: ${urls[0]}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (envExfil) {
|
|
118
|
+
const envScore = 90;
|
|
119
|
+
if (envScore > baseScore) {
|
|
120
|
+
baseScore = envScore;
|
|
121
|
+
subtype = hasUrls ? 'hidden_preinstall' : 'encoded_payload_postinstall';
|
|
122
|
+
severity = 'critical';
|
|
123
|
+
}
|
|
124
|
+
evidence.push('pattern: process.env exfiltration');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (obfuscated && hasEval && hasNetwork && !hasUrls && !envExfil) {
|
|
128
|
+
if (baseScore < 90) {
|
|
129
|
+
baseScore = 90;
|
|
130
|
+
subtype = 'obfuscated_install';
|
|
131
|
+
severity = 'critical';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (obfuscated) {
|
|
136
|
+
const entropy = shannonEntropy(truncated);
|
|
137
|
+
evidence.push(`entropy: ${entropy.toFixed(2)} (suspicious)`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (silent && baseScore >= 70) {
|
|
141
|
+
subtype = 'silent_eval_in_hook';
|
|
142
|
+
evidence.push('silent: no explicit require()');
|
|
143
|
+
baseScore = Math.min(100, Math.round(baseScore * 2.5));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (baseScore === 0) continue;
|
|
147
|
+
|
|
148
|
+
const confidenceScore = Math.max(50, Math.min(100, baseScore));
|
|
149
|
+
|
|
150
|
+
function confidenceLabel(score) {
|
|
151
|
+
if (score >= 95) return 'CRITICAL';
|
|
152
|
+
if (score >= 80) return 'HIGH';
|
|
153
|
+
return 'MEDIUM';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
findings.push({
|
|
157
|
+
detector: 'tier1-lifecycle-hook',
|
|
158
|
+
id: 'TIER1-LIFECYCLE-HOOK',
|
|
159
|
+
severity,
|
|
160
|
+
confidence: confidenceLabel(confidenceScore),
|
|
161
|
+
confidenceScore,
|
|
162
|
+
subtype,
|
|
163
|
+
message: `Suspicious lifecycle hook "${hookName}"`,
|
|
164
|
+
evidence,
|
|
165
|
+
locations: [{
|
|
166
|
+
file: 'package.json',
|
|
167
|
+
field: `scripts.${hookName}`,
|
|
168
|
+
value: content.length > 200 ? `${content.slice(0, 200)}...` : content,
|
|
169
|
+
}],
|
|
170
|
+
crossFiles: [],
|
|
171
|
+
reference: 'Campaign 1',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
2
|
+
|
|
3
|
+
const INTERNAL_SUFFIX_RE = /\.(?:internal|local|corp|intra|priv|lan)(?:[.:/]|$)/i;
|
|
4
|
+
const CORPORATE_RE = /(?:github-ent|jira-ent|github\.enterprise|internal-gitlab|gitlab\.internal|jenkins\.internal|confluence\.internal)/i;
|
|
5
|
+
const PRIVATE_IP_RE = /^(?: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
|
+
|
|
7
|
+
function extractDomain(url) {
|
|
8
|
+
try {
|
|
9
|
+
const u = new URL(url);
|
|
10
|
+
return u.hostname;
|
|
11
|
+
} catch {
|
|
12
|
+
const m = url.match(/^(?:https?:\/\/)?([^\/\s:]+)/);
|
|
13
|
+
return m ? m[1] : null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isInternalUrl(url) {
|
|
18
|
+
if (!url) return false;
|
|
19
|
+
const domain = extractDomain(url);
|
|
20
|
+
if (!domain) return false;
|
|
21
|
+
if (INTERNAL_SUFFIX_RE.test(domain)) return true;
|
|
22
|
+
if (CORPORATE_RE.test(domain)) return true;
|
|
23
|
+
if (PRIVATE_IP_RE.test(domain)) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseSemver(version) {
|
|
28
|
+
if (!version) return null;
|
|
29
|
+
const parts = version.replace(/^[~^]/, '').split('.');
|
|
30
|
+
const m = parseInt(parts[0], 10);
|
|
31
|
+
const n = parseInt(parts[1], 10);
|
|
32
|
+
const p = parseInt(parts[2], 10);
|
|
33
|
+
if (isNaN(m) || isNaN(n) || isNaN(p)) return null;
|
|
34
|
+
return { major: m, minor: n, patch: p };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function detectSemverInflation(currentVer, registryMeta) {
|
|
38
|
+
if (!currentVer || !registryMeta) return null;
|
|
39
|
+
|
|
40
|
+
const age = registryMeta.age;
|
|
41
|
+
if (age !== undefined && age < 7) return null;
|
|
42
|
+
|
|
43
|
+
const previousVer = registryMeta.previousVersion || null;
|
|
44
|
+
if (!previousVer) return null;
|
|
45
|
+
|
|
46
|
+
const cur = parseSemver(currentVer);
|
|
47
|
+
const prev = parseSemver(previousVer);
|
|
48
|
+
if (!cur || !prev) return null;
|
|
49
|
+
|
|
50
|
+
const majorJump = cur.major - prev.major;
|
|
51
|
+
const minorJump = cur.minor - prev.minor;
|
|
52
|
+
const patchJump = cur.patch - prev.patch;
|
|
53
|
+
|
|
54
|
+
if (majorJump > 10) return { type: 'major', from: previousVer, to: currentVer, jump: majorJump };
|
|
55
|
+
if (minorJump > 20) return { type: 'minor', from: previousVer, to: currentVer, jump: minorJump };
|
|
56
|
+
if (patchJump > 50) return { type: 'patch', from: previousVer, to: currentVer, jump: patchJump };
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const name = 'tier1-metadata-spoof';
|
|
62
|
+
|
|
63
|
+
export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
64
|
+
const pkgName = pkgJson?.name;
|
|
65
|
+
if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
|
|
66
|
+
|
|
67
|
+
const fieldUrls = [];
|
|
68
|
+
|
|
69
|
+
function addField(field, value) {
|
|
70
|
+
if (value && typeof value === 'string') {
|
|
71
|
+
fieldUrls.push({ field, value });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (pkgJson.repository) {
|
|
76
|
+
const v = typeof pkgJson.repository === 'string' ? pkgJson.repository : pkgJson.repository.url;
|
|
77
|
+
addField('repository.url', v);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
addField('homepage', pkgJson.homepage);
|
|
81
|
+
|
|
82
|
+
if (pkgJson.bugs) {
|
|
83
|
+
const v = typeof pkgJson.bugs === 'string' ? pkgJson.bugs : pkgJson.bugs.url;
|
|
84
|
+
addField('bugs.url', v);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (pkgJson.funding) {
|
|
88
|
+
const arr = Array.isArray(pkgJson.funding) ? pkgJson.funding : [pkgJson.funding];
|
|
89
|
+
for (let i = 0; i < arr.length; i++) {
|
|
90
|
+
if (arr[i] && arr[i].url) addField(`funding[${i}].url`, arr[i].url);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (pkgJson.author && typeof pkgJson.author === 'object' && pkgJson.author.url) {
|
|
95
|
+
addField('author.url', pkgJson.author.url);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const internalFields = fieldUrls.filter(f => isInternalUrl(f.value));
|
|
99
|
+
const hasInternalUrls = internalFields.length > 0;
|
|
100
|
+
|
|
101
|
+
const currentVersion = pkgJson?.version;
|
|
102
|
+
const semverInflation = detectSemverInflation(currentVersion, registryMeta);
|
|
103
|
+
|
|
104
|
+
if (!hasInternalUrls && !semverInflation) return [];
|
|
105
|
+
|
|
106
|
+
let baseScore = 0;
|
|
107
|
+
let subtype = '';
|
|
108
|
+
let primaryMessage = '';
|
|
109
|
+
const evidence = [];
|
|
110
|
+
const locations = [];
|
|
111
|
+
|
|
112
|
+
if (hasInternalUrls) {
|
|
113
|
+
baseScore = 65;
|
|
114
|
+
subtype = 'internal_url_in_repo';
|
|
115
|
+
|
|
116
|
+
for (const f of internalFields) {
|
|
117
|
+
const domain = extractDomain(f.value);
|
|
118
|
+
evidence.push(`url: ${f.field} = ${f.value}`);
|
|
119
|
+
|
|
120
|
+
let pattern = '';
|
|
121
|
+
if (PRIVATE_IP_RE.test(domain)) pattern = 'private IP';
|
|
122
|
+
else if (CORPORATE_RE.test(domain)) pattern = 'corporate domain';
|
|
123
|
+
else pattern = 'internal domain';
|
|
124
|
+
evidence.push(`pattern: ${domain} (${pattern})`);
|
|
125
|
+
|
|
126
|
+
locations.push({ field: f.field, value: f.value });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (internalFields.length > 1) {
|
|
130
|
+
baseScore += 20;
|
|
131
|
+
evidence.push('coordinated: multiple internal URLs');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
primaryMessage = `Package metadata contains spoofed internal URL${internalFields.length > 1 ? 's' : ''}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (semverInflation) {
|
|
138
|
+
const semverMsg = `semver: ${semverInflation.from} \u2192 ${semverInflation.to} (${semverInflation.type} jump of ${semverInflation.jump})`;
|
|
139
|
+
|
|
140
|
+
if (hasInternalUrls) {
|
|
141
|
+
baseScore = Math.round(baseScore * 1.3);
|
|
142
|
+
evidence.push(semverMsg);
|
|
143
|
+
evidence.push(`${semverInflation.type} version jump (${semverInflation.jump}) without changelog`);
|
|
144
|
+
locations.push({ field: 'version', old: semverInflation.from, new: semverInflation.to });
|
|
145
|
+
primaryMessage += ' + unjustified semver jump';
|
|
146
|
+
} else {
|
|
147
|
+
baseScore = 40;
|
|
148
|
+
subtype = 'semver_inflation';
|
|
149
|
+
evidence.push(semverMsg);
|
|
150
|
+
locations.push({ field: 'version', old: semverInflation.from, new: semverInflation.to });
|
|
151
|
+
primaryMessage = 'Unjustified semver version jump detected';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const confidenceScore = Math.max(50, Math.min(90, baseScore));
|
|
156
|
+
|
|
157
|
+
function severityLabel(sc) {
|
|
158
|
+
if (sc >= 70) return 'high';
|
|
159
|
+
return 'medium';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function confidenceLabel(sc) {
|
|
163
|
+
if (sc >= 80) return 'HIGH';
|
|
164
|
+
if (sc >= 60) return 'MEDIUM';
|
|
165
|
+
return 'LOW';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [{
|
|
169
|
+
detector: 'tier1-metadata-spoof',
|
|
170
|
+
id: 'TIER1-METADATA-SPOOF',
|
|
171
|
+
severity: severityLabel(confidenceScore),
|
|
172
|
+
confidence: confidenceLabel(confidenceScore),
|
|
173
|
+
confidenceScore,
|
|
174
|
+
subtype,
|
|
175
|
+
message: primaryMessage,
|
|
176
|
+
evidence,
|
|
177
|
+
locations,
|
|
178
|
+
reference: 'Campaign 1',
|
|
179
|
+
}];
|
|
180
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const TOP_PACKAGES = require('../../src/config/top-5000.json');
|
|
6
|
+
const TOP_SET = new Set(TOP_PACKAGES);
|
|
7
|
+
|
|
8
|
+
function levenshtein(a, b) {
|
|
9
|
+
if (Math.abs(a.length - b.length) > 2) return 3;
|
|
10
|
+
const m = a.length, n = b.length;
|
|
11
|
+
if (m === 0) return n;
|
|
12
|
+
if (n === 0) return m;
|
|
13
|
+
let prev = new Int32Array(n + 1);
|
|
14
|
+
let curr = new Int32Array(n + 1);
|
|
15
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
16
|
+
for (let i = 1; i <= m; i++) {
|
|
17
|
+
curr[0] = i;
|
|
18
|
+
let rowMin = curr[0];
|
|
19
|
+
for (let j = 1; j <= n; j++) {
|
|
20
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
21
|
+
curr[j] = Math.min(
|
|
22
|
+
prev[j] + 1,
|
|
23
|
+
curr[j - 1] + 1,
|
|
24
|
+
prev[j - 1] + cost
|
|
25
|
+
);
|
|
26
|
+
if (curr[j] < rowMin) rowMin = curr[j];
|
|
27
|
+
}
|
|
28
|
+
if (rowMin > 2) return 3;
|
|
29
|
+
const tmp = prev; prev = curr; curr = tmp;
|
|
30
|
+
}
|
|
31
|
+
return prev[n];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jaroWinkler(a, b) {
|
|
35
|
+
if (a === b) return 1;
|
|
36
|
+
const m = a.length, n = b.length;
|
|
37
|
+
if (m === 0 || n === 0) return 0;
|
|
38
|
+
const matchDist = Math.floor(Math.max(m, n) / 2) - 1;
|
|
39
|
+
const aMatch = new Array(m).fill(false);
|
|
40
|
+
const bMatch = new Array(n).fill(false);
|
|
41
|
+
let matches = 0;
|
|
42
|
+
for (let i = 0; i < m; i++) {
|
|
43
|
+
const start = Math.max(0, i - matchDist);
|
|
44
|
+
const end = Math.min(n, i + matchDist + 1);
|
|
45
|
+
for (let j = start; j < end; j++) {
|
|
46
|
+
if (bMatch[j] || a[i] !== b[j]) continue;
|
|
47
|
+
aMatch[i] = true;
|
|
48
|
+
bMatch[j] = true;
|
|
49
|
+
matches++;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (matches === 0) return 0;
|
|
54
|
+
let t = 0, k = 0;
|
|
55
|
+
for (let i = 0; i < m; i++) {
|
|
56
|
+
if (!aMatch[i]) continue;
|
|
57
|
+
while (!bMatch[k]) k++;
|
|
58
|
+
if (a[i] !== b[k]) t++;
|
|
59
|
+
k++;
|
|
60
|
+
}
|
|
61
|
+
const jaro = (matches / m + matches / n + (matches - t / 2) / matches) / 3;
|
|
62
|
+
let prefix = 0;
|
|
63
|
+
const limit = Math.min(4, m, n);
|
|
64
|
+
for (let i = 0; i < limit; i++) {
|
|
65
|
+
if (a[i] === b[i]) prefix++;
|
|
66
|
+
else break;
|
|
67
|
+
}
|
|
68
|
+
return jaro + prefix * 0.1 * (1 - jaro);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function soundex(s) {
|
|
72
|
+
if (!s) return '';
|
|
73
|
+
s = s.toLowerCase();
|
|
74
|
+
const first = s[0];
|
|
75
|
+
const rest = s.slice(1)
|
|
76
|
+
.replace(/[aeiouyhw]/g, '')
|
|
77
|
+
.replace(/[bfpv]/g, '1')
|
|
78
|
+
.replace(/[cgjkqsxz]/g, '2')
|
|
79
|
+
.replace(/[dt]/g, '3')
|
|
80
|
+
.replace(/l/g, '4')
|
|
81
|
+
.replace(/[mn]/g, '5')
|
|
82
|
+
.replace(/r/g, '6')
|
|
83
|
+
.replace(/(\d)\1+/g, '$1');
|
|
84
|
+
return (first + rest + '000').slice(0, 4);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function homoglyphScore(a, b) {
|
|
88
|
+
if (a.length !== b.length) return 0;
|
|
89
|
+
const map = { '0': 'o', '1': 'l', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't', '8': 'b', '@': 'a' };
|
|
90
|
+
let swaps = 0;
|
|
91
|
+
for (let i = 0; i < a.length; i++) {
|
|
92
|
+
if (a[i] === b[i]) continue;
|
|
93
|
+
const na = map[a[i]] || a[i];
|
|
94
|
+
const nb = map[b[i]] || b[i];
|
|
95
|
+
if (na === b[i] || a[i] === nb || na === nb) swaps++;
|
|
96
|
+
else return 0;
|
|
97
|
+
}
|
|
98
|
+
return swaps > 0 ? 1 - swaps / a.length : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function computeConfidence(dist, phonetic, homoglyph, registryMeta) {
|
|
102
|
+
let subtype, base;
|
|
103
|
+
if (dist === 1) {
|
|
104
|
+
subtype = 'edit_distance_1';
|
|
105
|
+
base = 75;
|
|
106
|
+
} else if (dist === 2) {
|
|
107
|
+
subtype = 'edit_distance_2';
|
|
108
|
+
base = 45;
|
|
109
|
+
} else if (phonetic > 0.85) {
|
|
110
|
+
subtype = 'phonetic_match';
|
|
111
|
+
base = 50;
|
|
112
|
+
} else if (homoglyph > 0.7) {
|
|
113
|
+
subtype = 'homoglyph_swap';
|
|
114
|
+
base = 60;
|
|
115
|
+
} else {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
let score = base;
|
|
119
|
+
if (registryMeta) {
|
|
120
|
+
const age = registryMeta.age || 0;
|
|
121
|
+
const downloads = registryMeta.weeklyDownloads || 0;
|
|
122
|
+
if (age < 30) score += 15;
|
|
123
|
+
if (downloads < 1000) score += 10;
|
|
124
|
+
if (age > 365 && downloads > 100000) score -= 30;
|
|
125
|
+
}
|
|
126
|
+
score = Math.max(50, Math.min(100, score));
|
|
127
|
+
return { subtype, score };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function severityLabel(score) {
|
|
131
|
+
if (score >= 80) return 'high';
|
|
132
|
+
if (score >= 60) return 'medium';
|
|
133
|
+
return 'low';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function confidenceLabel(score) {
|
|
137
|
+
if (score >= 80) return 'HIGH';
|
|
138
|
+
if (score >= 60) return 'MEDIUM';
|
|
139
|
+
return 'LOW';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const name = 'tier1-typosquat';
|
|
143
|
+
|
|
144
|
+
export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
|
|
145
|
+
const findings = [];
|
|
146
|
+
const pkgName = pkgJson?.name;
|
|
147
|
+
if (!pkgName) return findings;
|
|
148
|
+
|
|
149
|
+
if (KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return findings;
|
|
150
|
+
|
|
151
|
+
const namesToCheck = [];
|
|
152
|
+
let scopedName = null;
|
|
153
|
+
|
|
154
|
+
if (pkgName.startsWith('@')) {
|
|
155
|
+
const parts = pkgName.split('/');
|
|
156
|
+
if (parts.length === 2) {
|
|
157
|
+
scopedName = parts[1];
|
|
158
|
+
namesToCheck.push(pkgName, scopedName);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
namesToCheck.push(pkgName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let best = null;
|
|
165
|
+
let bestScore = 0;
|
|
166
|
+
|
|
167
|
+
for (const checkName of namesToCheck) {
|
|
168
|
+
for (const target of TOP_PACKAGES) {
|
|
169
|
+
if (checkName === target) {
|
|
170
|
+
if (pkgName.startsWith('@') && checkName === scopedName && !KNOWN_REPUTABLE_PACKAGES.has(scopedName)) {
|
|
171
|
+
let score = 80;
|
|
172
|
+
if (registryMeta) {
|
|
173
|
+
if ((registryMeta.age || 0) < 30) score += 15;
|
|
174
|
+
if ((registryMeta.weeklyDownloads || 0) < 1000) score += 10;
|
|
175
|
+
}
|
|
176
|
+
score = Math.max(50, Math.min(100, score));
|
|
177
|
+
if (score > bestScore) {
|
|
178
|
+
bestScore = score;
|
|
179
|
+
best = { target, dist: 0, phonetic: 1, homoglyph: 0, subtype: 'edit_distance_1', isScopeSquat: true };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (Math.abs(checkName.length - target.length) > 2) continue;
|
|
185
|
+
const dist = levenshtein(checkName, target);
|
|
186
|
+
if (dist > 2) continue;
|
|
187
|
+
const phonetic = jaroWinkler(checkName, target);
|
|
188
|
+
const homoglyph = homoglyphScore(checkName, target);
|
|
189
|
+
const conf = computeConfidence(dist, phonetic, homoglyph, registryMeta);
|
|
190
|
+
if (!conf || conf.score <= bestScore) continue;
|
|
191
|
+
bestScore = conf.score;
|
|
192
|
+
best = { target, dist, phonetic, homoglyph, subtype: conf.subtype, isScopeSquat: false };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (best) {
|
|
197
|
+
findings.push({
|
|
198
|
+
detector: 'tier1-typosquat',
|
|
199
|
+
id: 'TIER1-TYPOSQUAT',
|
|
200
|
+
severity: severityLabel(bestScore),
|
|
201
|
+
confidence: confidenceLabel(bestScore),
|
|
202
|
+
confidenceScore: bestScore,
|
|
203
|
+
subtype: best.subtype,
|
|
204
|
+
message: `Package name "${pkgName}" is typo of "${best.target}"${best.dist > 0 ? ` (distance ${best.dist})` : ''}`,
|
|
205
|
+
evidence: [
|
|
206
|
+
`distance: ${best.dist}`,
|
|
207
|
+
`phonetic_score: ${(best.phonetic || 1).toFixed(2)}`,
|
|
208
|
+
`similar_package: ${best.target}`,
|
|
209
|
+
`age_days: ${registryMeta?.age || 0}`,
|
|
210
|
+
`weekly_downloads: ${registryMeta?.weeklyDownloads || 0}`,
|
|
211
|
+
],
|
|
212
|
+
crossFiles: [],
|
|
213
|
+
locations: [{ file: 'package.json', line: 2, column: 10 }],
|
|
214
|
+
reference: 'Campaign 2: Cloud-Secret Typosquatting',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return findings;
|
|
219
|
+
}
|