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.
- package/README.md +15 -10
- package/index.js +236 -169
- 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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|

|
|
150
153
|

|
|
151
154
|

|
|
155
|
+

|
|
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
|
-
##
|
|
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** |
|
|
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
|
|
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
|
|
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
|
|
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
|
-
]
|
|
78
|
+
];
|
|
72
79
|
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
95
|
-
padEnd(
|
|
120
|
+
padEnd(maintDisplay, COL.maintainers),
|
|
121
|
+
padEnd(dlDisplay, COL.downloads),
|
|
96
122
|
padEnd((pkg.ageYears || '?').toString().replace(/(\.\d).*/, '$1') + 'y', COL.age),
|
|
97
|
-
]
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
if (isNpm) {
|
|
126
|
+
rowParts.push(padEnd(provDisplay, COL.provenance));
|
|
127
|
+
}
|
|
98
128
|
|
|
99
|
-
console.log(
|
|
129
|
+
console.log(rowParts.join(' '));
|
|
100
130
|
|
|
101
131
|
// Show GitHub contributor context for CRITICAL packages with active communities
|
|
102
|
-
if (pkg.riskFlags
|
|
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 =
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
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.
|
|
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...]
|
|
142
|
-
npx proof-of-commitment --pypi [pkgs...]
|
|
143
|
-
npx proof-of-commitment --
|
|
144
|
-
npx proof-of-commitment --
|
|
145
|
-
npx proof-of-commitment --file
|
|
146
|
-
npx proof-of-commitment --file
|
|
147
|
-
npx proof-of-commitment --file
|
|
148
|
-
npx proof-of-commitment --file
|
|
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
|
-
--
|
|
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 --
|
|
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
|
|
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, '
|
|
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;
|
|
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
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
242
|
-
const
|
|
291
|
+
const dir = path.dirname(filePath);
|
|
292
|
+
const pkgs = new Set();
|
|
243
293
|
|
|
244
|
-
//
|
|
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
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
//
|
|
296
|
-
const
|
|
297
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
} catch {
|
|
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
|
|
343
|
+
// pnpm-workspace.yaml
|
|
345
344
|
if (basename === 'pnpm-workspace.yaml' || basename === 'pnpm-workspace.yml') {
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
508
|
+
// Sort: CRITICAL first, then by score ascending
|
|
438
509
|
all.sort((a, b) => {
|
|
439
|
-
const aCrit = a.riskFlags
|
|
440
|
-
const bCrit = b.riskFlags
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Supply chain risk scorer for npm, PyPI, and
|
|
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
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
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",
|