@westbayberry/dg 2.0.10 → 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.
Files changed (43) hide show
  1. package/dist/api/analyze.js +5 -3
  2. package/dist/bin/dg.js +1 -1
  3. package/dist/commands/completion.js +2 -1
  4. package/dist/commands/config.js +11 -3
  5. package/dist/commands/decisions.js +155 -0
  6. package/dist/commands/explain.js +6 -2
  7. package/dist/commands/router.js +2 -0
  8. package/dist/commands/scan.js +2 -1
  9. package/dist/commands/status.js +5 -2
  10. package/dist/config/settings.js +144 -25
  11. package/dist/decisions/apply.js +128 -0
  12. package/dist/decisions/remember-prompt.js +97 -0
  13. package/dist/install-ui/block-render.js +21 -4
  14. package/dist/install-ui/prompt.js +14 -0
  15. package/dist/launcher/install-preflight.js +126 -13
  16. package/dist/launcher/preflight-prompt.js +29 -2
  17. package/dist/launcher/run.js +14 -3
  18. package/dist/policy/cooldown.js +104 -0
  19. package/dist/policy/evaluate.js +0 -15
  20. package/dist/presentation/provenance.js +23 -0
  21. package/dist/project/dgfile.js +307 -0
  22. package/dist/proxy/enforcement.js +2 -1
  23. package/dist/proxy/metadata-map.js +25 -1
  24. package/dist/proxy/server.js +31 -2
  25. package/dist/scan/collect.js +10 -4
  26. package/dist/scan/command.js +35 -8
  27. package/dist/scan/discovery.js +66 -4
  28. package/dist/scan/render.js +35 -4
  29. package/dist/scan/scanner-report.js +31 -4
  30. package/dist/scan/staged.js +69 -10
  31. package/dist/scan-ui/LegacyApp.js +4 -4
  32. package/dist/scan-ui/components/InteractiveResultsView.js +64 -7
  33. package/dist/scan-ui/hooks/useScan.js +31 -3
  34. package/dist/scan-ui/launch.js +4 -1
  35. package/dist/scan-ui/shims.js +3 -0
  36. package/dist/scripts/detect.js +153 -0
  37. package/dist/scripts/gate.js +170 -0
  38. package/dist/scripts/rebuild.js +28 -0
  39. package/dist/setup/plan.js +36 -1
  40. package/dist/util/json-file.js +24 -0
  41. package/dist/util/tty-prompt.js +13 -6
  42. package/dist/verify/package-check.js +12 -0
  43. package/package.json +9 -1
