@westbayberry/dg 2.3.3 → 2.3.4
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 +94 -217
- package/dist/agents/copilot-cli.js +7 -2
- package/dist/agents/persistence.js +83 -8
- package/dist/agents/registry.js +5 -2
- package/dist/agents/routing.js +31 -14
- package/dist/api/analyze.js +138 -41
- package/dist/audit/deep.js +2 -1
- package/dist/audit/detectors.js +23 -2
- package/dist/audit/rules.js +4 -1
- package/dist/audit-ui/export.js +5 -4
- package/dist/auth/device-login.js +33 -11
- package/dist/auth/login-app.js +17 -12
- package/dist/auth/store.js +65 -9
- package/dist/bin/dg.js +1 -1
- package/dist/commands/audit.js +12 -20
- package/dist/commands/{guard-commit.js → commit-guard.js} +12 -12
- package/dist/commands/cooldown.js +2 -1
- package/dist/commands/decisions.js +8 -7
- package/dist/commands/help.js +11 -3
- package/dist/commands/licenses.js +17 -8
- package/dist/commands/login.js +58 -21
- package/dist/commands/router.js +12 -2
- package/dist/commands/sbom.js +20 -5
- package/dist/commands/scan.js +5 -4
- package/dist/commands/service.js +1 -1
- package/dist/commands/setup.js +12 -12
- package/dist/commands/status.js +4 -4
- package/dist/commands/uninstall.js +1 -1
- package/dist/commands/update.js +20 -0
- package/dist/commands/verify.js +4 -4
- package/dist/config/settings.js +81 -12
- package/dist/export-ui/ExportDialog.js +1 -1
- package/dist/gate/cooldown-request.js +22 -0
- package/dist/gate/egress-guard.js +185 -0
- package/dist/gate/host-match.js +48 -0
- package/dist/gate/verdict-core.js +101 -0
- package/dist/gate/verdict-fetch.js +214 -0
- package/dist/launcher/agent-check.js +1096 -42
- package/dist/launcher/agent-hook-exec.js +8 -1
- package/dist/launcher/agent-hook-io.js +5 -3
- package/dist/launcher/classify.js +110 -26
- package/dist/launcher/env.js +84 -18
- package/dist/launcher/install-preflight.js +26 -3
- package/dist/launcher/live-install.js +4 -2
- package/dist/launcher/manifest-screen.js +88 -7
- package/dist/launcher/output-redaction.js +153 -15
- package/dist/launcher/preflight-prompt.js +1 -1
- package/dist/launcher/resolve-real-binary.js +8 -3
- package/dist/launcher/run.js +237 -40
- package/dist/origin/artifact-cache.js +55 -0
- package/dist/origin/cargo-config.js +12 -0
- package/dist/origin/gate-mode.js +7 -0
- package/dist/origin/platform-paths.js +14 -0
- package/dist/origin/precedence.js +345 -0
- package/dist/origin/server.js +611 -0
- package/dist/origin/spike-worker.js +35 -0
- package/dist/origin/worker.js +116 -0
- package/dist/presentation/package-page.js +21 -1
- package/dist/presentation/safe-version.js +11 -0
- package/dist/presentation/sarif.js +109 -0
- package/dist/project/dgfile.js +54 -17
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/enforcement.js +27 -7
- package/dist/proxy/metadata-map.js +33 -7
- package/dist/proxy/preverified.js +0 -3
- package/dist/proxy/server.js +104 -446
- package/dist/proxy/upstream-proxy.js +23 -4
- package/dist/proxy/worker.js +3 -2
- package/dist/publish-set/collect.js +6 -0
- package/dist/runtime/fatal.js +2 -1
- package/dist/runtime/first-run.js +5 -1
- package/dist/runtime/node-version.js +51 -0
- package/dist/runtime/nudges.js +27 -1
- package/dist/sbom/cyclonedx.js +120 -33
- package/dist/sbom/graph.js +236 -0
- package/dist/sbom/spdx.js +53 -0
- package/dist/sbom-ui/SbomApp.js +5 -3
- package/dist/sbom-ui/inventory.js +8 -10
- package/dist/scan/analyze-worker.js +3 -1
- package/dist/scan/collect.js +38 -8
- package/dist/scan/command.js +77 -27
- package/dist/scan/discovery.js +18 -7
- package/dist/scan/installed.js +202 -0
- package/dist/scan/manifest-coverage.js +128 -0
- package/dist/scan/render.js +125 -40
- package/dist/scan/scanner-report.js +92 -22
- package/dist/scan/staged.js +68 -15
- package/dist/scan/types.js +7 -1
- package/dist/scan-ui/LegacyApp.js +3 -3
- package/dist/scan-ui/components/InteractiveResultsView.js +17 -14
- package/dist/scan-ui/hooks/useScan.js +37 -11
- package/dist/scan-ui/launch.js +2 -2
- package/dist/scan-ui/logo.js +6 -2
- package/dist/scan-ui/shims.js +6 -6
- package/dist/scripts/gate.js +53 -35
- package/dist/security/csv.js +9 -0
- package/dist/security/sanitize.js +11 -2
- package/dist/service/state.js +120 -37
- package/dist/service/trust-refresh.js +12 -1
- package/dist/service/trust-store.js +36 -5
- package/dist/service/worker.js +5 -7
- package/dist/setup/git-hook.js +2 -2
- package/dist/setup/install-method.js +36 -0
- package/dist/setup/plan.js +119 -57
- package/dist/setup/uninstall-standalone.js +7 -2
- package/dist/setup-ui/gate.js +1 -8
- package/dist/setup-ui/offer.js +14 -7
- package/dist/setup-ui/selector.js +1 -1
- package/dist/setup-ui/wizard.js +73 -78
- package/dist/standalone/uninstall.mjs +1114 -386
- package/dist/state/cleanup-registry.js +14 -3
- package/dist/state/cooldown-held.js +36 -13
- package/dist/state/locks.js +239 -34
- package/dist/state/sessions.js +2 -2
- package/dist/util/sh-escape.js +6 -0
- package/dist/util/tty-prompt.js +13 -4
- package/dist/verify/local.js +60 -10
- package/dist/verify/package-check.js +35 -49
- package/dist/verify/preflight.js +59 -33
- package/dist/verify/render.js +11 -12
- package/npm-shrinkwrap.json +714 -213
- package/package.json +3 -2
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, 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
6
|
import { applyDecisions, packageKey } from "../decisions/apply.js";
|
|
7
7
|
import { sanitize } from "../security/sanitize.js";
|
|
8
|
-
import { collectScanPackages, discoverScanProjects } from "./collect.js";
|
|
8
|
+
import { collectScanPackages, discoverScanProjects, mergeInstalledPackages } from "./collect.js";
|
|
9
|
+
import { discoverInstalledPackages } from "./installed.js";
|
|
10
|
+
import { applyLocalFindingEscalation } from "./render.js";
|
|
11
|
+
import { ESCALATING_SKIP_REASONS } from "./types.js";
|
|
9
12
|
const WORKER_TIMEOUT_BASE_MS = 180_000;
|
|
10
13
|
const SERVER_PER_PACKAGE_WORST_CASE_MS = 660_000;
|
|
11
14
|
const SERVER_SCAN_CONCURRENCY = 64;
|
|
@@ -13,16 +16,39 @@ const WORKER_MAX_BUFFER = 64 * 1024 * 1024;
|
|
|
13
16
|
export function scanWorkerTimeoutMs(packageCount) {
|
|
14
17
|
return WORKER_TIMEOUT_BASE_MS + packageCount * Math.ceil(SERVER_PER_PACKAGE_WORST_CASE_MS / SERVER_SCAN_CONCURRENCY);
|
|
15
18
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// Stable identity of the resolved dependency tree — sorted `eco:name@version`
|
|
20
|
+
// keys hashed. `dg scan --install-origin` sends it so the server bills the tree
|
|
21
|
+
// once per user per month rather than on every reinstall.
|
|
22
|
+
function computeTreeHash(groups) {
|
|
23
|
+
const keys = [];
|
|
24
|
+
for (const group of groups) {
|
|
25
|
+
for (const pkg of group.packages) {
|
|
26
|
+
keys.push(`${group.ecosystem}:${pkg.name}@${pkg.version}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
keys.sort();
|
|
30
|
+
return createHash("sha256").update(keys.join("\n")).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
export function runScannerScan(targetPath, localReport, env = process.env, decisionsFile = null, installOrigin = false) {
|
|
33
|
+
const root = resolve(targetPath);
|
|
34
|
+
const projects = discoverScanProjects(root);
|
|
35
|
+
const installed = discoverInstalledPackages(root, env);
|
|
36
|
+
const installedTotal = [...installed.byEcosystem.values()].reduce((sum, list) => sum + list.length, 0);
|
|
37
|
+
if (projects.length === 0 && installedTotal === 0) {
|
|
19
38
|
return { kind: "skipped", reason: "no_lockfiles" };
|
|
20
39
|
}
|
|
21
|
-
const collected = collectScanPackages(projects);
|
|
40
|
+
const collected = mergeInstalledPackages(collectScanPackages(projects), installed);
|
|
22
41
|
const groups = [...collected.byEcosystem.entries()].map(([ecosystem, packages]) => ({ ecosystem, packages }));
|
|
23
42
|
const total = groups.reduce((sum, group) => sum + group.packages.length, 0);
|
|
24
43
|
if (total === 0) {
|
|
25
|
-
|
|
44
|
+
// A lockfile that resolves to only workspace/local links (or a package from an
|
|
45
|
+
// ecosystem the analyzer does not cover, or a bare index directive) is a clean
|
|
46
|
+
// no-op, not a parse failure. Everything else that dropped a package —
|
|
47
|
+
// git/direct-url specs, unpinned or off-registry entries, real parse errors —
|
|
48
|
+
// still fails closed so an unverifiable artifact can't slip through as a
|
|
49
|
+
// benign skip.
|
|
50
|
+
const benignSkips = collected.skippedPackages.filter((pkg) => isBenignSkip(pkg.reason)).length;
|
|
51
|
+
if (collected.skipped > benignSkips) {
|
|
26
52
|
return { kind: "failed", error: lockfileParseError(projects, collected) };
|
|
27
53
|
}
|
|
28
54
|
return { kind: "skipped", reason: "no_packages" };
|
|
@@ -37,11 +63,12 @@ export function runScannerScan(targetPath, localReport, env = process.env, decis
|
|
|
37
63
|
error: { kind: "worker", message: "scanner worker is missing — reinstall @westbayberry/dg" }
|
|
38
64
|
};
|
|
39
65
|
}
|
|
66
|
+
const installTreeHash = installOrigin ? computeTreeHash(groups) : null;
|
|
40
67
|
const timeoutMs = scanWorkerTimeoutMs(total);
|
|
41
68
|
const worker = spawnSync(process.execPath, [workerPath], {
|
|
42
69
|
encoding: "utf8",
|
|
43
70
|
env,
|
|
44
|
-
input: JSON.stringify({ scanId: randomUUID(), groups }),
|
|
71
|
+
input: JSON.stringify({ scanId: randomUUID(), groups, ...(installTreeHash ? { installTreeHash } : {}) }),
|
|
45
72
|
maxBuffer: WORKER_MAX_BUFFER,
|
|
46
73
|
timeout: timeoutMs
|
|
47
74
|
});
|
|
@@ -65,12 +92,51 @@ export function runScannerScan(targetPath, localReport, env = process.env, decis
|
|
|
65
92
|
ecosystems.set(packageKey(pkg.name, pkg.version), group.ecosystem);
|
|
66
93
|
}
|
|
67
94
|
}
|
|
68
|
-
const report = buildScannerReport(localReport, response, total, ecosystems);
|
|
95
|
+
const report = withUnscanned(buildScannerReport(localReport, response, total, ecosystems, collected.origins), collected.skippedPackages);
|
|
69
96
|
if (!decisionsFile?.readable) {
|
|
70
97
|
return { kind: "report", report };
|
|
71
98
|
}
|
|
72
99
|
return { kind: "report", report: annotateReportWithDecisions(report, decisionsFile, ecosystems) };
|
|
73
100
|
}
|
|
101
|
+
function isBenignSkip(reason) {
|
|
102
|
+
return reason === "workspace" || reason === "local";
|
|
103
|
+
}
|
|
104
|
+
export function summarizeUnscanned(skippedPackages) {
|
|
105
|
+
if (!skippedPackages || skippedPackages.length === 0) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const surfaced = skippedPackages.filter((pkg) => !isBenignSkip(pkg.reason));
|
|
109
|
+
if (surfaced.length === 0) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const byReason = {};
|
|
113
|
+
let escalating = 0;
|
|
114
|
+
for (const pkg of surfaced) {
|
|
115
|
+
byReason[pkg.reason] = (byReason[pkg.reason] ?? 0) + 1;
|
|
116
|
+
if (ESCALATING_SKIP_REASONS.has(pkg.reason)) {
|
|
117
|
+
escalating += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { total: surfaced.length, escalating, byReason, sample: surfaced.slice(0, 20) };
|
|
121
|
+
}
|
|
122
|
+
// An escalating skip means a package that should have been verified was dropped,
|
|
123
|
+
// so a passing scan is downgraded to analysis_incomplete rather than reading clean.
|
|
124
|
+
export function withUnscanned(report, skippedPackages) {
|
|
125
|
+
const unscanned = summarizeUnscanned(skippedPackages);
|
|
126
|
+
if (!unscanned) {
|
|
127
|
+
return report;
|
|
128
|
+
}
|
|
129
|
+
const hasLocalBlock = report.findings.some((finding) => finding.severity === "block" && !finding.acknowledged);
|
|
130
|
+
if (unscanned.escalating > 0 && report.scanner && report.scanner.action !== "block" && !hasLocalBlock) {
|
|
131
|
+
return {
|
|
132
|
+
...report,
|
|
133
|
+
status: "unknown",
|
|
134
|
+
scanner: { ...report.scanner, action: "analysis_incomplete" },
|
|
135
|
+
unscanned
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ...report, unscanned };
|
|
139
|
+
}
|
|
74
140
|
export function tryScannerScan(targetPath, localReport, env = process.env, decisionsFile = null) {
|
|
75
141
|
const outcome = runScannerScan(targetPath, localReport, env, decisionsFile);
|
|
76
142
|
return outcome.kind === "report" ? outcome.report : null;
|
|
@@ -143,22 +209,24 @@ function lockfileParseError(projects, collected) {
|
|
|
143
209
|
: `found ${projects.length} lockfile${projects.length === 1 ? "" : "s"} (${lockfiles}) but no packages could be parsed`
|
|
144
210
|
};
|
|
145
211
|
}
|
|
146
|
-
export function buildScannerReport(localReport, response, analyzedCount, ecosystems) {
|
|
147
|
-
const packages =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
: response.packages;
|
|
153
|
-
const findings = packages
|
|
212
|
+
export function buildScannerReport(localReport, response, analyzedCount, ecosystems, origins) {
|
|
213
|
+
const packages = response.packages.map((pkg) => {
|
|
214
|
+
const ecosystem = pkg.ecosystem ?? ecosystems?.get(packageKey(pkg.name, pkg.version));
|
|
215
|
+
return ecosystem ? { ...pkg, ecosystem } : pkg;
|
|
216
|
+
});
|
|
217
|
+
const scannerFindings = packages
|
|
154
218
|
.filter((pkg) => (pkg.action ?? "pass") !== "pass")
|
|
155
|
-
.map((pkg) => scannerFinding(pkg));
|
|
219
|
+
.map((pkg) => scannerFinding(pkg, origins));
|
|
220
|
+
const scannerLocations = new Set(scannerFindings.map((finding) => finding.location));
|
|
221
|
+
const localFindings = localReport.findings.filter((finding) => !scannerLocations.has(finding.location));
|
|
222
|
+
const findings = [...localFindings, ...scannerFindings];
|
|
156
223
|
const warnCount = findings.filter((finding) => finding.severity === "warn").length;
|
|
157
224
|
const blockCount = findings.filter((finding) => finding.severity === "block").length;
|
|
158
225
|
return {
|
|
159
226
|
target: localReport.target,
|
|
160
|
-
|
|
161
|
-
|
|
227
|
+
...(localReport.targetDir !== undefined ? { targetDir: localReport.targetDir } : {}),
|
|
228
|
+
status: statusFromAction(applyLocalFindingEscalation(response.action, findings)),
|
|
229
|
+
projects: localReport.projects,
|
|
162
230
|
findings,
|
|
163
231
|
errors: localReport.errors,
|
|
164
232
|
summary: {
|
|
@@ -172,16 +240,18 @@ export function buildScannerReport(localReport, response, analyzedCount, ecosyst
|
|
|
172
240
|
scanner: { ...response, packages }
|
|
173
241
|
};
|
|
174
242
|
}
|
|
175
|
-
function scannerFinding(pkg) {
|
|
243
|
+
function scannerFinding(pkg, origins) {
|
|
176
244
|
const action = pkg.action ?? "pass";
|
|
177
245
|
const top = pkg.findings[0];
|
|
246
|
+
const manifestPath = pkg.ecosystem ? origins?.get(`${pkg.ecosystem}:${pkg.name}@${pkg.version}`) : undefined;
|
|
178
247
|
return {
|
|
179
248
|
id: top?.category ?? top?.id ?? "scanner-finding",
|
|
180
249
|
severity: action === "block" ? "block" : "warn",
|
|
181
250
|
title: top?.title ?? pkg.reasons[0] ?? `scanner ${action} verdict`,
|
|
182
251
|
message: pkg.reasons.join("; ") || top?.title || `scanner returned ${action} (score ${pkg.score})`,
|
|
183
252
|
project: "",
|
|
184
|
-
location: `${pkg.name}@${pkg.version}
|
|
253
|
+
location: `${pkg.name}@${pkg.version}`,
|
|
254
|
+
...(manifestPath ? { manifestPath } : {})
|
|
185
255
|
};
|
|
186
256
|
}
|
|
187
257
|
function statusFromAction(action) {
|
package/dist/scan/staged.js
CHANGED
|
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG, loadUserConfig } from "../config/settings.js";
|
|
|
7
7
|
import { offerRememberSync } from "../decisions/remember-prompt.js";
|
|
8
8
|
import { packageKey } from "../decisions/apply.js";
|
|
9
9
|
import { loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
10
|
+
import { gateProjectDecisions } from "../project/override-trust.js";
|
|
10
11
|
import { gitSync, gitTrimmed } from "../util/git.js";
|
|
11
12
|
import { promptYesNo } from "../util/tty-prompt.js";
|
|
12
13
|
import { GUARD_SELFTEST_ENV } from "../setup/git-hook.js";
|
|
@@ -30,6 +31,15 @@ function emptyLocalReport(target) {
|
|
|
30
31
|
}
|
|
31
32
|
};
|
|
32
33
|
}
|
|
34
|
+
const DEPENDENCY_MANIFEST_NAMES = new Set([
|
|
35
|
+
"package.json",
|
|
36
|
+
"pyproject.toml",
|
|
37
|
+
"Pipfile",
|
|
38
|
+
"setup.cfg",
|
|
39
|
+
"setup.py",
|
|
40
|
+
"environment.yml",
|
|
41
|
+
"environment.yaml"
|
|
42
|
+
]);
|
|
33
43
|
export function stagedLockfilePaths(cwd, env) {
|
|
34
44
|
const diff = gitSync(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, env });
|
|
35
45
|
if (!diff.ok) {
|
|
@@ -37,6 +47,13 @@ export function stagedLockfilePaths(cwd, env) {
|
|
|
37
47
|
}
|
|
38
48
|
return diff.stdout.split("\0").filter(Boolean).filter((path) => isLockfileName(basename(path)));
|
|
39
49
|
}
|
|
50
|
+
export function stagedDependencyManifestPaths(cwd, env) {
|
|
51
|
+
const diff = gitSync(["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, env });
|
|
52
|
+
if (!diff.ok) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return diff.stdout.split("\0").filter(Boolean).filter((path) => DEPENDENCY_MANIFEST_NAMES.has(basename(path)));
|
|
56
|
+
}
|
|
40
57
|
export function scopeStagedPaths(paths, root, cwd, targetPath) {
|
|
41
58
|
if (!targetPath) {
|
|
42
59
|
return [...paths];
|
|
@@ -53,18 +70,42 @@ export function scopeStagedPaths(paths, root, cwd, targetPath) {
|
|
|
53
70
|
}
|
|
54
71
|
export function materializeStaged(relPaths, cwd, env) {
|
|
55
72
|
const dir = mkdtempSync(join(tmpdir(), "dg-staged-"));
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
try {
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const relative of relPaths) {
|
|
76
|
+
const blob = gitSync(["show", `:${relative}`], { cwd, env });
|
|
77
|
+
if (!blob.ok) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const destination = join(dir, relative);
|
|
81
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
82
|
+
writeFileSync(destination, blob.stdout, "utf8");
|
|
83
|
+
count += 1;
|
|
61
84
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
return { dir, count };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
throw error;
|
|
66
90
|
}
|
|
67
|
-
|
|
91
|
+
}
|
|
92
|
+
// Ctrl-C during the scan (realistic on a slow pre-commit guard) would otherwise
|
|
93
|
+
// skip the try/finally and leak the materialized temp dir. Mirror the proxy
|
|
94
|
+
// path's signal handling: remove the dir then exit with the signal's code.
|
|
95
|
+
function installTempDirSignalCleanup(dir) {
|
|
96
|
+
const registrations = ["SIGINT", "SIGTERM"].map((signal) => {
|
|
97
|
+
const handler = () => {
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
100
|
+
};
|
|
101
|
+
process.once(signal, handler);
|
|
102
|
+
return { handler, signal };
|
|
103
|
+
});
|
|
104
|
+
return () => {
|
|
105
|
+
for (const registration of registrations) {
|
|
106
|
+
process.off(registration.signal, registration.handler);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
68
109
|
}
|
|
69
110
|
export function runStagedScan(options) {
|
|
70
111
|
const env = options.env ?? process.env;
|
|
@@ -72,7 +113,7 @@ export function runStagedScan(options) {
|
|
|
72
113
|
const theme = createTheme(resolvePresentation().color);
|
|
73
114
|
// git exports GIT_INDEX_FILE to real pre-commit runs; its absence separates dg's hook self-test from a user commit that inherited the env var.
|
|
74
115
|
if (options.hook && env[GUARD_SELFTEST_ENV] === "1" && env.GIT_INDEX_FILE === undefined) {
|
|
75
|
-
return { exitCode: 2, stdout: "", stderr: "dg guard
|
|
116
|
+
return { exitCode: 2, stdout: "", stderr: "dg commit-guard self-test: synthetic block (exit 2)\n" };
|
|
76
117
|
}
|
|
77
118
|
const root = gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
|
|
78
119
|
if (!root) {
|
|
@@ -88,14 +129,15 @@ export function runStagedScan(options) {
|
|
|
88
129
|
return outsideRepoResult(options.targetPath ?? "");
|
|
89
130
|
}
|
|
90
131
|
if (scoped.length === 0) {
|
|
91
|
-
return { exitCode: 0, stdout: "", stderr:
|
|
132
|
+
return { exitCode: 0, stdout: "", stderr: stagedManifestNotice(cwd, env, theme) };
|
|
92
133
|
}
|
|
93
134
|
const dgFile = options.useDecisions === false ? null : loadDgFile(root);
|
|
94
135
|
if (dgFile) {
|
|
95
136
|
warnUnreadableDgFile(dgFile);
|
|
96
137
|
}
|
|
97
|
-
const usableDgFile = dgFile?.readable ? dgFile : null;
|
|
138
|
+
const usableDgFile = dgFile?.readable ? gateProjectDecisions(dgFile, root, env) : null;
|
|
98
139
|
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
140
|
+
const removeSignalCleanup = installTempDirSignalCleanup(dir);
|
|
99
141
|
try {
|
|
100
142
|
if (count === 0) {
|
|
101
143
|
return failOpen(theme, "could not read the staged lockfile contents", onIncomplete);
|
|
@@ -107,6 +149,7 @@ export function runStagedScan(options) {
|
|
|
107
149
|
return decideStagedVerdict(report, env, options.hook, usableDgFile ? { root, file: usableDgFile } : undefined);
|
|
108
150
|
}
|
|
109
151
|
finally {
|
|
152
|
+
removeSignalCleanup();
|
|
110
153
|
rmSync(dir, { recursive: true, force: true });
|
|
111
154
|
}
|
|
112
155
|
}
|
|
@@ -134,16 +177,26 @@ export function stagedScanReport(options) {
|
|
|
134
177
|
warnUnreadableDgFile(dgFile);
|
|
135
178
|
}
|
|
136
179
|
const { dir, count } = materializeStaged(scoped, cwd, env);
|
|
180
|
+
const removeSignalCleanup = installTempDirSignalCleanup(dir);
|
|
137
181
|
try {
|
|
138
182
|
if (count === 0) {
|
|
139
183
|
return { report: base, outcome: { kind: "failed", error: { kind: "worker", message: "could not read the staged lockfile contents" } } };
|
|
140
184
|
}
|
|
141
|
-
return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? dgFile : null) };
|
|
185
|
+
return { report: base, outcome: runScannerScan(dir, base, env, dgFile?.readable ? gateProjectDecisions(dgFile, root, env) : null) };
|
|
142
186
|
}
|
|
143
187
|
finally {
|
|
188
|
+
removeSignalCleanup();
|
|
144
189
|
rmSync(dir, { recursive: true, force: true });
|
|
145
190
|
}
|
|
146
191
|
}
|
|
192
|
+
function stagedManifestNotice(cwd, env, theme) {
|
|
193
|
+
const manifests = stagedDependencyManifestPaths(cwd, env);
|
|
194
|
+
if (manifests.length === 0) {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
const sample = manifests.slice(0, 3).join(", ");
|
|
198
|
+
return ` ${theme.paint("unknown", "?")} ${theme.paint("muted", `DG could not verify staged dependency manifest${manifests.length === 1 ? "" : "s"} (${sample}) — no lockfile staged. Commit a lockfile so dg can resolve and scan the declared versions.`)}\n`;
|
|
199
|
+
}
|
|
147
200
|
function safeRealpath(path) {
|
|
148
201
|
try {
|
|
149
202
|
return realpathSync(path);
|
|
@@ -220,7 +273,7 @@ export function stagedRememberOffer(report, decisionContext, env) {
|
|
|
220
273
|
file: decisionContext.file,
|
|
221
274
|
packages,
|
|
222
275
|
acceptedBy: resolveAcceptedBy(decisionContext.root, env),
|
|
223
|
-
surface: "guard
|
|
276
|
+
surface: "commit-guard",
|
|
224
277
|
env,
|
|
225
278
|
...(decisionContext.prompts ? { prompts: decisionContext.prompts } : {})
|
|
226
279
|
});
|
package/dist/scan/types.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
// An unpinned entry has no resolvable version to verify, so it escalates a scan
|
|
2
|
+
// to analysis_incomplete. Other reasons are surfaced without failing the verdict:
|
|
3
|
+
// git/direct-url are screened by the proxy at install time, and workspace/local
|
|
4
|
+
// are not published artifacts.
|
|
5
|
+
export const ESCALATING_SKIP_REASONS = new Set([
|
|
6
|
+
"unpinned"
|
|
7
|
+
]);
|
|
@@ -10,7 +10,7 @@ import { ErrorView } from "./components/ErrorView.js";
|
|
|
10
10
|
import { ProjectSelector } from "./components/ProjectSelector.js";
|
|
11
11
|
import { SetupBanner } from "./components/SetupBanner.js";
|
|
12
12
|
import { useResizeRepaint } from "./hooks/useResizeRepaint.js";
|
|
13
|
-
import { effectiveScanAction,
|
|
13
|
+
import { effectiveScanAction, verdictExitCode } from "./shims.js";
|
|
14
14
|
import { leaveTui, showCursor, tuiIsActive } from "./alt-screen.js";
|
|
15
15
|
import { formatResetDate } from "../install-ui/block-render.js";
|
|
16
16
|
import { accountHeaderLine } from "./shims.js";
|
|
@@ -29,7 +29,7 @@ export const App = ({ config, setupIssues = [], initialView, updateAvailable })
|
|
|
29
29
|
useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
|
|
30
30
|
const handleResultsExit = useCallback(() => {
|
|
31
31
|
if (state.phase === "results") {
|
|
32
|
-
process.exitCode =
|
|
32
|
+
process.exitCode = verdictExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.failOn ?? "block");
|
|
33
33
|
}
|
|
34
34
|
leaveAltScreen();
|
|
35
35
|
exit();
|
|
@@ -61,7 +61,7 @@ export const App = ({ config, setupIssues = [], initialView, updateAvailable })
|
|
|
61
61
|
// not only when the user dismisses the view — a piped/killed/non-TTY exit
|
|
62
62
|
// must still carry the right code (an unverified scan is never a silent 0).
|
|
63
63
|
if (state.phase === "results") {
|
|
64
|
-
process.exitCode =
|
|
64
|
+
process.exitCode = verdictExitCode(effectiveScanAction(state.result.action, state.decisions?.effectiveAction, config.mode), config.failOn ?? "block");
|
|
65
65
|
}
|
|
66
66
|
}, [state, config, exitWithMessage]);
|
|
67
67
|
useInput((input, key) => {
|
|
@@ -6,6 +6,7 @@ import { ExportDialog, loginRequiredToast } from "../../export-ui/ExportDialog.j
|
|
|
6
6
|
import { resolvePresentation } from "../../presentation/mode.js";
|
|
7
7
|
import { createTheme } from "../../presentation/theme.js";
|
|
8
8
|
import { packagePageUrl } from "../../presentation/package-page.js";
|
|
9
|
+
import { csvCell as csvEscape } from "../../security/csv.js";
|
|
9
10
|
import { accountHeaderLine, isLoggedIn } from "../shims.js";
|
|
10
11
|
import { packageKey } from "../../decisions/apply.js";
|
|
11
12
|
import { ScoreHeader, COMPACT_ROWS, LOGO_MIN_COLS } from "./ScoreHeader.js";
|
|
@@ -242,7 +243,10 @@ function buildDetailLines(group, safeVersion, maxWidth) {
|
|
|
242
243
|
lines.push(_jsxs(Text, { children: [chalk.dim("Recommendation:"), " ", chalk.cyan(truncate(rep.recommendation, maxWidth - 18))] }, "recommendation"));
|
|
243
244
|
}
|
|
244
245
|
if (safeVersion) {
|
|
245
|
-
|
|
246
|
+
const sep = rep.ecosystem === "pypi" ? "==" : "@";
|
|
247
|
+
const note = safeVersion.crossesMajor ? "crosses major, may break" : "same major";
|
|
248
|
+
const text = `dg doesn't flag ${rep.name}${sep}${safeVersion.version} (${safeVersion.direction} · ${note})`;
|
|
249
|
+
lines.push(_jsx(Text, { children: safeVersion.crossesMajor ? chalk.yellow(text) : chalk.green(text) }, "safe"));
|
|
246
250
|
}
|
|
247
251
|
return lines;
|
|
248
252
|
}
|
|
@@ -430,7 +434,6 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
430
434
|
return {
|
|
431
435
|
...summary,
|
|
432
436
|
packages: result.packages,
|
|
433
|
-
safeVersions: result.safeVersions,
|
|
434
437
|
licenses: licenseGroups.map((g) => ({
|
|
435
438
|
spdx: g.spdx,
|
|
436
439
|
riskCategory: g.risk,
|
|
@@ -439,10 +442,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
439
442
|
})),
|
|
440
443
|
};
|
|
441
444
|
};
|
|
442
|
-
const csvCell = (v) =>
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
445
|
+
const csvCell = (v) => csvEscape(v === null || v === undefined ? "" : String(v));
|
|
446
|
+
// Escape Markdown table metacharacters so an attacker-named package can't forge
|
|
447
|
+
// a row/column or break the heading structure of an exported report.
|
|
448
|
+
const mdCell = (v) => String(v ?? "").replace(/\|/gu, "\\|").replace(/[\r\n]+/gu, " ");
|
|
446
449
|
const formatExport = (payload, scope, format) => {
|
|
447
450
|
if (format === "json") {
|
|
448
451
|
return { body: JSON.stringify(payload, null, 2) + "\n", ext: "json" };
|
|
@@ -510,13 +513,13 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
510
513
|
const renderPkgRows = (rows) => {
|
|
511
514
|
lines.push("## Packages", "", "| Name | Version | Score | License |", "|---|---|---|---|");
|
|
512
515
|
for (const r of rows)
|
|
513
|
-
lines.push(`| ${r.name} | ${r.version} | ${r.score} | ${r.license ?? "—"} |`);
|
|
516
|
+
lines.push(`| ${mdCell(r.name)} | ${mdCell(r.version)} | ${r.score} | ${mdCell(r.license ?? "—")} |`);
|
|
514
517
|
lines.push("");
|
|
515
518
|
};
|
|
516
519
|
const renderLicRows = (rows) => {
|
|
517
520
|
lines.push("## Licenses", "", "| License | Risk | Count |", "|---|---|---|");
|
|
518
521
|
for (const r of rows)
|
|
519
|
-
lines.push(`| ${r.spdx} | ${r.riskCategory} | ${r.count} |`);
|
|
522
|
+
lines.push(`| ${mdCell(r.spdx)} | ${mdCell(r.riskCategory)} | ${r.count} |`);
|
|
520
523
|
lines.push("");
|
|
521
524
|
};
|
|
522
525
|
if (scope === "summary") {
|
|
@@ -541,10 +544,10 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
541
544
|
}
|
|
542
545
|
else if (scope === "current-license") {
|
|
543
546
|
const r = payload;
|
|
544
|
-
lines.push(`## ${r.spdx} · ${r.riskCategory} · ${r.count} packages`, "");
|
|
547
|
+
lines.push(`## ${mdCell(r.spdx)} · ${mdCell(r.riskCategory)} · ${r.count} packages`, "");
|
|
545
548
|
lines.push("| Name | Version | Score |", "|---|---|---|");
|
|
546
549
|
for (const p of r.packages)
|
|
547
|
-
lines.push(`| ${p.name} | ${p.version} | ${p.score} |`);
|
|
550
|
+
lines.push(`| ${mdCell(p.name)} | ${mdCell(p.version)} | ${p.score} |`);
|
|
548
551
|
}
|
|
549
552
|
else if (scope === "findings") {
|
|
550
553
|
const rows = payload;
|
|
@@ -553,9 +556,9 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
553
556
|
}
|
|
554
557
|
else {
|
|
555
558
|
for (const r of rows) {
|
|
556
|
-
lines.push(`### ${r.name}@${r.version} · ${r.action.toUpperCase()} · score ${r.score}`);
|
|
559
|
+
lines.push(`### ${mdCell(r.name)}@${mdCell(r.version)} · ${mdCell(r.action.toUpperCase())} · score ${r.score}`);
|
|
557
560
|
for (const f of r.findings)
|
|
558
|
-
lines.push(`- sev ${f.severity}: ${f.title ?? "(hidden)"}`);
|
|
561
|
+
lines.push(`- sev ${f.severity}: ${mdCell(f.title ?? "(hidden)")}`);
|
|
559
562
|
lines.push("");
|
|
560
563
|
}
|
|
561
564
|
}
|
|
@@ -693,8 +696,8 @@ export const InteractiveResultsView = ({ result, config: _config, durationMs, on
|
|
|
693
696
|
const detailLines = useMemo(() => {
|
|
694
697
|
if (!detailGroup)
|
|
695
698
|
return [];
|
|
696
|
-
return buildDetailLines(detailGroup,
|
|
697
|
-
}, [detailGroup,
|
|
699
|
+
return buildDetailLines(detailGroup, firstPackage(detailGroup).safeVersion, innerWidth);
|
|
700
|
+
}, [detailGroup, innerWidth]);
|
|
698
701
|
const detailContentRows = Math.max(3, termRows - DETAIL_PANE_CHROME);
|
|
699
702
|
const getLevel = (idx) => {
|
|
700
703
|
return view.expandedKey !== null && groups[idx]?.key === view.expandedKey ? view.expandLevel : null;
|
|
@@ -3,7 +3,11 @@ import { useReducer, useEffect, useRef, useCallback, useState } from "react";
|
|
|
3
3
|
import { analyzePackages, AnalyzeError, mergeAnalyzeResponses } from "../../api/analyze.js";
|
|
4
4
|
import { applyDecisions, packageKey } from "../../decisions/apply.js";
|
|
5
5
|
import { findProjectRoot, loadDgFile } from "../../project/dgfile.js";
|
|
6
|
-
import {
|
|
6
|
+
import { gateProjectDecisions } from "../../project/override-trust.js";
|
|
7
|
+
import { collectScanPackages, discoverScanProjectsAsync, mergeInstalledPackages } from "../../scan/collect.js";
|
|
8
|
+
import { summarizeUnscanned } from "../../scan/scanner-report.js";
|
|
9
|
+
import { detectUnlockedManifests, unlockedManifestNotice } from "../../scan/manifest-coverage.js";
|
|
10
|
+
import { discoverInstalledPackages } from "../../scan/installed.js";
|
|
7
11
|
function reducer(_state, action) {
|
|
8
12
|
switch (action.type) {
|
|
9
13
|
case "PROJECTS_FOUND":
|
|
@@ -46,6 +50,7 @@ export function useScan(config) {
|
|
|
46
50
|
abortRef.current = new AbortController();
|
|
47
51
|
}
|
|
48
52
|
const [multiProjects, setMultiProjects] = useState(null);
|
|
53
|
+
const installedRef = useRef(null);
|
|
49
54
|
useEffect(() => () => abortRef.current?.abort(), []);
|
|
50
55
|
useEffect(() => {
|
|
51
56
|
if (started.current) {
|
|
@@ -53,12 +58,20 @@ export function useScan(config) {
|
|
|
53
58
|
}
|
|
54
59
|
started.current = true;
|
|
55
60
|
void (async () => {
|
|
56
|
-
const
|
|
61
|
+
const root = process.cwd();
|
|
62
|
+
const discovered = await discoverScanProjectsAsync(root, (progress) => {
|
|
57
63
|
dispatch({ type: "DISCOVERY_PROGRESS", path: progress.path, found: progress.found });
|
|
58
64
|
});
|
|
59
65
|
const projects = discovered.filter((project) => project.ecosystem === "npm" || project.ecosystem === "pypi");
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
const installed = discoverInstalledPackages(root, process.env);
|
|
67
|
+
installedRef.current = installed;
|
|
68
|
+
const installedTotal = [...installed.byEcosystem.values()].reduce((sum, list) => sum + list.length, 0);
|
|
69
|
+
if (projects.length === 0 && installedTotal === 0) {
|
|
70
|
+
const manifestNotice = unlockedManifestNotice(detectUnlockedManifests(root));
|
|
71
|
+
dispatch({
|
|
72
|
+
type: "DISCOVERY_EMPTY",
|
|
73
|
+
message: manifestNotice ?? "No dependencies found — no lockfiles, manifests, or installed packages here."
|
|
74
|
+
});
|
|
62
75
|
return;
|
|
63
76
|
}
|
|
64
77
|
if (projects.length > 1) {
|
|
@@ -66,11 +79,11 @@ export function useScan(config) {
|
|
|
66
79
|
dispatch({ type: "PROJECTS_FOUND", projects });
|
|
67
80
|
return;
|
|
68
81
|
}
|
|
69
|
-
await scanProjects(projects, dispatch, abortRef.current?.signal);
|
|
82
|
+
await scanProjects(projects, installed, dispatch, abortRef.current?.signal);
|
|
70
83
|
})();
|
|
71
84
|
}, [config]);
|
|
72
85
|
const scanSelectedProjects = useCallback((projects) => {
|
|
73
|
-
void scanProjects(projects, dispatch, abortRef.current?.signal);
|
|
86
|
+
void scanProjects(projects, installedRef.current, dispatch, abortRef.current?.signal);
|
|
74
87
|
}, []);
|
|
75
88
|
const restartSelection = useCallback(() => {
|
|
76
89
|
if (multiProjects) {
|
|
@@ -89,10 +102,13 @@ function computeProjectDecisions(result, entries) {
|
|
|
89
102
|
if (!root) {
|
|
90
103
|
return undefined;
|
|
91
104
|
}
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
105
|
+
const loaded = loadDgFile(root);
|
|
106
|
+
if (!loaded.readable) {
|
|
94
107
|
return undefined;
|
|
95
108
|
}
|
|
109
|
+
// Gate decisions through the trust check so a hostile repo's dg.json can't
|
|
110
|
+
// suppress a warn in the scan TUI that it did not author on this machine.
|
|
111
|
+
const file = gateProjectDecisions(loaded, root, process.env);
|
|
96
112
|
const ecosystems = new Map();
|
|
97
113
|
for (const [ecosystem, packages] of entries) {
|
|
98
114
|
for (const pkg of packages) {
|
|
@@ -105,17 +121,24 @@ function computeProjectDecisions(result, entries) {
|
|
|
105
121
|
return undefined;
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
|
-
|
|
124
|
+
function emptyScanMessage(skipped) {
|
|
125
|
+
if (skipped > 0) {
|
|
126
|
+
return `No packages to scan — found ${skipped} declared ${skipped === 1 ? "entry" : "entries"} with no pinned version. Pin them (e.g. requests==2.31.0) or run \`pip freeze > requirements.txt\`.`;
|
|
127
|
+
}
|
|
128
|
+
return "No packages to scan.";
|
|
129
|
+
}
|
|
130
|
+
async function scanProjects(projects, installed, dispatch, signal) {
|
|
109
131
|
const startMs = Date.now();
|
|
110
132
|
let skipped = 0;
|
|
111
133
|
let total = 0;
|
|
112
134
|
try {
|
|
113
|
-
const
|
|
135
|
+
const lockfilePackages = collectScanPackages(projects);
|
|
136
|
+
const collected = installed ? mergeInstalledPackages(lockfilePackages, installed) : lockfilePackages;
|
|
114
137
|
skipped = collected.skipped;
|
|
115
138
|
const entries = [...collected.byEcosystem.entries()];
|
|
116
139
|
total = entries.reduce((sum, [, list]) => sum + list.length, 0);
|
|
117
140
|
if (total === 0) {
|
|
118
|
-
dispatch({ type: "DISCOVERY_EMPTY", message:
|
|
141
|
+
dispatch({ type: "DISCOVERY_EMPTY", message: emptyScanMessage(skipped) });
|
|
119
142
|
return;
|
|
120
143
|
}
|
|
121
144
|
dispatch({ type: "DISCOVERY_COMPLETE", total });
|
|
@@ -170,8 +193,11 @@ async function scanProjects(projects, dispatch, signal) {
|
|
|
170
193
|
ecoByKey.set(packageKey(pkg.name, pkg.version), ecosystem);
|
|
171
194
|
}
|
|
172
195
|
}
|
|
196
|
+
const unscanned = summarizeUnscanned(collected.skippedPackages);
|
|
197
|
+
const escalated = unscanned !== undefined && unscanned.escalating > 0 && base.action !== "block";
|
|
173
198
|
const result = {
|
|
174
199
|
...base,
|
|
200
|
+
...(escalated ? { action: "analysis_incomplete" } : {}),
|
|
175
201
|
packages: base.packages.map((pkg) => {
|
|
176
202
|
const ecosystem = ecoByKey.get(packageKey(pkg.name, pkg.version));
|
|
177
203
|
return ecosystem ? { ...pkg, ecosystem } : pkg;
|
package/dist/scan-ui/launch.js
CHANGED
|
@@ -17,7 +17,7 @@ export function shouldLaunchScanTui(options) {
|
|
|
17
17
|
}
|
|
18
18
|
return resolvePresentation().mode === "rich";
|
|
19
19
|
}
|
|
20
|
-
export async function launchScanTui(initialView = "results") {
|
|
20
|
+
export async function launchScanTui(initialView = "results", failOn = "block") {
|
|
21
21
|
const ci = process.env.CI;
|
|
22
22
|
const ciCleared = ci === "" || ci === "0" || ci === "false";
|
|
23
23
|
if (ciCleared) {
|
|
@@ -29,7 +29,7 @@ export async function launchScanTui(initialView = "results") {
|
|
|
29
29
|
import("./LegacyApp.js")
|
|
30
30
|
]);
|
|
31
31
|
const mode = loadUserConfig().policy.mode;
|
|
32
|
-
const config = { mode };
|
|
32
|
+
const config = { mode, failOn: mode === "strict" ? "warn" : failOn };
|
|
33
33
|
const update = pendingUpdate();
|
|
34
34
|
const updateAvailable = update
|
|
35
35
|
? `Update available: ${update.current} → ${update.latest} · run dg update`
|
package/dist/scan-ui/logo.js
CHANGED
|
@@ -33,7 +33,9 @@ function fillCircle(grid, cx, cy, r, type) {
|
|
|
33
33
|
const LOGO_DATA = (() => {
|
|
34
34
|
const W = 22, H = 28;
|
|
35
35
|
const grid = Array.from({ length: H }, () => Array(W).fill(0));
|
|
36
|
-
|
|
36
|
+
// R<10: the even-width grid has no integer center, so R=10 lands the 0° node
|
|
37
|
+
// on the last column and clips its right half, skewing the logo left.
|
|
38
|
+
const cx = 11, cy = 14, R = 9;
|
|
37
39
|
const angles = [270, 315, 0, 45, 90, 135, 180, 225];
|
|
38
40
|
const nodes = angles.map(a => {
|
|
39
41
|
const rad = a * Math.PI / 180;
|
|
@@ -70,7 +72,9 @@ export function renderLogo(action) {
|
|
|
70
72
|
const colors = {
|
|
71
73
|
1: chalk.dim,
|
|
72
74
|
2: chalk.white,
|
|
73
|
-
|
|
75
|
+
// Brightness, not bold: terminals faux-bold braille by double-striking the
|
|
76
|
+
// glyph shifted right, which smears full cells (⣿) into the next column.
|
|
77
|
+
3: chalk.whiteBright,
|
|
74
78
|
4: action === "block" ? chalk.red
|
|
75
79
|
: action === "warn" ? chalk.yellow
|
|
76
80
|
: action === "analysis_incomplete" ? chalk.cyan
|