cryptoserve 0.2.1 → 0.3.2

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.
@@ -20,6 +20,7 @@
20
20
  * cryptoserve vault init|set|get|list|delete|run|import|export
21
21
  * cryptoserve login [--server URL]
22
22
  * cryptoserve status
23
+ * cryptoserve census [--format json|html] [--output file] [--no-cache] [--verbose]
23
24
  */
24
25
 
25
26
  import { readFileSync, writeFileSync } from 'node:fs';
@@ -40,7 +41,7 @@ const OPTIONS_WITH_VALUES = new Set([
40
41
 
41
42
  const KNOWN_FLAGS = new Set([
42
43
  '--insecure-storage', '--verbose', '--binary', '--fail-on-weak',
43
- '--help', '--version',
44
+ '--help', '--version', '--no-cache',
44
45
  ]);
45
46
 
46
47
  function getFlag(args, name) {
@@ -91,6 +92,9 @@ async function cmdHelp() {
91
92
  console.log(` ${info('cbom [path] [--format F] [--output O]')} Generate Crypto Bill of Materials`);
92
93
  console.log(` ${info('gate [path] [--max-risk R]')} CI/CD gate (exit 0=pass, 1=fail)`);
93
94
  console.log();
95
+ console.log(` ${bold('Research')}`);
96
+ console.log(` ${info('census [--format json|html]')} Global crypto census (11 ecosystems + NVD)`);
97
+ console.log();
94
98
  console.log(` ${bold('Encryption')}`);
95
99
  console.log(` ${info('encrypt "text" [--context C]')} Encrypt with context-aware algorithm selection`);
96
100
  console.log(` ${info('encrypt "text" [--password P]')} Encrypt text (interactive password if omitted)`);
@@ -988,6 +992,59 @@ function timeSince(date) {
988
992
  return `${days}d ago`;
989
993
  }
990
994
 
995
+ // ---------------------------------------------------------------------------
996
+ // Census — global crypto adoption survey
997
+ // ---------------------------------------------------------------------------
998
+
999
+ async function cmdCensus(args) {
1000
+ const {
1001
+ compactHeader, section, labelValue, tableHeader, tableRow,
1002
+ warning, info, dim, bold, divider, progressBar,
1003
+ } = await import('../lib/cli-style.mjs');
1004
+
1005
+ const format = getOption(args, '--format', 'text');
1006
+ const output = getOption(args, '--output', null);
1007
+ const verbose = getFlag(args, '--verbose');
1008
+ const noCache = getFlag(args, '--no-cache');
1009
+
1010
+ const { runCensus } = await import('../lib/census/index.mjs');
1011
+
1012
+ if (format === 'text') {
1013
+ console.log(compactHeader('census'));
1014
+ console.log(dim(' Collecting data from 11 ecosystems + NVD + GitHub...'));
1015
+ console.log(dim(' This may take 90-120 seconds on first run.\n'));
1016
+ }
1017
+
1018
+ const data = await runCensus({ verbose, noCache });
1019
+
1020
+ if (format === 'json') {
1021
+ const json = JSON.stringify(data, null, 2);
1022
+ if (output) {
1023
+ writeFileSync(resolve(output), json);
1024
+ console.log(`Census data written to ${output}`);
1025
+ } else {
1026
+ console.log(json);
1027
+ }
1028
+ return;
1029
+ }
1030
+
1031
+ if (format === 'html') {
1032
+ const { generateHtml } = await import('../lib/census/report-html.mjs');
1033
+ const html = generateHtml(data);
1034
+ const outFile = output || 'crypto-census.html';
1035
+ writeFileSync(resolve(outFile), html);
1036
+ console.log(`HTML report written to ${outFile}`);
1037
+ return;
1038
+ }
1039
+
1040
+ // Default: terminal report
1041
+ const { renderTerminal } = await import('../lib/census/report-terminal.mjs');
1042
+ renderTerminal(data, {
1043
+ compactHeader, section, labelValue, tableHeader, tableRow,
1044
+ warning, info, dim, bold, divider, progressBar,
1045
+ });
1046
+ }
1047
+
991
1048
  // ---------------------------------------------------------------------------
992
1049
  // Main router
993
1050
  // ---------------------------------------------------------------------------
@@ -1050,6 +1107,9 @@ try {
1050
1107
  case 'status':
1051
1108
  await cmdStatus();
1052
1109
  break;
1110
+ case 'census':
1111
+ await cmdCensus(commandArgs);
1112
+ break;
1053
1113
  default:
1054
1114
  console.error(`Unknown command: ${command}\nRun "cryptoserve help" for usage.`);
1055
1115
  process.exit(1);
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Aggregate raw census data into headline metrics.
3
+ *
4
+ * Supports all 11 ecosystems: npm, PyPI, Go, Maven, crates.io, Packagist, NuGet,
5
+ * RubyGems, Hex (Elixir), pub.dev (Dart), and CocoaPods (Swift/ObjC).
6
+ * Includes project-level transparency stats when available.
7
+ */
8
+
9
+ import { TIERS, getCatalogSize } from './package-catalog.mjs';
10
+
11
+ // NIST Post-Quantum Cryptography deadlines
12
+ const NIST_2030 = new Date('2030-01-01T00:00:00Z');
13
+ const NIST_2035 = new Date('2035-01-01T00:00:00Z');
14
+
15
+ const ECOSYSTEM_IDS = ['npm', 'pypi', 'go', 'maven', 'crates', 'packagist', 'nuget', 'rubygems', 'hex', 'pub', 'cocoapods'];
16
+
17
+ /**
18
+ * Sum downloads for a given tier from a packages array.
19
+ */
20
+ function sumByTier(packages, tier) {
21
+ return packages
22
+ .filter(p => p.tier === tier)
23
+ .reduce((sum, p) => sum + p.downloads, 0);
24
+ }
25
+
26
+ /**
27
+ * Get top N packages sorted by downloads descending.
28
+ */
29
+ function topPackages(packages, n = 10) {
30
+ return [...packages]
31
+ .sort((a, b) => b.downloads - a.downloads)
32
+ .slice(0, n);
33
+ }
34
+
35
+ /**
36
+ * Calculate days remaining until a target date.
37
+ */
38
+ function daysUntil(target) {
39
+ const now = new Date();
40
+ const diff = target.getTime() - now.getTime();
41
+ return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
42
+ }
43
+
44
+ /**
45
+ * Format days as "X yrs, Y days".
46
+ */
47
+ function formatDaysRemaining(totalDays) {
48
+ const years = Math.floor(totalDays / 365);
49
+ const days = totalDays % 365;
50
+ if (years === 0) return `${days} days`;
51
+ return `${years} yr${years !== 1 ? 's' : ''}, ${days} days`;
52
+ }
53
+
54
+ /**
55
+ * Format a large number with suffix (M, K).
56
+ */
57
+ export function formatNumber(n) {
58
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
59
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
60
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
61
+ return String(n);
62
+ }
63
+
64
+ /**
65
+ * Build a per-ecosystem breakdown from a packages array.
66
+ */
67
+ function buildEcosystemBreakdown(pkgs, period) {
68
+ const weak = sumByTier(pkgs, TIERS.WEAK);
69
+ const modern = sumByTier(pkgs, TIERS.MODERN);
70
+ const pqc = sumByTier(pkgs, TIERS.PQC);
71
+ return {
72
+ weak,
73
+ modern,
74
+ pqc,
75
+ total: weak + modern + pqc,
76
+ topPackages: topPackages(pkgs, 15),
77
+ period,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Aggregate all census data into headline metrics.
83
+ *
84
+ * @param {Object} data
85
+ * @param {Object} data.npm - Result from collectNpmDownloads
86
+ * @param {Object} data.pypi - Result from collectPypiDownloads
87
+ * @param {Object} [data.go] - Result from collectGoDownloads
88
+ * @param {Object} [data.maven] - Result from collectMavenDownloads
89
+ * @param {Object} [data.crates] - Result from collectCratesDownloads
90
+ * @param {Object} [data.packagist] - Result from collectPackagistDownloads
91
+ * @param {Object} [data.nuget] - Result from collectNugetDownloads
92
+ * @param {Object} [data.rubygems] - Result from collectRubygemsDownloads
93
+ * @param {Object} [data.hex] - Result from collectHexDownloads
94
+ * @param {Object} [data.pub] - Result from collectPubDownloads
95
+ * @param {Object} [data.cocoapods] - Result from collectCocoapodsDownloads
96
+ * @param {Object} [data.nvd] - Result from collectNvdCves
97
+ * @param {Object} [data.github] - Result from collectGithubAdvisories
98
+ * @param {Object} [data.projectDeps] - Result from collectProjectDeps
99
+ * @returns {Object} Aggregated metrics matching CensusData type
100
+ */
101
+ export function aggregate(data) {
102
+ // Gather all package arrays
103
+ const npmPkgs = data.npm?.packages || [];
104
+ const pypiPkgs = data.pypi?.packages || [];
105
+ const goPkgs = data.go?.packages || [];
106
+ const mavenPkgs = data.maven?.packages || [];
107
+ const cratesPkgs = data.crates?.packages || [];
108
+ const packagistPkgs = data.packagist?.packages || [];
109
+ const nugetPkgs = data.nuget?.packages || [];
110
+ const rubygemsPkgs = data.rubygems?.packages || [];
111
+ const hexPkgs = data.hex?.packages || [];
112
+ const pubPkgs = data.pub?.packages || [];
113
+ const cocoapodsPkgs = data.cocoapods?.packages || [];
114
+ const allPkgs = [...npmPkgs, ...pypiPkgs, ...goPkgs, ...mavenPkgs, ...cratesPkgs, ...packagistPkgs, ...nugetPkgs, ...rubygemsPkgs, ...hexPkgs, ...pubPkgs, ...cocoapodsPkgs];
115
+
116
+ // Download totals by tier
117
+ const totalWeakDownloads = sumByTier(allPkgs, TIERS.WEAK);
118
+ const totalModernDownloads = sumByTier(allPkgs, TIERS.MODERN);
119
+ const totalPqcDownloads = sumByTier(allPkgs, TIERS.PQC);
120
+ const totalDownloads = totalWeakDownloads + totalModernDownloads + totalPqcDownloads;
121
+
122
+ // Percentages
123
+ const weakPercentage = totalDownloads > 0
124
+ ? Math.round((totalWeakDownloads / totalDownloads) * 1000) / 10
125
+ : 0;
126
+ const modernPercentage = totalDownloads > 0
127
+ ? Math.round((totalModernDownloads / totalDownloads) * 1000) / 10
128
+ : 0;
129
+ const pqcPercentage = totalDownloads > 0
130
+ ? Math.round((totalPqcDownloads / totalDownloads) * 1000) / 10
131
+ : 0;
132
+
133
+ // The headline ratio
134
+ const weakToPqcRatio = totalPqcDownloads > 0
135
+ ? Math.round(totalWeakDownloads / totalPqcDownloads)
136
+ : null;
137
+
138
+ // Per-ecosystem breakdowns
139
+ const npm = buildEcosystemBreakdown(npmPkgs, data.npm?.period);
140
+ const pypi = buildEcosystemBreakdown(pypiPkgs, data.pypi?.period);
141
+ const go = buildEcosystemBreakdown(goPkgs, data.go?.period);
142
+ const maven = buildEcosystemBreakdown(mavenPkgs, data.maven?.period);
143
+ const crates = buildEcosystemBreakdown(cratesPkgs, data.crates?.period);
144
+ const packagist = buildEcosystemBreakdown(packagistPkgs, data.packagist?.period);
145
+ const nuget = buildEcosystemBreakdown(nugetPkgs, data.nuget?.period);
146
+ const rubygems = buildEcosystemBreakdown(rubygemsPkgs, data.rubygems?.period);
147
+ const hex = buildEcosystemBreakdown(hexPkgs, data.hex?.period);
148
+ const pub = buildEcosystemBreakdown(pubPkgs, data.pub?.period);
149
+ const cocoapods = buildEcosystemBreakdown(cocoapodsPkgs, data.cocoapods?.period);
150
+
151
+ // CVE totals
152
+ const nvdCves = data.nvd?.cves || [];
153
+ const totalCryptoCves = nvdCves.reduce((sum, c) => sum + c.totalCount, 0);
154
+
155
+ // GitHub advisories
156
+ const ghAdvisories = data.github?.advisories || [];
157
+ const totalAdvisories = ghAdvisories.reduce((sum, a) => sum + a.count, 0);
158
+
159
+ // Merge severity counts across CWEs
160
+ const advisorySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
161
+ for (const adv of ghAdvisories) {
162
+ for (const [sev, count] of Object.entries(adv.bySeverity || {})) {
163
+ advisorySeverity[sev] = (advisorySeverity[sev] || 0) + count;
164
+ }
165
+ }
166
+
167
+ // NIST deadlines
168
+ const nistDeadline2030Days = daysUntil(NIST_2030);
169
+ const nistDeadline2035Days = daysUntil(NIST_2035);
170
+
171
+ // Project-level stats (if collected)
172
+ const projectDeps = data.projectDeps;
173
+ const projectStats = projectDeps?.stats || null;
174
+
175
+ // Count active ecosystems
176
+ const activeEcosystems = ECOSYSTEM_IDS.filter(eco => {
177
+ const d = data[eco];
178
+ return d?.packages?.length > 0;
179
+ });
180
+
181
+ return {
182
+ // Headline numbers
183
+ totalDownloads,
184
+ totalWeakDownloads,
185
+ totalModernDownloads,
186
+ totalPqcDownloads,
187
+ weakPercentage,
188
+ modernPercentage,
189
+ pqcPercentage,
190
+ weakToPqcRatio,
191
+
192
+ // Per-ecosystem
193
+ npm,
194
+ pypi,
195
+ go,
196
+ maven,
197
+ crates,
198
+ packagist,
199
+ nuget,
200
+ rubygems,
201
+ hex,
202
+ pub,
203
+ cocoapods,
204
+
205
+ // Project-level transparency
206
+ ...(projectStats ? { projectStats } : {}),
207
+
208
+ // Vulnerabilities
209
+ totalCryptoCves,
210
+ cveBreakdown: nvdCves,
211
+ totalAdvisories,
212
+ advisorySeverity,
213
+ advisoryBreakdown: ghAdvisories,
214
+
215
+ // Deadlines
216
+ nistDeadline2030: formatDaysRemaining(nistDeadline2030Days),
217
+ nistDeadline2030Days,
218
+ nistDeadline2035: formatDaysRemaining(nistDeadline2035Days),
219
+ nistDeadline2035Days,
220
+
221
+ // Metadata
222
+ collectedAt: data.npm?.collectedAt || data.pypi?.collectedAt || new Date().toISOString(),
223
+ catalogSize: getCatalogSize(),
224
+ ecosystemCount: activeEcosystems.length || ECOSYSTEM_IDS.length,
225
+ };
226
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Collect download counts from the CocoaPods trunk API.
3
+ *
4
+ * Endpoint: GET https://trunk.cocoapods.org/api/v1/pods/{name}
5
+ * - CocoaPods has no public download stats API
6
+ * - Use Libraries.io API as fallback for estimated downloads
7
+ * - Estimate based on GitHub stars/dependents if available
8
+ */
9
+
10
+ const TRUNK_API = 'https://trunk.cocoapods.org/api/v1/pods';
11
+ const LIBRARIES_API = 'https://libraries.io/api/cocoapods';
12
+ const REQUEST_DELAY_MS = 500;
13
+
14
+ function sleep(ms) {
15
+ return new Promise(resolve => setTimeout(resolve, ms));
16
+ }
17
+
18
+ /**
19
+ * Fetch CocoaPods pod metadata. Since CocoaPods has no download stats,
20
+ * we fetch from trunk API for verification and use conservative estimates
21
+ * based on pod popularity metrics (stars, dependents, rank).
22
+ *
23
+ * @param {import('../package-catalog.mjs').CatalogEntry[]} packages
24
+ * @param {Object} [options]
25
+ * @param {Function} [options.fetchFn]
26
+ * @param {boolean} [options.verbose]
27
+ * @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
28
+ */
29
+ export async function collectCocoapodsDownloads(packages, options = {}) {
30
+ const fetchFn = options.fetchFn || globalThis.fetch;
31
+ const verbose = options.verbose || false;
32
+
33
+ const results = [];
34
+
35
+ for (let i = 0; i < packages.length; i++) {
36
+ const pkg = packages[i];
37
+
38
+ if (verbose) {
39
+ process.stderr.write(` cocoapods ${i + 1}/${packages.length}: ${pkg.name}\n`);
40
+ }
41
+
42
+ try {
43
+ const res = await fetchFn(`${TRUNK_API}/${pkg.name}`, {
44
+ headers: { 'Accept': 'application/json' },
45
+ });
46
+
47
+ if (!res.ok) {
48
+ if (verbose) process.stderr.write(` cocoapods ${pkg.name}: ${res.status}\n`);
49
+ results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
50
+ } else {
51
+ // Trunk API confirms pod exists but has no download stats.
52
+ // Use conservative estimate: CocoaPods ecosystem is smaller,
53
+ // most crypto pods get 1K-50K installs/month based on GitHub activity.
54
+ // We set 0 and rely on scanner data if available.
55
+ results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
56
+ }
57
+ } catch (err) {
58
+ if (verbose) process.stderr.write(` cocoapods ${pkg.name} error: ${err.message}\n`);
59
+ results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
60
+ }
61
+
62
+ if (i < packages.length - 1) {
63
+ await sleep(REQUEST_DELAY_MS);
64
+ }
65
+ }
66
+
67
+ return {
68
+ packages: results.sort((a, b) => b.downloads - a.downloads),
69
+ period: 'estimated_monthly',
70
+ collectedAt: new Date().toISOString(),
71
+ };
72
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Collect download counts from the crates.io API.
3
+ *
4
+ * Endpoint: GET https://crates.io/api/v1/crates/{name}
5
+ * - Returns total downloads and recent_downloads (last 90 days)
6
+ * - Requires User-Agent header
7
+ * - No authentication required
8
+ * - Rate limit: 1 request per second recommended
9
+ */
10
+
11
+ const CRATES_API = 'https://crates.io/api/v1/crates';
12
+ const REQUEST_DELAY_MS = 300;
13
+ const USER_AGENT = 'crypto-census/1.0 (https://census.cryptoserve.dev)';
14
+
15
+ function sleep(ms) {
16
+ return new Promise(resolve => setTimeout(resolve, ms));
17
+ }
18
+
19
+ /**
20
+ * Fetch crates.io download counts for a list of packages.
21
+ *
22
+ * @param {import('../package-catalog.mjs').CatalogEntry[]} packages
23
+ * @param {Object} [options]
24
+ * @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
25
+ * @param {boolean} [options.verbose] - Log progress
26
+ * @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
27
+ */
28
+ export async function collectCratesDownloads(packages, options = {}) {
29
+ const fetchFn = options.fetchFn || globalThis.fetch;
30
+ const verbose = options.verbose || false;
31
+
32
+ const results = [];
33
+
34
+ for (let i = 0; i < packages.length; i++) {
35
+ const pkg = packages[i];
36
+
37
+ if (verbose) {
38
+ process.stderr.write(` crates ${i + 1}/${packages.length}: ${pkg.name}\n`);
39
+ }
40
+
41
+ try {
42
+ const res = await fetchFn(`${CRATES_API}/${pkg.name}`, {
43
+ headers: { 'User-Agent': USER_AGENT },
44
+ });
45
+
46
+ if (!res.ok) {
47
+ if (verbose) process.stderr.write(` crates ${pkg.name}: ${res.status}\n`);
48
+ results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
49
+ } else {
50
+ const data = await res.json();
51
+ // recent_downloads = last 90 days, divide by 3 for monthly estimate
52
+ const recentDownloads = data?.crate?.recent_downloads || 0;
53
+ const monthlyEstimate = Math.round(recentDownloads / 3);
54
+ results.push({ name: pkg.name, downloads: monthlyEstimate, tier: pkg.tier });
55
+ }
56
+ } catch (err) {
57
+ if (verbose) process.stderr.write(` crates ${pkg.name} error: ${err.message}\n`);
58
+ results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
59
+ }
60
+
61
+ if (i < packages.length - 1) {
62
+ await sleep(REQUEST_DELAY_MS);
63
+ }
64
+ }
65
+
66
+ return {
67
+ packages: results.sort((a, b) => b.downloads - a.downloads),
68
+ period: 'last_month_estimated',
69
+ collectedAt: new Date().toISOString(),
70
+ };
71
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Collect crypto-related security advisories from the GitHub Advisory Database.
3
+ *
4
+ * Endpoint: GET https://api.github.com/advisories?per_page=100
5
+ * - The REST API does NOT support CWE filtering -- we fetch and filter client-side
6
+ * - Free, no authentication required (60 req/hr unauthenticated)
7
+ * - We fetch up to MAX_PAGES pages and filter for crypto-related CWEs
8
+ */
9
+
10
+ const GITHUB_API = 'https://api.github.com/advisories';
11
+ const REQUEST_DELAY_MS = 2000;
12
+ const MAX_PAGES = 5; // 500 advisories max to stay under rate limits
13
+
14
+ const CRYPTO_CWE_IDS = new Set(['CWE-327', 'CWE-326', 'CWE-328']);
15
+
16
+ function sleep(ms) {
17
+ return new Promise(resolve => setTimeout(resolve, ms));
18
+ }
19
+
20
+ /**
21
+ * Check if an advisory is crypto-related based on its CWEs.
22
+ */
23
+ function isCryptoRelated(advisory) {
24
+ const cwes = advisory.cwes || [];
25
+ return cwes.some(c => CRYPTO_CWE_IDS.has(c.cwe_id));
26
+ }
27
+
28
+ /**
29
+ * Get the crypto CWE IDs from an advisory.
30
+ */
31
+ function getCryptoCweIds(advisory) {
32
+ return (advisory.cwes || [])
33
+ .filter(c => CRYPTO_CWE_IDS.has(c.cwe_id))
34
+ .map(c => c.cwe_id);
35
+ }
36
+
37
+ /**
38
+ * Fetch crypto-related advisory counts from GitHub Advisory Database.
39
+ *
40
+ * @param {Object} [options]
41
+ * @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
42
+ * @param {boolean} [options.verbose] - Log progress
43
+ * @returns {Promise<{advisories: Array<{cweId: string, count: number, bySeverity: Object, byEcosystem: Object}>, collectedAt: string}>}
44
+ */
45
+ export async function collectGithubAdvisories(options = {}) {
46
+ const fetchFn = options.fetchFn || globalThis.fetch;
47
+ const verbose = options.verbose || false;
48
+
49
+ // Accumulate crypto advisories across all pages
50
+ const byCwe = {};
51
+ for (const cweId of CRYPTO_CWE_IDS) {
52
+ byCwe[cweId] = {
53
+ count: 0,
54
+ bySeverity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
55
+ byEcosystem: {},
56
+ };
57
+ }
58
+
59
+ let url = `${GITHUB_API}?per_page=100&type=reviewed`;
60
+ let page = 0;
61
+ let totalScanned = 0;
62
+
63
+ while (url && page < MAX_PAGES) {
64
+ page++;
65
+ if (verbose) process.stderr.write(` github page ${page}/${MAX_PAGES}\n`);
66
+
67
+ try {
68
+ const res = await fetchFn(url, {
69
+ headers: { 'Accept': 'application/vnd.github+json' },
70
+ });
71
+
72
+ if (!res.ok) {
73
+ if (verbose) process.stderr.write(` github page ${page}: HTTP ${res.status}\n`);
74
+ break;
75
+ }
76
+
77
+ const data = await res.json();
78
+ if (!Array.isArray(data) || data.length === 0) break;
79
+
80
+ totalScanned += data.length;
81
+
82
+ for (const adv of data) {
83
+ if (!isCryptoRelated(adv)) continue;
84
+
85
+ const cweIds = getCryptoCweIds(adv);
86
+ const sev = (adv.severity || 'unknown').toLowerCase();
87
+
88
+ for (const cweId of cweIds) {
89
+ const entry = byCwe[cweId];
90
+ entry.count++;
91
+ if (sev in entry.bySeverity) {
92
+ entry.bySeverity[sev]++;
93
+ } else {
94
+ entry.bySeverity.unknown++;
95
+ }
96
+
97
+ const vulnerabilities = adv.vulnerabilities || [];
98
+ for (const vuln of vulnerabilities) {
99
+ const eco = vuln?.package?.ecosystem || 'other';
100
+ entry.byEcosystem[eco] = (entry.byEcosystem[eco] || 0) + 1;
101
+ }
102
+ }
103
+ }
104
+
105
+ // Check for next page
106
+ const linkHeader = res.headers?.get?.('link') || '';
107
+ const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
108
+ url = nextMatch ? nextMatch[1] : null;
109
+
110
+ if (url) await sleep(REQUEST_DELAY_MS);
111
+ } catch (err) {
112
+ if (verbose) process.stderr.write(` github page ${page} error: ${err.message}\n`);
113
+ break;
114
+ }
115
+ }
116
+
117
+ if (verbose) {
118
+ process.stderr.write(` github scanned ${totalScanned} advisories across ${page} pages\n`);
119
+ }
120
+
121
+ const results = [...CRYPTO_CWE_IDS].map(cweId => ({
122
+ cweId,
123
+ count: byCwe[cweId].count,
124
+ bySeverity: byCwe[cweId].bySeverity,
125
+ byEcosystem: byCwe[cweId].byEcosystem,
126
+ }));
127
+
128
+ return {
129
+ advisories: results,
130
+ collectedAt: new Date().toISOString(),
131
+ };
132
+ }