forged-cli 0.4.3 → 0.6.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 +32 -4
- package/bin/forged.js +8 -2
- package/package.json +8 -2
- package/src/commands/readme.js +1 -1
- package/src/commands/scan.js +48 -25
- package/src/utils/detectProject.js +1 -1
- package/src/utils/scanState.js +51 -0
- package/src/utils/verifyIntegrity.js +36 -4
- package/src/utils/verifyPublisher.js +135 -0
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ forged readme
|
|
|
45
45
|
- **Known malware** — checked against [OSV.dev](https://osv.dev), including OpenSSF `MAL-` reports and GitHub malware advisories (CWE-506)
|
|
46
46
|
- **Brand-new versions** — flags versions published in the last 72 hours, the window when hijacked releases usually go unnoticed (packages that routinely release every week or two, like `caniuse-lite`, are skipped)
|
|
47
47
|
- **Tarball integrity** — lockfile hashes vs. the npm registry
|
|
48
|
-
- **Publisher changes** — flags a version whose publisher has never released this package before. Returning maintainers, known teams, and moves to npm trusted publishing are suppressed;
|
|
48
|
+
- **Publisher changes** — flags a version whose publisher has never released this package before. Returning maintainers, known teams, and moves to npm trusted publishing are suppressed. A new publisher is **auto-verified** when the evidence holds up (see below); otherwise it's flagged as needing review. Security-critical packages like `jsonwebtoken` and `bcrypt` are always flagged for review, with the evidence attached
|
|
49
49
|
- **Typosquats** — names one or two characters away from popular packages (one for short names, so `tsx` isn't mistaken for `nx`)
|
|
50
50
|
- **Suspicious install scripts** — `curl | sh`, `eval`, base64 decoding and similar
|
|
51
51
|
|
|
@@ -55,11 +55,39 @@ Summary: 0 error(s), 1 warning(s), 39 suppressed
|
|
|
55
55
|
ℹ Checked 760 packages against OSV.dev known-malware database
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
### Publisher auto-verify
|
|
59
|
+
|
|
60
|
+
Most publisher changes are ordinary maintainer rotations. Before flagging one, the scanner checks for evidence the new publisher belongs:
|
|
61
|
+
|
|
62
|
+
| Signal | Strength | Check |
|
|
63
|
+
|--------|----------|-------|
|
|
64
|
+
| Provenance | strong | The release carries a signed npm provenance attestation |
|
|
65
|
+
| Prior maintainer | strong | The publisher was already a maintainer on the previous version |
|
|
66
|
+
| Repo contributor | medium | The publisher's npm name is a contributor to the package's GitHub repo |
|
|
67
|
+
| Org email | weak | The publisher's email domain matches the repo owner (`@auth0.com` → `auth0/…`) |
|
|
68
|
+
| Trusted co-maintainer | weak | A known trusted publisher also maintains the package |
|
|
69
|
+
|
|
70
|
+
One strong signal, or a medium plus a weak one, verifies the change. Weak signals alone never do. The contributor check calls the GitHub API: set `GITHUB_TOKEN` or log in with `gh` for 5,000 requests/hour instead of 60. If GitHub can't be reached the signal is skipped, not counted against the publisher.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
⚠ jsonwebtoken@9.0.3: Publisher changed from "charlesrea" to "julien.wollscheid" — security-critical package, review this release (email domain matches the repo owner)
|
|
74
|
+
~ react-native-web@0.21.3: Publisher changed from "necolas" to "zoontek" — verified: contributor to the GitHub repo; co-maintains with a trusted publisher
|
|
75
|
+
```
|
|
76
|
+
|
|
58
77
|
| Flag | Description |
|
|
59
78
|
|------|-------------|
|
|
60
|
-
| `--verbose`, `-v` | Show suppressed maintainer rotations |
|
|
79
|
+
| `--verbose`, `-v` | Show suppressed maintainer rotations and verified publishers |
|
|
61
80
|
| `--report` | Save findings to `forged-report.json` |
|
|
62
81
|
| `--report-md` | Save findings to `forged-report.md` |
|
|
82
|
+
| `--changed` | Skip if `package.json` + lockfile match a scan from the last 7 days (rescans weekly anyway — new malware advisories land even when your lockfile doesn't change) |
|
|
83
|
+
| `--quiet`, `-q` | Print nothing unless something is flagged, then one line — for shell hooks |
|
|
84
|
+
|
|
85
|
+
**Scan on `cd`:** `--changed --quiet` is cheap enough to run every time you enter a project (≈50ms when unchanged). A zsh example:
|
|
86
|
+
|
|
87
|
+
```zsh
|
|
88
|
+
_forged_autoscan() { [[ -f package-lock.json ]] && { forged scan --changed --quiet & } 2>/dev/null }
|
|
89
|
+
chpwd_functions+=(_forged_autoscan)
|
|
90
|
+
```
|
|
63
91
|
|
|
64
92
|
**In CI:** `forged scan` exits with code `1` when it finds errors (malware, integrity mismatches, dangerous scripts), so it can fail a build:
|
|
65
93
|
|
|
@@ -96,11 +124,11 @@ These are advertised in `forged help` and currently print "coming soon":
|
|
|
96
124
|
|
|
97
125
|
| Command | Plan |
|
|
98
126
|
|---------|------|
|
|
99
|
-
| `forged init` |
|
|
127
|
+
| `forged init` | Guided setup of a modular zsh environment — plugins, hooks, project auto-detection, and the GitHub workflow (Ctrl+G: issue → branch → commit → PR, project boards, label and template pickers) |
|
|
100
128
|
| `forged new` | Create a new project with GitHub setup |
|
|
101
129
|
| `forged install` | Add Forged to an existing shell config |
|
|
102
130
|
|
|
103
|
-
Want them today?
|
|
131
|
+
Want them today? Everything above, including the full GitHub workflow, already runs in my [dotfiles](https://github.com/bkness/dotfiles) — `forged init` will package it.
|
|
104
132
|
|
|
105
133
|
---
|
|
106
134
|
|
package/bin/forged.js
CHANGED
|
@@ -38,6 +38,9 @@ ${Object.entries(planned).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`).
|
|
|
38
38
|
|
|
39
39
|
Scan options:
|
|
40
40
|
forged scan [path] [--verbose|-v] [--report|--report-md]
|
|
41
|
+
[--changed] [--quiet|-q]
|
|
42
|
+
--changed skip if package.json + lockfile match a scan from the last 7 days
|
|
43
|
+
--quiet print nothing unless something is flagged (for shell hooks)
|
|
41
44
|
Exits 1 when errors are found, so it can fail a CI job.
|
|
42
45
|
`);
|
|
43
46
|
process.exit(0);
|
|
@@ -66,11 +69,14 @@ if (command === 'scan') {
|
|
|
66
69
|
const report = args.includes('--report') || args.includes('--report-md');
|
|
67
70
|
const reportFormat = args.includes('--report-md') ? 'markdown' : 'json';
|
|
68
71
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
72
|
+
const changed = args.includes('--changed');
|
|
73
|
+
const quiet = args.includes('--quiet') || args.includes('-q');
|
|
69
74
|
// Any dash-prefixed arg is a flag, not the path (so `-v` isn't scanned as a dir)
|
|
70
75
|
const pathArg = args.find(a => !a.startsWith('-'));
|
|
71
76
|
const targetPath = pathArg ? resolve(pathArg) : process.cwd();
|
|
72
|
-
const findings = await scanCommand(targetPath, { report, reportFormat, verbose });
|
|
73
|
-
|
|
77
|
+
const findings = await scanCommand(targetPath, { report, reportFormat, verbose, changed, quiet });
|
|
78
|
+
const errorCount = findings?.skipped ? findings.errorCount : findings?.errors.length;
|
|
79
|
+
process.exit(errorCount ? 1 : 0);
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
if (planned[command]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forged-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A complete dev environment toolkit — shell, workflows, and project scaffolding in one install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -29,9 +29,15 @@
|
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"start": "node bin/forged.js",
|
|
32
|
-
"test": "node --test"
|
|
32
|
+
"test": "node --test",
|
|
33
|
+
"lint": "eslint ."
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"inquirer": "^13.4.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^10.0.1",
|
|
40
|
+
"eslint": "^10.11.0",
|
|
41
|
+
"globals": "^17.12.0"
|
|
36
42
|
}
|
|
37
43
|
}
|
package/src/commands/readme.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
|
-
import { writeFile
|
|
2
|
+
import { writeFile } from 'fs/promises';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { detectProjectInfo } from '../utils/detectProject.js';
|
|
5
5
|
import { generateMarkdown } from '../utils/generateMarkdown.js';
|
package/src/commands/scan.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync, lstatSync, writeFileSync } from 'fs';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
2
|
import { join, basename } from 'path';
|
|
4
3
|
import { homedir } from 'os';
|
|
5
4
|
import { isSuspiciousName } from '../utils/levenshtein.js';
|
|
6
5
|
import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
|
|
7
6
|
import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
|
|
8
7
|
import { queryOsv } from '../utils/osv.js';
|
|
8
|
+
import { previousResult, recordResult } from '../utils/scanState.js';
|
|
9
9
|
|
|
10
10
|
const green = '\x1b[32m';
|
|
11
11
|
const yellow = '\x1b[33m';
|
|
@@ -112,10 +112,14 @@ function saveMarkdownReport(reportPath, data) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
115
|
-
const { report, reportFormat = 'json', verbose = false } = opts;
|
|
115
|
+
const { report, reportFormat = 'json', verbose = false, changed = false, quiet = false } = opts;
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// --quiet: no output unless something is flagged (for shell hooks)
|
|
118
|
+
const log = quiet ? () => {} : console.log;
|
|
119
|
+
const write = quiet ? () => {} : (text) => process.stdout.write(text);
|
|
120
|
+
|
|
121
|
+
log(`\n${bold}⚒ Forged Scanner${reset}`);
|
|
122
|
+
log(` Scanning: ${cwd}\n`);
|
|
119
123
|
|
|
120
124
|
const findings = { warnings: [], errors: [], info: [], suppressed: [] };
|
|
121
125
|
let packagesVerified = 0;
|
|
@@ -126,9 +130,20 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
126
130
|
// Not a Node project: nothing to scan. Return before writing the scan
|
|
127
131
|
// cache, so the dotfiles badge keeps the last real result.
|
|
128
132
|
if (!existsSync(join(cwd, 'package.json'))) {
|
|
129
|
-
|
|
133
|
+
log(`${yellow}ℹ No package.json here — not a Node project, nothing to scan.${reset}\n`);
|
|
130
134
|
return null;
|
|
131
135
|
}
|
|
136
|
+
|
|
137
|
+
// --changed: skip when package.json + lockfile match a recent scan
|
|
138
|
+
if (changed) {
|
|
139
|
+
const prev = previousResult(cwd);
|
|
140
|
+
if (prev) {
|
|
141
|
+
log(`${green}✔ Unchanged since the scan on ${prev.scannedAt.slice(0, 10)} — skipped ` +
|
|
142
|
+
`(${prev.errors} error(s), ${prev.warnings} warning(s)).${reset}\n`);
|
|
143
|
+
return { skipped: true, errorCount: prev.errors, warningCount: prev.warnings };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
if (error) findings.errors.push({ message: error });
|
|
133
148
|
|
|
134
149
|
// 2. Dangerous scripts
|
|
@@ -157,16 +172,16 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
// 4. Tarball integrity verification
|
|
160
|
-
|
|
175
|
+
write(' Verifying tarball integrity');
|
|
161
176
|
const { error: integrityError, findings: integrityFindings, total, packages } = await verifyTarballIntegrity(
|
|
162
177
|
cwd,
|
|
163
178
|
(checked, t) => {
|
|
164
179
|
if (checked % 10 === 0 || checked === t) {
|
|
165
|
-
|
|
180
|
+
write(`\r Verifying tarball integrity (${checked}/${t})...`);
|
|
166
181
|
}
|
|
167
182
|
}
|
|
168
183
|
);
|
|
169
|
-
|
|
184
|
+
write('\n');
|
|
170
185
|
|
|
171
186
|
if (integrityError) {
|
|
172
187
|
findings.warnings.push({ message: integrityError });
|
|
@@ -212,50 +227,58 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
212
227
|
|
|
213
228
|
// Print results
|
|
214
229
|
if (findings.errors.length === 0 && findings.warnings.length === 0) {
|
|
215
|
-
|
|
230
|
+
log(`${green}✔ No issues found. Looks clean.${reset}\n`);
|
|
216
231
|
} else {
|
|
217
232
|
if (findings.errors.length > 0) {
|
|
218
|
-
|
|
233
|
+
log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
|
|
219
234
|
for (const e of findings.errors) {
|
|
220
235
|
const label = e.package ? `${e.package}@${e.version}: ` : '';
|
|
221
|
-
|
|
236
|
+
log(` ${red}✖${reset} ${label}${e.message}`);
|
|
222
237
|
if (e.detail) {
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
log(` locked: ${e.detail.locked}`);
|
|
239
|
+
log(` registry: ${e.detail.registry}`);
|
|
225
240
|
}
|
|
226
241
|
}
|
|
227
|
-
|
|
242
|
+
log();
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
if (findings.warnings.length > 0) {
|
|
231
|
-
|
|
246
|
+
log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
|
|
232
247
|
for (const w of findings.warnings) {
|
|
233
248
|
const label = w.package ? `${w.package}@${w.version}: ` : '';
|
|
234
|
-
|
|
249
|
+
log(` ${yellow}⚠${reset} ${label}${w.message}`);
|
|
235
250
|
}
|
|
236
|
-
|
|
251
|
+
log();
|
|
237
252
|
}
|
|
238
253
|
}
|
|
239
254
|
|
|
240
255
|
// Verbose: show suppressed trusted rotations
|
|
241
256
|
if (verbose && findings.suppressed.length > 0) {
|
|
242
|
-
|
|
257
|
+
log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
|
|
243
258
|
for (const s of findings.suppressed) {
|
|
244
|
-
|
|
259
|
+
log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
|
|
245
260
|
}
|
|
246
|
-
|
|
261
|
+
log();
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
if (findings.info.length > 0) {
|
|
250
|
-
for (const i of findings.info)
|
|
265
|
+
for (const i of findings.info) log(` ${green}ℹ${reset} ${i}`);
|
|
251
266
|
}
|
|
252
267
|
if (findings.suppressed.length > 0) {
|
|
253
268
|
const note = verbose ? '' : ' (run with --verbose to see them)';
|
|
254
|
-
|
|
269
|
+
log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
|
|
255
270
|
}
|
|
256
|
-
|
|
271
|
+
log();
|
|
272
|
+
|
|
273
|
+
log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
|
|
257
274
|
|
|
258
|
-
|
|
275
|
+
recordResult(cwd, findings);
|
|
276
|
+
|
|
277
|
+
if (quiet && (findings.errors.length || findings.warnings.length)) {
|
|
278
|
+
const color = findings.errors.length ? red : yellow;
|
|
279
|
+
console.log(`${color}⚒ forged: ${findings.errors.length} error(s), ${findings.warnings.length} warning(s) ` +
|
|
280
|
+
`in ${basename(cwd)} — run \`forged scan\` for details${reset}`);
|
|
281
|
+
}
|
|
259
282
|
|
|
260
283
|
// Write local scan cache — dotfiles reads this to push telemetry
|
|
261
284
|
const flagged = [
|
|
@@ -303,7 +326,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
303
326
|
saveJsonReport(reportFile, reportData);
|
|
304
327
|
}
|
|
305
328
|
|
|
306
|
-
|
|
329
|
+
log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
|
|
307
330
|
}
|
|
308
331
|
|
|
309
332
|
return findings;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
// An unchanged project still gets rescanned after this long: new malware
|
|
7
|
+
// advisories land on OSV even when the lockfile doesn't move.
|
|
8
|
+
export const RESCAN_DAYS = 7;
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const statePath = () => join(homedir(), '.forged-scan-state.json');
|
|
12
|
+
|
|
13
|
+
// package.json is part of the fingerprint too — the install-script check reads it
|
|
14
|
+
export function fingerprint(cwd) {
|
|
15
|
+
const hash = createHash('sha256');
|
|
16
|
+
for (const file of ['package.json', 'package-lock.json']) {
|
|
17
|
+
const path = join(cwd, file);
|
|
18
|
+
hash.update(`${file}\0`);
|
|
19
|
+
if (existsSync(path)) hash.update(readFileSync(path));
|
|
20
|
+
hash.update('\0');
|
|
21
|
+
}
|
|
22
|
+
return hash.digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readState() {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(statePath(), 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The last result for this project if nothing changed since and it's recent
|
|
34
|
+
// enough to trust, else null (→ scan again)
|
|
35
|
+
export function previousResult(cwd, now = Date.now()) {
|
|
36
|
+
const entry = readState()[cwd];
|
|
37
|
+
if (!entry || entry.fingerprint !== fingerprint(cwd)) return null;
|
|
38
|
+
if (now - Date.parse(entry.scannedAt) > RESCAN_DAYS * DAY_MS) return null;
|
|
39
|
+
return entry;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recordResult(cwd, findings, now = Date.now()) {
|
|
43
|
+
const state = readState();
|
|
44
|
+
state[cwd] = {
|
|
45
|
+
fingerprint: fingerprint(cwd),
|
|
46
|
+
scannedAt: new Date(now).toISOString(),
|
|
47
|
+
errors: findings.errors.length,
|
|
48
|
+
warnings: findings.warnings.length,
|
|
49
|
+
};
|
|
50
|
+
writeFileSync(statePath(), JSON.stringify(state, null, 2), 'utf8');
|
|
51
|
+
}
|
|
@@ -3,6 +3,12 @@ import { join } from 'path';
|
|
|
3
3
|
import { publisherChangeSeverity } from './trustedPublishers.js';
|
|
4
4
|
import { hoursSincePublish, isRoutineRelease, FRESH_HOURS } from './freshness.js';
|
|
5
5
|
import { previousVersion } from './semver.js';
|
|
6
|
+
import {
|
|
7
|
+
collectRegistryEvidence,
|
|
8
|
+
fetchContributors,
|
|
9
|
+
judgeEvidence,
|
|
10
|
+
parseGithubRepo,
|
|
11
|
+
} from './verifyPublisher.js';
|
|
6
12
|
|
|
7
13
|
// Fetch registry metadata with a simple in-memory cache to avoid duplicate requests
|
|
8
14
|
const registryCache = new Map();
|
|
@@ -137,18 +143,44 @@ export async function verifyTarballIntegrity(cwd, onProgress) {
|
|
|
137
143
|
v !== meta.version &&
|
|
138
144
|
registryMeta.versions[v]?._npmUser?.name === currPublisher &&
|
|
139
145
|
Date.parse(registryMeta.time?.[v]) < publishedAt);
|
|
140
|
-
|
|
146
|
+
let { type, reason } = publisherChangeSeverity({
|
|
141
147
|
pkg: name, from: prevPublisher, to: currPublisher, returning,
|
|
142
148
|
});
|
|
149
|
+
let verification = null;
|
|
150
|
+
if (reason === 'new' || reason === 'review') {
|
|
151
|
+
const registryEvidence = collectRegistryEvidence({
|
|
152
|
+
registryMeta,
|
|
153
|
+
version: meta.version,
|
|
154
|
+
prevVersion,
|
|
155
|
+
publisher: currPublisher,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const gh = parseGithubRepo(versionData.repository?.url);
|
|
159
|
+
let contributor = null;
|
|
160
|
+
if (gh) {
|
|
161
|
+
const logins = await fetchContributors(gh.owner, gh.repo);
|
|
162
|
+
contributor = logins ? logins.has(currPublisher.toLowerCase()) : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
verification = judgeEvidence({ ...registryEvidence, contributor });
|
|
166
|
+
|
|
167
|
+
if (reason === 'new' && verification.verified) {
|
|
168
|
+
type = 'info';
|
|
169
|
+
reason = 'verified';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const evidence = verification?.reasons.length ? ` (${verification.reasons.join('; ')})` : '';
|
|
143
174
|
const change = `Publisher changed from "${prevPublisher}" to "${currPublisher}"`;
|
|
144
175
|
const message = {
|
|
145
|
-
review: `${change} — security-critical package, review this release`,
|
|
176
|
+
review: `${change} — security-critical package, review this release${evidence}`,
|
|
177
|
+
verified: `${change} — verified: ${verification?.reasons.join('; ')}`,
|
|
146
178
|
oidc: `Moved from "${prevPublisher}" to npm trusted publishing (GitHub Actions)`,
|
|
147
179
|
returning: `${change} (returning maintainer)`,
|
|
148
180
|
trusted: `${change} (trusted publisher)`,
|
|
149
|
-
new: `${change} — first release by this account`,
|
|
181
|
+
new: `${change} — first release by this account, needs review${evidence}`,
|
|
150
182
|
}[reason];
|
|
151
|
-
findings.push({ type, package: name, version: meta.version, message });
|
|
183
|
+
findings.push({ type, package: name, version: meta.version, message, verification });
|
|
152
184
|
}
|
|
153
185
|
}
|
|
154
186
|
}));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { NPM_TEAM, TRUSTED_COMMUNITY } from './trustedPublishers.js';
|
|
3
|
+
|
|
4
|
+
export function parseGithubRepo(url) {
|
|
5
|
+
if (!url || typeof url !== 'string') return null;
|
|
6
|
+
|
|
7
|
+
const normalized = url
|
|
8
|
+
.replace(/^git\+/, '')
|
|
9
|
+
.replace(/^git:/, 'https:')
|
|
10
|
+
.trim();
|
|
11
|
+
|
|
12
|
+
const m = normalized.match(
|
|
13
|
+
/(?:https?:\/\/|git@|ssh:\/\/git@)?github\.com[:/]+([^/]+)\/([^/#]+?)(?:\.git)?(?:[/?#].*)?$/i,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function collectRegistryEvidence({
|
|
20
|
+
registryMeta,
|
|
21
|
+
version,
|
|
22
|
+
prevVersion,
|
|
23
|
+
publisher,
|
|
24
|
+
}) {
|
|
25
|
+
const curr = registryMeta?.versions?.[version] ?? {};
|
|
26
|
+
const prev = registryMeta?.versions?.[prevVersion] ?? {};
|
|
27
|
+
|
|
28
|
+
const provenance = !!curr.dist?.attestations?.provenance;
|
|
29
|
+
|
|
30
|
+
const prevMaintainers = Array.isArray(prev.maintainers)
|
|
31
|
+
? prev.maintainers
|
|
32
|
+
: [];
|
|
33
|
+
const currMaintainers = Array.isArray(curr.maintainers)
|
|
34
|
+
? curr.maintainers
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
const priorMaintainer = prevMaintainers.some((m) => {
|
|
38
|
+
const name = typeof m === 'string' ? m : m?.name;
|
|
39
|
+
return name === publisher;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const email = curr?._npmUser?.email ?? curr?.publisher?.email ?? null;
|
|
43
|
+
const owner =
|
|
44
|
+
parseGithubRepo(curr?.repository?.url ?? prev?.repository?.url ?? null)
|
|
45
|
+
?.owner ?? null;
|
|
46
|
+
const orgEmail =
|
|
47
|
+
!!email &&
|
|
48
|
+
!!owner &&
|
|
49
|
+
email.toLowerCase().split('@')?.[1]?.split('.')?.[0]?.toLowerCase() ===
|
|
50
|
+
owner.toLowerCase();
|
|
51
|
+
|
|
52
|
+
const trustedCoMaintainer = currMaintainers.some((m) => {
|
|
53
|
+
const name = typeof m === 'string' ? m : m?.name;
|
|
54
|
+
if (!name || name === publisher) return false;
|
|
55
|
+
return NPM_TEAM.has(name) || TRUSTED_COMMUNITY.has(name);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { provenance, priorMaintainer, orgEmail, trustedCoMaintainer };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// GITHUB_TOKEN if set, else the gh CLI's token, else none (60 req/hr).
|
|
62
|
+
// Looked up once per run — spawning gh for every repo adds up.
|
|
63
|
+
let cachedToken;
|
|
64
|
+
function githubToken() {
|
|
65
|
+
if (cachedToken !== undefined) return cachedToken;
|
|
66
|
+
cachedToken = process.env.GITHUB_TOKEN || readGhToken();
|
|
67
|
+
return cachedToken;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readGhToken() {
|
|
71
|
+
try {
|
|
72
|
+
return execFileSync('gh', ['auth', 'token'], {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
}).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const contributorCache = new Map();
|
|
82
|
+
|
|
83
|
+
// → Set of lowercase GitHub logins (top 100 contributors), or null if unknown
|
|
84
|
+
export async function fetchContributors(owner, repo, fetchImpl = fetch) {
|
|
85
|
+
const key = `${owner}/${repo}`.toLowerCase();
|
|
86
|
+
if (contributorCache.has(key)) return contributorCache.get(key);
|
|
87
|
+
|
|
88
|
+
let result;
|
|
89
|
+
const token = githubToken();
|
|
90
|
+
const headers = { accept: 'application/vnd.github+json' };
|
|
91
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetchImpl(
|
|
95
|
+
`https://api.github.com/repos/${owner}/${repo}/contributors?per_page=100`,
|
|
96
|
+
{ headers },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
result = null;
|
|
101
|
+
} else {
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
result = new Set((Array.isArray(data) ? data : []).map((c) => String(c?.login || '').toLowerCase()).filter(Boolean));
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
result = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
contributorCache.set(key, result);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SIGNALS = {
|
|
114
|
+
provenance: { weight: 'strong', label: 'signed provenance from the package repo' },
|
|
115
|
+
priorMaintainer: { weight: 'strong', label: 'already a maintainer before this release' },
|
|
116
|
+
contributor: { weight: 'medium', label: 'contributor to the GitHub repo' },
|
|
117
|
+
orgEmail: { weight: 'weak', label: 'email domain matches the repo owner' },
|
|
118
|
+
trustedCoMaintainer: { weight: 'weak', label: 'co-maintains with a trusted publisher' },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// evidence: collectRegistryEvidence(...) plus contributor: true | false | null
|
|
122
|
+
// → { verified, reasons: [labels of the signals present] }
|
|
123
|
+
export function judgeEvidence(evidence) {
|
|
124
|
+
const present = Object.keys(SIGNALS).filter((key) => evidence?.[key] === true);
|
|
125
|
+
const count = (weight) => present.filter((key) => SIGNALS[key].weight === weight).length;
|
|
126
|
+
|
|
127
|
+
const strong = count('strong');
|
|
128
|
+
const medium = count('medium');
|
|
129
|
+
const weak = count('weak');
|
|
130
|
+
|
|
131
|
+
const verified = strong >= 1 || (medium >= 1 && weak >= 1);
|
|
132
|
+
const reasons = present.map((key) => SIGNALS[key].label);
|
|
133
|
+
|
|
134
|
+
return { verified, reasons };
|
|
135
|
+
}
|