proof-of-commitment 1.5.0 → 1.7.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 (3) hide show
  1. package/README.md +15 -10
  2. package/index.js +236 -169
  3. package/package.json +8 -6
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **Stars lie. Behavioral signals don't.**
6
6
 
7
- An MCP server and web tool that scores npm packages, PyPI packages, Rust crates, and GitHub repos on **behavioral commitment** — signals that are harder to fake than stars, READMEs, or download counts.
7
+ An MCP server and web tool that scores npm packages, PyPI packages, Rust crates, Go modules, and GitHub repos on **behavioral commitment** — signals that are harder to fake than stars, READMEs, or download counts.
8
8
 
9
9
  ## The supply chain problem
10
10
 
@@ -40,10 +40,13 @@ npx proof-of-commitment --file pnpm-workspace.yaml # pnpm workspaces
40
40
  npx proof-of-commitment --file package-lock.json --json | jq '.criticalCount'
41
41
  # PyPI too:
42
42
  npx proof-of-commitment --pypi litellm langchain requests
43
- # Cargo (Rust) via API:
44
- curl -s https://poc-backend.amdal-dev.workers.dev/api/audit \
45
- -X POST -H "Content-Type: application/json" \
46
- -d '{"packages":["serde","tokio","reqwest"],"ecosystem":"cargo"}'
43
+ # Cargo (Rust) via CLI:
44
+ npx proof-of-commitment --cargo serde tokio reqwest
45
+ # Go modules via CLI (full module path required):
46
+ npx proof-of-commitment --golang github.com/gin-gonic/gin golang.org/x/net
47
+ # Or scan a go.mod / go.sum file directly:
48
+ npx proof-of-commitment --file go.mod
49
+ npx proof-of-commitment --file go.sum # full transitive set
47
50
  ```
48
51
 
49
52
  **Web demo (no install):** [getcommit.dev/audit](https://getcommit.dev/audit) — paste your packages, see risk scores in seconds.
@@ -143,12 +146,13 @@ Grades: 🟢 OK (75+) · 🟠 WARNING (40–74) · 🔴 CRITICAL (<40 or sole np
143
146
 
144
147
  Badges are cached 1 hour. No API key needed.
145
148
 
146
- Also supports PyPI, Cargo, and the full ecosystem-specific format:
149
+ Also supports PyPI, Cargo, Go modules, and the full ecosystem-specific format:
147
150
 
148
151
  ```markdown
149
152
  ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/npm/YOUR-PACKAGE)
150
153
  ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/pypi/YOUR-PACKAGE)
151
154
  ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/cargo/YOUR-CRATE)
