@westbayberry/dg 1.3.2 → 2.0.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/LICENSE +1 -201
- package/NOTICE +1 -4
- package/README.md +293 -0
- package/dist/api/analyze.js +210 -0
- package/dist/audit/deep.js +180 -0
- package/dist/audit/detectors.js +247 -0
- package/dist/audit/events.js +41 -0
- package/dist/audit/rules.js +426 -0
- package/dist/audit-ui/AuditApp.js +39 -0
- package/dist/audit-ui/components/AuditHeader.js +24 -0
- package/dist/audit-ui/components/AuditResultsView.js +307 -0
- package/dist/audit-ui/components/DeepStatusRow.js +11 -0
- package/dist/audit-ui/export.js +85 -0
- package/dist/audit-ui/format.js +34 -0
- package/dist/audit-ui/launch.js +34 -0
- package/dist/auth/device-login.js +271 -0
- package/dist/auth/env-token.js +6 -0
- package/dist/auth/login-app.js +156 -0
- package/dist/auth/store.js +147 -0
- package/dist/bin/dg.js +71 -0
- package/dist/commands/audit.js +357 -0
- package/dist/commands/completion.js +116 -0
- package/dist/commands/config.js +99 -0
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/explain.js +100 -0
- package/dist/commands/guard-commit.js +158 -0
- package/dist/commands/help.js +74 -0
- package/dist/commands/licenses.js +435 -0
- package/dist/commands/login.js +81 -0
- package/dist/commands/logout.js +37 -0
- package/dist/commands/router.js +98 -0
- package/dist/commands/scan.js +18 -0
- package/dist/commands/service.js +475 -0
- package/dist/commands/setup.js +302 -0
- package/dist/commands/status.js +115 -0
- package/dist/commands/suggest.js +35 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/unavailable.js +11 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/commands/update.js +210 -0
- package/dist/commands/verify.js +151 -0
- package/dist/commands/version.js +22 -0
- package/dist/commands/wrap.js +55 -0
- package/dist/config/settings.js +302 -0
- package/dist/install-ui/LiveInstall.js +24 -0
- package/dist/install-ui/block-render.js +83 -0
- package/dist/install-ui/live-install-app.js +48 -0
- package/dist/install-ui/prompt.js +24 -0
- package/dist/launcher/classify.js +116 -0
- package/dist/launcher/env.js +53 -0
- package/dist/launcher/live-install.js +50 -0
- package/dist/launcher/output-redaction.js +77 -0
- package/dist/launcher/preflight-prompt.js +139 -0
- package/dist/launcher/resolve-real-binary.js +73 -0
- package/dist/launcher/run.js +417 -0
- package/dist/policy/evaluate.js +128 -0
- package/dist/presentation/mode.js +52 -0
- package/dist/presentation/theme.js +29 -0
- package/dist/proxy/buffer-budget.js +64 -0
- package/dist/proxy/ca.js +126 -0
- package/dist/proxy/classify-host.js +26 -0
- package/dist/proxy/enforcement.js +102 -0
- package/dist/proxy/metadata-map.js +336 -0
- package/dist/proxy/server.js +909 -0
- package/dist/proxy/upstream-proxy.js +102 -0
- package/dist/proxy/worker.js +39 -0
- package/dist/publish-set/collect.js +51 -0
- package/dist/publish-set/no-exec-shell.js +19 -0
- package/dist/publish-set/npm.js +109 -0
- package/dist/publish-set/pack.js +36 -0
- package/dist/publish-set/pypi.js +59 -0
- package/dist/runtime/cli.js +17 -0
- package/dist/runtime/first-run.js +60 -0
- package/dist/runtime/node-version.js +58 -0
- package/dist/runtime/nudges.js +105 -0
- package/dist/scan/analyze-worker.js +21 -0
- package/dist/scan/collect.js +153 -0
- package/dist/scan/command.js +159 -0
- package/dist/scan/discovery.js +209 -0
- package/dist/scan/render.js +240 -0
- package/dist/scan/scanner-report.js +82 -0
- package/dist/scan/staged.js +173 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan-ui/LegacyApp.js +156 -0
- package/dist/scan-ui/alt-screen.js +84 -0
- package/dist/scan-ui/api-aliases.js +1 -0
- package/dist/scan-ui/components/ErrorView.js +23 -0
- package/dist/scan-ui/components/InteractiveResultsView.js +1166 -0
- package/dist/scan-ui/components/ProgressBar.js +89 -0
- package/dist/scan-ui/components/ProjectSelector.js +62 -0
- package/dist/scan-ui/components/ScoreHeader.js +20 -0
- package/dist/scan-ui/components/SetupBanner.js +13 -0
- package/dist/scan-ui/components/Spinner.js +4 -0
- package/dist/scan-ui/format-helpers.js +40 -0
- package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
- package/dist/scan-ui/hooks/useScan.js +113 -0
- package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
- package/dist/scan-ui/launch.js +27 -0
- package/dist/scan-ui/logo.js +91 -0
- package/dist/scan-ui/shims.js +30 -0
- package/dist/security/sanitize.js +28 -0
- package/dist/service/state.js +837 -0
- package/dist/service/trust-store.js +234 -0
- package/dist/service/worker.js +88 -0
- package/dist/setup/git-hook.js +244 -0
- package/dist/setup/optional-support.js +58 -0
- package/dist/setup/plan.js +899 -0
- package/dist/state/cleanup-registry.js +60 -0
- package/dist/state/index.js +5 -0
- package/dist/state/locks.js +161 -0
- package/dist/state/paths.js +24 -0
- package/dist/state/sessions.js +170 -0
- package/dist/state/store.js +50 -0
- package/dist/telemetry/events.js +40 -0
- package/dist/util/git.js +20 -0
- package/dist/util/tty-prompt.js +43 -0
- package/dist/verify/local.js +400 -0
- package/dist/verify/package-check.js +240 -0
- package/dist/verify/preflight.js +698 -0
- package/dist/verify/render.js +184 -0
- package/dist/verify/types.js +1 -0
- package/package.json +33 -50
- package/dist/index.mjs +0 -54141
- package/dist/postinstall.mjs +0 -731
- package/dist/python-hook/dg_pip_hook.pth +0 -1
- package/dist/python-hook/dg_pip_hook.py +0 -130
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createTheme } from "../presentation/theme.js";
|
|
2
|
+
const DETAIL_PROJECT_LIMIT = 8;
|
|
3
|
+
const CLEAN_PROJECT_PREVIEW_LIMIT = 5;
|
|
4
|
+
const FINDING_GROUP_PREVIEW_LIMIT = 3;
|
|
5
|
+
const SCAN_STATUS_ROLE = {
|
|
6
|
+
pass: "pass",
|
|
7
|
+
warn: "warn",
|
|
8
|
+
block: "block",
|
|
9
|
+
unknown: "unknown",
|
|
10
|
+
error: "block"
|
|
11
|
+
};
|
|
12
|
+
export function renderTextReport(report, terminalWidth = terminalWidthFromEnv(), theme = createTheme(false), scannerUnavailable = false) {
|
|
13
|
+
const width = Math.max(48, Math.min(terminalWidth, 140));
|
|
14
|
+
const cleanProjects = report.projects.filter((project) => project.findings.length === 0);
|
|
15
|
+
const findingProjects = report.projects.filter((project) => project.findings.length > 0);
|
|
16
|
+
const shouldCollapseProjects = report.projects.length > DETAIL_PROJECT_LIMIT;
|
|
17
|
+
const lines = [
|
|
18
|
+
"Dependency Guardian scan",
|
|
19
|
+
`Target: ${report.target}`,
|
|
20
|
+
`Scanning: checked ${report.summary.projectCount} project manifest${report.summary.projectCount === 1 ? "" : "s"}.`,
|
|
21
|
+
`Status: ${theme.paint(SCAN_STATUS_ROLE[report.status], report.status)}`,
|
|
22
|
+
`Projects: ${report.summary.projectCount}`,
|
|
23
|
+
`Dependencies: ${report.summary.dependencyCount}`,
|
|
24
|
+
`Findings: ${report.summary.findingCount} (${report.summary.warnCount} warn, ${report.summary.blockCount} block)`,
|
|
25
|
+
...(report.scanner
|
|
26
|
+
? [`Scanner: score ${report.scanner.score}, ${report.scanner.packages.length} packages verified`]
|
|
27
|
+
: []),
|
|
28
|
+
""
|
|
29
|
+
];
|
|
30
|
+
if (scannerUnavailable) {
|
|
31
|
+
lines.push("server scan unavailable — local heuristics only (run 'dg doctor' to diagnose)");
|
|
32
|
+
lines.push("");
|
|
33
|
+
}
|
|
34
|
+
if (report.projects.length === 0 && report.errors.length === 0) {
|
|
35
|
+
lines.push("No supported project manifests found.");
|
|
36
|
+
}
|
|
37
|
+
if (findingProjects.length > 0) {
|
|
38
|
+
lines.push("Finding groups:");
|
|
39
|
+
for (const group of groupFindings(report.findings)) {
|
|
40
|
+
const severityLabel = theme.paint(group.severity === "block" ? "block" : "warn", group.severity.toUpperCase());
|
|
41
|
+
lines.push(` ${severityLabel} ${group.id}: ${group.count} finding${group.count === 1 ? "" : "s"} across ${group.projectCount} project${group.projectCount === 1 ? "" : "s"}`);
|
|
42
|
+
for (const finding of group.examples) {
|
|
43
|
+
lines.push(...wrapLine(" example: ", `${finding.project} at ${finding.location}: ${finding.message}`, width));
|
|
44
|
+
}
|
|
45
|
+
if (group.hiddenCount > 0) {
|
|
46
|
+
lines.push(` ${group.hiddenCount} more hidden by default.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
if (shouldCollapseProjects) {
|
|
52
|
+
if (findingProjects.length > 0) {
|
|
53
|
+
lines.push(`Projects with findings: ${findingProjects.length}`);
|
|
54
|
+
for (const project of findingProjects.slice(0, CLEAN_PROJECT_PREVIEW_LIMIT)) {
|
|
55
|
+
const version = project.version ? `@${project.version}` : "";
|
|
56
|
+
lines.push(` ${project.name}${version} (${project.manifestPath}) findings:${project.findings.length}`);
|
|
57
|
+
}
|
|
58
|
+
if (findingProjects.length > CLEAN_PROJECT_PREVIEW_LIMIT) {
|
|
59
|
+
lines.push(` ${findingProjects.length - CLEAN_PROJECT_PREVIEW_LIMIT} more projects with findings hidden by default.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (cleanProjects.length > 0) {
|
|
63
|
+
const preview = cleanProjects.slice(0, CLEAN_PROJECT_PREVIEW_LIMIT).map((project) => project.name).join(", ");
|
|
64
|
+
const suffix = cleanProjects.length > CLEAN_PROJECT_PREVIEW_LIMIT ? `, plus ${cleanProjects.length - CLEAN_PROJECT_PREVIEW_LIMIT} more` : "";
|
|
65
|
+
lines.push(`Clean projects collapsed: ${cleanProjects.length}${preview.length > 0 ? ` (${preview}${suffix})` : ""}.`);
|
|
66
|
+
}
|
|
67
|
+
lines.push(`For full project detail, run: dg scan ${quoteTarget(report.target)} in a terminal (opens the interactive view) or dg scan ${quoteTarget(report.target)} --json`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
for (const project of report.projects) {
|
|
72
|
+
lines.push(...formatProject(project, width));
|
|
73
|
+
lines.push("");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const error of report.errors) {
|
|
77
|
+
lines.push(...wrapLine(`ERROR ${error.location}: `, error.message, width));
|
|
78
|
+
}
|
|
79
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
80
|
+
}
|
|
81
|
+
function formatProject(project, width) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
const version = project.version ? `@${project.version}` : "";
|
|
84
|
+
const license = project.license ?? "unknown";
|
|
85
|
+
lines.push(`${project.name}${version} (${project.manifestPath})`);
|
|
86
|
+
lines.push(` license: ${license}`);
|
|
87
|
+
lines.push(` dependencies: ${project.dependencyCount}`);
|
|
88
|
+
if (project.findings.length === 0) {
|
|
89
|
+
lines.push(" result: pass");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
for (const finding of project.findings) {
|
|
93
|
+
lines.push(...formatFinding(finding, width));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
export function renderJsonReport(report, scannerUnavailable = false) {
|
|
99
|
+
return `${JSON.stringify({ ...report, scannerUnavailable }, null, 2)}\n`;
|
|
100
|
+
}
|
|
101
|
+
export function renderSarifReport(report) {
|
|
102
|
+
const rules = uniqueFindings(report.findings).map((finding) => ({
|
|
103
|
+
id: finding.id,
|
|
104
|
+
name: finding.title,
|
|
105
|
+
shortDescription: {
|
|
106
|
+
text: finding.title
|
|
107
|
+
},
|
|
108
|
+
fullDescription: {
|
|
109
|
+
text: finding.message
|
|
110
|
+
},
|
|
111
|
+
defaultConfiguration: {
|
|
112
|
+
level: finding.severity === "block" ? "error" : "warning"
|
|
113
|
+
}
|
|
114
|
+
}));
|
|
115
|
+
const sarif = {
|
|
116
|
+
version: "2.1.0",
|
|
117
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
118
|
+
runs: [
|
|
119
|
+
{
|
|
120
|
+
tool: {
|
|
121
|
+
driver: {
|
|
122
|
+
name: "Dependency Guardian",
|
|
123
|
+
informationUri: "https://westbayberry.com",
|
|
124
|
+
rules
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
results: report.findings.map((finding) => ({
|
|
128
|
+
ruleId: finding.id,
|
|
129
|
+
level: finding.severity === "block" ? "error" : "warning",
|
|
130
|
+
message: {
|
|
131
|
+
text: finding.message
|
|
132
|
+
},
|
|
133
|
+
locations: [
|
|
134
|
+
{
|
|
135
|
+
physicalLocation: {
|
|
136
|
+
artifactLocation: {
|
|
137
|
+
uri: artifactUri(finding.location)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
};
|
|
146
|
+
return `${JSON.stringify(sarif, null, 2)}\n`;
|
|
147
|
+
}
|
|
148
|
+
function artifactUri(location) {
|
|
149
|
+
return location.replace(/:\d+$/u, "");
|
|
150
|
+
}
|
|
151
|
+
function uniqueFindings(findings) {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const unique = [];
|
|
154
|
+
for (const finding of findings) {
|
|
155
|
+
if (seen.has(finding.id)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
seen.add(finding.id);
|
|
159
|
+
unique.push(finding);
|
|
160
|
+
}
|
|
161
|
+
return unique;
|
|
162
|
+
}
|
|
163
|
+
function formatFinding(finding, width) {
|
|
164
|
+
const prefix = ` ${finding.severity.toUpperCase()} ${finding.id} ${finding.location}: `;
|
|
165
|
+
return wrapLine(prefix, finding.message, width);
|
|
166
|
+
}
|
|
167
|
+
function groupFindings(findings) {
|
|
168
|
+
const groups = new Map();
|
|
169
|
+
for (const finding of findings) {
|
|
170
|
+
const key = `${finding.severity}:${finding.id}`;
|
|
171
|
+
const group = groups.get(key) ?? {
|
|
172
|
+
findings: [],
|
|
173
|
+
projects: new Set()
|
|
174
|
+
};
|
|
175
|
+
group.findings.push(finding);
|
|
176
|
+
group.projects.add(finding.project);
|
|
177
|
+
groups.set(key, group);
|
|
178
|
+
}
|
|
179
|
+
return [...groups.entries()]
|
|
180
|
+
.map(([key, group]) => {
|
|
181
|
+
const [severity, id] = key.split(":");
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
severity,
|
|
185
|
+
count: group.findings.length,
|
|
186
|
+
projectCount: group.projects.size,
|
|
187
|
+
examples: group.findings.slice(0, FINDING_GROUP_PREVIEW_LIMIT),
|
|
188
|
+
hiddenCount: Math.max(0, group.findings.length - FINDING_GROUP_PREVIEW_LIMIT)
|
|
189
|
+
};
|
|
190
|
+
})
|
|
191
|
+
.sort((left, right) => {
|
|
192
|
+
if (left.severity !== right.severity) {
|
|
193
|
+
return left.severity === "block" ? -1 : 1;
|
|
194
|
+
}
|
|
195
|
+
return left.id.localeCompare(right.id);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function quoteTarget(target) {
|
|
199
|
+
if (/^[A-Za-z0-9_./:-]+$/u.test(target)) {
|
|
200
|
+
return target;
|
|
201
|
+
}
|
|
202
|
+
return JSON.stringify(target);
|
|
203
|
+
}
|
|
204
|
+
function wrapLine(prefix, text, width) {
|
|
205
|
+
if (prefix.length > width - 16) {
|
|
206
|
+
return [
|
|
207
|
+
prefix.trimEnd(),
|
|
208
|
+
...wrapLine(" ", text, width)
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
const available = Math.max(16, width - prefix.length);
|
|
212
|
+
const words = text.split(/\s+/);
|
|
213
|
+
const lines = [];
|
|
214
|
+
let current = "";
|
|
215
|
+
for (const word of words) {
|
|
216
|
+
if (current.length === 0) {
|
|
217
|
+
current = word;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (current.length + 1 + word.length > available) {
|
|
221
|
+
lines.push(current);
|
|
222
|
+
current = word;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
current = `${current} ${word}`;
|
|
226
|
+
}
|
|
227
|
+
if (current.length > 0) {
|
|
228
|
+
lines.push(current);
|
|
229
|
+
}
|
|
230
|
+
if (lines.length === 0) {
|
|
231
|
+
return [prefix.trimEnd()];
|
|
232
|
+
}
|
|
233
|
+
const continuation = " ".repeat(Math.min(prefix.length, width - available));
|
|
234
|
+
return lines.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
|
|
235
|
+
}
|
|
236
|
+
function terminalWidthFromEnv() {
|
|
237
|
+
const value = process.env.DG_TEST_TERMINAL_WIDTH ?? String(process.stdout.columns ?? 100);
|
|
238
|
+
const parsed = Number.parseInt(value, 10);
|
|
239
|
+
return Number.isFinite(parsed) ? parsed : 100;
|
|
240
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { collectScanPackages, discoverScanProjects } from "./collect.js";
|
|
6
|
+
const WORKER_TIMEOUT_MS = 180_000;
|
|
7
|
+
export function tryScannerScan(targetPath, localReport, env = process.env) {
|
|
8
|
+
const projects = discoverScanProjects(resolve(targetPath));
|
|
9
|
+
if (projects.length === 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const { byEcosystem } = collectScanPackages(projects);
|
|
13
|
+
const groups = [...byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
|
|
14
|
+
const total = groups.reduce((sum, group) => sum + group.packages.length, 0);
|
|
15
|
+
if (total === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const workerPath = [
|
|
19
|
+
fileURLToPath(new URL("./analyze-worker.js", import.meta.url)),
|
|
20
|
+
fileURLToPath(new URL("../../dist/scan/analyze-worker.js", import.meta.url))
|
|
21
|
+
].find((candidate) => existsSync(candidate));
|
|
22
|
+
if (!workerPath) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const worker = spawnSync(process.execPath, [workerPath, JSON.stringify(groups)], {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
env,
|
|
28
|
+
timeout: WORKER_TIMEOUT_MS
|
|
29
|
+
});
|
|
30
|
+
if (worker.status !== 0 || !worker.stdout) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = JSON.parse(worker.stdout);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return buildScannerReport(localReport, response, total);
|
|
41
|
+
}
|
|
42
|
+
export function buildScannerReport(localReport, response, analyzedCount) {
|
|
43
|
+
const findings = response.packages
|
|
44
|
+
.filter((pkg) => (pkg.action ?? "pass") !== "pass")
|
|
45
|
+
.map((pkg) => scannerFinding(pkg));
|
|
46
|
+
const warnCount = findings.filter((finding) => finding.severity === "warn").length;
|
|
47
|
+
const blockCount = findings.filter((finding) => finding.severity === "block").length;
|
|
48
|
+
return {
|
|
49
|
+
target: localReport.target,
|
|
50
|
+
status: statusFromAction(response.action),
|
|
51
|
+
projects: localReport.projects.map((project) => ({ ...project, findings: [] })),
|
|
52
|
+
findings,
|
|
53
|
+
errors: localReport.errors,
|
|
54
|
+
summary: {
|
|
55
|
+
projectCount: localReport.summary.projectCount,
|
|
56
|
+
dependencyCount: analyzedCount,
|
|
57
|
+
findingCount: findings.length,
|
|
58
|
+
warnCount,
|
|
59
|
+
blockCount,
|
|
60
|
+
errorCount: localReport.summary.errorCount
|
|
61
|
+
},
|
|
62
|
+
scanner: response
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function scannerFinding(pkg) {
|
|
66
|
+
const action = pkg.action ?? "pass";
|
|
67
|
+
const top = pkg.findings[0];
|
|
68
|
+
return {
|
|
69
|
+
id: top?.category ?? top?.id ?? "scanner-finding",
|
|
70
|
+
severity: action === "block" ? "block" : "warn",
|
|
71
|
+
title: top?.title ?? pkg.reasons[0] ?? `scanner ${action} verdict`,
|
|
72
|
+
message: pkg.reasons.join("; ") || top?.title || `scanner returned ${action} (score ${pkg.score})`,
|
|
73
|
+
project: "",
|
|
74
|
+
location: `${pkg.name}@${pkg.version}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function statusFromAction(action) {
|
|
78
|
+
if (action === "analysis_incomplete") {
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
return action;
|
|
82
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { createTheme } from "../presentation/theme.js";
|
|
5
|
+
import { resolvePresentation } from "../presentation/mode.js";
|
|
6
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
7
|
+
import { gitSync, gitTrimmed } from "../util/git.js";
|
|
8
|
+
import { promptYesNo } from "../util/tty-prompt.js";
|
|
9
|
+
import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
|
|
10
|
+
import { isLockfileName } from "./collect.js";
|
|
11
|
+
import { tryScannerScan } from "./scanner-report.js";
|
|
12
|
+
import { EXIT_USAGE } from "../commands/types.js";
|
|
13
|
+
function emptyLocalReport(target) {
|
|
14
|
+
return {
|
|
15
|
+
target,
|
|
16
|
+
status: "unknown",
|
|
17
|
+
projects: [],
|
|
18
|
+
findings: [],
|
|
19
|
+
errors: [],
|
|
20
|
+
summary: {
|
|
21
|
+
projectCount: 0,
|
|
22
|
+
dependencyCount: 0,
|
|
23
|
+
findingCount: 0,
|
|
24
|
+
warnCount: 0,
|
|
25
|
+
blockCount: 0,
|
|
26
|
+
errorCount: 0
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function stagedLockfilePaths(cwd, env) {
|
|
31
|
+
const diff = gitSync(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, env });
|
|
32
|
+
if (!diff.ok) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return diff.stdout.split("\0").filter(Boolean).filter((path) => isLockfileName(basename(path)));
|
|
36
|
+
}
|
|
37
|
+
export function materializeStaged(relPaths, cwd, env) {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "dg-staged-"));
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const relative of relPaths) {
|
|
41
|
+
const blob = gitSync(["show", `:${relative}`], { cwd, env });
|
|
42
|
+
if (!blob.ok) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const destination = join(dir, relative);
|
|
46
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
47
|
+
writeFileSync(destination, blob.stdout, "utf8");
|
|
48
|
+
count += 1;
|
|
49
|
+
}
|
|
50
|
+
return { dir, count };
|
|
51
|
+
}
|
|
52
|
+
export function runStagedScan(options) {
|
|
53
|
+
const env = options.env ?? process.env;
|
|
54
|
+
const cwd = options.cwd ?? process.cwd();
|
|
55
|
+
const theme = createTheme(resolvePresentation().color);
|
|
56
|
+
if (env[GUARD_SELFTEST_ENV] === "1") {
|
|
57
|
+
return { exitCode: 2, stdout: "", stderr: "dg guard-commit self-test: synthetic block (exit 2)\n" };
|
|
58
|
+
}
|
|
59
|
+
const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
60
|
+
if (!root) {
|
|
61
|
+
return { exitCode: EXIT_USAGE, stdout: "", stderr: "dg scan --staged: not a git repository.\n" };
|
|
62
|
+
}
|
|
63
|
+
const lockfiles = stagedLockfilePaths(cwd, env);
|
|
64
|
+
if (lockfiles === null) {
|
|
65
|
+
return failOpen(theme, "could not read staged changes");
|
|
66
|
+
}
|
|
67
|
+
if (lockfiles.length === 0) {
|
|
68
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
69
|
+
}
|
|
70
|
+
const { dir, count } = materializeStaged(lockfiles, cwd, env);
|
|
71
|
+
try {
|
|
72
|
+
if (count === 0) {
|
|
73
|
+
return failOpen(theme, "could not read the staged lockfile contents");
|
|
74
|
+
}
|
|
75
|
+
const report = tryScannerScan(dir, emptyLocalReport(dir), env);
|
|
76
|
+
if (!report || !report.scanner) {
|
|
77
|
+
return failOpen(theme, "could not reach the scanner");
|
|
78
|
+
}
|
|
79
|
+
return decideStagedVerdict(report, env, options.hook);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function decideStagedVerdict(report, env = process.env, hook = false) {
|
|
86
|
+
const theme = createTheme(resolvePresentation().color);
|
|
87
|
+
const action = report.scanner?.action ?? report.status;
|
|
88
|
+
const config = loadUserConfig(env);
|
|
89
|
+
const count = report.summary.dependencyCount;
|
|
90
|
+
if (action === "block") {
|
|
91
|
+
return { exitCode: 2, stdout: "", stderr: renderBlock(report.findings, theme) };
|
|
92
|
+
}
|
|
93
|
+
if (action === "error" || action === "unknown") {
|
|
94
|
+
return scannerUnavailable(theme);
|
|
95
|
+
}
|
|
96
|
+
if (action === "warn") {
|
|
97
|
+
return decideWarn(report.findings, theme, config.gitHook.onWarn, config.policy.mode, hook);
|
|
98
|
+
}
|
|
99
|
+
if (action === "analysis_incomplete") {
|
|
100
|
+
if (config.gitHook.onIncomplete === "block") {
|
|
101
|
+
return { exitCode: 1, stdout: "", stderr: incompleteNotice(theme, true) };
|
|
102
|
+
}
|
|
103
|
+
return { exitCode: 0, stdout: "", stderr: incompleteNotice(theme, false) };
|
|
104
|
+
}
|
|
105
|
+
return { exitCode: 0, stdout: "", stderr: ` ${theme.paint("pass", "✓")} ${theme.paint("muted", `DG verified ${count} staged package${count === 1 ? "" : "s"} — clean`)}\n` };
|
|
106
|
+
}
|
|
107
|
+
function decideWarn(findings, theme, onWarn, policyMode, hook) {
|
|
108
|
+
const summary = warnSummary(findings, theme);
|
|
109
|
+
if (onWarn === "allow") {
|
|
110
|
+
return { exitCode: 0, stdout: "", stderr: `${summary} ${theme.paint("muted", "proceeding (gitHook.onWarn=allow)")}\n` };
|
|
111
|
+
}
|
|
112
|
+
if (onWarn === "block") {
|
|
113
|
+
return { exitCode: 1, stdout: "", stderr: `${summary} ${theme.paint("muted", "commit blocked (gitHook.onWarn=block). Use")} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n` };
|
|
114
|
+
}
|
|
115
|
+
const answer = hook ? null : promptYesNo(`${summary} ${theme.paint("accent", "Commit anyway?")}`, false);
|
|
116
|
+
if (answer === null) {
|
|
117
|
+
const proceed = policyMode === "warn" || policyMode === "off";
|
|
118
|
+
if (proceed) {
|
|
119
|
+
return { exitCode: 0, stdout: "", stderr: `${summary} ${theme.paint("muted", `proceeding (no terminal; policy ${policyMode})`)}\n` };
|
|
120
|
+
}
|
|
121
|
+
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` };
|
|
122
|
+
}
|
|
123
|
+
if (answer) {
|
|
124
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
125
|
+
}
|
|
126
|
+
return { exitCode: 1, stdout: "", stderr: ` ${theme.paint("muted", "Nothing was committed.")}\n` };
|
|
127
|
+
}
|
|
128
|
+
function warnSummary(findings, theme) {
|
|
129
|
+
const warns = findings.filter((finding) => finding.severity === "warn");
|
|
130
|
+
const lines = [` ${theme.paint("warn", "⚠")} ${theme.paint("warn", `DG flagged ${warns.length} staged package${warns.length === 1 ? "" : "s"}`)}`];
|
|
131
|
+
for (const finding of warns.slice(0, 5)) {
|
|
132
|
+
lines.push(` ${theme.paint("warn", "⚠")} ${finding.location} ${theme.paint("muted", finding.message)}`);
|
|
133
|
+
}
|
|
134
|
+
if (warns.length > 5) {
|
|
135
|
+
lines.push(` ${theme.paint("muted", `…and ${warns.length - 5} more`)}`);
|
|
136
|
+
}
|
|
137
|
+
return `${lines.join("\n")}\n`;
|
|
138
|
+
}
|
|
139
|
+
function renderBlock(findings, theme) {
|
|
140
|
+
const blocks = findings.filter((finding) => finding.severity === "block");
|
|
141
|
+
const lines = [
|
|
142
|
+
"",
|
|
143
|
+
` ${theme.paint("block", "✘ DG blocked this commit")} ${theme.paint("muted", "— a staged dependency is unsafe")}`
|
|
144
|
+
];
|
|
145
|
+
for (const finding of blocks) {
|
|
146
|
+
lines.push(` ${theme.paint("block", "✘")} ${finding.location} ${theme.paint("muted", finding.message)}`);
|
|
147
|
+
}
|
|
148
|
+
lines.push(` ${theme.paint("muted", "Details:")} ${theme.paint("accent", `dg verify ${blocks[0]?.location ?? "<package>"}`)}`);
|
|
149
|
+
lines.push(` ${theme.paint("muted", "Override:")} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "(installs nothing — only skips the check)")}`);
|
|
150
|
+
lines.push("");
|
|
151
|
+
return `${lines.join("\n")}\n`;
|
|
152
|
+
}
|
|
153
|
+
function incompleteNotice(theme, blocked) {
|
|
154
|
+
const head = ` ${theme.paint("unknown", "?")} ${theme.paint("muted", "DG could not fully analyze the staged change")}`;
|
|
155
|
+
if (blocked) {
|
|
156
|
+
return `${head}\n ${theme.paint("muted", "commit blocked (gitHook.onIncomplete=block). Use")} ${theme.paint("accent", "git commit --no-verify")} ${theme.paint("muted", "to override.")}\n`;
|
|
157
|
+
}
|
|
158
|
+
return `${head} ${theme.paint("muted", "— proceeding")}\n`;
|
|
159
|
+
}
|
|
160
|
+
function failOpen(theme, reason) {
|
|
161
|
+
return {
|
|
162
|
+
exitCode: 0,
|
|
163
|
+
stdout: "",
|
|
164
|
+
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify (${reason}) — commit allowed`)}\n`
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function scannerUnavailable(theme) {
|
|
168
|
+
return {
|
|
169
|
+
exitCode: 0,
|
|
170
|
+
stdout: "",
|
|
171
|
+
stderr: ` ${theme.paint("unknown", "?")} ${theme.paint("muted", "dg: scanner unavailable — staged changes not verified")}\n`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useLayoutEffect, useCallback, useRef } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { getStoredApiKey } from "./shims.js";
|
|
5
|
+
import { useScan } from "./hooks/useScan.js";
|
|
6
|
+
import { Spinner } from "./components/Spinner.js";
|
|
7
|
+
import { ProgressBar } from "./components/ProgressBar.js";
|
|
8
|
+
import { InteractiveResultsView } from "./components/InteractiveResultsView.js";
|
|
9
|
+
import { ErrorView } from "./components/ErrorView.js";
|
|
10
|
+
import { ProjectSelector } from "./components/ProjectSelector.js";
|
|
11
|
+
import { SetupBanner } from "./components/SetupBanner.js";
|
|
12
|
+
import { useTerminalSize } from "./hooks/useTerminalSize.js";
|
|
13
|
+
import { scanExitCode } from "./shims.js";
|
|
14
|
+
import { enterTui, leaveTui, showCursor } from "./alt-screen.js";
|
|
15
|
+
export const App = ({ config, userStatus, scanUsage, setupIssues = [], initialView }) => {
|
|
16
|
+
const { state, scanSelectedProjects, restartSelection } = useScan(config);
|
|
17
|
+
const { exit } = useApp();
|
|
18
|
+
useTerminalSize();
|
|
19
|
+
const prevPhaseRef = useRef(state.phase);
|
|
20
|
+
const altScreenActiveRef = useRef(false);
|
|
21
|
+
// Enter the alternate screen ONLY after we leave the discovering phase.
|
|
22
|
+
// The spinner stays inline on the user's main terminal during discovery so
|
|
23
|
+
// they see "Searching for dependencies..." in their normal scrollback rather
|
|
24
|
+
// than a cleared screen. useLayoutEffect runs synchronously after React
|
|
25
|
+
// commits the new tree but before Ink writes the next frame to stdout, so
|
|
26
|
+
// post-discovery content lands directly in the alt buffer (avoiding the
|
|
27
|
+
// "blank until keypress" diff-tracker mismatch documented in alt-screen.ts).
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (state.phase === "discovering")
|
|
30
|
+
return;
|
|
31
|
+
if (!process.stdout.isTTY)
|
|
32
|
+
return;
|
|
33
|
+
if (altScreenActiveRef.current)
|
|
34
|
+
return;
|
|
35
|
+
enterTui();
|
|
36
|
+
altScreenActiveRef.current = true;
|
|
37
|
+
}, [state.phase]);
|
|
38
|
+
// Cleanup on unmount: leave alt screen, restore cursor.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
return () => {
|
|
41
|
+
if (altScreenActiveRef.current) {
|
|
42
|
+
leaveTui();
|
|
43
|
+
altScreenActiveRef.current = false;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
showCursor();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
// Track phase transitions (Ink handles repainting automatically)
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
prevPhaseRef.current = state.phase;
|
|
53
|
+
}, [state.phase]);
|
|
54
|
+
const leaveAltScreen = useCallback(() => {
|
|
55
|
+
if (altScreenActiveRef.current) {
|
|
56
|
+
leaveTui();
|
|
57
|
+
altScreenActiveRef.current = false;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
showCursor();
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
const handleResultsExit = useCallback(() => {
|
|
64
|
+
if (state.phase === "results") {
|
|
65
|
+
process.exitCode = scanExitCode(state.result.action, config.mode);
|
|
66
|
+
}
|
|
67
|
+
leaveAltScreen();
|
|
68
|
+
exit();
|
|
69
|
+
}, [state, config, exit, leaveAltScreen]);
|
|
70
|
+
// Exit alt screen BEFORE writing messages so they appear on the main screen.
|
|
71
|
+
// `delayMs` keeps the alt-screen panel visible for that long before tearing
|
|
72
|
+
// down (default 0 — fire-and-exit). Used by the free_cap_reached path to
|
|
73
|
+
// give the free_cap_reached path time to read the "sign in to unlock" panel.
|
|
74
|
+
const exitWithMessage = useCallback((message, exitCode, delayMs = 0) => {
|
|
75
|
+
process.exitCode = exitCode;
|
|
76
|
+
if (delayMs === 0) {
|
|
77
|
+
leaveAltScreen();
|
|
78
|
+
process.stderr.write(message);
|
|
79
|
+
return setTimeout(() => exit(), 0);
|
|
80
|
+
}
|
|
81
|
+
return setTimeout(() => {
|
|
82
|
+
leaveAltScreen();
|
|
83
|
+
process.stderr.write(message);
|
|
84
|
+
exit();
|
|
85
|
+
}, delayMs);
|
|
86
|
+
}, [exit, leaveAltScreen]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (state.phase === "empty") {
|
|
89
|
+
const timer = exitWithMessage(`${state.message}\n`, 0);
|
|
90
|
+
return () => clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
if (state.phase === "error") {
|
|
93
|
+
const timer = exitWithMessage(`Error: ${state.error.message}\n`, 3);
|
|
94
|
+
return () => clearTimeout(timer);
|
|
95
|
+
}
|
|
96
|
+
// Reflect the verdict in the process exit code as soon as results render,
|
|
97
|
+
// not only when the user dismisses the view — a piped/killed/non-TTY exit
|
|
98
|
+
// must still carry the right code (an unverified scan is never a silent 0).
|
|
99
|
+
if (state.phase === "results") {
|
|
100
|
+
process.exitCode = scanExitCode(state.result.action, config.mode);
|
|
101
|
+
}
|
|
102
|
+
}, [state, config, exitWithMessage]);
|
|
103
|
+
useInput((input, key) => {
|
|
104
|
+
if (state.phase === "discovering" || state.phase === "scanning") {
|
|
105
|
+
if (input === "q" || key.escape) {
|
|
106
|
+
process.exitCode = 0;
|
|
107
|
+
leaveAltScreen();
|
|
108
|
+
exit();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (state.phase === "free_cap_reached") {
|
|
112
|
+
if (input === "q" || key.escape || key.return) {
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
leaveAltScreen();
|
|
115
|
+
exit();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const content = (() => {
|
|
120
|
+
switch (state.phase) {
|
|
121
|
+
case "discovering": {
|
|
122
|
+
if (!state.found || !state.path) {
|
|
123
|
+
return _jsx(Spinner, { label: "Scanning for projects\u2026" });
|
|
124
|
+
}
|
|
125
|
+
const path = state.path.length > 52 ? `…${state.path.slice(-51)}` : state.path;
|
|
126
|
+
return _jsx(Spinner, { label: `Found ${state.found} · ${path}` });
|
|
127
|
+
}
|
|
128
|
+
case "selecting":
|
|
129
|
+
return (_jsx(ProjectSelector, { projects: state.projects, onConfirm: scanSelectedProjects, onCancel: () => { process.exitCode = 0; leaveAltScreen(); exit(); }, userStatus: userStatus }));
|
|
130
|
+
case "scanning":
|
|
131
|
+
return (_jsx(ProgressBar, { value: state.done, total: state.total, label: state.currentBatch.length > 0
|
|
132
|
+
? state.currentBatch[state.currentBatch.length - 1]
|
|
133
|
+
: undefined }));
|
|
134
|
+
case "results":
|
|
135
|
+
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 }));
|
|
136
|
+
case "empty":
|
|
137
|
+
return _jsx(Text, { dimColor: true, children: state.message });
|
|
138
|
+
case "error":
|
|
139
|
+
return _jsx(ErrorView, { error: state.error });
|
|
140
|
+
case "free_cap_reached": {
|
|
141
|
+
let hasKey = false;
|
|
142
|
+
try {
|
|
143
|
+
hasKey = !!getStoredApiKey();
|
|
144
|
+
}
|
|
145
|
+
catch { /* ignore — best-effort */ }
|
|
146
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [hasKey ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", bold: true, children: "Your session expired." }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: "cyan", bold: true, children: "dg logout" }), " then ", _jsx(Text, { color: "cyan", bold: true, children: "dg login" }), " to re-authenticate."] })] })) : state.capReason === "prefix_cap" ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", bold: true, children: "Too many anonymous devices from your network this month." }), _jsxs(Text, { children: ["Sign in with ", _jsx(Text, { color: "cyan", bold: true, children: "dg login" }), " to keep scanning."] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Free monthly limit reached (", state.scansUsed.toLocaleString(), "/", state.maxScans.toLocaleString(), " packages)."] }), _jsxs(Text, { children: ["Upgrade to Pro with ", _jsx(Text, { color: "cyan", bold: true, children: "dg upgrade" }), " for 250k packages/month."] })] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "[q] quit" })] }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
// Show the setup banner above the results — but not during empty/error/
|
|
151
|
+
// free_cap_reached phases where it would compete with the message the user
|
|
152
|
+
// actually needs to read.
|
|
153
|
+
const showBanner = state.phase === "selecting" &&
|
|
154
|
+
setupIssues.length > 0;
|
|
155
|
+
return (_jsxs(Box, { flexDirection: "column", children: [showBanner ? _jsx(SetupBanner, { issues: setupIssues }) : null, content] }));
|
|
156
|
+
};
|