cryptoserve 0.2.1 → 0.3.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.
- package/bin/cryptoserve.mjs +61 -1
- package/lib/census/aggregator.mjs +158 -0
- package/lib/census/collectors/github-advisories.mjs +132 -0
- package/lib/census/collectors/npm-downloads.mjs +132 -0
- package/lib/census/collectors/nvd-cves.mjs +71 -0
- package/lib/census/collectors/pypi-downloads.mjs +67 -0
- package/lib/census/index.mjs +124 -0
- package/lib/census/package-catalog.mjs +209 -0
- package/lib/census/report-html.mjs +540 -0
- package/lib/census/report-terminal.mjs +126 -0
- package/package.json +1 -1
package/bin/cryptoserve.mjs
CHANGED
|
@@ -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 (npm + PyPI + 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 npm, PyPI, NVD, and GitHub...'));
|
|
1015
|
+
console.log(dim(' This may take 30-60 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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate raw census data into headline metrics.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TIERS } from './package-catalog.mjs';
|
|
6
|
+
|
|
7
|
+
// NIST Post-Quantum Cryptography deadlines
|
|
8
|
+
const NIST_2030 = new Date('2030-01-01T00:00:00Z');
|
|
9
|
+
const NIST_2035 = new Date('2035-01-01T00:00:00Z');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sum downloads for a given tier from a packages array.
|
|
13
|
+
*/
|
|
14
|
+
function sumByTier(packages, tier) {
|
|
15
|
+
return packages
|
|
16
|
+
.filter(p => p.tier === tier)
|
|
17
|
+
.reduce((sum, p) => sum + p.downloads, 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get top N packages sorted by downloads descending.
|
|
22
|
+
*/
|
|
23
|
+
function topPackages(packages, n = 10) {
|
|
24
|
+
return [...packages]
|
|
25
|
+
.sort((a, b) => b.downloads - a.downloads)
|
|
26
|
+
.slice(0, n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Calculate days remaining until a target date.
|
|
31
|
+
*/
|
|
32
|
+
function daysUntil(target) {
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const diff = target.getTime() - now.getTime();
|
|
35
|
+
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format days as "X yrs, Y days".
|
|
40
|
+
*/
|
|
41
|
+
function formatDaysRemaining(totalDays) {
|
|
42
|
+
const years = Math.floor(totalDays / 365);
|
|
43
|
+
const days = totalDays % 365;
|
|
44
|
+
if (years === 0) return `${days} days`;
|
|
45
|
+
return `${years} yr${years !== 1 ? 's' : ''}, ${days} days`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format a large number with suffix (M, K).
|
|
50
|
+
*/
|
|
51
|
+
export function formatNumber(n) {
|
|
52
|
+
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
53
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
54
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
55
|
+
return String(n);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Aggregate all census data into headline metrics.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} data
|
|
62
|
+
* @param {Object} data.npm - Result from collectNpmDownloads
|
|
63
|
+
* @param {Object} data.pypi - Result from collectPypiDownloads
|
|
64
|
+
* @param {Object} [data.nvd] - Result from collectNvdCves
|
|
65
|
+
* @param {Object} [data.github] - Result from collectGithubAdvisories
|
|
66
|
+
* @returns {Object} Aggregated metrics
|
|
67
|
+
*/
|
|
68
|
+
export function aggregate(data) {
|
|
69
|
+
const npmPkgs = data.npm?.packages || [];
|
|
70
|
+
const pypiPkgs = data.pypi?.packages || [];
|
|
71
|
+
const allPkgs = [...npmPkgs, ...pypiPkgs];
|
|
72
|
+
|
|
73
|
+
// Download totals by tier
|
|
74
|
+
const totalWeakDownloads = sumByTier(allPkgs, TIERS.WEAK);
|
|
75
|
+
const totalModernDownloads = sumByTier(allPkgs, TIERS.MODERN);
|
|
76
|
+
const totalPqcDownloads = sumByTier(allPkgs, TIERS.PQC);
|
|
77
|
+
const totalDownloads = totalWeakDownloads + totalModernDownloads + totalPqcDownloads;
|
|
78
|
+
|
|
79
|
+
// Percentages
|
|
80
|
+
const weakPercentage = totalDownloads > 0 ? (totalWeakDownloads / totalDownloads * 100) : 0;
|
|
81
|
+
const modernPercentage = totalDownloads > 0 ? (totalModernDownloads / totalDownloads * 100) : 0;
|
|
82
|
+
const pqcPercentage = totalDownloads > 0 ? (totalPqcDownloads / totalDownloads * 100) : 0;
|
|
83
|
+
|
|
84
|
+
// The headline ratio
|
|
85
|
+
const weakToPqcRatio = totalPqcDownloads > 0
|
|
86
|
+
? Math.round(totalWeakDownloads / totalPqcDownloads)
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
// Per-ecosystem breakdowns
|
|
90
|
+
const npmWeak = sumByTier(npmPkgs, TIERS.WEAK);
|
|
91
|
+
const npmModern = sumByTier(npmPkgs, TIERS.MODERN);
|
|
92
|
+
const npmPqc = sumByTier(npmPkgs, TIERS.PQC);
|
|
93
|
+
const pypiWeak = sumByTier(pypiPkgs, TIERS.WEAK);
|
|
94
|
+
const pypiModern = sumByTier(pypiPkgs, TIERS.MODERN);
|
|
95
|
+
const pypiPqc = sumByTier(pypiPkgs, TIERS.PQC);
|
|
96
|
+
|
|
97
|
+
// CVE totals
|
|
98
|
+
const nvdCves = data.nvd?.cves || [];
|
|
99
|
+
const totalCryptoCves = nvdCves.reduce((sum, c) => sum + c.totalCount, 0);
|
|
100
|
+
|
|
101
|
+
// GitHub advisories
|
|
102
|
+
const ghAdvisories = data.github?.advisories || [];
|
|
103
|
+
const totalAdvisories = ghAdvisories.reduce((sum, a) => sum + a.count, 0);
|
|
104
|
+
|
|
105
|
+
// Merge severity counts across CWEs
|
|
106
|
+
const advisorySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
107
|
+
for (const adv of ghAdvisories) {
|
|
108
|
+
for (const [sev, count] of Object.entries(adv.bySeverity || {})) {
|
|
109
|
+
advisorySeverity[sev] = (advisorySeverity[sev] || 0) + count;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// NIST deadlines
|
|
114
|
+
const nistDeadline2030Days = daysUntil(NIST_2030);
|
|
115
|
+
const nistDeadline2035Days = daysUntil(NIST_2035);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
// Headline numbers
|
|
119
|
+
totalDownloads,
|
|
120
|
+
totalWeakDownloads,
|
|
121
|
+
totalModernDownloads,
|
|
122
|
+
totalPqcDownloads,
|
|
123
|
+
weakPercentage,
|
|
124
|
+
modernPercentage,
|
|
125
|
+
pqcPercentage,
|
|
126
|
+
weakToPqcRatio,
|
|
127
|
+
|
|
128
|
+
// Per-ecosystem
|
|
129
|
+
npm: {
|
|
130
|
+
weak: npmWeak, modern: npmModern, pqc: npmPqc,
|
|
131
|
+
total: npmWeak + npmModern + npmPqc,
|
|
132
|
+
topPackages: topPackages(npmPkgs, 15),
|
|
133
|
+
period: data.npm?.period,
|
|
134
|
+
},
|
|
135
|
+
pypi: {
|
|
136
|
+
weak: pypiWeak, modern: pypiModern, pqc: pypiPqc,
|
|
137
|
+
total: pypiWeak + pypiModern + pypiPqc,
|
|
138
|
+
topPackages: topPackages(pypiPkgs, 15),
|
|
139
|
+
period: data.pypi?.period,
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// Vulnerabilities
|
|
143
|
+
totalCryptoCves,
|
|
144
|
+
cveBreakdown: nvdCves,
|
|
145
|
+
totalAdvisories,
|
|
146
|
+
advisorySeverity,
|
|
147
|
+
advisoryBreakdown: ghAdvisories,
|
|
148
|
+
|
|
149
|
+
// Deadlines
|
|
150
|
+
nistDeadline2030: formatDaysRemaining(nistDeadline2030Days),
|
|
151
|
+
nistDeadline2030Days,
|
|
152
|
+
nistDeadline2035: formatDaysRemaining(nistDeadline2035Days),
|
|
153
|
+
nistDeadline2035Days,
|
|
154
|
+
|
|
155
|
+
// Metadata
|
|
156
|
+
collectedAt: data.npm?.collectedAt || data.pypi?.collectedAt || new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect download counts from the npm registry API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://api.npmjs.org/downloads/point/last-month/pkg1,pkg2,...
|
|
5
|
+
* - Bulk endpoint does NOT support scoped packages (@org/pkg)
|
|
6
|
+
* - Scoped packages must be fetched individually
|
|
7
|
+
* - No authentication required
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const NPM_API = 'https://api.npmjs.org/downloads/point/last-month';
|
|
11
|
+
const BATCH_SIZE = 50;
|
|
12
|
+
const BATCH_DELAY_MS = 1000;
|
|
13
|
+
const INDIVIDUAL_DELAY_MS = 200;
|
|
14
|
+
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch a single package's download count.
|
|
21
|
+
*/
|
|
22
|
+
async function fetchSingle(pkg, fetchFn, period, verbose) {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetchFn(`${NPM_API}/${pkg.name}`);
|
|
25
|
+
if (res.ok) {
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
if (!period.start && data.start) {
|
|
28
|
+
period.start = data.start;
|
|
29
|
+
period.end = data.end;
|
|
30
|
+
}
|
|
31
|
+
return { name: pkg.name, downloads: data.downloads || 0, tier: pkg.tier };
|
|
32
|
+
}
|
|
33
|
+
if (verbose) process.stderr.write(` npm ${pkg.name}: HTTP ${res.status}\n`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (verbose) process.stderr.write(` npm ${pkg.name} error: ${err.message}\n`);
|
|
36
|
+
}
|
|
37
|
+
return { name: pkg.name, downloads: 0, tier: pkg.tier };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch npm download counts for a list of packages.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('../package-catalog.mjs').CatalogEntry[]} packages
|
|
44
|
+
* @param {Object} [options]
|
|
45
|
+
* @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
|
|
46
|
+
* @param {boolean} [options.verbose] - Log progress
|
|
47
|
+
* @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: {start: string, end: string}, collectedAt: string}>}
|
|
48
|
+
*/
|
|
49
|
+
export async function collectNpmDownloads(packages, options = {}) {
|
|
50
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
51
|
+
const verbose = options.verbose || false;
|
|
52
|
+
|
|
53
|
+
const results = [];
|
|
54
|
+
const period = { start: '', end: '' };
|
|
55
|
+
|
|
56
|
+
// Separate scoped (@org/pkg) from unscoped packages
|
|
57
|
+
const scoped = packages.filter(p => p.name.startsWith('@'));
|
|
58
|
+
const unscoped = packages.filter(p => !p.name.startsWith('@'));
|
|
59
|
+
|
|
60
|
+
// Batch unscoped packages
|
|
61
|
+
const batches = [];
|
|
62
|
+
for (let i = 0; i < unscoped.length; i += BATCH_SIZE) {
|
|
63
|
+
batches.push(unscoped.slice(i, i + BATCH_SIZE));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < batches.length; i++) {
|
|
67
|
+
const batch = batches[i];
|
|
68
|
+
const names = batch.map(p => p.name).join(',');
|
|
69
|
+
const url = `${NPM_API}/${names}`;
|
|
70
|
+
|
|
71
|
+
if (verbose) {
|
|
72
|
+
process.stderr.write(` npm batch ${i + 1}/${batches.length} (${batch.length} unscoped packages)\n`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetchFn(url);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
if (verbose) process.stderr.write(` npm batch ${i + 1} failed: ${res.status}, falling back to individual\n`);
|
|
79
|
+
for (const pkg of batch) {
|
|
80
|
+
results.push(await fetchSingle(pkg, fetchFn, period, verbose));
|
|
81
|
+
await sleep(INDIVIDUAL_DELAY_MS);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const data = await res.json();
|
|
87
|
+
|
|
88
|
+
if (batch.length === 1) {
|
|
89
|
+
if (!period.start && data.start) {
|
|
90
|
+
period.start = data.start;
|
|
91
|
+
period.end = data.end;
|
|
92
|
+
}
|
|
93
|
+
results.push({ name: batch[0].name, downloads: data.downloads || 0, tier: batch[0].tier });
|
|
94
|
+
} else {
|
|
95
|
+
for (const pkg of batch) {
|
|
96
|
+
const entry = data[pkg.name];
|
|
97
|
+
if (entry) {
|
|
98
|
+
if (!period.start && entry.start) {
|
|
99
|
+
period.start = entry.start;
|
|
100
|
+
period.end = entry.end;
|
|
101
|
+
}
|
|
102
|
+
results.push({ name: pkg.name, downloads: entry.downloads || 0, tier: pkg.tier });
|
|
103
|
+
} else {
|
|
104
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (verbose) process.stderr.write(` npm batch ${i + 1} error: ${err.message}\n`);
|
|
110
|
+
for (const pkg of batch) {
|
|
111
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (i < batches.length - 1) await sleep(BATCH_DELAY_MS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fetch scoped packages individually (bulk API doesn't support them)
|
|
119
|
+
if (scoped.length > 0 && verbose) {
|
|
120
|
+
process.stderr.write(` npm fetching ${scoped.length} scoped packages individually\n`);
|
|
121
|
+
}
|
|
122
|
+
for (let i = 0; i < scoped.length; i++) {
|
|
123
|
+
results.push(await fetchSingle(scoped[i], fetchFn, period, verbose));
|
|
124
|
+
if (i < scoped.length - 1) await sleep(INDIVIDUAL_DELAY_MS);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
packages: results.sort((a, b) => b.downloads - a.downloads),
|
|
129
|
+
period,
|
|
130
|
+
collectedAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect crypto-related CVE counts from NVD (National Vulnerability Database).
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://services.nvd.nist.gov/rest/json/cves/2.0?cweId=CWE-XXX&resultsPerPage=1
|
|
5
|
+
* - Free, no authentication required (API key optional for higher rate limits)
|
|
6
|
+
* - Rate limit: 5 requests per 30 seconds without API key
|
|
7
|
+
* - We use 7s delay between requests to stay well under limits
|
|
8
|
+
* - resultsPerPage=1 to minimize payload (we only need totalResults)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const NVD_API = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
|
|
12
|
+
const REQUEST_DELAY_MS = 7000;
|
|
13
|
+
|
|
14
|
+
const CRYPTO_CWES = [
|
|
15
|
+
{ id: 'CWE-327', name: 'Use of a Broken or Risky Cryptographic Algorithm' },
|
|
16
|
+
{ id: 'CWE-326', name: 'Inadequate Encryption Strength' },
|
|
17
|
+
{ id: 'CWE-328', name: 'Use of Weak Hash' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch crypto-related CVE counts from NVD.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} [options]
|
|
28
|
+
* @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
|
|
29
|
+
* @param {boolean} [options.verbose] - Log progress
|
|
30
|
+
* @returns {Promise<{cves: Array<{cweId: string, cweName: string, totalCount: number}>, collectedAt: string}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function collectNvdCves(options = {}) {
|
|
33
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
34
|
+
const verbose = options.verbose || false;
|
|
35
|
+
|
|
36
|
+
const results = [];
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < CRYPTO_CWES.length; i++) {
|
|
39
|
+
const cwe = CRYPTO_CWES[i];
|
|
40
|
+
const url = `${NVD_API}?cweId=${cwe.id}&resultsPerPage=1`;
|
|
41
|
+
|
|
42
|
+
if (verbose) {
|
|
43
|
+
process.stderr.write(` nvd ${i + 1}/${CRYPTO_CWES.length}: ${cwe.id} (${cwe.name})\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetchFn(url);
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
if (verbose) process.stderr.write(` nvd ${cwe.id}: HTTP ${res.status}\n`);
|
|
50
|
+
results.push({ cweId: cwe.id, cweName: cwe.name, totalCount: 0 });
|
|
51
|
+
} else {
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
const totalCount = data?.totalResults || 0;
|
|
54
|
+
results.push({ cweId: cwe.id, cweName: cwe.name, totalCount });
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (verbose) process.stderr.write(` nvd ${cwe.id} error: ${err.message}\n`);
|
|
58
|
+
results.push({ cweId: cwe.id, cweName: cwe.name, totalCount: 0 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Delay between requests (NVD rate limit)
|
|
62
|
+
if (i < CRYPTO_CWES.length - 1) {
|
|
63
|
+
await sleep(REQUEST_DELAY_MS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
cves: results,
|
|
69
|
+
collectedAt: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect download counts from PyPI Stats API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://pypistats.org/api/packages/{pkg}/recent
|
|
5
|
+
* - Individual requests only (no batch endpoint)
|
|
6
|
+
* - No authentication required
|
|
7
|
+
* - 500ms delay between requests to be polite
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PYPI_API = 'https://pypistats.org/api/packages';
|
|
11
|
+
const REQUEST_DELAY_MS = 500;
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch PyPI download counts for a list of packages.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('../package-catalog.mjs').CatalogEntry[]} packages
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
|
|
23
|
+
* @param {boolean} [options.verbose] - Log progress
|
|
24
|
+
* @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
|
|
25
|
+
*/
|
|
26
|
+
export async function collectPypiDownloads(packages, options = {}) {
|
|
27
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
28
|
+
const verbose = options.verbose || false;
|
|
29
|
+
|
|
30
|
+
const results = [];
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < packages.length; i++) {
|
|
33
|
+
const pkg = packages[i];
|
|
34
|
+
const url = `${PYPI_API}/${pkg.name}/recent`;
|
|
35
|
+
|
|
36
|
+
if (verbose) {
|
|
37
|
+
process.stderr.write(` pypi ${i + 1}/${packages.length}: ${pkg.name}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetchFn(url);
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
if (verbose) process.stderr.write(` pypi ${pkg.name}: ${res.status}\n`);
|
|
44
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
45
|
+
} else {
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
// Response: { data: { last_month: N, last_week: N, last_day: N }, ... }
|
|
48
|
+
const downloads = data?.data?.last_month || 0;
|
|
49
|
+
results.push({ name: pkg.name, downloads, tier: pkg.tier });
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (verbose) process.stderr.write(` pypi ${pkg.name} error: ${err.message}\n`);
|
|
53
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Delay between requests
|
|
57
|
+
if (i < packages.length - 1) {
|
|
58
|
+
await sleep(REQUEST_DELAY_MS);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
packages: results.sort((a, b) => b.downloads - a.downloads),
|
|
64
|
+
period: 'last_month',
|
|
65
|
+
collectedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|