@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
package/dist/agents/routing.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { buildAgentRoutingEnv } from "../launcher/env.js";
|
|
4
4
|
import { readServiceState } from "../service/state.js";
|
|
5
5
|
import { resolveDgPaths } from "../state/index.js";
|
|
6
|
-
import { readSettings, writeSettingsAtomic } from "./persistence.js";
|
|
6
|
+
import { readSettings, writeSettingsAtomic, writeFileAtomicSafe } from "./persistence.js";
|
|
7
7
|
import { getAgent } from "./registry.js";
|
|
8
8
|
// The agent's install hook screens statically; this routes the agent's ACTUAL
|
|
9
9
|
// fetches through dg's proxy so a wrapped/dynamic install (eval, $VAR, python -m
|
|
@@ -29,30 +29,47 @@ function stripCodexBlock(content) {
|
|
|
29
29
|
const block = new RegExp(`\\n?${escapeRegex(CODEX_BEGIN)}[\\s\\S]*?${escapeRegex(CODEX_END)}\\n?`, "g");
|
|
30
30
|
return content.replace(block, "\n").replace(/\n{3,}/g, "\n\n");
|
|
31
31
|
}
|
|
32
|
+
function loadBackupPrior(backup) {
|
|
33
|
+
if (!existsSync(backup)) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
32
43
|
function applyClaudeRouting(settingsPath, routing, backup) {
|
|
33
44
|
const { settings } = readSettings(settingsPath);
|
|
34
45
|
const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
|
|
35
|
-
|
|
46
|
+
// The backup must hold the user's PRE-dg values so removal can restore them. On
|
|
47
|
+
// a re-apply the `k in prior` guard preserves the snapshot captured on first
|
|
48
|
+
// apply, so dg's own values are never recorded as the user's prior. We only
|
|
49
|
+
// collapse a value that exactly equals what dg is writing now — never one that
|
|
50
|
+
// merely *looks* like dg's (a loopback host), which would silently delete the
|
|
51
|
+
// user's own local proxy / CA on `dg agents off`.
|
|
52
|
+
const prior = loadBackupPrior(backup);
|
|
36
53
|
for (const [k, v] of Object.entries(routing)) {
|
|
37
|
-
|
|
54
|
+
if (!(k in prior)) {
|
|
55
|
+
const current = k in envObj ? String(envObj[k]) : null;
|
|
56
|
+
prior[k] = current === v ? null : current;
|
|
57
|
+
}
|
|
38
58
|
envObj[k] = v;
|
|
39
59
|
}
|
|
40
|
-
settings
|
|
41
|
-
|
|
60
|
+
// Write the restore backup BEFORE mutating the agent settings: an IO failure on
|
|
61
|
+
// the settings write then leaves a recoverable state, never settings routed
|
|
62
|
+
// through dg with no backup to undo them.
|
|
42
63
|
mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
|
|
43
64
|
writeFileSync(backup, `${JSON.stringify({ kind: "claude-env", path: settingsPath, prior }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
65
|
+
settings.env = envObj;
|
|
66
|
+
writeSettingsAtomic(settingsPath, settings);
|
|
44
67
|
}
|
|
45
68
|
function removeClaudeRouting(settingsPath, backup) {
|
|
46
69
|
if (!existsSync(backup)) {
|
|
47
70
|
return;
|
|
48
71
|
}
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
prior = JSON.parse(readFileSync(backup, "utf8")).prior ?? {};
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
prior = {};
|
|
55
|
-
}
|
|
72
|
+
const prior = loadBackupPrior(backup);
|
|
56
73
|
if (existsSync(settingsPath)) {
|
|
57
74
|
const { settings } = readSettings(settingsPath);
|
|
58
75
|
const envObj = typeof settings.env === "object" && settings.env !== null ? { ...settings.env } : {};
|
|
@@ -82,14 +99,14 @@ function applyCodexRouting(configPath, routing) {
|
|
|
82
99
|
const set = Object.entries(routing).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ");
|
|
83
100
|
const block = `${CODEX_BEGIN}\n[shell_environment_policy]\nset = { ${set} }\n${CODEX_END}\n`;
|
|
84
101
|
const next = content === "" || content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
|
|
85
|
-
|
|
102
|
+
writeFileAtomicSafe(configPath, next);
|
|
86
103
|
return { applied: true, detail: configPath };
|
|
87
104
|
}
|
|
88
105
|
function removeCodexRouting(configPath) {
|
|
89
106
|
if (!existsSync(configPath)) {
|
|
90
107
|
return;
|
|
91
108
|
}
|
|
92
|
-
|
|
109
|
+
writeFileAtomicSafe(configPath, stripCodexBlock(readFileSync(configPath, "utf8")));
|
|
93
110
|
}
|
|
94
111
|
function codexConfigPath(home) {
|
|
95
112
|
return join(home, ".codex", "config.toml");
|
package/dist/api/analyze.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
2
|
import { readAuthStateOrWarn } from "../auth/store.js";
|
|
5
3
|
import { envAuthToken } from "../auth/env-token.js";
|
|
6
|
-
import { loadUserConfig } from "../config/settings.js";
|
|
4
|
+
import { assertVendorApiBaseUrl, loadUserConfig } from "../config/settings.js";
|
|
7
5
|
import { sanitize, sanitizeResponse } from "../security/sanitize.js";
|
|
8
|
-
import { resolveDgPaths } from "../state/index.js";
|
|
9
6
|
import { dgVersion } from "../commands/version.js";
|
|
10
7
|
export class AnalyzeError extends Error {
|
|
11
8
|
statusCode;
|
|
@@ -95,16 +92,19 @@ const DEFAULT_TIMEOUT_MS = 180_000;
|
|
|
95
92
|
const BATCH_CONCURRENCY = Math.max(1, Number(process.env.DG_ANALYZE_CONCURRENCY) || 4);
|
|
96
93
|
export async function analyzePackages(packages, options) {
|
|
97
94
|
const env = options.env ?? process.env;
|
|
95
|
+
if (options.bill === false || (options.publicFallback && !resolveToken(env))) {
|
|
96
|
+
return analyzePublicVerdicts(packages, options, env);
|
|
97
|
+
}
|
|
98
98
|
const baseUrl = resolveApiBaseUrl(env);
|
|
99
99
|
const context = {
|
|
100
100
|
url: `${baseUrl}${ANALYZE_PATHS[options.ecosystem]}`,
|
|
101
101
|
token: resolveToken(env),
|
|
102
|
-
deviceId: getOrCreateDeviceId(env),
|
|
103
102
|
scanId: options.scanId ?? randomUUID(),
|
|
104
103
|
fetchImpl: options.fetchImpl ?? fetch,
|
|
105
104
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
106
105
|
...(options.signal ? { signal: options.signal } : {}),
|
|
107
|
-
...(options.cooldown ? { cooldown: options.cooldown } : {})
|
|
106
|
+
...(options.cooldown ? { cooldown: options.cooldown } : {}),
|
|
107
|
+
...(options.installTreeHash ? { installTreeHash: options.installTreeHash } : {})
|
|
108
108
|
};
|
|
109
109
|
const total = packages.length;
|
|
110
110
|
const batches = [];
|
|
@@ -138,7 +138,107 @@ export async function analyzePackages(packages, options) {
|
|
|
138
138
|
};
|
|
139
139
|
const workers = Math.min(BATCH_CONCURRENCY, Math.max(1, batches.length));
|
|
140
140
|
await Promise.all(Array.from({ length: workers }, () => runWorker()));
|
|
141
|
-
|
|
141
|
+
const merged = mergeAnalyzeResponses(responses);
|
|
142
|
+
// Tag every result with the ecosystem this call resolved, so consumers can tell
|
|
143
|
+
// a name@version apart across ecosystems (the authed batch response omits it).
|
|
144
|
+
return {
|
|
145
|
+
...merged,
|
|
146
|
+
packages: merged.packages.map((pkg) => (pkg.ecosystem ? pkg : { ...pkg, ecosystem: options.ecosystem }))
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const PUBLIC_VERDICT_PATH = "/v1/install-verdict";
|
|
150
|
+
// Account-free per-package verdict path for the install-time firewall when no
|
|
151
|
+
// API token is present. The authed batch endpoints require a Bearer key, but
|
|
152
|
+
// the firewall must keep blocking known-malicious packages for signed-out
|
|
153
|
+
// users, so each package is resolved through the public install-verdict
|
|
154
|
+
// endpoint (the same verdict the package pages show) and reshaped into the
|
|
155
|
+
// AnalyzeResponse the firewall callers already consume.
|
|
156
|
+
async function analyzePublicVerdicts(packages, options, env) {
|
|
157
|
+
const url = `${resolveApiBaseUrl(env)}${PUBLIC_VERDICT_PATH}`;
|
|
158
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
159
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
160
|
+
const total = packages.length;
|
|
161
|
+
options.onProgress?.({ done: 0, total, batchIndex: 0, batchCount: 1 });
|
|
162
|
+
const results = new Array(packages.length);
|
|
163
|
+
let done = 0;
|
|
164
|
+
let cursor = 0;
|
|
165
|
+
const runWorker = async () => {
|
|
166
|
+
for (let i = cursor++; i < packages.length; i = cursor++) {
|
|
167
|
+
const entry = packages[i];
|
|
168
|
+
if (!entry)
|
|
169
|
+
continue;
|
|
170
|
+
results[i] = await fetchPublicVerdict(url, options.ecosystem, entry, fetchImpl, timeoutMs, options);
|
|
171
|
+
done += 1;
|
|
172
|
+
options.onProgress?.({ done, total, batchIndex: 0, batchCount: 1 });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const workers = Math.min(BATCH_CONCURRENCY, Math.max(1, packages.length));
|
|
176
|
+
await Promise.all(Array.from({ length: workers }, () => runWorker()));
|
|
177
|
+
const rank = { pass: 0, analysis_incomplete: 1, warn: 2, block: 3 };
|
|
178
|
+
let action = "pass";
|
|
179
|
+
for (const pkg of results) {
|
|
180
|
+
const a = pkg.action ?? "analysis_incomplete";
|
|
181
|
+
if (rank[a] > rank[action])
|
|
182
|
+
action = a;
|
|
183
|
+
}
|
|
184
|
+
return { score: 0, action, packages: results, durationMs: 0 };
|
|
185
|
+
}
|
|
186
|
+
async function fetchPublicVerdict(url, ecosystem, entry, fetchImpl, timeoutMs, options) {
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
189
|
+
const onAbort = () => controller.abort();
|
|
190
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
191
|
+
if (options.signal?.aborted) {
|
|
192
|
+
controller.abort();
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetchImpl(url, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
"X-Dg-Version": dgVersion()
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
ecosystem,
|
|
203
|
+
name: entry.name,
|
|
204
|
+
version: entry.version,
|
|
205
|
+
...(options.cooldown ? { cooldown: options.cooldown } : {})
|
|
206
|
+
}),
|
|
207
|
+
signal: controller.signal
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
const body = await response.json().catch(() => undefined);
|
|
211
|
+
throw analyzeErrorFromResponse(response.status, body);
|
|
212
|
+
}
|
|
213
|
+
const payload = await response.json().catch(() => {
|
|
214
|
+
throw new AnalyzeError("scanner returned an unreadable response", response.status, undefined, "invalid_response");
|
|
215
|
+
});
|
|
216
|
+
return publicVerdictToPackage(ecosystem, entry, payload);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function publicVerdictToPackage(ecosystem, entry, payload) {
|
|
224
|
+
const v = (payload && typeof payload === "object" ? payload : {});
|
|
225
|
+
const action = v.cause === "analysis-incomplete"
|
|
226
|
+
? "analysis_incomplete"
|
|
227
|
+
: normalizeAction(v.verdict);
|
|
228
|
+
const reason = typeof v.reason === "string" && v.reason.length > 0 ? sanitize(v.reason) : undefined;
|
|
229
|
+
return {
|
|
230
|
+
name: entry.name,
|
|
231
|
+
version: entry.version,
|
|
232
|
+
score: 0,
|
|
233
|
+
action,
|
|
234
|
+
findings: [],
|
|
235
|
+
reasons: reason ? [reason] : [],
|
|
236
|
+
...(reason ? { recommendation: reason } : {}),
|
|
237
|
+
cached: false,
|
|
238
|
+
ecosystem,
|
|
239
|
+
...(typeof v.scannedSha256 === "string" ? { artifactSha256: v.scannedSha256 } : {}),
|
|
240
|
+
...(v.provenance && typeof v.provenance === "object" ? { provenance: v.provenance } : {})
|
|
241
|
+
};
|
|
142
242
|
}
|
|
143
243
|
const MAX_BATCH_ATTEMPTS = 3;
|
|
144
244
|
async function analyzeBatchWithRetry(context, batch, onBatchProgress) {
|
|
@@ -179,7 +279,7 @@ function delay(ms) {
|
|
|
179
279
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
180
280
|
}
|
|
181
281
|
async function analyzeBatch(context, batch, onBatchProgress) {
|
|
182
|
-
const { url, token,
|
|
282
|
+
const { url, token, scanId, fetchImpl, timeoutMs, signal, cooldown, installTreeHash } = context;
|
|
183
283
|
const controller = new AbortController();
|
|
184
284
|
let timedOut = false;
|
|
185
285
|
let silenceTimer;
|
|
@@ -202,9 +302,9 @@ async function analyzeBatch(context, batch, onBatchProgress) {
|
|
|
202
302
|
headers: {
|
|
203
303
|
"Content-Type": "application/json",
|
|
204
304
|
"Accept": "application/x-ndjson",
|
|
205
|
-
"X-Device-Id": deviceId,
|
|
206
305
|
"X-Scan-Id": scanId,
|
|
207
306
|
"X-Dg-Version": dgVersion(),
|
|
307
|
+
...(installTreeHash ? { "X-Install-Tree-Hash": installTreeHash } : {}),
|
|
208
308
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
209
309
|
},
|
|
210
310
|
body: JSON.stringify({
|
|
@@ -255,6 +355,7 @@ function analyzeErrorFromResponse(status, body) {
|
|
|
255
355
|
}
|
|
256
356
|
return probe;
|
|
257
357
|
}
|
|
358
|
+
const MAX_STREAM_LINE_BYTES = 32 * 1024 * 1024;
|
|
258
359
|
async function consumeAnalyzeStream(body, onBatchProgress, onActivity) {
|
|
259
360
|
const reader = body.getReader();
|
|
260
361
|
const decoder = new TextDecoder();
|
|
@@ -293,6 +394,11 @@ async function consumeAnalyzeStream(body, onBatchProgress, onActivity) {
|
|
|
293
394
|
handleLine(buffer.slice(0, newline).trim());
|
|
294
395
|
buffer = buffer.slice(newline + 1);
|
|
295
396
|
}
|
|
397
|
+
// A newline-less / header-less stream would grow the buffer without bound;
|
|
398
|
+
// a single NDJSON event is never this large, so cap it and fail closed.
|
|
399
|
+
if (buffer.length > MAX_STREAM_LINE_BYTES) {
|
|
400
|
+
throw new AnalyzeError("scanner stream line exceeded the size limit", 0);
|
|
401
|
+
}
|
|
296
402
|
}
|
|
297
403
|
handleLine(buffer.trim());
|
|
298
404
|
}
|
|
@@ -316,9 +422,9 @@ export function normalizeAnalyzeResponse(raw) {
|
|
|
316
422
|
...entry,
|
|
317
423
|
action: entry.action === undefined ? undefined : normalizeAction(entry.action),
|
|
318
424
|
findings: Array.isArray(entry.findings) ? entry.findings : [],
|
|
319
|
-
reasons: Array.isArray(entry.reasons) ? entry.reasons : []
|
|
425
|
+
reasons: Array.isArray(entry.reasons) ? entry.reasons : [],
|
|
426
|
+
safeVersion: normalizeSafeVersion(entry.safeVersion)
|
|
320
427
|
})),
|
|
321
|
-
safeVersions: candidate.safeVersions ?? {},
|
|
322
428
|
durationMs: typeof candidate.durationMs === "number" ? candidate.durationMs : 0,
|
|
323
429
|
...(candidate.freeScansRemaining !== undefined ? { freeScansRemaining: candidate.freeScansRemaining } : {}),
|
|
324
430
|
...(candidate.usage !== undefined ? { usage: candidate.usage } : {})
|
|
@@ -330,6 +436,16 @@ function normalizeAction(action) {
|
|
|
330
436
|
}
|
|
331
437
|
return "analysis_incomplete";
|
|
332
438
|
}
|
|
439
|
+
function normalizeSafeVersion(value) {
|
|
440
|
+
if (!value || typeof value !== "object")
|
|
441
|
+
return undefined;
|
|
442
|
+
const v = value;
|
|
443
|
+
if (typeof v.version !== "string" || v.version.length === 0)
|
|
444
|
+
return undefined;
|
|
445
|
+
if (v.direction !== "upgrade" && v.direction !== "downgrade")
|
|
446
|
+
return undefined;
|
|
447
|
+
return { version: sanitize(v.version), direction: v.direction, crossesMajor: v.crossesMajor === true };
|
|
448
|
+
}
|
|
333
449
|
export function mergeAnalyzeResponses(responses) {
|
|
334
450
|
const first = responses[0];
|
|
335
451
|
if (!first) {
|
|
@@ -337,7 +453,6 @@ export function mergeAnalyzeResponses(responses) {
|
|
|
337
453
|
score: 0,
|
|
338
454
|
action: "pass",
|
|
339
455
|
packages: [],
|
|
340
|
-
safeVersions: {},
|
|
341
456
|
durationMs: 0
|
|
342
457
|
};
|
|
343
458
|
}
|
|
@@ -354,7 +469,6 @@ export function mergeAnalyzeResponses(responses) {
|
|
|
354
469
|
score: Math.max(merged.score, next.score),
|
|
355
470
|
action: rank[next.action] > rank[merged.action] ? next.action : merged.action,
|
|
356
471
|
packages: [...merged.packages, ...next.packages],
|
|
357
|
-
safeVersions: { ...merged.safeVersions, ...next.safeVersions },
|
|
358
472
|
durationMs: merged.durationMs + next.durationMs,
|
|
359
473
|
...(next.freeScansRemaining !== undefined
|
|
360
474
|
? { freeScansRemaining: next.freeScansRemaining }
|
|
@@ -365,38 +479,21 @@ export function mergeAnalyzeResponses(responses) {
|
|
|
365
479
|
}));
|
|
366
480
|
}
|
|
367
481
|
function resolveApiBaseUrl(env) {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
482
|
+
const config = loadUserConfig(env);
|
|
483
|
+
// An env-provided token (resolveToken prefers it) belongs to the endpoint
|
|
484
|
+
// config points at, NOT the apiBaseUrl pinned in auth.json — which may be a
|
|
485
|
+
// different instance from a prior `dg login`. Pairing an env token with the
|
|
486
|
+
// file-pinned URL would send that credential to the wrong host. The file's
|
|
487
|
+
// apiBaseUrl is honored only when the file token is the one in use.
|
|
488
|
+
const baseUrl = envAuthToken(env)
|
|
489
|
+
? config.api.baseUrl
|
|
490
|
+
: readAuthStateOrWarn(env)?.apiBaseUrl ?? config.api.baseUrl;
|
|
491
|
+
return assertVendorApiBaseUrl(baseUrl, config.policy.allowNonVendorApiHost);
|
|
373
492
|
}
|
|
374
493
|
function resolveToken(env) {
|
|
375
494
|
return envAuthToken(env) ?? readAuthStateOrWarn(env)?.token;
|
|
376
495
|
}
|
|
377
496
|
export function identityHeaders(env) {
|
|
378
|
-
const headers = { "X-Device-Id": getOrCreateDeviceId(env) };
|
|
379
497
|
const token = resolveToken(env);
|
|
380
|
-
|
|
381
|
-
headers.Authorization = `Bearer ${token}`;
|
|
382
|
-
}
|
|
383
|
-
return headers;
|
|
384
|
-
}
|
|
385
|
-
function getOrCreateDeviceId(env) {
|
|
386
|
-
const path = join(resolveDgPaths(env).stateDir, "device-id");
|
|
387
|
-
try {
|
|
388
|
-
if (existsSync(path)) {
|
|
389
|
-
const existing = readFileSync(path, "utf8").trim();
|
|
390
|
-
if (existing) {
|
|
391
|
-
return existing;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
const id = randomUUID();
|
|
395
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
396
|
-
writeFileSync(path, `${id}\n`, { encoding: "utf8", mode: 0o600 });
|
|
397
|
-
return id;
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
return "anonymous";
|
|
401
|
-
}
|
|
498
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
402
499
|
}
|
package/dist/audit/deep.js
CHANGED
|
@@ -2,6 +2,7 @@ import { authStatus, readAuthState } from "../auth/store.js";
|
|
|
2
2
|
import { envAuthToken } from "../auth/env-token.js";
|
|
3
3
|
import { loadUserConfig } from "../config/settings.js";
|
|
4
4
|
import { packNpmArtifact } from "../publish-set/pack.js";
|
|
5
|
+
import { sanitize } from "../security/sanitize.js";
|
|
5
6
|
export function deepDecision(scope, local, env = process.env) {
|
|
6
7
|
if (local) {
|
|
7
8
|
return { upload: false, reason: "local mode (--local)" };
|
|
@@ -99,7 +100,7 @@ export async function runDeepUpload(scope, packageJson, deps = {}) {
|
|
|
99
100
|
const body = await safeJson(response);
|
|
100
101
|
const verdict = body?.verdict;
|
|
101
102
|
if (verdict === "pass" || verdict === "warn" || verdict === "block") {
|
|
102
|
-
return { ran: true, action: verdict, reason: typeof body?.reason === "string" ? body.reason : `behavioral verdict: ${verdict}` };
|
|
103
|
+
return { ran: true, action: verdict, reason: typeof body?.reason === "string" ? sanitize(body.reason) : `behavioral verdict: ${verdict}` };
|
|
103
104
|
}
|
|
104
105
|
return { ran: true, action: "analysis_incomplete", reason: "scanner returned an incomplete verdict" };
|
|
105
106
|
}
|
package/dist/audit/detectors.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { BIDI_OVERRIDE_RE, CONTENT_RULES, DANGEROUS_SCRIPT_RE, FILENAME_RULES, INVISIBLE_UNICODE_RE, RISKY_SCRIPT_NAMES } from "./rules.js";
|
|
2
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
3
|
+
// Untrusted audit strings (file paths, titles built from package.json script
|
|
4
|
+
// keys, evidence snippets) reach the terminal and exported md/txt — strip
|
|
5
|
+
// control bytes at the shared accessors so a hostile repo cannot inject ANSI/OSC
|
|
6
|
+
// or forge table rows.
|
|
2
7
|
export function findingLocation(finding) {
|
|
3
|
-
return finding.line ? `${finding.location}:${finding.line}` : finding.location;
|
|
8
|
+
return sanitizeLine(finding.line ? `${finding.location}:${finding.line}` : finding.location);
|
|
9
|
+
}
|
|
10
|
+
export function findingTitleSafe(finding) {
|
|
11
|
+
return sanitizeLine(finding.title);
|
|
12
|
+
}
|
|
13
|
+
export function findingEvidenceSafe(finding) {
|
|
14
|
+
return sanitizeLine(finding.evidence);
|
|
4
15
|
}
|
|
5
16
|
function lineAt(body, index) {
|
|
6
17
|
let line = 1;
|
|
@@ -21,7 +32,17 @@ export function detectFindings(files, context) {
|
|
|
21
32
|
return;
|
|
22
33
|
}
|
|
23
34
|
seen.add(key);
|
|
24
|
-
findings
|
|
35
|
+
// Sanitize at the single point findings leave the detector layer so EVERY
|
|
36
|
+
// consumer (the TUI, the text report, the md/txt/JSON export) is safe — these
|
|
37
|
+
// strings embed package.json script keys and file content, which a hostile
|
|
38
|
+
// repo can fill with ANSI/OSC control sequences.
|
|
39
|
+
findings.push({
|
|
40
|
+
...finding,
|
|
41
|
+
title: sanitizeLine(finding.title),
|
|
42
|
+
recommendation: sanitizeLine(finding.recommendation),
|
|
43
|
+
evidence: sanitizeLine(finding.evidence),
|
|
44
|
+
location: sanitizeLine(finding.location),
|
|
45
|
+
});
|
|
25
46
|
};
|
|
26
47
|
for (const file of files) {
|
|
27
48
|
structuralFileChecks(file, push);
|
package/dist/audit/rules.js
CHANGED
|
@@ -426,5 +426,8 @@ export const RISKY_SCRIPT_NAMES = [
|
|
|
426
426
|
// node -e, python -c, base64 -d, curl|sh, AND download-then-execute where the fetch
|
|
427
427
|
// is chained (&& / ;) to a shell or a freshly-downloaded file.
|
|
428
428
|
export const DANGEROUS_SCRIPT_RE = /\|\s*(?:sh|bash|zsh)\b|\beval\s|node\s+-e\b|python[0-9.]*\s+-c\b|base64\s+-d|(?:curl|wget|fetch)\b[^\n]*\|\s*\w*sh\b|(?:curl|wget|fetch)\b[^\n]*(?:&&|;)\s*(?:sh|bash|zsh|source|\.\/)/iu;
|
|
429
|
-
|
|
429
|
+
// Single source of truth lives in security/sanitize.ts (the strip set the
|
|
430
|
+
// sanitizers apply); re-exported here so the audit detector's invisible-char
|
|
431
|
+
// check and the strip set can never drift apart.
|
|
432
|
+
export { INVISIBLE_UNICODE_RE } from "../security/sanitize.js";
|
|
430
433
|
export const BIDI_OVERRIDE_RE = /[\u202A-\u202E\u2066-\u2069]/u;
|
package/dist/audit-ui/export.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { findingLocation } from "../audit/detectors.js";
|
|
1
|
+
import { findingEvidenceSafe, findingLocation, findingTitleSafe } from "../audit/detectors.js";
|
|
2
|
+
import { sanitizeLine } from "../security/sanitize.js";
|
|
2
3
|
import { deepSummary } from "../audit/deep.js";
|
|
3
4
|
import { severityKind, WARN_GLYPH } from "./format.js";
|
|
4
5
|
function countSummaryLine(findings) {
|
|
@@ -43,7 +44,7 @@ function buildMd(input) {
|
|
|
43
44
|
for (const finding of input.findings) {
|
|
44
45
|
const kind = severityKind(finding.severity);
|
|
45
46
|
const sev = kind === "block" ? "BLOCK" : kind === "warn" ? "WARN" : "NOTE";
|
|
46
|
-
const cell = (value) => value.replace(/\|/g, "\\|")
|
|
47
|
+
const cell = (value) => sanitizeLine(value).replace(/\|/g, "\\|");
|
|
47
48
|
lines.push(`| ${sev} | ${cell(findingLocation(finding))} | ${cell(finding.title)} | ${cell(finding.evidence)} | ${cell(finding.recommendation)} |`);
|
|
48
49
|
}
|
|
49
50
|
lines.push("");
|
|
@@ -61,9 +62,9 @@ function buildTxt(input) {
|
|
|
61
62
|
const kind = severityKind(finding.severity);
|
|
62
63
|
const tag = kind === "block" ? "✘" : kind === "warn" ? WARN_GLYPH : "·";
|
|
63
64
|
lines.push(` ${tag} ${findingLocation(finding)}`);
|
|
64
|
-
lines.push(` ${finding
|
|
65
|
+
lines.push(` ${findingTitleSafe(finding)}`);
|
|
65
66
|
if (finding.evidence && finding.evidence !== `path: ${finding.location}` && finding.evidence !== finding.location) {
|
|
66
|
-
lines.push(` ${finding
|
|
67
|
+
lines.push(` ${findingEvidenceSafe(finding)}`);
|
|
67
68
|
}
|
|
68
69
|
lines.push(` → ${finding.recommendation}`);
|
|
69
70
|
lines.push("");
|
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
3
3
|
import { displayTier, writeAuthState } from "./store.js";
|
|
4
|
-
import { loadUserConfig } from "../config/settings.js";
|
|
4
|
+
import { loadUserConfig, VENDOR_API_HOST } from "../config/settings.js";
|
|
5
5
|
import { createTheme } from "../presentation/theme.js";
|
|
6
6
|
import { resolvePresentation } from "../presentation/mode.js";
|
|
7
7
|
const DEFAULT_WEB_BASE = "https://westbayberry.com";
|
|
8
8
|
export const POLL_INTERVAL_MS = 2000;
|
|
9
9
|
export const POLL_TIMEOUT_MS = 5 * 60_000;
|
|
10
|
+
const VENDOR_WEB_HOST = VENDOR_API_HOST.replace(/^api\./, "");
|
|
11
|
+
function isVendorWebHost(host) {
|
|
12
|
+
return host === VENDOR_WEB_HOST || host.endsWith(`.${VENDOR_WEB_HOST}`) || host === "localhost" || host === "127.0.0.1";
|
|
13
|
+
}
|
|
14
|
+
function allowsNonVendorHost(env) {
|
|
15
|
+
try {
|
|
16
|
+
return loadUserConfig(env).policy.allowNonVendorApiHost;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
10
22
|
export function resolveWebBase(env) {
|
|
11
23
|
const override = env.DG_AUTH_BASE;
|
|
12
24
|
if (override) {
|
|
13
25
|
try {
|
|
14
26
|
const url = new URL(override);
|
|
15
27
|
const localhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
16
|
-
|
|
28
|
+
// The login browser opens this base and the polled token is stored as the
|
|
29
|
+
// user's credential, so an attacker-set DG_AUTH_BASE would harvest it. Pin
|
|
30
|
+
// it to the vendor web host (or localhost) unless the operator opted into a
|
|
31
|
+
// self-hosted host in their own global config — same anchor as the api pin.
|
|
32
|
+
if ((url.protocol === "https:" || (url.protocol === "http:" && localhost))
|
|
33
|
+
&& (isVendorWebHost(url.hostname) || allowsNonVendorHost(env))) {
|
|
17
34
|
return override.replace(/\/$/, "");
|
|
18
35
|
}
|
|
19
36
|
}
|
|
@@ -43,8 +60,9 @@ export function assertTrustedVerifyUrl(verifyUrl, webBase) {
|
|
|
43
60
|
catch {
|
|
44
61
|
throw new Error("login server returned an invalid verify URL");
|
|
45
62
|
}
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
const isLocalhost = verify.hostname === "localhost" || verify.hostname === "127.0.0.1";
|
|
64
|
+
if (verify.protocol !== "https:" && !(verify.protocol === "http:" && isLocalhost)) {
|
|
65
|
+
throw new Error("login server returned an insecure verify URL (https required)");
|
|
48
66
|
}
|
|
49
67
|
const baseHost = new URL(webBase).hostname;
|
|
50
68
|
if (!isSameOrSubdomain(verify.hostname, baseHost)) {
|
|
@@ -136,13 +154,16 @@ export function openBrowser(url) {
|
|
|
136
154
|
function delay(ms) {
|
|
137
155
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
138
156
|
}
|
|
157
|
+
// Returns true only when the user actually pressed Enter on the controlling
|
|
158
|
+
// terminal. A false return (no /dev/tty, or EOF) means we could NOT confirm, so
|
|
159
|
+
// the caller must not auto-open a browser on the user's behalf.
|
|
139
160
|
function waitForEnter() {
|
|
140
161
|
let tty;
|
|
141
162
|
try {
|
|
142
163
|
tty = openSync("/dev/tty", "rs");
|
|
143
164
|
}
|
|
144
165
|
catch {
|
|
145
|
-
return;
|
|
166
|
+
return false;
|
|
146
167
|
}
|
|
147
168
|
try {
|
|
148
169
|
const byte = Buffer.alloc(1);
|
|
@@ -155,14 +176,14 @@ function waitForEnter() {
|
|
|
155
176
|
if (error.code === "EAGAIN") {
|
|
156
177
|
continue;
|
|
157
178
|
}
|
|
158
|
-
|
|
179
|
+
return false;
|
|
159
180
|
}
|
|
160
181
|
if (read === 0) {
|
|
161
|
-
|
|
182
|
+
return false;
|
|
162
183
|
}
|
|
163
184
|
const char = byte.toString("utf8");
|
|
164
185
|
if (char === "\n" || char === "\r") {
|
|
165
|
-
|
|
186
|
+
return true;
|
|
166
187
|
}
|
|
167
188
|
}
|
|
168
189
|
}
|
|
@@ -226,9 +247,10 @@ export async function runDeviceLogin(io = {}) {
|
|
|
226
247
|
stderr: `dg login: ${error instanceof Error ? error.message : "could not start login"}.\n`
|
|
227
248
|
};
|
|
228
249
|
}
|
|
229
|
-
stderr.write(`\n Sign in at:\n ${accent(session.verifyUrl)}\n\n ${muted("Press Enter to open it in your browser…")}`);
|
|
230
|
-
confirm()
|
|
231
|
-
|
|
250
|
+
stderr.write(`\n Sign in at:\n ${accent(session.verifyUrl)}\n\n ${muted("Press Enter to open it in your browser, or open the link above yourself…")}`);
|
|
251
|
+
if (confirm()) {
|
|
252
|
+
open(session.verifyUrl);
|
|
253
|
+
}
|
|
232
254
|
stderr.write(`\n ${muted("Waiting for you to approve in the browser…")}\n\n`);
|
|
233
255
|
const deadline = now() + POLL_TIMEOUT_MS;
|
|
234
256
|
for (;;) {
|
package/dist/auth/login-app.js
CHANGED
|
@@ -9,7 +9,8 @@ const SUCCESS_MAX_MS = 2500;
|
|
|
9
9
|
export function useLogin(webBase, env) {
|
|
10
10
|
const [state, setState] = useState({ phase: "creating", verifyUrl: "", email: "", plan: "", message: "" });
|
|
11
11
|
const sessionId = useRef("");
|
|
12
|
-
const
|
|
12
|
+
const verifyUrl = useRef("");
|
|
13
|
+
const pollStarted = useRef(false);
|
|
13
14
|
const cancelled = useRef(false);
|
|
14
15
|
const tierSettled = useRef(false);
|
|
15
16
|
const pollTimer = useRef(undefined);
|
|
@@ -20,6 +21,7 @@ export function useLogin(webBase, env) {
|
|
|
20
21
|
if (sessionCancelled)
|
|
21
22
|
return;
|
|
22
23
|
sessionId.current = session.sessionId;
|
|
24
|
+
verifyUrl.current = session.verifyUrl;
|
|
23
25
|
setState((prev) => ({ ...prev, phase: "ready", verifyUrl: session.verifyUrl }));
|
|
24
26
|
})
|
|
25
27
|
.catch((error) => {
|
|
@@ -41,12 +43,12 @@ export function useLogin(webBase, env) {
|
|
|
41
43
|
}
|
|
42
44
|
};
|
|
43
45
|
}, []);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
// Polling starts as soon as the session is ready, so signing in works whether
|
|
47
|
+
// the user lets us open the browser (Enter) or opens the link themselves.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (state.phase !== "ready" || pollStarted.current)
|
|
46
50
|
return;
|
|
47
|
-
|
|
48
|
-
openBrowser(state.verifyUrl);
|
|
49
|
-
setState((prev) => ({ ...prev, phase: "waiting" }));
|
|
51
|
+
pollStarted.current = true;
|
|
50
52
|
void (async () => {
|
|
51
53
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
52
54
|
while (!cancelled.current) {
|
|
@@ -85,11 +87,16 @@ export function useLogin(webBase, env) {
|
|
|
85
87
|
});
|
|
86
88
|
}
|
|
87
89
|
})();
|
|
90
|
+
}, [state.phase, webBase, env]);
|
|
91
|
+
const openInBrowser = () => {
|
|
92
|
+
if (verifyUrl.current) {
|
|
93
|
+
openBrowser(verifyUrl.current);
|
|
94
|
+
}
|
|
88
95
|
};
|
|
89
|
-
return { state, tierSettled,
|
|
96
|
+
return { state, tierSettled, openInBrowser };
|
|
90
97
|
}
|
|
91
98
|
const LoginApp = ({ webBase, env }) => {
|
|
92
|
-
const { state, tierSettled,
|
|
99
|
+
const { state, tierSettled, openInBrowser } = useLogin(webBase, env);
|
|
93
100
|
const { exit } = useApp();
|
|
94
101
|
useEffect(() => {
|
|
95
102
|
if (state.phase === "expired" || state.phase === "error") {
|
|
@@ -122,7 +129,7 @@ const LoginApp = ({ webBase, env }) => {
|
|
|
122
129
|
}, [state.phase, exit, tierSettled]);
|
|
123
130
|
useInput((_input, key) => {
|
|
124
131
|
if (state.phase === "ready" && key.return) {
|
|
125
|
-
|
|
132
|
+
openInBrowser();
|
|
126
133
|
}
|
|
127
134
|
else if (state.phase === "success") {
|
|
128
135
|
exit();
|
|
@@ -132,9 +139,7 @@ const LoginApp = ({ webBase, env }) => {
|
|
|
132
139
|
case "creating":
|
|
133
140
|
return (_jsx(Box, { paddingLeft: 1, paddingTop: 1, children: _jsx(Spinner, { label: "Creating login session\u2026" }) }));
|
|
134
141
|
case "ready":
|
|
135
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to open it in your browser\u2026" })] }));
|
|
136
|
-
case "waiting":
|
|
137
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Spinner, { label: "Waiting for you to approve in the browser\u2026" })] }));
|
|
142
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsx(Text, { children: "Sign in at:" }), _jsx(Text, { color: "cyan", children: state.verifyUrl }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Press Enter to open it in your browser, or open the link yourself." }), _jsx(Text, { children: " " }), _jsx(Spinner, { label: "Waiting for you to approve\u2026" })] }));
|
|
138
143
|
case "success":
|
|
139
144
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingTop: 1, children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Logged in", state.email ? ` as ${state.email}` : "", state.plan ? ` (${displayTier(state.plan)} plan)` : ""] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Run `dg scan` to check your dependencies." })] }));
|
|
140
145
|
case "expired":
|