forged-cli 0.4.3 → 0.5.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,9 +55,28 @@ 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` |
63
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forged-cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.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,5 +1,4 @@
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';
@@ -239,7 +238,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
239
238
 
240
239
  // Verbose: show suppressed trusted rotations
241
240
  if (verbose && findings.suppressed.length > 0) {
242
- console.log(`${bold}SUPPRESSED — trusted, returning, or trusted-publishing publishers:${reset}`);
241
+ console.log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
243
242
  for (const s of findings.suppressed) {
244
243
  console.log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
245
244
  }
@@ -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();
@@ -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
+ }