@westbayberry/dg 2.0.11 → 2.1.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/dist/api/analyze.js +5 -3
- package/dist/bin/dg.js +1 -1
- package/dist/commands/completion.js +2 -1
- package/dist/commands/config.js +11 -3
- package/dist/commands/decisions.js +155 -0
- package/dist/commands/explain.js +6 -2
- package/dist/commands/router.js +2 -0
- package/dist/commands/scan.js +2 -1
- package/dist/commands/status.js +5 -2
- package/dist/config/settings.js +144 -25
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +97 -0
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/install-preflight.js +126 -13
- package/dist/launcher/preflight-prompt.js +29 -2
- package/dist/launcher/run.js +14 -3
- package/dist/policy/cooldown.js +104 -0
- package/dist/policy/evaluate.js +0 -15
- package/dist/presentation/provenance.js +23 -0
- package/dist/project/dgfile.js +307 -0
- package/dist/proxy/enforcement.js +2 -1
- package/dist/proxy/metadata-map.js +25 -1
- package/dist/proxy/server.js +31 -2
- package/dist/scan/command.js +35 -8
- package/dist/scan/render.js +35 -4
- package/dist/scan/scanner-report.js +31 -4
- package/dist/scan/staged.js +69 -10
- package/dist/scan-ui/LegacyApp.js +4 -4
- package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
- package/dist/scan-ui/hooks/useScan.js +31 -3
- package/dist/scan-ui/shims.js +3 -0
- package/dist/scripts/detect.js +153 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/scripts/rebuild.js +28 -0
- package/dist/setup/plan.js +36 -1
- package/dist/util/json-file.js +24 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/package-check.js +12 -0
- package/package.json +9 -1
package/dist/scan/render.js
CHANGED
|
@@ -21,17 +21,23 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
|
|
|
21
21
|
const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
|
|
22
22
|
const findingProjects = report.projects.filter((project) => project.findings.length > 0);
|
|
23
23
|
const shouldCollapseProjects = report.projects.length > DETAIL_PROJECT_LIMIT;
|
|
24
|
+
const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
|
|
25
|
+
const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
|
|
24
26
|
const lines = [
|
|
25
27
|
"Dependency Guardian scan",
|
|
26
28
|
`Target: ${report.target}`,
|
|
27
29
|
`Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
|
|
28
|
-
`Status: ${
|
|
30
|
+
`Status: ${paintEffectiveStatus(report, theme)}`,
|
|
29
31
|
`Projects: ${report.summary.projectCount}`,
|
|
30
32
|
`Dependencies: ${report.summary.dependencyCount}`,
|
|
31
|
-
`Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
|
|
33
|
+
`Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)${acknowledgedCount > 0 ? ` · ${acknowledgedCount} acknowledged` : ""}`,
|
|
34
|
+
...(acknowledgedCount > 0
|
|
35
|
+
? [`Acknowledged: ${acknowledgedCount} warn verdict${acknowledgedCount === 1 ? "" : "s"} accepted in dg.json — run 'dg decisions' to review`]
|
|
36
|
+
: []),
|
|
32
37
|
...(report.scanner
|
|
33
38
|
? [`Scanner: score ${report.scanner.score}, ${report.scanner.packages.length} packages verified`]
|
|
34
39
|
: []),
|
|
40
|
+
...(report.scanner ? provenanceDowngradeLines(report.scanner.packages, theme) : []),
|
|
35
41
|
""
|
|
36
42
|
];
|
|
37
43
|
if (report.scannerError) {
|
|
@@ -52,7 +58,7 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
|
|
|
52
58
|
}
|
|
53
59
|
if (findingProjects.length > 0) {
|
|
54
60
|
lines.push("Finding groups:");
|
|
55
|
-
for (const group of groupFindings(
|
|
61
|
+
for (const group of groupFindings(activeFindings)) {
|
|
56
62
|
const severityLabel = theme.paint(group.severity === "block" ? "block" : "warn", group.severity.toUpperCase());
|
|
57
63
|
lines.push(` ${severityLabel} ${group.id}: ${group.count} finding${group.count === 1 ? "" : "s"} across ${group.projectCount} project${group.projectCount === 1 ? "" : "s"}`);
|
|
58
64
|
for (const finding of group.examples) {
|
|
@@ -94,6 +100,21 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
|
|
|
94
100
|
}
|
|
95
101
|
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
96
102
|
}
|
|
103
|
+
function provenanceDowngradeLines(packages, theme) {
|
|
104
|
+
return packages
|
|
105
|
+
.filter((pkg) => pkg.provenance?.downgrade)
|
|
106
|
+
.map((pkg) => theme.paint("warn", `Provenance downgrades: ${pkg.name}@${pkg.version} (was attested at ${pkg.provenance.downgrade.fromVersion})`));
|
|
107
|
+
}
|
|
108
|
+
function paintEffectiveStatus(report, theme) {
|
|
109
|
+
if (report.scanner && report.decisions && !report.scannerError) {
|
|
110
|
+
const action = report.decisions.effectiveAction;
|
|
111
|
+
if (action === "analysis_incomplete") {
|
|
112
|
+
return theme.paint("unknown", "analysis_incomplete");
|
|
113
|
+
}
|
|
114
|
+
return theme.paint(SCAN_STATUS_ROLE[action], action);
|
|
115
|
+
}
|
|
116
|
+
return theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report));
|
|
117
|
+
}
|
|
97
118
|
function formatProject(project, width) {
|
|
98
119
|
const lines = [];
|
|
99
120
|
const version = project.version ? `@${project.version}` : "";
|
|
@@ -154,7 +175,17 @@ export function renderSarifReport(report) {
|
|
|
154
175
|
}
|
|
155
176
|
}
|
|
156
177
|
}
|
|
157
|
-
]
|
|
178
|
+
],
|
|
179
|
+
...(finding.acknowledged
|
|
180
|
+
? {
|
|
181
|
+
suppressions: [
|
|
182
|
+
{
|
|
183
|
+
kind: "external",
|
|
184
|
+
justification: finding.acknowledged.reason || `accepted by ${finding.acknowledged.by}`
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
: {})
|
|
158
189
|
}))
|
|
159
190
|
}
|
|
160
191
|
]
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { applyDecisions, packageKey } from "../decisions/apply.js";
|
|
6
7
|
import { sanitize } from "../security/sanitize.js";
|
|
7
8
|
import { collectScanPackages, discoverScanProjects } from "./collect.js";
|
|
8
9
|
const WORKER_TIMEOUT_BASE_MS = 180_000;
|
|
@@ -12,7 +13,7 @@ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
|
|
|
12
13
|
export function scanWorkerTimeoutMs(packageCount) {
|
|
13
14
|
return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
|
|
14
15
|
}
|
|
15
|
-
export function runScannerScan(targetPath, localReport, env = process.env) {
|
|
16
|
+
export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
|
|
16
17
|
const projects = discoverScanProjects(resolve(targetPath));
|
|
17
18
|
if (projects.length === 0) {
|
|
18
19
|
return { kind: "skipped", reason: "no_lockfiles" };
|
|
@@ -58,12 +59,38 @@ export function runScannerScan(targetPath, localReport, env = process.env) {
|
|
|
58
59
|
error: { kind: "invalid_response", message: "scanner worker returned unreadable output" }
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
const report = buildScannerReport(localReport, response, total);
|
|
63
|
+
if (!decisionsFile?.readable) {
|
|
64
|
+
return { kind: "report", report };
|
|
65
|
+
}
|
|
66
|
+
const ecosystems = new Map();
|
|
67
|
+
for (const group of groups) {
|
|
68
|
+
for (const pkg of group.packages) {
|
|
69
|
+
ecosystems.set(packageKey(pkg.name, pkg.version), group.ecosystem);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { kind: "report", report: annotateReportWithDecisions(report, decisionsFile, ecosystems) };
|
|
62
73
|
}
|
|
63
|
-
export function tryScannerScan(targetPath, localReport, env = process.env) {
|
|
64
|
-
const outcome = runScannerScan(targetPath, localReport, env);
|
|
74
|
+
export function tryScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
|
|
75
|
+
const outcome = runScannerScan(targetPath, localReport, env, decisionsFile);
|
|
65
76
|
return outcome.kind === "report" ? outcome.report : null;
|
|
66
77
|
}
|
|
78
|
+
export function annotateReportWithDecisions(report, decisionsFile, ecosystems) {
|
|
79
|
+
if (!report.scanner || !decisionsFile.readable) {
|
|
80
|
+
return report;
|
|
81
|
+
}
|
|
82
|
+
const applied = applyDecisions(report.scanner.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), decisionsFile, report.scanner.action);
|
|
83
|
+
const findings = report.findings.map((finding) => {
|
|
84
|
+
const acknowledged = applied.packages[finding.location]?.acknowledged;
|
|
85
|
+
return acknowledged && finding.severity === "warn" ? { ...finding, acknowledged } : finding;
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
...report,
|
|
89
|
+
findings,
|
|
90
|
+
summary: { ...report.summary, acknowledgedCount: applied.acknowledgedCount },
|
|
91
|
+
decisions: applied
|
|
92
|
+
};
|
|
93
|
+
}
|
|
67
94
|
export function workerFailure(worker, timeoutMs, packageCount) {
|
|
68
95
|
if (worker.error?.code === "ETIMEDOUT") {
|
|
69
96
|
return {
|
package/dist/scan/staged.js
CHANGED
|
@@ -4,6 +4,9 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
4
4
|
import { createTheme } from "../presentation/theme.js";
|
|
5
5
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
6
|
import { loadUserConfig } from "../config/settings.js";
|
|
7
|
+
import { offerRememberSync } from "../decisions/remember-prompt.js";
|
|
8
|
+
import { packageKey } from "../decisions/apply.js";
|
|
9
|
+
import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
7
10
|
import { gitSync, gitTrimmed } from "../util/git.js";
|
|
8
11
|
import { promptYesNo } from "../util/tty-prompt.js";
|
|
9
12
|
import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
|
|
@@ -85,16 +88,21 @@ export function runStagedScan(options) {
|
|
|
85
88
|
if (scoped.length === 0) {
|
|
86
89
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
87
90
|
}
|
|
91
|
+
const dgFile = options.useDecisions === false ? null : loadDgFile(root);
|
|
92
|
+
if (dgFile) {
|
|
93
|
+
warnUnreadableDgFile(dgFile);
|
|
94
|
+
}
|
|
95
|
+
const usableDgFile = dgFile?.readable ? dgFile : null;
|
|
88
96
|
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
89
97
|
try {
|
|
90
98
|
if (count === 0) {
|
|
91
99
|
return failOpen(theme, "could not read the staged lockfile contents");
|
|
92
100
|
}
|
|
93
|
-
const report = tryScannerScan(dir, emptyLocalReport(dir), env);
|
|
101
|
+
const report = tryScannerScan(dir, emptyLocalReport(dir), env, usableDgFile);
|
|
94
102
|
if (!report || !report.scanner) {
|
|
95
103
|
return failOpen(theme, "could not reach the scanner");
|
|
96
104
|
}
|
|
97
|
-
return decideStagedVerdict(report, env, options.hook);
|
|
105
|
+
return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
|
|
98
106
|
}
|
|
99
107
|
finally {
|
|
100
108
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -119,12 +127,16 @@ export function stagedScanReport(options) {
|
|
|
119
127
|
if (scoped.length === 0) {
|
|
120
128
|
return { report: base, outcome: { kind: "skipped", reason: "no_lockfiles" } };
|
|
121
129
|
}
|
|
130
|
+
const dgFile = options.useDecisions === false ? null : loadDgFile(root);
|
|
131
|
+
if (dgFile) {
|
|
132
|
+
warnUnreadableDgFile(dgFile);
|
|
133
|
+
}
|
|
122
134
|
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
123
135
|
try {
|
|
124
136
|
if (count === 0) {
|
|
125
137
|
return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
|
|
126
138
|
}
|
|
127
|
-
return { report: base, outcome: runScannerScan(dir, base, env) };
|
|
139
|
+
return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? dgFile : null) };
|
|
128
140
|
}
|
|
129
141
|
finally {
|
|
130
142
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -144,11 +156,13 @@ function notARepoResult() {
|
|
|
144
156
|
function outsideRepoResult(targetPath) {
|
|
145
157
|
return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg scan --staged: ${targetPath} is outside this repository.\n` };
|
|
146
158
|
}
|
|
147
|
-
export function decideStagedVerdict(report, env = process.env, hook = false) {
|
|
159
|
+
export function decideStagedVerdict(report, env = process.env, hook = false, decisionContext) {
|
|
148
160
|
const theme = createTheme(resolvePresentation().color);
|
|
149
161
|
const action = report.scanner?.action ?? report.status;
|
|
150
162
|
const config = loadUserConfig(env);
|
|
151
163
|
const count = report.summary.dependencyCount;
|
|
164
|
+
const effective = config.policy.mode === "strict" ? action : report.decisions?.effectiveAction ?? action;
|
|
165
|
+
const acknowledgedCount = report.decisions?.acknowledgedCount ?? 0;
|
|
152
166
|
if (action === "block") {
|
|
153
167
|
return { exitCode: 2, stdout: "", stderr: renderBlock(report.findings, theme) };
|
|
154
168
|
}
|
|
@@ -156,17 +170,61 @@ export function decideStagedVerdict(report, env = process.env, hook = false) {
|
|
|
156
170
|
return scannerUnavailable(theme);
|
|
157
171
|
}
|
|
158
172
|
if (action === "warn") {
|
|
159
|
-
|
|
173
|
+
if (acknowledgedCount > 0 && effective === "pass") {
|
|
174
|
+
return {
|
|
175
|
+
exitCode: 0,
|
|
176
|
+
stdout: "",
|
|
177
|
+
stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — ${acknowledgedCount} warning${acknowledgedCount === 1 ? "" : "s"} previously accepted (dg.json)`)}\n`
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (acknowledgedCount > 0 && effective === "analysis_incomplete") {
|
|
181
|
+
return decideIncomplete(config.gitHook.onIncomplete, theme);
|
|
182
|
+
}
|
|
183
|
+
const activeFindings = report.findings.filter((finding) => !finding.acknowledged);
|
|
184
|
+
return decideWarn(activeFindings, theme, config.gitHook.onWarn, config.policy.mode, hook, stagedRememberOffer(report, decisionContext, env));
|
|
160
185
|
}
|
|
161
186
|
if (action === "analysis_incomplete") {
|
|
162
|
-
|
|
163
|
-
return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
|
|
164
|
-
}
|
|
165
|
-
return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
|
|
187
|
+
return decideIncomplete(config.gitHook.onIncomplete, theme);
|
|
166
188
|
}
|
|
167
189
|
return { exitCode: 0, stdout: "", stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — clean`)}\n` };
|
|
168
190
|
}
|
|
169
|
-
function
|
|
191
|
+
function decideIncomplete(onIncomplete, theme) {
|
|
192
|
+
if (onIncomplete === "block") {
|
|
193
|
+
return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
|
|
194
|
+
}
|
|
195
|
+
return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
|
|
196
|
+
}
|
|
197
|
+
export function stagedRememberOffer(report, decisionContext, env) {
|
|
198
|
+
if (!decisionContext || !report.scanner || !report.decisions) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const annotations = report.decisions.packages;
|
|
202
|
+
const packages = [];
|
|
203
|
+
for (const pkg of report.scanner.packages) {
|
|
204
|
+
if ((pkg.action ?? "pass") !== "warn") {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const annotation = annotations[packageKey(pkg.name, pkg.version)];
|
|
208
|
+
if (!annotation || annotation.acknowledged) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
packages.push({ ecosystem: annotation.ecosystem, name: pkg.name, version: pkg.version, findings: pkg.findings });
|
|
212
|
+
}
|
|
213
|
+
if (packages.length === 0) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
return () => {
|
|
217
|
+
offerRememberSync({
|
|
218
|
+
file: decisionContext.file,
|
|
219
|
+
packages,
|
|
220
|
+
acceptedBy: resolveAcceptedBy(decisionContext.root, env),
|
|
221
|
+
surface: "guard-commit",
|
|
222
|
+
env,
|
|
223
|
+
...(decisionContext.prompts ? { prompts: decisionContext.prompts } : {})
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function decideWarn(findings, theme, onWarn, policyMode, hook, onAccepted) {
|
|
170
228
|
const summary = warnSummary(findings, theme);
|
|
171
229
|
if (onWarn === "allow") {
|
|
172
230
|
return { exitCode: 0, stdout: "", stderr: `${summary} ${theme.paint("muted", "proceeding (gitHook.onWarn=allow)")}\n` };
|
|
@@ -183,6 +241,7 @@ function decideWarn(findings, theme, onWarn, policyMode, hook) {
|
|
|
183
241
|
return { exitCode: 1, stdout: "", stderr: `${summary} ${theme.paint("muted", `commit blocked (no terminal; policy ${policyMode}). Use`)} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n` };
|
|
184
242
|
}
|
|
185
243
|
if (answer) {
|
|
244
|
+
onAccepted?.();
|
|
186
245
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
187
246
|
}
|
|
188
247
|
return { exitCode: 1, stdout: "", stderr: ` ${theme.paint("muted", "Nothing was committed.")}\n` };
|
|
@@ -9,7 +9,7 @@ import { ErrorView } from "./components/ErrorView.js";
|
|
|
9
9
|
import { ProjectSelector } from "./components/ProjectSelector.js";
|
|
10
10
|
import { SetupBanner } from "./components/SetupBanner.js";
|
|
11
11
|
import { useTerminalSize } from "./hooks/useTerminalSize.js";
|
|
12
|
-
import { scanExitCode } from "./shims.js";
|
|
12
|
+
import { effectiveScanAction, scanExitCode } from "./shims.js";
|
|
13
13
|
import { leaveTui, showCursor, tuiIsActive } from "./alt-screen.js";
|
|
14
14
|
import { formatResetDate } from "../install-ui/block-render.js";
|
|
15
15
|
export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialView, updateAvailable }) => {
|
|
@@ -27,7 +27,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
|
|
|
27
27
|
useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
|
|
28
28
|
const handleResultsExit = useCallback(() => {
|
|
29
29
|
if (state.phase === "results") {
|
|
30
|
-
process.exitCode = scanExitCode(state.result.action, config.mode);
|
|
30
|
+
process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
|
|
31
31
|
}
|
|
32
32
|
leaveAltScreen();
|
|
33
33
|
exit();
|
|
@@ -62,7 +62,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
|
|
|
62
62
|
// not only when the user dismisses the view — a piped/killed/non-TTY exit
|
|
63
63
|
// must still carry the right code (an unverified scan is never a silent 0).
|
|
64
64
|
if (state.phase === "results") {
|
|
65
|
-
process.exitCode = scanExitCode(state.result.action, config.mode);
|
|
65
|
+
process.exitCode = scanExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.mode);
|
|
66
66
|
}
|
|
67
67
|
}, [state, config, exitWithMessage]);
|
|
68
68
|
useInput((input, key) => {
|
|
@@ -97,7 +97,7 @@ export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialVi
|
|
|
97
97
|
? `batch ${state.batchIndex}/${state.batchCount}`
|
|
98
98
|
: undefined }));
|
|
99
99
|
case "results":
|
|
100
|
-
return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView }));
|
|
100
|
+
return (_jsx(InteractiveResultsView, { result: state.result, config: config, durationMs: state.durationMs, onExit: handleResultsExit, onBack: restartSelection ?? undefined, discoveredTotal: state.discoveredTotal, userStatus: userStatus, scanUsage: scanUsage, initialView: initialView, decisions: state.decisions }));
|
|
101
101
|
case "empty":
|
|
102
102
|
return _jsx(Text, { dimColor: true, children: state.message });
|
|
103
103
|
case "error":
|
|
@@ -5,11 +5,14 @@ import chalk from "chalk";
|
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { resolve as resolvePath } from "node:path";
|
|
7
7
|
import { isLoggedIn } from "../shims.js";
|
|
8
|
+
import { packageKey } from "../../decisions/apply.js";
|
|
8
9
|
import { ScoreHeader, COMPACT_ROWS } from "./ScoreHeader.js";
|
|
9
10
|
import { useExpandAnimation } from "../hooks/useExpandAnimation.js";
|
|
10
11
|
import { useTerminalSize } from "../hooks/useTerminalSize.js";
|
|
11
12
|
import { clearScreen } from "../alt-screen.js";
|
|
12
13
|
import { pad, truncate, groupPackages as sharedGroupPackages, formatUsage } from "../format-helpers.js";
|
|
14
|
+
import { provenanceLabel, provenanceDowngradeLine } from "../../presentation/provenance.js";
|
|
15
|
+
const ACK_PREVIEW_LIMIT = 3;
|
|
13
16
|
function groupPackages(packages) {
|
|
14
17
|
return sharedGroupPackages(packages, "fingerprint");
|
|
15
18
|
}
|
|
@@ -50,6 +53,19 @@ export function packageBadge(pkg) {
|
|
|
50
53
|
return { label: "Unverified", color: chalk.yellow };
|
|
51
54
|
return actionBadge(pkg.action);
|
|
52
55
|
}
|
|
56
|
+
export function provenanceMarker(pkg) {
|
|
57
|
+
const prov = pkg.provenance;
|
|
58
|
+
if (!prov)
|
|
59
|
+
return " ";
|
|
60
|
+
if (prov.downgrade)
|
|
61
|
+
return chalk.yellow("◇ ");
|
|
62
|
+
if (prov.status === "attested")
|
|
63
|
+
return chalk.dim("◆ ");
|
|
64
|
+
return " ";
|
|
65
|
+
}
|
|
66
|
+
function packageDowngradeLine(pkg) {
|
|
67
|
+
return pkg.provenance ? provenanceDowngradeLine(pkg.version, pkg.provenance) : null;
|
|
68
|
+
}
|
|
53
69
|
const EVIDENCE_LIMIT = 2;
|
|
54
70
|
const BADGE_COL = "Unverified".length + 1;
|
|
55
71
|
// Fixed lines outside the scrollable group area:
|
|
@@ -80,6 +96,8 @@ function findingsSummaryHeight(group) {
|
|
|
80
96
|
let h = 0;
|
|
81
97
|
if (rep.license)
|
|
82
98
|
h += 1;
|
|
99
|
+
if (packageDowngradeLine(rep))
|
|
100
|
+
h += 1;
|
|
83
101
|
if (isFree) {
|
|
84
102
|
h += 1;
|
|
85
103
|
}
|
|
@@ -140,6 +158,14 @@ function buildDetailLines(group, safeVersion, maxWidth) {
|
|
|
140
158
|
lines.push(_jsxs(Text, { dimColor: true, children: ["Score: ", rep.score, "/100"] }, "score-info"));
|
|
141
159
|
lines.push(_jsx(Text, { children: "" }, "score-gap"));
|
|
142
160
|
}
|
|
161
|
+
if (rep.provenance) {
|
|
162
|
+
lines.push(_jsxs(Text, { dimColor: true, children: ["Provenance: ", provenanceLabel(rep.provenance)] }, "provenance"));
|
|
163
|
+
const downgrade = packageDowngradeLine(rep);
|
|
164
|
+
if (downgrade) {
|
|
165
|
+
lines.push(_jsx(Text, { wrap: "truncate-end", children: chalk.yellow(downgrade) }, "provenance-downgrade"));
|
|
166
|
+
}
|
|
167
|
+
lines.push(_jsx(Text, { children: "" }, "provenance-gap"));
|
|
168
|
+
}
|
|
143
169
|
if (visibleFindings.length > 0) {
|
|
144
170
|
for (let i = 0; i < visibleFindings.length; i++) {
|
|
145
171
|
const f = visibleFindings[i];
|
|
@@ -194,7 +220,7 @@ function viewReducer(_state, action) {
|
|
|
194
220
|
return { ..._state, expandedKey: action.expandedKey, expandLevel: action.expandLevel, viewport: action.viewport };
|
|
195
221
|
}
|
|
196
222
|
}
|
|
197
|
-
export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, }) => {
|
|
223
|
+
export const InteractiveResultsView = ({ result, config: _config, durationMs, onExit, onBack, discoveredTotal, userStatus, scanUsage: scanUsageProp, initialView, decisions, }) => {
|
|
198
224
|
// Prefer the server's uniform usage block ("used / limit packages this
|
|
199
225
|
// month"); fall back to the legacy freeScansRemaining field, then to the
|
|
200
226
|
// generic placeholder from bin.ts. usageNearLimit drives the yellow + nudge.
|
|
@@ -212,7 +238,20 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
212
238
|
const clean = useMemo(() => result.packages.filter((p) => (p.action ?? "pass") === "pass"), [result.packages]);
|
|
213
239
|
const total = result.packages.length;
|
|
214
240
|
const [searchQuery, setSearchQuery] = useState("");
|
|
215
|
-
const
|
|
241
|
+
const ackByKey = useMemo(() => {
|
|
242
|
+
const map = new Map();
|
|
243
|
+
if (decisions) {
|
|
244
|
+
for (const [key, annotation] of Object.entries(decisions.packages)) {
|
|
245
|
+
if (annotation.acknowledged)
|
|
246
|
+
map.set(key, annotation.acknowledged);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return map;
|
|
250
|
+
}, [decisions]);
|
|
251
|
+
const activeFlagged = useMemo(() => flagged.filter((p) => !ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
|
|
252
|
+
const acked = useMemo(() => flagged.filter((p) => ackByKey.has(packageKey(p.name, p.version))), [flagged, ackByKey]);
|
|
253
|
+
const ackGroups = useMemo(() => groupPackages(acked), [acked]);
|
|
254
|
+
const allGroups = useMemo(() => groupPackages(activeFlagged), [activeFlagged]);
|
|
216
255
|
const groups = useMemo(() => {
|
|
217
256
|
if (!searchQuery)
|
|
218
257
|
return allGroups;
|
|
@@ -223,13 +262,18 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
223
262
|
if (members.length > 0)
|
|
224
263
|
matched.push({ packages: members, key: g.key });
|
|
225
264
|
}
|
|
265
|
+
for (const p of acked) {
|
|
266
|
+
if (p.name.toLowerCase().includes(q)) {
|
|
267
|
+
matched.push({ packages: [p], key: `ack|${p.name}@${p.version ?? ""}` });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
226
270
|
for (const p of clean) {
|
|
227
271
|
if (p.name.toLowerCase().includes(q)) {
|
|
228
272
|
matched.push({ packages: [p], key: `pass|${p.name}@${p.version ?? ""}` });
|
|
229
273
|
}
|
|
230
274
|
}
|
|
231
275
|
return matched;
|
|
232
|
-
}, [allGroups, clean, searchQuery]);
|
|
276
|
+
}, [allGroups, acked, clean, searchQuery]);
|
|
233
277
|
const matchCount = useMemo(() => groups.reduce((n, g) => n + g.packages.length, 0), [groups]);
|
|
234
278
|
const [view, dispatchView] = useReducer(viewReducer, {
|
|
235
279
|
cursor: 0,
|
|
@@ -619,7 +663,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
619
663
|
searchModeRef.current = searchMode;
|
|
620
664
|
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
621
665
|
const compact = termRows < COMPACT_ROWS;
|
|
622
|
-
const
|
|
666
|
+
const ackSectionLines = acked.length > 0
|
|
667
|
+
? 2 + Math.min(ackGroups.length, ACK_PREVIEW_LIMIT) + (ackGroups.length > ACK_PREVIEW_LIMIT ? 1 : 0)
|
|
668
|
+
: 0;
|
|
669
|
+
const availableRows = Math.max(5, termRows - (compact ? FIXED_CHROME_COMPACT : FIXED_CHROME) - ackSectionLines);
|
|
623
670
|
const innerWidth = Math.max(40, termCols - 6);
|
|
624
671
|
const detailGroup = useMemo(() => {
|
|
625
672
|
if (!detailPane)
|
|
@@ -1014,7 +1061,7 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
1014
1061
|
const aboveCount = view.viewport;
|
|
1015
1062
|
const belowCount = groups.length - visibleEnd;
|
|
1016
1063
|
const lcCol = 16; // fixed width for license column
|
|
1017
|
-
const nameCol = Math.max(20, innerWidth - BADGE_COL -
|
|
1064
|
+
const nameCol = Math.max(20, innerWidth - BADGE_COL - 16 - lcCol);
|
|
1018
1065
|
// Clamp cursor to valid range (groups may shrink via search filter)
|
|
1019
1066
|
const clampedCursor = groups.length > 0 ? Math.min(view.cursor, groups.length - 1) : 0;
|
|
1020
1067
|
// ── Export menu overlay ──
|
|
@@ -1156,8 +1203,14 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
1156
1203
|
: (lcInfo.riskCategory === "no-license" || lcInfo.riskCategory === "unlicensed" || lcInfo.riskCategory === "network-copyleft") ? chalk.red
|
|
1157
1204
|
: chalk.yellow;
|
|
1158
1205
|
const arrow = level === "summary" ? "\u25BE" : "\u25B8"; // ▾ expanded, ▸ collapsed
|
|
1159
|
-
return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
|
|
1160
|
-
}), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })),
|
|
1206
|
+
return (_jsxs(Box, { flexDirection: "column", children: [isCursor ? (_jsxs(Text, { backgroundColor: "#1a1a2e", wrap: "truncate-end", children: [chalk.cyan("\u258C"), " ", chalk.cyan(arrow), " ", ` `, color(pad(label, BADGE_COL)), chalk.bold(pad(truncate(names, nameCol - 2), nameCol)), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3)), " "] })) : (_jsxs(Text, { wrap: "truncate-end", children: [` ${chalk.dim(arrow)} `, color(pad(label, BADGE_COL)), pad(truncate(names, nameCol - 2), nameCol), provenanceMarker(rep), lcColor(pad(lcStr, lcCol)), color(scoreStr.padStart(3))] })), level === "summary" && (_jsx(FindingsSummary, { group: group, maxWidth: innerWidth - 8, maxLines: group.key === view.expandedKey ? animVisibleLines : undefined }))] }, group.key));
|
|
1207
|
+
}), belowCount > 0 && (_jsxs(Text, { dimColor: true, children: [chalk.cyan(" \u2193"), " ", belowCount, " more below"] }))] })] })), searchQuery && groups.length === 0 && (_jsx(Text, { dimColor: true, children: ` No packages match "${searchQuery}"` })), acked.length > 0 && !searchQuery && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [_jsxs(Text, { bold: true, dimColor: true, children: ["Acknowledged (", acked.length, ") \\u00b7 dg.json"] }), ackGroups.slice(0, ACK_PREVIEW_LIMIT).map((group) => {
|
|
1208
|
+
const rep = firstPackage(group);
|
|
1209
|
+
const ack = ackByKey.get(packageKey(rep.name, rep.version));
|
|
1210
|
+
const who = ack?.by ?? "unknown";
|
|
1211
|
+
const when = ack?.at ? ` on ${ack.at.slice(0, 10)}` : "";
|
|
1212
|
+
return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", chalk.yellow("\u25b8"), " ", groupNames(group), " ", chalk.dim(`accepted by ${who}${when}`)] }, `ack-${group.key}`));
|
|
1213
|
+
}), ackGroups.length > ACK_PREVIEW_LIMIT && (_jsx(Text, { dimColor: true, children: ` +${ackGroups.length - ACK_PREVIEW_LIMIT} more \u2014 dg decisions` }))] })), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, width: "100%", children: [discoveredTotal !== undefined && discoveredTotal > total && (_jsxs(Text, { dimColor: true, children: ["Scanned ", total, " of ", discoveredTotal, " packages"] })), acked.length > 0 && (_jsxs(Text, { wrap: "truncate-end", children: [chalk.yellow("\u26a0"), " ", chalk.yellow(String(acked.length)), " ", chalk.dim(`acknowledged warn${acked.length !== 1 ? "s" : ""} \u00b7 dg.json \u00b7 review with 'dg decisions'`)] })), _jsxs(Box, { justifyContent: "space-between", children: [clean.length > 0 ? (_jsxs(Text, { wrap: "truncate-end", children: [chalk.green("\u2713"), " ", chalk.green.bold(String(clean.length)), " ", chalk.dim(`package${clean.length !== 1 ? "s" : ""} passed`), " ", chalk.dim(`\u00b7 ${(durationMs / 1000).toFixed(1)}s`)] })) : (_jsxs(Text, { dimColor: true, children: [(durationMs / 1000).toFixed(1), "s"] })), result.freeScansRemaining !== undefined && (_jsx(Text, { dimColor: true, children: "Free tier \u00B7 dg login for higher scan limits" }))] })] }), !compact && _jsx(Text, { dimColor: true, children: chalk.dim("\u2500".repeat(Math.max(20, termCols - 4))) }), searchMode ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, chalk.cyan("\u2588"), " ", chalk.dim("Esc clear")] })) : exportMsg ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.green(exportMsg)] })) : searchQuery ? (_jsxs(Text, { wrap: "truncate-end", children: [" ", chalk.bold.cyan("/"), " ", searchQuery, " ", chalk.dim(`${matchCount} of ${total} packages`), " ", chalk.bold.cyan("Esc"), " ", chalk.dim("clear"), " ", chalk.bold.cyan("q"), " ", chalk.dim("quit")] })) : (_jsxs(Text, { wrap: "truncate-end", children: [" ", groups.length > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("\u2191\u2193"), " ", chalk.dim("navigate"), " ", chalk.bold.cyan("\u23CE"), " ", chalk.dim("expand"), " "] })), total > 0 && (_jsxs(_Fragment, { children: [chalk.bold.cyan("/"), " ", chalk.dim("search"), " "] })), chalk.bold.cyan("l"), " ", chalk.dim("licenses"), " ", chalk.bold.cyan("e"), " ", chalk.dim("export"), " ", onBack && _jsxs(_Fragment, { children: [chalk.bold.cyan("Esc"), " ", chalk.dim("back"), " "] }), chalk.bold.cyan("q"), " ", chalk.dim("quit")] }))] }));
|
|
1161
1214
|
};
|
|
1162
1215
|
const T = {
|
|
1163
1216
|
branch: chalk.dim("\u251C\u2500\u2500"),
|
|
@@ -1197,6 +1250,10 @@ const FindingsSummary = ({ group, maxWidth, maxLines }) => {
|
|
|
1197
1250
|
const lcLine = licenseLine(rep);
|
|
1198
1251
|
if (lcLine)
|
|
1199
1252
|
allLines.push(lcLine);
|
|
1253
|
+
const downgrade = packageDowngradeLine(rep);
|
|
1254
|
+
if (downgrade) {
|
|
1255
|
+
allLines.push(_jsxs(Text, { wrap: "truncate-end", children: [T.branch, " ", chalk.yellow(downgrade)] }, "provenance-downgrade"));
|
|
1256
|
+
}
|
|
1200
1257
|
// Render findings — API returns tier-gated data:
|
|
1201
1258
|
// Free: { category, severity } — don't show raw IDs, just upgrade prompt
|
|
1202
1259
|
// Pro: { category, severity, title } — show category + title
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { useReducer, useEffect, useRef, useCallback, useState } from "react";
|
|
3
3
|
import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
|
|
4
|
+
import { applyDecisions, packageKey } from "../../decisions/apply.js";
|
|
5
|
+
import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
|
|
4
6
|
import { collectScanPackages, discoverScanProjectsAsync } from "../../scan/collect.js";
|
|
5
7
|
function reducer(_state, action) {
|
|
6
8
|
switch (action.type) {
|
|
@@ -21,7 +23,8 @@ function reducer(_state, action) {
|
|
|
21
23
|
result: action.result,
|
|
22
24
|
durationMs: action.durationMs,
|
|
23
25
|
skippedCount: action.skippedCount,
|
|
24
|
-
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {})
|
|
26
|
+
...(action.discoveredTotal !== undefined ? { discoveredTotal: action.discoveredTotal } : {}),
|
|
27
|
+
...(action.decisions !== undefined ? { decisions: action.decisions } : {})
|
|
25
28
|
};
|
|
26
29
|
case "ERROR":
|
|
27
30
|
return { phase: "error", error: action.error };
|
|
@@ -79,6 +82,28 @@ export function useScan(config) {
|
|
|
79
82
|
restartSelection: multiProjects ? restartSelection : null
|
|
80
83
|
};
|
|
81
84
|
}
|
|
85
|
+
function computeProjectDecisions(result, entries) {
|
|
86
|
+
try {
|
|
87
|
+
const root = findProjectRoot(process.cwd());
|
|
88
|
+
if (!root) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const file = loadDgFile(root);
|
|
92
|
+
if (!file.readable) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const ecosystems = new Map();
|
|
96
|
+
for (const [ecosystem, packages] of entries) {
|
|
97
|
+
for (const pkg of packages) {
|
|
98
|
+
ecosystems.set(packageKey(pkg.name, pkg.version), ecosystem);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return applyDecisions(result.packages, (pkg) => ecosystems.get(packageKey(pkg.name, pkg.version)), file, result.action);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
82
107
|
async function scanProjects(projects, dispatch, signal) {
|
|
83
108
|
const startMs = Date.now();
|
|
84
109
|
let skipped = 0;
|
|
@@ -134,12 +159,15 @@ async function scanProjects(projects, dispatch, signal) {
|
|
|
134
159
|
const firstFailure = outcomes.find((outcome) => "error" in outcome);
|
|
135
160
|
if (responses.length > 0) {
|
|
136
161
|
const merged = mergeAnalyzeResponses(responses);
|
|
162
|
+
const result = firstFailure && merged.action === "pass" ? { ...merged, action: "analysis_incomplete" } : merged;
|
|
163
|
+
const decisions = computeProjectDecisions(result, entries);
|
|
137
164
|
dispatch({
|
|
138
165
|
type: "SCAN_COMPLETE",
|
|
139
|
-
result
|
|
166
|
+
result,
|
|
140
167
|
durationMs: Date.now() - startMs,
|
|
141
168
|
skippedCount: skipped,
|
|
142
|
-
discoveredTotal: total
|
|
169
|
+
discoveredTotal: total,
|
|
170
|
+
...(decisions ? { decisions } : {})
|
|
143
171
|
});
|
|
144
172
|
return;
|
|
145
173
|
}
|
package/dist/scan-ui/shims.js
CHANGED
|
@@ -16,6 +16,9 @@ export function getStoredApiKey() {
|
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
export function effectiveScanAction(raw, effective, mode) {
|
|
20
|
+
return mode === "strict" || effective === undefined ? raw : effective;
|
|
21
|
+
}
|
|
19
22
|
export function scanExitCode(action, mode) {
|
|
20
23
|
if (action === "block") {
|
|
21
24
|
return 2;
|