@westbayberry/dg 2.3.2 → 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 +95 -208
- 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 +4 -2
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { componentRef } from "./cyclonedx.js";
|
|
4
|
+
export const SBOM_ROOT_REF = "root";
|
|
5
|
+
export function buildDependencyGraph(projects, componentRefs) {
|
|
6
|
+
const edges = new Map();
|
|
7
|
+
for (const project of projects) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = readFileSync(join(project.path, project.depFile), "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (project.depFile === "package-lock.json" || project.depFile === "npm-shrinkwrap.json") {
|
|
16
|
+
collectNpmEdges(text, componentRefs, edges);
|
|
17
|
+
}
|
|
18
|
+
else if (project.depFile === "Cargo.lock") {
|
|
19
|
+
collectCargoEdges(text, componentRefs, edges);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const dependencies = [];
|
|
23
|
+
for (const [ref, dependsOn] of edges) {
|
|
24
|
+
if (ref !== SBOM_ROOT_REF && !componentRefs.has(ref)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
dependencies.push({ ref, dependsOn: [...dependsOn].sort(compareCodePoints) });
|
|
28
|
+
}
|
|
29
|
+
return dependencies.sort((a, b) => compareCodePoints(a.ref, b.ref));
|
|
30
|
+
}
|
|
31
|
+
function record(edges, from, to) {
|
|
32
|
+
if (from === to) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const set = edges.get(from) ?? new Set();
|
|
36
|
+
set.add(to);
|
|
37
|
+
edges.set(from, set);
|
|
38
|
+
}
|
|
39
|
+
function ensureNode(edges, ref) {
|
|
40
|
+
if (!edges.has(ref)) {
|
|
41
|
+
edges.set(ref, new Set());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const NPM_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
|
45
|
+
function collectNpmEdges(text, componentRefs, edges) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const packages = parsed.packages;
|
|
57
|
+
for (const [path, entry] of Object.entries(packages)) {
|
|
58
|
+
if (!isRecord(entry) || entry.link === true) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (path.length > 0 && !path.includes("node_modules")) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const fromRef = path.length === 0 ? SBOM_ROOT_REF : npmEntryRef(path, entry, componentRefs);
|
|
65
|
+
if (fromRef === null) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
ensureNode(edges, fromRef);
|
|
69
|
+
for (const depName of npmDeclaredDeps(entry)) {
|
|
70
|
+
const targetPath = resolveNpmDep(packages, path, depName);
|
|
71
|
+
if (targetPath === null) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const targetEntry = packages[targetPath];
|
|
75
|
+
if (!isRecord(targetEntry)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const targetRef = npmEntryRef(targetPath, targetEntry, componentRefs);
|
|
79
|
+
if (targetRef !== null) {
|
|
80
|
+
record(edges, fromRef, targetRef);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function npmDeclaredDeps(entry) {
|
|
86
|
+
const names = new Set();
|
|
87
|
+
for (const section of NPM_DEPENDENCY_SECTIONS) {
|
|
88
|
+
const deps = entry[section];
|
|
89
|
+
if (isRecord(deps)) {
|
|
90
|
+
for (const name of Object.keys(deps)) {
|
|
91
|
+
names.add(name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...names];
|
|
96
|
+
}
|
|
97
|
+
function resolveNpmDep(packages, fromPath, depName) {
|
|
98
|
+
let scope = fromPath;
|
|
99
|
+
for (;;) {
|
|
100
|
+
const candidate = scope.length === 0 ? `node_modules/${depName}` : `${scope}/node_modules/${depName}`;
|
|
101
|
+
if (Object.prototype.hasOwnProperty.call(packages, candidate)) {
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
if (scope.length === 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const cut = scope.lastIndexOf("/node_modules/");
|
|
108
|
+
scope = cut === -1 ? "" : scope.slice(0, cut);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function npmEntryRef(path, entry, componentRefs) {
|
|
112
|
+
const declaredName = typeof entry.name === "string" ? entry.name : nameFromNodeModulesPath(path);
|
|
113
|
+
const alias = npmAliasVersion(typeof entry.version === "string" ? entry.version : null);
|
|
114
|
+
const name = alias?.name ?? declaredName;
|
|
115
|
+
const version = alias?.version ?? (typeof entry.version === "string" ? entry.version : null);
|
|
116
|
+
if (!name || !version) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const ref = componentRef({ ecosystem: "npm", name, version });
|
|
120
|
+
return componentRefs.has(ref) ? ref : null;
|
|
121
|
+
}
|
|
122
|
+
function nameFromNodeModulesPath(path) {
|
|
123
|
+
const marker = path.lastIndexOf("node_modules/");
|
|
124
|
+
if (marker === -1) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const tail = path.slice(marker + "node_modules/".length);
|
|
128
|
+
return tail.length > 0 ? tail : null;
|
|
129
|
+
}
|
|
130
|
+
function npmAliasVersion(version) {
|
|
131
|
+
if (!version || !version.startsWith("npm:")) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const spec = version.slice("npm:".length);
|
|
135
|
+
const at = spec.lastIndexOf("@");
|
|
136
|
+
if (at <= 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return { name: spec.slice(0, at), version: spec.slice(at + 1) || null };
|
|
140
|
+
}
|
|
141
|
+
function collectCargoEdges(text, componentRefs, edges) {
|
|
142
|
+
const byName = new Map();
|
|
143
|
+
const packages = [];
|
|
144
|
+
const rootDeps = [];
|
|
145
|
+
for (const block of cargoPackageBlocks(text)) {
|
|
146
|
+
const name = tomlValue(block, "name");
|
|
147
|
+
const version = tomlValue(block, "version");
|
|
148
|
+
if (!name || !version) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const source = tomlValue(block, "source");
|
|
152
|
+
const deps = tomlArray(block, "dependencies");
|
|
153
|
+
if (source === null) {
|
|
154
|
+
rootDeps.push(deps);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const ref = componentRef({ ecosystem: "cargo", name, version, resolvedUrl: source });
|
|
158
|
+
const list = byName.get(name) ?? [];
|
|
159
|
+
list.push({ version, ref });
|
|
160
|
+
byName.set(name, list);
|
|
161
|
+
packages.push({ ref, deps });
|
|
162
|
+
}
|
|
163
|
+
for (const pkg of packages) {
|
|
164
|
+
if (!componentRefs.has(pkg.ref)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
ensureNode(edges, pkg.ref);
|
|
168
|
+
for (const dep of pkg.deps) {
|
|
169
|
+
const targetRef = resolveCargoDep(byName, dep);
|
|
170
|
+
if (targetRef && componentRefs.has(targetRef)) {
|
|
171
|
+
record(edges, pkg.ref, targetRef);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (rootDeps.length === 1 && rootDeps[0]) {
|
|
176
|
+
ensureNode(edges, SBOM_ROOT_REF);
|
|
177
|
+
for (const dep of rootDeps[0]) {
|
|
178
|
+
const targetRef = resolveCargoDep(byName, dep);
|
|
179
|
+
if (targetRef && componentRefs.has(targetRef)) {
|
|
180
|
+
record(edges, SBOM_ROOT_REF, targetRef);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function resolveCargoDep(byName, dep) {
|
|
186
|
+
const parts = dep.trim().split(/\s+/u);
|
|
187
|
+
const name = parts[0];
|
|
188
|
+
if (!name) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const candidates = byName.get(name);
|
|
192
|
+
if (!candidates || candidates.length === 0) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const wantedVersion = parts[1];
|
|
196
|
+
if (wantedVersion) {
|
|
197
|
+
return candidates.find((candidate) => candidate.version === wantedVersion)?.ref ?? null;
|
|
198
|
+
}
|
|
199
|
+
return candidates.length === 1 && candidates[0] ? candidates[0].ref : null;
|
|
200
|
+
}
|
|
201
|
+
function cargoPackageBlocks(text) {
|
|
202
|
+
const blocks = [];
|
|
203
|
+
const segments = text.split(/^\s*\[\[package\]\]\s*$/mu);
|
|
204
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
205
|
+
const segment = segments[i];
|
|
206
|
+
if (segment === undefined) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const end = segment.search(/^\s*\[/mu);
|
|
210
|
+
blocks.push(end === -1 ? segment : segment.slice(0, end));
|
|
211
|
+
}
|
|
212
|
+
return blocks;
|
|
213
|
+
}
|
|
214
|
+
function tomlValue(block, key) {
|
|
215
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "mu").exec(block);
|
|
216
|
+
return match?.[1] ?? null;
|
|
217
|
+
}
|
|
218
|
+
function tomlArray(block, key) {
|
|
219
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "mu").exec(block);
|
|
220
|
+
if (!match || match[1] === undefined) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
const values = [];
|
|
224
|
+
for (const item of match[1].matchAll(/"([^"]*)"/gu)) {
|
|
225
|
+
if (item[1] !== undefined) {
|
|
226
|
+
values.push(item[1]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return values;
|
|
230
|
+
}
|
|
231
|
+
function compareCodePoints(a, b) {
|
|
232
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
233
|
+
}
|
|
234
|
+
function isRecord(value) {
|
|
235
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
236
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const CANONICAL_SPDX_IDS = [
|
|
2
|
+
"0BSD",
|
|
3
|
+
"AGPL-3.0-only",
|
|
4
|
+
"AGPL-3.0-or-later",
|
|
5
|
+
"Apache-1.1",
|
|
6
|
+
"Apache-2.0",
|
|
7
|
+
"Artistic-2.0",
|
|
8
|
+
"Beerware",
|
|
9
|
+
"BlueOak-1.0.0",
|
|
10
|
+
"BSD-2-Clause",
|
|
11
|
+
"BSD-3-Clause",
|
|
12
|
+
"BSD-3-Clause-Clear",
|
|
13
|
+
"BSL-1.0",
|
|
14
|
+
"CC-BY-4.0",
|
|
15
|
+
"CC-BY-SA-4.0",
|
|
16
|
+
"CC0-1.0",
|
|
17
|
+
"CDDL-1.0",
|
|
18
|
+
"CDDL-1.1",
|
|
19
|
+
"EPL-1.0",
|
|
20
|
+
"EPL-2.0",
|
|
21
|
+
"EUPL-1.1",
|
|
22
|
+
"EUPL-1.2",
|
|
23
|
+
"GPL-2.0-only",
|
|
24
|
+
"GPL-2.0-or-later",
|
|
25
|
+
"GPL-3.0-only",
|
|
26
|
+
"GPL-3.0-or-later",
|
|
27
|
+
"ISC",
|
|
28
|
+
"LGPL-2.0-only",
|
|
29
|
+
"LGPL-2.0-or-later",
|
|
30
|
+
"LGPL-2.1-only",
|
|
31
|
+
"LGPL-2.1-or-later",
|
|
32
|
+
"LGPL-3.0-only",
|
|
33
|
+
"LGPL-3.0-or-later",
|
|
34
|
+
"MIT",
|
|
35
|
+
"MIT-0",
|
|
36
|
+
"MPL-1.1",
|
|
37
|
+
"MPL-2.0",
|
|
38
|
+
"MS-PL",
|
|
39
|
+
"MS-RL",
|
|
40
|
+
"NCSA",
|
|
41
|
+
"OFL-1.1",
|
|
42
|
+
"PostgreSQL",
|
|
43
|
+
"Python-2.0",
|
|
44
|
+
"Ruby",
|
|
45
|
+
"Unicode-DFS-2016",
|
|
46
|
+
"Unlicense",
|
|
47
|
+
"WTFPL",
|
|
48
|
+
"Zlib"
|
|
49
|
+
];
|
|
50
|
+
const BY_LOWERCASE = new Map(CANONICAL_SPDX_IDS.map((id) => [id.toLowerCase(), id]));
|
|
51
|
+
export function canonicalSpdxId(declared) {
|
|
52
|
+
return BY_LOWERCASE.get(declared.trim().toLowerCase()) ?? null;
|
|
53
|
+
}
|
package/dist/sbom-ui/SbomApp.js
CHANGED
|
@@ -16,8 +16,9 @@ const HEADER_LINES = 9;
|
|
|
16
16
|
const FOOTER_LINES = 2;
|
|
17
17
|
const COUNTER_LINE = 1;
|
|
18
18
|
const SAFETY_LINE = 1;
|
|
19
|
+
const RULE_LINES = 2;
|
|
19
20
|
const FILTER_CYCLE = ["all", "risky", "unlicensed"];
|
|
20
|
-
const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed"
|
|
21
|
+
const FILTER_LABEL = { all: "all", risky: "risky", unlicensed: "unlicensed" };
|
|
21
22
|
function ecosystemCounts(rows) {
|
|
22
23
|
const counts = new Map();
|
|
23
24
|
for (const row of rows) {
|
|
@@ -150,9 +151,10 @@ export const SbomApp = ({ store, document, cwd }) => {
|
|
|
150
151
|
openExport();
|
|
151
152
|
}
|
|
152
153
|
});
|
|
153
|
-
const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE);
|
|
154
|
+
const listHeight = Math.max(3, termRows - HEADER_LINES - FOOTER_LINES - COUNTER_LINE - SAFETY_LINE - RULE_LINES);
|
|
155
|
+
const rule = chalk.dim("─".repeat(Math.max(20, termCols - 4)));
|
|
154
156
|
if (exportDialog) {
|
|
155
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(ExportDialog, { options: exportDialog, theme: theme, cwd: cwd, onDone: handleExportDone })] }));
|
|
156
158
|
}
|
|
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")] }))] })] }));
|
|
159
|
+
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(Text, { dimColor: true, children: rule }), _jsx(SbomList, { rows: visible, selected: clamped, height: listHeight, width: termCols, emptyMessage: emptyFilterMessage(filter, query, view.phase, tally, view.scanError) }), _jsx(Text, { dimColor: true, children: rule }), _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
160
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
2
|
+
import { csvCell } from "../security/csv.js";
|
|
1
3
|
const ROW_ECOSYSTEMS = { npm: "npm", pypi: "pypi", cargo: "cargo" };
|
|
2
4
|
export function rowKey(ecosystem, name, version) {
|
|
3
5
|
return `${ecosystem}:${name.toLowerCase()}@${version}`;
|
|
@@ -6,12 +8,15 @@ export function buildSbomRows(components) {
|
|
|
6
8
|
return components
|
|
7
9
|
.map((component) => {
|
|
8
10
|
const ecosystem = ROW_ECOSYSTEMS[component.ecosystem] ?? "other";
|
|
11
|
+
// Lockfile-declared name/version/license are attacker-controlled and reach
|
|
12
|
+
// the terminal (TUI rows, summary) + exports; strip control bytes here so
|
|
13
|
+
// every downstream renderer is safe without re-remembering to.
|
|
9
14
|
return {
|
|
10
15
|
key: rowKey(ecosystem, component.name, component.version),
|
|
11
|
-
name: component.name,
|
|
12
|
-
version: component.version,
|
|
16
|
+
name: sanitizeLine(component.name),
|
|
17
|
+
version: sanitizeLine(component.version),
|
|
13
18
|
ecosystem,
|
|
14
|
-
license: component.license
|
|
19
|
+
license: component.license != null ? sanitizeLine(component.license) : null,
|
|
15
20
|
hasHash: component.integrity != null && component.integrity.length > 0,
|
|
16
21
|
scannable: ecosystem === "npm" || ecosystem === "pypi"
|
|
17
22
|
};
|
|
@@ -94,13 +99,6 @@ export function emptyFilterMessage(filter, query, phase, tally, scanError) {
|
|
|
94
99
|
}
|
|
95
100
|
return "no components";
|
|
96
101
|
}
|
|
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
102
|
export function componentsCsv(rows) {
|
|
105
103
|
const lines = ["name,version,ecosystem,license,verdict"];
|
|
106
104
|
for (const row of rows) {
|
|
@@ -20,7 +20,9 @@ async function main() {
|
|
|
20
20
|
}
|
|
21
21
|
responses.push(await analyzePackages(group.packages, {
|
|
22
22
|
ecosystem: group.ecosystem,
|
|
23
|
-
|
|
23
|
+
publicFallback: true,
|
|
24
|
+
...(payload.scanId ? { scanId: payload.scanId } : {}),
|
|
25
|
+
...(payload.installTreeHash ? { installTreeHash: payload.installTreeHash } : {})
|
|
24
26
|
}));
|
|
25
27
|
}
|
|
26
28
|
process.stdout.write(JSON.stringify(mergeAnalyzeResponses(responses)));
|
package/dist/scan/collect.js
CHANGED
|
@@ -29,9 +29,15 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
29
29
|
".venv",
|
|
30
30
|
"venv",
|
|
31
31
|
"target",
|
|
32
|
-
"coverage"
|
|
32
|
+
"coverage",
|
|
33
|
+
".cache",
|
|
34
|
+
".pytest_cache",
|
|
35
|
+
".mypy_cache",
|
|
36
|
+
".ruff_cache",
|
|
37
|
+
".idea",
|
|
38
|
+
".vscode"
|
|
33
39
|
]);
|
|
34
|
-
const MAX_DISCOVERY_DEPTH =
|
|
40
|
+
const MAX_DISCOVERY_DEPTH = 12;
|
|
35
41
|
function readDirents(directory) {
|
|
36
42
|
try {
|
|
37
43
|
return readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
@@ -40,16 +46,14 @@ function readDirents(directory) {
|
|
|
40
46
|
return [];
|
|
41
47
|
}
|
|
42
48
|
}
|
|
49
|
+
// Every lockfile in a directory is scanned, not just the first per ecosystem:
|
|
50
|
+
// side-by-side lockfiles can resolve to different trees, and the collector dedups
|
|
51
|
+
// by name@version, so a package unique to a non-primary lockfile is not dropped.
|
|
43
52
|
function lockfilesPerEcosystem(entries, ecosystems) {
|
|
44
53
|
const matches = [];
|
|
45
|
-
const claimed = new Set();
|
|
46
54
|
for (const [lockfile, ecosystem] of Object.entries(ecosystems)) {
|
|
47
|
-
if (claimed.has(ecosystem)) {
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
55
|
if (entries.some((entry) => entry.name === lockfile && entry.isFile())) {
|
|
51
56
|
matches.push([lockfile, ecosystem]);
|
|
52
|
-
claimed.add(ecosystem);
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
return matches;
|
|
@@ -127,8 +131,31 @@ function yieldToEventLoop() {
|
|
|
127
131
|
function countLockfilePackages(lockfilePath) {
|
|
128
132
|
return parseLockfilePackages(lockfilePath).packages.length;
|
|
129
133
|
}
|
|
134
|
+
export function mergeInstalledPackages(collected, installed) {
|
|
135
|
+
const byEcosystem = new Map([...collected.byEcosystem].map(([eco, list]) => [eco, [...list]]));
|
|
136
|
+
const origins = new Map(collected.origins);
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const [eco, list] of byEcosystem) {
|
|
139
|
+
for (const pkg of list)
|
|
140
|
+
seen.add(`${eco}:${pkg.name}@${pkg.version}`);
|
|
141
|
+
}
|
|
142
|
+
for (const [eco, list] of installed.byEcosystem) {
|
|
143
|
+
for (const pkg of list) {
|
|
144
|
+
const key = `${eco}:${pkg.name}@${pkg.version}`;
|
|
145
|
+
if (seen.has(key))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(key);
|
|
148
|
+
const arr = byEcosystem.get(eco) ?? [];
|
|
149
|
+
arr.push(pkg);
|
|
150
|
+
byEcosystem.set(eco, arr);
|
|
151
|
+
origins.set(key, installed.origins.get(key) ?? "installed");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { ...collected, byEcosystem, origins };
|
|
155
|
+
}
|
|
130
156
|
export function collectScanPackages(projects) {
|
|
131
157
|
const byEcosystem = new Map();
|
|
158
|
+
const origins = new Map();
|
|
132
159
|
const seen = new Set();
|
|
133
160
|
const skippedPackages = [];
|
|
134
161
|
const parseErrors = [];
|
|
@@ -149,10 +176,12 @@ export function collectScanPackages(projects) {
|
|
|
149
176
|
}
|
|
150
177
|
for (const identity of parsed.packages) {
|
|
151
178
|
if (identity.ecosystem !== "npm" && identity.ecosystem !== "pypi") {
|
|
179
|
+
skippedPackages.push({ name: identity.name, reason: "unsupported-ecosystem", location: `${depFilePath}: ${identity.requested}` });
|
|
152
180
|
skipped += 1;
|
|
153
181
|
continue;
|
|
154
182
|
}
|
|
155
183
|
if (!identity.version) {
|
|
184
|
+
skippedPackages.push({ name: identity.name, reason: "unpinned", location: `${depFilePath}: ${identity.requested}` });
|
|
156
185
|
skipped += 1;
|
|
157
186
|
continue;
|
|
158
187
|
}
|
|
@@ -164,7 +193,8 @@ export function collectScanPackages(projects) {
|
|
|
164
193
|
const list = byEcosystem.get(identity.ecosystem) ?? [];
|
|
165
194
|
list.push({ name: identity.name, version: identity.version });
|
|
166
195
|
byEcosystem.set(identity.ecosystem, list);
|
|
196
|
+
origins.set(`${identity.ecosystem}:${identity.name}@${identity.version}`, depFilePath);
|
|
167
197
|
}
|
|
168
198
|
}
|
|
169
|
-
return { byEcosystem, skipped, skippedPackages, parseErrors };
|
|
199
|
+
return { byEcosystem, origins, skipped, skippedPackages, parseErrors };
|
|
170
200
|
}
|
package/dist/scan/command.js
CHANGED
|
@@ -2,21 +2,39 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { writeReportAtomic } from "../util/report-writer.js";
|
|
4
4
|
import { findProjectRoot, loadDgFile, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
5
|
+
import { gateProjectDecisions } from "../project/override-trust.js";
|
|
5
6
|
import { scanProject } from "./discovery.js";
|
|
6
|
-
import { renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
|
|
7
|
+
import { applyLocalFindingEscalation, renderJsonReport, renderSarifReport, renderTextReport } from "./render.js";
|
|
7
8
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
8
9
|
import { createTheme } from "../presentation/theme.js";
|
|
9
10
|
import { launchScanTui, shouldLaunchScanTui } from "../scan-ui/launch.js";
|
|
10
11
|
import { runScannerScan } from "./scanner-report.js";
|
|
12
|
+
import { detectUnlockedManifests, unlockedManifestNotice } from "./manifest-coverage.js";
|
|
11
13
|
import { runStagedScan, stagedScanReport } from "./staged.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
14
|
+
import { verdictExitCode } from "../scan-ui/shims.js";
|
|
15
|
+
import { authStatus } from "../auth/store.js";
|
|
16
|
+
import { EXIT_ANALYSIS_INCOMPLETE, EXIT_NOTHING_TO_SCAN, EXIT_TOOL_ERROR, EXIT_USAGE_VERDICT } from "../commands/types.js";
|
|
15
17
|
export function runScanCommand(context) {
|
|
16
18
|
const parsed = parseScanArgs(context.args);
|
|
17
19
|
if ("error" in parsed) {
|
|
18
20
|
return usageError(parsed.error);
|
|
19
21
|
}
|
|
22
|
+
// The interactive project scan is the product and requires sign-in. The
|
|
23
|
+
// staged path (dg commit-guard) is automated protection like the install
|
|
24
|
+
// firewall, so it stays account-free and resolves verdicts through the public
|
|
25
|
+
// endpoint when signed out.
|
|
26
|
+
if (!parsed.staged && !authStatus(process.env).authenticated) {
|
|
27
|
+
// The install-origin metering scan runs detached after an install; with no
|
|
28
|
+
// signed-in key there is nothing to bill, so exit quietly instead of nagging.
|
|
29
|
+
if (parsed.installOrigin) {
|
|
30
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
exitCode: EXIT_USAGE_VERDICT,
|
|
34
|
+
stdout: "",
|
|
35
|
+
stderr: "Sign in to run dg scan. Run `dg login`.\n"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
20
38
|
const stagedTarget = parsed.sawTarget ? parsed.targetPath : null;
|
|
21
39
|
const machineOutput = parsed.format !== "text" || parsed.outputPath !== null;
|
|
22
40
|
if (parsed.staged && !machineOutput) {
|
|
@@ -33,12 +51,13 @@ export function runScanCommand(context) {
|
|
|
33
51
|
outcome = staged.outcome;
|
|
34
52
|
}
|
|
35
53
|
else {
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
if (!parsed.installOrigin &&
|
|
55
|
+
shouldLaunchScanTui({
|
|
56
|
+
targetPath: parsed.targetPath,
|
|
57
|
+
format: parsed.format,
|
|
58
|
+
outputPath: parsed.outputPath ?? undefined
|
|
59
|
+
})) {
|
|
60
|
+
void launchScanTui("results", parsed.failOn).catch((error) => {
|
|
42
61
|
process.stderr.write(`dg scan TUI failed: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
43
62
|
process.exitCode = 1;
|
|
44
63
|
});
|
|
@@ -60,7 +79,7 @@ export function runScanCommand(context) {
|
|
|
60
79
|
stderr: `dg scan failed: ${error instanceof Error ? error.message : "unknown scan error"}\n`
|
|
61
80
|
};
|
|
62
81
|
}
|
|
63
|
-
outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath));
|
|
82
|
+
outcome = runScannerScan(parsed.targetPath, report, process.env, parsed.noDecisions ? null : loadScanDecisions(parsed.targetPath), parsed.installOrigin);
|
|
64
83
|
}
|
|
65
84
|
if (outcome.kind === "report") {
|
|
66
85
|
report = outcome.report;
|
|
@@ -75,15 +94,23 @@ export function runScanCommand(context) {
|
|
|
75
94
|
!report.scannerError &&
|
|
76
95
|
report.summary.projectCount === 0 &&
|
|
77
96
|
report.summary.errorCount === 0;
|
|
78
|
-
const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report);
|
|
97
|
+
const exitCode = nothingToScan ? EXIT_NOTHING_TO_SCAN : exitCodeForReport(report, parsed.failOn);
|
|
98
|
+
// With no server verdict, name any dependency manifest that lacks a lockfile so
|
|
99
|
+
// the run is not a silent "nothing to scan".
|
|
100
|
+
const manifestNotice = !report.scanner && !report.scannerError
|
|
101
|
+
? unlockedManifestNotice(detectUnlockedManifests(resolve(parsed.targetPath)))
|
|
102
|
+
: null;
|
|
79
103
|
const rendered = renderReport(report, parsed.format, scannerUnavailable, skipNotice, nothingToScan);
|
|
80
104
|
if (parsed.outputPath) {
|
|
81
105
|
try {
|
|
82
106
|
writeReportAtomic(resolve(parsed.outputPath), rendered);
|
|
83
107
|
}
|
|
84
108
|
catch (error) {
|
|
109
|
+
// A write failure is a tool error, not an incomplete analysis — match dg
|
|
110
|
+
// sbom/audit (EXIT_TOOL_ERROR) so a consumer can tell "couldn't produce the
|
|
111
|
+
// report" apart from "couldn't reach the scanner".
|
|
85
112
|
return {
|
|
86
|
-
exitCode:
|
|
113
|
+
exitCode: EXIT_TOOL_ERROR,
|
|
87
114
|
stdout: "",
|
|
88
115
|
stderr: `dg scan could not write ${parsed.outputPath}: ${error instanceof Error ? error.message : "unknown write error"}\n`
|
|
89
116
|
};
|
|
@@ -91,13 +118,15 @@ export function runScanCommand(context) {
|
|
|
91
118
|
return {
|
|
92
119
|
exitCode,
|
|
93
120
|
stdout: `Wrote ${parsed.format} scan report to ${parsed.outputPath}\n`,
|
|
94
|
-
|
|
121
|
+
// Surface a degraded scan (e.g. a rejected key) even when writing to a file,
|
|
122
|
+
// so the failure isn't silent in --json/--sarif mode.
|
|
123
|
+
stderr: report.scannerError ? `dg scan: server scan failed: ${report.scannerError.message}\n` : ""
|
|
95
124
|
};
|
|
96
125
|
}
|
|
97
126
|
return {
|
|
98
127
|
exitCode,
|
|
99
128
|
stdout: rendered,
|
|
100
|
-
stderr: ""
|
|
129
|
+
stderr: manifestNotice ? `${manifestNotice}\n` : ""
|
|
101
130
|
};
|
|
102
131
|
}
|
|
103
132
|
function parseScanArgs(args) {
|
|
@@ -108,6 +137,8 @@ function parseScanArgs(args) {
|
|
|
108
137
|
let staged = false;
|
|
109
138
|
let hook = false;
|
|
110
139
|
let noDecisions = false;
|
|
140
|
+
let failOn = "block";
|
|
141
|
+
let installOrigin = false;
|
|
111
142
|
for (let index = 0; index < args.length; index += 1) {
|
|
112
143
|
const arg = args[index];
|
|
113
144
|
if (!arg) {
|
|
@@ -117,6 +148,10 @@ function parseScanArgs(args) {
|
|
|
117
148
|
staged = true;
|
|
118
149
|
continue;
|
|
119
150
|
}
|
|
151
|
+
if (arg === "--install-origin") {
|
|
152
|
+
installOrigin = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
120
155
|
if (arg === "--hook") {
|
|
121
156
|
hook = true;
|
|
122
157
|
continue;
|
|
@@ -148,6 +183,14 @@ function parseScanArgs(args) {
|
|
|
148
183
|
index += 1;
|
|
149
184
|
continue;
|
|
150
185
|
}
|
|
186
|
+
if (arg === "--fail-on" || arg.startsWith("--fail-on=")) {
|
|
187
|
+
const value = arg === "--fail-on" ? args[(index += 1)] : arg.slice("--fail-on=".length);
|
|
188
|
+
if (value !== "block" && value !== "warn" && value !== "never") {
|
|
189
|
+
return { error: "--fail-on requires 'block', 'warn', or 'never'" };
|
|
190
|
+
}
|
|
191
|
+
failOn = value;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
151
194
|
if (arg.startsWith("-")) {
|
|
152
195
|
return { error: `unknown option '${arg}'` };
|
|
153
196
|
}
|
|
@@ -164,14 +207,16 @@ function parseScanArgs(args) {
|
|
|
164
207
|
sawTarget,
|
|
165
208
|
staged,
|
|
166
209
|
hook,
|
|
167
|
-
noDecisions
|
|
210
|
+
noDecisions,
|
|
211
|
+
failOn,
|
|
212
|
+
installOrigin
|
|
168
213
|
};
|
|
169
214
|
}
|
|
170
215
|
function usageError(message) {
|
|
171
216
|
return {
|
|
172
217
|
exitCode: EXIT_USAGE_VERDICT,
|
|
173
218
|
stdout: "",
|
|
174
|
-
stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--no-decisions]\n`
|
|
219
|
+
stderr: `dg scan: ${message}. Usage: dg scan [path] [--json|--sarif] [--output <path>] [--fail-on <block|warn|never>] [--no-decisions]\n`
|
|
175
220
|
};
|
|
176
221
|
}
|
|
177
222
|
function loadScanDecisions(targetPath, env = process.env) {
|
|
@@ -190,7 +235,9 @@ function loadScanDecisions(targetPath, env = process.env) {
|
|
|
190
235
|
}
|
|
191
236
|
const file = loadDgFile(root);
|
|
192
237
|
warnUnreadableDgFile(file);
|
|
193
|
-
|
|
238
|
+
// Gate decisions/exemptions through the trust check so a cloned hostile repo's
|
|
239
|
+
// dg.json cannot suppress a warn it did not author on this machine.
|
|
240
|
+
return file.readable ? gateProjectDecisions(file, root, env) : null;
|
|
194
241
|
}
|
|
195
242
|
function degradeReport(report, error) {
|
|
196
243
|
const status = report.status === "block" || report.status === "warn" ? report.status : "unknown";
|
|
@@ -214,20 +261,23 @@ function renderReport(report, format, scannerUnavailable, skipNotice, nothingToS
|
|
|
214
261
|
}
|
|
215
262
|
return renderTextReport(report, undefined, createTheme(resolvePresentation().color), skipNotice);
|
|
216
263
|
}
|
|
217
|
-
function exitCodeForReport(report) {
|
|
264
|
+
function exitCodeForReport(report, failOn) {
|
|
218
265
|
if (report.scanner) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
return scanExitCode(action, mode);
|
|
266
|
+
const action = report.decisions?.effectiveAction ?? report.scanner.action;
|
|
267
|
+
return verdictExitCode(applyLocalFindingEscalation(action, report.findings), failOn);
|
|
222
268
|
}
|
|
223
|
-
if (report.status === "block") {
|
|
224
|
-
return
|
|
269
|
+
if (report.status === "block" || report.status === "warn") {
|
|
270
|
+
return verdictExitCode(report.status, failOn);
|
|
225
271
|
}
|
|
226
|
-
|
|
227
|
-
|
|
272
|
+
// A 401 is a permanent configuration error (a rejected/expired key), not a
|
|
273
|
+
// transient outage — surface it as a usage error so CI fails closed rather than
|
|
274
|
+
// passing green the way analysis-incomplete (4) does under a fail-open policy. A
|
|
275
|
+
// 403 can be edge/proxy/WAF-injected and transient, so leave it to the 4 path.
|
|
276
|
+
if (report.scannerError?.kind === "auth" && report.scannerError.statusCode === 401) {
|
|
277
|
+
return EXIT_USAGE_VERDICT;
|
|
228
278
|
}
|
|
229
279
|
if (report.status === "error" || report.status === "unknown") {
|
|
230
280
|
return 4;
|
|
231
281
|
}
|
|
232
|
-
return
|
|
282
|
+
return verdictExitCode(report.status, failOn);
|
|
233
283
|
}
|