@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.
Files changed (149) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +864 -826
  3. package/VALIDATION.md +92 -0
  4. package/backend/cra.js +113 -21
  5. package/backend/db/pg-schema.sql +155 -0
  6. package/backend/db.js +18 -10
  7. package/backend/detectors/atk-001-lifecycle.js +5 -5
  8. package/backend/detectors/atk-002-obfusc.js +126 -47
  9. package/backend/detectors/atk-003-creds.js +8 -4
  10. package/backend/detectors/atk-004-persist.js +3 -3
  11. package/backend/detectors/atk-005-exfil.js +8 -4
  12. package/backend/detectors/atk-006-depconf.js +3 -3
  13. package/backend/detectors/atk-007-typosquat.js +64 -10
  14. package/backend/detectors/atk-008-tarball-tamper.js +6 -6
  15. package/backend/detectors/atk-009-dormant-trigger.js +9 -5
  16. package/backend/detectors/atk-010-sandbox-evasion.js +25 -10
  17. package/backend/detectors/atk-011-transitive-prop.js +14 -13
  18. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +4 -4
  19. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +5 -1
  20. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +64 -19
  21. package/backend/detectors/axios-poisoning/index.js +77 -60
  22. package/backend/detectors/config/thresholds.js +111 -0
  23. package/backend/detectors/config/whitelist.json +74 -0
  24. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +26 -9
  25. package/backend/detectors/cve-2026-48710-badhost/findings.js +8 -4
  26. package/backend/detectors/cve-2026-48710-badhost/index.js +1 -1
  27. package/backend/detectors/cve-2026-48710-badhost/manifest.js +127 -39
  28. package/backend/detectors/cve-2026-48710-badhost/transitive.js +87 -28
  29. package/backend/detectors/hf-impersonation/index.js +94 -31
  30. package/backend/detectors/hf-impersonation/jaro-winkler.js +33 -12
  31. package/backend/detectors/hf-impersonation/known-orgs.js +15 -3
  32. package/backend/detectors/hf-impersonation/simhash.js +2 -2
  33. package/backend/detectors/index.js +184 -31
  34. package/backend/detectors/lib/ast-patterns.js +24 -0
  35. package/backend/detectors/lib/entropy-analyzer.js +32 -0
  36. package/backend/detectors/megalodon/d1-workflow-scan.js +40 -16
  37. package/backend/detectors/megalodon/d2-credential-harvest.js +12 -5
  38. package/backend/detectors/megalodon/d3-publish-velocity.js +17 -11
  39. package/backend/detectors/megalodon/d4-publisher-drift.js +48 -16
  40. package/backend/detectors/megalodon/d5-bot-commit-identity.js +1 -1
  41. package/backend/detectors/megalodon/d6-date-anachronism.js +1 -1
  42. package/backend/detectors/megalodon/index.js +35 -25
  43. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +3 -1
  44. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +22 -10
  45. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +30 -10
  46. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +17 -13
  47. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +12 -4
  48. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +6 -2
  49. package/backend/detectors/mini-shai-hulud/index.js +63 -26
  50. package/backend/detectors/msh-supplement/d2-persistence.js +30 -12
  51. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +20 -8
  52. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +19 -5
  53. package/backend/detectors/msh-supplement/index.js +78 -63
  54. package/backend/detectors/node-ipc-compromise/d1-version-blocklist.js +4 -2
  55. package/backend/detectors/node-ipc-compromise/d10-unauthorized-publisher.js +9 -5
  56. package/backend/detectors/node-ipc-compromise/d11-blast-radius.js +7 -3
  57. package/backend/detectors/node-ipc-compromise/d2-tarball-hash.js +9 -4
  58. package/backend/detectors/node-ipc-compromise/d3-cjs-payload-injection.js +7 -5
  59. package/backend/detectors/node-ipc-compromise/d4-injected-payload-hash.js +4 -2
  60. package/backend/detectors/node-ipc-compromise/d5-dns-c2-pattern.js +13 -10
  61. package/backend/detectors/node-ipc-compromise/d7-dns-txt-exfil.js +3 -1
  62. package/backend/detectors/node-ipc-compromise/d8-runtime-trigger.js +5 -2
  63. package/backend/detectors/node-ipc-compromise/index.js +21 -15
  64. package/backend/detectors/tier1-binary-embed.js +138 -41
  65. package/backend/detectors/tier1-cloud-imds.js +57 -37
  66. package/backend/detectors/tier1-encrypted-c2.js +198 -0
  67. package/backend/detectors/tier1-infostealer.js +121 -68
  68. package/backend/detectors/tier1-lifecycle-hook.js +63 -23
  69. package/backend/detectors/tier1-maintainer-compromise.js +157 -0
  70. package/backend/detectors/tier1-metadata-spoof.js +92 -42
  71. package/backend/detectors/tier1-multistage-postinstall.js +46 -19
  72. package/backend/detectors/tier1-obfuscation-heuristics.js +184 -0
  73. package/backend/detectors/tier1-self-propagation.js +115 -0
  74. package/backend/detectors/tier1-slsa-attestation.js +12 -0
  75. package/backend/detectors/tier1-transitive-deps.js +182 -0
  76. package/backend/detectors/tier1-typosquat.js +129 -50
  77. package/backend/detectors/tier1-version-anomaly.js +223 -0
  78. package/backend/detectors/tier1-version-confusion.js +79 -59
  79. package/backend/detectors/trapdoor/d1-campaign-marker.js +3 -1
  80. package/backend/detectors/trapdoor/d2-payload-fingerprint.js +1 -1
  81. package/backend/detectors/trapdoor/d3-publisher-blocklist.js +4 -3
  82. package/backend/detectors/trapdoor/d4-gists-exfil.js +4 -2
  83. package/backend/detectors/trapdoor/d5-ai-poisoning.js +5 -3
  84. package/backend/detectors/trapdoor/d6-lure-name.js +12 -7
  85. package/backend/detectors/trapdoor/d7-crypto-primitives.js +2 -2
  86. package/backend/detectors/trapdoor/d8-xor-key.js +7 -2
  87. package/backend/detectors/trapdoor/d9-cred-validation.js +4 -5
  88. package/backend/detectors/trapdoor/index.js +19 -14
  89. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +32 -8
  90. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +5 -3
  91. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +34 -12
  92. package/backend/detectors/typosquat-vpmdhaj/index.js +78 -59
  93. package/backend/detectors.test.js +147 -0
  94. package/backend/fetch.js +37 -29
  95. package/backend/index.js +1 -1
  96. package/backend/license.js +20 -4
  97. package/backend/lockfile.js +60 -36
  98. package/backend/pdf.js +107 -28
  99. package/backend/policy.js +183 -56
  100. package/backend/provenance.js +28 -3
  101. package/backend/report.js +136 -70
  102. package/backend/sbom.js +33 -27
  103. package/backend/scripts/analyze-false-positives.js +152 -0
  104. package/backend/scripts/analyze-validation.js +157 -0
  105. package/backend/scripts/detect-false-positives.js +103 -0
  106. package/backend/scripts/fetch-top-packages.js +277 -0
  107. package/backend/scripts/validate-d10-d13.js +103 -0
  108. package/backend/scripts/validate-detectors.js +151 -0
  109. package/backend/siem/cef.js +23 -21
  110. package/backend/siem/ecs.js +3 -3
  111. package/backend/siem/index.js +1 -1
  112. package/backend/siem/qradar.js +3 -3
  113. package/backend/siem/sentinel.js +2 -2
  114. package/backend/tests-d5-enhanced.test.js +47 -0
  115. package/backend/tests-d6-version-anomaly.test.js +67 -0
  116. package/backend/tests-d6.test.js +126 -0
  117. package/backend/tests-d6c.test.js +119 -0
  118. package/backend/tests-d7-obfuscation.test.js +88 -0
  119. package/backend/tests.test.js +997 -0
  120. package/backend/vsix-scan/detectors/activation-event-risk.js +36 -19
  121. package/backend/vsix-scan/detectors/burst-publish.js +14 -7
  122. package/backend/vsix-scan/detectors/exfil-pattern.js +7 -3
  123. package/backend/vsix-scan/detectors/known-ioc.js +23 -8
  124. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +11 -7
  125. package/backend/vsix-scan/detectors/publisher-anomaly.js +24 -10
  126. package/backend/vsix-scan/index.js +97 -41
  127. package/backend/vsix-scan/marketplace-client.js +29 -13
  128. package/cli/cli.js +154 -64
  129. package/package.json +36 -10
  130. package/.dockerignore +0 -20
  131. package/.husky/pre-commit +0 -1
  132. package/SECURITY.md +0 -73
  133. package/deploy/helm/npm-scan/Chart.yaml +0 -22
  134. package/deploy/helm/npm-scan/templates/_helpers.tpl +0 -9
  135. package/deploy/helm/npm-scan/templates/api.yaml +0 -94
  136. package/deploy/helm/npm-scan/templates/ingress.yaml +0 -28
  137. package/deploy/helm/npm-scan/templates/postgresql.yaml +0 -67
  138. package/deploy/helm/npm-scan/templates/secrets.yaml +0 -19
  139. package/deploy/helm/npm-scan/templates/worker.yaml +0 -32
  140. package/deploy/helm/npm-scan/values.byoc.yaml +0 -75
  141. package/deploy/helm/npm-scan/values.yaml +0 -103
  142. package/scripts/download-corpus.js +0 -30
  143. package/scripts/gen-mal-corpus.js +0 -35
  144. package/scripts/generate-campaign-fixtures.js +0 -170
  145. package/src/config/top-5000.json +0 -87
  146. package/test/fixtures/lockfiles/npm-lock.json +0 -69
  147. package/test/fixtures/lockfiles/pnpm-lock.yaml +0 -118
  148. package/test/fixtures/lockfiles/yarn.lock +0 -104
  149. package/test/fixtures/mock-data.js +0 -69
