proof-of-commitment 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +215 -45
  2. 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 criticalCount = 0;
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')) criticalCount++;
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
- if (criticalCount > 0) {
112
- console.log('\n' + clr(c.red + c.bold, `⚠ ${criticalCount} CRITICAL package${criticalCount > 1 ? 's' : ''} found.`));
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
- console.log('\n' + clr(c.green, '✓ No CRITICAL packages found.'));
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...] Score npm packages
127
- npx proof-of-commitment --pypi [pkgs...] Score PyPI packages
128
- npx proof-of-commitment --file <path> Auto-detect from package.json / requirements.txt
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 requirements.txt
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
- if (basename === 'package.json' || filePath.endsWith('.json')) {
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).slice(0, 20), ecosystem: 'npm' };
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
- .slice(0, 20);
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: ${filePath}. Use package.json or requirements.txt`);
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
- console.log(clr(c.dim, `Detected ${packages.length} packages from ${filePath} (${ecosystem})`));
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
- if (packages.length > 20) {
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
- const pkgList = packages.join(', ');
230
- process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
362
+ let allResults;
231
363
 
232
- const t0 = Date.now();
233
- let data;
234
- try {
235
- const res = await fetch(API, {
236
- method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify({ packages, ecosystem }),
239
- });
240
- if (!res.ok) {
241
- const text = await res.text();
242
- throw new Error(`API error ${res.status}: ${text}`);
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
- const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
251
- process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
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 (!data.results || data.results.length === 0) {
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(data.results);
428
+ printTable(allResults);
259
429
  }
260
430
 
261
431
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Supply chain risk scorer for npm and PyPI packages — behavioral signals that can't be faked",
5
5
  "type": "module",
6
6
  "bin": {