@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
@@ -1,7 +1,8 @@
1
1
  import { MegalodonSignal } from './types.js';
2
2
  import yaml from 'js-yaml';
3
3
 
4
- const C2_EXFIL_RE = /curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
4
+ const C2_EXFIL_RE =
5
+ /curl\s+.*?https?:\/\/(?!github\.com|githubusercontent\.com|raw\.githubusercontent\.com)[^\s'"]+/i;
5
6
  const SECRETS_REF_RE = /\$\{\{?\s*secrets\.\w+/;
6
7
  const B64_DECODE_CHAIN_RE = /base64\s+-d\s*[|>]\s*(ba)?sh/;
7
8
 
@@ -11,16 +12,23 @@ function isWorkflowFile(f) {
11
12
  }
12
13
 
13
14
  function countExecutableLines(text) {
14
- return text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
15
+ return text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).length;
15
16
  }
16
17
 
17
18
  function extractRunBlocks(parsed) {
18
19
  const runs = [];
19
- if (!parsed || typeof parsed !== 'object') return runs;
20
+ if (!parsed || typeof parsed !== 'object') {
21
+ return runs;
22
+ }
20
23
 
21
24
  const walk = (obj) => {
22
- if (!obj || typeof obj !== 'object') return;
23
- if (Array.isArray(obj)) { obj.forEach(walk); return; }
25
+ if (!obj || typeof obj !== 'object') {
26
+ return;
27
+ }
28
+ if (Array.isArray(obj)) {
29
+ obj.forEach(walk);
30
+ return;
31
+ }
24
32
  for (const [k, v] of Object.entries(obj)) {
25
33
  if (k === 'run' && typeof v === 'string') {
26
34
  runs.push(v);
@@ -38,21 +46,31 @@ function extractRunBlocks(parsed) {
38
46
  function extractRunBlocksRaw(text) {
39
47
  const runs = [];
40
48
  const runMatch = text.match(/run:\s*[|>]\s*\n(\s{2,}.*(?:\n\s{2,}.*)*)/g);
41
- if (runMatch) runs.push(...runMatch.map(m => m.replace(/^run:\s*[|>]\s*\n/, '')));
49
+ if (runMatch) {
50
+ runs.push(...runMatch.map((m) => m.replace(/^run:\s*[|>]\s*\n/, '')));
51
+ }
42
52
 
43
53
  const inlineRe = /run:\s*['"](.+?)['"]\s*$/gm;
44
54
  let m;
45
- while ((m = inlineRe.exec(text)) !== null) runs.push(m[1]);
55
+ while ((m = inlineRe.exec(text)) !== null) {
56
+ runs.push(m[1]);
57
+ }
46
58
 
47
59
  const envRe = /env:\s*\n((?:\s{2,}\w+:\s*.+\n?)*)/g;
48
60
  let em;
49
- while ((em = envRe.exec(text)) !== null) runs.push({ _env: em[1] });
61
+ while ((em = envRe.exec(text)) !== null) {
62
+ runs.push({ _env: em[1] });
63
+ }
50
64
  return runs;
51
65
  }
52
66
 
53
67
  function runInStepHasBoth(step, signal) {
54
68
  const runVal = step.run;
55
- const envVals = step.env ? Object.values(step.env).filter(v => typeof v === 'string').join(' ') : '';
69
+ const envVals = step.env
70
+ ? Object.values(step.env)
71
+ .filter((v) => typeof v === 'string')
72
+ .join(' ')
73
+ : '';
56
74
  const combined = typeof runVal === 'string' ? `${runVal} ${envVals}` : '';
57
75
 
58
76
  if (signal === 'exfil') {
@@ -69,19 +87,21 @@ export async function scan(allFiles) {
69
87
  const workflowFiles = allFiles.filter(isWorkflowFile);
70
88
 
71
89
  for (const f of workflowFiles) {
72
- if (f.content.length > 512 * 1024) continue;
90
+ if (f.content.length > 512 * 1024) {
91
+ continue;
92
+ }
73
93
 
74
94
  let parsed = null;
75
- let parseError = null;
95
+ let _parseError = null;
76
96
  try {
77
97
  parsed = yaml.load(f.content);
78
98
  } catch (e) {
79
- parseError = e;
99
+ _parseError = e;
80
100
  }
81
101
 
82
102
  const rawRunBlocks = parsed ? extractRunBlocks(parsed) : extractRunBlocksRaw(f.content);
83
- const runStrings = rawRunBlocks.filter(r => typeof r === 'string');
84
- const envBlocks = rawRunBlocks.filter(r => typeof r === 'object' && r._env);
103
+ const runStrings = rawRunBlocks.filter((r) => typeof r === 'string');
104
+ const _envBlocks = rawRunBlocks.filter((r) => typeof r === 'object' && r._env);
85
105
 
86
106
  let exfilTriggered = false;
87
107
  let decodeTriggered = false;
@@ -109,7 +129,7 @@ export async function scan(allFiles) {
109
129
  }
110
130
 
111
131
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
112
- const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap(j => j.steps || []) : [];
132
+ const steps = parsed.jobs ? Object.values(parsed.jobs).flatMap((j) => j.steps || []) : [];
113
133
  for (const step of steps) {
114
134
  if (!exfilTriggered && runInStepHasBoth(step, 'exfil')) {
115
135
  exfilTriggered = true;
@@ -136,7 +156,11 @@ export async function scan(allFiles) {
136
156
 
137
157
  const lineCount = countExecutableLines(f.content);
138
158
  if ((exfilTriggered || decodeTriggered) && lineCount >= 100 && lineCount <= 120) {
139
- const found = evidence.find(e => e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL || e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN);
159
+ const found = evidence.find(
160
+ (e) =>
161
+ e.signal === MegalodonSignal.WORKFLOW_C2_EXFIL ||
162
+ e.signal === MegalodonSignal.WORKFLOW_DECODE_CHAIN
163
+ );
140
164
  if (found) {
141
165
  found.detail += ` | Matches ${lineCount}-line Megalodon payload footprint`;
142
166
  }
@@ -3,7 +3,10 @@ import { MegalodonSignal } from './types.js';
3
3
  const CRED_PATTERNS = [
4
4
  { pattern: /\bAWS_(SECRET_ACCESS_KEY|ACCESS_KEY_ID|SESSION_TOKEN)\b/, label: 'AWS credential' },
5
5
  { pattern: /\bGOOGLE_APPLICATION_CREDENTIALS\b/, label: 'GCP credential' },
6
- { pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/, label: 'Azure credential' },
6
+ {
7
+ pattern: /\bAZURE_(CLIENT_SECRET|TENANT_ID|CLIENT_ID|SUBSCRIPTION_ID)\b/,
8
+ label: 'Azure credential',
9
+ },
7
10
  { pattern: /\bGH_(TOKEN|PAT)\b/, label: 'GitHub PAT' },
8
11
  { pattern: /\bGITHUB_TOKEN\b/, label: 'GitHub token' },
9
12
  { pattern: /\bNPM_TOKEN\b/, label: 'npm token' },
@@ -15,7 +18,8 @@ const CRED_PATTERNS = [
15
18
  { pattern: /\bMONGO_(URI|URL|CONNECTION)\b/, label: 'MongoDB connection' },
16
19
  ];
17
20
 
18
- const OUTBOUND_NET_RE = /curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
21
+ const OUTBOUND_NET_RE =
22
+ /curl\s+|wget\s+|fetch\s*\(|https?\.request\s*\(|http\.request\s*\(|got\s*\(|axios\s*\.|request\s*\(|node-fetch|\.post\s*\(|\.get\s*\(/i;
19
23
 
20
24
  const TARGET_EXTENSIONS = ['.sh', '.bash', '.yml', '.yaml', '.js'];
21
25
 
@@ -37,7 +41,7 @@ export async function scan(allFiles) {
37
41
  const re = new RegExp(cp.pattern.source, 'gi');
38
42
  let m;
39
43
  while ((m = re.exec(content)) !== null) {
40
- if (!matched.some(ex => ex.label === cp.label)) {
44
+ if (!matched.some((ex) => ex.label === cp.label)) {
41
45
  matched.push({ label: cp.label, match: m[0] });
42
46
  }
43
47
  score += 3;
@@ -50,8 +54,11 @@ export async function scan(allFiles) {
50
54
  evidence.push({
51
55
  signal: MegalodonSignal.CREDENTIAL_HARVEST,
52
56
  file: f.path,
53
- excerpt: matched.map(m => m.label).join(', ').slice(0, 120),
54
- detail: `Credential env vars (${matched.map(m => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
57
+ excerpt: matched
58
+ .map((m) => m.label)
59
+ .join(', ')
60
+ .slice(0, 120),
61
+ detail: `Credential env vars (${matched.map((m) => m.label).join(', ')}) co-occur with outbound network call (score: ${score})`,
55
62
  });
56
63
  }
57
64
  }
@@ -3,7 +3,9 @@ import { MegalodonSignal } from './types.js';
3
3
  export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
4
4
  const filtered = {};
5
5
  for (const [v, t] of Object.entries(times)) {
6
- if (v === 'created' || v === 'modified') continue;
6
+ if (v === 'created' || v === 'modified') {
7
+ continue;
8
+ }
7
9
  filtered[v] = t;
8
10
  }
9
11
 
@@ -33,7 +35,7 @@ export function detectVelocitySpike(times, windowHours = 6, threshold = 3) {
33
35
  }
34
36
 
35
37
  if (inWindow.length >= threshold) {
36
- let display = inWindow.slice(0, 10);
38
+ const display = inWindow.slice(0, 10);
37
39
  let suffix = '';
38
40
  if (inWindow.length > 10) {
39
41
  suffix = ` +${inWindow.length - 10} more`;
@@ -54,14 +56,18 @@ export async function scan(registryMeta) {
54
56
  const times = registryMeta?.time || {};
55
57
  const result = detectVelocitySpike(times);
56
58
 
57
- if (!result.triggered) return [];
59
+ if (!result.triggered) {
60
+ return [];
61
+ }
58
62
 
59
- return [{
60
- signal: MegalodonSignal.PUBLISH_VELOCITY,
61
- file: 'registry.npmjs.org',
62
- excerpt: result.versionsInWindow,
63
- detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
64
- _windowStartISO: result.windowStartISO,
65
- _allVersions: result._allVersions,
66
- }];
63
+ return [
64
+ {
65
+ signal: MegalodonSignal.PUBLISH_VELOCITY,
66
+ file: 'registry.npmjs.org',
67
+ excerpt: result.versionsInWindow,
68
+ detail: `Version publish velocity spike: ${result.versionsInWindow} versions in window starting ${result.windowStartISO}`,
69
+ _windowStartISO: result.windowStartISO,
70
+ _allVersions: result._allVersions,
71
+ },
72
+ ];
67
73
  }
@@ -7,8 +7,12 @@ export async function scan(registryMeta, velocityResult) {
7
7
 
8
8
  const filteredTimes = {};
9
9
  for (const [v, t] of Object.entries(timeMap)) {
10
- if (v === 'created' || v === 'modified') continue;
11
- if (t) filteredTimes[v] = t;
10
+ if (v === 'created' || v === 'modified') {
11
+ continue;
12
+ }
13
+ if (t) {
14
+ filteredTimes[v] = t;
15
+ }
12
16
  }
13
17
 
14
18
  const sortedVersions = Object.entries(filteredTimes)
@@ -16,7 +20,9 @@ export async function scan(registryMeta, velocityResult) {
16
20
  .sort((a, b) => new Date(a[1]).getTime() - new Date(b[1]).getTime())
17
21
  .map(([v]) => v);
18
22
 
19
- if (sortedVersions.length === 0) return [];
23
+ if (sortedVersions.length === 0) {
24
+ return [];
25
+ }
20
26
 
21
27
  if (velocityResult?.triggered) {
22
28
  const windowStartISO = velocityResult.windowStartISO;
@@ -24,14 +30,20 @@ export async function scan(registryMeta, velocityResult) {
24
30
 
25
31
  const priorPublishers = new Set();
26
32
  for (const v of sortedVersions) {
27
- if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) break;
33
+ if (new Date(filteredTimes[v]).getTime() >= new Date(windowStartISO).getTime()) {
34
+ break;
35
+ }
28
36
  const user = versions[v]?._npmUser?.name;
29
- if (user) priorPublishers.add(user);
37
+ if (user) {
38
+ priorPublishers.add(user);
39
+ }
30
40
  }
31
41
 
32
42
  if (priorPublishers.size === 0 && allInWindow.length > 0) {
33
43
  const firstUser = versions[allInWindow[0]]?._npmUser?.name;
34
- if (firstUser) priorPublishers.add(firstUser);
44
+ if (firstUser) {
45
+ priorPublishers.add(firstUser);
46
+ }
35
47
  }
36
48
 
37
49
  const suspiciousPublishers = [];
@@ -39,15 +51,19 @@ export async function scan(registryMeta, velocityResult) {
39
51
  for (const v of allInWindow) {
40
52
  const user = versions[v]?._npmUser?.name;
41
53
  if (user && !priorPublishers.has(user)) {
42
- if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
43
- if (!affectedVersions.includes(v)) affectedVersions.push(v);
54
+ if (!suspiciousPublishers.includes(user)) {
55
+ suspiciousPublishers.push(user);
56
+ }
57
+ if (!affectedVersions.includes(v)) {
58
+ affectedVersions.push(v);
59
+ }
44
60
  }
45
61
  }
46
62
 
47
63
  if (suspiciousPublishers.length > 0) {
48
64
  const detail = `Drift detected: known publishers [${[...priorPublishers].join(', ')}], new publisher(s) [${suspiciousPublishers.join(', ')}] in versions [${affectedVersions.join(', ')}]`;
49
65
 
50
- const firstSuspiciousVer = allInWindow.find(v => affectedVersions.includes(v));
66
+ const firstSuspiciousVer = allInWindow.find((v) => affectedVersions.includes(v));
51
67
  let ageNote = '';
52
68
  if (firstSuspiciousVer && suspiciousPublishers[0]) {
53
69
  ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[firstSuspiciousVer]);
@@ -62,7 +78,9 @@ export async function scan(registryMeta, velocityResult) {
62
78
  });
63
79
  }
64
80
  } else {
65
- if (sortedVersions.length < 4) return [];
81
+ if (sortedVersions.length < 4) {
82
+ return [];
83
+ }
66
84
 
67
85
  const last3 = sortedVersions.slice(-3);
68
86
  const prior = sortedVersions.slice(0, -3);
@@ -70,7 +88,9 @@ export async function scan(registryMeta, velocityResult) {
70
88
  const priorPublishers = new Set();
71
89
  for (const v of prior) {
72
90
  const user = versions[v]?._npmUser?.name;
73
- if (user) priorPublishers.add(user);
91
+ if (user) {
92
+ priorPublishers.add(user);
93
+ }
74
94
  }
75
95
 
76
96
  const suspiciousPublishers = [];
@@ -78,8 +98,12 @@ export async function scan(registryMeta, velocityResult) {
78
98
  for (const v of last3) {
79
99
  const user = versions[v]?._npmUser?.name;
80
100
  if (user && !priorPublishers.has(user)) {
81
- if (!suspiciousPublishers.includes(user)) suspiciousPublishers.push(user);
82
- if (!affectedVersions.includes(v)) affectedVersions.push(v);
101
+ if (!suspiciousPublishers.includes(user)) {
102
+ suspiciousPublishers.push(user);
103
+ }
104
+ if (!affectedVersions.includes(v)) {
105
+ affectedVersions.push(v);
106
+ }
83
107
  }
84
108
  }
85
109
 
@@ -88,7 +112,10 @@ export async function scan(registryMeta, velocityResult) {
88
112
 
89
113
  let ageNote = '';
90
114
  if (suspiciousPublishers[0] && affectedVersions[0]) {
91
- ageNote = await checkAccountAge(suspiciousPublishers[0], filteredTimes[affectedVersions[0]]);
115
+ ageNote = await checkAccountAge(
116
+ suspiciousPublishers[0],
117
+ filteredTimes[affectedVersions[0]]
118
+ );
92
119
  }
93
120
 
94
121
  evidence.push({
@@ -108,10 +135,14 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
108
135
  try {
109
136
  const url = `https://registry.npmjs.org/-/user/org.couchdb.user/${encodeURIComponent(npmUser)}`;
110
137
  const res = await fetch(url);
111
- if (!res.ok) return '';
138
+ if (!res.ok) {
139
+ return '';
140
+ }
112
141
  const data = await res.json();
113
142
  const created = data?.date;
114
- if (!created) return '';
143
+ if (!created) {
144
+ return '';
145
+ }
115
146
  const createdDate = new Date(created).getTime();
116
147
  const firstPub = new Date(firstSuspiciousTime).getTime();
117
148
  const daysDiff = (firstPub - createdDate) / (1000 * 60 * 60 * 24);
@@ -119,6 +150,7 @@ async function checkAccountAge(npmUser, firstSuspiciousTime) {
119
150
  return `Publisher account created ${Math.round(daysDiff)} days before first suspicious publish`;
120
151
  }
121
152
  } catch {
153
+ /* ignore fetch errors */
122
154
  }
123
155
  return '';
124
156
  }
@@ -1,3 +1,3 @@
1
- export async function scan(registryMeta) {
1
+ export async function scan(_registryMeta) {
2
2
  return [];
3
3
  }
@@ -1,3 +1,3 @@
1
- export async function scan(pkgJson, registryMeta) {
1
+ export async function scan(_pkgJson, _registryMeta) {
2
2
  return [];
3
3
  }
@@ -24,9 +24,10 @@ function resolveSeverity(signals, d4Evidence) {
24
24
  maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
25
25
  }
26
26
 
27
- const d4Hint = d4Evidence.find(e => e._severityHint);
27
+ const d4Hint = d4Evidence.find((e) => e._severityHint);
28
28
  if (d4Hint) {
29
- const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
29
+ const hintScore =
30
+ d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
30
31
  maxScore = Math.max(maxScore, hintScore);
31
32
  }
32
33
 
@@ -45,36 +46,45 @@ export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
45
46
  const d3Ev = await scanD3(registryMeta);
46
47
  allEvidence.push(...d3Ev);
47
48
 
48
- const velocityResult = d3Ev.length > 0 ? {
49
- triggered: true,
50
- windowStartISO: d3Ev[0]._windowStartISO || null,
51
- versionsInWindow: d3Ev[0].excerpt || '',
52
- _allVersions: d3Ev[0]._allVersions || [],
53
- } : { triggered: false, versionsInWindow: [], windowStartISO: null };
49
+ const velocityResult =
50
+ d3Ev.length > 0
51
+ ? {
52
+ triggered: true,
53
+ windowStartISO: d3Ev[0]._windowStartISO || null,
54
+ versionsInWindow: d3Ev[0].excerpt || '',
55
+ _allVersions: d3Ev[0]._allVersions || [],
56
+ }
57
+ : { triggered: false, versionsInWindow: [], windowStartISO: null };
54
58
 
55
59
  const d4Ev = await scanD4(registryMeta, velocityResult);
56
60
  allEvidence.push(...d4Ev);
57
61
 
58
- allEvidence.push(...await scanD5(registryMeta));
59
- allEvidence.push(...await scanD6(pkgJson, registryMeta));
62
+ allEvidence.push(...(await scanD5(registryMeta)));
63
+ allEvidence.push(...(await scanD6(pkgJson, registryMeta)));
60
64
 
61
- const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
65
+ const signals = [...new Set(allEvidence.map((e) => e.signal).filter(Boolean))];
62
66
 
63
- if (signals.length === 0) return [];
67
+ if (signals.length === 0) {
68
+ return [];
69
+ }
64
70
 
65
71
  const severity = resolveSeverity(signals, d4Ev);
66
72
 
67
- const cleaned = allEvidence.map(({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest);
68
-
69
- return [{
70
- id: 'MEGALODON',
71
- severity,
72
- title: 'Megalodon CI/CD attack campaign',
73
- description: `${signals.length} signal(s): ${signals.join(', ')}`,
74
- evidence: JSON.stringify({
75
- campaign: 'MEGALODON',
76
- signals,
77
- evidence: cleaned,
78
- }),
79
- }];
73
+ const cleaned = allEvidence.map(
74
+ ({ _windowStartISO, _allVersions, _severityHint, ...rest }) => rest
75
+ );
76
+
77
+ return [
78
+ {
79
+ id: 'MEGALODON',
80
+ severity,
81
+ title: 'Megalodon CI/CD attack campaign',
82
+ description: `${signals.length} signal(s): ${signals.join(', ')}`,
83
+ evidence: JSON.stringify({
84
+ campaign: 'MEGALODON',
85
+ signals,
86
+ evidence: cleaned,
87
+ }),
88
+ },
89
+ ];
80
90
  }
@@ -10,7 +10,9 @@ export async function checkBurstPublish(registryMeta, config = {}) {
10
10
  .filter(([, ts]) => !Number.isNaN(ts))
11
11
  .sort((a, b) => a[1] - b[1]);
12
12
 
13
- if (entries.length === 0) return { triggered: false };
13
+ if (entries.length === 0) {
14
+ return { triggered: false };
15
+ }
14
16
 
15
17
  const windowMs = windowMinutes * 60 * 1000;
16
18
 
@@ -12,7 +12,9 @@ function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
12
12
  .filter(([, ts]) => !Number.isNaN(ts))
13
13
  .sort((a, b) => a[1] - b[1]);
14
14
 
15
- if (entries.length === 0) return null;
15
+ if (entries.length === 0) {
16
+ return null;
17
+ }
16
18
 
17
19
  const windowMs = windowMinutes * 60 * 1000;
18
20
 
@@ -55,17 +57,23 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
55
57
  for (const name of Object.keys(deps)) {
56
58
  if (name.startsWith('@')) {
57
59
  const scope = name.split('/')[0];
58
- if (!scopedDeps[scope]) scopedDeps[scope] = [];
60
+ if (!scopedDeps[scope]) {
61
+ scopedDeps[scope] = [];
62
+ }
59
63
  scopedDeps[scope].push(name);
60
64
  }
61
65
  }
62
66
 
63
- if (Object.keys(scopedDeps).length === 0) return { triggered: false };
67
+ if (Object.keys(scopedDeps).length === 0) {
68
+ return { triggered: false };
69
+ }
64
70
 
65
71
  const results = [];
66
72
 
67
73
  for (const [scope, packages] of Object.entries(scopedDeps)) {
68
- if (packages.length < 2) continue;
74
+ if (packages.length < 2) {
75
+ continue;
76
+ }
69
77
 
70
78
  const burstSiblings = [];
71
79
 
@@ -75,7 +83,9 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
75
83
  try {
76
84
  const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
77
85
  const res = await fetch(url);
78
- if (!res.ok) continue;
86
+ if (!res.ok) {
87
+ continue;
88
+ }
79
89
  const data = await res.json();
80
90
  timeData = data.time || {};
81
91
  siblingCache.set(pkg, timeData);
@@ -91,19 +101,19 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
91
101
  }
92
102
 
93
103
  if (burstSiblings.length >= 2) {
94
- const windows = burstSiblings.map(s => ({
104
+ const windows = burstSiblings.map((s) => ({
95
105
  start: new Date(s.windowStart).getTime(),
96
106
  end: new Date(s.windowEnd).getTime(),
97
107
  }));
98
108
 
99
- const overlapStart = Math.max(...windows.map(w => w.start));
100
- const overlapEnd = Math.min(...windows.map(w => w.end));
109
+ const overlapStart = Math.max(...windows.map((w) => w.start));
110
+ const overlapEnd = Math.min(...windows.map((w) => w.end));
101
111
 
102
112
  if (overlapStart < overlapEnd) {
103
113
  results.push({
104
114
  triggered: true,
105
115
  scope,
106
- siblingPackages: burstSiblings.map(s => s.name),
116
+ siblingPackages: burstSiblings.map((s) => s.name),
107
117
  windowStart: new Date(overlapStart).toISOString(),
108
118
  windowEnd: new Date(overlapEnd).toISOString(),
109
119
  });
@@ -111,6 +121,8 @@ export async function checkSiblingCompromise(pkgJson, config = {}) {
111
121
  }
112
122
  }
113
123
 
114
- if (results.length === 0) return { triggered: false };
124
+ if (results.length === 0) {
125
+ return { triggered: false };
126
+ }
115
127
  return { triggered: true, results };
116
128
  }
@@ -1,24 +1,40 @@
1
- export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
2
- if (!burstWindow?.triggered) return { triggered: false };
1
+ export async function checkSlsaMismatch(
2
+ packageName,
3
+ version,
4
+ burstWindow,
5
+ timeMap = {},
6
+ _config = {}
7
+ ) {
8
+ if (!burstWindow?.triggered) {
9
+ return { triggered: false };
10
+ }
3
11
 
4
12
  const anomalies = [];
5
13
  const publishTime = timeMap?.[version];
6
- if (!publishTime) return { triggered: false };
14
+ if (!publishTime) {
15
+ return { triggered: false };
16
+ }
7
17
 
8
18
  try {
9
19
  const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
10
20
  const res = await fetch(url);
11
- if (!res.ok) return { triggered: false };
21
+ if (!res.ok) {
22
+ return { triggered: false };
23
+ }
12
24
 
13
25
  const data = await res.json();
14
26
  const attestations = data?.attestations || [];
15
- if (attestations.length === 0) return { triggered: false };
27
+ if (attestations.length === 0) {
28
+ return { triggered: false };
29
+ }
16
30
 
17
31
  const publishMs = new Date(publishTime).getTime();
18
- if (Number.isNaN(publishMs)) return { triggered: false };
32
+ if (Number.isNaN(publishMs)) {
33
+ return { triggered: false };
34
+ }
19
35
 
20
36
  // Check if this is the first-ever attested version for this package
21
- const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
37
+ const allVersions = Object.keys(timeMap).filter((v) => v !== 'created' && v !== 'modified');
22
38
  const currentIdx = allVersions.indexOf(version);
23
39
  let prevHadAttestation = false;
24
40
 
@@ -49,7 +65,7 @@ export async function checkSlsaMismatch(packageName, version, burstWindow, timeM
49
65
  const ts = att?.timestamp;
50
66
  if (ts) {
51
67
  const attMs = new Date(ts).getTime();
52
- if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
68
+ if (!Number.isNaN(attMs) && attMs >= publishMs && attMs - publishMs < 60000) {
53
69
  const gapMs = attMs - publishMs;
54
70
  anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
55
71
  }
@@ -57,8 +73,12 @@ export async function checkSlsaMismatch(packageName, version, burstWindow, timeM
57
73
 
58
74
  const builderId = att?.predicate?.runDetails?.builder?.id;
59
75
  if (builderId) {
60
- const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
61
- const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
76
+ const knownPrefixes = [
77
+ 'https://github.com/',
78
+ 'https://gitlab.com/',
79
+ 'https://circleci.com/',
80
+ ];
81
+ const isKnown = knownPrefixes.some((p) => builderId.startsWith(p));
62
82
  if (!isKnown) {
63
83
  anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
64
84
  }
@@ -1,4 +1,4 @@
1
- export async function checkMaintainerAnomaly(registryMeta, config = {}) {
1
+ export async function checkMaintainerAnomaly(registryMeta, _config = {}) {
2
2
  const versions = registryMeta?.versions || {};
3
3
  const timeMap = registryMeta?.time || {};
4
4
 
@@ -10,10 +10,12 @@ export async function checkMaintainerAnomaly(registryMeta, config = {}) {
10
10
  time: new Date(t).getTime(),
11
11
  user: versions[v]?._npmUser?.name,
12
12
  }))
13
- .filter(e => !Number.isNaN(e.time) && e.user)
13
+ .filter((e) => !Number.isNaN(e.time) && e.user)
14
14
  .sort((a, b) => a.time - b.time);
15
15
 
16
- if (sorted.length < 2) return { triggered: false };
16
+ if (sorted.length < 2) {
17
+ return { triggered: false };
18
+ }
17
19
 
18
20
  for (let i = 1; i < sorted.length; i++) {
19
21
  const prev = sorted[i - 1];
@@ -22,19 +24,21 @@ export async function checkMaintainerAnomaly(registryMeta, config = {}) {
22
24
  if (curr.user !== prev.user) {
23
25
  const gapMinutes = (curr.time - prev.time) / (1000 * 60);
24
26
  if (gapMinutes <= 10) {
25
- const newUserVersions = sorted.filter(e => e.user === curr.user);
27
+ const newUserVersions = sorted.filter((e) => e.user === curr.user);
26
28
  if (newUserVersions.length >= 2) {
27
29
  return {
28
30
  triggered: true,
29
- signals: [{
30
- type: 'PUBLISHER_DRIFT_RAPID',
31
- previousPublisher: prev.user,
32
- newPublisher: curr.user,
33
- gapMinutes,
34
- newUserVersionCount: newUserVersions.length,
35
- driftVersion: curr.version,
36
- driftWindowStart: new Date(curr.time).toISOString(),
37
- }],
31
+ signals: [
32
+ {
33
+ type: 'PUBLISHER_DRIFT_RAPID',
34
+ previousPublisher: prev.user,
35
+ newPublisher: curr.user,
36
+ gapMinutes,
37
+ newUserVersionCount: newUserVersions.length,
38
+ driftVersion: curr.version,
39
+ driftWindowStart: new Date(curr.time).toISOString(),
40
+ },
41
+ ],
38
42
  };
39
43
  }
40
44
  }