@@ -11,7 +11,9 @@ const __dirname = dirname(__filename);
11
11
  const IOC_PATH = join(__dirname, 'iocs.json');
12
12
 
13
13
  function loadIOCData() {
14
- if (iocsLoaded) return iocsData;
14
+ if (iocsLoaded) {
15
+ return iocsData;
16
+ }
15
17
  iocsLoaded = true;
16
18
  try {
17
19
  iocsData = JSON.parse(readFileSync(IOC_PATH, 'utf8'));
@@ -34,7 +36,9 @@ export function reloadIOCData() {
34
36
 
35
37
  export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, timeMap = {}) {
36
38
  const data = loadIOCData();
37
- if (!data) return { triggered: false, matches: [] };
39
+ if (!data) {
40
+ return { triggered: false, matches: [] };
41
+ }
38
42
 
39
43
  const matches = [];
40
44
  const allIOCs = [];
@@ -44,7 +48,7 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
44
48
  for (const waveKey of Object.keys(data.waves || {})) {
45
49
  const wave = data.waves[waveKey];
46
50
  const waveNum = waveKey === 'wave1' ? 1 : waveKey === 'wave2' ? 2 : 3;
47
- for (const ioc of (wave.iocs || [])) {
51
+ for (const ioc of wave.iocs || []) {
48
52
  allIOCs.push({ ...ioc, wave: waveNum });
49
53
  }
50
54
  }
@@ -53,7 +57,11 @@ export async function checkIOC(pkgName, pkgVersion, sha512, publisherAccount, ti
53
57
  switch (ioc.type) {
54
58
  case 'packageName': {
55
59
  if (ioc.value === pkgName) {
56
- if (!ioc.maliciousVersions || ioc.maliciousVersions.length === 0 || ioc.maliciousVersions.includes(pkgVersion)) {
60
+ if (
61
+ !ioc.maliciousVersions ||
62
+ ioc.maliciousVersions.length === 0 ||
63
+ ioc.maliciousVersions.includes(pkgVersion)
64
+ ) {
57
65
  matches.push({ type: 'packageName', value: pkgName, wave: ioc.wave });
58
66
  }
59
67
  }
@@ -14,7 +14,9 @@ const SUSPICIOUS_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
14
14
  const MAX_SNIPPET_LENGTH = 200;
15
15
 
16
16
  function truncateSnippet(text) {
17
- if (text.length <= MAX_SNIPPET_LENGTH) return text;
17
+ if (text.length <= MAX_SNIPPET_LENGTH) {
18
+ return text;
19
+ }
18
20
  return text.slice(0, MAX_SNIPPET_LENGTH - 3) + '...';
19
21
  }
20
22
 
@@ -24,7 +26,9 @@ export function checkTokenExfil(allFiles, pkgJson) {
24
26
 
25
27
  for (const hook of SUSPICIOUS_SCRIPTS) {
26
28
  const scriptContent = scripts[hook];
27
- if (!scriptContent) continue;
29
+ if (!scriptContent) {
30
+ continue;
31
+ }
28
32
 
29
33
  for (const pattern of EXFIL_PATTERNS) {
30
34
  if (pattern.test(scriptContent)) {
@@ -1,5 +1,8 @@
1
1
  import { checkBurstPublish } from './d1-burst-publish.js';
2
- import { checkSiblingCompromise, clearSiblingCache } from './d2-sibling-compromise.js';
2
+ import {
3
+ checkSiblingCompromise,
4
+ clearSiblingCache as _clearSiblingCache,
5
+ } from './d2-sibling-compromise.js';
3
6
  import { checkSlsaMismatch } from './d3-slsa-mismatch.js';
4
7
  import { checkMaintainerAnomaly } from './d4-maintainer-anomaly.js';
5
8
  import { checkIOC } from './d5-ioc-check.js';
@@ -31,15 +34,31 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
31
34
  const nxDownstreamResult = checkNxConsoleDownstream(pkgJson, allFiles || files);
32
35
 
33
36
  const triggeredChecks = [];
34
- if (burstResult.triggered) triggeredChecks.push('D1_BURST');
35
- if (siblingResult.triggered) triggeredChecks.push('D2_SIBLING');
36
- if (slsaResult.triggered) triggeredChecks.push('D3_SLSA');
37
- if (maintainerResult.triggered) triggeredChecks.push('D4_MAINTAINER');
38
- if (iocResult.triggered) triggeredChecks.push('D5_IOC');
39
- if (exfilResult.triggered) triggeredChecks.push('D6_EXFIL');
40
- if (nxDownstreamResult.triggered) triggeredChecks.push('D7_NX_CONSOLE');
37
+ if (burstResult.triggered) {
38
+ triggeredChecks.push('D1_BURST');
39
+ }
40
+ if (siblingResult.triggered) {
41
+ triggeredChecks.push('D2_SIBLING');
42
+ }
43
+ if (slsaResult.triggered) {
44
+ triggeredChecks.push('D3_SLSA');
45
+ }
46
+ if (maintainerResult.triggered) {
47
+ triggeredChecks.push('D4_MAINTAINER');
48
+ }
49
+ if (iocResult.triggered) {
50
+ triggeredChecks.push('D5_IOC');
51
+ }
52
+ if (exfilResult.triggered) {
53
+ triggeredChecks.push('D6_EXFIL');
54
+ }
55
+ if (nxDownstreamResult.triggered) {
56
+ triggeredChecks.push('D7_NX_CONSOLE');
57
+ }
41
58
 
42
- if (triggeredChecks.length === 0) return [];
59
+ if (triggeredChecks.length === 0) {
60
+ return [];
61
+ }
43
62
 
44
63
  let waveAttribution = 'unknown';
45
64
  if (pkgName.startsWith('@tanstack')) {
@@ -49,9 +68,10 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
49
68
  } else if (nxDownstreamResult.triggered) {
50
69
  waveAttribution = 'wave3-nx-console';
51
70
  } else if (iocResult.matches && iocResult.matches.length > 0) {
52
- const waves = [...new Set(iocResult.matches.map(m => m.wave))];
71
+ const waves = [...new Set(iocResult.matches.map((m) => m.wave))];
53
72
  if (waves.length === 1) {
54
- waveAttribution = waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
73
+ waveAttribution =
74
+ waves[0] === 1 ? 'wave1-tanstack' : waves[0] === 2 ? 'wave2-antv' : 'wave3-nx-console';
55
75
  }
56
76
  }
57
77
 
@@ -62,10 +82,14 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
62
82
  waveAttribution,
63
83
  triggeredChecks,
64
84
  burstWindow: burstResult.triggered
65
- ? { start: burstResult.windowStart, end: burstResult.windowEnd, versionCount: burstResult.versionCount }
85
+ ? {
86
+ start: burstResult.windowStart,
87
+ end: burstResult.windowEnd,
88
+ versionCount: burstResult.versionCount,
89
+ }
66
90
  : null,
67
91
  siblingPackages: siblingResult.triggered
68
- ? siblingResult.results.flatMap(r => r.siblingPackages)
92
+ ? siblingResult.results.flatMap((r) => r.siblingPackages)
69
93
  : null,
70
94
  attestationAnomalies: slsaResult.triggered ? slsaResult.anomalies : null,
71
95
  iocMatches: iocResult.triggered ? iocResult.matches : null,
@@ -75,25 +99,38 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
75
99
  : null,
76
100
  };
77
101
 
78
- return [{
79
- id: 'MINI_SHAI_HULUD',
80
- severity: isCritical ? 'critical' : 'high',
81
- title: 'Mini Shai-Hulud worm campaign',
82
- description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
83
- evidence: JSON.stringify(evidence),
84
- mitigation: 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
85
- }];
102
+ return [
103
+ {
104
+ id: 'MINI_SHAI_HULUD',
105
+ severity: isCritical ? 'critical' : 'high',
106
+ title: 'Mini Shai-Hulud worm campaign',
107
+ description: `${triggeredChecks.length} signal(s): ${triggeredChecks.join(', ')}`,
108
+ evidence: JSON.stringify(evidence),
109
+ mitigation:
110
+ 'Revoke all npm tokens immediately. Rotate CI/CD secrets. Audit maintainer access on all scoped packages. Review recent version publish history for anomalous bursts. Check for postinstall scripts accessing credentials or environment variables. If Wave 1 (TanStack scope): inspect GitHub Actions workflow logs for unauthorized build steps. If Wave 2 (atool/AntV scope): rotate all npm tokens associated with @antv/* packages. If Wave 3 (Nx Console): remove nrwl.angular-console extension immediately, revoke all npm tokens used in CI/CD, and audit @nx/* dependency versions.',
111
+ },
112
+ ];
86
113
  }
87
114
 
88
115
  function checkNxConsoleDownstream(pkgJson, allFiles) {
89
- const deps = { ...pkgJson?.dependencies, ...pkgJson?.devDependencies, ...pkgJson?.peerDependencies };
90
- const nxDeps = Object.keys(deps).filter(d => d.startsWith('@nx/') || d.startsWith('nrwl/'));
91
- if (nxDeps.length === 0) return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
116
+ const deps = {
117
+ ...pkgJson?.dependencies,
118
+ ...pkgJson?.devDependencies,
119
+ ...pkgJson?.peerDependencies,
120
+ };
121
+ const nxDeps = Object.keys(deps).filter((d) => d.startsWith('@nx/') || d.startsWith('nrwl/'));
122
+ if (nxDeps.length === 0) {
123
+ return { triggered: false, nxDeps: [], vsCodeExtensions: [] };
124
+ }
92
125
 
93
126
  let vsCodeExtensions = [];
94
127
  if (allFiles && Array.isArray(allFiles)) {
95
128
  for (const file of allFiles) {
96
- if (file.path && (file.path.endsWith('.vscode/extensions.json') || file.path.endsWith('.vscode/extensions.json'))) {
129
+ if (
130
+ file.path &&
131
+ (file.path.endsWith('.vscode/extensions.json') ||
132
+ file.path.endsWith('.vscode/extensions.json'))
133
+ ) {
97
134
  try {
98
135
  const content = typeof file.content === 'string' ? file.content : '';
99
136
  const parsed = JSON.parse(content);
@@ -101,7 +138,7 @@ function checkNxConsoleDownstream(pkgJson, allFiles) {
101
138
  ...(parsed.recommendations || []),
102
139
  ...(parsed.unwantedRecommendations || []),
103
140
  ];
104
- const matched = allExts.filter(e => e.includes('nrwl.angular-console'));
141
+ const matched = allExts.filter((e) => e.includes('nrwl.angular-console'));
105
142
  if (matched.length > 0) {
106
143
  vsCodeExtensions = matched;
107
144
  }
@@ -16,30 +16,48 @@ export function scanPersistence(pkgJson, files = []) {
16
16
  }
17
17
  }
18
18
 
19
- const code = files.map(f => f.content || '').join('\n');
20
- const codeWithScripts = code + '\n' + allScripts.map(s => s.content).join('\n');
19
+ const code = files.map((f) => f.content || '').join('\n');
20
+ const codeWithScripts = code + '\n' + allScripts.map((s) => s.content).join('\n');
21
21
 
22
22
  const detectedApis = [];
23
23
  let hasCiGuard = false;
24
24
  let hasDaemon = false;
25
25
 
26
- if (DAEMON_RE.test(codeWithScripts)) detectedApis.push('daemon');
27
- if (SPAWN_DETACHED_RE.test(codeWithScripts)) detectedApis.push('spawn_detached');
28
- if (SYSTEMD_RE.test(codeWithScripts)) detectedApis.push('systemd');
29
- if (CRON_RE.test(codeWithScripts)) detectedApis.push('cron');
30
- if (LAUNCHD_RE.test(codeWithScripts)) detectedApis.push('launchd');
31
- if (TASK_SCHED_RE.test(codeWithScripts)) detectedApis.push('task_scheduler');
32
- if (CI_GUARD_RE.test(codeWithScripts)) hasCiGuard = true;
26
+ if (DAEMON_RE.test(codeWithScripts)) {
27
+ detectedApis.push('daemon');
28
+ }
29
+ if (SPAWN_DETACHED_RE.test(codeWithScripts)) {
30
+ detectedApis.push('spawn_detached');
31
+ }
32
+ if (SYSTEMD_RE.test(codeWithScripts)) {
33
+ detectedApis.push('systemd');
34
+ }
35
+ if (CRON_RE.test(codeWithScripts)) {
36
+ detectedApis.push('cron');
37
+ }
38
+ if (LAUNCHD_RE.test(codeWithScripts)) {
39
+ detectedApis.push('launchd');
40
+ }
41
+ if (TASK_SCHED_RE.test(codeWithScripts)) {
42
+ detectedApis.push('task_scheduler');
43
+ }
44
+ if (CI_GUARD_RE.test(codeWithScripts)) {
45
+ hasCiGuard = true;
46
+ }
33
47
 
34
- if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts)) hasDaemon = true;
48
+ if (DAEMON_RE.test(codeWithScripts) || SPAWN_DETACHED_RE.test(codeWithScripts)) {
49
+ hasDaemon = true;
50
+ }
35
51
 
36
52
  if (hasDaemon || detectedApis.length > 0) {
37
53
  return {
38
54
  triggered: true,
39
55
  detectedApis,
40
56
  hasCiGuard,
41
- hooks: allScripts.map(s => s.hook),
42
- context: hasCiGuard ? 'Spawns background process when CI env var absent' : 'Suspicious persistence/detached process detected',
57
+ hooks: allScripts.map((s) => s.hook),
58
+ context: hasCiGuard
59
+ ? 'Spawns background process when CI env var absent'
60
+ : 'Suspicious persistence/detached process detected',
43
61
  };
44
62
  }
45
63
 
@@ -9,25 +9,37 @@ const TARGET_LOCALES = /ru_RU|be_BY|uk_UA/;
9
9
  const SILENT_EXIT_RE = /process\.exit\s*\(\s*0\s*\)/;
10
10
 
11
11
  export function scanGeoKillswitch(files = []) {
12
- const code = files.map(f => f.content || '').join('\n');
13
- if (!code) return { triggered: false, targetedLocales: [], triggerBehavior: null };
12
+ const code = files.map((f) => f.content || '').join('\n');
13
+ if (!code) {
14
+ return { triggered: false, targetedLocales: [], triggerBehavior: null };
15
+ }
14
16
 
15
- const hasLocaleCheck = LOCALE_CHECKS.some(re => re.test(code));
16
- if (!hasLocaleCheck) return { triggered: false, targetedLocales: [], triggerBehavior: null };
17
+ const hasLocaleCheck = LOCALE_CHECKS.some((re) => re.test(code));
18
+ if (!hasLocaleCheck) {
19
+ return { triggered: false, targetedLocales: [], triggerBehavior: null };
20
+ }
17
21
 
18
22
  const hasTargetLocale = TARGET_LOCALES.test(code);
19
23
  const hasSilentExit = SILENT_EXIT_RE.test(code);
20
24
 
21
25
  if (hasTargetLocale || hasSilentExit) {
22
26
  const matchedLocales = [];
23
- if (/ru_RU/.test(code)) matchedLocales.push('ru_RU');
24
- if (/be_BY/.test(code)) matchedLocales.push('be_BY');
25
- if (/uk_UA/.test(code)) matchedLocales.push('uk_UA');
27
+ if (/ru_RU/.test(code)) {
28
+ matchedLocales.push('ru_RU');
29
+ }
30
+ if (/be_BY/.test(code)) {
31
+ matchedLocales.push('be_BY');
32
+ }
33
+ if (/uk_UA/.test(code)) {
34
+ matchedLocales.push('uk_UA');
35
+ }
26
36
 
27
37
  return {
28
38
  triggered: true,
29
39
  targetedLocales: matchedLocales.length > 0 ? matchedLocales : ['ru_RU', 'be_BY'],
30
- triggerBehavior: hasSilentExit ? 'Silent exit' : 'Locale/timezone match with conditional behavior',
40
+ triggerBehavior: hasSilentExit
41
+ ? 'Silent exit'
42
+ : 'Locale/timezone match with conditional behavior',
31
43
  };
32
44
  }
33
45
 
@@ -5,13 +5,19 @@ const GITHUB_TOKEN_ACCESS_RE = /process\.env\.(?:GH_TOKEN|GITHUB_TOKEN|GITHUB_AC
5
5
  const COMMIT_PARSE_LOOP_RE = /commits?\s*\.\s*(?:map|filter|forEach|for\s*\(|while\s*\()/;
6
6
 
7
7
  export function scanC2DeadDrop(files = []) {
8
- const code = files.map(f => f.content || '').join('\n');
9
- if (!code) return { triggered: false, matches: [] };
8
+ const code = files.map((f) => f.content || '').join('\n');
9
+ if (!code) {
10
+ return { triggered: false, matches: [] };
11
+ }
10
12
 
11
13
  const matches = [];
12
14
 
13
15
  if (OHNO_WHATS_GOING_ON_RE.test(code)) {
14
- matches.push({ type: 'ioc_keyword', value: 'OhNoWhatsGoingOnWithGitHub', attackVector: 'GitHub commit scraping for token recovery' });
16
+ matches.push({
17
+ type: 'ioc_keyword',
18
+ value: 'OhNoWhatsGoingOnWithGitHub',
19
+ attackVector: 'GitHub commit scraping for token recovery',
20
+ });
15
21
  }
16
22
 
17
23
  const hasTokenAccess = GITHUB_TOKEN_ACCESS_RE.test(code);
@@ -19,11 +25,19 @@ export function scanC2DeadDrop(files = []) {
19
25
  const hasCommitParseLoop = COMMIT_PARSE_LOOP_RE.test(code);
20
26
 
21
27
  if (hasTokenAccess && hasGithubApi) {
22
- matches.push({ type: 'token_exfil_github_api', value: 'Credential access followed by GitHub API call', attackVector: 'Credential/token extraction followed by GitHub API calls' });
28
+ matches.push({
29
+ type: 'token_exfil_github_api',
30
+ value: 'Credential access followed by GitHub API call',
31
+ attackVector: 'Credential/token extraction followed by GitHub API calls',
32
+ });
23
33
  }
24
34
 
25
35
  if (hasCommitParseLoop && hasGithubApi) {
26
- matches.push({ type: 'commit_scraping', value: 'Commit message parsing with GitHub API', attackVector: 'Commit scraping for secret detection' });
36
+ matches.push({
37
+ type: 'commit_scraping',
38
+ value: 'Commit message parsing with GitHub API',
39
+ attackVector: 'Commit scraping for secret detection',
40
+ });
27
41
  }
28
42
 
29
43
  return {
@@ -15,48 +15,57 @@ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'none'];
15
15
 
16
16
  function highestSeverity(severities) {
17
17
  for (const s of SEVERITY_ORDER) {
18
- if (severities.includes(s)) return s;
18
+ if (severities.includes(s)) {
19
+ return s;
20
+ }
19
21
  }
20
22
  return 'none';
21
23
  }
22
24
 
23
- export async function scan(pkgJson, files = [], registryMeta = null, allFiles = null) {
25
+ export async function scan(pkgJson, files = [], _registryMeta = null, allFiles = null) {
24
26
  const fileList = allFiles || files || [];
25
27
  const pkgName = pkgJson?.name || 'unknown';
26
28
  const pkgVersion = pkgJson?.version || '0.0.0';
27
29
 
28
30
  const d1Results = scanCtfScramble(fileList);
29
31
  if (d1Results.stopCondition) {
30
- const evidence = attachProvenance({
31
- rule: 'MSH-OBF-001',
32
- campaign: 'MINI_SHAI_HULUD',
33
- triggeredChecks: ['D1'],
34
- filePath: d1Results.filePath,
35
- patternMatched: d1Results.patternMatched,
36
- action: 'BLOCK_IMMEDIATELY',
37
- }, {
38
- ruleId: 'MSH-OBF-001',
39
- ruleName: 'ctf-scramble-v2 Obfuscation Detection',
40
- severity: 'CRITICAL',
41
- campaignName: 'Mini Shai-Hulud',
42
- pkgName,
43
- pkgVersion,
44
- triggered: true,
45
- severity: 'critical',
46
- indicators: [{ type: 'obfuscation_found', value: d1Results.patternMatched }],
47
- ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/d1-obfuscation.js',
48
- campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
49
- });
32
+ const evidence = attachProvenance(
33
+ {
34
+ rule: 'MSH-OBF-001',
35
+ campaign: 'MINI_SHAI_HULUD',
36
+ triggeredChecks: ['D1'],
37
+ filePath: d1Results.filePath,
38
+ patternMatched: d1Results.patternMatched,
39
+ action: 'BLOCK_IMMEDIATELY',
40
+ },
41
+ {
42
+ ruleId: 'MSH-OBF-001',
43
+ ruleName: 'ctf-scramble-v2 Obfuscation Detection',
44
+ campaignName: 'Mini Shai-Hulud',
45
+ pkgName,
46
+ pkgVersion,
47
+ triggered: true,
48
+ severity: 'critical',
49
+ indicators: [{ type: 'obfuscation_found', value: d1Results.patternMatched }],
50
+ ruleProvenanceUrl:
51
+ 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/d1-obfuscation.js',
52
+ campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
53
+ }
54
+ );
50
55
 
51
- return [{
52
- id: 'MINI_SHAI_HULUD',
53
- severity: 'critical',
54
- title: 'Mini Shai-Hulud worm campaign — ctf-scramble-v2 malware obfuscation detected',
55
- description: 'HALT: ctf-scramble-v2 obfuscation layer detected. Package is compromised. Block install immediately.',
56
- evidence: JSON.stringify(evidence),
57
- mitigation: 'BLOCK IMMEDIATELY. Do not install this package version. Revoke any npm tokens exposed to this package. Rotate all CI/CD secrets. Run full malware scan on any system that processed this package.',
58
- stopCondition: true,
59
- }];
56
+ return [
57
+ {
58
+ id: 'MINI_SHAI_HULUD',
59
+ severity: 'critical',
60
+ title: 'Mini Shai-Hulud worm campaign — ctf-scramble-v2 malware obfuscation detected',
61
+ description:
62
+ 'HALT: ctf-scramble-v2 obfuscation layer detected. Package is compromised. Block install immediately.',
63
+ evidence: JSON.stringify(evidence),
64
+ mitigation:
65
+ 'BLOCK IMMEDIATELY. Do not install this package version. Revoke any npm tokens exposed to this package. Rotate all CI/CD secrets. Run full malware scan on any system that processed this package.',
66
+ stopCondition: true,
67
+ },
68
+ ];
60
69
  }
61
70
 
62
71
  const results = {
@@ -69,39 +78,45 @@ export async function scan(pkgJson, files = [], registryMeta = null, allFiles =
69
78
  .filter(([_, r]) => r.triggered)
70
79
  .map(([id]) => id);
71
80
 
72
- if (triggered.length === 0) return [];
81
+ if (triggered.length === 0) {
82
+ return [];
83
+ }
73
84
 
74
- const severity = highestSeverity(triggered.map(id => RULE_SEVERITY[id]));
85
+ const severity = highestSeverity(triggered.map((id) => RULE_SEVERITY[id]));
75
86
 
76
- const evidence = attachProvenance({
77
- campaign: 'MINI_SHAI_HULUD',
78
- triggeredChecks: triggered,
79
- details: Object.fromEntries(
80
- Object.entries(results).filter(([_, r]) => r.triggered)
81
- ),
82
- }, {
83
- ruleId: 'MSH-SUPPLEMENT',
84
- ruleName: 'Mini Shai-Hulud Supplement Detection',
85
- severity: severity.toUpperCase(),
86
- campaignName: 'Mini Shai-Hulud',
87
- pkgName,
88
- pkgVersion,
89
- triggered: true,
90
- severity,
91
- indicators: triggered.map(id => ({
92
- type: `rule_${id}`,
93
- value: RULE_SEVERITY[id],
94
- })),
95
- ruleProvenanceUrl: 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/',
96
- campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
97
- });
87
+ const evidence = attachProvenance(
88
+ {
89
+ campaign: 'MINI_SHAI_HULUD',
90
+ triggeredChecks: triggered,
91
+ details: Object.fromEntries(Object.entries(results).filter(([_, r]) => r.triggered)),
92
+ },
93
+ {
94
+ ruleId: 'MSH-SUPPLEMENT',
95
+ ruleName: 'Mini Shai-Hulud Supplement Detection',
96
+ campaignName: 'Mini Shai-Hulud',
97
+ pkgName,
98
+ pkgVersion,
99
+ triggered: true,
100
+ severity,
101
+ indicators: triggered.map((id) => ({
102
+ type: `rule_${id}`,
103
+ value: RULE_SEVERITY[id],
104
+ })),
105
+ ruleProvenanceUrl:
106
+ 'https://github.com/lateos/npm-scan/blob/main/backend/detectors/msh-supplement/',
107
+ campaignSourceUrl: 'https://security.researcher.org/supply-chain-report',
108
+ }
109
+ );
98
110
 
99
- return [{
100
- id: 'MINI_SHAI_HULUD',
101
- severity,
102
- title: 'Mini Shai-Hulud worm campaign — supplement indicators',
103
- description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
104
- evidence: JSON.stringify(evidence),
105
- mitigation: 'If daemonization detected: revoke npm tokens and rotate CI/CD secrets. If geographic killswitch detected: verify running in expected region; attacker may be avoiding certain locales. If C2 dead-drop detected: check for unauthorized GitHub API access and token exfiltration. Review recent version publish history for anomalous bursts.',
106
- }];
111
+ return [
112
+ {
113
+ id: 'MINI_SHAI_HULUD',
114
+ severity,
115
+ title: 'Mini Shai-Hulud worm campaign — supplement indicators',
116
+ description: `${triggered.length} signal(s): ${triggered.join(', ')}`,
117
+ evidence: JSON.stringify(evidence),
118
+ mitigation:
119
+ 'If daemonization detected: revoke npm tokens and rotate CI/CD secrets. If geographic killswitch detected: verify running in expected region; attacker may be avoiding certain locales. If C2 dead-drop detected: check for unauthorized GitHub API access and token exfiltration. Review recent version publish history for anomalous bursts.',
120
+ },
121
+ ];
107
122
  }
@@ -6,9 +6,11 @@ const SAFE_PINS = {
6
6
  '12.0.1': '12.0.0',
7
7
  };
8
8
 
9
- export function scanVersionBlocklist(pkgJson, registryMeta) {
9
+ export function scanVersionBlocklist(pkgJson, _registryMeta) {
10
10
  const pkgName = pkgJson?.name || '';
11
- if (pkgName !== 'node-ipc') return { triggered: false };
11
+ if (pkgName !== 'node-ipc') {
12
+ return { triggered: false };
13
+ }
12
14
 
13
15
  const version = pkgJson?.version || '';
14
16
  if (BLOCKED_VERSIONS.has(version)) {
@@ -1,17 +1,21 @@
1
1
  export function scanUnauthorizedPublisher(pkgJson, registryMeta) {
2
2
  const pkgName = pkgJson?.name || '';
3
- if (pkgName !== 'node-ipc') return { triggered: false };
3
+ if (pkgName !== 'node-ipc') {
4
+ return { triggered: false };
5
+ }
4
6
 
5
- const publisherAccount = registryMeta?.versions?.[pkgJson?.version]?._npmUser?.name
6
- || registryMeta?.versions?.[Object.keys(registryMeta.versions || {})[0]]?._npmUser?.name
7
- || null;
7
+ const publisherAccount =
8
+ registryMeta?.versions?.[pkgJson?.version]?._npmUser?.name ||
9
+ registryMeta?.versions?.[Object.keys(registryMeta.versions || {})[0]]?._npmUser?.name ||
10
+ null;
8
11
 
9
12
  if (publisherAccount === 'atiertant') {
10
13
  return {
11
14
  triggered: true,
12
15
  publisher: publisherAccount,
13
16
  package: pkgName,
14
- detail: 'Account atiertant has no prior release history on node-ipc — account recovery via expired email domain takeover',
17
+ detail:
18
+ 'Account atiertant has no prior release history on node-ipc — account recovery via expired email domain takeover',
15
19
  };
16
20
  }
17
21
 
@@ -16,12 +16,16 @@ export function scanBlastRadius(allFiles) {
16
16
 
17
17
  for (const file of allFiles) {
18
18
  const path = file.path?.replace(/\\/g, '/') || '';
19
- const isLockfile = LOCKFILE_PATTERNS.some(p => p.test(path));
20
- if (!isLockfile) continue;
19
+ const isLockfile = LOCKFILE_PATTERNS.some((p) => p.test(path));
20
+ if (!isLockfile) {
21
+ continue;
22
+ }
21
23
 
22
24
  const content = file.content || '';
23
25
  const hasNodeIpc = /\bnode-ipc\b/i.test(content);
24
- if (!hasNodeIpc) continue;
26
+ if (!hasNodeIpc) {
27
+ continue;
28
+ }
25
29
 
26
30
  for (const [badVersion, info] of Object.entries(COMPROMISED_VERSIONS)) {
27
31
  const versionInQuotes = `"${badVersion}"`;
@@ -11,7 +11,9 @@ export function scanTarballHash(allFiles) {
11
11
 
12
12
  for (const file of allFiles) {
13
13
  const path = file.path || '';
14
- if (!path.endsWith('.tgz') && !path.endsWith('.tar.gz')) continue;
14
+ if (!path.endsWith('.tgz') && !path.endsWith('.tar.gz')) {
15
+ continue;
16
+ }
15
17
 
16
18
  const content = file.content || '';
17
19
  const hash = createHash('sha256').update(content, 'utf8').digest('hex');
@@ -20,9 +22,12 @@ export function scanTarballHash(allFiles) {
20
22
  matches.push({
21
23
  file: path,
22
24
  sha256: hash,
23
- version: hash === '449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e'
24
- ? '9.1.6' : hash === 'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea'
25
- ? '9.2.3' : '12.0.1',
25
+ version:
26
+ hash === '449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e'
27
+ ? '9.1.6'
28
+ : hash === 'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea'
29
+ ? '9.2.3'
30
+ : '12.0.1',
26
31
  });
27
32
  }
28
33
  }