@@ -0,0 +1,104 @@
1
+ import { parseCooldownAge } from "../config/settings.js";
2
+ export function durationToDays(value) {
3
+ const trimmed = value.trim();
4
+ if (trimmed === "" || trimmed === "0" || trimmed === "off") {
5
+ return 0;
6
+ }
7
+ const match = /^([1-9]\d{0,3})(h|d)$/.exec(trimmed);
8
+ if (!match || !match[1] || !match[2]) {
9
+ return 0;
10
+ }
11
+ const amount = Number.parseInt(match[1], 10);
12
+ return match[2] === "h" ? amount / 24 : amount;
13
+ }
14
+ export function effectiveCooldownAge(config, env, ecosystem) {
15
+ const fromEnv = env.DG_COOLDOWN_AGE;
16
+ if (fromEnv !== undefined) {
17
+ try {
18
+ return parseCooldownAge(fromEnv, "DG_COOLDOWN_AGE", false);
19
+ }
20
+ catch {
21
+ // fall through to config: a malformed env override must not change policy
22
+ }
23
+ }
24
+ const perEcosystem = ecosystem === "npm"
25
+ ? config.cooldown.npmAge
26
+ : ecosystem === "pypi"
27
+ ? config.cooldown.pypiAge
28
+ : config.cooldown.cargoAge;
29
+ return perEcosystem !== "" ? perEcosystem : config.cooldown.age;
30
+ }
31
+ function normalizePypiName(name) {
32
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
33
+ }
34
+ export function isCooldownExempt(packageName, exempt, ecosystem) {
35
+ const name = ecosystem === "pypi" ? normalizePypiName(packageName) : packageName;
36
+ return exempt
37
+ .split(",")
38
+ .map((entry) => entry.trim())
39
+ .filter(Boolean)
40
+ .some((pattern) => {
41
+ const candidate = ecosystem === "pypi" ? normalizePypiName(pattern) : pattern;
42
+ if (candidate.endsWith("*")) {
43
+ return name.startsWith(candidate.slice(0, -1));
44
+ }
45
+ return name === candidate;
46
+ });
47
+ }
48
+ export function cooldownRequestParam(config, env, ecosystem, packageName) {
49
+ const minAgeDays = durationToDays(effectiveCooldownAge(config, env, ecosystem));
50
+ if (minAgeDays <= 0) {
51
+ return undefined;
52
+ }
53
+ if (packageName && isCooldownExempt(packageName, config.cooldown.exempt, ecosystem)) {
54
+ return undefined;
55
+ }
56
+ return { minAgeDays, onUnknown: config.cooldown.onUnknown };
57
+ }
58
+ export function formatCooldownDuration(days) {
59
+ const hours = days * 24;
60
+ if (hours <= 0) {
61
+ return "0h";
62
+ }
63
+ if (hours < 48) {
64
+ return `${Math.round(hours)}h`;
65
+ }
66
+ return `${Math.floor(days)}d`;
67
+ }
68
+ export function formatPackageAge(ageDays) {
69
+ if (ageDays < 0) {
70
+ return "in the future";
71
+ }
72
+ const hours = ageDays * 24;
73
+ if (hours < 1) {
74
+ return "<1h ago";
75
+ }
76
+ if (hours < 48) {
77
+ return `${Math.floor(hours)}h ago`;
78
+ }
79
+ return `${Math.floor(ageDays)}d ago`;
80
+ }
81
+ export function describeCooldownSettings(config, env) {
82
+ if (env.DG_COOLDOWN_AGE !== undefined) {
83
+ try {
84
+ const envDays = durationToDays(parseCooldownAge(env.DG_COOLDOWN_AGE, "DG_COOLDOWN_AGE", false));
85
+ return `${envDays > 0 ? formatCooldownDuration(envDays) : "off"} (DG_COOLDOWN_AGE)`;
86
+ }
87
+ catch {
88
+ // malformed env override is ignored everywhere; describe the config instead
89
+ }
90
+ }
91
+ const baseDays = durationToDays(config.cooldown.age);
92
+ const base = baseDays > 0 ? formatCooldownDuration(baseDays) : "off";
93
+ const overrides = ["npm", "pypi", "cargo"]
94
+ .map((ecosystem) => {
95
+ const value = ecosystem === "npm" ? config.cooldown.npmAge : ecosystem === "pypi" ? config.cooldown.pypiAge : config.cooldown.cargoAge;
96
+ if (value === "") {
97
+ return undefined;
98
+ }
99
+ const days = durationToDays(value);
100
+ return `${ecosystem} ${days > 0 ? formatCooldownDuration(days) : "off"}`;
101
+ })
102
+ .filter((entry) => entry !== undefined);
103
+ return overrides.length > 0 ? `${base} (${overrides.join(", ")})` : base;
104
+ }
@@ -100,18 +100,6 @@ export function applyForceOverride(options, env = process.env) {
100
100
  webhookQueued: emitWebhookEvent(event, env)
101
101
  };
102
102
  }
