@lateos/npm-scan 0.16.0 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.dockerignore +20 -20
  2. package/.husky/pre-commit +1 -1
  3. package/CHANGELOG.md +199 -199
  4. package/LICENSING.md +19 -19
  5. package/README.de.md +708 -669
  6. package/README.fr.md +707 -668
  7. package/README.ja.md +704 -665
  8. package/README.md +826 -801
  9. package/README.zh.md +708 -669
  10. package/SECURITY.md +72 -72
  11. package/backend/cra.js +68 -68
  12. package/backend/db/schema.sql +32 -32
  13. package/backend/db.js +88 -88
  14. package/backend/detectors/atk-001-lifecycle.js +17 -17
  15. package/backend/detectors/atk-002-obfusc.js +261 -261
  16. package/backend/detectors/atk-003-creds.js +13 -13
  17. package/backend/detectors/atk-004-persist.js +13 -13
  18. package/backend/detectors/atk-005-exfil.js +13 -13
  19. package/backend/detectors/atk-006-depconf.js +14 -14
  20. package/backend/detectors/atk-007-typosquat.js +34 -34
  21. package/backend/detectors/atk-008-tarball-tamper.js +91 -91
  22. package/backend/detectors/atk-009-dormant-trigger.js +62 -62
  23. package/backend/detectors/atk-010-sandbox-evasion.js +50 -50
  24. package/backend/detectors/atk-011-transitive-prop.js +76 -76
  25. package/backend/detectors/axios-poisoning/d1-version-fingerprint.js +24 -0
  26. package/backend/detectors/axios-poisoning/d2-decoy-dep.js +24 -0
  27. package/backend/detectors/axios-poisoning/d3-postinstall-rat.js +90 -0
  28. package/backend/detectors/axios-poisoning/index.js +94 -0
  29. package/backend/detectors/cve-2026-48710-badhost/codePattern.js +99 -99
  30. package/backend/detectors/cve-2026-48710-badhost/findings.js +105 -105
  31. package/backend/detectors/cve-2026-48710-badhost/index.js +15 -15
  32. package/backend/detectors/cve-2026-48710-badhost/manifest.js +305 -305
  33. package/backend/detectors/cve-2026-48710-badhost/transitive.js +189 -189
  34. package/backend/detectors/hf-impersonation/index.js +396 -396
  35. package/backend/detectors/hf-impersonation/jaro-winkler.js +44 -44
  36. package/backend/detectors/hf-impersonation/known-orgs.js +5 -5
  37. package/backend/detectors/hf-impersonation/simhash.js +46 -46
  38. package/backend/detectors/index.js +75 -38
  39. package/backend/detectors/megalodon/d1-workflow-scan.js +147 -147
  40. package/backend/detectors/megalodon/d2-credential-harvest.js +61 -61
  41. package/backend/detectors/megalodon/d3-publish-velocity.js +67 -67
  42. package/backend/detectors/megalodon/d4-publisher-drift.js +124 -124
  43. package/backend/detectors/megalodon/d5-bot-commit-identity.js +3 -3
  44. package/backend/detectors/megalodon/d6-date-anachronism.js +3 -3
  45. package/backend/detectors/megalodon/index.js +80 -80
  46. package/backend/detectors/megalodon/types.js +9 -9
  47. package/backend/detectors/mini-shai-hulud/d1-burst-publish.js +42 -42
  48. package/backend/detectors/mini-shai-hulud/d2-sibling-compromise.js +116 -116
  49. package/backend/detectors/mini-shai-hulud/d3-slsa-mismatch.js +72 -72
  50. package/backend/detectors/mini-shai-hulud/d4-maintainer-anomaly.js +45 -45
  51. package/backend/detectors/mini-shai-hulud/d5-ioc-check.js +95 -95
  52. package/backend/detectors/mini-shai-hulud/d6-token-exfil.js +38 -38
  53. package/backend/detectors/mini-shai-hulud/index.js +118 -118
  54. package/backend/detectors/mini-shai-hulud/iocs.json +79 -79
  55. package/backend/detectors/msh-supplement/d1-obfuscation.js +18 -0
  56. package/backend/detectors/msh-supplement/d2-persistence.js +47 -0
  57. package/backend/detectors/msh-supplement/d3-geo-killswitch.js +35 -0
  58. package/backend/detectors/msh-supplement/d4-c2-deaddrop.js +33 -0
  59. package/backend/detectors/msh-supplement/index.js +107 -0
  60. package/backend/detectors/tier1-binary-embed.js +219 -0
  61. package/backend/detectors/tier1-infostealer.js +280 -0
  62. package/backend/detectors/tier1-lifecycle-hook.js +176 -0
  63. package/backend/detectors/tier1-metadata-spoof.js +180 -0
  64. package/backend/detectors/tier1-typosquat.js +219 -0
  65. package/backend/detectors/typosquat-vpmdhaj/d1-maintainer.js +77 -0
  66. package/backend/detectors/typosquat-vpmdhaj/d2-preinstall-loader.js +37 -0
  67. package/backend/detectors/typosquat-vpmdhaj/d3-cred-exfil.js +66 -0
  68. package/backend/detectors/typosquat-vpmdhaj/index.js +98 -0
  69. package/backend/fetch.js +175 -175
  70. package/backend/index.js +4 -4
  71. package/backend/license.js +89 -89
  72. package/backend/lockfile.js +379 -379
  73. package/backend/pdf.js +245 -245
  74. package/backend/policy.js +193 -176
  75. package/backend/provenance.js +79 -0
  76. package/backend/report.js +254 -254
  77. package/backend/sbom.js +66 -66
  78. package/backend/siem/cef.js +32 -32
  79. package/backend/siem/ecs.js +40 -40
  80. package/backend/siem/index.js +18 -18
  81. package/backend/siem/qradar.js +56 -56
  82. package/backend/siem/sentinel.js +27 -27
  83. package/backend/vsix-scan/detectors/activation-event-risk.js +116 -116
  84. package/backend/vsix-scan/detectors/burst-publish.js +52 -52
  85. package/backend/vsix-scan/detectors/exfil-pattern.js +88 -88
  86. package/backend/vsix-scan/detectors/known-ioc.js +105 -105
  87. package/backend/vsix-scan/detectors/orphan-commit-fetch.js +69 -69
  88. package/backend/vsix-scan/detectors/publisher-anomaly.js +70 -70
  89. package/backend/vsix-scan/index.js +183 -183
  90. package/backend/vsix-scan/marketplace-client.js +145 -145
  91. package/backend/vsix-scan/vsix-iocs.json +31 -31
  92. package/cli/cli.js +458 -458
  93. package/deploy/helm/npm-scan/Chart.yaml +21 -21
  94. package/deploy/helm/npm-scan/templates/_helpers.tpl +8 -8
  95. package/deploy/helm/npm-scan/templates/api.yaml +93 -93
  96. package/deploy/helm/npm-scan/templates/ingress.yaml +27 -27
  97. package/deploy/helm/npm-scan/templates/postgresql.yaml +66 -66
  98. package/deploy/helm/npm-scan/templates/secrets.yaml +18 -18
  99. package/deploy/helm/npm-scan/templates/worker.yaml +31 -31
  100. package/deploy/helm/npm-scan/values.byoc.yaml +74 -74
  101. package/deploy/helm/npm-scan/values.yaml +102 -102
  102. package/package.json +57 -57
  103. package/scripts/download-corpus.js +30 -30
  104. package/scripts/gen-mal-corpus.js +34 -34
  105. package/scripts/generate-campaign-fixtures.js +170 -0
  106. package/src/config/top-5000.json +87 -0
  107. package/test/fixtures/lockfiles/npm-lock.json +68 -68
  108. package/test/fixtures/lockfiles/pnpm-lock.yaml +117 -117
  109. package/test/fixtures/lockfiles/yarn.lock +103 -103
  110. package/test/fixtures/mock-data.js +69 -69
