@lateos/npm-scan 1.0.0 → 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.
Files changed (125) hide show
  1. package/README.md +864 -861
  2. package/backend/cra.js +113 -21
  3. package/backend/db.js +18 -10
  4. package/backend/detectors/atk-001-lifecycle.js +5 -5
  5. package/backend/detectors/atk-002-obfusc.js +126 -47
  6. package/backend/detectors/atk-003-creds.js +8 -4
  7. package/backend/detectors/atk-004-persist.js +3 -3
  8. package/backend/detectors/atk-005-exfil.js +8 -4
  9. package/backend/detectors/atk-006-depconf.js +3 -3
  10. package/backend/detectors/atk-007-typosquat.js +64 -10
  11. package/backend/detectors/atk-008-tarball-tamper.js +6 -6
  12. package/backend/detectors/atk-009-dormant-trigger.js +9 -5
  13. package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
  14. package/backend/detectors/atk-011-transitive-prop.js +14 -13
  15. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
  16. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
  17. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
  18. package/backend/detectors/axios-poisoning/index.js +77 -60
  19. package/backend/detectors/config/thresholds.js +48 -3
  20. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
  21. package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
  22. package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
  23. package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
  24. package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
  25. package/backend/detectors/hf-impersonation/index.js +94 -31
  26. package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
  27. package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
  28. package/backend/detectors/hf-impersonation/simhash.js +2 -2
  29. package/backend/detectors/index.js +181 -34
  30. package/backend/detectors/lib/ast-patterns.js +4 -1
  31. package/backend/detectors/lib/entropy-analyzer.js +12 -4
  32. package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
  33. package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
  34. package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
  35. package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
  36. package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
  37. package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
  38. package/backend/detectors/megalodon/index.js +35 -25
  39. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
  40. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
  41. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
  42. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
  43. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
  44. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
  45. package/backend/detectors/mini-shai-hulud/index.js +63 -26
  46. package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
  47. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
  48. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
  49. package/backend/detectors/msh-supplement/index.js +78 -63
  50. package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
  51. package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
  52. package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
  53. package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
  54. package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
  55. package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
  56. package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
  57. package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
  58. package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
  59. package/backend/detectors/node-ipc-compromise/index.js +21 -15
  60. package/backend/detectors/tier1-binary-embed.js +109 -41
  61. package/backend/detectors/tier1-cloud-imds.js +57 -37
  62. package/backend/detectors/tier1-encrypted-c2.js +198 -0
  63. package/backend/detectors/tier1-infostealer.js +121 -68
  64. package/backend/detectors/tier1-lifecycle-hook.js +63 -23
  65. package/backend/detectors/tier1-maintainer-compromise.js +157 -0
  66. package/backend/detectors/tier1-metadata-spoof.js +92 -42
  67. package/backend/detectors/tier1-multistage-postinstall.js +46 -19
  68. package/backend/detectors/tier1-obfuscation-heuristics.js +45 -17
  69. package/backend/detectors/tier1-self-propagation.js +115 -0
  70. package/backend/detectors/tier1-slsa-attestation.js +1 -1
  71. package/backend/detectors/tier1-transitive-deps.js +182 -0
  72. package/backend/detectors/tier1-typosquat.js +129 -50
  73. package/backend/detectors/tier1-version-anomaly.js +77 -41
  74. package/backend/detectors/tier1-version-confusion.js +79 -59
  75. package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
  76. package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
  77. package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
  78. package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
  79. package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
  80. package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
  81. package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
  82. package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
  83. package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
  84. package/backend/detectors/trapdoor/index.js +19 -14
  85. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
  86. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
  87. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
  88. package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
  89. package/backend/detectors.test.js +78 -19
  90. package/backend/fetch.js +37 -29
  91. package/backend/index.js +1 -1
  92. package/backend/license.js +20 -4
  93. package/backend/lockfile.js +60 -36
  94. package/backend/pdf.js +107 -28
  95. package/backend/policy.js +183 -56
  96. package/backend/provenance.js +28 -3
  97. package/backend/report.js +136 -70
  98. package/backend/sbom.js +33 -27
  99. package/backend/scripts/analyze-false-positives.js +14 -8
  100. package/backend/scripts/analyze-validation.js +27 -21
  101. package/backend/scripts/detect-false-positives.js +20 -10
  102. package/backend/scripts/fetch-top-packages.js +197 -49
  103. package/backend/scripts/validate-d10-d13.js +103 -0
  104. package/backend/scripts/validate-detectors.js +26 -17
  105. package/backend/siem/cef.js +23 -21
  106. package/backend/siem/ecs.js +3 -3
  107. package/backend/siem/index.js +1 -1
  108. package/backend/siem/qradar.js +3 -3
  109. package/backend/siem/sentinel.js +2 -2
  110. package/backend/tests-d5-enhanced.test.js +13 -12
  111. package/backend/tests-d6-version-anomaly.test.js +17 -8
  112. package/backend/tests-d6.test.js +24 -14
  113. package/backend/tests-d6c.test.js +27 -14
  114. package/backend/tests-d7-obfuscation.test.js +9 -12
  115. package/backend/tests.test.js +182 -83
  116. package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
  117. package/backend/vsix-scan/detectors/burst-publish.js +14 -7
  118. package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
  119. package/backend/vsix-scan/detectors/known-ioc.js +23 -8
  120. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
  121. package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
  122. package/backend/vsix-scan/index.js +97 -41
  123. package/backend/vsix-scan/marketplace-client.js +29 -13
  124. package/cli/cli.js +154 -64
  125. package/package.json +12 -3
