@lateos/npm-scan 0.16.0 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.dockerignore +20 -20
  2. package/.husky/pre-commit +1 -1
  3. package/CHANGELOG.md +199 -199
  4. package/LICENSING.md +19 -19
  5. package/README.de.md +708 -669
  6. package/README.fr.md +707 -668
  7. package/README.ja.md +704 -665
  8. package/README.md +826 -801
  9. package/README.zh.md +708 -669
  10. package/SECURITY.md +72 -72
  11. package/backend/cra.js +68 -68
  12. package/backend/db/schema.sql +32 -32
  13. package/backend/db.js +88 -88
  14. package/backend/detectors/atk-001-lifecycle.js +17 -17
  15. package/backend/detectors/atk-002-obfusc.js +261 -261
  16. package/backend/detectors/atk-003-creds.js +13 -13
  17. package/backend/detectors/atk-004-persist.js +13 -13
  18. package/backend/detectors/atk-005-exfil.js +13 -13
  19. package/backend/detectors/atk-006-depconf.js +14 -14
  20. package/backend/detectors/atk-007-typosquat.js +34 -34
  21. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  22. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  23. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  24. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  25. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +24 -0
  26. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +24 -0
  27. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +90 -0
  28. package/backend/detectors/axios-poisoning/index.js +94 -0
  29. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  30. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  31. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  32. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  33. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  34. package/backend/detectors/hf-impersonation/index.js +396 -396
  35. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  36. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  37. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  38. package/backend/detectors/index.js +75 -38
  39. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  40. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  41. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  42. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  43. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  44. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  45. package/backend/detectors/megalodon/index.js +80 -80
  46. package/backend/detectors/megalodon/types.js +9 -9
  47. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  48. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  49. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  50. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  51. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  52. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  53. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  54. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  55. package/backend/detectors/msh-supplement/d1-obfuscation.js +18 -0
  56. package/backend/detectors/msh-supplement/d2-persistence.js +47 -0
  57. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +35 -0
  58. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +33 -0
  59. package/backend/detectors/msh-supplement/index.js +107 -0
  60. package/backend/detectors/tier1-binary-embed.js +219 -0
  61. package/backend/detectors/tier1-infostealer.js +280 -0
  62. package/backend/detectors/tier1-lifecycle-hook.js +176 -0
  63. package/backend/detectors/tier1-metadata-spoof.js +180 -0
  64. package/backend/detectors/tier1-typosquat.js +219 -0
  65. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +77 -0
  66. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +37 -0
  67. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +66 -0
  68. package/backend/detectors/typosquat-vpmdhaj/index.js +98 -0
  69. package/backend/fetch.js +175 -175
  70. package/backend/index.js +4 -4
  71. package/backend/license.js +89 -89
  72. package/backend/lockfile.js +379 -379
  73. package/backend/pdf.js +245 -245
  74. package/backend/policy.js +193 -176
  75. package/backend/provenance.js +79 -0
  76. package/backend/report.js +254 -254
  77. package/backend/sbom.js +66 -66
  78. package/backend/siem/cef.js +32 -32
  79. package/backend/siem/ecs.js +40 -40
  80. package/backend/siem/index.js +18 -18
  81. package/backend/siem/qradar.js +56 -56
  82. package/backend/siem/sentinel.js +27 -27
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/deploy/helm/npm-scan/Chart.yaml +21 -21
  94. package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
  95. package/deploy/helm/npm-scan/templates/api.yaml +93 -93
  96. package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
  97. package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
  98. package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
  99. package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
  100. package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
  101. package/deploy/helm/npm-scan/values.yaml +102 -102
  102. package/package.json +57 -57
  103. package/scripts/download-corpus.js +30 -30
  104. package/scripts/gen-mal-corpus.js +34 -34
  105. package/scripts/generate-campaign-fixtures.js +170 -0
  106. package/src/config/top-5000.json +87 -0
  107. package/test/fixtures/lockfiles/npm-lock.json +68 -68
  108. package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
  109. package/test/fixtures/lockfiles/yarn.lock +103 -103
  110. package/test/fixtures/mock-data.js +69 -69
