@westbayberry/dg 2.1.0 → 2.3.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/README.md +216 -226
- package/dist/agents/claude-code.js +123 -0
- package/dist/agents/codex.js +65 -0
- package/dist/agents/copilot-cli.js +115 -0
- package/dist/agents/cursor.js +118 -0
- package/dist/agents/gate-posture.js +21 -0
- package/dist/agents/gemini.js +107 -0
- package/dist/agents/persistence.js +351 -0
- package/dist/agents/registry.js +128 -0
- package/dist/agents/routing.js +118 -0
- package/dist/agents/types.js +1 -0
- package/dist/agents/windsurf.js +93 -0
- package/dist/api/analyze.js +1 -1
- package/dist/audit/detectors.js +0 -11
- package/dist/audit/events.js +5 -21
- package/dist/audit/rules.js +6 -2
- package/dist/audit-ui/AuditApp.js +2 -0
- package/dist/audit-ui/components/AuditResultsView.js +55 -92
- package/dist/audit-ui/export.js +0 -4
- package/dist/audit-ui/format.js +0 -3
- package/dist/audit-ui/launch.js +10 -1
- package/dist/auth/device-login.js +4 -5
- package/dist/auth/login-app.js +7 -7
- package/dist/auth/store.js +17 -5
- package/dist/bin/dg.js +59 -52
- package/dist/commands/agents.js +275 -0
- package/dist/commands/audit.js +28 -3
- package/dist/commands/config.js +15 -7
- package/dist/commands/cooldown.js +389 -0
- package/dist/commands/decisions.js +23 -9
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/licenses.js +10 -22
- package/dist/commands/logout.js +4 -11
- package/dist/commands/router.js +6 -4
- package/dist/commands/sbom.js +206 -0
- package/dist/commands/scan.js +1 -1
- package/dist/commands/service.js +32 -13
- package/dist/commands/setup.js +196 -27
- package/dist/commands/types.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/commands/update.js +17 -8
- package/dist/commands/verify.js +8 -5
- package/dist/config/settings.js +49 -48
- package/dist/decisions/remember-prompt.js +5 -10
- package/dist/export-ui/ExportDialog.js +198 -0
- package/dist/install-ui/LiveInstall.js +5 -4
- package/dist/install-ui/block-render.js +18 -14
- package/dist/install-ui/prep-spinner.js +32 -0
- package/dist/launcher/agent-check.js +896 -0
- package/dist/launcher/agent-hook-exec.js +70 -0
- package/dist/launcher/agent-hook-io.js +39 -0
- package/dist/launcher/cargo-cache.js +40 -0
- package/dist/launcher/classify.js +44 -7
- package/dist/launcher/env.js +105 -26
- package/dist/launcher/install-preflight.js +64 -18
- package/dist/launcher/live-install.js +29 -8
- package/dist/launcher/manifest-screen.js +171 -0
- package/dist/launcher/output-redaction.js +10 -4
- package/dist/launcher/preflight-prompt.js +18 -4
- package/dist/launcher/run.js +178 -103
- package/dist/launcher/spawn-invocation.js +21 -0
- package/dist/policy/cooldown.js +18 -5
- package/dist/policy/evaluate.js +5 -6
- package/dist/policy/pypi-name.js +17 -0
- package/dist/presentation/mode.js +3 -2
- package/dist/presentation/package-page.js +9 -0
- package/dist/presentation/theme.js +7 -7
- package/dist/project/dgfile.js +146 -6
- package/dist/project/override-trust.js +0 -0
- package/dist/proxy/auth.js +42 -0
- package/dist/proxy/ca.js +29 -9
- package/dist/proxy/cooldown-exemptions-file.js +33 -0
- package/dist/proxy/enforcement.js +55 -16
- package/dist/proxy/metadata-map.js +66 -12
- package/dist/proxy/preverified.js +55 -0
- package/dist/proxy/server.js +568 -53
- package/dist/proxy/worker.js +16 -1
- package/dist/publish-set/collect.js +1 -4
- package/dist/publish-set/npm.js +8 -5
- package/dist/publish-set/pack.js +9 -3
- package/dist/runtime/cli.js +0 -4
- package/dist/runtime/fatal.js +31 -0
- package/dist/runtime/first-run.js +14 -12
- package/dist/runtime/node-version.js +43 -6
- package/dist/runtime/nudges.js +35 -2
- package/dist/sbom/cyclonedx.js +211 -0
- package/dist/sbom-ui/SbomApp.js +158 -0
- package/dist/sbom-ui/components/SbomHeader.js +32 -0
- package/dist/sbom-ui/components/SbomList.js +52 -0
- package/dist/sbom-ui/inventory.js +132 -0
- package/dist/sbom-ui/launch.js +51 -0
- package/dist/sbom-ui/run.js +55 -0
- package/dist/sbom-ui/store.js +26 -0
- package/dist/scan/collect.js +10 -6
- package/dist/scan/command.js +17 -10
- package/dist/scan/discovery.js +11 -2
- package/dist/scan/render.js +28 -4
- package/dist/scan/scanner-report.js +15 -9
- package/dist/scan/staged.js +33 -9
- package/dist/scan-ui/LegacyApp.js +9 -13
- package/dist/scan-ui/alt-screen.js +5 -8
- package/dist/scan-ui/components/InteractiveResultsView.js +133 -120
- package/dist/scan-ui/components/ProgressBar.js +3 -14
- package/dist/scan-ui/components/ProjectSelector.js +1 -1
- package/dist/scan-ui/components/ScoreHeader.js +2 -3
- package/dist/scan-ui/components/SetupBanner.js +0 -6
- package/dist/scan-ui/format-helpers.js +61 -5
- package/dist/scan-ui/hooks/useResizeRepaint.js +25 -0
- package/dist/scan-ui/hooks/useScan.js +19 -2
- package/dist/scan-ui/launch.js +7 -4
- package/dist/scan-ui/shims.js +11 -4
- package/dist/scripts/detect.js +7 -2
- package/dist/scripts/gate.js +1 -1
- package/dist/security/sanitize.js +8 -4
- package/dist/service/state.js +28 -9
- package/dist/service/trust-refresh.js +92 -0
- package/dist/service/trust-store.js +5 -9
- package/dist/service/worker.js +23 -1
- package/dist/setup/activate-shell.js +28 -0
- package/dist/setup/git-hook.js +49 -4
- package/dist/setup/plan.js +227 -49
- package/dist/setup/uninstall-standalone.js +25 -0
- package/dist/setup-ui/gate.js +39 -0
- package/dist/setup-ui/offer.js +42 -0
- package/dist/setup-ui/selector.js +27 -0
- package/dist/setup-ui/tasks.js +56 -0
- package/dist/setup-ui/wizard.js +233 -0
- package/dist/standalone/uninstall.mjs +2123 -0
- package/dist/state/cooldown-held.js +66 -0
- package/dist/state/index.js +1 -0
- package/dist/state/locks.js +4 -2
- package/dist/state/store.js +2 -1
- package/dist/util/external-tool.js +25 -0
- package/dist/util/git.js +10 -3
- package/dist/util/report-writer.js +57 -0
- package/dist/verify/local.js +242 -44
- package/dist/verify/package-check.js +87 -29
- package/dist/verify/preflight.js +242 -49
- package/dist/verify/render.js +15 -1
- package/npm-shrinkwrap.json +2383 -0
- package/package.json +7 -8
- package/NOTICE +0 -5
- package/dist/commands/completion.js +0 -117
- package/dist/commands/explain.js +0 -236
- package/dist/commands/unavailable.js +0 -11
- package/dist/scripts/rebuild.js +0 -28
- package/dist/telemetry/events.js +0 -40
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { useTerminalSize } from "../scan-ui/hooks/useTerminalSize.js";
|
|
6
|
+
import { useResizeRepaint } from "../scan-ui/hooks/useResizeRepaint.js";
|
|
7
|
+
import { leaveTui, showCursor, tuiIsActive } from "../scan-ui/alt-screen.js";
|
|
8
|
+
import { SbomHeader } from "./components/SbomHeader.js";
|
|
9
|
+
import { SbomList } from "./components/SbomList.js";
|
|
10
|
+
import { componentsCsv, componentsMarkdown, emptyFilterMessage, filterRows, tallyVerdicts } from "./inventory.js";
|
|
11
|
+
import { ExportDialog, loginRequiredToast } from "../export-ui/ExportDialog.js";
|
|
12
|
+
import { isLoggedIn } from "../scan-ui/shims.js";
|
|
13
|
+
import { resolvePresentation } from "../presentation/mode.js";
|
|
14
|
+
import { createTheme } from "../presentation/theme.js";
|
|
15
|
+
const HEADER_LINES = 9;
|
|
16
|
+
const FOOTER_LINES = 2;
|
|
17
|
+
const COUNTER_LINE = 1;
|
|
18
|
+
const SAFETY_LINE = 1;
|
|
19
|
+
const FILTER_CYCLE = ["all", "risky", "unlicensed"];
|
|
20
|
+
const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed", unpinned: "unpinned" };
|
|
21
|
+
function ecosystemCounts(rows) {
|
|
22
|
+
const counts = new Map();
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
counts.set(row.ecosystem, (counts.get(row.ecosystem) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
27
|
+
}
|
|
28
|
+
export const SbomApp = ({ store, document, cwd }) => {
|
|
29
|
+
const view = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
30
|
+
const { exit } = useApp();
|
|
31
|
+
const { rows: termRows, cols: termCols } = useTerminalSize();
|
|
32
|
+
useResizeRepaint();
|
|
33
|
+
const [selected, setSelected] = useState(0);
|
|
34
|
+
const [filter, setFilter] = useState("all");
|
|
35
|
+
const [query, setQuery] = useState("");
|
|
36
|
+
const [searching, setSearching] = useState(false);
|
|
37
|
+
const [exportDialog, setExportDialog] = useState(null);
|
|
38
|
+
const [toast, setToast] = useState(null);
|
|
39
|
+
const toastTimer = useRef(null);
|
|
40
|
+
const theme = useMemo(() => createTheme(resolvePresentation().color), []);
|
|
41
|
+
const showToast = useCallback((message) => {
|
|
42
|
+
setToast(message);
|
|
43
|
+
if (toastTimer.current) {
|
|
44
|
+
clearTimeout(toastTimer.current);
|
|
45
|
+
}
|
|
46
|
+
toastTimer.current = setTimeout(() => setToast(null), 4000);
|
|
47
|
+
}, []);
|
|
48
|
+
const leaveAltScreen = useCallback(() => {
|
|
49
|
+
if (tuiIsActive()) {
|
|
50
|
+
leaveTui();
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
showCursor();
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
useEffect(() => () => leaveAltScreen(), [leaveAltScreen]);
|
|
57
|
+
useEffect(() => () => {
|
|
58
|
+
if (toastTimer.current) {
|
|
59
|
+
clearTimeout(toastTimer.current);
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
const visible = filterRows(view.rows, filter, query);
|
|
63
|
+
const clamped = Math.min(selected, Math.max(0, visible.length - 1));
|
|
64
|
+
const tally = tallyVerdicts(view.rows);
|
|
65
|
+
const cargoCount = view.rows.filter((row) => row.ecosystem === "cargo").length;
|
|
66
|
+
const quit = useCallback(() => {
|
|
67
|
+
process.exitCode = 0;
|
|
68
|
+
leaveAltScreen();
|
|
69
|
+
exit();
|
|
70
|
+
}, [exit, leaveAltScreen]);
|
|
71
|
+
const openExport = useCallback(() => {
|
|
72
|
+
if (!isLoggedIn()) {
|
|
73
|
+
showToast(loginRequiredToast());
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setToast(null);
|
|
77
|
+
setExportDialog([
|
|
78
|
+
{ label: "CycloneDX JSON", defaultName: "sbom.cdx.json", render: () => document },
|
|
79
|
+
{ label: "Components CSV", defaultName: "sbom-components.csv", render: () => componentsCsv(view.rows) },
|
|
80
|
+
{ label: "Components Markdown", defaultName: "sbom-components.md", render: () => componentsMarkdown(view.rows) }
|
|
81
|
+
]);
|
|
82
|
+
}, [document, view.rows, showToast]);
|
|
83
|
+
const handleExportDone = useCallback((result) => {
|
|
84
|
+
setExportDialog(null);
|
|
85
|
+
if (result === null)
|
|
86
|
+
return;
|
|
87
|
+
showToast("path" in result ? chalk.green(`✓ exported ${result.path}`) : chalk.red(`export failed: ${result.error}`));
|
|
88
|
+
}, [showToast]);
|
|
89
|
+
useInput((input, key) => {
|
|
90
|
+
if (exportDialog)
|
|
91
|
+
return;
|
|
92
|
+
if (searching) {
|
|
93
|
+
if (key.escape) {
|
|
94
|
+
setSearching(false);
|
|
95
|
+
setQuery("");
|
|
96
|
+
setSelected(0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.return) {
|
|
100
|
+
setSearching(false);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (key.backspace || key.delete) {
|
|
104
|
+
setQuery((q) => q.slice(0, -1));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && /^[\x20-\x7e]+$/.test(input)) {
|
|
108
|
+
setQuery((q) => q + input);
|
|
109
|
+
setSelected(0);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.escape && query) {
|
|
114
|
+
setQuery("");
|
|
115
|
+
setSelected(0);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (input === "q" || key.escape) {
|
|
119
|
+
quit();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (input === "j" || key.downArrow) {
|
|
123
|
+
setSelected((s) => Math.min(s + 1, Math.max(0, visible.length - 1)));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (input === "k" || key.upArrow) {
|
|
127
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (input === "g") {
|
|
131
|
+
setSelected(0);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (input === "G") {
|
|
135
|
+
setSelected(Math.max(0, visible.length - 1));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (input === "/") {
|
|
139
|
+
setSearching(true);
|
|
140
|
+
setToast(null);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (input === "f") {
|
|
144
|
+
const next = FILTER_CYCLE[(FILTER_CYCLE.indexOf(filter) + 1) % FILTER_CYCLE.length] ?? "all";
|
|
145
|
+
setFilter(next);
|
|
146
|
+
setSelected(0);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (input === "e") {
|
|
150
|
+
openExport();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE);
|
|
154
|
+
if (exportDialog) {
|
|
155
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(ExportDialog, { options: exportDialog, theme: theme, cwd: cwd, onDone: handleExportDone })] }));
|
|
156
|
+
}
|
|
157
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SbomHeader, { total: view.rows.length, ecosystems: ecosystemCounts(view.rows), phase: view.phase, tally: tally, scannable: view.scannable, scanProgress: view.scanProgress, scanError: view.scanError, usage: view.usage, subject: view.subject, cargoCount: cargoCount }), _jsx(SbomList, { rows: visible, selected: clamped, height: listHeight, width: termCols, emptyMessage: emptyFilterMessage(filter, query, view.phase, tally, view.scanError) }), _jsxs(Box, { paddingLeft: 1, flexDirection: "column", children: [toast ? _jsx(Text, { children: toast }) : null, searching ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(" esc to clear")] })) : query ? (_jsxs(Text, { children: [chalk.cyan("/"), query, chalk.dim(` ${visible.length} of ${view.rows.length} components esc clear q quit`)] })) : (_jsxs(Text, { children: [chalk.dim("↑↓ move / search f "), chalk.dim(`filter: ${FILTER_LABEL[filter]}`), chalk.dim(" "), chalk.bold("e"), chalk.dim(" export q quit")] }))] })] }));
|
|
158
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { renderLogo } from "../../scan-ui/logo.js";
|
|
5
|
+
import { useTerminalSize } from "../../scan-ui/hooks/useTerminalSize.js";
|
|
6
|
+
const LOGO_MIN_COLS = 76;
|
|
7
|
+
const LOGO_MIN_ROWS = 16;
|
|
8
|
+
function logoAction(phase, tally, scanError) {
|
|
9
|
+
if (phase !== "done" || scanError) {
|
|
10
|
+
return "analysis_incomplete";
|
|
11
|
+
}
|
|
12
|
+
if (tally.block > 0)
|
|
13
|
+
return "block";
|
|
14
|
+
if (tally.warn > 0)
|
|
15
|
+
return "warn";
|
|
16
|
+
if (tally.scanned > 0)
|
|
17
|
+
return "pass";
|
|
18
|
+
return "analysis_incomplete";
|
|
19
|
+
}
|
|
20
|
+
function verdictLine(tally, scanError) {
|
|
21
|
+
if (scanError) {
|
|
22
|
+
return _jsx(Text, { children: chalk.dim(scanError) });
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(Text, { children: [tally.block > 0 ? chalk.red.bold(`${tally.block} BLOCK`) : chalk.dim("0 BLOCK"), chalk.dim(" · "), tally.warn > 0 ? chalk.yellow(`${tally.warn} WARN`) : chalk.dim("0 WARN"), chalk.dim(" · "), chalk.dim(`${tally.pass} PASS`)] }));
|
|
25
|
+
}
|
|
26
|
+
export const SbomHeader = ({ total, ecosystems, phase, tally, scannable, scanProgress, scanError, usage, subject, cargoCount }) => {
|
|
27
|
+
const { cols, rows } = useTerminalSize();
|
|
28
|
+
const breakdown = ecosystems.map(([name, count]) => `${count} ${name}`).join(chalk.dim(" · "));
|
|
29
|
+
const logo = renderLogo(logoAction(phase, tally, scanError));
|
|
30
|
+
const showLogo = cols >= LOGO_MIN_COLS && rows >= LOGO_MIN_ROWS;
|
|
31
|
+
return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingLeft: 1, paddingRight: 1, children: _jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { children: [chalk.bold("Dependency Guardian"), chalk.dim(" · SBOM")] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [chalk.bold(String(total)), " ", total === 1 ? "component" : "components", " ", chalk.dim(breakdown)] }), phase === "inventory" ? null : (_jsxs(_Fragment, { children: [phase === "scanning" && !scanError ? (_jsxs(Text, { children: [chalk.cyan(`scanning ${scanProgress} / ${scannable}`), chalk.dim(" npm + pypi")] })) : (verdictLine(tally, scanError)), cargoCount > 0 ? _jsx(Text, { children: chalk.dim(`${cargoCount} cargo: inventory only, not verdict-checked`) }) : null] })), _jsx(Text, { children: " " }), _jsx(Text, { children: chalk.dim(`CycloneDX 1.5 · ${subject}${usage ? ` · ${usage.used.toLocaleString()}${usage.limit === null ? "" : ` / ${usage.limit.toLocaleString()}`} scanned this month` : ""}`) })] }), showLogo ? (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: logo.map((line, index) => (_jsx(Text, { children: line }, index))) })) : null] }) }));
|
|
32
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { pad, truncate } from "../../scan-ui/format-helpers.js";
|
|
5
|
+
export function verdictGlyph(row) {
|
|
6
|
+
if (!row.scannable) {
|
|
7
|
+
return chalk.dim("·");
|
|
8
|
+
}
|
|
9
|
+
if (!row.verdict) {
|
|
10
|
+
return chalk.dim("…");
|
|
11
|
+
}
|
|
12
|
+
switch (row.verdict.action) {
|
|
13
|
+
case "block":
|
|
14
|
+
return chalk.red.bold("✘");
|
|
15
|
+
case "warn":
|
|
16
|
+
return chalk.yellow("⚠");
|
|
17
|
+
case "pass":
|
|
18
|
+
return chalk.green("✓");
|
|
19
|
+
default:
|
|
20
|
+
return chalk.cyan("?");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function windowStart(selected, count, height) {
|
|
24
|
+
if (count <= height) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const half = Math.floor(height / 2);
|
|
28
|
+
return Math.min(Math.max(0, selected - half), count - height);
|
|
29
|
+
}
|
|
30
|
+
export const SbomList = ({ rows, selected, height, width, emptyMessage }) => {
|
|
31
|
+
if (rows.length === 0) {
|
|
32
|
+
return (_jsx(Box, { paddingLeft: 1, children: _jsx(Text, { children: chalk.dim(emptyMessage) }) }));
|
|
33
|
+
}
|
|
34
|
+
const ecoWidth = 6;
|
|
35
|
+
const licenseWidth = Math.min(20, Math.max(10, Math.floor(width * 0.22)));
|
|
36
|
+
const hashWidth = 11;
|
|
37
|
+
const nameWidth = Math.max(12, width - 2 - 2 - ecoWidth - 1 - licenseWidth - 1 - hashWidth - 2);
|
|
38
|
+
const start = windowStart(selected, rows.length, height);
|
|
39
|
+
const shown = rows.slice(start, start + height);
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", children: [shown.map((row, index) => {
|
|
41
|
+
const isSelected = start + index === selected;
|
|
42
|
+
const cursor = isSelected ? chalk.cyan("›") : " ";
|
|
43
|
+
const id = truncate(`${row.name}@${row.version}`, nameWidth);
|
|
44
|
+
const name = isSelected ? chalk.bold(pad(id, nameWidth)) : pad(id, nameWidth);
|
|
45
|
+
const eco = chalk.dim(pad(row.ecosystem, ecoWidth));
|
|
46
|
+
const license = row.license
|
|
47
|
+
? chalk.dim(pad(truncate(row.license, licenseWidth), licenseWidth))
|
|
48
|
+
: chalk.yellow(pad("no license", licenseWidth));
|
|
49
|
+
const hash = row.hasHash ? chalk.dim(pad("checksum", hashWidth)) : chalk.yellow(pad("no checksum", hashWidth));
|
|
50
|
+
return (_jsxs(Text, { children: [cursor, " ", verdictGlyph(row), " ", name, " ", eco, " ", license, " ", hash] }, row.key));
|
|
51
|
+
}), rows.length > height ? (_jsx(Text, { children: chalk.dim(` ${start + 1}–${Math.min(start + height, rows.length)} of ${rows.length}`) })) : null] }));
|
|
52
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const ROW_ECOSYSTEMS = { npm: "npm", pypi: "pypi", cargo: "cargo" };
|
|
2
|
+
export function rowKey(ecosystem, name, version) {
|
|
3
|
+
return `${ecosystem}:${name.toLowerCase()}@${version}`;
|
|
4
|
+
}
|
|
5
|
+
export function buildSbomRows(components) {
|
|
6
|
+
return components
|
|
7
|
+
.map((component) => {
|
|
8
|
+
const ecosystem = ROW_ECOSYSTEMS[component.ecosystem] ?? "other";
|
|
9
|
+
return {
|
|
10
|
+
key: rowKey(ecosystem, component.name, component.version),
|
|
11
|
+
name: component.name,
|
|
12
|
+
version: component.version,
|
|
13
|
+
ecosystem,
|
|
14
|
+
license: component.license ?? null,
|
|
15
|
+
hasHash: component.integrity != null && component.integrity.length > 0,
|
|
16
|
+
scannable: ecosystem === "npm" || ecosystem === "pypi"
|
|
17
|
+
};
|
|
18
|
+
})
|
|
19
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
|
|
20
|
+
}
|
|
21
|
+
export function verdictFromResult(result) {
|
|
22
|
+
if (!result.action) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const reason = result.provenance?.downgrade
|
|
26
|
+
? "provenance downgraded"
|
|
27
|
+
: result.cooldown?.status === "quarantine"
|
|
28
|
+
? "in cooldown"
|
|
29
|
+
: result.reasons[0] ?? (result.action === "block" ? "malware" : result.action === "warn" ? "flagged" : "clean");
|
|
30
|
+
return {
|
|
31
|
+
action: result.action,
|
|
32
|
+
reason,
|
|
33
|
+
...(result.provenance?.downgrade ? { provenanceFrom: result.provenance.downgrade.fromVersion } : {}),
|
|
34
|
+
...(result.cooldown?.ageDays !== undefined ? { cooldownAgeDays: result.cooldown.ageDays } : {})
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function mergeVerdicts(rows, ecosystem, results) {
|
|
38
|
+
const byKey = new Map();
|
|
39
|
+
for (const result of results) {
|
|
40
|
+
const verdict = verdictFromResult(result);
|
|
41
|
+
if (verdict) {
|
|
42
|
+
byKey.set(rowKey(ecosystem, result.name, result.version), verdict);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (byKey.size === 0) {
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
|
48
|
+
return rows.map((row) => {
|
|
49
|
+
const verdict = byKey.get(row.key);
|
|
50
|
+
return verdict ? { ...row, verdict } : row;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const VERDICT_RANK = { block: 0, warn: 1, analysis_incomplete: 2, pass: 3 };
|
|
54
|
+
export function filterRows(rows, filter, query) {
|
|
55
|
+
const needle = query.trim().toLowerCase();
|
|
56
|
+
const matched = rows.filter((row) => {
|
|
57
|
+
if (needle && !row.name.toLowerCase().includes(needle)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (filter === "risky") {
|
|
61
|
+
return row.verdict?.action === "block" || row.verdict?.action === "warn";
|
|
62
|
+
}
|
|
63
|
+
if (filter === "unlicensed") {
|
|
64
|
+
return row.license === null;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
if (filter === "risky") {
|
|
69
|
+
return matched
|
|
70
|
+
.slice()
|
|
71
|
+
.sort((a, b) => (VERDICT_RANK[a.verdict.action] - VERDICT_RANK[b.verdict.action]) || a.name.localeCompare(b.name));
|
|
72
|
+
}
|
|
73
|
+
return matched;
|
|
74
|
+
}
|
|
75
|
+
export function emptyFilterMessage(filter, query, phase, tally, scanError) {
|
|
76
|
+
const trimmed = query.trim();
|
|
77
|
+
if (trimmed) {
|
|
78
|
+
return `no components match "${trimmed}"`;
|
|
79
|
+
}
|
|
80
|
+
if (filter === "risky") {
|
|
81
|
+
if (phase === "scanning") {
|
|
82
|
+
return "scanning… nothing flagged yet";
|
|
83
|
+
}
|
|
84
|
+
if (scanError) {
|
|
85
|
+
return scanError;
|
|
86
|
+
}
|
|
87
|
+
if (tally.scanned === 0) {
|
|
88
|
+
return "no components were verdict-checked";
|
|
89
|
+
}
|
|
90
|
+
return "nothing flagged — no malware, downgrades, or cooldowns";
|
|
91
|
+
}
|
|
92
|
+
if (filter === "unlicensed") {
|
|
93
|
+
return "every component declares a license";
|
|
94
|
+
}
|
|
95
|
+
return "no components";
|
|
96
|
+
}
|
|
97
|
+
function csvCell(value) {
|
|
98
|
+
// Neutralize spreadsheet formula injection: a cell starting with = + - @ (or
|
|
99
|
+
// tab/CR) is interpreted as a formula by Excel/Sheets, letting an attacker-named
|
|
100
|
+
// dependency run on open. Prefix a sentinel apostrophe so it is treated as text.
|
|
101
|
+
const guarded = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
102
|
+
return /[",\n\r]/.test(guarded) ? `"${guarded.replace(/"/g, '""')}"` : guarded;
|
|
103
|
+
}
|
|
104
|
+
export function componentsCsv(rows) {
|
|
105
|
+
const lines = ["name,version,ecosystem,license,verdict"];
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
lines.push([row.name, row.version, row.ecosystem, row.license ?? "", row.verdict?.action ?? ""].map(csvCell).join(","));
|
|
108
|
+
}
|
|
109
|
+
return `${lines.join("\n")}\n`;
|
|
110
|
+
}
|
|
111
|
+
export function componentsMarkdown(rows) {
|
|
112
|
+
const cell = (value) => value.replace(/\|/g, "\\|");
|
|
113
|
+
const lines = ["| Name | Version | Ecosystem | License | Verdict |", "|---|---|---|---|---|"];
|
|
114
|
+
for (const row of rows) {
|
|
115
|
+
lines.push(`| ${cell(row.name)} | ${cell(row.version)} | ${row.ecosystem} | ${cell(row.license ?? "—")} | ${row.verdict?.action ?? ""} |`);
|
|
116
|
+
}
|
|
117
|
+
return `${lines.join("\n")}\n`;
|
|
118
|
+
}
|
|
119
|
+
export function tallyVerdicts(rows) {
|
|
120
|
+
let block = 0;
|
|
121
|
+
let warn = 0;
|
|
122
|
+
let pass = 0;
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
if (row.verdict?.action === "block")
|
|
125
|
+
block += 1;
|
|
126
|
+
else if (row.verdict?.action === "warn")
|
|
127
|
+
warn += 1;
|
|
128
|
+
else if (row.verdict?.action === "pass")
|
|
129
|
+
pass += 1;
|
|
130
|
+
}
|
|
131
|
+
return { block, warn, pass, scanned: block + warn + pass };
|
|
132
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { resolvePresentation } from "../presentation/mode.js";
|
|
2
|
+
import { createSbomStore } from "./store.js";
|
|
3
|
+
import { runSbomScan } from "./run.js";
|
|
4
|
+
export function shouldLaunchSbomTui(options) {
|
|
5
|
+
if (options.json || options.outputPath) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
if (process.env.TERM === "dumb") {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (!process.stdin.isTTY) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return resolvePresentation().mode === "rich";
|
|
15
|
+
}
|
|
16
|
+
export async function launchSbomTui(payload) {
|
|
17
|
+
const ci = process.env.CI;
|
|
18
|
+
const ciCleared = ci === "" || ci === "0" || ci === "false";
|
|
19
|
+
if (ciCleared) {
|
|
20
|
+
delete process.env.CI;
|
|
21
|
+
}
|
|
22
|
+
const [{ render }, react, app, altScreen] = await Promise.all([
|
|
23
|
+
import("ink"),
|
|
24
|
+
import("react"),
|
|
25
|
+
import("./SbomApp.js"),
|
|
26
|
+
import("../scan-ui/alt-screen.js")
|
|
27
|
+
]);
|
|
28
|
+
const store = createSbomStore({
|
|
29
|
+
phase: "inventory",
|
|
30
|
+
rows: payload.rows,
|
|
31
|
+
subject: payload.subject,
|
|
32
|
+
dropped: payload.dropped,
|
|
33
|
+
scannable: payload.rows.filter((row) => row.scannable).length,
|
|
34
|
+
scanProgress: 0,
|
|
35
|
+
scanError: null,
|
|
36
|
+
usage: null
|
|
37
|
+
});
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
altScreen.enterTui();
|
|
40
|
+
try {
|
|
41
|
+
const instance = render(react.default.createElement(app.SbomApp, { store, document: payload.document, cwd: payload.cwd }), { exitOnCtrlC: true });
|
|
42
|
+
void runSbomScan(store, payload.env, controller.signal);
|
|
43
|
+
await instance.waitUntilExit();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (ciCleared)
|
|
47
|
+
process.env.CI = ci;
|
|
48
|
+
controller.abort();
|
|
49
|
+
altScreen.leaveTui();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AnalyzeError, analyzePackages } from "../api/analyze.js";
|
|
2
|
+
import { mergeVerdicts } from "./inventory.js";
|
|
3
|
+
const SCAN_ECOSYSTEMS = ["npm", "pypi"];
|
|
4
|
+
export async function runSbomScan(store, env, signal) {
|
|
5
|
+
const scannable = store.get().rows.filter((row) => row.scannable);
|
|
6
|
+
if (scannable.length === 0) {
|
|
7
|
+
store.update({ phase: "done" });
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
store.update({ phase: "scanning", scannable: scannable.length, scanProgress: 0 });
|
|
11
|
+
let completed = 0;
|
|
12
|
+
try {
|
|
13
|
+
for (const ecosystem of SCAN_ECOSYSTEMS) {
|
|
14
|
+
const inputs = scannable.filter((row) => row.ecosystem === ecosystem).map((row) => ({ name: row.name, version: row.version }));
|
|
15
|
+
if (inputs.length === 0) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const base = completed;
|
|
19
|
+
const response = await analyzePackages(inputs, {
|
|
20
|
+
ecosystem,
|
|
21
|
+
env,
|
|
22
|
+
...(signal ? { signal } : {}),
|
|
23
|
+
onProgress: (progress) => store.update({ scanProgress: base + progress.done })
|
|
24
|
+
});
|
|
25
|
+
completed += inputs.length;
|
|
26
|
+
store.update({
|
|
27
|
+
rows: mergeVerdicts(store.get().rows, ecosystem, response.packages),
|
|
28
|
+
scanProgress: completed,
|
|
29
|
+
...(response.usage ? { usage: response.usage } : {})
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
store.update({ phase: "done" });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
store.update({
|
|
36
|
+
phase: "done",
|
|
37
|
+
scanError: error instanceof AnalyzeError ? failOpenReason(error.code) : error instanceof Error ? error.message : "verdict scan failed"
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function failOpenReason(code) {
|
|
42
|
+
switch (code) {
|
|
43
|
+
case "auth":
|
|
44
|
+
return "sign in with dg login to see verdicts";
|
|
45
|
+
case "quota_exceeded":
|
|
46
|
+
return "scan quota reached — showing inventory only";
|
|
47
|
+
case "rate_limited":
|
|
48
|
+
return "rate limited — showing inventory only";
|
|
49
|
+
case "network":
|
|
50
|
+
case "timeout":
|
|
51
|
+
return "offline — showing inventory only";
|
|
52
|
+
default:
|
|
53
|
+
return "scanner unavailable — showing inventory only";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function createSbomStore(initial) {
|
|
2
|
+
let view = initial;
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
const emit = () => {
|
|
5
|
+
for (const listener of listeners) {
|
|
6
|
+
listener();
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
get: () => view,
|
|
11
|
+
set: (next) => {
|
|
12
|
+
view = next;
|
|
13
|
+
emit();
|
|
14
|
+
},
|
|
15
|
+
update: (patch) => {
|
|
16
|
+
view = { ...view, ...patch };
|
|
17
|
+
emit();
|
|
18
|
+
},
|
|
19
|
+
subscribe: (listener) => {
|
|
20
|
+
listeners.add(listener);
|
|
21
|
+
return () => {
|
|
22
|
+
listeners.delete(listener);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/scan/collect.js
CHANGED
|
@@ -12,6 +12,10 @@ export const LOCKFILE_ECOSYSTEMS = {
|
|
|
12
12
|
"uv.lock": "pypi",
|
|
13
13
|
"requirements.txt": "pypi"
|
|
14
14
|
};
|
|
15
|
+
export const SBOM_LOCKFILE_ECOSYSTEMS = {
|
|
16
|
+
...LOCKFILE_ECOSYSTEMS,
|
|
17
|
+
"Cargo.lock": "cargo"
|
|
18
|
+
};
|
|
15
19
|
export function isLockfileName(name) {
|
|
16
20
|
return Object.prototype.hasOwnProperty.call(LOCKFILE_ECOSYSTEMS, name);
|
|
17
21
|
}
|
|
@@ -30,16 +34,16 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
30
34
|
const MAX_DISCOVERY_DEPTH = 8;
|
|
31
35
|
function readDirents(directory) {
|
|
32
36
|
try {
|
|
33
|
-
return readdirSync(directory, { withFileTypes: true });
|
|
37
|
+
return readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
34
38
|
}
|
|
35
39
|
catch {
|
|
36
40
|
return [];
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
|
-
function lockfilesPerEcosystem(entries) {
|
|
43
|
+
function lockfilesPerEcosystem(entries, ecosystems) {
|
|
40
44
|
const matches = [];
|
|
41
45
|
const claimed = new Set();
|
|
42
|
-
for (const [lockfile, ecosystem] of Object.entries(
|
|
46
|
+
for (const [lockfile, ecosystem] of Object.entries(ecosystems)) {
|
|
43
47
|
if (claimed.has(ecosystem)) {
|
|
44
48
|
continue;
|
|
45
49
|
}
|
|
@@ -56,7 +60,7 @@ function shouldDescend(entry, directory, gitIgnored) {
|
|
|
56
60
|
!entry.name.startsWith(".") &&
|
|
57
61
|
!gitIgnored.has(join(directory, entry.name)));
|
|
58
62
|
}
|
|
59
|
-
export function discoverScanProjects(root) {
|
|
63
|
+
export function discoverScanProjects(root, ecosystems = LOCKFILE_ECOSYSTEMS) {
|
|
60
64
|
const projects = [];
|
|
61
65
|
const gitIgnored = gitIgnoredDirectories(root);
|
|
62
66
|
walk(root, 0);
|
|
@@ -66,7 +70,7 @@ export function discoverScanProjects(root) {
|
|
|
66
70
|
return;
|
|
67
71
|
}
|
|
68
72
|
const entries = readDirents(directory);
|
|
69
|
-
for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
|
|
73
|
+
for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries, ecosystems)) {
|
|
70
74
|
projects.push({
|
|
71
75
|
path: directory,
|
|
72
76
|
relativePath: relative(root, directory) || ".",
|
|
@@ -97,7 +101,7 @@ export async function discoverScanProjectsAsync(root, onProgress) {
|
|
|
97
101
|
lastYield = Date.now();
|
|
98
102
|
}
|
|
99
103
|
const entries = readDirents(directory);
|
|
100
|
-
for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries)) {
|
|
104
|
+
for (const [depFile, ecosystem] of lockfilesPerEcosystem(entries, LOCKFILE_ECOSYSTEMS)) {
|
|
101
105
|
const relativePath = relative(root, directory) || ".";
|
|
102
106
|
projects.push({
|
|
103
107
|
path: directory,
|
package/dist/scan/command.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { statSync
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { writeReportAtomic } from "../util/report-writer.js";
|
|
3
4
|
import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
4
5
|
import { scanProject } from "./discovery.js";
|
|
5
6
|
import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
|
|
@@ -10,7 +11,7 @@ import { runScannerScan } from "./scanner-report.js";
|
|
|
10
11
|
import { runStagedScan, stagedScanReport } from "./staged.js";
|
|
11
12
|
import { scanExitCode } from "../scan-ui/shims.js";
|
|
12
13
|
import { loadUserConfig } from "../config/settings.js";
|
|
13
|
-
import { EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
14
|
+
import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
14
15
|
export function runScanCommand(context) {
|
|
15
16
|
const parsed = parseScanArgs(context.args);
|
|
16
17
|
if ("error" in parsed) {
|
|
@@ -54,7 +55,7 @@ export function runScanCommand(context) {
|
|
|
54
55
|
}
|
|
55
56
|
catch (error) {
|
|
56
57
|
return {
|
|
57
|
-
exitCode:
|
|
58
|
+
exitCode: EXIT_ANALYSIS_INCOMPLETE,
|
|
58
59
|
stdout: "",
|
|
59
60
|
stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
|
|
60
61
|
};
|
|
@@ -69,26 +70,32 @@ export function runScanCommand(context) {
|
|
|
69
70
|
}
|
|
70
71
|
const skipNotice = skipNoticeFor(outcome, report);
|
|
71
72
|
const scannerUnavailable = !report.scanner && report.summary.projectCount > 0;
|
|
72
|
-
const
|
|
73
|
+
const nothingToScan = !parsed.staged &&
|
|
74
|
+
!report.scanner &&
|
|
75
|
+
!report.scannerError &&
|
|
76
|
+
report.summary.projectCount === 0 &&
|
|
77
|
+
report.summary.errorCount === 0;
|
|
78
|
+
const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report);
|
|
79
|
+
const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice, nothingToScan);
|
|
73
80
|
if (parsed.outputPath) {
|
|
74
81
|
try {
|
|
75
|
-
|
|
82
|
+
writeReportAtomic(resolve(parsed.outputPath), rendered);
|
|
76
83
|
}
|
|
77
84
|
catch (error) {
|
|
78
85
|
return {
|
|
79
|
-
exitCode:
|
|
86
|
+
exitCode: EXIT_ANALYSIS_INCOMPLETE,
|
|
80
87
|
stdout: "",
|
|
81
88
|
stderr: `dg scan could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
return {
|
|
85
|
-
exitCode
|
|
92
|
+
exitCode,
|
|
86
93
|
stdout: `Wrote ${parsed.format} scan report to ${parsed.outputPath}\n`,
|
|
87
94
|
stderr: ""
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
97
|
return {
|
|
91
|
-
exitCode
|
|
98
|
+
exitCode,
|
|
92
99
|
stdout: rendered,
|
|
93
100
|
stderr: ""
|
|
94
101
|
};
|
|
@@ -198,9 +205,9 @@ function skipNoticeFor(outcome, report) {
|
|
|
198
205
|
}
|
|
199
206
|
return "empty_lockfile";
|
|
200
207
|
}
|
|
201
|
-
function renderReport(report, format, scannerUnavailable, skipNotice) {
|
|
208
|
+
function renderReport(report, format, scannerUnavailable, skipNotice, nothingToScan) {
|
|
202
209
|
if (format === "json") {
|
|
203
|
-
return renderJsonReport(report, scannerUnavailable);
|
|
210
|
+
return renderJsonReport(report, scannerUnavailable, nothingToScan);
|
|
204
211
|
}
|
|
205
212
|
if (format === "sarif") {
|
|
206
213
|
return renderSarifReport(report);
|