@rainy-updates/cli 0.5.2-rc.1 → 0.5.2-rc.2
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/CHANGELOG.md +25 -0
- package/README.md +5 -1
- package/dist/bin/cli.js +20 -1
- package/dist/commands/audit/fetcher.d.ts +2 -6
- package/dist/commands/audit/fetcher.js +2 -79
- package/dist/commands/audit/mapper.d.ts +8 -1
- package/dist/commands/audit/mapper.js +105 -9
- package/dist/commands/audit/parser.js +36 -2
- package/dist/commands/audit/runner.js +179 -15
- package/dist/commands/audit/sources/github.d.ts +2 -0
- package/dist/commands/audit/sources/github.js +125 -0
- package/dist/commands/audit/sources/index.d.ts +6 -0
- package/dist/commands/audit/sources/index.js +92 -0
- package/dist/commands/audit/sources/osv.d.ts +2 -0
- package/dist/commands/audit/sources/osv.js +131 -0
- package/dist/commands/audit/sources/types.d.ts +21 -0
- package/dist/commands/audit/sources/types.js +1 -0
- package/dist/commands/audit/targets.d.ts +20 -0
- package/dist/commands/audit/targets.js +314 -0
- package/dist/commands/changelog/fetcher.d.ts +9 -0
- package/dist/commands/changelog/fetcher.js +130 -0
- package/dist/types/index.d.ts +34 -1
- package/dist/ui/tui.d.ts +6 -0
- package/dist/ui/tui.js +50 -0
- package/package.json +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.5.2-rc.2] - 2026-02-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Audit RC2 overhaul**:
|
|
10
|
+
- `audit --summary` / `audit --report summary` groups noisy advisory lists into affected-package summaries.
|
|
11
|
+
- `audit --source auto|osv|github|all` adds multi-source security lookups, with `auto` querying **OSV.dev + GitHub Advisory Database** and merging results.
|
|
12
|
+
- Lockfile-backed version inference for `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, and basic `bun.lock` workspace entries resolves real installed versions for complex ranges.
|
|
13
|
+
- JSON audit output now includes package summaries, source metadata, and resolution statistics.
|
|
14
|
+
- Source-health reporting now distinguishes `ok`, `partial`, and `failed` advisory backends so partial coverage is explicit instead of silent.
|
|
15
|
+
- **Interactive TUI Engine**: An `ink`-based Terminal User Interface for interactive dependency updates, featuring semantic diff coloring and keyboard navigation (`src/ui/tui.tsx`).
|
|
16
|
+
- **Changelog Fetcher**: Implemented `changelog/fetcher.ts` to retrieve release notes dynamically from GitHub API.
|
|
17
|
+
- Utilizes `bun:sqlite` backed `VersionCache` to prevent API rate limit (403) errors.
|
|
18
|
+
- Strictly lazy-loaded to preserve zero-overhead startup time.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Audit patch planning now chooses the lowest safe patched version that clears all detected vulnerable ranges, avoiding unnecessary major jumps during `audit --fix`.
|
|
23
|
+
- Audit findings now record the current installed version and contributing advisory sources per finding.
|
|
24
|
+
- Audit now warns when one advisory source degrades and fails the run when all selected advisory sources are unavailable.
|
|
25
|
+
- Audit terminal output now shows advisory-source health directly in table and summary modes, so degraded coverage is visible without reading JSON.
|
|
26
|
+
- Resolved TypeScript JSX compiler errors by properly exposing `"jsx": "react-jsx"` in `tsconfig.json`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
5
30
|
## [0.5.2] - 2026-02-27
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ npx @rainy-updates/cli ci --workspace --mode strict
|
|
|
59
59
|
|
|
60
60
|
### Security & health (_new in v0.5.1_)
|
|
61
61
|
|
|
62
|
-
- `audit` — scan dependencies for CVEs using [OSV.dev](https://osv.dev)
|
|
62
|
+
- `audit` — scan dependencies for CVEs using [OSV.dev](https://osv.dev) plus GitHub Advisory Database, with lockfile-aware version inference
|
|
63
63
|
- `health` — detect stale, deprecated, and unmaintained packages before they become liabilities
|
|
64
64
|
- `bisect` — binary-search across semver versions to find the exact version that broke your tests
|
|
65
65
|
|
|
@@ -99,9 +99,13 @@ npx @rainy-updates/cli baseline --check --file .artifacts/deps-baseline.json --w
|
|
|
99
99
|
# 8) Scan for known CVEs ── NEW in v0.5.1
|
|
100
100
|
npx @rainy-updates/cli audit
|
|
101
101
|
npx @rainy-updates/cli audit --severity high
|
|
102
|
+
npx @rainy-updates/cli audit --summary
|
|
103
|
+
npx @rainy-updates/cli audit --source osv
|
|
102
104
|
npx @rainy-updates/cli audit --fix # prints the patching npm install command
|
|
103
105
|
rup audit --severity high # if installed
|
|
104
106
|
|
|
107
|
+
`audit` prefers npm/pnpm lockfiles today for exact installed-version inference, and now also reads simple `bun.lock` workspace entries when available. It reports source-health warnings when OSV or GitHub returns only partial coverage.
|
|
108
|
+
|
|
105
109
|
# 9) Check dependency maintenance health ── NEW in v0.5.1
|
|
106
110
|
npx @rainy-updates/cli health
|
|
107
111
|
npx @rainy-updates/cli health --stale 6m # flag packages with no release in 6 months
|
package/dist/bin/cli.js
CHANGED
|
@@ -330,6 +330,25 @@ Options:
|
|
|
330
330
|
--workspace
|
|
331
331
|
--dep-kinds deps,dev,optional,peer
|
|
332
332
|
--ci`;
|
|
333
|
+
}
|
|
334
|
+
if (isCommand && command === "audit") {
|
|
335
|
+
return `rainy-updates audit [options]
|
|
336
|
+
|
|
337
|
+
Scan dependencies for CVEs using OSV.dev and GitHub Advisory Database.
|
|
338
|
+
|
|
339
|
+
Options:
|
|
340
|
+
--workspace
|
|
341
|
+
--severity critical|high|medium|low
|
|
342
|
+
--summary
|
|
343
|
+
--report table|summary|json
|
|
344
|
+
--source auto|osv|github|all
|
|
345
|
+
--fix
|
|
346
|
+
--dry-run
|
|
347
|
+
--commit
|
|
348
|
+
--pm auto|npm|pnpm|bun|yarn
|
|
349
|
+
--json-file <path>
|
|
350
|
+
--concurrency <n>
|
|
351
|
+
--registry-timeout-ms <n>`;
|
|
333
352
|
}
|
|
334
353
|
return `rainy-updates (rup / rainy-up) <command> [options]
|
|
335
354
|
|
|
@@ -340,7 +359,7 @@ Commands:
|
|
|
340
359
|
warm-cache Warm local cache for fast/offline checks
|
|
341
360
|
init-ci Scaffold GitHub Actions workflow
|
|
342
361
|
baseline Save/check dependency baseline snapshots
|
|
343
|
-
audit Scan dependencies for CVEs (OSV.dev)
|
|
362
|
+
audit Scan dependencies for CVEs (OSV.dev + GitHub)
|
|
344
363
|
health Detect stale/deprecated/unmaintained packages
|
|
345
364
|
bisect Find which version of a dep introduced a failure
|
|
346
365
|
unused Detect unused or missing npm dependencies
|
|
@@ -1,6 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Fetches CVE advisories for all given package names in parallel.
|
|
4
|
-
* Uses OSV.dev as primary source.
|
|
5
|
-
*/
|
|
6
|
-
export declare function fetchAdvisories(packageNames: string[], options: Pick<AuditOptions, "concurrency" | "registryTimeoutMs">): Promise<CveAdvisory[]>;
|
|
1
|
+
export { extractAuditVersion } from "./targets.js";
|
|
2
|
+
export { fetchAdvisoriesFromSources as fetchAdvisories } from "./sources/index.js";
|
|
@@ -1,79 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const GITHUB_ADVISORY_API = "https://api.github.com/advisories";
|
|
4
|
-
/**
|
|
5
|
-
* Queries OSV.dev for advisories for a single npm package.
|
|
6
|
-
*/
|
|
7
|
-
async function queryOsv(packageName, timeoutMs) {
|
|
8
|
-
const body = JSON.stringify({
|
|
9
|
-
package: { name: packageName, ecosystem: "npm" },
|
|
10
|
-
});
|
|
11
|
-
let response;
|
|
12
|
-
try {
|
|
13
|
-
const controller = new AbortController();
|
|
14
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
15
|
-
response = await fetch(OSV_API, {
|
|
16
|
-
method: "POST",
|
|
17
|
-
headers: { "Content-Type": "application/json" },
|
|
18
|
-
body,
|
|
19
|
-
signal: controller.signal,
|
|
20
|
-
});
|
|
21
|
-
clearTimeout(timer);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return [];
|
|
25
|
-
}
|
|
26
|
-
if (!response.ok)
|
|
27
|
-
return [];
|
|
28
|
-
const data = (await response.json());
|
|
29
|
-
const advisories = [];
|
|
30
|
-
for (const vuln of data.vulns ?? []) {
|
|
31
|
-
const cveId = vuln.id ?? "UNKNOWN";
|
|
32
|
-
const rawSeverity = (vuln.database_specific?.severity ?? "medium").toLowerCase();
|
|
33
|
-
const severity = (["critical", "high", "medium", "low"].includes(rawSeverity)
|
|
34
|
-
? rawSeverity
|
|
35
|
-
: "medium");
|
|
36
|
-
let patchedVersion = null;
|
|
37
|
-
let vulnerableRange = "*";
|
|
38
|
-
for (const affected of vuln.affected ?? []) {
|
|
39
|
-
if (affected.package?.name !== packageName)
|
|
40
|
-
continue;
|
|
41
|
-
for (const range of affected.ranges ?? []) {
|
|
42
|
-
const fixedEvent = range.events?.find((e) => e.fixed);
|
|
43
|
-
if (fixedEvent?.fixed) {
|
|
44
|
-
patchedVersion = fixedEvent.fixed;
|
|
45
|
-
const introducedEvent = range.events?.find((e) => e.introduced);
|
|
46
|
-
vulnerableRange = introducedEvent?.introduced
|
|
47
|
-
? `>=${introducedEvent.introduced} <${patchedVersion}`
|
|
48
|
-
: `<${patchedVersion}`;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
advisories.push({
|
|
53
|
-
cveId,
|
|
54
|
-
packageName,
|
|
55
|
-
severity,
|
|
56
|
-
vulnerableRange,
|
|
57
|
-
patchedVersion,
|
|
58
|
-
title: vuln.summary ?? cveId,
|
|
59
|
-
url: vuln.references?.[0]?.url ?? `https://osv.dev/vulnerability/${cveId}`,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
return advisories;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Fetches CVE advisories for all given package names in parallel.
|
|
66
|
-
* Uses OSV.dev as primary source.
|
|
67
|
-
*/
|
|
68
|
-
export async function fetchAdvisories(packageNames, options) {
|
|
69
|
-
const tasks = packageNames.map((name) => () => queryOsv(name, options.registryTimeoutMs));
|
|
70
|
-
const results = await asyncPool(options.concurrency, tasks);
|
|
71
|
-
const advisories = [];
|
|
72
|
-
for (const r of results) {
|
|
73
|
-
if (!(r instanceof Error)) {
|
|
74
|
-
for (const adv of r)
|
|
75
|
-
advisories.push(adv);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return advisories;
|
|
79
|
-
}
|
|
1
|
+
export { extractAuditVersion } from "./targets.js";
|
|
2
|
+
export { fetchAdvisoriesFromSources as fetchAdvisories } from "./sources/index.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CveAdvisory, AuditSeverity } from "../../types/index.js";
|
|
1
|
+
import type { AuditPackageSummary, AuditSourceStatus, CveAdvisory, AuditSeverity } from "../../types/index.js";
|
|
2
2
|
/**
|
|
3
3
|
* Filters advisories by minimum severity level.
|
|
4
4
|
* e.g. --severity high → keeps critical and high.
|
|
@@ -8,9 +8,16 @@ export declare function filterBySeverity(advisories: CveAdvisory[], minSeverity:
|
|
|
8
8
|
* For each advisory that has a known patchedVersion,
|
|
9
9
|
* produces a sorted, deduplicated map of package → minimum secure version.
|
|
10
10
|
* Used by --fix to determine what version to update to.
|
|
11
|
+
*
|
|
12
|
+
* Uses proper semver numeric comparison — NOT string comparison — so that
|
|
13
|
+
* e.g. "5.19.1" correctly beats "5.5.1" (lexicographically "5.5.1" > "5.19.1"
|
|
14
|
+
* because "5" > "1" at the third character, which is the classic semver trap).
|
|
11
15
|
*/
|
|
12
16
|
export declare function buildPatchMap(advisories: CveAdvisory[]): Map<string, string>;
|
|
17
|
+
export declare function summarizeAdvisories(advisories: CveAdvisory[]): AuditPackageSummary[];
|
|
13
18
|
/**
|
|
14
19
|
* Renders audit advisories as a formatted table string for terminal output.
|
|
15
20
|
*/
|
|
16
21
|
export declare function renderAuditTable(advisories: CveAdvisory[]): string;
|
|
22
|
+
export declare function renderAuditSummary(packages: AuditPackageSummary[]): string;
|
|
23
|
+
export declare function renderAuditSourceHealth(sourceHealth: AuditSourceStatus[]): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compareVersions, parseVersion, satisfies } from "../../utils/semver.js";
|
|
1
2
|
const SEVERITY_RANK = {
|
|
2
3
|
critical: 4,
|
|
3
4
|
high: 3,
|
|
@@ -18,19 +19,66 @@ export function filterBySeverity(advisories, minSeverity) {
|
|
|
18
19
|
* For each advisory that has a known patchedVersion,
|
|
19
20
|
* produces a sorted, deduplicated map of package → minimum secure version.
|
|
20
21
|
* Used by --fix to determine what version to update to.
|
|
22
|
+
*
|
|
23
|
+
* Uses proper semver numeric comparison — NOT string comparison — so that
|
|
24
|
+
* e.g. "5.19.1" correctly beats "5.5.1" (lexicographically "5.5.1" > "5.19.1"
|
|
25
|
+
* because "5" > "1" at the third character, which is the classic semver trap).
|
|
21
26
|
*/
|
|
22
27
|
export function buildPatchMap(advisories) {
|
|
23
28
|
const patchMap = new Map();
|
|
29
|
+
const byPackage = new Map();
|
|
24
30
|
for (const advisory of advisories) {
|
|
25
|
-
|
|
31
|
+
const items = byPackage.get(advisory.packageName) ?? [];
|
|
32
|
+
items.push(advisory);
|
|
33
|
+
byPackage.set(advisory.packageName, items);
|
|
34
|
+
}
|
|
35
|
+
for (const [packageName, items] of byPackage) {
|
|
36
|
+
const candidates = [...new Set(items.flatMap((item) => item.patchedVersion ? [item.patchedVersion] : []))].sort(compareSemverAsc);
|
|
37
|
+
if (candidates.length === 0)
|
|
26
38
|
continue;
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
patchMap.set(advisory.packageName, advisory.patchedVersion);
|
|
30
|
-
}
|
|
39
|
+
const safeCandidate = candidates.find((candidate) => items.every((item) => !satisfies(candidate, item.vulnerableRange)));
|
|
40
|
+
patchMap.set(packageName, safeCandidate ?? candidates[candidates.length - 1]);
|
|
31
41
|
}
|
|
32
42
|
return patchMap;
|
|
33
43
|
}
|
|
44
|
+
function compareSemverAsc(a, b) {
|
|
45
|
+
const pa = parseVersion(a);
|
|
46
|
+
const pb = parseVersion(b);
|
|
47
|
+
if (pa && pb)
|
|
48
|
+
return compareVersions(pa, pb);
|
|
49
|
+
if (a === b)
|
|
50
|
+
return 0;
|
|
51
|
+
return a < b ? -1 : 1;
|
|
52
|
+
}
|
|
53
|
+
export function summarizeAdvisories(advisories) {
|
|
54
|
+
const byPackage = new Map();
|
|
55
|
+
for (const advisory of advisories) {
|
|
56
|
+
const key = `${advisory.packageName}|${advisory.currentVersion ?? "?"}`;
|
|
57
|
+
const items = byPackage.get(key) ?? [];
|
|
58
|
+
items.push(advisory);
|
|
59
|
+
byPackage.set(key, items);
|
|
60
|
+
}
|
|
61
|
+
const summaries = [];
|
|
62
|
+
for (const [, items] of byPackage) {
|
|
63
|
+
const sorted = [...items].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
64
|
+
const representative = sorted[0];
|
|
65
|
+
const patchMap = buildPatchMap(items);
|
|
66
|
+
summaries.push({
|
|
67
|
+
packageName: representative.packageName,
|
|
68
|
+
currentVersion: representative.currentVersion,
|
|
69
|
+
severity: representative.severity,
|
|
70
|
+
advisoryCount: items.length,
|
|
71
|
+
patchedVersion: patchMap.get(representative.packageName) ?? null,
|
|
72
|
+
sources: [...new Set(items.flatMap((item) => item.sources))].sort(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return summaries.sort((a, b) => {
|
|
76
|
+
const severityDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
77
|
+
if (severityDiff !== 0)
|
|
78
|
+
return severityDiff;
|
|
79
|
+
return a.packageName.localeCompare(b.packageName);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
34
82
|
/**
|
|
35
83
|
* Renders audit advisories as a formatted table string for terminal output.
|
|
36
84
|
*/
|
|
@@ -47,15 +95,63 @@ export function renderAuditTable(advisories) {
|
|
|
47
95
|
const sorted = [...advisories].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]);
|
|
48
96
|
const lines = [
|
|
49
97
|
`Found ${advisories.length} ${advisories.length === 1 ? "vulnerability" : "vulnerabilities"}:\n`,
|
|
50
|
-
"Package".padEnd(
|
|
51
|
-
|
|
98
|
+
"Package".padEnd(24) +
|
|
99
|
+
"Current".padEnd(14) +
|
|
100
|
+
"Severity".padEnd(20) +
|
|
101
|
+
"CVE".padEnd(22) +
|
|
102
|
+
"Patch",
|
|
103
|
+
"─".repeat(104),
|
|
52
104
|
];
|
|
53
105
|
for (const adv of sorted) {
|
|
54
|
-
const name = adv.packageName.slice(0,
|
|
106
|
+
const name = adv.packageName.slice(0, 22).padEnd(24);
|
|
107
|
+
const current = (adv.currentVersion ?? "?").slice(0, 12).padEnd(14);
|
|
55
108
|
const sev = SEVERITY_ICON[adv.severity].padEnd(20);
|
|
56
109
|
const cve = adv.cveId.slice(0, 20).padEnd(22);
|
|
57
110
|
const patch = adv.patchedVersion ? `→ ${adv.patchedVersion}` : "no patch";
|
|
58
|
-
lines.push(`${name}${sev}${cve}${patch}`);
|
|
111
|
+
lines.push(`${name}${current}${sev}${cve}${patch}`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
export function renderAuditSummary(packages) {
|
|
116
|
+
if (packages.length === 0) {
|
|
117
|
+
return "✔ No vulnerable packages found.\n";
|
|
118
|
+
}
|
|
119
|
+
const SEVERITY_ICON = {
|
|
120
|
+
critical: "🔴 CRITICAL",
|
|
121
|
+
high: "🟠 HIGH ",
|
|
122
|
+
medium: "🟡 MEDIUM ",
|
|
123
|
+
low: "⚪ LOW ",
|
|
124
|
+
};
|
|
125
|
+
const lines = [
|
|
126
|
+
`Found ${packages.length} affected ${packages.length === 1 ? "package" : "packages"}:\n`,
|
|
127
|
+
"Package".padEnd(24) +
|
|
128
|
+
"Current".padEnd(14) +
|
|
129
|
+
"Severity".padEnd(20) +
|
|
130
|
+
"Advisories".padEnd(12) +
|
|
131
|
+
"Patch",
|
|
132
|
+
"─".repeat(98),
|
|
133
|
+
];
|
|
134
|
+
for (const item of packages) {
|
|
135
|
+
const name = item.packageName.slice(0, 22).padEnd(24);
|
|
136
|
+
const current = (item.currentVersion ?? "?").slice(0, 12).padEnd(14);
|
|
137
|
+
const sev = SEVERITY_ICON[item.severity].padEnd(20);
|
|
138
|
+
const count = String(item.advisoryCount).padEnd(12);
|
|
139
|
+
const patch = item.patchedVersion ? `→ ${item.patchedVersion}` : "no patch";
|
|
140
|
+
lines.push(`${name}${current}${sev}${count}${patch}`);
|
|
141
|
+
}
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
export function renderAuditSourceHealth(sourceHealth) {
|
|
145
|
+
if (sourceHealth.length === 0)
|
|
146
|
+
return "";
|
|
147
|
+
const lines = ["", "Sources:"];
|
|
148
|
+
for (const item of sourceHealth) {
|
|
149
|
+
const label = item.source === "osv" ? "OSV.dev" : "GitHub Advisory DB";
|
|
150
|
+
const status = item.status.toUpperCase().padEnd(7);
|
|
151
|
+
const coverage = `${item.successfulTargets}/${item.attemptedTargets} targets`;
|
|
152
|
+
const advisories = `${item.advisoriesFound} advisories`;
|
|
153
|
+
const suffix = item.message ? ` (${item.message})` : "";
|
|
154
|
+
lines.push(` ${label.padEnd(22)} ${status} ${coverage}, ${advisories}${suffix}`);
|
|
59
155
|
}
|
|
60
156
|
return lines.join("\n");
|
|
61
157
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
const SEVERITY_LEVELS = ["critical", "high", "medium", "low"];
|
|
4
|
+
const SOURCE_MODES = ["auto", "osv", "github", "all"];
|
|
4
5
|
export function parseSeverity(value) {
|
|
5
6
|
if (SEVERITY_LEVELS.includes(value)) {
|
|
6
7
|
return value;
|
|
@@ -14,7 +15,10 @@ export function parseAuditArgs(args) {
|
|
|
14
15
|
severity: undefined,
|
|
15
16
|
fix: false,
|
|
16
17
|
dryRun: false,
|
|
18
|
+
commit: false,
|
|
19
|
+
packageManager: "auto",
|
|
17
20
|
reportFormat: "table",
|
|
21
|
+
sourceMode: "auto",
|
|
18
22
|
jsonFile: undefined,
|
|
19
23
|
concurrency: 16,
|
|
20
24
|
registryTimeoutMs: 8000,
|
|
@@ -52,9 +56,24 @@ export function parseAuditArgs(args) {
|
|
|
52
56
|
index += 1;
|
|
53
57
|
continue;
|
|
54
58
|
}
|
|
59
|
+
if (current === "--commit") {
|
|
60
|
+
options.commit = true;
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (current === "--pm" && next) {
|
|
65
|
+
const valid = ["auto", "npm", "pnpm", "bun", "yarn"];
|
|
66
|
+
if (!valid.includes(next))
|
|
67
|
+
throw new Error(`--pm must be one of: ${valid.join(", ")}`);
|
|
68
|
+
options.packageManager = next;
|
|
69
|
+
index += 2;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (current === "--pm")
|
|
73
|
+
throw new Error("Missing value for --pm");
|
|
55
74
|
if (current === "--report" && next) {
|
|
56
|
-
if (next !== "table" && next !== "json") {
|
|
57
|
-
throw new Error("--report must be table or json");
|
|
75
|
+
if (next !== "table" && next !== "json" && next !== "summary") {
|
|
76
|
+
throw new Error("--report must be table, summary, or json");
|
|
58
77
|
}
|
|
59
78
|
options.reportFormat = next;
|
|
60
79
|
index += 2;
|
|
@@ -62,6 +81,21 @@ export function parseAuditArgs(args) {
|
|
|
62
81
|
}
|
|
63
82
|
if (current === "--report")
|
|
64
83
|
throw new Error("Missing value for --report");
|
|
84
|
+
if (current === "--summary") {
|
|
85
|
+
options.reportFormat = "summary";
|
|
86
|
+
index += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (current === "--source" && next) {
|
|
90
|
+
if (!SOURCE_MODES.includes(next)) {
|
|
91
|
+
throw new Error(`--source must be one of: ${SOURCE_MODES.join(", ")}`);
|
|
92
|
+
}
|
|
93
|
+
options.sourceMode = next;
|
|
94
|
+
index += 2;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (current === "--source")
|
|
98
|
+
throw new Error("Missing value for --source");
|
|
65
99
|
if (current === "--json-file" && next) {
|
|
66
100
|
options.jsonFile = path.resolve(options.cwd, next);
|
|
67
101
|
index += 2;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { collectDependencies, readManifest, } from "../../parsers/package-json.js";
|
|
2
5
|
import { discoverPackageDirs } from "../../workspace/discover.js";
|
|
3
6
|
import { writeFileAtomic } from "../../utils/io.js";
|
|
4
7
|
import { stableStringify } from "../../utils/stable-json.js";
|
|
5
8
|
import { fetchAdvisories } from "./fetcher.js";
|
|
6
|
-
import {
|
|
9
|
+
import { resolveAuditTargets } from "./targets.js";
|
|
10
|
+
import { filterBySeverity, buildPatchMap, renderAuditSourceHealth, renderAuditSummary, renderAuditTable, summarizeAdvisories, } from "./mapper.js";
|
|
7
11
|
/**
|
|
8
12
|
* Entry point for `rup audit`. Lazy-loaded by cli.ts.
|
|
9
13
|
* Discovers packages, fetches CVE advisories, filters by severity, and
|
|
@@ -12,13 +16,20 @@ import { filterBySeverity, buildPatchMap, renderAuditTable } from "./mapper.js";
|
|
|
12
16
|
export async function runAudit(options) {
|
|
13
17
|
const result = {
|
|
14
18
|
advisories: [],
|
|
19
|
+
packages: [],
|
|
15
20
|
autoFixable: 0,
|
|
16
21
|
errors: [],
|
|
17
22
|
warnings: [],
|
|
23
|
+
sourcesUsed: [],
|
|
24
|
+
sourceHealth: [],
|
|
25
|
+
resolution: {
|
|
26
|
+
lockfile: 0,
|
|
27
|
+
manifest: 0,
|
|
28
|
+
unresolved: 0,
|
|
29
|
+
},
|
|
18
30
|
};
|
|
19
31
|
const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
|
|
20
|
-
|
|
21
|
-
const packageNames = new Set();
|
|
32
|
+
const depsByDir = new Map();
|
|
22
33
|
for (const dir of packageDirs) {
|
|
23
34
|
let manifest;
|
|
24
35
|
try {
|
|
@@ -33,32 +44,185 @@ export async function runAudit(options) {
|
|
|
33
44
|
"devDependencies",
|
|
34
45
|
"optionalDependencies",
|
|
35
46
|
]);
|
|
36
|
-
|
|
37
|
-
packageNames.add(dep.name);
|
|
38
|
-
}
|
|
47
|
+
depsByDir.set(dir, deps);
|
|
39
48
|
}
|
|
40
|
-
|
|
49
|
+
const targetResolution = await resolveAuditTargets(options.cwd, packageDirs, depsByDir);
|
|
50
|
+
result.warnings.push(...targetResolution.warnings);
|
|
51
|
+
result.resolution = targetResolution.resolution;
|
|
52
|
+
if (targetResolution.targets.length === 0) {
|
|
41
53
|
result.warnings.push("No dependencies found to audit.");
|
|
42
54
|
return result;
|
|
43
55
|
}
|
|
44
|
-
process.stderr.write(`[audit] Querying
|
|
45
|
-
|
|
56
|
+
process.stderr.write(`[audit] Querying ${describeSourceMode(options.sourceMode)} for ${targetResolution.targets.length} dependency version${targetResolution.targets.length === 1 ? "" : "s"}...\n`);
|
|
57
|
+
const fetched = await fetchAdvisories(targetResolution.targets, {
|
|
46
58
|
concurrency: options.concurrency,
|
|
47
59
|
registryTimeoutMs: options.registryTimeoutMs,
|
|
60
|
+
sourceMode: options.sourceMode,
|
|
48
61
|
});
|
|
62
|
+
result.sourcesUsed = fetched.sourcesUsed;
|
|
63
|
+
result.sourceHealth = fetched.sourceHealth;
|
|
64
|
+
result.warnings.push(...fetched.warnings);
|
|
65
|
+
if (fetched.sourceHealth.every((item) => item.status === "failed")) {
|
|
66
|
+
result.errors.push("All advisory sources failed. Audit coverage is unavailable for this run.");
|
|
67
|
+
}
|
|
68
|
+
let advisories = fetched.advisories;
|
|
49
69
|
advisories = filterBySeverity(advisories, options.severity);
|
|
50
70
|
result.advisories = advisories;
|
|
71
|
+
result.packages = summarizeAdvisories(advisories);
|
|
51
72
|
result.autoFixable = advisories.filter((a) => a.patchedVersion !== null).length;
|
|
52
|
-
if (options.reportFormat === "
|
|
53
|
-
process.stdout.write(
|
|
73
|
+
if (options.reportFormat === "summary") {
|
|
74
|
+
process.stdout.write(renderAuditSummary(result.packages) +
|
|
75
|
+
renderAuditSourceHealth(result.sourceHealth) +
|
|
76
|
+
"\n");
|
|
77
|
+
}
|
|
78
|
+
else if (options.reportFormat === "table" || !options.jsonFile) {
|
|
79
|
+
process.stdout.write(renderAuditTable(advisories) +
|
|
80
|
+
renderAuditSourceHealth(result.sourceHealth) +
|
|
81
|
+
"\n");
|
|
54
82
|
}
|
|
55
83
|
if (options.jsonFile) {
|
|
56
|
-
await writeFileAtomic(options.jsonFile, stableStringify({
|
|
84
|
+
await writeFileAtomic(options.jsonFile, stableStringify({
|
|
85
|
+
advisories,
|
|
86
|
+
packages: result.packages,
|
|
87
|
+
sourcesUsed: result.sourcesUsed,
|
|
88
|
+
sourceHealth: result.sourceHealth,
|
|
89
|
+
resolution: result.resolution,
|
|
90
|
+
errors: result.errors,
|
|
91
|
+
warnings: result.warnings,
|
|
92
|
+
}, 2) + "\n");
|
|
57
93
|
process.stderr.write(`[audit] JSON report written to ${options.jsonFile}\n`);
|
|
58
94
|
}
|
|
59
|
-
if (options.fix &&
|
|
60
|
-
|
|
61
|
-
process.stderr.write(`[audit] --fix: ${patchMap.size} packages have available patches. Apply with: npm install ${[...patchMap.entries()].map(([n, v]) => `${n}@${v}`).join(" ")}\n`);
|
|
95
|
+
if (options.fix && result.autoFixable > 0) {
|
|
96
|
+
await applyFix(advisories, options);
|
|
62
97
|
}
|
|
63
98
|
return result;
|
|
64
99
|
}
|
|
100
|
+
function describeSourceMode(mode) {
|
|
101
|
+
if (mode === "osv")
|
|
102
|
+
return "OSV.dev";
|
|
103
|
+
if (mode === "github")
|
|
104
|
+
return "GitHub Advisory DB";
|
|
105
|
+
return "OSV.dev + GitHub Advisory DB";
|
|
106
|
+
}
|
|
107
|
+
// ─── Fix application ──────────────────────────────────────────────────────────
|
|
108
|
+
async function applyFix(advisories, options) {
|
|
109
|
+
const patchMap = buildPatchMap(advisories);
|
|
110
|
+
if (patchMap.size === 0)
|
|
111
|
+
return;
|
|
112
|
+
const pm = await detectPackageManager(options.cwd, options.packageManager);
|
|
113
|
+
const installArgs = buildInstallArgs(pm, patchMap);
|
|
114
|
+
const installCmd = `${pm} ${installArgs.join(" ")}`;
|
|
115
|
+
if (options.dryRun) {
|
|
116
|
+
process.stderr.write(`[audit] --dry-run: would execute:\n ${installCmd}\n`);
|
|
117
|
+
if (options.commit) {
|
|
118
|
+
const msg = buildCommitMessage(patchMap);
|
|
119
|
+
process.stderr.write(`[audit] --dry-run: would commit:\n git commit -m "${msg}"\n`);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write(`[audit] Applying ${patchMap.size} fix(es)...\n`);
|
|
124
|
+
process.stderr.write(` → ${installCmd}\n`);
|
|
125
|
+
try {
|
|
126
|
+
await runCommand(pm, installArgs, options.cwd);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
process.stderr.write(`[audit] Install failed: ${String(err)}\n`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
process.stderr.write(`[audit] ✔ Patches applied successfully.\n`);
|
|
133
|
+
if (options.commit) {
|
|
134
|
+
await commitFix(patchMap, options.cwd);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
process.stderr.write(`[audit] Tip: run with --commit to automatically commit the changes.\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function buildInstallArgs(pm, patchMap) {
|
|
141
|
+
const packages = [...patchMap.entries()].map(([n, v]) => `${n}@${v}`);
|
|
142
|
+
switch (pm) {
|
|
143
|
+
case "pnpm":
|
|
144
|
+
return ["add", ...packages];
|
|
145
|
+
case "bun":
|
|
146
|
+
return ["add", ...packages];
|
|
147
|
+
case "yarn":
|
|
148
|
+
return ["add", ...packages];
|
|
149
|
+
default:
|
|
150
|
+
return ["install", ...packages]; // npm
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function commitFix(patchMap, cwd) {
|
|
154
|
+
const msg = buildCommitMessage(patchMap);
|
|
155
|
+
try {
|
|
156
|
+
// Stage all modified files (package.json + lockfiles)
|
|
157
|
+
await runCommand("git", [
|
|
158
|
+
"add",
|
|
159
|
+
"package.json",
|
|
160
|
+
"package-lock.json",
|
|
161
|
+
"pnpm-lock.yaml",
|
|
162
|
+
"yarn.lock",
|
|
163
|
+
"bun.lock",
|
|
164
|
+
"bun.lockb",
|
|
165
|
+
], cwd, true);
|
|
166
|
+
await runCommand("git", ["commit", "-m", msg], cwd);
|
|
167
|
+
process.stderr.write(`[audit] ✔ Committed: "${msg}"\n`);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
process.stderr.write(`[audit] Git commit failed: ${String(err)}\n`);
|
|
171
|
+
process.stderr.write(`[audit] Changes are applied — commit manually with:\n`);
|
|
172
|
+
process.stderr.write(` git add -A && git commit -m "${msg}"\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function buildCommitMessage(patchMap) {
|
|
176
|
+
const items = [...patchMap.entries()];
|
|
177
|
+
if (items.length === 1) {
|
|
178
|
+
const [name, version] = items[0];
|
|
179
|
+
return `fix(security): patch ${name} to ${version} (rup audit)`;
|
|
180
|
+
}
|
|
181
|
+
const names = items.map(([n]) => n).join(", ");
|
|
182
|
+
return `fix(security): patch ${items.length} vulnerabilities — ${names} (rup audit)`;
|
|
183
|
+
}
|
|
184
|
+
/** Detects the package manager in use by checking for lockfiles. */
|
|
185
|
+
async function detectPackageManager(cwd, explicit) {
|
|
186
|
+
if (explicit !== "auto")
|
|
187
|
+
return explicit;
|
|
188
|
+
const checks = [
|
|
189
|
+
["bun.lock", "bun"],
|
|
190
|
+
["bun.lockb", "bun"],
|
|
191
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
192
|
+
["yarn.lock", "yarn"],
|
|
193
|
+
];
|
|
194
|
+
for (const [lockfile, pm] of checks) {
|
|
195
|
+
try {
|
|
196
|
+
await fs.access(path.join(cwd, lockfile));
|
|
197
|
+
return pm;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// not found, try next
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return "npm"; // default
|
|
204
|
+
}
|
|
205
|
+
/** Spawns a subprocess, pipes stdio live to the terminal. */
|
|
206
|
+
function runCommand(cmd, args, cwd, ignoreErrors = false) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const child = spawn(cmd, args, {
|
|
209
|
+
cwd,
|
|
210
|
+
stdio: "inherit", // stream stdout/stderr live
|
|
211
|
+
shell: process.platform === "win32",
|
|
212
|
+
});
|
|
213
|
+
child.on("close", (code) => {
|
|
214
|
+
if (code === 0 || ignoreErrors) {
|
|
215
|
+
resolve();
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
child.on("error", (err) => {
|
|
222
|
+
if (ignoreErrors)
|
|
223
|
+
resolve();
|
|
224
|
+
else
|
|
225
|
+
reject(err);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|