@westbayberry/dg 2.0.10 → 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/collect.js +10 -4
- package/dist/scan/command.js +35 -8
- package/dist/scan/discovery.js +66 -4
- 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/launch.js +4 -1
- 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/command.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { writeFileSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
3
4
|
import { scanProject } from "./discovery.js";
|
|
4
5
|
import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
|
|
5
6
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
@@ -18,12 +19,12 @@ export function runScanCommand(context) {
|
|
|
18
19
|
const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
|
|
19
20
|
const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
|
|
20
21
|
if (parsed.staged && !machineOutput) {
|
|
21
|
-
return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
|
|
22
|
+
return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
|
|
22
23
|
}
|
|
23
24
|
let report;
|
|
24
25
|
let outcome;
|
|
25
26
|
if (parsed.staged) {
|
|
26
|
-
const staged = stagedScanReport({ targetPath: stagedTarget });
|
|
27
|
+
const staged = stagedScanReport({ targetPath: stagedTarget, useDecisions: !parsed.noDecisions });
|
|
27
28
|
if ("result" in staged) {
|
|
28
29
|
return staged.result;
|
|
29
30
|
}
|
|
@@ -58,7 +59,7 @@ export function runScanCommand(context) {
|
|
|
58
59
|
stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
|
-
outcome = runScannerScan(parsed.targetPath, report);
|
|
62
|
+
outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath));
|
|
62
63
|
}
|
|
63
64
|
if (outcome.kind === "report") {
|
|
64
65
|
report = outcome.report;
|
|
@@ -99,6 +100,7 @@ function parseScanArgs(args) {
|
|
|
99
100
|
let sawTarget = false;
|
|
100
101
|
let staged = false;
|
|
101
102
|
let hook = false;
|
|
103
|
+
let noDecisions = false;
|
|
102
104
|
for (let index = 0; index < args.length; index += 1) {
|
|
103
105
|
const arg = args[index];
|
|
104
106
|
if (!arg) {
|
|
@@ -112,6 +114,10 @@ function parseScanArgs(args) {
|
|
|
112
114
|
hook = true;
|
|
113
115
|
continue;
|
|
114
116
|
}
|
|
117
|
+
if (arg === "--no-decisions") {
|
|
118
|
+
noDecisions = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
115
121
|
if (arg === "--json") {
|
|
116
122
|
if (format !== "text") {
|
|
117
123
|
return { error: "choose only one output format" };
|
|
@@ -150,16 +156,35 @@ function parseScanArgs(args) {
|
|
|
150
156
|
targetPath,
|
|
151
157
|
sawTarget,
|
|
152
158
|
staged,
|
|
153
|
-
hook
|
|
159
|
+
hook,
|
|
160
|
+
noDecisions
|
|
154
161
|
};
|
|
155
162
|
}
|
|
156
163
|
function usageError(message) {
|
|
157
164
|
return {
|
|
158
165
|
exitCode: EXIT_USAGE_VERDICT,
|
|
159
166
|
stdout: "",
|
|
160
|
-
stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
|
|
167
|
+
stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--no-decisions]\n`
|
|
161
168
|
};
|
|
162
169
|
}
|
|
170
|
+
function loadScanDecisions(targetPath, env = process.env) {
|
|
171
|
+
let dir = resolve(targetPath);
|
|
172
|
+
try {
|
|
173
|
+
if (!statSync(dir).isDirectory()) {
|
|
174
|
+
dir = dirname(dir);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const root = findProjectRoot(dir, env);
|
|
181
|
+
if (!root) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const file = loadDgFile(root);
|
|
185
|
+
warnUnreadableDgFile(file);
|
|
186
|
+
return file.readable ? file : null;
|
|
187
|
+
}
|
|
163
188
|
function degradeReport(report, error) {
|
|
164
189
|
const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
|
|
165
190
|
return { ...report, status, scannerError: error };
|
|
@@ -184,7 +209,9 @@ function renderReport(report, format, scannerUnavailable, skipNotice) {
|
|
|
184
209
|
}
|
|
185
210
|
function exitCodeForReport(report) {
|
|
186
211
|
if (report.scanner) {
|
|
187
|
-
|
|
212
|
+
const mode = loadUserConfig().policy.mode;
|
|
213
|
+
const action = mode === "strict" ? report.scanner.action : report.decisions?.effectiveAction ?? report.scanner.action;
|
|
214
|
+
return scanExitCode(action, mode);
|
|
188
215
|
}
|
|
189
216
|
if (report.status === "block") {
|
|
190
217
|
return 2;
|
package/dist/scan/discovery.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
3
4
|
const IGNORED_DIRECTORIES = new Set([
|
|
@@ -75,10 +76,71 @@ function resolveStatus(counts) {
|
|
|
75
76
|
}
|
|
76
77
|
function discoverPackageManifests(root) {
|
|
77
78
|
const manifests = [];
|
|
78
|
-
walk(root, 0, manifests);
|
|
79
|
+
walk(root, 0, manifests, gitIgnoredDirectories(root));
|
|
79
80
|
return manifests.sort((left, right) => displayPath(root, left).localeCompare(displayPath(root, right)));
|
|
80
81
|
}
|
|
81
|
-
function
|
|
82
|
+
export function gitIgnoredDirectories(root) {
|
|
83
|
+
const ignored = new Set();
|
|
84
|
+
if (!insideGitWorkTree(root)) {
|
|
85
|
+
return ignored;
|
|
86
|
+
}
|
|
87
|
+
let level = [root];
|
|
88
|
+
for (let depth = 0; depth <= MAX_DISCOVERY_DEPTH && level.length > 0; depth++) {
|
|
89
|
+
const candidates = [];
|
|
90
|
+
for (const directory of level) {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)) {
|
|
100
|
+
candidates.push(resolve(directory, entry.name));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (candidates.length === 0) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const flagged = checkIgnoreBatch(root, candidates);
|
|
108
|
+
for (const directory of flagged) {
|
|
109
|
+
ignored.add(directory);
|
|
110
|
+
}
|
|
111
|
+
level = candidates.filter((directory) => !flagged.has(directory));
|
|
112
|
+
}
|
|
113
|
+
return ignored;
|
|
114
|
+
}
|
|
115
|
+
function insideGitWorkTree(root) {
|
|
116
|
+
try {
|
|
117
|
+
const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
|
|
118
|
+
encoding: "utf8",
|
|
119
|
+
timeout: 3000,
|
|
120
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
121
|
+
});
|
|
122
|
+
return out.trim() === "true";
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function checkIgnoreBatch(root, directories) {
|
|
129
|
+
try {
|
|
130
|
+
const out = execFileSync("git", ["-C", root, "check-ignore", "--stdin", "-z"], {
|
|
131
|
+
input: directories.join("\0"),
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
135
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
136
|
+
});
|
|
137
|
+
return new Set(out.split("\0").filter(Boolean));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return new Set();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function walk(directory, depth, manifests, gitIgnored) {
|
|
82
144
|
if (depth > MAX_DISCOVERY_DEPTH) {
|
|
83
145
|
return;
|
|
84
146
|
}
|
|
@@ -94,8 +156,8 @@ function walk(directory, depth, manifests) {
|
|
|
94
156
|
for (const entry of entries) {
|
|
95
157
|
const absolutePath = resolve(directory, entry.name);
|
|
96
158
|
if (entry.isDirectory()) {
|
|
97
|
-
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
98
|
-
walk(absolutePath, depth + 1, manifests);
|
|
159
|
+
if (!IGNORED_DIRECTORIES.has(entry.name) && !gitIgnored.has(absolutePath)) {
|
|
160
|
+
walk(absolutePath, depth + 1, manifests, gitIgnored);
|
|
99
161
|
}
|
|
100
162
|
continue;
|
|
101
163
|
}
|
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":
|