@@ -12,25 +12,29 @@ const NPMJS_DOMAIN_RE = /npmjs\.(?:com|org)/i;
12
12
  const AWS_KEY_RE = /AKIA[0-9A-Z]{16}/g;
13
13
  const NPM_TOKEN_RE = /npm_[a-zA-Z0-9]{36}/g;
14
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;
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
17
 
18
18
  const ENV_DUMP_RE = /process\.env\.(?:AWS_[A-Z_]+|NPM_TOKEN|NPM_AUTH_TOKEN|GIT_TOKEN|SSH_KEY)/g;
19
19
 
20
20
  const EVAL_RE = /\beval\s*\(/g;
21
21
  const FUNCTION_CTOR_RE = /\bFunction\s*\(/g;
22
- const B64_STRING_RE = /['"`]([A-Za-z0-9+/]{40,}={0,2})['"`]/g;
22
+ const _B64_STRING_RE = /['"`]([A-Za-z0-9+/]{40,}={0,2})['"`]/g;
23
23
 
24
24
  // Named malware signatures — zero-FP string literals for confirmed campaigns
25
25
  const NAMED_SIGNATURES = [
26
- 'Miasma: The Spreading Blight', // Miasma campaign, June 2026, @redhat-cloud-services compromise
26
+ 'Miasma: The Spreading Blight', // Miasma campaign, June 2026, @redhat-cloud-services compromise
27
27
  ];
28
28
 
29
29
  function shannonEntropy(s) {
30
30
  const len = s.length;
31
- if (len === 0) return 0;
31
+ if (len === 0) {
32
+ return 0;
33
+ }
32
34
  const freq = {};
33
- for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
35
+ for (const ch of s) {
36
+ freq[ch] = (freq[ch] || 0) + 1;
37
+ }
34
38
  let entropy = 0;
35
39
  for (const count of Object.values(freq)) {
36
40
  const p = count / len;
@@ -43,7 +47,9 @@ function isMinified(content) {
43
47
  const identifiers = content.match(/\b[a-zA-Z_$][\w$]*\b/g);
44
48
  if (identifiers && identifiers.length > 0) {
45
49
  const avgLen = identifiers.reduce((s, id) => s + id.length, 0) / identifiers.length;
46
- if (avgLen < 3) return true;
50
+ if (avgLen < 3) {
51
+ return true;
52
+ }
47
53
  }
48
54
  return shannonEntropy(content) > 5.5;
49
55
  }
@@ -94,9 +100,12 @@ function patternMatcher(f, content) {
94
100
  isObfuscated: false,
95
101
  };
96
102
 
97
- if (!content) return result;
103
+ if (!content) {
104
+ return result;
105
+ }
98
106
 
99
- result.isObfuscated = isMinified(content) || EVAL_RE.test(content) || FUNCTION_CTOR_RE.test(content);
107
+ result.isObfuscated =
108
+ isMinified(content) || EVAL_RE.test(content) || FUNCTION_CTOR_RE.test(content);
100
109
 
101
110
  FS_READ_RE.lastIndex = 0;
102
111
  HTTP_FETCH_RE.lastIndex = 0;
@@ -109,13 +118,17 @@ function patternMatcher(f, content) {
109
118
  const hasCurlWget = CURL_WGET_RE.test(content);
110
119
 
111
120
  const domains = extractDomains(content);
112
- const externalDomains = domains.filter(d => !NPMJS_DOMAIN_RE.test(d));
113
- const gitHubDomains = domains.filter(d => GITHUB_DOMAIN_RE.test(d) && !NPMJS_DOMAIN_RE.test(d));
121
+ const externalDomains = domains.filter((d) => !NPMJS_DOMAIN_RE.test(d));
122
+ const gitHubDomains = domains.filter((d) => GITHUB_DOMAIN_RE.test(d) && !NPMJS_DOMAIN_RE.test(d));
114
123
 
115
124
  if (hasFsRead && hasHttpFetch) {
116
- const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
125
+ const isGithubOnly =
126
+ gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
117
127
  result.hasPattern = true;
118
- result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
128
+ result.patterns.push({
129
+ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil',
130
+ baseScore: 80,
131
+ });
119
132
  result.domainsFound.push(...domains);
120
133
  FS_READ_RE.lastIndex = 0;
121
134
  const fsMatch = FS_READ_RE.exec(content);
@@ -123,15 +136,21 @@ function patternMatcher(f, content) {
123
136
  const lc = getLineColumn(content, fsMatch.index);
124
137
  result.locations.push({ file, line: lc.line, column: lc.column });
125
138
  }
126
- result.evidence.push(isGithubOnly
127
- ? 'pattern: fs.readFile + network to GitHub'
128
- : 'pattern: fs.readFile + external fetch');
139
+ result.evidence.push(
140
+ isGithubOnly
141
+ ? 'pattern: fs.readFile + network to GitHub'
142
+ : 'pattern: fs.readFile + external fetch'
143
+ );
129
144
  }
130
145
 
131
146
  if (hasFsRead && (hasChildProc || hasCurlWget)) {
132
- const isGithubOnly = gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
147
+ const isGithubOnly =
148
+ gitHubDomains.length > 0 && externalDomains.length === gitHubDomains.length;
133
149
  result.hasPattern = true;
134
- result.patterns.push({ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil', baseScore: 80 });
150
+ result.patterns.push({
151
+ subtype: isGithubOnly ? 'nw_exfil_to_github' : 'fs_exfil',
152
+ baseScore: 80,
153
+ });
135
154
  result.domainsFound.push(...domains);
136
155
  FS_READ_RE.lastIndex = 0;
137
156
  const fsMatch = FS_READ_RE.exec(content);
@@ -139,9 +158,11 @@ function patternMatcher(f, content) {
139
158
  const lc = getLineColumn(content, fsMatch.index);
140
159
  result.locations.push({ file, line: lc.line, column: lc.column });
141
160
  }
142
- result.evidence.push(isGithubOnly
143
- ? 'pattern: fs.readFile + child_process to GitHub'
144
- : 'pattern: fs.readFile + child_process network');
161
+ result.evidence.push(
162
+ isGithubOnly
163
+ ? 'pattern: fs.readFile + child_process to GitHub'
164
+ : 'pattern: fs.readFile + child_process network'
165
+ );
145
166
  }
146
167
 
147
168
  const creds = extractCredentials(content);
@@ -152,7 +173,7 @@ function patternMatcher(f, content) {
152
173
  result.patterns.push({ subtype: primaryType, baseScore: 85 });
153
174
  const lc = getLineColumn(content, creds[0].index);
154
175
  result.locations.push({ file, line: lc.line, column: lc.column });
155
- const typeNames = [...new Set(creds.map(c => c.type))];
176
+ const typeNames = [...new Set(creds.map((c) => c.type))];
156
177
  result.evidence.push(`hardcoded_credentials: ${creds.length} (${typeNames.join(', ')})`);
157
178
  }
158
179
 
@@ -171,9 +192,11 @@ function patternMatcher(f, content) {
171
192
 
172
193
  export const name = 'tier1-infostealer';
173
194
 
174
- export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
195
+ export async function scan(pkgJson, jsFiles, _registryMeta, _allFiles) {
175
196
  const pkgName = pkgJson?.name;
176
- if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
197
+ if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) {
198
+ return [];
199
+ }
177
200
 
178
201
  const files = jsFiles || [];
179
202
 
@@ -181,39 +204,49 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
181
204
  const sigTexts = [];
182
205
  if (pkgJson?.scripts && typeof pkgJson.scripts === 'object') {
183
206
  for (const value of Object.values(pkgJson.scripts)) {
184
- if (typeof value === 'string') sigTexts.push(value);
207
+ if (typeof value === 'string') {
208
+ sigTexts.push(value);
209
+ }
185
210
  }
186
211
  }
187
212
  for (const f of files) {
188
- if (f?.content) sigTexts.push(f.content);
213
+ if (f?.content) {
214
+ sigTexts.push(f.content);
215
+ }
189
216
  }
190
217
  for (const sig of NAMED_SIGNATURES) {
191
218
  for (const text of sigTexts) {
192
219
  if (text.includes(sig)) {
193
- return [{
194
- detector: 'tier1-infostealer',
195
- id: 'TIER1-INFOSTEALER',
196
- severity: 'critical',
197
- confidence: 'CRITICAL',
198
- confidenceScore: 98,
199
- subtype: 'named_signature_miasma',
200
- message: `Named malware signature detected: "${sig}"`,
201
- evidence: [sig],
202
- locations: [{ file: '', line: 0 }],
203
- crossFiles: [],
204
- reference: 'Campaign 2 & 3',
205
- }];
220
+ return [
221
+ {
222
+ detector: 'tier1-infostealer',
223
+ id: 'TIER1-INFOSTEALER',
224
+ severity: 'critical',
225
+ confidence: 'CRITICAL',
226
+ confidenceScore: 98,
227
+ subtype: 'named_signature_miasma',
228
+ message: `Named malware signature detected: "${sig}"`,
229
+ evidence: [sig],
230
+ locations: [{ file: '', line: 0 }],
231
+ crossFiles: [],
232
+ reference: 'Campaign 2 & 3',
233
+ },
234
+ ];
206
235
  }
207
236
  }
208
237
  }
209
238
 
210
- if (files.length === 0) return [];
239
+ if (files.length === 0) {
240
+ return [];
241
+ }
211
242
 
212
243
  let parseFailCount = 0;
213
244
 
214
245
  for (const f of files) {
215
246
  const content = f.content || '';
216
- if (!content) continue;
247
+ if (!content) {
248
+ continue;
249
+ }
217
250
  try {
218
251
  acorn.parse(content, { ecmaVersion: 'latest' });
219
252
  } catch {
@@ -221,12 +254,16 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
221
254
  }
222
255
  }
223
256
 
224
- if (files.length >= 20 && parseFailCount / files.length >= 0.1) return [];
257
+ if (files.length >= 20 && parseFailCount / files.length >= 0.1) {
258
+ return [];
259
+ }
225
260
 
226
- const perFile = files.map(f => patternMatcher(f, f.content || ''));
227
- const filesWithPatterns = perFile.filter(p => p.hasPattern);
261
+ const perFile = files.map((f) => patternMatcher(f, f.content || ''));
262
+ const filesWithPatterns = perFile.filter((p) => p.hasPattern);
228
263
 
229
- if (filesWithPatterns.length === 0) return [];
264
+ if (filesWithPatterns.length === 0) {
265
+ return [];
266
+ }
230
267
 
231
268
  let highestBase = 0;
232
269
  let mainSubtype = '';
@@ -234,13 +271,17 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
234
271
  const allEvidence = [];
235
272
  const allLocations = [];
236
273
  const involvedFiles = [];
237
- const hasCreds = false;
274
+ const _hasCreds = false;
238
275
 
239
276
  for (const f of filesWithPatterns) {
240
- if (!involvedFiles.includes(f.file)) involvedFiles.push(f.file);
277
+ if (!involvedFiles.includes(f.file)) {
278
+ involvedFiles.push(f.file);
279
+ }
241
280
  allLocations.push(...f.locations);
242
281
  allEvidence.push(...f.evidence);
243
- if (f.isObfuscated) isObfuscated = true;
282
+ if (f.isObfuscated) {
283
+ isObfuscated = true;
284
+ }
244
285
  for (const p of f.patterns) {
245
286
  if (p.baseScore > highestBase) {
246
287
  highestBase = p.baseScore;
@@ -251,12 +292,16 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
251
292
 
252
293
  let baseScore = highestBase;
253
294
 
254
- const anyCredPattern = filesWithPatterns.some(f => f.patterns.some(p => p.subtype.startsWith('cred_')));
295
+ const anyCredPattern = filesWithPatterns.some((f) =>
296
+ f.patterns.some((p) => p.subtype.startsWith('cred_'))
297
+ );
255
298
  if (anyCredPattern) {
256
299
  baseScore = Math.min(100, Math.round(baseScore * 2.5));
257
300
  }
258
301
 
259
- if (isObfuscated) baseScore += 15;
302
+ if (isObfuscated) {
303
+ baseScore += 15;
304
+ }
260
305
 
261
306
  if (involvedFiles.length > 1) {
262
307
  baseScore = Math.min(100, Math.round(baseScore * 1.3));
@@ -265,8 +310,12 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
265
310
  const confidenceScore = Math.max(50, Math.min(100, baseScore));
266
311
 
267
312
  function confidenceLabel(score) {
268
- if (score >= 95) return 'CRITICAL';
269
- if (score >= 80) return 'HIGH';
313
+ if (score >= 95) {
314
+ return 'CRITICAL';
315
+ }
316
+ if (score >= 80) {
317
+ return 'HIGH';
318
+ }
270
319
  return 'MEDIUM';
271
320
  }
272
321
 
@@ -276,14 +325,16 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
276
325
  const locationMap = new Map();
277
326
  for (const loc of allLocations) {
278
327
  const key = `${loc.file}:${loc.line}:${loc.column}`;
279
- if (!locationMap.has(key)) locationMap.set(key, loc);
328
+ if (!locationMap.has(key)) {
329
+ locationMap.set(key, loc);
330
+ }
280
331
  }
281
332
 
282
333
  const isCritical = anyCredPattern;
283
334
  const severity = isCritical ? 'critical' : confidenceScore >= 80 ? 'high' : 'medium';
284
335
 
285
- const domainSummary = filesWithPatterns
286
- .flatMap(f => f.domainsFound)
336
+ const _domainSummary = filesWithPatterns
337
+ .flatMap((f) => f.domainsFound)
287
338
  .filter(Boolean)
288
339
  .slice(0, 3);
289
340
 
@@ -300,17 +351,19 @@ export async function scan(pkgJson, jsFiles, registryMeta, allFiles) {
300
351
  message = 'Filesystem exfiltration to external domain detected';
301
352
  }
302
353
 
303
- return [{
304
- detector: 'tier1-infostealer',
305
- id: 'TIER1-INFOSTEALER',
306
- severity,
307
- confidence: confidenceLabel(confidenceScore),
308
- confidenceScore,
309
- subtype: mainSubtype || 'fs_exfil',
310
- message,
311
- evidence,
312
- locations: [...locationMap.values()],
313
- crossFiles: [...new Set(involvedFiles)],
314
- reference: 'Campaign 2 & 3',
315
- }];
354
+ return [
355
+ {
356
+ detector: 'tier1-infostealer',
357
+ id: 'TIER1-INFOSTEALER',
358
+ severity,
359
+ confidence: confidenceLabel(confidenceScore),
360
+ confidenceScore,
361
+ subtype: mainSubtype || 'fs_exfil',
362
+ message,
363
+ evidence,
364
+ locations: [...locationMap.values()],
365
+ crossFiles: [...new Set(involvedFiles)],
366
+ reference: 'Campaign 2 & 3',
367
+ },
368
+ ];
316
369
  }
@@ -1,6 +1,13 @@
1
1
  import { KNOWN_REPUTABLE_PACKAGES } from '../policy.js';
2
2
 
3
- const HOOK_NAMES = ['postinstall', 'preinstall', 'install', 'prepare', 'preuninstall', 'postuninstall'];
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) return 0;
27
+ if (len === 0) {
28
+ return 0;
29
+ }
21
30
  const freq = {};
22
- for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
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) return false;
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) return true;
40
- if (noWhitespace && /^[a-zA-Z_$][\w$]*\([^)]*\)$/.test(content.trim())) return true;
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)) return true;
59
+ if (HEX_STRING_RE.test(content)) {
60
+ return true;
61
+ }
43
62
  B64_RE.lastIndex = 0;
44
- if (B64_RE.test(content)) return true;
45
- if (shannonEntropy(content) > 5.5) return true;
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, jsFiles, registryMeta, allFiles) {
84
+ export async function scan(pkgJson, _jsFiles, _registryMeta, _allFiles) {
62
85
  const pkgName = pkgJson?.name;
63
- if (pkgName && KNOWN_REPUTABLE_PACKAGES.has(pkgName)) return [];
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) return [];
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) continue;
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 = EVAL_RE.test(truncated) || FUNCTION_CTOR_RE.test(truncated) || ZERO_EVAL_RE.test(truncated);
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) evidence.push(`target: ${urls[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) continue;
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) return 'CRITICAL';
152
- if (score >= 80) return 'HIGH';
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
- file: 'package.json',
167
- field: `scripts.${hookName}`,
168
- value: content.length > 200 ? `${content.slice(0, 200)}...` : content,
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
+ }