103
- export function scriptPolicyArgs(options) {
104
- if (!options.policy.scriptHardening && options.policy.mode !== "strict") {
105
- return options.existingArgs;
106
- }
107
- if (hasScriptDisableFlag(options.existingArgs)) {
108
- return options.existingArgs;
109
- }
110
- if (options.manager === "yarn") {
111
- return [...options.existingArgs, "--ignore-scripts"];
112
- }
113
- return [...options.existingArgs, "--ignore-scripts"];
114
- }
115
103
  function findTrustedAllowlist(packageName, policy, allowlists) {
116
104
  return allowlists.find((entry) => {
117
105
  if (entry.packageName !== packageName) {
@@ -123,6 +111,3 @@ function findTrustedAllowlist(packageName, policy, allowlists) {
123
111
  return true;
124
112
  });
125
113
  }
126
- function hasScriptDisableFlag(args) {
127
- return args.some((arg) => arg === "--ignore-scripts" || arg === "--ignore-scripts=true");
128
- }
@@ -0,0 +1,23 @@
1
+ export function provenanceLabel(provenance) {
2
+ if (provenance.status === "attested") {
3
+ const tag = slsaTag(provenance.predicateType);
4
+ return tag ? `attested (${tag})` : "attested";
5
+ }
6
+ if (provenance.status === "none") {
7
+ return "none";
8
+ }
9
+ return "unknown (not yet checked)";
10
+ }
11
+ export function provenanceDowngradeLine(version, provenance) {
12
+ if (!provenance.downgrade) {
13
+ return null;
14
+ }
15
+ return `provenance downgraded — ${provenance.downgrade.fromVersion} was attested, ${version} is not`;
16
+ }
17
+ function slsaTag(predicateType) {
18
+ if (!predicateType) {
19
+ return null;
20
+ }
21
+ const match = /slsa\.dev\/provenance\/(v[0-9][0-9.]*)/.exec(predicateType);
22
+ return match ? `slsa ${match[1]}` : null;
23
+ }
@@ -0,0 +1,307 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { userInfo } from "node:os";
4
+ import { join } from "node:path";
5
+ import { gitTrimmed } from "../util/git.js";
6
+ import { writeJsonAtomic } from "../util/json-file.js";
7
+ export const DG_FILE_NAME = "dg.json";
8
+ export const DECISION_ENTRY_CAP = 500;
9
+ export const DECISION_REASON_MAX = 500;
10
+ const KNOWN_TOP_LEVEL_KEYS = new Set(["version", "scriptApprovals", "decisions"]);
11
+ const KNOWN_SCRIPT_APPROVAL_KEYS = new Set(["npm", "observed"]);
12
+ const SCRIPT_HOOKS = ["preinstall", "install", "postinstall", "gyp"];
13
+ export function dgFilePath(root) {
14
+ return join(root, DG_FILE_NAME);
15
+ }
16
+ export function findProjectRoot(cwd, env = process.env) {
17
+ try {
18
+ return gitTrimmed(["rev-parse", "--show-toplevel"], { cwd, env });
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function emptyDgFile(path) {
25
+ return { path, exists: false, readable: true, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
26
+ }
27
+ export function loadDgFile(root) {
28
+ const path = dgFilePath(root);
29
+ if (!existsSync(path)) {
30
+ return emptyDgFile(path);
31
+ }
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(readFileSync(path, "utf8"));
35
+ }
36
+ catch (error) {
37
+ return failOpen(path, `malformed JSON (${error instanceof Error ? error.message : "parse error"})`);
38
+ }
39
+ if (!isPlainObject(raw)) {
40
+ return failOpen(path, "top level must be a JSON object");
41
+ }
42
+ if (raw.version !== undefined && raw.version !== 1) {
43
+ return failOpen(path, `unsupported dg.json version ${String(raw.version)}`);
44
+ }
45
+ const listed = raw.decisions;
46
+ if (listed !== undefined && !Array.isArray(listed)) {
47
+ return failOpen(path, "decisions must be an array");
48
+ }
49
+ const entries = Array.isArray(listed) ? listed : [];
50
+ if (entries.length > DECISION_ENTRY_CAP) {
51
+ return failOpen(path, `more than ${DECISION_ENTRY_CAP} decisions`);
52
+ }
53
+ const approvalsRaw = raw.scriptApprovals;
54
+ if (approvalsRaw !== undefined && !isPlainObject(approvalsRaw)) {
55
+ return failOpen(path, "scriptApprovals must be an object");
56
+ }
57
+ const decisions = [];
58
+ for (const entry of entries) {
59
+ const parsed = parseDecisionEntry(entry);
60
+ if (parsed) {
61
+ decisions.push(parsed);
62
+ }
63
+ }
64
+ const approvals = approvalsRaw ?? {};
65
+ return {
66
+ path,
67
+ exists: true,
68
+ readable: true,
69
+ raw,
70
+ decisions,
71
+ scriptApprovals: {
72
+ npm: parseEntryMap(approvals.npm, parseApprovalEntry),
73
+ observed: parseEntryMap(approvals.observed, parseObservedEntry),
74
+ unknownKeys: unknownKeysOf(approvals, KNOWN_SCRIPT_APPROVAL_KEYS)
75
+ }
76
+ };
77
+ }
78
+ export function warnUnreadableDgFile(file, write = (line) => process.stderr.write(line)) {
79
+ if (!file.readable) {
80
+ write(`dg: ignoring decisions in ${file.path} — ${file.failure ?? "unreadable"} (no warnings suppressed)\n`);
81
+ }
82
+ }
83
+ export function appendDecisions(file, additions, now = new Date()) {
84
+ const added = additions.map((decision) => ({
85
+ ...decision,
86
+ id: randomUUID(),
87
+ reason: decision.reason.slice(0, DECISION_REASON_MAX),
88
+ acceptedAt: decision.acceptedAt ?? now.toISOString()
89
+ }));
90
+ return { ...file, decisions: [...file.decisions, ...added] };
91
+ }
92
+ export function removeDecisions(file, ids) {
93
+ return { ...file, decisions: file.decisions.filter((entry) => !ids.has(entry.id)) };
94
+ }
95
+ export function saveDgFile(file) {
96
+ if (!file.readable) {
97
+ throw new Error(`refusing to rewrite ${file.path}: ${file.failure ?? "unreadable"}`);
98
+ }
99
+ const top = unknownKeysOf(file.raw, KNOWN_TOP_LEVEL_KEYS);
100
+ if (file.decisions.length > 0) {
101
+ top.decisions = file.decisions.map(serializeDecisionEntry);
102
+ }
103
+ const approvals = { ...file.scriptApprovals.unknownKeys };
104
+ if (Object.keys(file.scriptApprovals.npm).length > 0) {
105
+ approvals.npm = sortedRecord(file.scriptApprovals.npm);
106
+ }
107
+ if (Object.keys(file.scriptApprovals.observed).length > 0) {
108
+ approvals.observed = sortedRecord(file.scriptApprovals.observed);
109
+ }
110
+ if (Object.keys(approvals).length > 0) {
111
+ top.scriptApprovals = sortedRecord(approvals);
112
+ }
113
+ const ordered = { version: 1 };
114
+ for (const key of Object.keys(top).sort()) {
115
+ ordered[key] = top[key];
116
+ }
117
+ writeJsonAtomic(file.path, ordered, { fileMode: 0o644, dirMode: 0o755 });
118
+ }
119
+ export function resolveAcceptedBy(cwd, env = process.env) {
120
+ try {
121
+ const email = gitTrimmed(["config", "user.email"], { cwd, env });
122
+ if (email) {
123
+ return email;
124
+ }
125
+ }
126
+ catch {
127
+ /* fall through */
128
+ }
129
+ try {
130
+ return userInfo().username;
131
+ }
132
+ catch {
133
+ return "unknown";
134
+ }
135
+ }
136
+ function serializeDecisionEntry(entry) {
137
+ return {
138
+ ...entry.extra,
139
+ id: entry.id,
140
+ ecosystem: entry.ecosystem,
141
+ name: entry.name,
142
+ scope: entry.scope.kind === "exact" ? { kind: "exact", version: entry.scope.version } : { kind: "any" },
143
+ findings: entry.findings,
144
+ reason: entry.reason,
145
+ acceptedBy: entry.acceptedBy,
146
+ acceptedAt: entry.acceptedAt,
147
+ ...(entry.expiresAt ? { expiresAt: entry.expiresAt } : {})
148
+ };
149
+ }
150
+ const ENTRY_FIELDS = new Set(["id", "ecosystem", "name", "scope", "findings", "reason", "acceptedBy", "acceptedAt", "expiresAt"]);
151
+ function parseDecisionEntry(value) {
152
+ if (!isPlainObject(value)) {
153
+ return null;
154
+ }
155
+ const ecosystem = value.ecosystem;
156
+ if (ecosystem !== "npm" && ecosystem !== "pypi") {
157
+ return null;
158
+ }
159
+ const name = value.name;
160
+ if (typeof name !== "string" || name.length === 0) {
161
+ return null;
162
+ }
163
+ const scope = parseScope(value.scope);
164
+ if (!scope) {
165
+ return null;
166
+ }
167
+ const findings = parseFindings(value.findings);
168
+ if (!findings) {
169
+ return null;
170
+ }
171
+ const expiresAt = value.expiresAt;
172
+ if (expiresAt !== undefined && typeof expiresAt !== "string") {
173
+ return null;
174
+ }
175
+ const extra = {};
176
+ for (const [key, field] of Object.entries(value)) {
177
+ if (!ENTRY_FIELDS.has(key)) {
178
+ extra[key] = field;
179
+ }
180
+ }
181
+ return {
182
+ id: typeof value.id === "string" && value.id.length > 0 ? value.id : derivedEntryId(value),
183
+ ecosystem,
184
+ name,
185
+ scope,
186
+ findings,
187
+ reason: typeof value.reason === "string" ? value.reason.slice(0, DECISION_REASON_MAX) : "",
188
+ acceptedBy: typeof value.acceptedBy === "string" && value.acceptedBy.length > 0 ? value.acceptedBy : "unknown",
189
+ acceptedAt: typeof value.acceptedAt === "string" ? value.acceptedAt : "",
190
+ ...(typeof expiresAt === "string" ? { expiresAt } : {}),
191
+ ...(Object.keys(extra).length > 0 ? { extra } : {})
192
+ };
193
+ }
194
+ function parseScope(value) {
195
+ if (!isPlainObject(value)) {
196
+ return null;
197
+ }
198
+ if (value.kind === "any") {
199
+ return { kind: "any" };
200
+ }
201
+ if (value.kind === "exact" && typeof value.version === "string" && value.version.length > 0) {
202
+ return { kind: "exact", version: value.version };
203
+ }
204
+ return null;
205
+ }
206
+ function parseFindings(value) {
207
+ if (value === undefined) {
208
+ return {};
209
+ }
210
+ if (!isPlainObject(value)) {
211
+ return null;
212
+ }
213
+ const findings = {};
214
+ for (const [category, severity] of Object.entries(value)) {
215
+ if (typeof severity !== "number" || !Number.isInteger(severity) || severity < 1 || severity > 5) {
216
+ return null;
217
+ }
218
+ findings[category] = severity;
219
+ }
220
+ return findings;
221
+ }
222
+ function derivedEntryId(value) {
223
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 12);
224
+ }
225
+ function parseEntryMap(raw, parseEntry) {
226
+ if (!isPlainObject(raw)) {
227
+ return {};
228
+ }
229
+ const entries = {};
230
+ for (const [name, value] of Object.entries(raw)) {
231
+ const parsed = parseEntry(value);
232
+ if (parsed !== null) {
233
+ entries[name] = parsed;
234
+ }
235
+ }
236
+ return entries;
237
+ }
238
+ function parseApprovalEntry(value) {
239
+ if (!isPlainObject(value)) {
240
+ return null;
241
+ }
242
+ const decision = value.decision;
243
+ if (decision !== "allow" && decision !== "deny") {
244
+ return null;
245
+ }
246
+ if (typeof value.scriptsHash !== "string" || typeof value.approvedAt !== "string") {
247
+ return null;
248
+ }
249
+ const provenance = value.provenance;
250
+ if (provenance !== "prompt" && provenance !== "command" && provenance !== "imported-pnpm") {
251
+ return null;
252
+ }
253
+ return {
254
+ decision,
255
+ scriptsHash: value.scriptsHash,
256
+ hooks: parseHooks(value.hooks),
257
+ ...(typeof value.approvedVersion === "string" ? { approvedVersion: value.approvedVersion } : {}),
258
+ ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
259
+ approvedAt: value.approvedAt,
260
+ provenance
261
+ };
262
+ }
263
+ function parseObservedEntry(value) {
264
+ if (!isPlainObject(value)) {
265
+ return null;
266
+ }
267
+ if (typeof value.version !== "string" || typeof value.scriptsHash !== "string" || typeof value.firstSeen !== "string") {
268
+ return null;
269
+ }
270
+ return {
271
+ version: value.version,
272
+ hooks: parseHooks(value.hooks),
273
+ scriptsHash: value.scriptsHash,
274
+ firstSeen: value.firstSeen
275
+ };
276
+ }
277
+ function parseHooks(raw) {
278
+ if (!Array.isArray(raw)) {
279
+ return [];
280
+ }
281
+ return SCRIPT_HOOKS.filter((hook) => raw.includes(hook));
282
+ }
283
+ function sortedRecord(record) {
284
+ const sorted = {};
285
+ for (const key of Object.keys(record).sort()) {
286
+ sorted[key] = record[key];
287
+ }
288
+ return sorted;
289
+ }
290
+ function unknownKeysOf(raw, known) {
291
+ const unknown = {};
292
+ for (const [key, value] of Object.entries(raw)) {
293
+ if (!known.has(key)) {
294
+ unknown[key] = value;
295
+ }
296
+ }
297
+ return unknown;
298
+ }
299
+ function isPlainObject(value) {
300
+ return typeof value === "object" && value !== null && !Array.isArray(value);
301
+ }
302
+ function emptyScriptApprovals() {
303
+ return { npm: {}, observed: {}, unknownKeys: {} };
304
+ }
305
+ function failOpen(path, failure) {
306
+ return { path, exists: true, readable: false, failure, raw: {}, decisions: [], scriptApprovals: emptyScriptApprovals() };
307
+ }
@@ -140,6 +140,7 @@ function withOptionalDecisionFields(decision, verdict) {
140
140
  ...(verdict.dashboardUrl ? { dashboardUrl: verdict.dashboardUrl } : {}),
141
141
  ...(verdict.unauthenticated ? { unauthenticated: true } : {}),
142
142
  ...(verdict.resetsAt ? { resetsAt: verdict.resetsAt } : {}),
143
- ...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {})
143
+ ...(verdict.quotaBehavior ? { quotaBehavior: verdict.quotaBehavior } : {}),
144
+ ...(verdict.cooldown ? { cooldown: verdict.cooldown } : {})
144
145
  };
145
146
  }
@@ -202,7 +202,9 @@ function fallbackIdentity(artifactUrl, classification) {
202
202
  const ecosystem = ecosystemForManager(classification.manager);
203
203
  const parsed = ecosystem === "pypi"
204
204
  ? parsePypiArtifactFilename(decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
205
- : parsePackageVersionFromUrl(artifactUrl);
205
+ : ecosystem === "cargo"
206
+ ? parseCargoArtifactUrl(artifactUrl) ?? parsePackageVersionFromUrl(artifactUrl)
207
+ : parsePackageVersionFromUrl(artifactUrl);
206
208
  const requested = requestedIdentityFromArgs(classification, parsed.name);
207
209
  return {
208
210
  ecosystem,
@@ -213,6 +215,28 @@ function fallbackIdentity(artifactUrl, classification) {
213
215
  sourceKind: "url-fallback"
214
216
  };
215
217
  }
218
+ // crates.io serves downloads at /api/v1/crates/{name}/{version}/download,
219
+ // static.crates.io at /crates/{name}/{version}/download and as
220
+ // {name}-{version}.crate files. Crate versions always start with a digit
221
+ // (semver), so the filename split is unambiguous even for names with dashes.
222
+ function parseCargoArtifactUrl(artifactUrl) {
223
+ const downloadMatch = /^\/(?:api\/v1\/)?crates\/([^/]+)\/([^/]+)\/download\/?$/.exec(artifactUrl.pathname);
224
+ if (downloadMatch && downloadMatch[1] && downloadMatch[2]) {
225
+ return {
226
+ name: decodeURIComponent(downloadMatch[1]),
227
+ version: decodeURIComponent(downloadMatch[2])
228
+ };
229
+ }
230
+ const file = decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "");
231
+ if (/\.crate$/i.test(file)) {
232
+ const stem = file.slice(0, -".crate".length);
233
+ const match = /^(.+)-(\d[0-9A-Za-z.+-]*)$/.exec(stem);
234
+ if (match && match[1] && match[2]) {
235
+ return { name: match[1], version: match[2] };
236
+ }
237
+ }
238
+ return null;
239
+ }
216
240
  function parsePackageVersionFromUrl(artifactUrl) {
217
241
  const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
218
242
  const file = parts.at(-1) ?? artifactUrl.hostname;
@@ -11,6 +11,8 @@ import { createSecureContext, createServer as createTlsServer, connect as tlsCon
11
11
  import { createEphemeralCertificateAuthority } from "./ca.js";
12
12
  import { shouldMitmHost } from "./classify-host.js";
13
13
  import { enforceProtectedInstall } from "./enforcement.js";
14
+ import { cooldownRequestParam } from "../policy/cooldown.js";
15
+ import { loadUserConfig } from "../config/settings.js";
14
16
  import { artifactDisplayName, artifactUrlHash, extractRegistryMetadataIdentities, isRegistryIndexRequest, resolveArtifactIdentity } from "./metadata-map.js";
15
17
  import { authorityFor, connectViaUpstreamProxy, selectUpstreamProxy } from "./upstream-proxy.js";
16
18
  import { redactSecrets } from "../launcher/output-redaction.js";
@@ -625,6 +627,7 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
625
627
  if (shouldUseScanTarball(options, target, identity)) {
626
628
  return lookupScanTarballVerdict(options, target, sha256, upstream, identity);
627
629
  }
630
+ const cooldown = resolveCooldownRequest(options.env, identity);
628
631
  const controller = new AbortController();
629
632
  const timeout = setTimeout(() => controller.abort(), options.verdictTimeoutMs ?? installVerdictTimeoutMs(options.env));
630
633
  try {
@@ -647,7 +650,8 @@ async function lookupVerdict(options, target, sha256, upstream, identity) {
647
650
  sourceKind: identity.sourceKind,
648
651
  sha256,
649
652
  statusCode: upstream.statusCode,
650
- contentType: headerValue(upstream.headers["content-type"])
653
+ contentType: headerValue(upstream.headers["content-type"]),
654
+ ...(cooldown ? { cooldown } : {})
651
655
  }),
652
656
  signal: controller.signal
653
657
  });
@@ -762,13 +766,37 @@ function normalizeVerdict(value, target, identity, streamedSha256) {
762
766
  };
763
767
  }
764
768
  const cause = typeof value.cause === "string" ? value.cause : undefined;
769
+ const cooldown = parseCooldownInfo(value.cooldown);
765
770
  return {
766
771
  verdict,
767
772
  packageName: typeof value.packageName === "string" ? value.packageName : artifactDisplayName(identity) || packageNameFromUrl(target),
768
773
  ...(isProxyCause(cause) ? { cause } : {}),
769
774
  reason: typeof value.reason === "string" ? value.reason : `API verdict ${verdict}`,
770
775
  ...(typeof value.dashboardUrl === "string" ? { dashboardUrl: value.dashboardUrl } : {}),
771
- ...(value.unauthenticated === true ? { unauthenticated: true } : {})
776
+ ...(value.unauthenticated === true ? { unauthenticated: true } : {}),
777
+ ...(cooldown ? { cooldown } : {})
778
+ };
779
+ }
780
+ function resolveCooldownRequest(env, identity) {
781
+ if (identity.ecosystem === "unknown" || identity.version === "unknown") {
782
+ return undefined;
783
+ }
784
+ try {
785
+ return cooldownRequestParam(loadUserConfig(env), env, identity.ecosystem, identity.name);
786
+ }
787
+ catch {
788
+ return undefined;
789
+ }
790
+ }
791
+ function parseCooldownInfo(value) {
792
+ if (!isRecord(value) || typeof value.requiredDays !== "number" || !Number.isFinite(value.requiredDays)) {
793
+ return undefined;
794
+ }
795
+ return {
796
+ requiredDays: value.requiredDays,
797
+ ...(typeof value.ageDays === "number" && Number.isFinite(value.ageDays) ? { ageDays: value.ageDays } : {}),
798
+ ...(typeof value.publishedAt === "string" ? { publishedAt: value.publishedAt } : {}),
799
+ ...(typeof value.eligibleAt === "string" ? { eligibleAt: value.eligibleAt } : {})
772
800
  };
773
801
  }
