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 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; security-critical packages like `jsonwebtoken` and `bcrypt` are always flagged for review
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` | Set up a modular zsh environment — plugins, hooks, project auto-detection, and a GitHub dashboard |
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? They're running in my [dotfiles](https://github.com/bkness/dotfiles).
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
- process.exit(findings?.errors.length ? 1 : 0);
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.4.3",
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
  }
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from 'module';
2
- import { writeFile, mkdir } from 'fs/promises';
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';
@@ -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
- console.log(`\n${bold}⚒ Forged Scanner${reset}`);
118
- console.log(` Scanning: ${cwd}\n`);
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
- console.log(`${yellow}ℹ No package.json here — not a Node project, nothing to scan.${reset}\n`);
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
- process.stdout.write(' Verifying tarball integrity');
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
- process.stdout.write(`\r Verifying tarball integrity (${checked}/${t})...`);
180
+ write(`\r Verifying tarball integrity (${checked}/${t})...`);
166
181
  }
167
182
  }
168
183
  );
169
- process.stdout.write('\n');
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
- console.log(`${green}✔ No issues found. Looks clean.${reset}\n`);
230
+ log(`${green}✔ No issues found. Looks clean.${reset}\n`);
216
231
  } else {
217
232
  if (findings.errors.length > 0) {
218
- console.log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
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
- console.log(` ${red}✖${reset} ${label}${e.message}`);
236
+ log(` ${red}✖${reset} ${label}${e.message}`);
222
237
  if (e.detail) {
223
- console.log(` locked: ${e.detail.locked}`);
224
- console.log(` registry: ${e.detail.registry}`);
238
+ log(` locked: ${e.detail.locked}`);
239
+ log(` registry: ${e.detail.registry}`);
225
240
  }
226
241
  }
227
- console.log();
242
+ log();
228
243
  }
229
244
 
230
245
  if (findings.warnings.length > 0) {
231
- console.log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
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
- console.log(` ${yellow}⚠${reset} ${label}${w.message}`);
249
+ log(` ${yellow}⚠${reset} ${label}${w.message}`);
235
250
  }
236
- console.log();
251
+ log();
237
252
  }
238
253
  }
239
254
 
240
255
  // Verbose: show suppressed trusted rotations
241
256
  if (verbose && findings.suppressed.length > 0) {
242
- console.log(`${bold}SUPPRESSED — trusted, returning, or trusted-publishing publishers:${reset}`);
257
+ log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
243
258
  for (const s of findings.suppressed) {
244
- console.log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
259
+ log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
245
260
  }
246
- console.log();
261
+ log();
247
262
  }
248
263
 
249
264
  if (findings.info.length > 0) {
250
- for (const i of findings.info) console.log(` ${green}ℹ${reset} ${i}`);
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
- console.log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
269
+ log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
255
270
  }
256
- console.log();
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
- console.log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
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
- console.log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
329
+ log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
307
330
  }
308
331
 
309
332
  return findings;
@@ -1,6 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  import { existsSync, readFileSync } from 'fs';
3
- import { basename, resolve } from 'path';
3
+ import { basename } from 'path';
4
4
 
5
5
  export function detectProjectInfo() {
6
6
  const cwd = process.cwd();
@@ -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
- const { type, reason } = publisherChangeSeverity({
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
+ }