@@ -0,0 +1,280 @@
1
+ import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
2
+ import * as acorn from 'acorn';
3
+
4
+ const FS_READ_RE = /fs\.(?:readFile|readFileSync|readdir|readdirSync)\s*\(/g;
5
+ const HTTP_FETCH_RE = /\b(?:fetch|axios|got|superagent|request)\s*\(/g;
6
+ const CURL_WGET_RE = /\b(?:curl|wget|powershell)\s+/gi;
7
+ const CHILD_PROC_RE = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(/g;
8
+ const DOMAIN_EXTRACT_RE = /https?:\/\/([^'"\s)\];,\n\r]+)/gi;
9
+ const GITHUB_DOMAIN_RE = /github\.com/i;
10
+ const NPMJS_DOMAIN_RE = /npmjs\.(?:com|org)/i;
11
+
12
+ const AWS_KEY_RE = /AKIA[0-9A-Z]{16}/g;
13
+ const NPM_TOKEN_RE = /npm_[a-zA-Z0-9]{36}/g;
14
+ const GH_TOKEN_RE = /ghp_[a-zA-Z0-9]{30,40}/g;
15
+ const GH_OLD_TOKEN_RE = /gho_[a-zA-Z0-9]{36}/g;
16
+ const GITLAB_TOKEN_RE = /glpat-[a-zA-Z0-9_-]{20,}/g;
17
+
18
+ const ENV_DUMP_RE = /process\.env\.(?:AWS_[A-Z_]+|NPM_TOKEN|NPM_AUTH_TOKEN|GIT_TOKEN|SSH_KEY)/g;
19
+
20
+ const EVAL_RE = /\beval\s*\(/g;
21
+ const FUNCTION_CTOR_RE = /\bFunction\s*\(/g;
22
+ const B64_STRING_RE = /['"`]([A-Za-z0-9+/]{40,}={0,2})['"`]/g;
23
+
24
+ function shannonEntropy(s) {
25
+ const len = s.length;
26
+ if (len === 0) return 0;
27
+ const freq = {};
28
+ for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
29
+ let entropy = 0;
30
+ for (const count of Object.values(freq)) {
31
+ const p = count / len;
32
+ entropy -= p * Math.log2(p);
33
+ }
34
+ return entropy;
35
+ }
36
+
37
+ function isMinified(content) {
38
+ const identifiers = content.match(/\b[a-zA-Z_$][\w$]*\b/g);
39
+ if (identifiers && identifiers.length > 0) {
40
+ const avgLen = identifiers.reduce((s, id) => s + id.length, 0) / identifiers.length;
41
+ if (avgLen < 3) return true;
42
+ }
43
+ return shannonEntropy(content) > 5.5;
44
+ }
45
+
46
+ function extractDomains(content) {
47
+ const domains = [];
48
+ let match;
49
+ DOMAIN_EXTRACT_RE.lastIndex = 0;
50
+ while ((match = DOMAIN_EXTRACT_RE.exec(content)) !== null) {
51
+ domains.push(match[1]);
52
+ }
53
+ return domains;
54
+ }
55
+
56
+ function extractCredentials(content) {
57
+ const creds = [];
58
+ let match;
59
+ AWS_KEY_RE.lastIndex = 0;
60
+ while ((match = AWS_KEY_RE.exec(content)) !== null) {
61
+ creds.push({ type: 'cred_regex_aws', value: match[0], index: match.index });
62
+ }
63
+ NPM_TOKEN_RE.lastIndex = 0;
64
+ while ((match = NPM_TOKEN_RE.exec(content)) !== null) {
65
+ creds.push({ type: 'cred_regex_npm_token', value: match[0], index: match.index });
66
+ }
67
+ GH_TOKEN_RE.lastIndex = 0;
68
+ while ((match = GH_TOKEN_RE.exec(content)) !== null) {
69
+ creds.push({ type: 'cred_regex_gh_token', value: match[0], index: match.index });
70
+ }
71
+ return creds;
72
+ }
73
+
74
+ function getLineColumn(content, index) {
75
+ const lines = content.slice(0, index).split('\n');
76
+ return { line: lines.length, column: lines[lines.length - 1].length + 1 };
77
+ }
78
+
79
+ function patternMatcher(f, content) {
80
+ const file = f.path || f.name || 'unknown';
81
+ const result = {
82
+ file,
83
+ hasPattern: false,
84
+ patterns: [],
85
+ locations: [],
86
+ evidence: [],
87
+ domainsFound: [],
88
+ credsFound: [],
89
+ isObfuscated: false,
90
+ };
91
+
92
+ if (!content) return result;
93
+
94
+ result.isObfuscated = isMinified(content) || EVAL_RE.test(content) || FUNCTION_CTOR_RE.test(content);
95
+
96
+ FS_READ_RE.lastIndex = 0;
97
+ HTTP_FETCH_RE.lastIndex = 0;
98
+ CHILD_PROC_RE.lastIndex = 0;
99
+ CURL_WGET_RE.lastIndex = 0;
100
+
101
+ const hasFsRead = FS_READ_RE.test(content);
102
+ const hasHttpFetch = HTTP_FETCH_RE.test(content);
103
+ const hasChildProc = CHILD_PROC_RE.test(content);
104
+ const hasCurlWget = CURL_WGET_RE.test(content);
105
+
106
+ const domains = extractDomains(content);
107
+ const externalDomains = domains.filter(d => !NPMJS_DOMAIN_RE.test(d));
108
+ const gitHubDomains = domains.filter(d => GITHUB_DOMAIN_RE.test(d) && !NPMJS_DOMAIN_RE.test(d));
109
+
110
+ if (hasFsRead && hasHttpFetch) {
111
+ const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
112
+ result.hasPattern = true;
113
+ result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
114
+ result.domainsFound.push(...domains);
115
+ FS_READ_RE.lastIndex = 0;
116
+ const fsMatch = FS_READ_RE.exec(content);
117
+ if (fsMatch) {
118
+ const lc = getLineColumn(content, fsMatch.index);
119
+ result.locations.push({ file, line: lc.line, column: lc.column });
120
+ }
121
+ result.evidence.push(isGithubOnly
122
+ ? 'pattern: fs.readFile + network to GitHub'
123
+ : 'pattern: fs.readFile + external fetch');
124
+ }
125
+
126
+ if (hasFsRead && (hasChildProc || hasCurlWget)) {
127
+ const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
128
+ result.hasPattern = true;
129
+ result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
130
+ result.domainsFound.push(...domains);
131
+ FS_READ_RE.lastIndex = 0;
132
+ const fsMatch = FS_READ_RE.exec(content);
133
+ if (fsMatch) {
134
+ const lc = getLineColumn(content, fsMatch.index);
135
+ result.locations.push({ file, line: lc.line, column: lc.column });
136
+ }
137
+ result.evidence.push(isGithubOnly
138
+ ? 'pattern: fs.readFile + child_process to GitHub'
139
+ : 'pattern: fs.readFile + child_process network');
140
+ }
141
+
142
+ const creds = extractCredentials(content);
143
+ if (creds.length > 0) {
144
+ result.hasPattern = true;
145
+ result.credsFound.push(...creds);
146
+ const primaryType = creds[0].type;
147
+ result.patterns.push({ subtype: primaryType, baseScore: 85 });
148
+ const lc = getLineColumn(content, creds[0].index);
149
+ result.locations.push({ file, line: lc.line, column: lc.column });
150
+ const typeNames = [...new Set(creds.map(c => c.type))];
151
+ result.evidence.push(`hardcoded_credentials: ${creds.length} (${typeNames.join(', ')})`);
152
+ }
153
+
154
+ ENV_DUMP_RE.lastIndex = 0;
155
+ const envMatch = ENV_DUMP_RE.exec(content);
156
+ if (envMatch) {
157
+ result.hasPattern = true;
158
+ result.patterns.push({ subtype: 'env_dump', baseScore: 80 });
159
+ const lc = getLineColumn(content, envMatch.index);
160
+ result.locations.push({ file, line: lc.line, column: lc.column });
161
+ result.evidence.push('pattern: process.env.AWS_* dump');
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ export const name = 'tier1-infostealer';
168
+
169
+ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
170
+ const pkgName = pkgJson?.name;
171
+ if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
172
+
173
+ const files = jsFiles || [];
174
+ if (files.length === 0) return [];
175
+
176
+ let parseFailCount = 0;
177
+
178
+ for (const f of files) {
179
+ const content = f.content || '';
180
+ if (!content) continue;
181
+ try {
182
+ acorn.parse(content, { ecmaVersion: 'latest' });
183
+ } catch {
184
+ parseFailCount++;
185
+ }
186
+ }
187
+
188
+ if (files.length >= 20 && parseFailCount / files.length >= 0.1) return [];
189
+
190
+ const perFile = files.map(f => patternMatcher(f, f.content || ''));
191
+ const filesWithPatterns = perFile.filter(p => p.hasPattern);
192
+
193
+ if (filesWithPatterns.length === 0) return [];
194
+
195
+ let highestBase = 0;
196
+ let mainSubtype = '';
197
+ let isObfuscated = false;
198
+ const allEvidence = [];
199
+ const allLocations = [];
200
+ const involvedFiles = [];
201
+ const hasCreds = false;
202
+
203
+ for (const f of filesWithPatterns) {
204
+ if (!involvedFiles.includes(f.file)) involvedFiles.push(f.file);
205
+ allLocations.push(...f.locations);
206
+ allEvidence.push(...f.evidence);
207
+ if (f.isObfuscated) isObfuscated = true;
208
+ for (const p of f.patterns) {
209
+ if (p.baseScore > highestBase) {
210
+ highestBase = p.baseScore;
211
+ mainSubtype = p.subtype;
212
+ }
213
+ }
214
+ }
215
+
216
+ let baseScore = highestBase;
217
+
218
+ const anyCredPattern = filesWithPatterns.some(f => f.patterns.some(p => p.subtype.startsWith('cred_')));
219
+ if (anyCredPattern) {
220
+ baseScore = Math.min(100, Math.round(baseScore * 2.5));
221
+ }
222
+
223
+ if (isObfuscated) baseScore += 15;
224
+
225
+ if (involvedFiles.length > 1) {
226
+ baseScore = Math.min(100, Math.round(baseScore * 1.3));
227
+ }
228
+
229
+ const confidenceScore = Math.max(50, Math.min(100, baseScore));
230
+
231
+ function confidenceLabel(score) {
232
+ if (score >= 95) return 'CRITICAL';
233
+ if (score >= 80) return 'HIGH';
234
+ return 'MEDIUM';
235
+ }
236
+
237
+ const evidenceSet = new Set(allEvidence);
238
+ const evidence = [...evidenceSet].slice(0, 10);
239
+
240
+ const locationMap = new Map();
241
+ for (const loc of allLocations) {
242
+ const key = `${loc.file}:${loc.line}:${loc.column}`;
243
+ if (!locationMap.has(key)) locationMap.set(key, loc);
244
+ }
245
+
246
+ const isCritical = anyCredPattern;
247
+ const severity = isCritical ? 'critical' : confidenceScore >= 80 ? 'high' : 'medium';
248
+
249
+ const domainSummary = filesWithPatterns
250
+ .flatMap(f => f.domainsFound)
251
+ .filter(Boolean)
252
+ .slice(0, 3);
253
+
254
+ const credCount = filesWithPatterns.reduce((s, f) => s + f.credsFound.length, 0);
255
+
256
+ let message;
257
+ if (anyCredPattern) {
258
+ message = `Hardcoded credentials detected (${credCount} found)`;
259
+ } else if (involvedFiles.length > 1) {
260
+ message = `Cross-file exfiltration detected across ${involvedFiles.length} files`;
261
+ } else if (mainSubtype === 'env_dump') {
262
+ message = 'Environment variable harvesting detected';
263
+ } else {
264
+ message = 'Filesystem exfiltration to external domain detected';
265
+ }
266
+
267
+ return [{
268
+ detector: 'tier1-infostealer',
269
+ id: 'TIER1-INFOSTEALER',
270
+ severity,
271
+ confidence: confidenceLabel(confidenceScore),
272
+ confidenceScore,
273
+ subtype: mainSubtype || 'fs_exfil',
274
+ message,
275
+ evidence,
276
+ locations: [...locationMap.values()],
277
+ crossFiles: [...new Set(involvedFiles)],
278
+ reference: 'Campaign 2 & 3',
279
+ }];
280
+ }
@@ -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
+ }