774
802
  function normalizeScanTarballVerdict(value, target, identity, streamedSha256) {
@@ -920,6 +948,7 @@ function isProxyCause(value) {
920
948
  "api-timeout",
921
949
  "registry-timeout",
922
950
  "analysis-incomplete",
951
+ "cooldown",
923
952
  "unsupported-manager",
924
953
  "proxy-setup-failure"
925
954
  ].includes(value ?? "");
@@ -1,5 +1,6 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
+ import { gitIgnoredDirectories } from "./discovery.js";
3
4
  import { parseLockfilePackages } from "../verify/preflight.js";
4
5
  export const LOCKFILE_ECOSYSTEMS = {
5
6
  "package-lock.json": "npm",
@@ -49,11 +50,15 @@ function lockfilesPerEcosystem(entries) {
49
50
  }
50
51
  return matches;
51
52
  }
52
- function shouldDescend(entry) {
53
- return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name) && !entry.name.startsWith(".");
53
+ function shouldDescend(entry, directory, gitIgnored) {
54
+ return (entry.isDirectory() &&
55
+ !IGNORED_DIRECTORIES.has(entry.name) &&
56
+ !entry.name.startsWith(".") &&
57
+ !gitIgnored.has(join(directory, entry.name)));
54
58
  }
55
59
  export function discoverScanProjects(root) {
56
60
  const projects = [];
61
+ const gitIgnored = gitIgnoredDirectories(root);
57
62
  walk(root, 0);
58
63
  return projects;
59
64
  function walk(directory, depth) {
@@ -71,7 +76,7 @@ export function discoverScanProjects(root) {
71
76
  });
72
77
  }
73
78
  for (const entry of entries) {
74
- if (shouldDescend(entry)) {
79
+ if (shouldDescend(entry, directory, gitIgnored)) {
75
80
  walk(join(directory, entry.name), depth + 1);
76
81
  }
77
82
  }
@@ -79,6 +84,7 @@ export function discoverScanProjects(root) {
79
84
  }
80
85
  export async function discoverScanProjectsAsync(root, onProgress) {
81
86
  const projects = [];
87
+ const gitIgnored = gitIgnoredDirectories(root);
82
88
  let lastYield = Date.now();
83
89
  await walk(root, 0);
84
90
  return projects;
@@ -103,7 +109,7 @@ export async function discoverScanProjectsAsync(root, onProgress) {
103
109
  onProgress?.({ path: relativePath === "." ? depFile : `${relativePath}/${depFile}`, found: projects.length });
104
110
  }
105
111
  for (const entry of entries) {
106
- if (shouldDescend(entry)) {
112
+ if (shouldDescend(entry, directory, gitIgnored)) {
107
113
  await walk(join(directory, entry.name), depth + 1);
108
114
  }
109
115
  }