@@ -1,80 +1,80 @@
1
- import { MegalodonSignal } from './types.js';
2
- import { scan as scanD1 } from './d1-workflow-scan.js';
3
- import { scan as scanD2 } from './d2-credential-harvest.js';
4
- import { scan as scanD3 } from './d3-publish-velocity.js';
5
- import { scan as scanD4 } from './d4-publisher-drift.js';
6
- import { scan as scanD5 } from './d5-bot-commit-identity.js';
7
- import { scan as scanD6 } from './d6-date-anachronism.js';
8
-
9
- const SIGNAL_SEVERITY = {
10
- [MegalodonSignal.WORKFLOW_C2_EXFIL]: 5,
11
- [MegalodonSignal.WORKFLOW_DECODE_CHAIN]: 4,
12
- [MegalodonSignal.PUBLISH_VELOCITY]: 4,
13
- [MegalodonSignal.PUBLISHER_DRIFT]: 4,
14
- [MegalodonSignal.CREDENTIAL_HARVEST]: 3,
15
- [MegalodonSignal.BOT_COMMIT_IDENTITY]: 2,
16
- [MegalodonSignal.DATE_ANACHRONISM]: 2,
17
- };
18
-
19
- const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical', 'critical'];
20
-
21
- function resolveSeverity(signals, d4Evidence) {
22
- let maxScore = 0;
23
- for (const s of signals) {
24
- maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
25
- }
26
-
27
- const d4Hint = d4Evidence.find(e => e._severityHint);
28
- if (d4Hint) {
29
- const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
30
- maxScore = Math.max(maxScore, hintScore);
31
- }
32
-
33
- return SEVERITY_LABELS[maxScore] || 'none';
34
- }
35
-
36
- export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
37
- const allEvidence = [];
38
-
39
- const d1Ev = await scanD1(allFiles);
40
- allEvidence.push(...d1Ev);
41
-
42
- const d2Ev = await scanD2(allFiles);
43
- allEvidence.push(...d2Ev);
44
-
45
- const d3Ev = await scanD3(registryMeta);
46
- allEvidence.push(...d3Ev);
47
-
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 };
54
-
55
- const d4Ev = await scanD4(registryMeta, velocityResult);
56
- allEvidence.push(...d4Ev);
57
-
58
- allEvidence.push(...await scanD5(registryMeta));
59
- allEvidence.push(...await scanD6(pkgJson, registryMeta));
60
-
61
- const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
62
-
63
- if (signals.length === 0) return [];
64
-
65
- const severity = resolveSeverity(signals, d4Ev);
66
-
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
- }];
80
- }
1
+ import { MegalodonSignal } from './types.js';
2
+ import { scan as scanD1 } from './d1-workflow-scan.js';
3
+ import { scan as scanD2 } from './d2-credential-harvest.js';
4
+ import { scan as scanD3 } from './d3-publish-velocity.js';
5
+ import { scan as scanD4 } from './d4-publisher-drift.js';
6
+ import { scan as scanD5 } from './d5-bot-commit-identity.js';
7
+ import { scan as scanD6 } from './d6-date-anachronism.js';
8
+
9
+ const SIGNAL_SEVERITY = {
10
+ [MegalodonSignal.WORKFLOW_C2_EXFIL]: 5,
11
+ [MegalodonSignal.WORKFLOW_DECODE_CHAIN]: 4,
12
+ [MegalodonSignal.PUBLISH_VELOCITY]: 4,
13
+ [MegalodonSignal.PUBLISHER_DRIFT]: 4,
14
+ [MegalodonSignal.CREDENTIAL_HARVEST]: 3,
15
+ [MegalodonSignal.BOT_COMMIT_IDENTITY]: 2,
16
+ [MegalodonSignal.DATE_ANACHRONISM]: 2,
17
+ };
18
+
19
+ const SEVERITY_LABELS = ['none', 'low', 'medium', 'high', 'critical', 'critical'];
20
+
21
+ function resolveSeverity(signals, d4Evidence) {
22
+ let maxScore = 0;
23
+ for (const s of signals) {
24
+ maxScore = Math.max(maxScore, SIGNAL_SEVERITY[s] || 0);
25
+ }
26
+
27
+ const d4Hint = d4Evidence.find(e => e._severityHint);
28
+ if (d4Hint) {
29
+ const hintScore = d4Hint._severityHint === 'HIGH' ? 4 : d4Hint._severityHint === 'MEDIUM' ? 3 : 0;
30
+ maxScore = Math.max(maxScore, hintScore);
31
+ }
32
+
33
+ return SEVERITY_LABELS[maxScore] || 'none';
34
+ }
35
+
36
+ export async function scanAll(pkgJson, allFiles = [], registryMeta = {}) {
37
+ const allEvidence = [];
38
+
39
+ const d1Ev = await scanD1(allFiles);
40
+ allEvidence.push(...d1Ev);
41
+
42
+ const d2Ev = await scanD2(allFiles);
43
+ allEvidence.push(...d2Ev);
44
+
45
+ const d3Ev = await scanD3(registryMeta);
46
+ allEvidence.push(...d3Ev);
47
+
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 };
54
+
55
+ const d4Ev = await scanD4(registryMeta, velocityResult);
56
+ allEvidence.push(...d4Ev);
57
+
58
+ allEvidence.push(...await scanD5(registryMeta));
59
+ allEvidence.push(...await scanD6(pkgJson, registryMeta));
60
+
61
+ const signals = [...new Set(allEvidence.map(e => e.signal).filter(Boolean))];
62
+
63
+ if (signals.length === 0) return [];
64
+
65
+ const severity = resolveSeverity(signals, d4Ev);
66
+
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
+ }];
80
+ }
@@ -1,9 +1,9 @@
1
- export const MegalodonSignal = Object.freeze({
2
- WORKFLOW_C2_EXFIL: 'D1_WORKFLOW_C2_EXFIL',
3
- WORKFLOW_DECODE_CHAIN: 'D1_WORKFLOW_DECODE_CHAIN',
4
- CREDENTIAL_HARVEST: 'D2_CREDENTIAL_HARVEST',
5
- PUBLISH_VELOCITY: 'D3_PUBLISH_VELOCITY',
6
- PUBLISHER_DRIFT: 'D4_PUBLISHER_DRIFT',
7
- BOT_COMMIT_IDENTITY: 'D5_BOT_COMMIT_IDENTITY',
8
- DATE_ANACHRONISM: 'D6_DATE_ANACHRONISM',
9
- });
1
+ export const MegalodonSignal = Object.freeze({
2
+ WORKFLOW_C2_EXFIL: 'D1_WORKFLOW_C2_EXFIL',
3
+ WORKFLOW_DECODE_CHAIN: 'D1_WORKFLOW_DECODE_CHAIN',
4
+ CREDENTIAL_HARVEST: 'D2_CREDENTIAL_HARVEST',
5
+ PUBLISH_VELOCITY: 'D3_PUBLISH_VELOCITY',
6
+ PUBLISHER_DRIFT: 'D4_PUBLISHER_DRIFT',
7
+ BOT_COMMIT_IDENTITY: 'D5_BOT_COMMIT_IDENTITY',
8
+ DATE_ANACHRONISM: 'D6_DATE_ANACHRONISM',
9
+ });
@@ -1,42 +1,42 @@
1
- export async function checkBurstPublish(registryMeta, config = {}) {
2
- const windowMinutes = config.burstWindowMinutes ?? 30;
3
- const threshold = config.burstVersionThreshold ?? 3;
4
-
5
- const times = registryMeta?.time || {};
6
- const entries = Object.entries(times)
7
- .filter(([v]) => v !== 'created' && v !== 'modified')
8
- .filter(([, t]) => t)
9
- .map(([v, t]) => [v, new Date(t).getTime()])
10
- .filter(([, ts]) => !Number.isNaN(ts))
11
- .sort((a, b) => a[1] - b[1]);
12
-
13
- if (entries.length === 0) return { triggered: false };
14
-
15
- const windowMs = windowMinutes * 60 * 1000;
16
-
17
- for (let i = 0; i < entries.length; i++) {
18
- const windowStart = entries[i][1];
19
- const windowEnd = windowStart + windowMs;
20
- const inWindow = [];
21
-
22
- for (let j = i; j < entries.length; j++) {
23
- if (entries[j][1] <= windowEnd) {
24
- inWindow.push(entries[j][0]);
25
- } else {
26
- break;
27
- }
28
- }
29
-
30
- if (inWindow.length >= threshold) {
31
- return {
32
- triggered: true,
33
- windowStart: new Date(windowStart).toISOString(),
34
- windowEnd: new Date(windowEnd).toISOString(),
35
- versionCount: inWindow.length,
36
- versions: inWindow,
37
- };
38
- }
39
- }
40
-
41
- return { triggered: false };
42
- }
1
+ export async function checkBurstPublish(registryMeta, config = {}) {
2
+ const windowMinutes = config.burstWindowMinutes ?? 30;
3
+ const threshold = config.burstVersionThreshold ?? 3;
4
+
5
+ const times = registryMeta?.time || {};
6
+ const entries = Object.entries(times)
7
+ .filter(([v]) => v !== 'created' && v !== 'modified')
8
+ .filter(([, t]) => t)
9
+ .map(([v, t]) => [v, new Date(t).getTime()])
10
+ .filter(([, ts]) => !Number.isNaN(ts))
11
+ .sort((a, b) => a[1] - b[1]);
12
+
13
+ if (entries.length === 0) return { triggered: false };
14
+
15
+ const windowMs = windowMinutes * 60 * 1000;
16
+
17
+ for (let i = 0; i < entries.length; i++) {
18
+ const windowStart = entries[i][1];
19
+ const windowEnd = windowStart + windowMs;
20
+ const inWindow = [];
21
+
22
+ for (let j = i; j < entries.length; j++) {
23
+ if (entries[j][1] <= windowEnd) {
24
+ inWindow.push(entries[j][0]);
25
+ } else {
26
+ break;
27
+ }
28
+ }
29
+
30
+ if (inWindow.length >= threshold) {
31
+ return {
32
+ triggered: true,
33
+ windowStart: new Date(windowStart).toISOString(),
34
+ windowEnd: new Date(windowEnd).toISOString(),
35
+ versionCount: inWindow.length,
36
+ versions: inWindow,
37
+ };
38
+ }
39
+ }
40
+
41
+ return { triggered: false };
42
+ }
@@ -1,116 +1,116 @@
1
- const siblingCache = new Map();
2
-
3
- export function clearSiblingCache() {
4
- siblingCache.clear();
5
- }
6
-
7
- function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
8
- const entries = Object.entries(timeMap)
9
- .filter(([v]) => v !== 'created' && v !== 'modified')
10
- .filter(([, t]) => t)
11
- .map(([v, t]) => [v, new Date(t).getTime()])
12
- .filter(([, ts]) => !Number.isNaN(ts))
13
- .sort((a, b) => a[1] - b[1]);
14
-
15
- if (entries.length === 0) return null;
16
-
17
- const windowMs = windowMinutes * 60 * 1000;
18
-
19
- for (let i = 0; i < entries.length; i++) {
20
- const wStart = entries[i][1];
21
- const wEnd = wStart + windowMs;
22
- const inWindow = [];
23
-
24
- for (let j = i; j < entries.length; j++) {
25
- if (entries[j][1] <= wEnd) {
26
- inWindow.push(entries[j][0]);
27
- } else {
28
- break;
29
- }
30
- }
31
-
32
- if (inWindow.length >= threshold) {
33
- return {
34
- windowStart: new Date(wStart).toISOString(),
35
- windowEnd: new Date(wEnd).toISOString(),
36
- versionCount: inWindow.length,
37
- };
38
- }
39
- }
40
-
41
- return null;
42
- }
43
-
44
- export async function checkSiblingCompromise(pkgJson, config = {}) {
45
- const windowMinutes = config.burstWindowMinutes ?? 30;
46
- const threshold = config.burstVersionThreshold ?? 3;
47
-
48
- const deps = {
49
- ...pkgJson.dependencies,
50
- ...pkgJson.devDependencies,
51
- ...pkgJson.peerDependencies,
52
- };
53
-
54
- const scopedDeps = {};
55
- for (const name of Object.keys(deps)) {
56
- if (name.startsWith('@')) {
57
- const scope = name.split('/')[0];
58
- if (!scopedDeps[scope]) scopedDeps[scope] = [];
59
- scopedDeps[scope].push(name);
60
- }
61
- }
62
-
63
- if (Object.keys(scopedDeps).length === 0) return { triggered: false };
64
-
65
- const results = [];
66
-
67
- for (const [scope, packages] of Object.entries(scopedDeps)) {
68
- if (packages.length < 2) continue;
69
-
70
- const burstSiblings = [];
71
-
72
- for (const pkg of packages) {
73
- let timeData = siblingCache.get(pkg);
74
- if (!timeData) {
75
- try {
76
- const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
77
- const res = await fetch(url);
78
- if (!res.ok) continue;
79
- const data = await res.json();
80
- timeData = data.time || {};
81
- siblingCache.set(pkg, timeData);
82
- } catch {
83
- continue;
84
- }
85
- }
86
-
87
- const burstInfo = checkBurstOnTimeMap(timeData, windowMinutes, threshold);
88
- if (burstInfo) {
89
- burstSiblings.push({ name: pkg, ...burstInfo });
90
- }
91
- }
92
-
93
- if (burstSiblings.length >= 2) {
94
- const windows = burstSiblings.map(s => ({
95
- start: new Date(s.windowStart).getTime(),
96
- end: new Date(s.windowEnd).getTime(),
97
- }));
98
-
99
- const overlapStart = Math.max(...windows.map(w => w.start));
100
- const overlapEnd = Math.min(...windows.map(w => w.end));
101
-
102
- if (overlapStart < overlapEnd) {
103
- results.push({
104
- triggered: true,
105
- scope,
106
- siblingPackages: burstSiblings.map(s => s.name),
107
- windowStart: new Date(overlapStart).toISOString(),
108
- windowEnd: new Date(overlapEnd).toISOString(),
109
- });
110
- }
111
- }
112
- }
113
-
114
- if (results.length === 0) return { triggered: false };
115
- return { triggered: true, results };
116
- }
1
+ const siblingCache = new Map();
2
+
3
+ export function clearSiblingCache() {
4
+ siblingCache.clear();
5
+ }
6
+
7
+ function checkBurstOnTimeMap(timeMap, windowMinutes, threshold) {
8
+ const entries = Object.entries(timeMap)
9
+ .filter(([v]) => v !== 'created' && v !== 'modified')
10
+ .filter(([, t]) => t)
11
+ .map(([v, t]) => [v, new Date(t).getTime()])
12
+ .filter(([, ts]) => !Number.isNaN(ts))
13
+ .sort((a, b) => a[1] - b[1]);
14
+
15
+ if (entries.length === 0) return null;
16
+
17
+ const windowMs = windowMinutes * 60 * 1000;
18
+
19
+ for (let i = 0; i < entries.length; i++) {
20
+ const wStart = entries[i][1];
21
+ const wEnd = wStart + windowMs;
22
+ const inWindow = [];
23
+
24
+ for (let j = i; j < entries.length; j++) {
25
+ if (entries[j][1] <= wEnd) {
26
+ inWindow.push(entries[j][0]);
27
+ } else {
28
+ break;
29
+ }
30
+ }
31
+
32
+ if (inWindow.length >= threshold) {
33
+ return {
34
+ windowStart: new Date(wStart).toISOString(),
35
+ windowEnd: new Date(wEnd).toISOString(),
36
+ versionCount: inWindow.length,
37
+ };
38
+ }
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ export async function checkSiblingCompromise(pkgJson, config = {}) {
45
+ const windowMinutes = config.burstWindowMinutes ?? 30;
46
+ const threshold = config.burstVersionThreshold ?? 3;
47
+
48
+ const deps = {
49
+ ...pkgJson.dependencies,
50
+ ...pkgJson.devDependencies,
51
+ ...pkgJson.peerDependencies,
52
+ };
53
+
54
+ const scopedDeps = {};
55
+ for (const name of Object.keys(deps)) {
56
+ if (name.startsWith('@')) {
57
+ const scope = name.split('/')[0];
58
+ if (!scopedDeps[scope]) scopedDeps[scope] = [];
59
+ scopedDeps[scope].push(name);
60
+ }
61
+ }
62
+
63
+ if (Object.keys(scopedDeps).length === 0) return { triggered: false };
64
+
65
+ const results = [];
66
+
67
+ for (const [scope, packages] of Object.entries(scopedDeps)) {
68
+ if (packages.length < 2) continue;
69
+
70
+ const burstSiblings = [];
71
+
72
+ for (const pkg of packages) {
73
+ let timeData = siblingCache.get(pkg);
74
+ if (!timeData) {
75
+ try {
76
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}`;
77
+ const res = await fetch(url);
78
+ if (!res.ok) continue;
79
+ const data = await res.json();
80
+ timeData = data.time || {};
81
+ siblingCache.set(pkg, timeData);
82
+ } catch {
83
+ continue;
84
+ }
85
+ }
86
+
87
+ const burstInfo = checkBurstOnTimeMap(timeData, windowMinutes, threshold);
88
+ if (burstInfo) {
89
+ burstSiblings.push({ name: pkg, ...burstInfo });
90
+ }
91
+ }
92
+
93
+ if (burstSiblings.length >= 2) {
94
+ const windows = burstSiblings.map(s => ({
95
+ start: new Date(s.windowStart).getTime(),
96
+ end: new Date(s.windowEnd).getTime(),
97
+ }));
98
+
99
+ const overlapStart = Math.max(...windows.map(w => w.start));
100
+ const overlapEnd = Math.min(...windows.map(w => w.end));
101
+
102
+ if (overlapStart < overlapEnd) {
103
+ results.push({
104
+ triggered: true,
105
+ scope,
106
+ siblingPackages: burstSiblings.map(s => s.name),
107
+ windowStart: new Date(overlapStart).toISOString(),
108
+ windowEnd: new Date(overlapEnd).toISOString(),
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ if (results.length === 0) return { triggered: false };
115
+ return { triggered: true, results };
116
+ }
@@ -1,72 +1,72 @@
1
- export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
2
- if (!burstWindow?.triggered) return { triggered: false };
3
-
4
- const anomalies = [];
5
- const publishTime = timeMap?.[version];
6
- if (!publishTime) return { triggered: false };
7
-
8
- try {
9
- const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
10
- const res = await fetch(url);
11
- if (!res.ok) return { triggered: false };
12
-
13
- const data = await res.json();
14
- const attestations = data?.attestations || [];
15
- if (attestations.length === 0) return { triggered: false };
16
-
17
- const publishMs = new Date(publishTime).getTime();
18
- if (Number.isNaN(publishMs)) return { triggered: false };
19
-
20
- // Check if this is the first-ever attested version for this package
21
- const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
22
- const currentIdx = allVersions.indexOf(version);
23
- let prevHadAttestation = false;
24
-
25
- if (currentIdx > 0) {
26
- const priorVersions = allVersions.slice(0, currentIdx).slice(-2);
27
- for (const pv of priorVersions) {
28
- try {
29
- const purl = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(pv)}`;
30
- const pres = await fetch(purl);
31
- if (pres.ok) {
32
- const pdata = await pres.json();
33
- if (pdata?.attestations?.length > 0) {
34
- prevHadAttestation = true;
35
- break;
36
- }
37
- }
38
- } catch {
39
- // skip prior version check
40
- }
41
- }
42
-
43
- if (!prevHadAttestation && priorVersions.length > 0) {
44
- anomalies.push(`First-ever SLSA attestation for ${packageName}, published in burst window`);
45
- }
46
- }
47
-
48
- for (const att of attestations) {
49
- const ts = att?.timestamp;
50
- if (ts) {
51
- const attMs = new Date(ts).getTime();
52
- if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
53
- const gapMs = attMs - publishMs;
54
- anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
55
- }
56
- }
57
-
58
- const builderId = att?.predicate?.runDetails?.builder?.id;
59
- if (builderId) {
60
- const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
61
- const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
62
- if (!isKnown) {
63
- anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
64
- }
65
- }
66
- }
67
- } catch {
68
- return { triggered: false };
69
- }
70
-
71
- return { triggered: anomalies.length > 0, anomalies };
72
- }
1
+ export async function checkSlsaMismatch(packageName, version, burstWindow, timeMap = {}, config = {}) {
2
+ if (!burstWindow?.triggered) return { triggered: false };
3
+
4
+ const anomalies = [];
5
+ const publishTime = timeMap?.[version];
6
+ if (!publishTime) return { triggered: false };
7
+
8
+ try {
9
+ const url = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`;
10
+ const res = await fetch(url);
11
+ if (!res.ok) return { triggered: false };
12
+
13
+ const data = await res.json();
14
+ const attestations = data?.attestations || [];
15
+ if (attestations.length === 0) return { triggered: false };
16
+
17
+ const publishMs = new Date(publishTime).getTime();
18
+ if (Number.isNaN(publishMs)) return { triggered: false };
19
+
20
+ // Check if this is the first-ever attested version for this package
21
+ const allVersions = Object.keys(timeMap).filter(v => v !== 'created' && v !== 'modified');
22
+ const currentIdx = allVersions.indexOf(version);
23
+ let prevHadAttestation = false;
24
+
25
+ if (currentIdx > 0) {
26
+ const priorVersions = allVersions.slice(0, currentIdx).slice(-2);
27
+ for (const pv of priorVersions) {
28
+ try {
29
+ const purl = `https://registry.npmjs.org/-/npm/v1/attestations/${encodeURIComponent(packageName)}/${encodeURIComponent(pv)}`;
30
+ const pres = await fetch(purl);
31
+ if (pres.ok) {
32
+ const pdata = await pres.json();
33
+ if (pdata?.attestations?.length > 0) {
34
+ prevHadAttestation = true;
35
+ break;
36
+ }
37
+ }
38
+ } catch {
39
+ // skip prior version check
40
+ }
41
+ }
42
+
43
+ if (!prevHadAttestation && priorVersions.length > 0) {
44
+ anomalies.push(`First-ever SLSA attestation for ${packageName}, published in burst window`);
45
+ }
46
+ }
47
+
48
+ for (const att of attestations) {
49
+ const ts = att?.timestamp;
50
+ if (ts) {
51
+ const attMs = new Date(ts).getTime();
52
+ if (!Number.isNaN(attMs) && attMs >= publishMs && (attMs - publishMs) < 60000) {
53
+ const gapMs = attMs - publishMs;
54
+ anomalies.push(`Sub-60s attestation gap for ${version}: ${gapMs}ms`);
55
+ }
56
+ }
57
+
58
+ const builderId = att?.predicate?.runDetails?.builder?.id;
59
+ if (builderId) {
60
+ const knownPrefixes = ['https://github.com/', 'https://gitlab.com/', 'https://circleci.com/'];
61
+ const isKnown = knownPrefixes.some(p => builderId.startsWith(p));
62
+ if (!isKnown) {
63
+ anomalies.push(`Unrecognized builder ID for ${version}: ${builderId}`);
64
+ }
65
+ }
66
+ }
67
+ } catch {
68
+ return { triggered: false };
69
+ }
70
+
71
+ return { triggered: anomalies.length > 0, anomalies };
72
+ }