155
+ ![commit score](https://poc-backend.amdal-dev.workers.dev/api/badge/golang/github.com/owner/repo)
152
156
  ```
153
157
 
154
158
  ## REST API
@@ -183,14 +187,15 @@ curl https://poc-backend.amdal-dev.workers.dev/api/audit \
183
187
  }
184
188
  ```
185
189
 
186
- ## 8 MCP tools
190
+ ## 9 MCP tools
187
191
 
188
192
  | Tool | Description |
189
193
  |------|-------------|
190
- | `audit_dependencies` | Batch risk audit for up to 20 npm/PyPI/Cargo packages |
194
+ | `audit_dependencies` | Batch risk audit for up to 20 npm/PyPI/Cargo/Go packages |
191
195
  | `lookup_npm_package` | Single npm package behavioral profile |
192
196
  | `lookup_pypi_package` | Single PyPI package behavioral profile |
193
197
  | `lookup_cargo_crate` | Single Rust crate behavioral profile (crates.io) |
198
+ | `lookup_go_module` | Single Go module behavioral profile (proxy.golang.org + GitHub) |
194
199
  | `lookup_github_repo` | GitHub repo commitment score (longevity, commit frequency, contributor depth) |
195
200
  | `lookup_business` | Norwegian business register — operating years, employees, financials |
196
201
  | `lookup_business_by_org` | Same, by org number |
@@ -249,7 +254,7 @@ Declarative signals (stars, README quality, CI badges) don't capture this risk.
249
254
  |-------|-----------|
250
255
  | Backend | Cloudflare Workers + D1 |
251
256
  | MCP | Model Context Protocol SDK |
252
- | Data | npm registry, PyPI, crates.io, GitHub API, Brønnøysund (NO) |
257
+ | Data | npm registry, PyPI, crates.io, proxy.golang.org, deps.dev, GitHub API, Brønnøysund (NO) |
253
258
  | Landing | Astro + Cloudflare Pages |
254
259
 
255
260
  ## Roadmap
@@ -259,7 +264,7 @@ Planned, not promised. The project is early-stage — contributions welcome on a
259
264
  | Feature | Status | Notes |
260
265
  |---------|--------|-------|
261
266
  | **Cargo (Rust) registry support** | ✅ Live | MCP tool, REST API, badge endpoint — `ecosystem: "cargo"` |
262
- | **Go modules support** | Planned | pkg.go.dev API + GitHub backing score |
267
+ | **Go modules support** | Live | proxy.golang.org + deps.dev + GitHub-primary scoring `ecosystem: "golang"` |
263
268
  | **Score breakdown visualization** | Planned | Chart component for the 5 dimensions on getcommit.dev/audit |
264
269
  | **`--json` flag for CLI** | ✅ Live | `npx proof-of-commitment --file package-lock.json --json \| jq '.criticalCount'` |
265
270
  | **pnpm workspace monorepo support** | ✅ Live | `--file pnpm-workspace.yaml` or auto-detected from `pnpm-lock.yaml` |
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proof-of-commitment CLI
4
- * Scores npm/PyPI packages on behavioral commitment signals.
3
+ * proof-of-commitment CLI v1.7.0
4
+ * Scores npm/PyPI/Cargo/Go packages on behavioral commitment signals.
5
5
  * Usage: npx proof-of-commitment [packages...] [options]
6
6
  */
7
7
 
@@ -20,6 +20,7 @@ const c = {
20
20
  white: '\x1b[37m',
21
21
  bgRed: '\x1b[41m',
22
22
  bgYellow: '\x1b[43m',
23
+ magenta: '\x1b[35m',
23
24
  };
24
25
 
25
26
  const NO_COLOR = process.env.NO_COLOR || !process.stdout.isTTY;
@@ -29,15 +30,20 @@ function clr(code, text) {
29
30
  return `${code}${text}${c.reset}`;
30
31
  }
31
32
 
33
+ /** Check if riskFlags array contains a CRITICAL-level flag (handles both "CRITICAL" and "CRITICAL: ..." formats) */
34
+ function hasCritical(flags) {
35
+ return flags && flags.some(f => typeof f === 'string' && f.startsWith('CRITICAL'));
36
+ }
37
+
32
38
  function riskColor(flags, score) {
33
- if (flags && flags.includes('CRITICAL')) return c.red + c.bold;
39
+ if (hasCritical(flags)) return c.red + c.bold;
34
40
  if (score < 40) return c.yellow + c.bold;
35
41
  if (score < 60) return c.yellow;
36
42
  return c.green;
37
43
  }
38
44
 
39
45
  function riskLabel(flags, score) {
40
- if (flags && flags.includes('CRITICAL')) return '🔴 CRITICAL';
46
+ if (hasCritical(flags)) return '🔴 CRITICAL';
41
47
  if (score < 40) return '🟠 HIGH';
42
48
  if (score < 60) return '🟡 MODERATE';
43
49
  if (score < 75) return '🟡 GOOD';
@@ -57,20 +63,28 @@ function padEnd(str, len) {
57
63
  }
58
64
 
59
65
  function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
66
+ const isNpm = !results[0] || results[0].ecosystem !== 'golang';
60
67
  const COL = {
61
- name: 20, risk: 14, score: 7, maintainers: 12, downloads: 12, age: 8,
68
+ name: 20, risk: 14, score: 7, maintainers: 12, downloads: 12, age: 8, provenance: 10,
62
69
  };
63
70
 
64
- const header = [
71
+ const headerParts = [
65
72
  padEnd(clr(c.bold, 'Package'), COL.name),
66
73
  padEnd(clr(c.bold, 'Risk'), COL.risk),
67
74
  padEnd(clr(c.bold, 'Score'), COL.score),
68
75
  padEnd(clr(c.bold, 'Publishers'), COL.maintainers),
69
76
  padEnd(clr(c.bold, 'Downloads'), COL.downloads),
70
77
  padEnd(clr(c.bold, 'Age'), COL.age),
71
- ].join(' ');
78
+ ];
72
79
 
73
- const divider = '─'.repeat(COL.name + COL.risk + COL.score + COL.maintainers + COL.downloads + COL.age + 10);
80
+ // Show Provenance column for npm packages
81
+ if (isNpm) {
82
+ headerParts.push(padEnd(clr(c.bold, 'Provenance'), COL.provenance));
83
+ }
84
+
85
+ const header = headerParts.join(' ');
86
+ const divWidth = COL.name + COL.risk + COL.score + COL.maintainers + COL.downloads + COL.age + (isNpm ? COL.provenance + 2 : 0) + 10;
87
+ const divider = '─'.repeat(divWidth);
74
88
 
75
89
  console.log('\n' + divider);
76
90
  if (lockfile && totalScanned && results.length < totalScanned) {
@@ -81,25 +95,41 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
81
95
  console.log(divider);
82
96
 
83
97
  let criticalInDisplay = 0;
98
+ let provenanceCount = 0;
84
99
 
85
100
  for (const pkg of results) {
86
101
  const rc = riskColor(pkg.riskFlags, pkg.score);
87
102
  const label = riskLabel(pkg.riskFlags, pkg.score);
88
- if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL')) criticalInDisplay++;
103
+ if (hasCritical(pkg.riskFlags)) criticalInDisplay++;
104
+ if (pkg.hasProvenance) provenanceCount++;
105
+
106
+ // Go modules have no download data
107
+ const isGo = pkg.ecosystem === 'golang';
108
+ const dlDisplay = isGo ? '—' : fmtDownloads(pkg.weeklyDownloads || 0);
109
+ const maintDisplay = pkg.maintainers === 35 ? '30+' : String(pkg.maintainers || '?');
89
110
 
90
- const row = [
111
+ // Provenance indicator
112
+ const provDisplay = pkg.hasProvenance
113
+ ? clr(c.green, '🔐 verified')
114
+ : clr(c.dim, '—');
115
+
116
+ const rowParts = [
91
117
  padEnd(pkg.name, COL.name),
92
118
  padEnd(clr(rc, label), COL.risk),
93
119
  padEnd(String(pkg.score), COL.score),
94
- padEnd(String(pkg.maintainers || '?'), COL.maintainers),
95
- padEnd(fmtDownloads(pkg.weeklyDownloads || 0), COL.downloads),
120
+ padEnd(maintDisplay, COL.maintainers),
121
+ padEnd(dlDisplay, COL.downloads),
96
122
  padEnd((pkg.ageYears || '?').toString().replace(/(\.\d).*/, '$1') + 'y', COL.age),
97
- ].join(' ');
123
+ ];
124
+
125
+ if (isNpm) {
126
+ rowParts.push(padEnd(provDisplay, COL.provenance));
127
+ }
98
128
 
99
- console.log(row);
129
+ console.log(rowParts.join(' '));
100
130
 
101
131
  // Show GitHub contributor context for CRITICAL packages with active communities
102
- if (pkg.riskFlags && pkg.riskFlags.includes('CRITICAL') && pkg.githubContributors && pkg.githubContributors > 1) {
132
+ if (hasCritical(pkg.riskFlags) && pkg.githubContributors && pkg.githubContributors > 1) {
103
133
  const ghCount = pkg.githubContributors === 35 ? '30+' : pkg.githubContributors;
104
134
  console.log(clr(c.dim, ` ↳ ${ghCount} GitHub contributors — publish-access concentration risk despite active community`));
105
135
  }
@@ -107,10 +137,16 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
107
137
  // Score breakdown if available
108
138
  if (pkg.scoreBreakdown) {
109
139
  const b = pkg.scoreBreakdown;
110
- const breakdown = clr(c.dim,
111
- ` └ longevity=${b.longevity} momentum=${b.downloadMomentum} ` +
112
- `releases=${b.releaseConsistency} publishers=${b.maintainerDepth} github=${b.githubBacking}`
113
- );
140
+ const breakdown = isGo
141
+ ? clr(c.dim,
142
+ ` └ longevity=${b.longevity} releases=${b.releaseConsistency} ` +
143
+ `contributors=${b.maintainerDepth} github=${b.githubBacking} stars=${b.popularityProxy}`
144
+ )
145
+ : clr(c.dim,
146
+ ` └ longevity=${b.longevity} momentum=${b.downloadMomentum} ` +
147
+ `releases=${b.releaseConsistency} publishers=${b.maintainerDepth} github=${b.githubBacking}` +
148
+ (b.trustedPublishing ? ` provenance=${b.trustedPublishing}` : '')
149
+ );
114
150
  console.log(breakdown);
115
151
  }
116
152
  }
@@ -122,76 +158,92 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
122
158
  const suffix = totalScanned ? ` (in ${totalScanned} packages scanned)` : '';
123
159
  console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL package${effectiveCritical > 1 ? 's' : ''} found${suffix}.`));
124
160
  console.log(clr(c.dim, ' CRITICAL = sole npm publisher + >10M weekly downloads (publish-access concentration risk)'));
161
+ if (provenanceCount > 0 && provenanceCount < results.length) {
162
+ console.log(clr(c.cyan, ` 🔐 ${provenanceCount}/${results.length} use Trusted Publishing (OIDC provenance) — partial mitigation`));
163
+ }
125
164
  } else {
126
165
  const suffix = totalScanned ? ` (${totalScanned} packages scanned)` : '';
127
166
  console.log('\n' + clr(c.green, `✓ No CRITICAL packages found${suffix}.`));
128
167
  }
129
168
 
130
- // Always show the web URL with top critical packages first
169
+ // Footer with web link + CI integration CTA
131
170
  const topPkgs = results.slice(0, 10).map(r => r.name).join(',');
132
- console.log(clr(c.dim, `\n🔗 Web view: ${WEB}?packages=${encodeURIComponent(topPkgs)}`));
171
+ console.log(clr(c.cyan, `\n 🔗 Full report: ${WEB}?packages=${encodeURIComponent(topPkgs)}`));
172
+ console.log(clr(c.cyan, ` 🤖 GitHub Action: github.com/piiiico/commit-action — block CRITICAL packages in CI`));
133
173
  console.log();
134
174
  }
135
175
 
136
176
  function printHelp() {
137
177
  console.log(`
138
- ${clr(c.bold, 'proof-of-commitment')} — supply chain risk scorer
178
+ ${clr(c.bold, 'proof-of-commitment')} v1.7.0 — supply chain risk scorer
139
179
 
140
180
  ${clr(c.bold, 'Usage:')}
141
- npx proof-of-commitment [packages...] Score npm packages
142
- npx proof-of-commitment --pypi [pkgs...] Score PyPI packages
143
- npx proof-of-commitment --file package.json Audit direct dependencies
144
- npx proof-of-commitment --file package-lock.json Audit ALL dependencies (lock file)
145
- npx proof-of-commitment --file yarn.lock Audit from yarn lock file
146
- npx proof-of-commitment --file pnpm-lock.yaml Audit from pnpm lock file
147
- npx proof-of-commitment --file pnpm-workspace.yaml Audit entire pnpm monorepo
148
- npx proof-of-commitment --file requirements.txt Audit Python packages
181
+ npx proof-of-commitment [packages...] Score npm packages
182
+ npx proof-of-commitment --pypi [pkgs...] Score PyPI packages
183
+ npx proof-of-commitment --cargo [crates...] Score Rust crates
184
+ npx proof-of-commitment --golang [modules...] Score Go modules (full path required)
185
+ npx proof-of-commitment --file package.json Audit direct dependencies
186
+ npx proof-of-commitment --file package-lock.json Audit ALL dependencies (lock file)
187
+ npx proof-of-commitment --file yarn.lock Audit from yarn lock file
188
+ npx proof-of-commitment --file pnpm-lock.yaml Audit from pnpm lock file
189
+ npx proof-of-commitment --file requirements.txt Audit Python packages
190
+ npx proof-of-commitment --file Cargo.toml Audit Rust direct dependencies
191
+ npx proof-of-commitment --file go.mod Audit Go direct + indirect deps
192
+ npx proof-of-commitment --file go.sum Audit Go full transitive set
149
193
 
150
194
  ${clr(c.bold, 'Options:')}
151
195
  --json Output results as JSON (exits 1 if any CRITICAL found — useful in CI)
152
196
  --pypi Score PyPI packages instead of npm
153
- --file, -f Read packages from package.json, lock file, pnpm-workspace.yaml, or requirements.txt
197
+ --cargo Score Rust crates from crates.io
198
+ --golang Score Go modules from proxy.golang.org (use full path: github.com/owner/repo)
199
+ --file, -f Read packages from package.json, lock file, requirements.txt, Cargo.toml, or go.mod/go.sum
154
200
 
155
201
  ${clr(c.bold, 'Examples:')}
156
202
  npx proof-of-commitment axios zod chalk
157
203
  npx proof-of-commitment --pypi litellm langchain requests
158
- npx proof-of-commitment --file package.json
204
+ npx proof-of-commitment --cargo serde tokio reqwest
205
+ npx proof-of-commitment --golang github.com/gin-gonic/gin golang.org/x/net
159
206
  npx proof-of-commitment --file package-lock.json # scans ALL transitive deps
207
+ npx proof-of-commitment --file go.sum # scans full Go module graph
160
208
  npx proof-of-commitment axios chalk --json | jq '.criticalCount'
161
209
 
162
210
  ${clr(c.bold, 'Score meaning:')}
163
- 🔴 CRITICAL Sole npm publisher + >10M downloads/wk (publish-access concentration risk)
211
+ 🔴 CRITICAL Sole publisher + >10M downloads/wk (publish-access concentration risk)
164
212
  🟠 HIGH Score < 40
165
213
  🟡 MODERATE Score 40–59
166
214
  🟡 GOOD Score 60–74
167
215
  🟢 HEALTHY Score 75+
168
216
 
169
- ${clr(c.bold, 'Score dimensions:')} longevity · download momentum · release consistency · publisher depth · GitHub backing
217
+ ${clr(c.bold, 'Provenance (npm):')}
218
+ 🔐 verified Package uses Trusted Publishing (OIDC provenance from CI — not a human credential)
219
+ — No provenance attestation detected
220
+
221
+ ${clr(c.bold, 'Score dimensions (npm/PyPI/Cargo):')} longevity · download momentum · release consistency · publisher depth · GitHub backing · provenance
222
+ ${clr(c.bold, 'Score dimensions (Go):')} longevity · release consistency · maintainer depth · GitHub backing · stars
223
+
224
+ ${clr(c.bold, 'CI integration:')}
225
+ GitHub Action: ${clr(c.cyan, 'github.com/piiiico/commit-action')} — fails PRs on CRITICAL packages
226
+ MCP server: Add to Claude Desktop / Cursor for AI-assisted auditing
170
227
 
171
228
  ${clr(c.bold, 'Web:')} ${WEB}
172
- ${clr(c.bold, 'MCP:')} ${clr(c.dim, 'Add to Claude Desktop / Cursor for AI-assisted auditing')}
173
229
  `);
174
230
  }
175
231
 
176
232
  /**
177
233
  * Parse package-lock.json (npm lockfileVersion 2 or 3)
178
- * Returns all package names found in the lock file.
179
234
  */
180
235
  function parseLockNpm(content) {
181
236
  const lock = JSON.parse(content);
182
237
  const pkgs = new Set();
183
238
 
184
239
  if (lock.packages) {
185
- // lockfileVersion 2+: keys are "node_modules/pkg" or "node_modules/@scope/pkg"
186
240
  for (const key of Object.keys(lock.packages)) {
187
- if (!key || key === '') continue; // root package
188
- // Strip "node_modules/" prefix, handle nested paths like "node_modules/foo/node_modules/bar"
241
+ if (!key || key === '') continue;
189
242
  const parts = key.split('node_modules/');
190
243
  const pkgPath = parts[parts.length - 1];
191
244
  if (pkgPath) pkgs.add(pkgPath);
192
245
  }
193
246
  } else if (lock.dependencies) {
194
- // lockfileVersion 1: flat dependencies object
195
247
  for (const name of Object.keys(lock.dependencies)) {
196
248
  pkgs.add(name);
197
249
  }
@@ -202,11 +254,9 @@ function parseLockNpm(content) {
202
254
 
203
255
  /**
204
256
  * Parse yarn.lock (v1 format)
205
- * Returns all unique package names.
206
257
  */
207
258
  function parseLockYarn(content) {
208
259
  const pkgs = new Set();
209
- // Each block starts with "name@version:" or "name@range1, name@range2:"
210
260
  const headerRe = /^"?(@?[^@\s"]+)@/gm;
211
261
  let match;
212
262
  while ((match = headerRe.exec(content)) !== null) {
@@ -217,11 +267,9 @@ function parseLockYarn(content) {
217
267
 
218
268
  /**
219
269
  * Parse pnpm-lock.yaml (v6+)
220
- * Returns all unique package names.
221
270
  */
222
271
  function parseLockPnpm(content) {
223
272
  const pkgs = new Set();
224
- // packages section has entries like " /chalk@5.3.0:" or " chalk@5.3.0:"
225
273
  const pkgRe = /^\s+\/?(@?[^@\s/]+(?:\/[^@\s]+)?)@/gm;
226
274
  let match;
227
275
  while ((match = pkgRe.exec(content)) !== null) {
@@ -231,123 +279,73 @@ function parseLockPnpm(content) {
231
279
  }
232
280
 
233
281
  /**
234
- * Parse pnpm-workspace.yaml and resolve all workspace package dependencies.
235
- * Reads package.json from each workspace, merges deps, deduplicates.
236
- * Internal workspace packages are excluded from the audit list.
282
+ * Parse pnpm-workspace.yaml find all workspace packages and aggregate their deps.
283
+ * Expects format:
284
+ * packages:
285
+ * - "packages/*"
286
+ * - "apps/*"
237
287
  */
238
- async function parsePnpmWorkspace(filePath) {
288
+ async function parsePnpmWorkspace(content, filePath) {
239
289
  const fs = await import('fs');
240
290
  const path = await import('path');
241
- const content = fs.readFileSync(filePath, 'utf-8');
242
- const dir = path.dirname(path.resolve(filePath));
291
+ const dir = path.dirname(filePath);
292
+ const pkgs = new Set();
243
293
 
244
- // Parse packages list from YAML
245
- // Format: "packages:\n - 'apps/*'\n - 'packages/*'"
294
+ // Extract glob patterns from YAML
246
295
  const patterns = [];
247
296
  const lines = content.split('\n');
248
297
  let inPackages = false;
249
- for (const line of lines) {
250
- if (/^packages\s*:/.test(line)) { inPackages = true; continue; }
251
- if (inPackages) {
252
- if (/^\s+-/.test(line)) {
253
- const pattern = line.replace(/^\s+-\s*/, '').replace(/['"]/g, '').trim();
254
- if (pattern) patterns.push(pattern);
255
- } else if (/^\S/.test(line) && line.trim()) {
256
- inPackages = false;
257
- }
258
- }
259
- }
260
-
261
- if (patterns.length === 0) {
262
- throw new Error('No workspace packages found in pnpm-workspace.yaml');
263
- }
264
-
265
- // Split into includes and excludes
266
- const excludes = patterns.filter(p => p.startsWith('!')).map(p => p.slice(1));
267
- const includes = patterns.filter(p => !p.startsWith('!'));
268
-
269
- // Resolve patterns to directories containing package.json
270
- const workspaceDirs = [];
271
- for (const pattern of includes) {
272
- if (pattern.endsWith('/*') || pattern.endsWith('/**')) {
273
- // Glob: 'apps/*' -> list all subdirectories of apps/
274
- const parentDir = path.join(dir, pattern.replace(/\/\*\*?$/, ''));
275
- try {
276
- const entries = fs.readdirSync(parentDir, { withFileTypes: true });
277
- for (const entry of entries) {
278
- if (entry.isDirectory()) {
279
- const fullPath = path.join(parentDir, entry.name);
280
- const isExcluded = excludes.some(ex => fullPath.includes(ex) || entry.name === ex);
281
- if (!isExcluded) workspaceDirs.push(fullPath);
282
- }
283
- }
284
- } catch { /* directory doesn't exist — skip */ }
285
- } else {
286
- // Direct path: 'packages/shared'
287
- const fullPath = path.join(dir, pattern);
288
- try {
289
- fs.statSync(fullPath);
290
- workspaceDirs.push(fullPath);
291
- } catch { /* doesn't exist — skip */ }
298
+ for (const raw of lines) {
299
+ const line = raw.trim();
300
+ if (line === 'packages:') { inPackages = true; continue; }
301
+ if (inPackages && line.startsWith('-')) {
302
+ const pattern = line.replace(/^-\s*["']?/, '').replace(/["']?\s*$/, '');
303
+ patterns.push(pattern);
304
+ } else if (inPackages && !line.startsWith('#') && line !== '') {
305
+ break;
292
306
  }
293
307
  }
294
308
 
295
- // Collect workspace package names (to exclude from audit — they're internal)
296
- const workspacePackageNames = new Set();
297
- for (const wsDir of workspaceDirs) {
309
+ // For each pattern, look for package.json files
310
+ for (const pattern of patterns) {
311
+ const globDir = path.join(dir, pattern.replace('/*', '').replace('/**', ''));
298
312
  try {
299
- const pkg = JSON.parse(fs.readFileSync(path.join(wsDir, 'package.json'), 'utf-8'));
300
- if (pkg.name) workspacePackageNames.add(pkg.name);
301
- } catch { /* no package.json — skip */ }
313
+ const entries = fs.readdirSync(globDir, { withFileTypes: true });
314
+ for (const entry of entries) {
315
+ if (!entry.isDirectory()) continue;
316
+ const pkgJsonPath = path.join(globDir, entry.name, 'package.json');
317
+ try {
318
+ const pkgContent = fs.readFileSync(pkgJsonPath, 'utf-8');
319
+ const pkg = JSON.parse(pkgContent);
320
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
321
+ for (const name of Object.keys(deps)) pkgs.add(name);
322
+ } catch {}
323
+ }
324
+ } catch {}
302
325
  }
303
326
 
304
- // Read root package.json + all workspace package.json files, merge deps
305
- const allDeps = new Set();
306
- let workspaceCount = 0;
307
-
308
- // Root
327
+ // Also check root package.json
309
328
  try {
310
329
  const rootPkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
311
- if (rootPkg.dependencies) Object.keys(rootPkg.dependencies).forEach(d => allDeps.add(d));
312
- if (rootPkg.devDependencies) Object.keys(rootPkg.devDependencies).forEach(d => allDeps.add(d));
313
- } catch { /* no root package.json */ }
314
-
315
- // Workspaces
316
- for (const wsDir of workspaceDirs) {
317
- try {
318
- const pkg = JSON.parse(fs.readFileSync(path.join(wsDir, 'package.json'), 'utf-8'));
319
- if (pkg.dependencies) Object.keys(pkg.dependencies).forEach(d => allDeps.add(d));
320
- if (pkg.devDependencies) Object.keys(pkg.devDependencies).forEach(d => allDeps.add(d));
321
- workspaceCount++;
322
- } catch { /* no package.json in workspace dir */ }
323
- }
324
-
325
- // Remove internal workspace packages from audit list
326
- for (const name of workspacePackageNames) {
327
- allDeps.delete(name);
328
- }
330
+ const deps = { ...rootPkg.dependencies, ...rootPkg.devDependencies };
331
+ for (const name of Object.keys(deps)) pkgs.add(name);
332
+ } catch {}
329
333
 
330
- return {
331
- packages: [...allDeps],
332
- ecosystem: 'npm',
333
- lockfile: false,
334
- totalInFile: allDeps.size,
335
- workspaceCount,
336
- };
334
+ return [...pkgs];
337
335
  }
338
336
 
339
337
  async function readPackagesFromFile(filePath) {
340
338
  const fs = await import('fs');
341
339
  const path = await import('path');
340
+ const content = fs.readFileSync(filePath, 'utf-8');
342
341
  const basename = path.basename(filePath).toLowerCase();
343
342
 
344
- // pnpm-workspace.yaml — monorepo workspace scanner
343
+ // pnpm-workspace.yaml
345
344
  if (basename === 'pnpm-workspace.yaml' || basename === 'pnpm-workspace.yml') {
346
- return parsePnpmWorkspace(filePath);
345
+ const pkgs = await parsePnpmWorkspace(content, filePath);
346
+ return { packages: pkgs, ecosystem: 'npm', lockfile: false, totalInFile: pkgs.length };
347
347
  }
348
348
 
349
- const content = fs.readFileSync(filePath, 'utf-8');
350
-
351
349
  // package-lock.json
352
350
  if (basename === 'package-lock.json') {
353
351
  const pkgs = parseLockNpm(content);
@@ -360,20 +358,8 @@ async function readPackagesFromFile(filePath) {
360
358
  return { packages: pkgs, ecosystem: 'npm', lockfile: true, totalInFile: pkgs.length };
361
359
  }
362
360
 
363
- // pnpm-lock.yaml — also check for workspace file to give better detection
361
+ // pnpm-lock.yaml
364
362
  if (basename === 'pnpm-lock.yaml' || basename === 'pnpm-lock.yml') {
365
- const dir = path.dirname(path.resolve(filePath));
366
- const workspaceFile = path.join(dir, 'pnpm-workspace.yaml');
367
- let hasWorkspace = false;
368
- try { fs.statSync(workspaceFile); hasWorkspace = true; } catch {}
369
-
370
- if (hasWorkspace) {
371
- // Monorepo detected — use workspace-aware parsing
372
- const result = await parsePnpmWorkspace(workspaceFile);
373
- result.monorepoDetected = true;
374
- return result;
375
- }
376
-
377
363
  const pkgs = parseLockPnpm(content);
378
364
  return { packages: pkgs, ecosystem: 'npm', lockfile: true, totalInFile: pkgs.length };
379
365
  }
@@ -399,12 +385,97 @@ async function readPackagesFromFile(filePath) {
399
385
  return { packages: pkgs, ecosystem: 'pypi', lockfile: false };
400
386
  }
401
387
 
402
- throw new Error(`Unsupported file: ${basename}. Supported: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, pnpm-workspace.yaml, requirements.txt`);
388
+ // Cargo.toml
389
+ if (basename === 'cargo.toml') {
390
+ const pkgs = parseCargoToml(content);
391
+ return { packages: pkgs, ecosystem: 'cargo', lockfile: false };
392
+ }
393
+
394
+ // go.mod
395
+ if (basename === 'go.mod') {
396
+ const pkgs = parseGoMod(content);
397
+ return { packages: pkgs, ecosystem: 'golang', lockfile: false, totalInFile: pkgs.length };
398
+ }
399
+
400
+ // go.sum
401
+ if (basename === 'go.sum') {
402
+ const pkgs = parseGoSum(content);
403
+ return { packages: pkgs, ecosystem: 'golang', lockfile: true, totalInFile: pkgs.length };
404
+ }
405
+
406
+ throw new Error(`Unsupported file: ${basename}. Supported: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, pnpm-workspace.yaml, requirements.txt, Cargo.toml, go.mod, go.sum`);
407
+ }
408
+
409
+ /**
410
+ * Parse Cargo.toml
411
+ */
412
+ function parseCargoToml(content) {
413
+ const pkgs = new Set();
414
+ const lines = content.split('\n');
415
+ let inDeps = false;
416
+ for (const raw of lines) {
417
+ const line = raw.trim();
418
+ if (line.startsWith('[')) {
419
+ inDeps = /^\[(dev-)?dependencies\]/.test(line);
420
+ continue;
421
+ }
422
+ if (!inDeps || !line || line.startsWith('#')) continue;
423
+ const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
424
+ if (match) pkgs.add(match[1]);
425
+ }
426
+ return [...pkgs];
427
+ }
428
+
429
+ /**
430
+ * Parse go.mod
431
+ */
432
+ function parseGoMod(content) {
433
+ const pkgs = new Set();
434
+ const lines = content.split('\n');
435
+ let inRequireBlock = false;
436
+
437
+ for (const raw of lines) {
438
+ const line = raw.trim();
439
+ if (!line || line.startsWith('//')) continue;
440
+
441
+ if (/^require\s*\(\s*$/.test(line)) {
442
+ inRequireBlock = true;
443
+ continue;
444
+ }
445
+ if (inRequireBlock && line === ')') {
446
+ inRequireBlock = false;
447
+ continue;
448
+ }
449
+
450
+ if (inRequireBlock) {
451
+ const match = line.match(/^([^\s]+)\s+v[^\s]+/);
452
+ if (match) pkgs.add(match[1]);
453
+ continue;
454
+ }
455
+
456
+ const single = line.match(/^require\s+([^\s]+)\s+v[^\s]+/);
457
+ if (single) pkgs.add(single[1]);
458
+ }
459
+
460
+ return [...pkgs];
461
+ }
462
+
463
+ /**
464
+ * Parse go.sum
465
+ */
466
+ function parseGoSum(content) {
467
+ const pkgs = new Set();
468
+ for (const raw of content.split('\n')) {
469
+ const line = raw.trim();
470
+ if (!line) continue;
471
+ const match = line.match(/^([^\s]+)\s+v[^\s/]+/);
472
+ if (match) pkgs.add(match[1]);
473
+ }
474
+ return [...pkgs];
403
475
  }
404
476
 
405
477
  /**
406
478
  * Audit packages in batches of 20, in parallel.
407
- * Returns all results sorted by risk score (highest risk first).
408
479
  */
409
480
  async function auditBatched(packages, ecosystem, { onProgress } = {}) {
410
481
  const BATCH_SIZE = 20;
@@ -434,10 +505,10 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
434
505
 
435
506
  const all = results.flat();
436
507
 
437
- // Sort: CRITICAL first, then by score ascending (lower score = higher risk)
508
+ // Sort: CRITICAL first, then by score ascending
438
509
  all.sort((a, b) => {
439
- const aCrit = a.riskFlags?.includes('CRITICAL') ? 1 : 0;
440
- const bCrit = b.riskFlags?.includes('CRITICAL') ? 1 : 0;
510
+ const aCrit = hasCritical(a.riskFlags) ? 1 : 0;
511
+ const bCrit = hasCritical(b.riskFlags) ? 1 : 0;
441
512
  if (aCrit !== bCrit) return bCrit - aCrit;
442
513
  return (a.score || 100) - (b.score || 100);
443
514
  });
@@ -465,6 +536,8 @@ async function main() {
465
536
  const a = args[i];
466
537
  if (a === '--pypi') { ecosystem = 'pypi'; i++; }
467
538
  else if (a === '--npm') { ecosystem = 'npm'; i++; }
539
+ else if (a === '--cargo') { ecosystem = 'cargo'; i++; }
540
+ else if (a === '--golang' || a === '--go') { ecosystem = 'golang'; i++; }
468
541
  else if (a === '--json') { jsonOutput = true; i++; }
469
542
  else if (a === '--file' || a === '-f') {
470
543
  filePath = args[++i];
@@ -484,14 +557,7 @@ async function main() {
484
557
  ecosystem = result.ecosystem;
485
558
  isLockfile = result.lockfile || false;
486
559
  totalInFile = result.totalInFile || packages.length;
487
- if (result.workspaceCount) {
488
- if (!jsonOutput) console.log(clr(c.dim, `Monorepo: ${result.workspaceCount} workspace${result.workspaceCount > 1 ? 's' : ''} → ${totalInFile} unique external dependencies (${ecosystem})`));
489
- if (result.monorepoDetected && !jsonOutput) {
490
- console.log(clr(c.dim, ` (auto-detected pnpm-workspace.yaml next to ${filePath})`));
491
- }
492
- } else {
493
- if (!jsonOutput) console.log(clr(c.dim, `Detected ${totalInFile} packages from ${filePath} (${ecosystem})`));
494
- }
560
+ if (!jsonOutput) console.log(clr(c.dim, `Detected ${totalInFile} packages from ${filePath} (${ecosystem})`));
495
561
  } catch (err) {
496
562
  console.error(`Error reading ${filePath}: ${err.message}`);
497
563
  process.exit(1);
@@ -508,7 +574,6 @@ async function main() {
508
574
  let allResults;
509
575
 
510
576
  if (packages.length <= 20) {
511
- // Single batch — existing behavior
512
577
  if (!jsonOutput) process.stdout.write(clr(c.dim, `Scoring ${packages.length} ${ecosystem} package${packages.length > 1 ? 's' : ''}...`));
513
578
 
514
579
  try {
@@ -532,7 +597,6 @@ async function main() {
532
597
  if (!jsonOutput) process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
533
598
 
534
599
  } else {
535
- // Multi-batch for lock files
536
600
  const batches = Math.ceil(packages.length / 20);
537
601
  if (!jsonOutput) process.stdout.write(clr(c.dim, `Scanning ${packages.length} packages (${batches} batches in parallel)...`));
538
602
 
@@ -555,28 +619,29 @@ async function main() {
555
619
  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
556
620
  if (!jsonOutput) process.stdout.write(clr(c.dim, ` done in ${elapsed}s\n`));
557
621
 
558
- // JSON output: emit all results with summary
559
622
  if (jsonOutput) {
560
- const criticalCount = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
623
+ const criticalCount = allResults.filter(r => hasCritical(r.riskFlags)).length;
624
+ const provenanceCount = allResults.filter(r => r.hasProvenance).length;
561
625
  console.log(JSON.stringify({
562
626
  totalScanned: allResults.length,
563
627
  criticalCount,
628
+ provenanceCount,
564
629
  results: allResults,
565
630
  }, null, 2));
566
631
  process.exit(criticalCount > 0 ? 1 : 0);
567
632
  }
568
633
 
569
- // For lock files: show top 25 highest-risk packages
634
+ // Lock files: show top 25 highest-risk
570
635
  const MAX_DISPLAY = 25;
571
636
  const displayed = allResults.slice(0, MAX_DISPLAY);
572
- const criticalTotal = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
637
+ const criticalTotal = allResults.filter(r => hasCritical(r.riskFlags)).length;
573
638
  printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
574
639
  return;
575
640
  }
576
641
 
577
642
  if (!allResults || allResults.length === 0) {
578
643
  if (jsonOutput) {
579
- console.log(JSON.stringify({ totalScanned: 0, criticalCount: 0, results: [] }, null, 2));
644
+ console.log(JSON.stringify({ totalScanned: 0, criticalCount: 0, provenanceCount: 0, results: [] }, null, 2));
580
645
  } else {
581
646
  console.log('No results returned. Check package names and try again.');
582
647
  }
@@ -584,10 +649,12 @@ async function main() {
584
649
  }
585
650
 
586
651
  if (jsonOutput) {
587
- const criticalCount = allResults.filter(r => r.riskFlags?.includes('CRITICAL')).length;
652
+ const criticalCount = allResults.filter(r => hasCritical(r.riskFlags)).length;
653
+ const provenanceCount = allResults.filter(r => r.hasProvenance).length;
588
654
  console.log(JSON.stringify({
589
655
  totalScanned: allResults.length,
590
656
  criticalCount,
657
+ provenanceCount,
591
658
  results: allResults,
592
659
  }, null, 2));
593
660
  process.exit(criticalCount > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.5.0",
4
- "description": "Supply chain risk scorer for npm, PyPI, and Cargo packages — behavioral signals that can't be faked",
3
+ "version": "1.7.0",
4
+ "description": "Supply chain risk scorer for npm, PyPI, Cargo, and Go packages — behavioral signals that can't be faked",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "proof-of-commitment": "./index.js",
@@ -19,16 +19,18 @@
19
19
  "pypi",
20
20
  "cargo",
21
21
  "rust",
22
- "pnpm",
23
- "monorepo",
24
- "workspace",
22
+ "golang",
23
+ "go",
24
+ "go-modules",
25
25
  "dependencies",
26
26
  "audit",
27
27
  "risk",
28
28
  "behavioral",
29
29
  "commitment",
30
30
  "maintainer",
31
- "publisher"
31
+ "publisher",
32
+ "provenance",
33
+ "trusted-publishing"
32
34
  ],
33
35
  "author": "piiiico",
34
36
  "license": "MIT",