forged-cli 0.4.2 → 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 +21 -2
- package/package.json +8 -2
- package/src/commands/readme.js +1 -1
- package/src/commands/scan.js +5 -3
- package/src/utils/detectProject.js +1 -1
- 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,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.
|
|
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
|
}
|
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,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
|
}
|
|
@@ -265,7 +264,10 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
265
264
|
writeFileSync(
|
|
266
265
|
join(homedir(), '.forged-scan-cache.json'),
|
|
267
266
|
JSON.stringify({
|
|
268
|
-
safe
|
|
267
|
+
// safe = no errors; warnings (e.g. a very fresh release) are advisory
|
|
268
|
+
safe: findings.errors.length === 0,
|
|
269
|
+
errors: findings.errors.length,
|
|
270
|
+
warnings: findings.warnings.length,
|
|
269
271
|
packages: packagesVerified,
|
|
270
272
|
flagged,
|
|
271
273
|
checked_at: new Date().toISOString(),
|
|
@@ -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
|
+
}
|