forged-cli 0.5.0 → 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 +11 -2
- package/bin/forged.js +8 -2
- package/package.json +1 -1
- package/src/commands/scan.js +48 -24
- package/src/utils/scanState.js +51 -0
package/README.md
CHANGED
|
@@ -79,6 +79,15 @@ One strong signal, or a medium plus a weak one, verifies the change. Weak signal
|
|
|
79
79
|
| `--verbose`, `-v` | Show suppressed maintainer rotations and verified publishers |
|
|
80
80
|
| `--report` | Save findings to `forged-report.json` |
|
|
81
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
|
+
```
|
|
82
91
|
|
|
83
92
|
**In CI:** `forged scan` exits with code `1` when it finds errors (malware, integrity mismatches, dangerous scripts), so it can fail a build:
|
|
84
93
|
|
|
@@ -115,11 +124,11 @@ These are advertised in `forged help` and currently print "coming soon":
|
|
|
115
124
|
|
|
116
125
|
| Command | Plan |
|
|
117
126
|
|---------|------|
|
|
118
|
-
| `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) |
|
|
119
128
|
| `forged new` | Create a new project with GitHub setup |
|
|
120
129
|
| `forged install` | Add Forged to an existing shell config |
|
|
121
130
|
|
|
122
|
-
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.
|
|
123
132
|
|
|
124
133
|
---
|
|
125
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
package/src/commands/scan.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isSuspiciousName } from '../utils/levenshtein.js';
|
|
|
5
5
|
import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
|
|
6
6
|
import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
|
|
7
7
|
import { queryOsv } from '../utils/osv.js';
|
|
8
|
+
import { previousResult, recordResult } from '../utils/scanState.js';
|
|
8
9
|
|
|
9
10
|
const green = '\x1b[32m';
|
|
10
11
|
const yellow = '\x1b[33m';
|
|
@@ -111,10 +112,14 @@ function saveMarkdownReport(reportPath, data) {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
114
|
-
const { report, reportFormat = 'json', verbose = false } = opts;
|
|
115
|
+
const { report, reportFormat = 'json', verbose = false, changed = false, quiet = false } = opts;
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
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`);
|
|
118
123
|
|
|
119
124
|
const findings = { warnings: [], errors: [], info: [], suppressed: [] };
|
|
120
125
|
let packagesVerified = 0;
|
|
@@ -125,9 +130,20 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
125
130
|
// Not a Node project: nothing to scan. Return before writing the scan
|
|
126
131
|
// cache, so the dotfiles badge keeps the last real result.
|
|
127
132
|
if (!existsSync(join(cwd, 'package.json'))) {
|
|
128
|
-
|
|
133
|
+
log(`${yellow}ℹ No package.json here — not a Node project, nothing to scan.${reset}\n`);
|
|
129
134
|
return null;
|
|
130
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
|
+
|
|
131
147
|
if (error) findings.errors.push({ message: error });
|
|
132
148
|
|
|
133
149
|
// 2. Dangerous scripts
|
|
@@ -156,16 +172,16 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
// 4. Tarball integrity verification
|
|
159
|
-
|
|
175
|
+
write(' Verifying tarball integrity');
|
|
160
176
|
const { error: integrityError, findings: integrityFindings, total, packages } = await verifyTarballIntegrity(
|
|
161
177
|
cwd,
|
|
162
178
|
(checked, t) => {
|
|
163
179
|
if (checked % 10 === 0 || checked === t) {
|
|
164
|
-
|
|
180
|
+
write(`\r Verifying tarball integrity (${checked}/${t})...`);
|
|
165
181
|
}
|
|
166
182
|
}
|
|
167
183
|
);
|
|
168
|
-
|
|
184
|
+
write('\n');
|
|
169
185
|
|
|
170
186
|
if (integrityError) {
|
|
171
187
|
findings.warnings.push({ message: integrityError });
|
|
@@ -211,50 +227,58 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
211
227
|
|
|
212
228
|
// Print results
|
|
213
229
|
if (findings.errors.length === 0 && findings.warnings.length === 0) {
|
|
214
|
-
|
|
230
|
+
log(`${green}✔ No issues found. Looks clean.${reset}\n`);
|
|
215
231
|
} else {
|
|
216
232
|
if (findings.errors.length > 0) {
|
|
217
|
-
|
|
233
|
+
log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
|
|
218
234
|
for (const e of findings.errors) {
|
|
219
235
|
const label = e.package ? `${e.package}@${e.version}: ` : '';
|
|
220
|
-
|
|
236
|
+
log(` ${red}✖${reset} ${label}${e.message}`);
|
|
221
237
|
if (e.detail) {
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
log(` locked: ${e.detail.locked}`);
|
|
239
|
+
log(` registry: ${e.detail.registry}`);
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
|
-
|
|
242
|
+
log();
|
|
227
243
|
}
|
|
228
244
|
|
|
229
245
|
if (findings.warnings.length > 0) {
|
|
230
|
-
|
|
246
|
+
log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
|
|
231
247
|
for (const w of findings.warnings) {
|
|
232
248
|
const label = w.package ? `${w.package}@${w.version}: ` : '';
|
|
233
|
-
|
|
249
|
+
log(` ${yellow}⚠${reset} ${label}${w.message}`);
|
|
234
250
|
}
|
|
235
|
-
|
|
251
|
+
log();
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
// Verbose: show suppressed trusted rotations
|
|
240
256
|
if (verbose && findings.suppressed.length > 0) {
|
|
241
|
-
|
|
257
|
+
log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
|
|
242
258
|
for (const s of findings.suppressed) {
|
|
243
|
-
|
|
259
|
+
log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
|
|
244
260
|
}
|
|
245
|
-
|
|
261
|
+
log();
|
|
246
262
|
}
|
|
247
263
|
|
|
248
264
|
if (findings.info.length > 0) {
|
|
249
|
-
for (const i of findings.info)
|
|
265
|
+
for (const i of findings.info) log(` ${green}ℹ${reset} ${i}`);
|
|
250
266
|
}
|
|
251
267
|
if (findings.suppressed.length > 0) {
|
|
252
268
|
const note = verbose ? '' : ' (run with --verbose to see them)';
|
|
253
|
-
|
|
269
|
+
log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
|
|
254
270
|
}
|
|
255
|
-
|
|
271
|
+
log();
|
|
272
|
+
|
|
273
|
+
log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
|
|
256
274
|
|
|
257
|
-
|
|
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
|
+
}
|
|
258
282
|
|
|
259
283
|
// Write local scan cache — dotfiles reads this to push telemetry
|
|
260
284
|
const flagged = [
|
|
@@ -302,7 +326,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
|
|
|
302
326
|
saveJsonReport(reportFile, reportData);
|
|
303
327
|
}
|
|
304
328
|
|
|
305
|
-
|
|
329
|
+
log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
|
|
306
330
|
}
|
|
307
331
|
|
|
308
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
|
+
}
|