@westbayberry/dg 2.0.8 → 2.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -12
- package/dist/api/analyze.js +134 -34
- package/dist/audit-ui/export.js +3 -4
- package/dist/auth/device-login.js +13 -9
- package/dist/auth/store.js +43 -26
- package/dist/bin/dg.js +5 -0
- package/dist/commands/audit.js +14 -4
- package/dist/commands/config.js +3 -5
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/explain.js +138 -6
- package/dist/commands/licenses.js +37 -24
- package/dist/commands/login.js +12 -3
- package/dist/commands/logout.js +15 -4
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +76 -24
- package/dist/commands/status.js +38 -4
- package/dist/commands/types.js +1 -0
- package/dist/config/settings.js +102 -22
- package/dist/launcher/install-preflight.js +81 -12
- package/dist/launcher/output-redaction.js +5 -3
- package/dist/launcher/preflight-prompt.js +31 -12
- package/dist/launcher/run.js +87 -8
- package/dist/proxy/ca.js +69 -29
- package/dist/proxy/enforcement.js +41 -3
- package/dist/proxy/worker.js +21 -9
- package/dist/runtime/first-run.js +33 -2
- package/dist/runtime/nudges.js +9 -2
- package/dist/scan/analyze-worker.js +18 -8
- package/dist/scan/collect.js +45 -32
- package/dist/scan/command.js +80 -40
- package/dist/scan/discovery.js +75 -7
- package/dist/scan/render.js +22 -6
- package/dist/scan/scanner-report.js +89 -12
- package/dist/scan/staged.js +69 -7
- package/dist/scan-ui/LegacyApp.js +10 -48
- package/dist/scan-ui/components/InteractiveResultsView.js +171 -111
- package/dist/scan-ui/components/ProjectSelector.js +3 -3
- package/dist/scan-ui/components/ScoreHeader.js +8 -4
- package/dist/scan-ui/hooks/useScan.js +74 -27
- package/dist/scan-ui/launch.js +21 -4
- package/dist/service/state.js +15 -4
- package/dist/service/trust-store.js +23 -2
- package/dist/setup/git-hook.js +28 -17
- package/dist/setup/plan.js +302 -18
- package/dist/state/cleanup-registry.js +65 -8
- package/dist/state/locks.js +95 -9
- package/dist/state/sessions.js +66 -2
- package/dist/verify/package-check.js +22 -3
- package/dist/verify/preflight.js +328 -170
- package/package.json +1 -1
package/dist/scan/command.js
CHANGED
|
@@ -5,53 +5,70 @@ import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.
|
|
|
5
5
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
6
|
import { createTheme } from "../presentation/theme.js";
|
|
7
7
|
import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
|
|
8
|
-
import {
|
|
9
|
-
import { runStagedScan } from "./staged.js";
|
|
8
|
+
import { runScannerScan } from "./scanner-report.js";
|
|
9
|
+
import { runStagedScan, stagedScanReport } from "./staged.js";
|
|
10
10
|
import { scanExitCode } from "../scan-ui/shims.js";
|
|
11
11
|
import { loadUserConfig } from "../config/settings.js";
|
|
12
|
-
import {
|
|
12
|
+
import { EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
13
13
|
export function runScanCommand(context) {
|
|
14
14
|
const parsed = parseScanArgs(context.args);
|
|
15
15
|
if ("error" in parsed) {
|
|
16
16
|
return usageError(parsed.error);
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
targetPath: parsed.targetPath,
|
|
23
|
-
format: parsed.format,
|
|
24
|
-
outputPath: parsed.outputPath ?? undefined
|
|
25
|
-
})) {
|
|
26
|
-
void launchScanTui().catch((error) => {
|
|
27
|
-
process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
28
|
-
process.exitCode = 1;
|
|
29
|
-
});
|
|
30
|
-
return {
|
|
31
|
-
exitCode: 0,
|
|
32
|
-
stdout: "",
|
|
33
|
-
stderr: ""
|
|
34
|
-
};
|
|
18
|
+
const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
|
|
19
|
+
const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
|
|
20
|
+
if (parsed.staged && !machineOutput) {
|
|
21
|
+
return runStagedScan({ hook: parsed.hook, targetPath: stagedTarget });
|
|
35
22
|
}
|
|
36
23
|
let report;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
let outcome;
|
|
25
|
+
if (parsed.staged) {
|
|
26
|
+
const staged = stagedScanReport({ targetPath: stagedTarget });
|
|
27
|
+
if ("result" in staged) {
|
|
28
|
+
return staged.result;
|
|
29
|
+
}
|
|
30
|
+
report = staged.report;
|
|
31
|
+
outcome = staged.outcome;
|
|
41
32
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
33
|
+
else {
|
|
34
|
+
if (shouldLaunchScanTui({
|
|
35
|
+
targetPath: parsed.targetPath,
|
|
36
|
+
format: parsed.format,
|
|
37
|
+
outputPath: parsed.outputPath ?? undefined
|
|
38
|
+
})) {
|
|
39
|
+
void launchScanTui().catch((error) => {
|
|
40
|
+
process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
exitCode: 0,
|
|
45
|
+
stdout: "",
|
|
46
|
+
stderr: ""
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
report = scanProject({
|
|
51
|
+
targetPath: parsed.targetPath
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return {
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
stdout: "",
|
|
58
|
+
stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
outcome = runScannerScan(parsed.targetPath, report);
|
|
62
|
+
}
|
|
63
|
+
if (outcome.kind === "report") {
|
|
64
|
+
report = outcome.report;
|
|
48
65
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
report = scannerReport;
|
|
66
|
+
else if (outcome.kind === "failed") {
|
|
67
|
+
report = degradeReport(report, outcome.error);
|
|
52
68
|
}
|
|
53
|
-
const
|
|
54
|
-
const
|
|
69
|
+
const skipNotice = skipNoticeFor(outcome, report);
|
|
70
|
+
const scannerUnavailable = !report.scanner && report.summary.projectCount > 0;
|
|
71
|
+
const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice);
|
|
55
72
|
if (parsed.outputPath) {
|
|
56
73
|
try {
|
|
57
74
|
writeFileSync(resolve(parsed.outputPath), rendered, "utf8");
|
|
@@ -111,7 +128,7 @@ function parseScanArgs(args) {
|
|
|
111
128
|
}
|
|
112
129
|
if (arg === "--output" || arg === "-o") {
|
|
113
130
|
const next = args[index + 1];
|
|
114
|
-
if (!next) {
|
|
131
|
+
if (!next || next.startsWith("-")) {
|
|
115
132
|
return { error: `${arg} requires a path` };
|
|
116
133
|
}
|
|
117
134
|
outputPath = next;
|
|
@@ -131,29 +148,52 @@ function parseScanArgs(args) {
|
|
|
131
148
|
format,
|
|
132
149
|
outputPath,
|
|
133
150
|
targetPath,
|
|
151
|
+
sawTarget,
|
|
134
152
|
staged,
|
|
135
153
|
hook
|
|
136
154
|
};
|
|
137
155
|
}
|
|
138
156
|
function usageError(message) {
|
|
139
157
|
return {
|
|
140
|
-
exitCode:
|
|
158
|
+
exitCode: EXIT_USAGE_VERDICT,
|
|
141
159
|
stdout: "",
|
|
142
160
|
stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>]\n`
|
|
143
161
|
};
|
|
144
162
|
}
|
|
145
|
-
function
|
|
163
|
+
function degradeReport(report, error) {
|
|
164
|
+
const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
|
|
165
|
+
return { ...report, status, scannerError: error };
|
|
166
|
+
}
|
|
167
|
+
function skipNoticeFor(outcome, report) {
|
|
168
|
+
if (outcome.kind !== "skipped") {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (outcome.reason === "no_lockfiles") {
|
|
172
|
+
return report.summary.projectCount > 0 ? "no_lockfile" : undefined;
|
|
173
|
+
}
|
|
174
|
+
return "empty_lockfile";
|
|
175
|
+
}
|
|
176
|
+
function renderReport(report, format, scannerUnavailable, skipNotice) {
|
|
146
177
|
if (format === "json") {
|
|
147
178
|
return renderJsonReport(report, scannerUnavailable);
|
|
148
179
|
}
|
|
149
180
|
if (format === "sarif") {
|
|
150
181
|
return renderSarifReport(report);
|
|
151
182
|
}
|
|
152
|
-
return renderTextReport(report, undefined, createTheme(resolvePresentation().color),
|
|
183
|
+
return renderTextReport(report, undefined, createTheme(resolvePresentation().color), skipNotice);
|
|
153
184
|
}
|
|
154
185
|
function exitCodeForReport(report) {
|
|
155
186
|
if (report.scanner) {
|
|
156
187
|
return scanExitCode(report.scanner.action, loadUserConfig().policy.mode);
|
|
157
188
|
}
|
|
158
|
-
|
|
189
|
+
if (report.status === "block") {
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
if (report.status === "warn") {
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
if (report.status === "error" || report.status === "unknown") {
|
|
196
|
+
return 4;
|
|
197
|
+
}
|
|
198
|
+
return 0;
|
|
159
199
|
}
|
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,21 +76,88 @@ 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
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(directory, {
|
|
150
|
+
withFileTypes: true
|
|
151
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
88
156
|
for (const entry of entries) {
|
|
89
157
|
const absolutePath = resolve(directory, entry.name);
|
|
90
158
|
if (entry.isDirectory()) {
|
|
91
|
-
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
92
|
-
walk(absolutePath, depth + 1, manifests);
|
|
159
|
+
if (!IGNORED_DIRECTORIES.has(entry.name) && !gitIgnored.has(absolutePath)) {
|
|
160
|
+
walk(absolutePath, depth + 1, manifests, gitIgnored);
|
|
93
161
|
}
|
|
94
162
|
continue;
|
|
95
163
|
}
|
package/dist/scan/render.js
CHANGED
|
@@ -9,7 +9,14 @@ const SCAN_STATUS_ROLE = {
|
|
|
9
9
|
unknown: "unknown",
|
|
10
10
|
error: "block"
|
|
11
11
|
};
|
|
12
|
-
export function
|
|
12
|
+
export function displayScanStatus(report) {
|
|
13
|
+
return report.scannerError && report.status === "unknown" ? "analysis_incomplete" : report.status;
|
|
14
|
+
}
|
|
15
|
+
const SCANNER_SKIP_MESSAGES = {
|
|
16
|
+
no_lockfile: "no lockfile found — server verification skipped (local heuristics only)",
|
|
17
|
+
empty_lockfile: "lockfile contains no scannable packages — server verification skipped (local heuristics only)"
|
|
18
|
+
};
|
|
19
|
+
export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerNotice) {
|
|
13
20
|
const width = Math.max(48, Math.min(terminalWidth, 140));
|
|
14
21
|
const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
|
|
15
22
|
const findingProjects = report.projects.filter((project) => project.findings.length > 0);
|
|
@@ -18,7 +25,7 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
|
|
|
18
25
|
"Dependency Guardian scan",
|
|
19
26
|
`Target: ${report.target}`,
|
|
20
27
|
`Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
|
|
21
|
-
`Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], report
|
|
28
|
+
`Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], displayScanStatus(report))}`,
|
|
22
29
|
`Projects: ${report.summary.projectCount}`,
|
|
23
30
|
`Dependencies: ${report.summary.dependencyCount}`,
|
|
24
31
|
`Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
|
|
@@ -27,11 +34,20 @@ export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(),
|
|
|
27
34
|
: []),
|
|
28
35
|
""
|
|
29
36
|
];
|
|
30
|
-
if (
|
|
31
|
-
|
|
37
|
+
if (report.scannerError) {
|
|
38
|
+
const scannerError = report.scannerError;
|
|
39
|
+
lines.push(theme.paint(scannerError.kind === "quota_exceeded" ? "warn" : "block", `server scan failed: ${scannerError.message}`));
|
|
40
|
+
if (scannerError.scansUsed !== undefined || scannerError.scansLimit !== undefined) {
|
|
41
|
+
lines.push(`scans used: ${scannerError.scansUsed ?? "?"} of ${scannerError.scansLimit ?? "?"}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push("local heuristics only — no server verdict (run 'dg doctor' to diagnose)");
|
|
44
|
+
lines.push("");
|
|
45
|
+
}
|
|
46
|
+
else if (scannerNotice) {
|
|
47
|
+
lines.push(SCANNER_SKIP_MESSAGES[scannerNotice]);
|
|
32
48
|
lines.push("");
|
|
33
49
|
}
|
|
34
|
-
if (report.projects.length === 0 && report.errors.length === 0) {
|
|
50
|
+
if (report.projects.length === 0 && report.errors.length === 0 && !report.scanner && !report.scannerError) {
|
|
35
51
|
lines.push("No supported project manifests found.");
|
|
36
52
|
}
|
|
37
53
|
if (findingProjects.length > 0) {
|
|
@@ -96,7 +112,7 @@ function formatProject(project, width) {
|
|
|
96
112
|
return lines;
|
|
97
113
|
}
|
|
98
114
|
export function renderJsonReport(report, scannerUnavailable = false) {
|
|
99
|
-
return `${JSON.stringify({ ...report, scannerUnavailable }, null, 2)}\n`;
|
|
115
|
+
return `${JSON.stringify({ ...report, status: displayScanStatus(report), scannerUnavailable }, null, 2)}\n`;
|
|
100
116
|
}
|
|
101
117
|
export function renderSarifReport(report) {
|
|
102
118
|
const rules = uniqueFindings(report.findings).map((finding) => ({
|
|
@@ -1,43 +1,120 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { existsSync } from "node:fs";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { sanitize } from "../security/sanitize.js";
|
|
5
7
|
import { collectScanPackages, discoverScanProjects } from "./collect.js";
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
+
const WORKER_TIMEOUT_BASE_MS = 180_000;
|
|
9
|
+
const SERVER_PER_PACKAGE_WORST_CASE_MS = 660_000;
|
|
10
|
+
const SERVER_SCAN_CONCURRENCY = 64;
|
|
11
|
+
const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
|
|
12
|
+
export function scanWorkerTimeoutMs(packageCount) {
|
|
13
|
+
return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
|
|
14
|
+
}
|
|
15
|
+
export function runScannerScan(targetPath, localReport, env = process.env) {
|
|
8
16
|
const projects = discoverScanProjects(resolve(targetPath));
|
|
9
17
|
if (projects.length === 0) {
|
|
10
|
-
return
|
|
18
|
+
return { kind: "skipped", reason: "no_lockfiles" };
|
|
11
19
|
}
|
|
12
|
-
const
|
|
13
|
-
const groups = [...byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
|
|
20
|
+
const collected = collectScanPackages(projects);
|
|
21
|
+
const groups = [...collected.byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
|
|
14
22
|
const total = groups.reduce((sum, group) => sum + group.packages.length, 0);
|
|
15
23
|
if (total === 0) {
|
|
16
|
-
|
|
24
|
+
if (collected.skipped > 0) {
|
|
25
|
+
return { kind: "failed", error: lockfileParseError(projects, collected) };
|
|
26
|
+
}
|
|
27
|
+
return { kind: "skipped", reason: "no_packages" };
|
|
17
28
|
}
|
|
18
29
|
const workerPath = [
|
|
19
30
|
fileURLToPath(new URL("./analyze-worker.js", import.meta.url)),
|
|
20
31
|
fileURLToPath(new URL("../../dist/scan/analyze-worker.js", import.meta.url))
|
|
21
32
|
].find((candidate) => existsSync(candidate));
|
|
22
33
|
if (!workerPath) {
|
|
23
|
-
return
|
|
34
|
+
return {
|
|
35
|
+
kind: "failed",
|
|
36
|
+
error: { kind: "worker", message: "scanner worker is missing — reinstall @westbayberry/dg" }
|
|
37
|
+
};
|
|
24
38
|
}
|
|
25
|
-
const
|
|
39
|
+
const timeoutMs = scanWorkerTimeoutMs(total);
|
|
40
|
+
const worker = spawnSync(process.execPath, [workerPath], {
|
|
26
41
|
encoding: "utf8",
|
|
27
42
|
env,
|
|
28
|
-
|
|
43
|
+
input: JSON.stringify({ scanId: randomUUID(), groups }),
|
|
44
|
+
maxBuffer: WORKER_MAX_BUFFER,
|
|
45
|
+
timeout: timeoutMs
|
|
29
46
|
});
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
const failure = workerFailure(worker, timeoutMs, total);
|
|
48
|
+
if (failure) {
|
|
49
|
+
return { kind: "failed", error: failure };
|
|
32
50
|
}
|
|
33
51
|
let response;
|
|
34
52
|
try {
|
|
35
53
|
response = JSON.parse(worker.stdout);
|
|
36
54
|
}
|
|
55
|
+
catch {
|
|
56
|
+
return {
|
|
57
|
+
kind: "failed",
|
|
58
|
+
error: { kind: "invalid_response", message: "scanner worker returned unreadable output" }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { kind: "report", report: buildScannerReport(localReport, response, total) };
|
|
62
|
+
}
|
|
63
|
+
export function tryScannerScan(targetPath, localReport, env = process.env) {
|
|
64
|
+
const outcome = runScannerScan(targetPath, localReport, env);
|
|
65
|
+
return outcome.kind === "report" ? outcome.report : null;
|
|
66
|
+
}
|
|
67
|
+
export function workerFailure(worker, timeoutMs, packageCount) {
|
|
68
|
+
if (worker.error?.code === "ETIMEDOUT") {
|
|
69
|
+
return {
|
|
70
|
+
kind: "timeout",
|
|
71
|
+
message: `server scan timed out after ${Math.round(timeoutMs / 1000)}s without finishing ${packageCount} package${packageCount === 1 ? "" : "s"}`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (worker.error) {
|
|
75
|
+
return { kind: "worker", message: `scanner worker failed to start: ${worker.error.message}` };
|
|
76
|
+
}
|
|
77
|
+
if (worker.status === 0 && worker.stdout) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const reported = parseWorkerError(worker.stdout);
|
|
81
|
+
if (reported) {
|
|
82
|
+
return reported;
|
|
83
|
+
}
|
|
84
|
+
const stderrLine = (worker.stderr ?? "").trim().split("\n")[0] ?? "";
|
|
85
|
+
return {
|
|
86
|
+
kind: "worker",
|
|
87
|
+
message: stderrLine ? `scanner worker failed: ${sanitize(stderrLine)}` : "scanner worker exited without a result"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function parseWorkerError(stdout) {
|
|
91
|
+
if (!stdout) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(stdout);
|
|
96
|
+
if (parsed.scannerError && typeof parsed.scannerError.message === "string" && typeof parsed.scannerError.kind === "string") {
|
|
97
|
+
return parsed.scannerError;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
37
100
|
catch {
|
|
38
101
|
return null;
|
|
39
102
|
}
|
|
40
|
-
return
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function lockfileParseError(projects, collected) {
|
|
106
|
+
const parseErrors = collected.parseErrors ?? [];
|
|
107
|
+
const detail = parseErrors
|
|
108
|
+
.map((entry) => [entry.path, entry.message].filter(Boolean).join(": "))
|
|
109
|
+
.filter((line) => line.length > 0)
|
|
110
|
+
.join("; ");
|
|
111
|
+
const lockfiles = [...new Set(projects.map((project) => project.depFile))].join(", ");
|
|
112
|
+
return {
|
|
113
|
+
kind: "lockfile_unparsed",
|
|
114
|
+
message: detail
|
|
115
|
+
? `could not parse lockfile${parseErrors.length === 1 ? "" : "s"}: ${detail}`
|
|
116
|
+
: `found ${projects.length} lockfile${projects.length === 1 ? "" : "s"} (${lockfiles}) but no packages could be parsed`
|
|
117
|
+
};
|
|
41
118
|
}
|
|
42
119
|
export function buildScannerReport(localReport, response, analyzedCount) {
|
|
43
120
|
const findings = response.packages
|
package/dist/scan/staged.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { createTheme } from "../presentation/theme.js";
|
|
5
5
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
6
|
import { loadUserConfig } from "../config/settings.js";
|
|
@@ -8,8 +8,8 @@ import { gitSync, gitTrimmed } from "../util/git.js";
|
|
|
8
8
|
import { promptYesNo } from "../util/tty-prompt.js";
|
|
9
9
|
import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
|
|
10
10
|
import { isLockfileName } from "./collect.js";
|
|
11
|
-
import { tryScannerScan } from "./scanner-report.js";
|
|
12
|
-
import {
|
|
11
|
+
import { runScannerScan, tryScannerScan } from "./scanner-report.js";
|
|
12
|
+
import { EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
13
13
|
function emptyLocalReport(target) {
|
|
14
14
|
return {
|
|
15
15
|
target,
|
|
@@ -34,6 +34,20 @@ export function stagedLockfilePaths(cwd, env) {
|
|
|
34
34
|
}
|
|
35
35
|
return diff.stdout.split("\0").filter(Boolean).filter((path) => isLockfileName(basename(path)));
|
|
36
36
|
}
|
|
37
|
+
export function scopeStagedPaths(paths, root, cwd, targetPath) {
|
|
38
|
+
if (!targetPath) {
|
|
39
|
+
return [...paths];
|
|
40
|
+
}
|
|
41
|
+
const prefix = relative(safeRealpath(root), safeRealpath(resolve(safeRealpath(cwd), targetPath)));
|
|
42
|
+
if (prefix.startsWith("..") || isAbsolute(prefix)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (prefix === "" || prefix === ".") {
|
|
46
|
+
return [...paths];
|
|
47
|
+
}
|
|
48
|
+
const normalized = prefix.split(sep).join("/");
|
|
49
|
+
return paths.filter((path) => path === normalized || path.startsWith(`${normalized}/`));
|
|
50
|
+
}
|
|
37
51
|
export function materializeStaged(relPaths, cwd, env) {
|
|
38
52
|
const dir = mkdtempSync(join(tmpdir(), "dg-staged-"));
|
|
39
53
|
let count = 0;
|
|
@@ -58,16 +72,20 @@ export function runStagedScan(options) {
|
|
|
58
72
|
}
|
|
59
73
|
const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
60
74
|
if (!root) {
|
|
61
|
-
return
|
|
75
|
+
return notARepoResult();
|
|
62
76
|
}
|
|
63
77
|
const lockfiles = stagedLockfilePaths(cwd, env);
|
|
64
78
|
if (lockfiles === null) {
|
|
65
79
|
return failOpen(theme, "could not read staged changes");
|
|
66
80
|
}
|
|
67
|
-
|
|
81
|
+
const scoped = scopeStagedPaths(lockfiles, root, cwd, options.targetPath ?? null);
|
|
82
|
+
if (scoped === null) {
|
|
83
|
+
return outsideRepoResult(options.targetPath ?? "");
|
|
84
|
+
}
|
|
85
|
+
if (scoped.length === 0) {
|
|
68
86
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
69
87
|
}
|
|
70
|
-
const { dir, count } = materializeStaged(
|
|
88
|
+
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
71
89
|
try {
|
|
72
90
|
if (count === 0) {
|
|
73
91
|
return failOpen(theme, "could not read the staged lockfile contents");
|
|
@@ -82,6 +100,50 @@ export function runStagedScan(options) {
|
|
|
82
100
|
rmSync(dir, { recursive: true, force: true });
|
|
83
101
|
}
|
|
84
102
|
}
|
|
103
|
+
export function stagedScanReport(options) {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
const cwd = options.cwd ?? process.cwd();
|
|
106
|
+
const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
107
|
+
if (!root) {
|
|
108
|
+
return { result: notARepoResult() };
|
|
109
|
+
}
|
|
110
|
+
const base = { ...emptyLocalReport(root), status: "pass" };
|
|
111
|
+
const lockfiles = stagedLockfilePaths(cwd, env);
|
|
112
|
+
if (lockfiles === null) {
|
|
113
|
+
return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read staged changes" } } };
|
|
114
|
+
}
|
|
115
|
+
const scoped = scopeStagedPaths(lockfiles, root, cwd, options.targetPath ?? null);
|
|
116
|
+
if (scoped === null) {
|
|
117
|
+
return { result: outsideRepoResult(options.targetPath ?? "") };
|
|
118
|
+
}
|
|
119
|
+
if (scoped.length === 0) {
|
|
120
|
+
return { report: base, outcome: { kind: "skipped", reason: "no_lockfiles" } };
|
|
121
|
+
}
|
|
122
|
+
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
123
|
+
try {
|
|
124
|
+
if (count === 0) {
|
|
125
|
+
return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
|
|
126
|
+
}
|
|
127
|
+
return { report: base, outcome: runScannerScan(dir, base, env) };
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
rmSync(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function safeRealpath(path) {
|
|
134
|
+
try {
|
|
135
|
+
return realpathSync(path);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return path;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function notARepoResult() {
|
|
142
|
+
return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: "dg scan --staged: not a git repository.\n" };
|
|
143
|
+
}
|
|
144
|
+
function outsideRepoResult(targetPath) {
|
|
145
|
+
return { exitCode: EXIT_USAGE_VERDICT, stdout: "", stderr: `dg scan --staged: ${targetPath} is outside this repository.\n` };
|
|
146
|
+
}
|
|
85
147
|
export function decideStagedVerdict(report, env = process.env, hook = false) {
|
|
86
148
|
const theme = createTheme(resolvePresentation().color);
|
|
87
149
|
const action = report.scanner?.action ?? report.status;
|