@westbayberry/dg 2.0.11 → 2.1.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/dist/api/analyze.js +5 -3
- package/dist/bin/dg.js +1 -1
- package/dist/commands/completion.js +2 -1
- package/dist/commands/config.js +11 -3
- package/dist/commands/decisions.js +155 -0
- package/dist/commands/explain.js +6 -2
- package/dist/commands/router.js +2 -0
- package/dist/commands/scan.js +2 -1
- package/dist/commands/status.js +5 -2
- package/dist/config/settings.js +144 -25
- package/dist/decisions/apply.js +128 -0
- package/dist/decisions/remember-prompt.js +97 -0
- package/dist/install-ui/block-render.js +21 -4
- package/dist/install-ui/prompt.js +14 -0
- package/dist/launcher/install-preflight.js +126 -13
- package/dist/launcher/preflight-prompt.js +29 -2
- package/dist/launcher/run.js +14 -3
- package/dist/policy/cooldown.js +104 -0
- package/dist/policy/evaluate.js +0 -15
- package/dist/presentation/provenance.js +23 -0
- package/dist/project/dgfile.js +307 -0
- package/dist/proxy/enforcement.js +2 -1
- package/dist/proxy/metadata-map.js +25 -1
- package/dist/proxy/server.js +31 -2
- package/dist/scan/command.js +35 -8
- package/dist/scan/render.js +35 -4
- package/dist/scan/scanner-report.js +31 -4
- package/dist/scan/staged.js +69 -10
- package/dist/scan-ui/LegacyApp.js +4 -4
- package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
- package/dist/scan-ui/hooks/useScan.js +31 -3
- package/dist/scan-ui/shims.js +3 -0
- package/dist/scripts/detect.js +153 -0
- package/dist/scripts/gate.js +170 -0
- package/dist/scripts/rebuild.js +28 -0
- package/dist/setup/plan.js +36 -1
- package/dist/util/json-file.js +24 -0
- package/dist/util/tty-prompt.js +13 -6
- package/dist/verify/package-check.js +12 -0
- package/package.json +9 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export function packageKey(name, version) {
|
|
2
|
+
return `${name}@${version}`;
|
|
3
|
+
}
|
|
4
|
+
export function findingCategory(finding) {
|
|
5
|
+
return finding.category ?? finding.id ?? "unknown";
|
|
6
|
+
}
|
|
7
|
+
export function findingFingerprint(findings) {
|
|
8
|
+
const fingerprint = {};
|
|
9
|
+
for (const finding of findings) {
|
|
10
|
+
const category = findingCategory(finding);
|
|
11
|
+
fingerprint[category] = Math.max(fingerprint[category] ?? 0, finding.severity);
|
|
12
|
+
}
|
|
13
|
+
return fingerprint;
|
|
14
|
+
}
|
|
15
|
+
export function matchDecision(pkg, ecosystem, entries, now = new Date()) {
|
|
16
|
+
if ((pkg.action ?? "pass") !== "warn") {
|
|
17
|
+
return { acknowledged: false };
|
|
18
|
+
}
|
|
19
|
+
const fingerprint = findingFingerprint(pkg.findings);
|
|
20
|
+
let nearestUncovered;
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.ecosystem !== ecosystem || entry.name !== pkg.name) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!scopeMatches(entry, pkg.version)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (isExpired(entry, now)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const uncovered = uncoveredFindings(fingerprint, entry.findings);
|
|
32
|
+
if (uncovered.length === 0) {
|
|
33
|
+
return { acknowledged: true, entry };
|
|
34
|
+
}
|
|
35
|
+
if (!nearestUncovered || uncovered.length < nearestUncovered.length) {
|
|
36
|
+
nearestUncovered = uncovered;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return nearestUncovered ? { acknowledged: false, newFindings: nearestUncovered } : { acknowledged: false };
|
|
40
|
+
}
|
|
41
|
+
export function applyDecisions(packages, ecosystemOf, file, rawAction, now = new Date()) {
|
|
42
|
+
const annotations = {};
|
|
43
|
+
let acknowledgedCount = 0;
|
|
44
|
+
let worst = 0;
|
|
45
|
+
for (const pkg of packages) {
|
|
46
|
+
const action = pkg.action ?? "pass";
|
|
47
|
+
if (action === "pass") {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const ecosystem = ecosystemOf(pkg);
|
|
51
|
+
if (!ecosystem) {
|
|
52
|
+
worst = Math.max(worst, actionSeverity(action));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const match = file.readable ? matchDecision(pkg, ecosystem, file.decisions, now) : { acknowledged: false };
|
|
56
|
+
const key = packageKey(pkg.name, pkg.version);
|
|
57
|
+
if (match.acknowledged) {
|
|
58
|
+
acknowledgedCount += 1;
|
|
59
|
+
annotations[key] = {
|
|
60
|
+
ecosystem,
|
|
61
|
+
acknowledged: {
|
|
62
|
+
decisionId: match.entry.id,
|
|
63
|
+
by: match.entry.acceptedBy,
|
|
64
|
+
at: match.entry.acceptedAt,
|
|
65
|
+
reason: match.entry.reason
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
annotations[key] = {
|
|
71
|
+
ecosystem,
|
|
72
|
+
...(match.newFindings ? { newFindings: match.newFindings } : {})
|
|
73
|
+
};
|
|
74
|
+
worst = Math.max(worst, actionSeverity(action));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const effectiveAction = acknowledgedCount === 0 || worst >= actionSeverity(rawAction) ? rawAction : actionFromSeverity(worst);
|
|
78
|
+
return {
|
|
79
|
+
file: file.path,
|
|
80
|
+
acknowledgedCount,
|
|
81
|
+
effectiveAction,
|
|
82
|
+
packages: annotations
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const ACTION_SEVERITY = {
|
|
86
|
+
pass: 0,
|
|
87
|
+
analysis_incomplete: 1,
|
|
88
|
+
warn: 2,
|
|
89
|
+
block: 3
|
|
90
|
+
};
|
|
91
|
+
function actionSeverity(action) {
|
|
92
|
+
return ACTION_SEVERITY[action] ?? 0;
|
|
93
|
+
}
|
|
94
|
+
function actionFromSeverity(severity) {
|
|
95
|
+
if (severity >= 3) {
|
|
96
|
+
return "block";
|
|
97
|
+
}
|
|
98
|
+
if (severity === 2) {
|
|
99
|
+
return "warn";
|
|
100
|
+
}
|
|
101
|
+
if (severity === 1) {
|
|
102
|
+
return "analysis_incomplete";
|
|
103
|
+
}
|
|
104
|
+
return "pass";
|
|
105
|
+
}
|
|
106
|
+
function scopeMatches(entry, version) {
|
|
107
|
+
if (entry.scope.kind === "any") {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return entry.scope.version === version;
|
|
111
|
+
}
|
|
112
|
+
function isExpired(entry, now) {
|
|
113
|
+
if (!entry.expiresAt) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const expiry = Date.parse(entry.expiresAt);
|
|
117
|
+
return !Number.isFinite(expiry) || expiry <= now.getTime();
|
|
118
|
+
}
|
|
119
|
+
function uncoveredFindings(fingerprint, accepted) {
|
|
120
|
+
const uncovered = [];
|
|
121
|
+
for (const [category, severity] of Object.entries(fingerprint)) {
|
|
122
|
+
const acceptedSeverity = accepted[category];
|
|
123
|
+
if (acceptedSeverity === undefined || severity > acceptedSeverity) {
|
|
124
|
+
uncovered.push(`${category}:${severity}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return uncovered.sort();
|
|
128
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { emitWebhookEvent, recordAuditEvent } from "../audit/events.js";
|
|
2
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
3
|
+
import { promptText, promptYesNo } from "../install-ui/prompt.js";
|
|
4
|
+
import { appendDecisions, saveDgFile } from "../project/dgfile.js";
|
|
5
|
+
import { promptLine as ttyPromptLine, promptYesNo as ttyPromptYesNo } from "../util/tty-prompt.js";
|
|
6
|
+
import { findingFingerprint, packageKey } from "./apply.js";
|
|
7
|
+
const defaultSyncPrompts = {
|
|
8
|
+
yesNo: (question, defaultYes) => ttyPromptYesNo(question, defaultYes),
|
|
9
|
+
line: (question) => ttyPromptLine(question),
|
|
10
|
+
write: (text) => process.stderr.write(text)
|
|
11
|
+
};
|
|
12
|
+
export async function offerRememberOnIo(options) {
|
|
13
|
+
const { io, file, packages } = options;
|
|
14
|
+
if (!io.isTTY || !file.readable || packages.length === 0) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const choice = (await promptText(" Remember this acceptance? [v] this version / [o] just once: ", io)).trim().toLowerCase();
|
|
18
|
+
if (choice !== "v" && choice !== "version") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (!file.exists) {
|
|
22
|
+
const create = await promptYesNo(` Create ${file.path}?`, io, false);
|
|
23
|
+
if (!create) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const reason = (await promptText(" Reason (Enter to skip): ", io)).trim();
|
|
28
|
+
persistRemembered(file, packages, {
|
|
29
|
+
reason: reason || `accepted at ${options.surface}`,
|
|
30
|
+
acceptedBy: options.acceptedBy,
|
|
31
|
+
env: options.env ?? process.env
|
|
32
|
+
});
|
|
33
|
+
io.output.write(` ✓ remembered in ${file.path} — review with 'dg decisions'\n`);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
export function offerRememberSync(options) {
|
|
37
|
+
const { file, packages } = options;
|
|
38
|
+
const prompts = options.prompts ?? defaultSyncPrompts;
|
|
39
|
+
if (!file.readable || packages.length === 0) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const remember = prompts.yesNo(" Remember this acceptance in dg.json for future commits?", false);
|
|
43
|
+
if (remember !== true) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (!file.exists) {
|
|
47
|
+
const create = prompts.yesNo(` Create ${file.path}?`, false);
|
|
48
|
+
if (create !== true) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const reason = (prompts.line(" Reason (Enter to skip): ") ?? "").trim();
|
|
53
|
+
persistRemembered(file, packages, {
|
|
54
|
+
reason: reason || `accepted at ${options.surface}`,
|
|
55
|
+
acceptedBy: options.acceptedBy,
|
|
56
|
+
env: options.env ?? process.env
|
|
57
|
+
});
|
|
58
|
+
prompts.write(` ✓ remembered in ${file.path} — review with 'dg decisions'\n`);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
export function persistRemembered(file, packages, options) {
|
|
62
|
+
const additions = packages.map((pkg) => ({
|
|
63
|
+
ecosystem: pkg.ecosystem,
|
|
64
|
+
name: pkg.name,
|
|
65
|
+
scope: { kind: "exact", version: pkg.version },
|
|
66
|
+
findings: findingFingerprint(pkg.findings),
|
|
67
|
+
reason: options.reason,
|
|
68
|
+
acceptedBy: options.acceptedBy
|
|
69
|
+
}));
|
|
70
|
+
saveDgFile(appendDecisions(file, additions));
|
|
71
|
+
recordDecisionEvents("decision.accepted", packages.map((pkg) => `${pkg.ecosystem}:${packageKey(pkg.name, pkg.version)}`), options.reason, options.env);
|
|
72
|
+
}
|
|
73
|
+
export function recordDecisionEvents(type, packageNames, reason, env) {
|
|
74
|
+
let policyMode = "unknown";
|
|
75
|
+
try {
|
|
76
|
+
policyMode = loadUserConfig(env).policy.mode;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// a corrupt user config must not block recording the decision trail
|
|
80
|
+
}
|
|
81
|
+
for (const packageName of packageNames) {
|
|
82
|
+
const event = {
|
|
83
|
+
type,
|
|
84
|
+
packageName,
|
|
85
|
+
reason,
|
|
86
|
+
policyMode,
|
|
87
|
+
createdAt: new Date().toISOString()
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
recordAuditEvent(event, env);
|
|
91
|
+
emitWebhookEvent(event, env);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// audit trail is best-effort; the decision write already succeeded
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatCooldownDuration, formatPackageAge } from "../policy/cooldown.js";
|
|
1
2
|
const VERIFIED_BAD = new Set([
|
|
2
3
|
"malware",
|
|
3
4
|
"policy",
|
|
@@ -19,6 +20,7 @@ const HEADLINES = {
|
|
|
19
20
|
"api-timeout": "scanner timed out",
|
|
20
21
|
"registry-timeout": "registry timed out",
|
|
21
22
|
"analysis-incomplete": "analysis incomplete",
|
|
23
|
+
cooldown: "release too new (cooldown)",
|
|
22
24
|
"unsupported-manager": "unsupported package manager",
|
|
23
25
|
"proxy-setup-failure": "protection unavailable"
|
|
24
26
|
};
|
|
@@ -29,14 +31,24 @@ const NEXT_STEP = {
|
|
|
29
31
|
"hash-mismatch": "Clear your package cache and retry. If it persists, do not install.",
|
|
30
32
|
"private-upload-disabled": "Enable private artifact scanning to verify this package.",
|
|
31
33
|
"needs-login": "Run 'dg login' (free) to check packages from the registry before they install.",
|
|
32
|
-
"quota-exceeded": "Upgrade your plan or wait for your monthly limit to reset. See westbayberry.com/pricing."
|
|
34
|
+
"quota-exceeded": "Upgrade your plan or wait for your monthly limit to reset. See westbayberry.com/pricing.",
|
|
35
|
+
cooldown: "Wait for the cooldown, pin an older version, or exempt it: dg config set cooldown.exempt <name>"
|
|
33
36
|
};
|
|
37
|
+
function cooldownDetailLine(cooldown) {
|
|
38
|
+
const window = formatCooldownDuration(cooldown.requiredDays);
|
|
39
|
+
const eligible = formatResetDate(cooldown.eligibleAt);
|
|
40
|
+
const suffix = eligible ? ` (eligible ${eligible})` : "";
|
|
41
|
+
if (cooldown.ageDays === undefined) {
|
|
42
|
+
return `publish time unknown; your cooldown is ${window}${suffix}`;
|
|
43
|
+
}
|
|
44
|
+
return `published ${formatPackageAge(cooldown.ageDays)}; your cooldown is ${window}${suffix}`;
|
|
45
|
+
}
|
|
34
46
|
export function describeBlockedInstall(decision) {
|
|
35
47
|
const verifiedBad = VERIFIED_BAD.has(decision.cause);
|
|
36
48
|
const override = decision.forceOverride && !decision.forceOverride.allowed
|
|
37
49
|
? "not allowed by your policy"
|
|
38
50
|
: "re-run with --dg-force-install";
|
|
39
|
-
const nextStep = verifiedBad || decision.cause === "needs-login"
|
|
51
|
+
const nextStep = verifiedBad || decision.cause === "needs-login" || decision.cause === "cooldown"
|
|
40
52
|
? NEXT_STEP[decision.cause]
|
|
41
53
|
: "Re-check later with 'dg verify', or override if you accept the risk.";
|
|
42
54
|
return {
|
|
@@ -85,9 +97,14 @@ export function renderInstallDecision(decision) {
|
|
|
85
97
|
const lines = [
|
|
86
98
|
verifiedBad
|
|
87
99
|
? `✘ DG blocked install — ${headline}`
|
|
88
|
-
:
|
|
100
|
+
: decision.cause === "cooldown"
|
|
101
|
+
? `? DG quarantined ${decision.packageName} — ${headline}`
|
|
102
|
+
: `? DG could not verify ${decision.packageName} — ${headline}`,
|
|
89
103
|
` ${decision.packageName} ${decision.reason}`
|
|
90
104
|
];
|
|
105
|
+
if (decision.cause === "cooldown" && decision.cooldown) {
|
|
106
|
+
lines.push(` ${cooldownDetailLine(decision.cooldown)}`);
|
|
107
|
+
}
|
|
91
108
|
if (decision.dashboardUrl) {
|
|
92
109
|
lines.push(` Evidence: ${decision.dashboardUrl}`);
|
|
93
110
|
}
|
|
@@ -97,7 +114,7 @@ export function renderInstallDecision(decision) {
|
|
|
97
114
|
lines.push(decision.forceOverride && !decision.forceOverride.allowed
|
|
98
115
|
? " Override: not allowed by your policy"
|
|
99
116
|
: " Override: re-run with --dg-force-install");
|
|
100
|
-
const next = verifiedBad || decision.cause === "needs-login"
|
|
117
|
+
const next = verifiedBad || decision.cause === "needs-login" || decision.cause === "cooldown"
|
|
101
118
|
? NEXT_STEP[decision.cause]
|
|
102
119
|
: "Re-check later with 'dg verify', or override if you accept the risk.";
|
|
103
120
|
if (next) {
|
|
@@ -6,6 +6,20 @@ export function defaultPromptIo() {
|
|
|
6
6
|
isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY)
|
|
7
7
|
};
|
|
8
8
|
}
|
|
9
|
+
export async function promptText(question, io) {
|
|
10
|
+
if (!io.isTTY) {
|
|
11
|
+
return "";
|
|
12
|
+
}
|
|
13
|
+
const rl = createInterface({ input: io.input, output: io.output });
|
|
14
|
+
try {
|
|
15
|
+
return await new Promise((resolve) => {
|
|
16
|
+
rl.question(question, resolve);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
rl.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
export async function promptYesNo(question, io, defaultYes = false) {
|
|
10
24
|
if (!io.isTTY) {
|
|
11
25
|
return false;
|
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { analyzePackages } from "../api/analyze.js";
|
|
4
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
5
|
+
import { matchDecision, packageKey } from "../decisions/apply.js";
|
|
6
|
+
import { offerRememberOnIo } from "../decisions/remember-prompt.js";
|
|
7
|
+
import { provenanceDowngradeLine } from "../presentation/provenance.js";
|
|
4
8
|
import { defaultPromptIo } from "../install-ui/prompt.js";
|
|
9
|
+
import { cooldownRequestParam, formatCooldownDuration, formatPackageAge, isCooldownExempt } from "../policy/cooldown.js";
|
|
10
|
+
import { findProjectRoot, loadDgFile, resolveAcceptedBy, warnUnreadableDgFile } from "../project/dgfile.js";
|
|
5
11
|
import { parsePipReportInstallCount, parsePipReportInstallSet } from "./pip-report.js";
|
|
12
|
+
export function resolvePreflightCooldown(env) {
|
|
13
|
+
try {
|
|
14
|
+
const config = loadUserConfig(env);
|
|
15
|
+
const param = cooldownRequestParam(config, env, "pypi", "");
|
|
16
|
+
return param ? { param, exempt: config.cooldown.exempt } : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isQuarantined(pkg, context) {
|
|
23
|
+
if (!context || !pkg.cooldown) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (isCooldownExempt(pkg.name, context.exempt, "pypi")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return pkg.cooldown.status === "quarantine"
|
|
30
|
+
|| (pkg.cooldown.status === "unknown" && context.param.onUnknown === "block");
|
|
31
|
+
}
|
|
32
|
+
export function resolvePreflightDecisions(ecosystem, cwd, env = process.env) {
|
|
33
|
+
const root = findProjectRoot(cwd, env);
|
|
34
|
+
if (!root) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const file = loadDgFile(root);
|
|
38
|
+
warnUnreadableDgFile(file);
|
|
39
|
+
if (!file.readable) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return { root, file, ecosystem, env };
|
|
43
|
+
}
|
|
6
44
|
const PROCEED = { proceed: true };
|
|
7
45
|
const UNRESOLVED = { set: undefined, count: undefined };
|
|
8
46
|
const approvedFlagRanks = new Map();
|
|
@@ -75,18 +113,48 @@ function resolvePipInstallSet(binary, args, env) {
|
|
|
75
113
|
function findingSummary(pkg) {
|
|
76
114
|
return pkg.reasons[0] ?? pkg.findings[0]?.title ?? pkg.findings[0]?.id ?? "flagged";
|
|
77
115
|
}
|
|
116
|
+
function cooldownSummary(pkg) {
|
|
117
|
+
const cooldown = pkg.cooldown;
|
|
118
|
+
if (!cooldown) {
|
|
119
|
+
return "release too new";
|
|
120
|
+
}
|
|
121
|
+
const window = cooldown.requiredDays !== undefined ? formatCooldownDuration(cooldown.requiredDays) : "your cooldown";
|
|
122
|
+
if (cooldown.ageDays === undefined) {
|
|
123
|
+
return `publish time unknown; cooldown ${window}`;
|
|
124
|
+
}
|
|
125
|
+
return `published ${formatPackageAge(cooldown.ageDays)}; cooldown ${window}`;
|
|
126
|
+
}
|
|
78
127
|
function renderPreflight(flagged, out) {
|
|
79
|
-
const blocks = flagged.filter((
|
|
128
|
+
const blocks = flagged.filter((entry) => entry.action === "block").length;
|
|
80
129
|
const noun = flagged.length === 1 ? "package" : "packages";
|
|
81
130
|
const tail = blocks > 0 ? ` (${blocks} blocked)` : "";
|
|
82
131
|
out.write(`\n DG flagged ${flagged.length} ${noun} before install${tail}:\n`);
|
|
83
|
-
for (const
|
|
84
|
-
const tag =
|
|
85
|
-
|
|
132
|
+
for (const entry of flagged) {
|
|
133
|
+
const tag = entry.viaCooldown ? "cooldown" : entry.action === "block" ? "block" : "warn";
|
|
134
|
+
const summary = entry.viaCooldown ? cooldownSummary(entry.pkg) : findingSummary(entry.pkg);
|
|
135
|
+
out.write(` ${entry.pkg.name}@${entry.pkg.version} ${tag} ${summary}${provenanceSuffix(entry.pkg)}\n`);
|
|
86
136
|
}
|
|
87
137
|
out.write("\n");
|
|
88
138
|
}
|
|
89
|
-
|
|
139
|
+
function provenanceSuffix(pkg) {
|
|
140
|
+
const prov = pkg.provenance;
|
|
141
|
+
if (!prov || prov.status === "unknown") {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
return ` provenance: ${prov.status}`;
|
|
145
|
+
}
|
|
146
|
+
export function renderProvenanceDowngrades(packages, out) {
|
|
147
|
+
const downgraded = packages.filter((pkg) => pkg.provenance?.downgrade);
|
|
148
|
+
if (downgraded.length === 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
out.write("\n");
|
|
152
|
+
for (const pkg of downgraded) {
|
|
153
|
+
const line = provenanceDowngradeLine(pkg.version, pkg.provenance);
|
|
154
|
+
out.write(` ⚠ ${pkg.name}@${pkg.version}: ${line} (display only, verdict unchanged)\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function runInstallPreflight(manager, binary, childArgs, env, cwd = process.cwd()) {
|
|
90
158
|
if (manager !== "pip" || !binary) {
|
|
91
159
|
return PROCEED;
|
|
92
160
|
}
|
|
@@ -100,29 +168,74 @@ export async function runInstallPreflight(manager, binary, childArgs, env) {
|
|
|
100
168
|
if (!set || set.length === 0) {
|
|
101
169
|
return PROCEED;
|
|
102
170
|
}
|
|
171
|
+
const cooldownContext = resolvePreflightCooldown(env);
|
|
103
172
|
let verdicts;
|
|
104
173
|
try {
|
|
105
|
-
verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env });
|
|
174
|
+
verdicts = await analyzePackages(set.map((pkg) => ({ name: pkg.name, version: pkg.version })), { ecosystem: "pypi", env, ...(cooldownContext ? { cooldown: cooldownContext.param } : {}) });
|
|
106
175
|
}
|
|
107
176
|
catch {
|
|
108
177
|
return PROCEED;
|
|
109
178
|
}
|
|
110
|
-
return decideFromVerdicts(verdicts.packages, defaultPromptIo());
|
|
179
|
+
return decideFromVerdicts(verdicts.packages, defaultPromptIo(), cooldownContext, resolvePreflightDecisions("pypi", cwd, env));
|
|
111
180
|
}
|
|
112
|
-
export async function decideFromVerdicts(packages, io) {
|
|
113
|
-
|
|
114
|
-
|
|
181
|
+
export async function decideFromVerdicts(packages, io, cooldownContext, decisions = null) {
|
|
182
|
+
if (io.isTTY) {
|
|
183
|
+
renderProvenanceDowngrades(packages, io.output);
|
|
184
|
+
}
|
|
185
|
+
const covered = [];
|
|
186
|
+
const entries = packages
|
|
187
|
+
.map((pkg) => {
|
|
188
|
+
const viaCooldown = isQuarantined(pkg, cooldownContext) && pkg.action !== "block";
|
|
189
|
+
const action = viaCooldown ? "block" : pkg.action ?? "pass";
|
|
190
|
+
return { pkg, action, viaCooldown };
|
|
191
|
+
})
|
|
192
|
+
.filter((entry) => {
|
|
193
|
+
if ((entry.action !== "warn" && entry.action !== "block")
|
|
194
|
+
|| isPreflightApproved({ name: entry.pkg.name, version: entry.pkg.version, action: entry.action })) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (decisions && entry.action === "warn" && matchDecision(entry.pkg, decisions.ecosystem, decisions.file.decisions).acknowledged) {
|
|
198
|
+
covered.push(entry.pkg);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
});
|
|
203
|
+
if (covered.length > 0 && io.isTTY) {
|
|
204
|
+
renderCoveredWarns(covered, decisions, io.output);
|
|
205
|
+
recordPreflightApprovals(covered.map((pkg) => ({ name: pkg.name, version: pkg.version, action: "warn" })));
|
|
206
|
+
}
|
|
207
|
+
if (entries.length === 0 || !io.isTTY) {
|
|
115
208
|
return PROCEED;
|
|
116
209
|
}
|
|
117
|
-
renderPreflight(
|
|
118
|
-
const hasBlock =
|
|
210
|
+
renderPreflight(entries, io.output);
|
|
211
|
+
const hasBlock = entries.some((entry) => entry.action === "block");
|
|
119
212
|
const accepted = await promptPreflightYesNo(hasBlock ? " Override and install anyway?" : " Proceed?", io, false);
|
|
120
213
|
if (!accepted) {
|
|
121
214
|
return { proceed: false };
|
|
122
215
|
}
|
|
123
|
-
recordPreflightApprovals(
|
|
216
|
+
recordPreflightApprovals(entries.map((entry) => ({ name: entry.pkg.name, version: entry.pkg.version, action: entry.action })));
|
|
217
|
+
if (!hasBlock && decisions) {
|
|
218
|
+
await offerRememberOnIo({
|
|
219
|
+
io,
|
|
220
|
+
file: decisions.file,
|
|
221
|
+
packages: entries
|
|
222
|
+
.filter((entry) => entry.action === "warn")
|
|
223
|
+
.map((entry) => ({ ecosystem: decisions.ecosystem, name: entry.pkg.name, version: entry.pkg.version, findings: entry.pkg.findings })),
|
|
224
|
+
acceptedBy: resolveAcceptedBy(decisions.root, decisions.env ?? process.env),
|
|
225
|
+
surface: "install preflight",
|
|
226
|
+
env: decisions.env ?? process.env
|
|
227
|
+
});
|
|
228
|
+
}
|
|
124
229
|
return hasBlock ? { proceed: true, forceOverride: { force: true } } : PROCEED;
|
|
125
230
|
}
|
|
231
|
+
export function renderCoveredWarns(covered, decisions, out) {
|
|
232
|
+
for (const pkg of covered) {
|
|
233
|
+
const match = decisions ? matchDecision(pkg, decisions.ecosystem, decisions.file.decisions) : { acknowledged: false };
|
|
234
|
+
const who = match.acknowledged ? match.entry.acceptedBy : "dg.json";
|
|
235
|
+
const when = match.acknowledged && match.entry.acceptedAt ? ` on ${match.entry.acceptedAt.slice(0, 10)}` : "";
|
|
236
|
+
out.write(` ⚠ ${packageKey(pkg.name, pkg.version)} warn previously accepted by ${who}${when} — see 'dg decisions'\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
126
239
|
export async function promptPreflightYesNo(question, io, defaultYes) {
|
|
127
240
|
if (!io.isTTY) {
|
|
128
241
|
return false;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { analyzePackages } from "../api/analyze.js";
|
|
2
2
|
import { isCiEnv } from "../presentation/mode.js";
|
|
3
|
+
import { matchDecision } from "../decisions/apply.js";
|
|
4
|
+
import { offerRememberOnIo } from "../decisions/remember-prompt.js";
|
|
3
5
|
import { renderInstallDecision } from "../install-ui/block-render.js";
|
|
4
6
|
import { defaultPromptIo } from "../install-ui/prompt.js";
|
|
7
|
+
import { resolveAcceptedBy } from "../project/dgfile.js";
|
|
5
8
|
import { enforceProtectedInstall } from "../proxy/enforcement.js";
|
|
6
9
|
import { classifyPackageManagerInvocation, isSupportedPackageManager } from "./classify.js";
|
|
7
|
-
import { actionRank, promptPreflightYesNo, recordPreflightApprovals } from "./install-preflight.js";
|
|
10
|
+
import { actionRank, promptPreflightYesNo, recordPreflightApprovals, renderCoveredWarns, renderProvenanceDowngrades, resolvePreflightDecisions } from "./install-preflight.js";
|
|
8
11
|
import { redactSecrets } from "./output-redaction.js";
|
|
9
12
|
const ECOSYSTEM_BY_MANAGER = {
|
|
10
13
|
npm: "npm",
|
|
@@ -38,25 +41,37 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
|
|
|
38
41
|
if (specs.length === 0) {
|
|
39
42
|
return FALL_THROUGH;
|
|
40
43
|
}
|
|
44
|
+
const decisions = options.decisionsCwd ? resolvePreflightDecisions(ecosystem, options.decisionsCwd, env) : null;
|
|
41
45
|
const flagged = [];
|
|
46
|
+
const covered = [];
|
|
42
47
|
try {
|
|
43
48
|
const response = await (options.analyze ?? analyzePackages)(specs.map((spec) => ({ name: spec.name, version: spec.version })), { ecosystem, env });
|
|
49
|
+
renderProvenanceDowngrades(response.packages, io.output);
|
|
44
50
|
for (const spec of specs) {
|
|
45
51
|
const pkg = response.packages.find((entry) => entry.name === spec.name && entry.version === spec.version);
|
|
46
52
|
const action = pkg?.action ?? "pass";
|
|
47
53
|
if (action === "pass") {
|
|
48
54
|
continue;
|
|
49
55
|
}
|
|
56
|
+
if (decisions && pkg && action === "warn" && matchDecision(pkg, decisions.ecosystem, decisions.file.decisions).acknowledged) {
|
|
57
|
+
covered.push(pkg);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
50
60
|
flagged.push({
|
|
51
61
|
action,
|
|
52
62
|
spec,
|
|
53
|
-
reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner"
|
|
63
|
+
reason: pkg?.reasons[0] ?? pkg?.findings[0]?.title ?? "flagged by the scanner",
|
|
64
|
+
findings: pkg?.findings ?? []
|
|
54
65
|
});
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
68
|
catch {
|
|
58
69
|
return FALL_THROUGH;
|
|
59
70
|
}
|
|
71
|
+
if (covered.length > 0) {
|
|
72
|
+
renderCoveredWarns(covered, decisions, io.output);
|
|
73
|
+
recordPreflightApprovals(covered.map((pkg) => ({ name: pkg.name, version: pkg.version, action: "warn" })));
|
|
74
|
+
}
|
|
60
75
|
const worst = flagged.reduce((top, entry) => (!top || actionRank(entry.action) > actionRank(top.action) ? entry : top), undefined);
|
|
61
76
|
if (!worst) {
|
|
62
77
|
return FALL_THROUGH;
|
|
@@ -97,6 +112,18 @@ export async function maybePreflightInstallPrompt(args, options = {}) {
|
|
|
97
112
|
const proceed = await promptPreflightYesNo(`⚠ DG flagged ${label} (warn) — ${worst.reason}. Proceed?`, io, false);
|
|
98
113
|
if (proceed) {
|
|
99
114
|
recordPreflightApprovals(flagged.map(asFlaggedPackage));
|
|
115
|
+
if (decisions) {
|
|
116
|
+
await offerRememberOnIo({
|
|
117
|
+
io,
|
|
118
|
+
file: decisions.file,
|
|
119
|
+
packages: flagged
|
|
120
|
+
.filter((entry) => entry.action === "warn")
|
|
121
|
+
.map((entry) => ({ ecosystem, name: entry.spec.name, version: entry.spec.version, findings: entry.findings })),
|
|
122
|
+
acceptedBy: resolveAcceptedBy(decisions.root, env),
|
|
123
|
+
surface: "install preflight",
|
|
124
|
+
env
|
|
125
|
+
});
|
|
126
|
+
}
|
|
100
127
|
return FALL_THROUGH;
|
|
101
128
|
}
|
|
102
129
|
return {
|
package/dist/launcher/run.js
CHANGED
|
@@ -16,6 +16,7 @@ import { cachedPipResolution } from "./install-preflight.js";
|
|
|
16
16
|
import { parsePipReportInstallCount } from "./pip-report.js";
|
|
17
17
|
import { createStreamRedactor, redactSecrets } from "./output-redaction.js";
|
|
18
18
|
import { resolveRealBinary } from "./resolve-real-binary.js";
|
|
19
|
+
import { runScriptGateAfterInstall } from "../scripts/gate.js";
|
|
19
20
|
export const EXIT_INSTALL_BLOCKED = 2;
|
|
20
21
|
const CMD_SCRIPT_PATTERN = /\.(cmd|bat)$/i;
|
|
21
22
|
const CMD_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
|
|
@@ -129,7 +130,7 @@ export async function runPackageManager(manager, args, options = {}) {
|
|
|
129
130
|
return {
|
|
130
131
|
exitCode: child.exitCode,
|
|
131
132
|
stdout: streamedOut(child.stdout, options),
|
|
132
|
-
stderr: `${rendered}${streamedErr(child.stderr, options)}`
|
|
133
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}`
|
|
133
134
|
};
|
|
134
135
|
}
|
|
135
136
|
const child = await spawnPackageManager(plan, args, options);
|
|
@@ -195,7 +196,7 @@ async function runWithProductionProxy(plan, args, options) {
|
|
|
195
196
|
return {
|
|
196
197
|
exitCode: child.exitCode,
|
|
197
198
|
stdout: streamedOut(child.stdout, options),
|
|
198
|
-
stderr: `${rendered}${streamedErr(child.stderr, options)}`
|
|
199
|
+
stderr: `${rendered}${streamedErr(child.stderr, options)}${scriptGateLine(plan, child.exitCode, options)}`
|
|
199
200
|
};
|
|
200
201
|
}
|
|
201
202
|
finally {
|
|
@@ -203,6 +204,16 @@ async function runWithProductionProxy(plan, args, options) {
|
|
|
203
204
|
stopProxyWorker(proxy);
|
|
204
205
|
}
|
|
205
206
|
}
|
|
207
|
+
function scriptGateLine(plan, exitCode, options) {
|
|
208
|
+
if (exitCode !== 0) {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
return runScriptGateAfterInstall({
|
|
212
|
+
classification: plan.classification,
|
|
213
|
+
env: options.env ?? process.env,
|
|
214
|
+
...(options.projectDir ? { projectDir: options.projectDir } : {})
|
|
215
|
+
});
|
|
216
|
+
}
|
|
206
217
|
export function deriveLiveView(state, phase, resolvedTotal) {
|
|
207
218
|
const verified = state.decisions.filter((decision) => decision.action === "pass").length;
|
|
208
219
|
const warnDecisions = state.decisions.filter((decision) => decision.action === "warn");
|
|
@@ -331,7 +342,7 @@ export async function runWithProductionProxyLive(plan, args, options, onView) {
|
|
|
331
342
|
const outcome = installOutcome(finished.stdout);
|
|
332
343
|
return { exitCode: EXIT_INSTALL_BLOCKED, stdout: outcome, stderr: cacheOnlyNotice(outcome.length > 0) };
|
|
333
344
|
}
|
|
334
|
-
return { exitCode: finished.exitCode, stdout: installOutcome(finished.stdout), stderr:
|
|
345
|
+
return { exitCode: finished.exitCode, stdout: installOutcome(finished.stdout), stderr: scriptGateLine(plan, finished.exitCode, options) };
|
|
335
346
|
}
|
|
336
347
|
finally {
|
|
337
348
|
restoreSignalHandlers();
|