proof-of-commitment 1.0.0 → 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.
- package/index.js +215 -45
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -23,7 +23,6 @@ const c = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const NO_COLOR = process.env.NO_COLOR || !process.stdout.isTTY;
|
|
26
|
-
const col = NO_COLOR ? () => '' : (code) => code;
|
|
27
26
|
|
|
28
27
|
function clr(code, text) {
|
|
29
28
|
if (NO_COLOR) return text;
|
|
@@ -57,7 +56,7 @@ function padEnd(str, len) {
|
|
|
57
56
|
return str + ' '.repeat(Math.max(0, len - visible.length));
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
function printTable(results) {
|
|
59
|
+
function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
|
|
61
60
|
const COL = {
|
|
62
61
|
name: 20, risk: 14, score: 7, maintainers: 12, downloads: 12, age: 8,
|
|
63
62
|
};
|
|
@@ -74,15 +73,19 @@ function printTable(results) {
|
|
|
74
73
|
const divider = '─'.repeat(COL.name + COL.risk + COL.score + COL.maintainers + COL.downloads + COL.age + 10);
|
|
75
74
|
|
|
76
75
|
console.log('\n' + divider);
|
|
76
|
+
if (lockfile && totalScanned && results.length < totalScanned) {
|
|
77
|
+
console.log(clr(c.dim, ` Top ${results.length} highest-risk of ${totalScanned} packages scanned`));
|
|
78
|
+
console.log(divider);
|
|
79
|
+
}
|
|
77
80
|
console.log(header);
|
|
78
81
|
console.log(divider);
|
|
79
82
|
|
|
80
|
-
let
|
|
83
|
+
let criticalInDisplay = 0;
|
|
81
84
|
|
|
82
85
|
for (const pkg of results) {
|
|
83
86
|
const rc = riskColor(pkg.riskFlags, pkg.score);
|
|
84
87
|
const label = riskLabel(pkg.riskFlags, pkg.score);
|
|
85
|
-
if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL'))
|
|
88
|
+
if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL')) criticalInDisplay++;
|
|
86
89
|
|
|
87
90
|
const row = [
|
|
88
91
|
padEnd(pkg.name, COL.name),
|
|
@@ -108,13 +111,19 @@ function printTable(results) {
|
|
|
108
111
|
|
|
109
112
|
console.log(divider);
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
const effectiveCritical = totalCritical !== undefined ? totalCritical : criticalInDisplay;
|
|
115
|
+
if (effectiveCritical > 0) {
|
|
116
|
+
const suffix = totalScanned ? ` (in ${totalScanned} packages scanned)` : '';
|
|
117
|
+
console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL package${effectiveCritical > 1 ? 's' : ''} found${suffix}.`));
|
|
113
118
|
console.log(clr(c.dim, ' CRITICAL = sole maintainer + >10M weekly downloads (high-value attack target)'));
|
|
114
|
-
console.log(clr(c.dim, ` Full breakdown: ${WEB}?packages=${results.map(r => r.name).join(',')}`));
|
|
115
119
|
} else {
|
|
116
|
-
|
|
120
|
+
const suffix = totalScanned ? ` (${totalScanned} packages scanned)` : '';
|
|
121
|
+
console.log('\n' + clr(c.green, `✓ No CRITICAL packages found${suffix}.`));
|
|
117
122
|
}
|
|
123
|
+
|
|
124
|
+
// Always show the web URL with top critical packages first
|
|
125
|
+
const topPkgs = results.slice(0, 10).map(r => r.name).join(',');
|
|
126
|
+
console.log(clr(c.dim, `\n🔗 Web view: ${WEB}?packages=${encodeURIComponent(topPkgs)}`));
|
|
118
127
|
console.log();
|
|
119
128
|
}
|
|
120
129
|
|
|
@@ -123,15 +132,19 @@ function printHelp() {
|
|
|
123
132
|
${clr(c.bold, 'proof-of-commitment')} — supply chain risk scorer
|
|
124
133
|
|
|
125
134
|
${clr(c.bold, 'Usage:')}
|
|
126
|
-
npx proof-of-commitment [packages...]
|
|
127
|
-
npx proof-of-commitment --pypi [pkgs...]
|
|
128
|
-
npx proof-of-commitment --file
|
|
135
|
+
npx proof-of-commitment [packages...] Score npm packages
|
|
136
|
+
npx proof-of-commitment --pypi [pkgs...] Score PyPI packages
|
|
137
|
+
npx proof-of-commitment --file package.json Audit direct dependencies
|
|
138
|
+
npx proof-of-commitment --file package-lock.json Audit ALL dependencies (lock file)
|
|
139
|
+
npx proof-of-commitment --file yarn.lock Audit from yarn lock file
|
|
140
|
+
npx proof-of-commitment --file pnpm-lock.yaml Audit from pnpm lock file
|
|
141
|
+
npx proof-of-commitment --file requirements.txt Audit Python packages
|
|
129
142
|
|
|
130
143
|
${clr(c.bold, 'Examples:')}
|
|
131
144
|
npx proof-of-commitment axios zod chalk
|
|
132
145
|
npx proof-of-commitment --pypi litellm langchain requests
|
|
133
146
|
npx proof-of-commitment --file package.json
|
|
134
|
-
npx proof-of-commitment --file
|
|
147
|
+
npx proof-of-commitment --file package-lock.json # scans ALL transitive deps
|
|
135
148
|
|
|
136
149
|
${clr(c.bold, 'Score meaning:')}
|
|
137
150
|
🔴 CRITICAL Sole maintainer + >10M downloads/wk (high-value attack target)
|
|
@@ -147,33 +160,152 @@ ${clr(c.bold, 'MCP:')} ${clr(c.dim, 'Add to Claude Desktop / Cursor for AI-assi
|
|
|
147
160
|
`);
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Parse package-lock.json (npm lockfileVersion 2 or 3)
|
|
165
|
+
* Returns all package names found in the lock file.
|
|
166
|
+
*/
|
|
167
|
+
function parseLockNpm(content) {
|
|
168
|
+
const lock = JSON.parse(content);
|
|
169
|
+
const pkgs = new Set();
|
|
170
|
+
|
|
171
|
+
if (lock.packages) {
|
|
172
|
+
// lockfileVersion 2+: keys are "node_modules/pkg" or "node_modules/@scope/pkg"
|
|
173
|
+
for (const key of Object.keys(lock.packages)) {
|
|
174
|
+
if (!key || key === '') continue; // root package
|
|
175
|
+
// Strip "node_modules/" prefix, handle nested paths like "node_modules/foo/node_modules/bar"
|
|
176
|
+
const parts = key.split('node_modules/');
|
|
177
|
+
const pkgPath = parts[parts.length - 1];
|
|
178
|
+
if (pkgPath) pkgs.add(pkgPath);
|
|
179
|
+
}
|
|
180
|
+
} else if (lock.dependencies) {
|
|
181
|
+
// lockfileVersion 1: flat dependencies object
|
|
182
|
+
for (const name of Object.keys(lock.dependencies)) {
|
|
183
|
+
pkgs.add(name);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [...pkgs];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse yarn.lock (v1 format)
|
|
192
|
+
* Returns all unique package names.
|
|
193
|
+
*/
|
|
194
|
+
function parseLockYarn(content) {
|
|
195
|
+
const pkgs = new Set();
|
|
196
|
+
// Each block starts with "name@version:" or "name@range1, name@range2:"
|
|
197
|
+
const headerRe = /^"?(@?[^@\s"]+)@/gm;
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = headerRe.exec(content)) !== null) {
|
|
200
|
+
pkgs.add(match[1]);
|
|
201
|
+
}
|
|
202
|
+
return [...pkgs];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse pnpm-lock.yaml (v6+)
|
|
207
|
+
* Returns all unique package names.
|
|
208
|
+
*/
|
|
209
|
+
function parseLockPnpm(content) {
|
|
210
|
+
const pkgs = new Set();
|
|
211
|
+
// packages section has entries like " /chalk@5.3.0:" or " chalk@5.3.0:"
|
|
212
|
+
const pkgRe = /^\s+\/?(@?[^@\s/]+(?:\/[^@\s]+)?)@/gm;
|
|
213
|
+
let match;
|
|
214
|
+
while ((match = pkgRe.exec(content)) !== null) {
|
|
215
|
+
pkgs.add(match[1]);
|
|
216
|
+
}
|
|
217
|
+
return [...pkgs];
|
|
218
|
+
}
|
|
219
|
+
|
|
150
220
|
async function readPackagesFromFile(filePath) {
|
|
151
221
|
const fs = await import('fs');
|
|
152
222
|
const path = await import('path');
|
|
153
223
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
154
224
|
const basename = path.basename(filePath).toLowerCase();
|
|
155
225
|
|
|
156
|
-
|
|
226
|
+
// package-lock.json
|
|
227
|
+
if (basename === 'package-lock.json') {
|
|
228
|
+
const pkgs = parseLockNpm(content);
|
|
229
|
+
return { packages: pkgs, ecosystem: 'npm', lockfile: true, totalInFile: pkgs.length };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// yarn.lock
|
|
233
|
+
if (basename === 'yarn.lock') {
|
|
234
|
+
const pkgs = parseLockYarn(content);
|
|
235
|
+
return { packages: pkgs, ecosystem: 'npm', lockfile: true, totalInFile: pkgs.length };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// pnpm-lock.yaml
|
|
239
|
+
if (basename === 'pnpm-lock.yaml' || basename === 'pnpm-lock.yml') {
|
|
240
|
+
const pkgs = parseLockPnpm(content);
|
|
241
|
+
return { packages: pkgs, ecosystem: 'npm', lockfile: true, totalInFile: pkgs.length };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// package.json
|
|
245
|
+
if (basename === 'package.json') {
|
|
157
246
|
const pkg = JSON.parse(content);
|
|
158
247
|
const deps = {
|
|
159
248
|
...pkg.dependencies,
|
|
160
249
|
...pkg.devDependencies,
|
|
161
250
|
};
|
|
162
|
-
return { packages: Object.keys(deps)
|
|
251
|
+
return { packages: Object.keys(deps), ecosystem: 'npm', lockfile: false };
|
|
163
252
|
}
|
|
164
253
|
|
|
254
|
+
// requirements.txt
|
|
165
255
|
if (basename === 'requirements.txt' || filePath.endsWith('.txt')) {
|
|
166
256
|
const pkgs = content
|
|
167
257
|
.split('\n')
|
|
168
258
|
.map(l => l.trim())
|
|
169
259
|
.filter(l => l && !l.startsWith('#'))
|
|
170
260
|
.map(l => l.replace(/[>=<!\s].*/,'').trim())
|
|
171
|
-
.filter(Boolean)
|
|
172
|
-
|
|
173
|
-
return { packages: pkgs, ecosystem: 'pypi' };
|
|
261
|
+
.filter(Boolean);
|
|
262
|
+
return { packages: pkgs, ecosystem: 'pypi', lockfile: false };
|
|
174
263
|
}
|
|
175
264
|
|
|
176
|
-
throw new Error(`Unsupported file: ${
|
|
265
|
+
throw new Error(`Unsupported file: ${basename}. Supported: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, requirements.txt`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Audit packages in batches of 20, in parallel.
|
|
270
|
+
* Returns all results sorted by risk score (highest risk first).
|
|
271
|
+
*/
|
|
272
|
+
async function auditBatched(packages, ecosystem, { onProgress } = {}) {
|
|
273
|
+
const BATCH_SIZE = 20;
|
|
274
|
+
const batches = [];
|
|
275
|
+
for (let i = 0; i < packages.length; i += BATCH_SIZE) {
|
|
276
|
+
batches.push(packages.slice(i, i + BATCH_SIZE));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let completed = 0;
|
|
280
|
+
const results = await Promise.all(
|
|
281
|
+
batches.map(async (batch) => {
|
|
282
|
+
const res = await fetch(API, {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({ packages: batch, ecosystem }),
|
|
286
|
+
});
|
|
287
|
+
if (!res.ok) {
|
|
288
|
+
const text = await res.text();
|
|
289
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
290
|
+
}
|
|
291
|
+
const data = await res.json();
|
|
292
|
+
completed += batch.length;
|
|
293
|
+
if (onProgress) onProgress(completed, packages.length);
|
|
294
|
+
return data.results || [];
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const all = results.flat();
|
|
299
|
+
|
|
300
|
+
// Sort: CRITICAL first, then by score ascending (lower score = higher risk)
|
|
301
|
+
all.sort((a, b) => {
|
|
302
|
+
const aCrit = a.riskFlags?.includes('CRITICAL') ? 1 : 0;
|
|
303
|
+
const bCrit = b.riskFlags?.includes('CRITICAL') ? 1 : 0;
|
|
304
|
+
if (aCrit !== bCrit) return bCrit - aCrit;
|
|
305
|
+
return (a.score || 100) - (b.score || 100);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
return all;
|
|
177
309
|
}
|
|
178
310
|
|
|
179
311
|
async function main() {
|
|
@@ -187,6 +319,8 @@ async function main() {
|
|
|
187
319
|
let ecosystem = 'npm';
|
|
188
320
|
let packages = [];
|
|
189
321
|
let filePath = null;
|
|
322
|
+
let isLockfile = false;
|
|
323
|
+
let totalInFile = 0;
|
|
190
324
|
|
|
191
325
|
let i = 0;
|
|
192
326
|
while (i < args.length) {
|
|
@@ -209,7 +343,9 @@ async function main() {
|
|
|
209
343
|
const result = await readPackagesFromFile(filePath);
|
|
210
344
|
packages = result.packages;
|
|
211
345
|
ecosystem = result.ecosystem;
|
|
212
|
-
|
|
346
|
+
isLockfile = result.lockfile || false;
|
|
347
|
+
totalInFile = result.totalInFile || packages.length;
|
|
348
|
+
console.log(clr(c.dim, `Detected ${totalInFile} packages from ${filePath} (${ecosystem})`));
|
|
213
349
|
} catch (err) {
|
|
214
350
|
console.error(`Error reading ${filePath}: ${err.message}`);
|
|
215
351
|
process.exit(1);
|
|
@@ -221,41 +357,75 @@ async function main() {
|
|
|
221
357
|
process.exit(1);
|
|
222
358
|
}
|
|
223
359
|
|
|
224
|
-
|
|
225
|
-
console.warn(clr(c.yellow, `Warning: truncating to first 20 packages (got ${packages.length})`));
|
|
226
|
-
packages = packages.slice(0, 20);
|
|
227
|
-
}
|
|
360
|
+
const t0 = Date.now();
|
|
228
361
|
|
|
229
|
-
|
|
230
|
-
process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
|
|
362
|
+
let allResults;
|
|
231
363
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
364
|
+
if (packages.length <= 20) {
|
|
365
|
+
// Single batch — existing behavior
|
|
366
|
+
process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const res = await fetch(API, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: { 'Content-Type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ packages, ecosystem }),
|
|
373
|
+
});
|
|
374
|
+
if (!res.ok) {
|
|
375
|
+
const text = await res.text();
|
|
376
|
+
throw new Error(`API error ${res.status}: ${text}`);
|
|
377
|
+
}
|
|
378
|
+
const data = await res.json();
|
|
379
|
+
allResults = data.results || [];
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error(`\nError: ${err.message}`);
|
|
382
|
+
process.exit(1);
|
|
243
383
|
}
|
|
244
|
-
data = await res.json();
|
|
245
|
-
} catch (err) {
|
|
246
|
-
console.error(`\nError: ${err.message}`);
|
|
247
|
-
process.exit(1);
|
|
248
|
-
}
|
|
249
384
|
|
|
250
|
-
|
|
251
|
-
|
|
385
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
386
|
+
process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
|
|
387
|
+
|
|
388
|
+
} else {
|
|
389
|
+
// Multi-batch for lock files
|
|
390
|
+
const batches = Math.ceil(packages.length / 20);
|
|
391
|
+
process.stdout.write(clr(c.dim, `Scanning ${packages.length} packages (${batches} batches in parallel)...`));
|
|
392
|
+
|
|
393
|
+
let lastPct = 0;
|
|
394
|
+
try {
|
|
395
|
+
allResults = await auditBatched(packages, ecosystem, {
|
|
396
|
+
onProgress: (done, total) => {
|
|
397
|
+
const pct = Math.round((done / total) * 100);
|
|
398
|
+
if (pct >= lastPct + 20) {
|
|
399
|
+
process.stdout.write(clr(c.dim, ` ${pct}%`));
|
|
400
|
+
lastPct = pct;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(`\nError: ${err.message}`);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
410
|
+
process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
|
|
411
|
+
|
|
412
|
+
// For lock files: show top 25 highest-risk packages
|
|
413
|
+
const MAX_DISPLAY = 25;
|
|
414
|
+
const displayed = allResults.slice(0, MAX_DISPLAY);
|
|
415
|
+
const criticalCount = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
|
|
416
|
+
const highRiskCount = allResults.filter(r => !r.riskFlags?.includes('CRITICAL') && r.score < 40).length;
|
|
417
|
+
|
|
418
|
+
const criticalTotal = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
|
|
419
|
+
printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
252
422
|
|
|
253
|
-
if (!
|
|
423
|
+
if (!allResults || allResults.length === 0) {
|
|
254
424
|
console.log('No results returned. Check package names and try again.');
|
|
255
425
|
process.exit(0);
|
|
256
426
|
}
|
|
257
427
|
|
|
258
|
-
printTable(
|
|
428
|
+
printTable(allResults);
|
|
259
429
|
}
|
|
260
430
|
|
|
261
431
|
main().catch(err => {
|