@pho9ubenaa/siro 0.3.0 → 0.4.1

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.
@@ -0,0 +1,2211 @@
1
+ import path from "node:path";
2
+ import { accessSync, constants, readFileSync, statSync } from "node:fs";
3
+ import { pathToFileURL } from "node:url";
4
+ import satisfies from "semver/functions/satisfies.js";
5
+ import * as vb from "valibot";
6
+ import pc from "picocolors";
7
+ import ini from "ini";
8
+ import { parse } from "smol-toml";
9
+ import { parseDocument } from "yaml";
10
+ import valid from "semver/functions/valid.js";
11
+ //#region src/shared/errors.ts
12
+ var SiroError = class extends Error {
13
+ exitCode;
14
+ constructor(message, exitCode) {
15
+ super(message);
16
+ this.name = "SiroError";
17
+ this.exitCode = exitCode;
18
+ }
19
+ };
20
+ var ConfigError = class extends SiroError {
21
+ constructor(message) {
22
+ super(message, 2);
23
+ this.name = "ConfigError";
24
+ }
25
+ };
26
+ var UsageError = class extends SiroError {
27
+ constructor(message) {
28
+ super(message, 2);
29
+ this.name = "UsageError";
30
+ }
31
+ };
32
+ /**
33
+ * Run `fn` and wrap any non-`ConfigError` failure as a `ConfigError` prefixed
34
+ * with `filePath`. A `ConfigError` that bubbles up from a nested call is re-thrown unchanged so
35
+ * the original `path: message` framing is preserved.
36
+ */
37
+ const wrapCodecError = (filePath, fn) => {
38
+ try {
39
+ return fn();
40
+ } catch (error) {
41
+ if (error instanceof ConfigError) throw error;
42
+ let msg = String(error);
43
+ if (error instanceof Error) msg = error.message;
44
+ throw new ConfigError(`${filePath}: ${msg}`);
45
+ }
46
+ };
47
+ //#endregion
48
+ //#region src/shared/paths.ts
49
+ const isAbsPath = (value) => typeof value === "string" && !value.includes("\0") && path.isAbsolute(value);
50
+ const isRelPath = (value) => typeof value === "string" && value.length > 0 && !value.includes("\0") && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !/^[a-z]:/iu.test(value) && !value.split(/[\\/]/u).includes("..");
51
+ const asAbsPath = (value) => {
52
+ if (!isAbsPath(value)) throw new TypeError("Expected an absolute filesystem path.");
53
+ return value;
54
+ };
55
+ const asRelPath = (value) => {
56
+ if (!isRelPath(value)) throw new TypeError("Expected a repository-relative path without parent traversal.");
57
+ return value;
58
+ };
59
+ //#endregion
60
+ //#region src/adapters/node-errors.ts
61
+ /** Only the Error and code properties are established by this guard. */
62
+ const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
63
+ //#endregion
64
+ //#region src/adapters/node-file-system.ts
65
+ const nodeFileSystem = {
66
+ exists(filePath) {
67
+ try {
68
+ accessSync(filePath, constants.F_OK);
69
+ return true;
70
+ } catch (error) {
71
+ if (isNodeError(error) && error.code === "ENOENT") return false;
72
+ throw error;
73
+ }
74
+ },
75
+ readText(filePath) {
76
+ try {
77
+ return readFileSync(filePath, "utf8");
78
+ } catch (error) {
79
+ if (isNodeError(error) && error.code === "ENOENT") return;
80
+ throw error;
81
+ }
82
+ }
83
+ };
84
+ const resolveIn = (root, relPath) => asAbsPath(path.join(root, asRelPath(relPath)));
85
+ const assertDirectory = (root) => {
86
+ if (!statSync(root).isDirectory()) throw new UsageError("The lint target must be a directory.");
87
+ };
88
+ //#endregion
89
+ //#region package.json
90
+ var version$1 = "0.4.1";
91
+ //#endregion
92
+ //#region src/shared/node-version.ts
93
+ const SUPPORTED_NODE_RANGE = { "node": "^22.18.0 || ^24.0.0" }.node;
94
+ const isSupportedNodeVersion = (version) => satisfies(version, SUPPORTED_NODE_RANGE);
95
+ //#endregion
96
+ //#region src/domain/entities/pms.ts
97
+ /**
98
+ * Single source of truth for package managers and severities.
99
+ * Types, runtime sets, and ordering are all derived from these tuples — add a
100
+ * package manager here and the rest of the codebase follows.
101
+ */
102
+ const PMS = [
103
+ "npm",
104
+ "pnpm",
105
+ "yarn",
106
+ "bun",
107
+ "deno",
108
+ "aube"
109
+ ];
110
+ const SEVERITIES = [
111
+ "error",
112
+ "warn",
113
+ "info"
114
+ ];
115
+ /**
116
+ * Numeric ordering of severities for threshold comparisons. Co-located with
117
+ * `Severity` so `filter`, `runLint`, and any future severity-aware code share
118
+ * one source of truth — drifting the order in one place silently changes the
119
+ * fail-on-threshold contract everywhere.
120
+ */
121
+ const SEVERITY_RANK = {
122
+ error: 3,
123
+ info: 1,
124
+ warn: 2
125
+ };
126
+ const PM_SET = new Set(PMS);
127
+ const SEVERITY_SET = new Set(SEVERITIES);
128
+ const isPM = (value) => PM_SET.has(value);
129
+ const isSeverity = (value) => SEVERITY_SET.has(value);
130
+ const PACKAGE_MANAGER_FIELD = /^(?<pmName>[a-z]+)(?:@.+)?$/u;
131
+ const parsePackageManagerField = (value) => {
132
+ const name = PACKAGE_MANAGER_FIELD.exec(value.trim())?.groups?.pmName;
133
+ if (typeof name !== "undefined" && isPM(name)) return name;
134
+ };
135
+ //#endregion
136
+ //#region src/domain/entities/project-type.ts
137
+ const PROJECT_TYPES = ["application", "package"];
138
+ const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
139
+ //#endregion
140
+ //#region src/shared/records.ts
141
+ /** Configuration maps are ordinary objects or null-prototype dictionaries. */
142
+ const isPlainRecord = (value) => {
143
+ if (typeof value !== "object" || value === null) return false;
144
+ const prototype = Object.getPrototypeOf(value);
145
+ return prototype === Object.prototype || prototype === null;
146
+ };
147
+ //#endregion
148
+ //#region src/domain/ports/reporter.ts
149
+ const isReporterShape = (value) => {
150
+ return isPlainRecord(value) && typeof value.name === "string" && typeof value.format === "function";
151
+ };
152
+ //#endregion
153
+ //#region src/domain/entities/config-value.ts
154
+ const CODEC_KINDS = [
155
+ "json",
156
+ "npmrc",
157
+ "toml",
158
+ "yaml"
159
+ ];
160
+ /** Read an own key path; absent or non-mapping parents yield undefined. */
161
+ const getByPath = (config, keyPath) => {
162
+ let current = config;
163
+ for (const key of keyPath) {
164
+ if (!isPlainRecord(current)) return;
165
+ if (!Object.hasOwn(current, key)) return;
166
+ current = current[key];
167
+ }
168
+ return current;
169
+ };
170
+ /** Accept only mapping roots while preserving unvalidated values inside them. */
171
+ const toParsedConfig = (value) => {
172
+ if (isPlainRecord(value)) return value;
173
+ throw new TypeError("Config root must be a mapping.");
174
+ };
175
+ //#endregion
176
+ //#region src/domain/entities/rule.ts
177
+ const defineRule = (rule) => rule;
178
+ const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
179
+ const CONFIG_FILE_KINDS = new Set(CODEC_KINDS);
180
+ const isConfigFileRefShape = (value) => {
181
+ if (!isPlainRecord(value) || typeof value.kind !== "string" || !isRelPath(value.path)) return false;
182
+ return CONFIG_FILE_KINDS.has(value.kind);
183
+ };
184
+ const isConfigValueShape = (value) => typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value);
185
+ const isDenseStringArray = (value) => Array.isArray(value) && Array.from(value).every((item) => typeof item === "string");
186
+ const isCheckStatusShape = (value) => {
187
+ if (!isPlainRecord(value)) return false;
188
+ if (value.state === "ok" || value.state === "na") return true;
189
+ return value.state === "violation" && typeof value.message === "string" && (value.file === void 0 || isRelPath(value.file)) && (value.expected === void 0 || isConfigValueShape(value.expected)) && (value.severity === void 0 || typeof value.severity === "string" && isSeverity(value.severity)) && !("manualSteps" in value) && !("fix" in value) && !("fixable" in value) && (value.remediation === void 0 || isRemediationShape(value.remediation));
190
+ };
191
+ const isSetKeyOperation = (value) => isPlainRecord(value) && value.op === "setKey" && isConfigFileRefShape(value.file) && isDenseStringArray(value.keyPath) && value.keyPath.length > 0 && isConfigValueShape(value.value);
192
+ const isRemediationShape = (value) => {
193
+ if (!isPlainRecord(value)) return false;
194
+ if (value.kind === "manual") return !("operations" in value) && isDenseStringArray(value.steps) && value.steps.length > 0;
195
+ return value.kind === "automatic" && !("steps" in value) && Array.isArray(value.operations) && value.operations.length > 0 && Array.from(value.operations).every(isSetKeyOperation);
196
+ };
197
+ const isVersionNoteShape = (value) => typeof value === "undefined" || isPlainRecord(value) && isOptionalString(value.configAvailableSince) && isOptionalString(value.defaultSafeSince) && isOptionalString(value.note);
198
+ const isRuleBindingShape = (value) => {
199
+ if (!isPlainRecord(value)) return false;
200
+ return (value.file === void 0 || isConfigFileRefShape(value.file)) && !("fix" in value) && !("fixKind" in value) && !("fileGlob" in value) && isOptionalString(value.docs) && (typeof value.severity === "undefined" || typeof value.severity === "string" && isSeverity(value.severity)) && isVersionNoteShape(value.versionNote) && typeof value.check === "function";
201
+ };
202
+ const isBindingsShape = (value) => isPlainRecord(value) && Object.keys(value).every(isPM) && PMS.every((pm) => value[pm] === void 0 || isRuleBindingShape(value[pm]));
203
+ const isProjectTypeValue = (value) => typeof value === "string" && isProjectType(value);
204
+ const isProjectTypesShape = (value) => typeof value === "undefined" || Array.isArray(value) && Array.from(value).every((projectType) => isProjectTypeValue(projectType));
205
+ const isRuleShape = (value) => {
206
+ if (!isPlainRecord(value)) return false;
207
+ return typeof value.id === "string" && typeof value.title === "string" && typeof value.description === "string" && typeof value.severity === "string" && isSeverity(value.severity) && isOptionalString(value.docs) && isProjectTypesShape(value.projectTypes) && isBindingsShape(value.bindings);
208
+ };
209
+ //#endregion
210
+ //#region src/application/config.ts
211
+ const RuleSettingSchema = vb.union([vb.picklist(SEVERITIES), vb.literal("off")]);
212
+ const ConfigSchema = vb.strictObject({
213
+ customRules: vb.optional(vb.array(vb.custom(isRuleShape, "must be a structurally valid rule"))),
214
+ pms: vb.optional(vb.pipe(vb.array(vb.picklist(PMS)), vb.minLength(1, "must not be empty (omit the key to auto-detect, or list at least one PM)"))),
215
+ projectType: vb.optional(vb.picklist(PROJECT_TYPES)),
216
+ reporters: vb.optional(vb.array(vb.custom(isReporterShape, "must be a { name, format } reporter"))),
217
+ rules: vb.optional(vb.pipe(vb.custom(isPlainRecord, "must be an object of rule settings"), vb.rawTransform(({ dataset, addIssue }) => {
218
+ const entries = [];
219
+ for (const [key, value] of Object.entries(dataset.value)) {
220
+ const result = vb.safeParse(RuleSettingSchema, value);
221
+ if (result.success) entries.push([key, result.output]);
222
+ else addIssue({
223
+ message: result.issues[0].message,
224
+ path: [{
225
+ input: dataset.value,
226
+ key,
227
+ origin: "value",
228
+ type: "object",
229
+ value
230
+ }]
231
+ });
232
+ }
233
+ return Object.fromEntries(entries);
234
+ })))
235
+ }, "unknown config key (check for a typo)");
236
+ const formatIssues = (issues) => issues.map((issue) => {
237
+ const keyPath = (issue.path ?? []).map((seg) => seg.key).filter((key) => typeof key === "string" || typeof key === "number").join(".");
238
+ if (keyPath) return `${keyPath}: ${issue.message}`;
239
+ return issue.message;
240
+ }).join("; ");
241
+ const parseConfig = (candidate, name = "siro.config") => {
242
+ if (!isPlainRecord(candidate)) throw new ConfigError(`${name} must export a config object (got ${Array.isArray(candidate) ? "an array" : typeof candidate}).`);
243
+ const result = vb.safeParse(ConfigSchema, Object.fromEntries(Object.entries(candidate)));
244
+ if (!result.success) throw new ConfigError(`${name}: ${formatIssues(result.issues)}`);
245
+ return result.output;
246
+ };
247
+ //#endregion
248
+ //#region src/adapters/config-loader.ts
249
+ const CONFIG_NAMES = [
250
+ "siro.config.ts",
251
+ "siro.config.mjs",
252
+ "siro.config.js"
253
+ ];
254
+ const describeError = (error) => error instanceof Error ? error.message : String(error);
255
+ let loadCounter = 0;
256
+ /** Load the first matching config; executable imports always use the real filesystem. */
257
+ const loadConfig = async (cwd, nodeVersion = process.versions.node) => {
258
+ const name = CONFIG_NAMES.find((candidate) => nodeFileSystem.exists(asAbsPath(path.join(cwd, candidate))));
259
+ if (name === void 0) return;
260
+ if (name.endsWith(".ts") && !isSupportedNodeVersion(nodeVersion)) throw new ConfigError(`${name} requires Node.js with native type stripping (${SUPPORTED_NODE_RANGE}); current is v${nodeVersion}. Rename the config to siro.config.mjs (plain JS) or upgrade Node.js.`);
261
+ const url = pathToFileURL(path.join(cwd, name));
262
+ url.searchParams.set("siro-load", String(++loadCounter));
263
+ let mod;
264
+ try {
265
+ mod = await import(url.href);
266
+ } catch (error) {
267
+ throw new ConfigError(`Failed to load ${name}: ${describeError(error)}`);
268
+ }
269
+ return parseConfig(mod !== null && typeof mod === "object" && "default" in mod ? mod.default : mod, name);
270
+ };
271
+ //#endregion
272
+ //#region src/domain/services/filter.ts
273
+ const EXIT_SUCCESS = 0;
274
+ const EXIT_FAILURE = 1;
275
+ const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
276
+ /** Keep only findings at or above `threshold`, recomputing the summary. */
277
+ const filterBySeverity = (result, threshold) => {
278
+ const findings = [];
279
+ const summary = {
280
+ error: 0,
281
+ info: 0,
282
+ warn: 0
283
+ };
284
+ for (const finding of result.findings) if (meetsThreshold(finding.severity, threshold)) {
285
+ findings.push(finding);
286
+ summary[finding.severity] += 1;
287
+ }
288
+ return {
289
+ findings,
290
+ summary
291
+ };
292
+ };
293
+ /**
294
+ * Exit code for a lint run. Non-zero when any finding meets `threshold`
295
+ * (default: only `error` fails the run).
296
+ */
297
+ const exitCodeForLint = (result, threshold = "error") => {
298
+ if (result.findings.some((finding) => meetsThreshold(finding.severity, threshold))) return EXIT_FAILURE;
299
+ return EXIT_SUCCESS;
300
+ };
301
+ //#endregion
302
+ //#region src/adapters/reporters/github.ts
303
+ const COMMAND = {
304
+ error: "error",
305
+ info: "notice",
306
+ warn: "warning"
307
+ };
308
+ const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
309
+ const escapeProp = (raw) => escapeData(raw).replaceAll(":", "%3A").replaceAll(",", "%2C");
310
+ /** Emit GitHub Actions workflow commands (annotations on PRs). */
311
+ const githubReporter = {
312
+ format(result, io) {
313
+ for (const finding of result.findings) {
314
+ let file = "";
315
+ if (finding.file) file = `file=${escapeProp(finding.file)},`;
316
+ let body = `[${finding.pm}] ${finding.message}`;
317
+ if (finding.docs) body += ` (${finding.docs})`;
318
+ io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
319
+ }
320
+ },
321
+ name: "github"
322
+ };
323
+ //#endregion
324
+ //#region src/version.ts
325
+ const version = version$1;
326
+ //#endregion
327
+ //#region src/adapters/reporters/json.ts
328
+ /**
329
+ * Versioned machine-readable output — the public contract consumed by
330
+ * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
331
+ * breaking shape change and update that doc in the same commit.
332
+ */
333
+ const SCHEMA_VERSION = 2;
334
+ const jsonReporter = {
335
+ format(result, io) {
336
+ io.stdout(JSON.stringify({
337
+ schemaVersion: SCHEMA_VERSION,
338
+ siroVersion: version,
339
+ findings: result.findings,
340
+ summary: result.summary
341
+ }, void 0, 2));
342
+ },
343
+ name: "json"
344
+ };
345
+ //#endregion
346
+ //#region src/adapters/reporters/pretty.ts
347
+ const GLYPH = {
348
+ error: "✖ error",
349
+ info: "ℹ info",
350
+ warn: "⚠ warn"
351
+ };
352
+ /**
353
+ * Decide colour at call time, not at import time.
354
+ *
355
+ * picocolors snapshots `isColorSupported` when imported, so a later
356
+ * `process.env.NO_COLOR = '1'` (or a different stdout) is ignored. The
357
+ * reporter is invoked per `siro lint` run from many contexts (TTY, CI
358
+ * with FORCE_COLOR, redirected output, …) — checking each call is the
359
+ * only way NO_COLOR can truly win, as https://no-color.org/ requires.
360
+ */
361
+ const colorSupportedNow = () => {
362
+ const { env } = process;
363
+ if (typeof env.NO_COLOR !== "undefined" && env.NO_COLOR !== "") return false;
364
+ if ("FORCE_COLOR" in env) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "";
365
+ return pc.isColorSupported;
366
+ };
367
+ const renderFinding = (finding, ctx) => {
368
+ let where = "";
369
+ if (finding.file) where = ctx.colors.dim(` (${finding.file})`);
370
+ ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm}] ${ctx.colors.bold(finding.ruleId)}${where}`);
371
+ ctx.io.stdout(` ${finding.message}`);
372
+ for (const step of finding.remediation?.kind === "manual" ? finding.remediation.steps : []) ctx.io.stdout(` ↳ ${step}`);
373
+ if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${finding.docs}`));
374
+ };
375
+ const buildRenderCtx = (io) => {
376
+ const colors = pc.createColors(colorSupportedNow());
377
+ return {
378
+ colors,
379
+ io,
380
+ tag: {
381
+ error: colors.red,
382
+ info: colors.cyan,
383
+ warn: colors.yellow
384
+ }
385
+ };
386
+ };
387
+ const prettyReporter = {
388
+ format(result, io) {
389
+ const ctx = buildRenderCtx(io);
390
+ if (result.findings.length === 0) {
391
+ io.stdout(ctx.colors.green("✔ No security best-practice issues found."));
392
+ return;
393
+ }
394
+ for (const finding of result.findings) renderFinding(finding, ctx);
395
+ const { error, warn, info } = result.summary;
396
+ io.stdout("");
397
+ io.stdout(`Summary: ${error} error, ${warn} warn, ${info} info`);
398
+ },
399
+ name: "pretty"
400
+ };
401
+ //#endregion
402
+ //#region src/adapters/reporters/registry.ts
403
+ const BUILTINS = [
404
+ prettyReporter,
405
+ jsonReporter,
406
+ githubReporter
407
+ ];
408
+ const DEFAULT_REPORTER_NAME = prettyReporter.name;
409
+ const JSON_REPORTER_NAME = jsonReporter.name;
410
+ /** Names of every built-in reporter, in stable display order. */
411
+ const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
412
+ /** Later registrations replace earlier reporters with the same name. */
413
+ const createRegistry = (extras = []) => new Map([...BUILTINS, ...extras].map((reporter) => [reporter.name, reporter]));
414
+ //#endregion
415
+ //#region src/domain/schemas/package-json.ts
416
+ const PackageJsonSchema = vb.looseObject({
417
+ files: vb.optional(vb.array(vb.string())),
418
+ name: vb.optional(vb.string()),
419
+ packageManager: vb.optional(vb.string()),
420
+ private: vb.optional(vb.boolean()),
421
+ publishConfig: vb.optional(vb.looseObject({ access: vb.optional(vb.picklist([
422
+ "public",
423
+ "restricted",
424
+ "private"
425
+ ])) })),
426
+ trustedDependencies: vb.optional(vb.array(vb.string()))
427
+ });
428
+ const parsePackageJson = (value) => {
429
+ if (!isPlainRecord(value)) throw new ConfigError("package.json: expected an object at the root.");
430
+ const result = vb.safeParse(PackageJsonSchema, value);
431
+ if (result.success) return result.output;
432
+ const issue = result.issues[0];
433
+ const field = vb.getDotPath(issue);
434
+ throw new ConfigError(`package.json: ${field ? `${field}: ` : ""}${issue.message}`);
435
+ };
436
+ //#endregion
437
+ //#region src/adapters/repo-context.ts
438
+ const tryParseJson = (text) => {
439
+ try {
440
+ return JSON.parse(text);
441
+ } catch (error) {
442
+ let errMsg = String(error);
443
+ if (error instanceof Error) errMsg = error.message;
444
+ throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
445
+ }
446
+ };
447
+ const readPackageJson = (raw) => {
448
+ return parsePackageJson(tryParseJson(raw.trim()));
449
+ };
450
+ const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
451
+ if (fs === nodeFileSystem) assertDirectory(root);
452
+ const readText = (relPath) => fs.readText(resolveIn(root, relPath));
453
+ const exists = (relPath) => fs.exists(resolveIn(root, relPath));
454
+ const raw = readText(asRelPath("package.json"));
455
+ let packageJson = void 0;
456
+ if (typeof raw !== "undefined") packageJson = readPackageJson(raw);
457
+ return {
458
+ exists,
459
+ packageJson,
460
+ projectType,
461
+ readText,
462
+ root
463
+ };
464
+ };
465
+ //#endregion
466
+ //#region src/adapters/codecs/ini.ts
467
+ const INTEGER_PATTERN = /^-?\d+$/u;
468
+ const coerceBoolean = (str) => {
469
+ if (str === "true") return true;
470
+ if (str === "false") return false;
471
+ };
472
+ const coerceInteger = (str) => {
473
+ if (INTEGER_PATTERN.test(str)) return Number(str);
474
+ };
475
+ const coerce = (raw) => {
476
+ if (typeof raw !== "string") return raw;
477
+ return coerceBoolean(raw) ?? coerceInteger(raw) ?? raw;
478
+ };
479
+ //#endregion
480
+ //#region src/adapters/codecs/store.ts
481
+ const CODECS = {
482
+ json: { parse(text) {
483
+ const trimmed = text.trim();
484
+ return toParsedConfig(JSON.parse(trimmed));
485
+ } },
486
+ npmrc: { parse(text) {
487
+ const raw = toParsedConfig(ini.parse(text));
488
+ const config = {};
489
+ for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
490
+ else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
491
+ else config[key] = coerce(value);
492
+ return config;
493
+ } },
494
+ toml: { parse(text) {
495
+ if (text.trim() === "") return {};
496
+ return toParsedConfig(parse(text.replace(/^\uFEFF/u, "")));
497
+ } },
498
+ yaml: { parse(text) {
499
+ if (text.trim() === "") return {};
500
+ const document = parseDocument(text);
501
+ if (document.errors.length > 0) throw document.errors[0];
502
+ if (document.contents === null) return {};
503
+ return toParsedConfig(document.toJS({ onAnchor: (value, count) => {
504
+ if (count > 1) JSON.stringify(value);
505
+ } }));
506
+ } }
507
+ };
508
+ /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
509
+ const codecFor = (kind) => CODECS[kind];
510
+ //#endregion
511
+ //#region src/domain/rules/remediation.ts
512
+ /** Scalar writes must not discard an existing container or replace a non-object parent. */
513
+ const proposeChanges = (config, operations) => {
514
+ for (const operation of operations) {
515
+ let current = config;
516
+ for (const key of operation.keyPath) {
517
+ if (current === void 0) break;
518
+ if (!isPlainRecord(current)) return {
519
+ kind: "manual",
520
+ steps: [`Review ${operation.file.path}: ${operation.keyPath.join(".")} has a non-object parent. Restructure it before setting the value to ${JSON.stringify(operation.value)}.`]
521
+ };
522
+ current = Object.hasOwn(current, key) ? current[key] : void 0;
523
+ }
524
+ if (current !== null && typeof current === "object") return {
525
+ kind: "manual",
526
+ steps: [`Review ${operation.file.path}: ${operation.keyPath.join(".")} contains nested settings. Preserve the required entries when correcting this value.`]
527
+ };
528
+ }
529
+ return {
530
+ kind: "automatic",
531
+ operations
532
+ };
533
+ };
534
+ //#endregion
535
+ //#region src/domain/rules/builders/require-config-key.ts
536
+ const overrideBindings = (rule, overrides) => ({
537
+ ...rule,
538
+ bindings: {
539
+ ...rule.bindings,
540
+ ...overrides
541
+ }
542
+ });
543
+ const accepts = (spec, actual) => spec.accept ? spec.accept(actual) : actual === spec.value;
544
+ const checkKeyValue = (spec, config) => {
545
+ const actual = getByPath(config, spec.keyPath);
546
+ const coveredByDefault = actual === void 0 && spec.documentedDefault !== void 0 && spec.versionNote?.defaultSafeSince === void 0 && accepts(spec, spec.documentedDefault);
547
+ if (!coveredByDefault && accepts(spec, actual)) return { state: "ok" };
548
+ const severity = coveredByDefault ? spec.defaultSatisfiedSeverity ?? "info" : void 0;
549
+ if (severity === "off") return { state: "ok" };
550
+ return {
551
+ state: "violation",
552
+ actual,
553
+ expected: spec.value,
554
+ message: spec.message,
555
+ ...severity === void 0 ? {} : { severity },
556
+ remediation: proposeChanges(config, [{
557
+ file: spec.file,
558
+ keyPath: spec.keyPath,
559
+ op: "setKey",
560
+ value: spec.value
561
+ }])
562
+ };
563
+ };
564
+ const buildBinding = (spec, applies) => ({
565
+ check(ctx, config) {
566
+ if (typeof applies !== "undefined" && !applies(ctx)) return { state: "na" };
567
+ return checkKeyValue(spec, config);
568
+ },
569
+ docs: spec.docs,
570
+ file: spec.file,
571
+ severity: spec.severity,
572
+ versionNote: spec.versionNote
573
+ });
574
+ /** Build a Rule from a per-PM table of {file, keyPath, value, message}. */
575
+ const requireConfigKey = (options) => {
576
+ const bindings = {};
577
+ for (const pm of PMS) {
578
+ const spec = options.bindings[pm];
579
+ if (typeof spec !== "undefined") {
580
+ if ("extraFix" in spec) throw new TypeError("extraFix is no longer supported; use a custom binding.");
581
+ bindings[pm] = buildBinding(spec, options.applies);
582
+ }
583
+ }
584
+ return {
585
+ bindings,
586
+ description: options.description,
587
+ docs: options.docs,
588
+ id: options.id,
589
+ projectTypes: options.projectTypes,
590
+ severity: options.severity,
591
+ title: options.title
592
+ };
593
+ };
594
+ //#endregion
595
+ //#region src/domain/rules/builders/with-aube-paranoid.ts
596
+ const withAubeParanoid = (rule) => {
597
+ const { aube } = rule.bindings;
598
+ if (!aube) return rule;
599
+ return overrideBindings(rule, { aube: {
600
+ ...aube,
601
+ check(ctx, config) {
602
+ if (getByPath(config, ["paranoid"]) === true) return { state: "ok" };
603
+ return aube.check(ctx, config);
604
+ }
605
+ } });
606
+ };
607
+ //#endregion
608
+ //#region src/domain/entities/config-files.ts
609
+ /** Known configuration locations and their parsers. */
610
+ const CONFIG_FILES = {
611
+ aubeWorkspace: {
612
+ kind: "yaml",
613
+ path: asRelPath("aube-workspace.yaml")
614
+ },
615
+ bunfig: {
616
+ kind: "toml",
617
+ path: asRelPath("bunfig.toml")
618
+ },
619
+ denoJson: {
620
+ kind: "json",
621
+ path: asRelPath("deno.json")
622
+ },
623
+ npmrc: {
624
+ kind: "npmrc",
625
+ path: asRelPath(".npmrc")
626
+ },
627
+ packageJson: {
628
+ kind: "json",
629
+ path: asRelPath("package.json")
630
+ },
631
+ pnpmWorkspace: {
632
+ kind: "yaml",
633
+ path: asRelPath("pnpm-workspace.yaml")
634
+ },
635
+ yarnrc: {
636
+ kind: "yaml",
637
+ path: asRelPath(".yarnrc.yml")
638
+ }
639
+ };
640
+ //#endregion
641
+ //#region src/domain/rules/advisory-check.ts
642
+ const { aubeWorkspace: aubeWorkspace$8 } = CONFIG_FILES;
643
+ const advisoryCheck = withAubeParanoid(requireConfigKey({
644
+ bindings: { aube: {
645
+ accept: (value) => value === "on" || value === "required",
646
+ docs: "https://aube.jdx.dev/security.html",
647
+ documentedDefault: "on",
648
+ file: aubeWorkspace$8,
649
+ keyPath: ["advisoryCheck"],
650
+ message: "Set `advisoryCheck: on` (or `required`) in aube-workspace.yaml to check dependencies against known-malicious advisories.",
651
+ value: "on"
652
+ } },
653
+ description: "Query the OSV database for known-malicious packages during dependency resolution.",
654
+ docs: "https://aube.jdx.dev/security.html",
655
+ id: "advisory-check",
656
+ severity: "warn",
657
+ title: "Enable malicious-package advisory checks"
658
+ }));
659
+ //#endregion
660
+ //#region src/domain/rules/config-predicates.ts
661
+ const isNonBlankString = (value) => typeof value === "string" && value.trim().length > 0;
662
+ const isStringList = (value) => Array.isArray(value) && Array.from(value).every(isNonBlankString);
663
+ //#endregion
664
+ //#region src/domain/rules/approved-git-repos.ts
665
+ const { yarnrc: yarnrc$9 } = CONFIG_FILES;
666
+ const message = "Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).";
667
+ const approvedGitRepos = defineRule({
668
+ bindings: { yarn: {
669
+ check(_ctx, config) {
670
+ const value = getByPath(config, ["approvedGitRepositories"]);
671
+ if (isStringList(value)) return { state: "ok" };
672
+ return {
673
+ remediation: {
674
+ kind: "manual",
675
+ steps: ["Add `approvedGitRepositories: []` to .yarnrc.yml to block all git deps, or list specific approved repository URL globs."]
676
+ },
677
+ actual: value,
678
+ expected: "[]",
679
+ message,
680
+ state: "violation"
681
+ };
682
+ },
683
+ docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
684
+ file: yarnrc$9,
685
+ versionNote: { configAvailableSince: "yarn 4.14.0" }
686
+ } },
687
+ description: "Restrict git: protocol dependencies to an explicit allowlist of approved repository URL patterns.",
688
+ docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
689
+ id: "approved-git-repos",
690
+ severity: "warn",
691
+ title: "Approve git repository dependencies"
692
+ });
693
+ //#endregion
694
+ //#region src/domain/rules/audit-suppression.ts
695
+ const { yarnrc: yarnrc$8 } = CONFIG_FILES;
696
+ const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
697
+ const SUPPRESSION_KEYS = ["npmAuditIgnoreAdvisories", "npmAuditExcludePackages"];
698
+ const checkSuppression = (config) => {
699
+ const present = SUPPRESSION_KEYS.filter((key) => isNonEmptyArray(getByPath(config, [key])));
700
+ const [first] = present;
701
+ if (typeof first === "undefined") return { state: "ok" };
702
+ return {
703
+ actual: getByPath(config, [first]),
704
+ message: `Review audit suppression in .yarnrc.yml (${present.join(", ")}). Broad glob patterns can silently hide future vulnerabilities.`,
705
+ state: "violation",
706
+ remediation: {
707
+ kind: "manual",
708
+ steps: ["Review entries in `npmAuditIgnoreAdvisories` and `npmAuditExcludePackages` — remove stale suppressions and overly broad glob patterns."]
709
+ }
710
+ };
711
+ };
712
+ const auditSuppression = defineRule({
713
+ bindings: { yarn: {
714
+ check(_ctx, config) {
715
+ return checkSuppression(config);
716
+ },
717
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
718
+ file: yarnrc$8
719
+ } },
720
+ description: "Flag audit advisory suppressions that may silently hide future vulnerabilities via broad glob patterns.",
721
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
722
+ id: "audit-suppression",
723
+ severity: "info",
724
+ title: "Review audit suppression entries"
725
+ });
726
+ //#endregion
727
+ //#region src/domain/rules/block-auto-install.ts
728
+ const { bunfig: bunfig$5 } = CONFIG_FILES;
729
+ const blockAutoInstall = requireConfigKey({
730
+ bindings: { bun: {
731
+ docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
732
+ file: bunfig$5,
733
+ keyPath: ["install", "auto"],
734
+ message: "Set `auto = \"disable\"` under [install] in bunfig.toml to prevent auto-install from bypassing security guards.",
735
+ value: "disable",
736
+ accept: (value) => value === "disable" || value === false
737
+ } },
738
+ description: "Disable auto-install so dependencies are only installed through an explicit install step where security guards apply.",
739
+ docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
740
+ id: "block-auto-install",
741
+ severity: "warn",
742
+ title: "Block automatic package installation"
743
+ });
744
+ //#endregion
745
+ //#region src/domain/rules/block-exotic-subdeps.ts
746
+ const { aubeWorkspace: aubeWorkspace$7, npmrc: npmrc$6, pnpmWorkspace: pnpmWorkspace$10 } = CONFIG_FILES;
747
+ const isRootOrNone = (value) => value === "root" || value === "none";
748
+ const npmMessage = "Set `allow-git=none` and `allow-remote=none` in .npmrc to block git/tarball URLs; use `root` only when direct URL dependencies are required.";
749
+ const npmBinding$2 = {
750
+ check(_ctx, config) {
751
+ const git = getByPath(config, ["allow-git"]);
752
+ const remote = getByPath(config, ["allow-remote"]);
753
+ const invalid = [git, remote].find((value) => value !== void 0 && !isRootOrNone(value));
754
+ if (invalid === void 0 && git !== void 0 && remote !== void 0) return { state: "ok" };
755
+ return {
756
+ state: "violation",
757
+ expected: "none",
758
+ ...invalid === void 0 ? {} : { actual: invalid },
759
+ message: npmMessage,
760
+ remediation: proposeChanges(config, [{
761
+ file: npmrc$6,
762
+ keyPath: ["allow-git"],
763
+ op: "setKey",
764
+ value: "none"
765
+ }, {
766
+ file: npmrc$6,
767
+ keyPath: ["allow-remote"],
768
+ op: "setKey",
769
+ value: "none"
770
+ }])
771
+ };
772
+ },
773
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#allow-git",
774
+ file: npmrc$6,
775
+ versionNote: { defaultSafeSince: "npm 12.0.0" }
776
+ };
777
+ const blockExoticSubdeps = overrideBindings(requireConfigKey({
778
+ bindings: {
779
+ aube: {
780
+ docs: "https://aube.jdx.dev/security.html",
781
+ documentedDefault: true,
782
+ file: aubeWorkspace$7,
783
+ keyPath: ["blockExoticSubdeps"],
784
+ message: "Set `blockExoticSubdeps: true` in aube-workspace.yaml to block git/tarball URLs in transitive dependencies.",
785
+ value: true
786
+ },
787
+ pnpm: {
788
+ docs: "https://pnpm.io/settings#blockexoticsubdeps",
789
+ documentedDefault: true,
790
+ file: pnpmWorkspace$10,
791
+ keyPath: ["blockExoticSubdeps"],
792
+ message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
793
+ value: true,
794
+ versionNote: {
795
+ configAvailableSince: "pnpm 10.26.0",
796
+ defaultSafeSince: "pnpm 10.26.0"
797
+ }
798
+ }
799
+ },
800
+ description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
801
+ docs: "https://pnpm.io/settings#blockexoticsubdeps",
802
+ id: "block-exotic-subdeps",
803
+ severity: "warn",
804
+ title: "Block exotic subdependencies"
805
+ }), { npm: npmBinding$2 });
806
+ //#endregion
807
+ //#region src/domain/rules/bun-security-scanner.ts
808
+ const { bunfig: bunfig$4 } = CONFIG_FILES;
809
+ const bunSecurityScanner = defineRule({
810
+ bindings: { bun: {
811
+ check(_ctx, config) {
812
+ const scanner = getByPath(config, [
813
+ "install",
814
+ "security",
815
+ "scanner"
816
+ ]);
817
+ if (isNonBlankString(scanner)) return { state: "ok" };
818
+ return {
819
+ remediation: {
820
+ kind: "manual",
821
+ steps: ["Add `[install.security] scanner = \"@socketsecurity/bun-security-scanner\"` (or another bun-compatible scanner) to bunfig.toml."]
822
+ },
823
+ actual: scanner,
824
+ message: "Configure `[install.security] scanner = \"...\"` in bunfig.toml (e.g. `@socketsecurity/bun-security-scanner`) to scan new packages on install.",
825
+ state: "violation"
826
+ };
827
+ },
828
+ docs: "https://bun.com/docs/pm/security-scanner-api",
829
+ file: bunfig$4,
830
+ versionNote: { configAvailableSince: "bun 1.3.0" }
831
+ } },
832
+ description: "Bun supports a Security Scanner API that intercepts new packages at install time (e.g. Socket Firewall).",
833
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#preinstall-scanners",
834
+ id: "bun-security-scanner",
835
+ severity: "info",
836
+ title: "Enable a bun install-time security scanner"
837
+ });
838
+ //#endregion
839
+ //#region src/domain/rules/checksum-verification.ts
840
+ const { yarnrc: yarnrc$7 } = CONFIG_FILES;
841
+ const checksumVerification = requireConfigKey({
842
+ bindings: { yarn: {
843
+ docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
844
+ documentedDefault: "throw",
845
+ file: yarnrc$7,
846
+ keyPath: ["checksumBehavior"],
847
+ message: "Set `checksumBehavior: throw` in .yarnrc.yml to reject packages whose checksum does not match the lockfile.",
848
+ value: "throw"
849
+ } },
850
+ description: "Throw on checksum mismatches so tampered or corrupted packages are never silently installed.",
851
+ docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
852
+ id: "checksum-verification",
853
+ severity: "warn",
854
+ title: "Verify package checksums"
855
+ });
856
+ //#endregion
857
+ //#region src/domain/entities/signals.ts
858
+ const PM_SIGNALS = {
859
+ aube: {
860
+ configs: [CONFIG_FILES.aubeWorkspace.path],
861
+ lockfiles: ["aube-lock.yaml"],
862
+ reusesLockfiles: [
863
+ "pnpm-lock.yaml",
864
+ "package-lock.json",
865
+ "npm-shrinkwrap.json",
866
+ "yarn.lock",
867
+ "bun.lock"
868
+ ]
869
+ },
870
+ bun: {
871
+ configs: [CONFIG_FILES.bunfig.path],
872
+ lockfiles: ["bun.lock", "bun.lockb"]
873
+ },
874
+ deno: {
875
+ configs: [CONFIG_FILES.denoJson.path, "deno.jsonc"],
876
+ lockfiles: ["deno.lock"]
877
+ },
878
+ npm: {
879
+ configs: [],
880
+ lockfiles: ["package-lock.json", "npm-shrinkwrap.json"]
881
+ },
882
+ pnpm: {
883
+ configs: [CONFIG_FILES.pnpmWorkspace.path],
884
+ lockfiles: ["pnpm-lock.yaml"]
885
+ },
886
+ yarn: {
887
+ configs: [CONFIG_FILES.yarnrc.path],
888
+ lockfiles: ["yarn.lock"]
889
+ }
890
+ };
891
+ //#endregion
892
+ //#region src/domain/rules/commit-lockfile.ts
893
+ const LOCKFILE_DOCS = {
894
+ aube: "https://github.com/aubepkg/aube/blob/main/docs/package-manager/lockfiles.md",
895
+ bun: "https://bun.com/docs/install/lockfile",
896
+ deno: "https://docs.deno.com/runtime/fundamentals/modules/#integrity-checking-and-lock-files",
897
+ npm: "https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json",
898
+ pnpm: "https://pnpm.io/git#lockfiles",
899
+ yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
900
+ };
901
+ const LOCKFILE_VERSION_NOTES = { deno: { configAvailableSince: "deno 1.28.0" } };
902
+ const lockfileBinding = (pm) => {
903
+ const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
904
+ const [primary] = lockfiles;
905
+ const accepted = [...lockfiles, ...reusesLockfiles ?? []].filter((lockfile) => pm !== "npm" || lockfile === primary);
906
+ return {
907
+ check(ctx) {
908
+ if (pm === "aube" && ctx.exists(asRelPath("bun.lockb")) && !ctx.exists(asRelPath("bun.lock"))) return {
909
+ state: "violation",
910
+ message: "Aube cannot read bun.lockb, even when another lockfile exists.",
911
+ remediation: {
912
+ kind: "manual",
913
+ steps: ["Run `bun install --save-text-lockfile` to generate bun.lock, then commit it."]
914
+ }
915
+ };
916
+ if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
917
+ return {
918
+ remediation: {
919
+ kind: "manual",
920
+ steps: [`Install dependencies to generate ${primary}, then commit it.`]
921
+ },
922
+ message: `No lockfile found. Generate and commit ${primary}.`,
923
+ state: "violation"
924
+ };
925
+ },
926
+ docs: LOCKFILE_DOCS[pm],
927
+ versionNote: LOCKFILE_VERSION_NOTES[pm]
928
+ };
929
+ };
930
+ const denoBinding$2 = {
931
+ file: CONFIG_FILES.denoJson,
932
+ docs: LOCKFILE_DOCS.deno,
933
+ versionNote: LOCKFILE_VERSION_NOTES.deno,
934
+ check(ctx, config) {
935
+ const lock = getByPath(config, ["lock"]);
936
+ if (lock != null && typeof lock !== "boolean" && typeof lock !== "string" && !(isPlainRecord(lock) && (lock.path == null || typeof lock.path === "string") && (lock.frozen == null || typeof lock.frozen === "boolean"))) return {
937
+ state: "violation",
938
+ message: "Invalid Deno lock setting.",
939
+ remediation: {
940
+ kind: "manual",
941
+ steps: ["Use a boolean, a lockfile path, or an object with optional path and frozen fields."]
942
+ }
943
+ };
944
+ const selected = typeof lock === "string" ? lock : getByPath(config, ["lock", "path"]);
945
+ const lockPath = selected == null ? asRelPath("deno.lock") : selected;
946
+ if (lock !== false && isRelPath(lockPath) && ctx.exists(lockPath)) return { state: "ok" };
947
+ return {
948
+ state: "violation",
949
+ message: lock === false ? "Enable the Deno lockfile; `lock: false` disables it even when deno.lock exists." : `No readable project lockfile found at ${String(lockPath)}.`,
950
+ remediation: {
951
+ kind: "manual",
952
+ steps: ["Enable lockfile use in deno.json and generate and commit the configured lockfile within the project."]
953
+ }
954
+ };
955
+ }
956
+ };
957
+ const commitLockfile = defineRule({
958
+ bindings: {
959
+ aube: lockfileBinding("aube"),
960
+ bun: lockfileBinding("bun"),
961
+ deno: denoBinding$2,
962
+ npm: lockfileBinding("npm"),
963
+ pnpm: lockfileBinding("pnpm"),
964
+ yarn: lockfileBinding("yarn")
965
+ },
966
+ description: "Lockfiles pin the full dependency tree and integrity hashes, enabling reproducible, verifiable installs (e.g. `npm ci`).",
967
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
968
+ id: "commit-lockfile",
969
+ severity: "error",
970
+ title: "Commit a lockfile"
971
+ });
972
+ //#endregion
973
+ //#region src/domain/rules/dependency-overrides.ts
974
+ const { aubeWorkspace: aubeWorkspace$6, pnpmWorkspace: pnpmWorkspace$9 } = CONFIG_FILES;
975
+ const isNonEmptyObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
976
+ const checkOverrides = (config, file) => {
977
+ const value = getByPath(config, ["overrides"]);
978
+ if (!isNonEmptyObject$2(value)) return { state: "ok" };
979
+ return {
980
+ actual: value,
981
+ message: `Review \`overrides\` in ${file}. Overrides can replace transitive dependencies with arbitrary versions or forks.`,
982
+ state: "violation",
983
+ remediation: {
984
+ kind: "manual",
985
+ steps: ["Review each entry in `overrides` — verify pinned versions address a known CVE and are not redirecting to untrusted packages."]
986
+ }
987
+ };
988
+ };
989
+ const makeBinding = (file, fileName, docs) => ({
990
+ check(_ctx, config) {
991
+ return checkOverrides(config, fileName);
992
+ },
993
+ docs,
994
+ file
995
+ });
996
+ const dependencyOverrides = defineRule({
997
+ bindings: {
998
+ aube: makeBinding(aubeWorkspace$6, "aube-workspace.yaml", "https://aube.sh/settings/"),
999
+ pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
1000
+ },
1001
+ description: "Flag dependency overrides that can replace transitive packages with arbitrary versions or forks — a supply-chain injection vector.",
1002
+ docs: "https://pnpm.io/settings#overrides",
1003
+ id: "dependency-overrides",
1004
+ severity: "info",
1005
+ title: "Review dependency overrides"
1006
+ });
1007
+ //#endregion
1008
+ //#region src/domain/rules/disable-lifecycle-scripts.ts
1009
+ const { npmrc: npmrc$5, pnpmWorkspace: pnpmWorkspace$8, yarnrc: yarnrc$6, aubeWorkspace: aubeWorkspace$5, bunfig: bunfig$3 } = CONFIG_FILES;
1010
+ const pnpmBinding$4 = {
1011
+ check(_ctx, config) {
1012
+ if (getByPath(config, ["ignoreScripts"]) === true) return { state: "ok" };
1013
+ const bypass = getByPath(config, ["dangerouslyAllowAllBuilds"]);
1014
+ if (bypass === true) return {
1015
+ actual: bypass,
1016
+ expected: false,
1017
+ remediation: {
1018
+ kind: "manual",
1019
+ steps: ["Remove `dangerouslyAllowAllBuilds: true` from pnpm-workspace.yaml (or set it to `false`). Setting `strictDepBuilds: true` alone has no effect while the bypass remains."]
1020
+ },
1021
+ message: "`dangerouslyAllowAllBuilds: true` in pnpm-workspace.yaml bypasses strictDepBuilds — remove it (or set it to false) to restore lifecycle-script gating.",
1022
+ state: "violation"
1023
+ };
1024
+ const strict = getByPath(config, ["strictDepBuilds"]);
1025
+ if (strict === true) return { state: "ok" };
1026
+ return {
1027
+ state: "violation",
1028
+ actual: strict,
1029
+ expected: true,
1030
+ message: strict === void 0 ? "Set `strictDepBuilds: true` in pnpm-workspace.yaml to pin lifecycle-script gating across versions." : "Set `strictDepBuilds: true` in pnpm-workspace.yaml to block silent skips of un-approved dep builds.",
1031
+ remediation: proposeChanges(config, [{
1032
+ file: pnpmWorkspace$8,
1033
+ keyPath: ["strictDepBuilds"],
1034
+ op: "setKey",
1035
+ value: true
1036
+ }])
1037
+ };
1038
+ },
1039
+ docs: "https://pnpm.io/settings#strictdepbuilds",
1040
+ file: pnpmWorkspace$8,
1041
+ versionNote: {
1042
+ configAvailableSince: "pnpm 10.3.0",
1043
+ defaultSafeSince: "pnpm 11.0.0"
1044
+ }
1045
+ };
1046
+ const aubeBinding$2 = {
1047
+ check(ctx, config) {
1048
+ const npmConfig = ctx.readConfig(npmrc$5);
1049
+ const jail = getByPath(config, ["jailBuilds"]);
1050
+ const strict = getByPath(npmConfig, ["strictDepBuilds"]);
1051
+ if (jail === true && strict === true) return { state: "ok" };
1052
+ const jailRemedy = proposeChanges(config, [{
1053
+ file: aubeWorkspace$5,
1054
+ keyPath: ["jailBuilds"],
1055
+ op: "setKey",
1056
+ value: true
1057
+ }]);
1058
+ const strictRemedy = proposeChanges(npmConfig, [{
1059
+ file: npmrc$5,
1060
+ keyPath: ["strictDepBuilds"],
1061
+ op: "setKey",
1062
+ value: true
1063
+ }]);
1064
+ return {
1065
+ state: "violation",
1066
+ file: jail !== true ? aubeWorkspace$5.path : npmrc$5.path,
1067
+ actual: jail !== true ? jail : strict,
1068
+ expected: true,
1069
+ message: "Set `jailBuilds: true` in aube-workspace.yaml and `strictDepBuilds=true` in .npmrc to sandbox approved builds and reject unreviewed lifecycle scripts.",
1070
+ remediation: jailRemedy.kind === "automatic" && strictRemedy.kind === "automatic" ? {
1071
+ kind: "automatic",
1072
+ operations: [...jailRemedy.operations, ...strictRemedy.operations]
1073
+ } : {
1074
+ kind: "manual",
1075
+ steps: [...jailRemedy.kind === "manual" ? jailRemedy.steps : ["Set `jailBuilds: true` in aube-workspace.yaml."], ...strictRemedy.kind === "manual" ? strictRemedy.steps : ["Set `strictDepBuilds=true` in .npmrc."]]
1076
+ }
1077
+ };
1078
+ },
1079
+ docs: "https://aube.jdx.dev/security.html",
1080
+ file: aubeWorkspace$5
1081
+ };
1082
+ const bunMessage = "Set `ignoreScripts = true` under [install] in bunfig.toml — or set `\"trustedDependencies\": []` in package.json — to opt out of the curated allow-list (postinstall is already blocked for untrusted packages by default).";
1083
+ const bunBinding = {
1084
+ check(ctx, config) {
1085
+ const ignoreScripts = getByPath(config, ["install", "ignoreScripts"]);
1086
+ if (ignoreScripts === true) return { state: "ok" };
1087
+ const trusted = ctx.packageJson?.trustedDependencies;
1088
+ if (typeof trusted !== "undefined" && trusted.length === 0) return { state: "ok" };
1089
+ return {
1090
+ remediation: proposeChanges(config, [{
1091
+ file: bunfig$3,
1092
+ keyPath: ["install", "ignoreScripts"],
1093
+ op: "setKey",
1094
+ value: true
1095
+ }]),
1096
+ actual: ignoreScripts,
1097
+ expected: true,
1098
+ message: bunMessage,
1099
+ state: "violation"
1100
+ };
1101
+ },
1102
+ docs: "https://bun.com/docs/pm/lifecycle",
1103
+ file: bunfig$3,
1104
+ severity: "info",
1105
+ versionNote: { configAvailableSince: "bun 1.2.0" }
1106
+ };
1107
+ const disableLifecycleScripts = withAubeParanoid(overrideBindings(requireConfigKey({
1108
+ bindings: {
1109
+ npm: {
1110
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#ignore-scripts",
1111
+ file: npmrc$5,
1112
+ keyPath: ["ignore-scripts"],
1113
+ message: "Set `ignore-scripts=true` in .npmrc to block dependency lifecycle scripts.",
1114
+ value: true
1115
+ },
1116
+ yarn: {
1117
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableScripts",
1118
+ documentedDefault: false,
1119
+ file: yarnrc$6,
1120
+ keyPath: ["enableScripts"],
1121
+ message: "Set `enableScripts: false` in .yarnrc.yml to pin the policy across versions.",
1122
+ value: false,
1123
+ versionNote: {
1124
+ configAvailableSince: "yarn 2.0.0",
1125
+ defaultSafeSince: "yarn 4.14.0"
1126
+ }
1127
+ }
1128
+ },
1129
+ description: "Malicious postinstall scripts are a primary supply-chain attack vector. Prevent automatic execution of dependency lifecycle scripts.",
1130
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#3-disable-lifecycle-scripts",
1131
+ id: "disable-lifecycle-scripts",
1132
+ severity: "error",
1133
+ title: "Disable dependency lifecycle scripts"
1134
+ }), {
1135
+ aube: aubeBinding$2,
1136
+ bun: bunBinding,
1137
+ pnpm: pnpmBinding$4
1138
+ }));
1139
+ //#endregion
1140
+ //#region src/domain/rules/enforce-strict-ssl.ts
1141
+ const { npmrc: npmrc$4, yarnrc: yarnrc$5 } = CONFIG_FILES;
1142
+ const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL certificate validation for registry connections.";
1143
+ const yarnBinding = {
1144
+ check(_ctx, config) {
1145
+ const strictSsl = getByPath(config, ["enableStrictSsl"]);
1146
+ const whitelist = getByPath(config, ["unsafeHttpWhitelist"]);
1147
+ if (Array.isArray(whitelist) && whitelist.length > 0) return {
1148
+ actual: whitelist,
1149
+ expected: "",
1150
+ remediation: {
1151
+ kind: "manual",
1152
+ steps: ["Review and remove entries from `unsafeHttpWhitelist` in .yarnrc.yml. Each entry permits unencrypted HTTP to that hostname. Also set `enableStrictSsl: true`."]
1153
+ },
1154
+ message: "`unsafeHttpWhitelist` in .yarnrc.yml allows unencrypted HTTP connections — remove entries or clear the list to enforce HTTPS-only registry access.",
1155
+ state: "violation"
1156
+ };
1157
+ if (strictSsl === true) return { state: "ok" };
1158
+ return {
1159
+ state: "violation",
1160
+ actual: strictSsl,
1161
+ expected: true,
1162
+ message: yarnMessage,
1163
+ ...strictSsl === void 0 ? { severity: "info" } : {},
1164
+ remediation: proposeChanges(config, [{
1165
+ file: yarnrc$5,
1166
+ keyPath: ["enableStrictSsl"],
1167
+ op: "setKey",
1168
+ value: true
1169
+ }])
1170
+ };
1171
+ },
1172
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableStrictSsl",
1173
+ file: yarnrc$5
1174
+ };
1175
+ const enforceStrictSsl = overrideBindings(requireConfigKey({
1176
+ bindings: { npm: {
1177
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1178
+ documentedDefault: true,
1179
+ file: npmrc$4,
1180
+ keyPath: ["strict-ssl"],
1181
+ message: "Set `strict-ssl=true` in .npmrc to enforce SSL certificate validation.",
1182
+ value: true
1183
+ } },
1184
+ description: "Require SSL certificate validation so registry traffic cannot be intercepted or tampered with.",
1185
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1186
+ id: "enforce-strict-ssl",
1187
+ severity: "warn",
1188
+ title: "Enforce SSL for registry connections"
1189
+ }), { yarn: yarnBinding });
1190
+ //#endregion
1191
+ //#region src/domain/services/project-type.ts
1192
+ const isPublishableName = (value) => typeof value === "string" && value.trim().length > 0;
1193
+ const resolveProjectType = (selected, inferredPackage) => selected ?? (inferredPackage ? "package" : "application");
1194
+ const resolvePackageJsonProjectType = (ctx) => {
1195
+ const pkg = ctx.packageJson;
1196
+ const inferredPackage = pkg?.private !== true && isPublishableName(pkg?.name);
1197
+ return resolveProjectType(ctx.projectType, inferredPackage);
1198
+ };
1199
+ const resolveDenoProjectType = (ctx, denoConfig) => resolveProjectType(ctx.projectType, isPublishableName(getByPath(denoConfig, ["name"])));
1200
+ //#endregion
1201
+ //#region src/domain/rules/publishable.ts
1202
+ const isPublishable = (ctx) => resolvePackageJsonProjectType(ctx) === "package";
1203
+ //#endregion
1204
+ //#region src/domain/rules/files-field.ts
1205
+ const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1206
+ const packageJsonFilesBinding = {
1207
+ check(ctx) {
1208
+ if (!isPublishable(ctx)) return { state: "na" };
1209
+ const files = ctx.packageJson?.files;
1210
+ if (isStringList(files) && files.length > 0) return { state: "ok" };
1211
+ return {
1212
+ remediation: {
1213
+ kind: "manual",
1214
+ steps: ["Add a `files` array to package.json (e.g. [\"dist\"]) and verify with `npm pack --dry-run`."]
1215
+ },
1216
+ actual: files,
1217
+ message: "Add a `files` allow-list to package.json to limit what gets published.",
1218
+ state: "violation"
1219
+ };
1220
+ },
1221
+ docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files",
1222
+ file: packageJson$1
1223
+ };
1224
+ const filesField = defineRule({
1225
+ bindings: {
1226
+ aube: packageJsonFilesBinding,
1227
+ bun: packageJsonFilesBinding,
1228
+ deno: {
1229
+ check(ctx, config) {
1230
+ if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1231
+ const include = getByPath(config, ["publish", "include"]);
1232
+ if (isStringList(include) && include.length > 0) return { state: "ok" };
1233
+ return {
1234
+ remediation: {
1235
+ kind: "manual",
1236
+ steps: ["Add a non-empty `publish.include` array to deno.json; use `publish.exclude` for additional exclusions."]
1237
+ },
1238
+ actual: include,
1239
+ message: "Add `publish.include` to deno.json to limit what gets published.",
1240
+ state: "violation"
1241
+ };
1242
+ },
1243
+ docs: "https://docs.deno.com/runtime/reference/cli/publish/#how-publishing-works",
1244
+ file: denoJson$3
1245
+ },
1246
+ npm: packageJsonFilesBinding,
1247
+ pnpm: packageJsonFilesBinding,
1248
+ yarn: packageJsonFilesBinding
1249
+ },
1250
+ description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1251
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1252
+ id: "files-field",
1253
+ projectTypes: ["package"],
1254
+ severity: "info",
1255
+ title: "Declare a published files allow-list"
1256
+ });
1257
+ //#endregion
1258
+ //#region src/domain/rules/frozen-lockfile.ts
1259
+ const { pnpmWorkspace: pnpmWorkspace$7, yarnrc: yarnrc$4, bunfig: bunfig$2, denoJson: denoJson$2 } = CONFIG_FILES;
1260
+ const aubeFrozenCommand = "Use `aube ci` or `aube install --frozen-lockfile` to fail when the lockfile would change.";
1261
+ const frozenLockfile = overrideBindings(requireConfigKey({
1262
+ bindings: {
1263
+ deno: {
1264
+ docs: "https://docs.deno.com/runtime/fundamentals/configuration/#lock",
1265
+ file: denoJson$2,
1266
+ keyPath: ["lock", "frozen"],
1267
+ message: "Set `lock.frozen: true` in deno.json for reproducible, verified installs.",
1268
+ value: true
1269
+ },
1270
+ bun: {
1271
+ docs: "https://bun.com/docs/runtime/bunfig#install-frozenlockfile",
1272
+ file: bunfig$2,
1273
+ keyPath: ["install", "frozenLockfile"],
1274
+ message: "Set `frozenLockfile = true` under [install] in bunfig.toml so installs fail when the lockfile would change.",
1275
+ value: true,
1276
+ versionNote: { configAvailableSince: "bun 0.6.10" }
1277
+ },
1278
+ pnpm: {
1279
+ docs: "https://pnpm.io/settings#frozenlockfile",
1280
+ file: pnpmWorkspace$7,
1281
+ keyPath: ["frozenLockfile"],
1282
+ message: "pnpm defaults frozenLockfile to true in CI — set it explicitly in pnpm-workspace.yaml to pin the policy outside CI too.",
1283
+ value: true
1284
+ },
1285
+ yarn: {
1286
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls",
1287
+ documentedDefault: true,
1288
+ file: yarnrc$4,
1289
+ keyPath: ["enableImmutableInstalls"],
1290
+ message: "Set `enableImmutableInstalls: true` in .yarnrc.yml to pin the policy outside CI too.",
1291
+ value: true,
1292
+ versionNote: {
1293
+ configAvailableSince: "yarn 2.0.0",
1294
+ defaultSafeSince: "yarn 3.0.0 in CI"
1295
+ }
1296
+ }
1297
+ },
1298
+ description: "Refuse to mutate the lockfile on install so unexpected dependency changes fail loudly.",
1299
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1300
+ id: "frozen-lockfile",
1301
+ severity: "warn",
1302
+ title: "Freeze the lockfile"
1303
+ }), { aube: {
1304
+ check() {
1305
+ return {
1306
+ remediation: {
1307
+ kind: "manual",
1308
+ steps: [aubeFrozenCommand]
1309
+ },
1310
+ message: "`preferFrozenLockfile` only reuses an up-to-date lockfile; it does not prevent updates. Verify that install commands enforce a frozen lockfile.",
1311
+ state: "violation"
1312
+ };
1313
+ },
1314
+ docs: "https://github.com/aubepkg/aube/blob/main/docs/cli/ci.md",
1315
+ severity: "info"
1316
+ } });
1317
+ //#endregion
1318
+ //#region src/domain/rules/frozen-store.ts
1319
+ const { pnpmWorkspace: pnpmWorkspace$6 } = CONFIG_FILES;
1320
+ const frozenStore = defineRule({
1321
+ bindings: { pnpm: {
1322
+ check(_ctx, config) {
1323
+ const value = getByPath(config, ["frozenStore"]);
1324
+ if (value === true) return { state: "ok" };
1325
+ return {
1326
+ remediation: {
1327
+ kind: "manual",
1328
+ steps: ["Populate the store before enabling `frozenStore: true`. Use it for read-only deployments; it is incompatible with --force and a configured pnpr server."]
1329
+ },
1330
+ actual: value,
1331
+ message: "For a pre-populated read-only store, consider `frozenStore` in pnpm-workspace.yaml. Normal installs need a writable store.",
1332
+ state: "violation"
1333
+ };
1334
+ },
1335
+ docs: "https://pnpm.io/settings/store#frozenstore",
1336
+ file: pnpmWorkspace$6,
1337
+ versionNote: { configAvailableSince: "pnpm 11.7.0" }
1338
+ } },
1339
+ description: "Consider read-only store access for deployments whose dependencies are already present.",
1340
+ docs: "https://pnpm.io/settings/store#frozenstore",
1341
+ id: "frozen-store",
1342
+ severity: "info",
1343
+ title: "Consider a pre-populated read-only store"
1344
+ });
1345
+ //#endregion
1346
+ //#region src/domain/rules/hardened-mode.ts
1347
+ const { yarnrc: yarnrc$3 } = CONFIG_FILES;
1348
+ const hardenedMode = requireConfigKey({
1349
+ bindings: { yarn: {
1350
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1351
+ documentedDefault: true,
1352
+ file: yarnrc$3,
1353
+ keyPath: ["enableHardenedMode"],
1354
+ message: "yarn enables hardened mode automatically only for pull requests on public repositories — set `enableHardenedMode: true` in .yarnrc.yml to extend end-to-end install verification to every install.",
1355
+ value: true,
1356
+ versionNote: {
1357
+ configAvailableSince: "yarn 4.0.0",
1358
+ defaultSafeSince: "yarn 4.0.0 (conditional: auto-enabled for PRs on public repositories)"
1359
+ }
1360
+ } },
1361
+ description: "Yarn 4's enableHardenedMode performs end-to-end checksum, lockfile, and version verification at install time.",
1362
+ docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1363
+ id: "hardened-mode",
1364
+ severity: "warn",
1365
+ title: "Enable hardened mode (yarn)"
1366
+ });
1367
+ //#endregion
1368
+ //#region src/domain/rules/minimum-release-age.ts
1369
+ const HOURS_PER_DAY = 24;
1370
+ const MINUTES_PER_HOUR = 60;
1371
+ const SECONDS_PER_MINUTE = 60;
1372
+ const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
1373
+ const SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE;
1374
+ const RECOMMENDED_RELEASE_AGE_DAYS = 3;
1375
+ const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_PER_DAY;
1376
+ const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
1377
+ const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
1378
+ const { npmrc: npmrc$3, pnpmWorkspace: pnpmWorkspace$5, yarnrc: yarnrc$2, bunfig: bunfig$1, denoJson: denoJson$1, aubeWorkspace: aubeWorkspace$4 } = CONFIG_FILES;
1379
+ const DENO_DURATION = /^\+?P(?:\+?\d+[Ww]|(?:\d+[Dd])*(?:T(?:\d+[HhMm]|\d+(?:\.\d+)?[Ss])+)?)$/u;
1380
+ const DENO_MIN_TIMESTAMP = Date.parse("-262143-01-01T00:00:00Z");
1381
+ const DENO_UNIT_SECONDS = {
1382
+ w: 604800,
1383
+ d: 86400,
1384
+ h: 3600,
1385
+ m: 60,
1386
+ s: 1
1387
+ };
1388
+ const DENO_DATE = /^\d{4}-\d{2}-\d{2}$/u;
1389
+ const DENO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt ](?:[01]\d|2[0-3]):[0-5]\d:(?:[0-5]\d|60)(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
1390
+ const DENO_OFFSET_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::(?:[0-5]\d|60))?[+-](?:[01]\d|2[0-3]):?[0-5]\d$/u;
1391
+ const isPositiveNumber = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
1392
+ const isPositiveYarnDuration = (value) => isPositiveNumber(value) || typeof value === "string" && /^\d*\.?\d+(?:ms|s|m|h|d|w)?$/u.test(value) && Number.isFinite(Number.parseFloat(value)) && Number.parseFloat(value) > 0;
1393
+ const isPositiveDenoSeconds = (seconds) => seconds > 0 && Number.isFinite(seconds) && Date.now() - seconds * 1e3 >= DENO_MIN_TIMESTAMP;
1394
+ const isActiveDenoAge = (value) => {
1395
+ if (typeof value === "number") return Number.isSafeInteger(value) && isPositiveDenoSeconds(value * 60);
1396
+ if (typeof value !== "string") return false;
1397
+ if (/^\d+$/u.test(value)) return Number.isSafeInteger(Number(value)) && isPositiveDenoSeconds(Number(value) * 60);
1398
+ if (DENO_DURATION.test(value)) {
1399
+ let seconds = 0;
1400
+ for (const [, integer, fraction, unit] of value.matchAll(/(\d+)(?:\.(\d+))?([WDHMS])/giu)) {
1401
+ const amount = Number(integer) + Number(`0.${(fraction ?? "").slice(0, 9) || "0"}`);
1402
+ seconds += amount * (DENO_UNIT_SECONDS[unit?.toLowerCase() ?? ""] ?? 0);
1403
+ }
1404
+ return isPositiveDenoSeconds(seconds);
1405
+ }
1406
+ if (!DENO_DATE.test(value) && !DENO_TIMESTAMP.test(value) && !DENO_OFFSET_TIMESTAMP.test(value)) return false;
1407
+ const date = value.slice(0, 10);
1408
+ const midnight = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
1409
+ const leapSecond = /:60(?=\.|Z|z|[+-])/u.test(value);
1410
+ const timestamp = Date.parse(value.replace(/:60(?=\.|Z|z|[+-])/u, ":59")) + (leapSecond ? 1e3 : 0);
1411
+ return !Number.isNaN(midnight.valueOf()) && midnight.toISOString().slice(0, 10) === date && timestamp < Date.now();
1412
+ };
1413
+ const isNonDisabledDenoDuration = (value) => {
1414
+ if (!isPlainRecord(value)) return isActiveDenoAge(value);
1415
+ if (Object.keys(value).some((key) => key !== "age" && key !== "exclude")) return false;
1416
+ if (value.exclude !== void 0 && !isStringList(value.exclude)) return false;
1417
+ return value.age == null || isActiveDenoAge(value.age);
1418
+ };
1419
+ const minimumReleaseAge = overrideBindings(requireConfigKey({
1420
+ bindings: {
1421
+ aube: {
1422
+ accept: isPositiveNumber,
1423
+ docs: "https://aube.sh/settings/",
1424
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1425
+ file: aubeWorkspace$4,
1426
+ keyPath: ["minimumReleaseAge"],
1427
+ message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in aube-workspace.yaml.`,
1428
+ value: RECOMMENDED_RELEASE_AGE_MINUTES
1429
+ },
1430
+ bun: {
1431
+ accept: isPositiveNumber,
1432
+ docs: "https://bun.com/docs/runtime/bunfig#install-minimumreleaseage",
1433
+ file: bunfig$1,
1434
+ keyPath: ["install", "minimumReleaseAge"],
1435
+ message: `Set install.minimumReleaseAge (bun) to ~${RECOMMENDED_RELEASE_AGE_SECONDS} seconds to quarantine brand-new releases.`,
1436
+ value: RECOMMENDED_RELEASE_AGE_SECONDS,
1437
+ versionNote: { configAvailableSince: "bun 1.3.0" }
1438
+ },
1439
+ pnpm: {
1440
+ accept: isPositiveNumber,
1441
+ docs: "https://pnpm.io/settings#minimumreleaseage",
1442
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1443
+ file: pnpmWorkspace$5,
1444
+ keyPath: ["minimumReleaseAge"],
1445
+ message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in pnpm-workspace.yaml.`,
1446
+ value: RECOMMENDED_RELEASE_AGE_MINUTES,
1447
+ versionNote: {
1448
+ configAvailableSince: "pnpm 10.16.0",
1449
+ defaultSafeSince: "pnpm 11.0.0 (1440 minutes)"
1450
+ }
1451
+ },
1452
+ yarn: {
1453
+ accept: isPositiveYarnDuration,
1454
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate",
1455
+ documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1456
+ file: yarnrc$2,
1457
+ keyPath: ["npmMinimalAgeGate"],
1458
+ message: `Set npmMinimalAgeGate (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in .yarnrc.yml.`,
1459
+ value: RECOMMENDED_RELEASE_AGE_MINUTES,
1460
+ versionNote: {
1461
+ configAvailableSince: "yarn 4.10.0",
1462
+ defaultSafeSince: "yarn 4.15.0 (1440 minutes)"
1463
+ }
1464
+ }
1465
+ },
1466
+ description: "Refuse to install releases newer than a cooldown window so freshly published (possibly compromised) versions are skipped.",
1467
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#set-minimal-release-age",
1468
+ id: "minimum-release-age",
1469
+ severity: "warn",
1470
+ title: "Set a minimum release age"
1471
+ }), {
1472
+ npm: {
1473
+ file: npmrc$3,
1474
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#min-release-age",
1475
+ versionNote: { note: "min-release-age available since npm 11.10.0" },
1476
+ check(_ctx, config) {
1477
+ const now = Date.now();
1478
+ if (Object.hasOwn(config, "before")) {
1479
+ const actual = config.before;
1480
+ if ((typeof actual === "string" || typeof actual === "number") && Date.parse(String(actual)) < now) return { state: "ok" };
1481
+ return {
1482
+ state: "violation",
1483
+ actual,
1484
+ expected: "a date in the past",
1485
+ message: "Use a valid past before cutoff, or remove before and set min-release-age.",
1486
+ remediation: {
1487
+ kind: "manual",
1488
+ steps: [`In .npmrc, set before to a valid past date, or remove before and set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days. A future or disabled before overrides min-release-age in this file.`]
1489
+ }
1490
+ };
1491
+ }
1492
+ const actual = getByPath(config, ["min-release-age"]);
1493
+ const age = typeof actual === "number" || typeof actual === "string" ? Number(actual) : NaN;
1494
+ const cutoff = (/* @__PURE__ */ new Date(now - SECONDS_PER_DAY * 1e3 * age)).valueOf();
1495
+ if (age > 0 && Number.isFinite(cutoff) && cutoff < now) return { state: "ok" };
1496
+ return {
1497
+ state: "violation",
1498
+ actual,
1499
+ expected: RECOMMENDED_RELEASE_AGE_DAYS,
1500
+ message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days to quarantine brand-new releases.`,
1501
+ remediation: proposeChanges(config, [{
1502
+ file: npmrc$3,
1503
+ op: "setKey",
1504
+ keyPath: ["min-release-age"],
1505
+ value: RECOMMENDED_RELEASE_AGE_DAYS
1506
+ }])
1507
+ };
1508
+ }
1509
+ },
1510
+ deno: {
1511
+ file: denoJson$1,
1512
+ docs: "https://docs.deno.com/runtime/reference/deno_json/",
1513
+ versionNote: {
1514
+ defaultSafeSince: "deno 2.9.0 (1440 minutes)",
1515
+ note: "object age may be omitted"
1516
+ },
1517
+ check(_ctx, config) {
1518
+ const actual = getByPath(config, ["minimumDependencyAge"]);
1519
+ if (isNonDisabledDenoDuration(actual)) return { state: "ok" };
1520
+ const objectAge = isPlainRecord(actual);
1521
+ const invalidObject = objectAge && (Object.keys(actual).some((key) => key !== "age" && key !== "exclude") || actual.exclude !== void 0 && !isStringList(actual.exclude));
1522
+ return {
1523
+ state: "violation",
1524
+ actual,
1525
+ expected: "P3D",
1526
+ message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
1527
+ remediation: invalidObject ? {
1528
+ kind: "manual",
1529
+ steps: ["Use only age and exclude in minimumDependencyAge. Make exclude a list of package names and set age to a positive supported duration."]
1530
+ } : proposeChanges(config, [{
1531
+ file: denoJson$1,
1532
+ op: "setKey",
1533
+ keyPath: objectAge ? ["minimumDependencyAge", "age"] : ["minimumDependencyAge"],
1534
+ value: "P3D"
1535
+ }])
1536
+ };
1537
+ }
1538
+ }
1539
+ });
1540
+ //#endregion
1541
+ //#region src/domain/rules/named-registries.ts
1542
+ const { pnpmWorkspace: pnpmWorkspace$4 } = CONFIG_FILES;
1543
+ const isNonEmptyObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1544
+ const namedRegistries = defineRule({
1545
+ bindings: { pnpm: {
1546
+ check(_ctx, config) {
1547
+ const value = getByPath(config, ["namedRegistries"]);
1548
+ if (!isNonEmptyObject$1(value)) return { state: "ok" };
1549
+ return {
1550
+ remediation: {
1551
+ kind: "manual",
1552
+ steps: ["Verify each named registry points to a trusted source and that scoped packages cannot be hijacked via a public registry of the same name."]
1553
+ },
1554
+ actual: value,
1555
+ message: "Review `namedRegistries` in pnpm-workspace.yaml. Custom registry mappings can redirect package resolution away from the public registry, enabling dependency confusion attacks.",
1556
+ state: "violation"
1557
+ };
1558
+ },
1559
+ docs: "https://pnpm.io/settings#namedregistries",
1560
+ file: pnpmWorkspace$4
1561
+ } },
1562
+ description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
1563
+ docs: "https://pnpm.io/settings#namedregistries",
1564
+ id: "named-registries",
1565
+ severity: "info",
1566
+ title: "Review named registry mappings"
1567
+ });
1568
+ //#endregion
1569
+ //#region src/domain/rules/paranoid-mode.ts
1570
+ const { aubeWorkspace: aubeWorkspace$3 } = CONFIG_FILES;
1571
+ const paranoidMode = requireConfigKey({
1572
+ bindings: { aube: {
1573
+ docs: "https://aube.jdx.dev/security.html",
1574
+ file: aubeWorkspace$3,
1575
+ keyPath: ["paranoid"],
1576
+ message: "Set `paranoid: true` in aube-workspace.yaml to enable the strict-security bundle (trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, advisoryCheck).",
1577
+ value: true
1578
+ } },
1579
+ description: "Activate the strict-security setting bundle that forces trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, and advisoryCheck on in one switch.",
1580
+ docs: "https://aube.jdx.dev/security.html",
1581
+ id: "paranoid-mode",
1582
+ severity: "info",
1583
+ title: "Enable paranoid security mode"
1584
+ });
1585
+ //#endregion
1586
+ //#region src/domain/rules/patched-dependencies.ts
1587
+ const { pnpmWorkspace: pnpmWorkspace$3 } = CONFIG_FILES;
1588
+ const isNonEmptyObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1589
+ const patchedDependencies = defineRule({
1590
+ bindings: { pnpm: {
1591
+ check(_ctx, config) {
1592
+ const value = getByPath(config, ["patchedDependencies"]);
1593
+ if (!isNonEmptyObject(value)) return { state: "ok" };
1594
+ return {
1595
+ remediation: {
1596
+ kind: "manual",
1597
+ steps: ["Review each entry in `patchedDependencies` — verify patches address a known issue and have not been tampered with."]
1598
+ },
1599
+ actual: value,
1600
+ message: "Review `patchedDependencies` in pnpm-workspace.yaml. Local patches modify dependency code after the registry artifact is verified; review the patch files separately.",
1601
+ state: "violation"
1602
+ };
1603
+ },
1604
+ docs: "https://pnpm.io/settings#patcheddependencies",
1605
+ file: pnpmWorkspace$3
1606
+ } },
1607
+ description: "Review local patches separately from the registry artifacts they modify.",
1608
+ docs: "https://pnpm.io/settings#patcheddependencies",
1609
+ id: "patched-dependencies",
1610
+ severity: "info",
1611
+ title: "Review patched dependencies"
1612
+ });
1613
+ //#endregion
1614
+ //#region src/domain/rules/pin-exact-versions.ts
1615
+ const { npmrc: npmrc$2, pnpmWorkspace: pnpmWorkspace$2, yarnrc: yarnrc$1, bunfig, denoJson } = CONFIG_FILES;
1616
+ const REGISTRY_VERSION = /^(?:npm|jsr):(?:@[^/@]+\/)?[^/@]+@(?<version>[^/]*)/u;
1617
+ const isUnpinnedRegistryImport = (specifier) => {
1618
+ if (!specifier.startsWith("npm:") && !specifier.startsWith("jsr:")) return false;
1619
+ const version = REGISTRY_VERSION.exec(specifier)?.groups?.version;
1620
+ return version === void 0 || valid(version.replace(/^=/u, "")) === null;
1621
+ };
1622
+ const collectUnpinnedImports = (imports) => {
1623
+ const offenders = [];
1624
+ for (const [name, value] of Object.entries(imports)) if (typeof value === "string" && isUnpinnedRegistryImport(value)) offenders.push(`${name}=${value}`);
1625
+ return offenders;
1626
+ };
1627
+ const MAX_SAMPLE_COUNT = 3;
1628
+ const formatOffenders = (offenders) => {
1629
+ const sample = offenders.slice(0, MAX_SAMPLE_COUNT).join(", ");
1630
+ let more = "";
1631
+ if (offenders.length > MAX_SAMPLE_COUNT) more = ` (and ${offenders.length - MAX_SAMPLE_COUNT} more)`;
1632
+ return {
1633
+ remediation: {
1634
+ kind: "manual",
1635
+ steps: ["Run `deno add --save-exact <pkg>` for each unpinned registry import in deno.json, or rewrite the `imports` entries to use exact versions."]
1636
+ },
1637
+ message: `${offenders.length} deno imports are not pinned: ${sample}${more}. Use \`deno add --save-exact\` or pin manually.`,
1638
+ state: "violation"
1639
+ };
1640
+ };
1641
+ const OK = { state: "ok" };
1642
+ const extractImportsRecord = (config) => {
1643
+ const imports = getByPath(config, ["imports"]);
1644
+ if (!isPlainRecord(imports)) return;
1645
+ return imports;
1646
+ };
1647
+ const denoBinding = {
1648
+ check(_ctx, config) {
1649
+ const imports = extractImportsRecord(config);
1650
+ if (!imports) return OK;
1651
+ const offenders = collectUnpinnedImports(imports);
1652
+ if (offenders.length === 0) return OK;
1653
+ return formatOffenders(offenders);
1654
+ },
1655
+ docs: "https://docs.deno.com/runtime/reference/cli/add/",
1656
+ file: denoJson,
1657
+ versionNote: { configAvailableSince: "deno 1.30.0" }
1658
+ };
1659
+ const npmBinding = {
1660
+ file: npmrc$2,
1661
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#save-exact",
1662
+ check(_ctx, config) {
1663
+ const exact = getByPath(config, ["save-exact"]);
1664
+ const prefix = getByPath(config, ["save-prefix"]);
1665
+ if (exact === true || prefix === "" || prefix === "=") return OK;
1666
+ return {
1667
+ remediation: proposeChanges(config, [{
1668
+ op: "setKey",
1669
+ file: npmrc$2,
1670
+ keyPath: ["save-exact"],
1671
+ value: true
1672
+ }]),
1673
+ state: "violation",
1674
+ actual: exact,
1675
+ expected: true,
1676
+ message: "Set `save-exact=true` in .npmrc to save exact versions by default."
1677
+ };
1678
+ }
1679
+ };
1680
+ const aubeBinding = {
1681
+ file: npmrc$2,
1682
+ docs: "https://aube.jdx.dev/settings/#saveprefix",
1683
+ check(_ctx, config) {
1684
+ const prefixes = ["save-prefix", "savePrefix"].filter((key) => Object.hasOwn(config, key)).map((key) => config[key]);
1685
+ if (prefixes.length > 0 && prefixes.every((value) => value === "")) return OK;
1686
+ return {
1687
+ remediation: {
1688
+ kind: "manual",
1689
+ steps: ["Use one `save-prefix=` entry in .npmrc. Aube resolves aliases by line order; reconcile conflicting entries before changing them."]
1690
+ },
1691
+ state: "violation",
1692
+ message: "Set `save-prefix=` in .npmrc; remove any conflicting `savePrefix` alias."
1693
+ };
1694
+ }
1695
+ };
1696
+ const pinExactVersions = overrideBindings(requireConfigKey({
1697
+ bindings: {
1698
+ bun: {
1699
+ docs: "https://bun.com/docs/runtime/bunfig#install-exact",
1700
+ file: bunfig,
1701
+ keyPath: ["install", "exact"],
1702
+ message: "Set `exact = true` under [install] in bunfig.toml to pin exact versions.",
1703
+ value: true,
1704
+ versionNote: { note: "install.exact verified in bun 1.2.0" }
1705
+ },
1706
+ pnpm: {
1707
+ accept: (value) => value === "" || value === "=",
1708
+ docs: "https://pnpm.io/settings/other#saveprefix",
1709
+ file: pnpmWorkspace$2,
1710
+ keyPath: ["savePrefix"],
1711
+ message: "Set `savePrefix: ''` in pnpm-workspace.yaml to pin exact versions.",
1712
+ value: ""
1713
+ },
1714
+ yarn: {
1715
+ docs: "https://yarnpkg.com/configuration/yarnrc#defaultSemverRangePrefix",
1716
+ file: yarnrc$1,
1717
+ keyPath: ["defaultSemverRangePrefix"],
1718
+ message: "Set `defaultSemverRangePrefix: ''` in .yarnrc.yml to pin exact versions.",
1719
+ value: "",
1720
+ versionNote: { configAvailableSince: "yarn 2.0.0" }
1721
+ }
1722
+ },
1723
+ description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default.",
1724
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#1-pin-dependency-versions",
1725
+ id: "pin-exact-versions",
1726
+ severity: "error",
1727
+ title: "Pin exact dependency versions"
1728
+ }), {
1729
+ aube: aubeBinding,
1730
+ deno: denoBinding,
1731
+ npm: npmBinding
1732
+ });
1733
+ //#endregion
1734
+ //#region src/domain/rules/provenance.ts
1735
+ const { npmrc: npmrc$1, yarnrc } = CONFIG_FILES;
1736
+ const npmrcProvenance = {
1737
+ file: npmrc$1,
1738
+ keyPath: ["provenance"],
1739
+ message: "Set `provenance=true` (and publish from CI) to attest releases.",
1740
+ value: true
1741
+ };
1742
+ const provenance = requireConfigKey({
1743
+ applies: isPublishable,
1744
+ bindings: {
1745
+ bun: {
1746
+ ...npmrcProvenance,
1747
+ docs: "https://github.com/oven-sh/bun/issues/15601",
1748
+ message: "Set `provenance=true` in .npmrc and publish via `bunx npm publish` from CI — `bun publish` does not emit provenance attestations natively (tracking: oven-sh/bun#15601)."
1749
+ },
1750
+ npm: {
1751
+ ...npmrcProvenance,
1752
+ docs: "https://docs.npmjs.com/cli/v11/using-npm/config#provenance",
1753
+ versionNote: { configAvailableSince: "npm 9.5.0" }
1754
+ },
1755
+ pnpm: {
1756
+ ...npmrcProvenance,
1757
+ docs: "https://pnpm.io/cli/publish"
1758
+ },
1759
+ yarn: {
1760
+ docs: "https://yarnpkg.com/configuration/yarnrc#npmPublishProvenance",
1761
+ file: yarnrc,
1762
+ keyPath: ["npmPublishProvenance"],
1763
+ message: "Set `npmPublishProvenance: true` (and publish from CI) to attest releases.",
1764
+ value: true
1765
+ }
1766
+ },
1767
+ description: "Provenance statements (via Sigstore) bind a published artifact to its recorded source and build.",
1768
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
1769
+ id: "provenance",
1770
+ projectTypes: ["package"],
1771
+ severity: "warn",
1772
+ title: "Publish with provenance"
1773
+ });
1774
+ //#endregion
1775
+ //#region src/domain/rules/publish-access.ts
1776
+ const { packageJson } = CONFIG_FILES;
1777
+ const publishAccessBinding = {
1778
+ check(ctx) {
1779
+ if (!isPublishable(ctx)) return { state: "na" };
1780
+ const access = ctx.packageJson?.publishConfig?.access;
1781
+ if (access === "public" || access === "restricted") return { state: "ok" };
1782
+ return {
1783
+ remediation: {
1784
+ kind: "manual",
1785
+ steps: ["Add `\"publishConfig\": { \"access\": \"public\" }` (or `\"restricted\"`) to package.json so publishes never default unexpectedly."]
1786
+ },
1787
+ actual: access,
1788
+ message: "Set `publishConfig.access` in package.json to `public` or `restricted` to declare publish scope explicitly.",
1789
+ state: "violation"
1790
+ };
1791
+ },
1792
+ docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#publishconfig",
1793
+ file: packageJson
1794
+ };
1795
+ const publishAccess = defineRule({
1796
+ bindings: {
1797
+ aube: publishAccessBinding,
1798
+ bun: publishAccessBinding,
1799
+ npm: {
1800
+ ...publishAccessBinding,
1801
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#access",
1802
+ check(ctx, config) {
1803
+ if (isPublishable(ctx) && ctx.packageJson?.publishConfig?.access === "private") return { state: "ok" };
1804
+ return publishAccessBinding.check(ctx, config);
1805
+ }
1806
+ },
1807
+ pnpm: publishAccessBinding,
1808
+ yarn: publishAccessBinding
1809
+ },
1810
+ description: "Set `publishConfig.access` so a misconfigured scope or registry never accidentally publishes an internal package publicly.",
1811
+ docs: "https://github.com/bodadotsh/npm-security-best-practices#for-maintainers",
1812
+ id: "publish-access",
1813
+ projectTypes: ["package"],
1814
+ severity: "info",
1815
+ title: "Declare publish access explicitly"
1816
+ });
1817
+ //#endregion
1818
+ //#region src/domain/rules/store-server.ts
1819
+ const { pnpmWorkspace: pnpmWorkspace$1 } = CONFIG_FILES;
1820
+ const storeServer = defineRule({
1821
+ bindings: { pnpm: {
1822
+ check(_ctx, config) {
1823
+ const value = getByPath(config, ["useRunningStoreServer"]);
1824
+ if (value !== true) return { state: "ok" };
1825
+ return {
1826
+ remediation: {
1827
+ kind: "manual",
1828
+ steps: ["Verify the store server is running in a trusted environment and that its communication channel is not exposed to untrusted networks."]
1829
+ },
1830
+ actual: value,
1831
+ message: "Review `useRunningStoreServer` in pnpm-workspace.yaml. Delegating store operations to an external process introduces a trust boundary — the server process can serve tampered packages.",
1832
+ state: "violation"
1833
+ };
1834
+ },
1835
+ docs: "https://pnpm.io/settings#userunningStoreserver",
1836
+ file: pnpmWorkspace$1
1837
+ } },
1838
+ description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
1839
+ docs: "https://pnpm.io/settings#userunningStoreserver",
1840
+ id: "store-server",
1841
+ severity: "info",
1842
+ title: "Review store server usage"
1843
+ });
1844
+ //#endregion
1845
+ //#region src/domain/rules/strict-allow-scripts.ts
1846
+ const { npmrc } = CONFIG_FILES;
1847
+ const strictAllowScripts = defineRule({
1848
+ bindings: { npm: {
1849
+ check(_ctx, config) {
1850
+ if (getByPath(config, ["ignore-scripts"]) === true) return { state: "ok" };
1851
+ const bypass = getByPath(config, ["dangerously-allow-all-scripts"]);
1852
+ if (bypass === true) return {
1853
+ actual: bypass,
1854
+ expected: false,
1855
+ remediation: {
1856
+ kind: "manual",
1857
+ steps: ["Remove `dangerously-allow-all-scripts=true` from .npmrc (or set it to false), then set `strict-allow-scripts=true`. The bypass overrides strict script approval."]
1858
+ },
1859
+ message: "`dangerously-allow-all-scripts=true` bypasses script approval even when `strict-allow-scripts=true`.",
1860
+ state: "violation"
1861
+ };
1862
+ const strict = getByPath(config, ["strict-allow-scripts"]);
1863
+ if (strict === true) return { state: "ok" };
1864
+ return {
1865
+ remediation: proposeChanges(config, [{
1866
+ file: npmrc,
1867
+ keyPath: ["strict-allow-scripts"],
1868
+ op: "setKey",
1869
+ value: true
1870
+ }]),
1871
+ actual: strict,
1872
+ expected: true,
1873
+ message: "Set `strict-allow-scripts=true` in .npmrc to fail installation for unreviewed install scripts.",
1874
+ state: "violation"
1875
+ };
1876
+ },
1877
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#strict-allow-scripts",
1878
+ file: npmrc
1879
+ } },
1880
+ description: "Turn install-script policy warnings into hard errors so unapproved lifecycle scripts block installation.",
1881
+ docs: "https://docs.npmjs.com/cli/v12/using-npm/config#strict-allow-scripts",
1882
+ id: "strict-allow-scripts",
1883
+ severity: "warn",
1884
+ title: "Require explicit script allow-listing"
1885
+ });
1886
+ //#endregion
1887
+ //#region src/domain/rules/strict-release-age.ts
1888
+ const { aubeWorkspace: aubeWorkspace$2 } = CONFIG_FILES;
1889
+ const strictReleaseAge = withAubeParanoid(requireConfigKey({
1890
+ bindings: { aube: {
1891
+ docs: "https://aube.jdx.dev/settings/",
1892
+ file: aubeWorkspace$2,
1893
+ keyPath: ["minimumReleaseAgeStrict"],
1894
+ message: "Set `minimumReleaseAgeStrict: true` in aube-workspace.yaml to make the release age gate a hard block.",
1895
+ value: true
1896
+ } },
1897
+ description: "Fail when no satisfying version meets the release age, instead of falling back to the lowest satisfying version.",
1898
+ docs: "https://aube.jdx.dev/security.html",
1899
+ id: "strict-release-age",
1900
+ severity: "info",
1901
+ title: "Enforce strict release age gate"
1902
+ }));
1903
+ //#endregion
1904
+ //#region src/domain/rules/strict-store-integrity.ts
1905
+ const { aubeWorkspace: aubeWorkspace$1 } = CONFIG_FILES;
1906
+ const strictStoreIntegrity = defineRule({
1907
+ bindings: { aube: {
1908
+ check(_ctx, config) {
1909
+ if (getByPath(config, ["verifyStoreIntegrity"]) === false) return {
1910
+ actual: false,
1911
+ expected: true,
1912
+ remediation: {
1913
+ kind: "manual",
1914
+ steps: ["Set `verifyStoreIntegrity: true` (or remove `verifyStoreIntegrity: false`) in aube-workspace.yaml, and enable `strictStoreIntegrity: true` or `paranoid: true`."]
1915
+ },
1916
+ message: "`verifyStoreIntegrity: false` bypasses strict integrity checks, including when `paranoid: true`.",
1917
+ state: "violation"
1918
+ };
1919
+ const strict = getByPath(config, ["strictStoreIntegrity"]);
1920
+ if (strict === true || getByPath(config, ["paranoid"]) === true) return { state: "ok" };
1921
+ return {
1922
+ remediation: proposeChanges(config, [{
1923
+ file: aubeWorkspace$1,
1924
+ keyPath: ["strictStoreIntegrity"],
1925
+ op: "setKey",
1926
+ value: true
1927
+ }]),
1928
+ actual: strict,
1929
+ expected: true,
1930
+ message: "Set `strictStoreIntegrity: true` in aube-workspace.yaml to refuse tarballs that lack integrity metadata.",
1931
+ state: "violation"
1932
+ };
1933
+ },
1934
+ docs: "https://aube.jdx.dev/settings/",
1935
+ file: aubeWorkspace$1
1936
+ } },
1937
+ description: "Refuse to import tarballs from the registry when the packument lacks a dist.integrity field, preventing silent integrity bypass.",
1938
+ docs: "https://aube.jdx.dev/security.html",
1939
+ id: "strict-store-integrity",
1940
+ severity: "warn",
1941
+ title: "Require tarball integrity metadata"
1942
+ });
1943
+ //#endregion
1944
+ //#region src/domain/rules/trust-policy.ts
1945
+ const { aubeWorkspace, pnpmWorkspace } = CONFIG_FILES;
1946
+ //#endregion
1947
+ //#region src/domain/builtin-rules.ts
1948
+ const rules = [
1949
+ advisoryCheck,
1950
+ approvedGitRepos,
1951
+ auditSuppression,
1952
+ blockAutoInstall,
1953
+ blockExoticSubdeps,
1954
+ bunSecurityScanner,
1955
+ checksumVerification,
1956
+ commitLockfile,
1957
+ dependencyOverrides,
1958
+ disableLifecycleScripts,
1959
+ enforceStrictSsl,
1960
+ filesField,
1961
+ frozenLockfile,
1962
+ frozenStore,
1963
+ hardenedMode,
1964
+ minimumReleaseAge,
1965
+ namedRegistries,
1966
+ paranoidMode,
1967
+ patchedDependencies,
1968
+ pinExactVersions,
1969
+ provenance,
1970
+ publishAccess,
1971
+ storeServer,
1972
+ strictAllowScripts,
1973
+ strictReleaseAge,
1974
+ strictStoreIntegrity,
1975
+ withAubeParanoid(requireConfigKey({
1976
+ bindings: {
1977
+ aube: {
1978
+ docs: "https://aube.jdx.dev/security.html",
1979
+ documentedDefault: "no-downgrade",
1980
+ file: aubeWorkspace,
1981
+ keyPath: ["trustPolicy"],
1982
+ message: "Set `trustPolicy: no-downgrade` in aube-workspace.yaml to block packages whose trust level has decreased.",
1983
+ value: "no-downgrade"
1984
+ },
1985
+ pnpm: {
1986
+ docs: "https://pnpm.io/settings#trustpolicy",
1987
+ file: pnpmWorkspace,
1988
+ keyPath: ["trustPolicy"],
1989
+ message: "Set `trustPolicy: no-downgrade` in pnpm-workspace.yaml to block packages whose trust level has decreased.",
1990
+ value: "no-downgrade",
1991
+ versionNote: { configAvailableSince: "pnpm 10.21.0" }
1992
+ }
1993
+ },
1994
+ description: "Fail installation when a package trust level has decreased compared to previous releases, catching publisher credential downgrades.",
1995
+ docs: "https://pnpm.io/settings#trustpolicy",
1996
+ id: "trust-policy",
1997
+ severity: "warn",
1998
+ title: "Enforce trust policy on dependency updates"
1999
+ }))
2000
+ ];
2001
+ //#endregion
2002
+ //#region src/domain/services/apply-config.ts
2003
+ /** Select active rules and keep user severity choices as explicit runtime data. */
2004
+ const applyConfig = (base, config) => {
2005
+ const merged = [...base, ...config?.customRules ?? []];
2006
+ const known = /* @__PURE__ */ new Set();
2007
+ const duplicates = /* @__PURE__ */ new Set();
2008
+ for (const rule of merged) {
2009
+ if (known.has(rule.id)) duplicates.add(rule.id);
2010
+ known.add(rule.id);
2011
+ }
2012
+ if (duplicates.size > 0) throw new ConfigError(`Duplicate rule ids: ${[...duplicates].map((id) => `'${id}'`).join(", ")}`);
2013
+ const unknown = Object.keys(config?.rules ?? {}).filter((id) => !known.has(id));
2014
+ if (unknown.length > 0) throw new ConfigError(`Unknown rule ids: ${unknown.map((id) => `'${id}'`).join(", ")}`);
2015
+ const activeRules = [];
2016
+ const severityOverrides = /* @__PURE__ */ new Map();
2017
+ const overrides = config?.rules;
2018
+ for (const rule of merged) {
2019
+ const override = overrides && Object.hasOwn(overrides, rule.id) ? overrides[rule.id] : void 0;
2020
+ if (override === "off") continue;
2021
+ activeRules.push(rule);
2022
+ if (override !== void 0) severityOverrides.set(rule.id, override);
2023
+ }
2024
+ return {
2025
+ rules: activeRules,
2026
+ severityOverrides
2027
+ };
2028
+ };
2029
+ //#endregion
2030
+ //#region src/domain/services/detect-pms.ts
2031
+ const registerSignals = (signals, pm, owner) => {
2032
+ for (const file of signals) {
2033
+ const prior = owner.get(file);
2034
+ if (typeof prior !== "undefined") throw new TypeError(`detection signal '${file}' is claimed by both '${prior}' and '${pm}'`);
2035
+ owner.set(file, pm);
2036
+ }
2037
+ };
2038
+ const buildDetectionSignals = () => {
2039
+ const out = /* @__PURE__ */ new Map();
2040
+ const owner = /* @__PURE__ */ new Map();
2041
+ for (const pm of PMS) {
2042
+ const { lockfiles, configs } = PM_SIGNALS[pm];
2043
+ const signals = [...lockfiles, ...configs];
2044
+ registerSignals(signals, pm, owner);
2045
+ out.set(pm, signals);
2046
+ }
2047
+ return out;
2048
+ };
2049
+ const DETECTION_SIGNALS = buildDetectionSignals();
2050
+ const detectPMs = (ctx) => {
2051
+ const found = /* @__PURE__ */ new Set();
2052
+ const declared = ctx.packageJson?.packageManager;
2053
+ const declaredPM = declared === void 0 ? void 0 : parsePackageManagerField(declared);
2054
+ if (declaredPM !== void 0) found.add(declaredPM);
2055
+ for (const pm of PMS) {
2056
+ const signals = DETECTION_SIGNALS.get(pm);
2057
+ if (signals && signals.some((file) => ctx.exists(asRelPath(file)))) found.add(pm);
2058
+ }
2059
+ return PMS.filter((pm) => found.has(pm));
2060
+ };
2061
+ //#endregion
2062
+ //#region src/domain/services/resolve-pms.ts
2063
+ /** Apply the explicit selection or detection, then the configured allow-list. */
2064
+ const resolvePMs = (ctx, opts) => {
2065
+ const { pmOverride, allowed } = opts;
2066
+ const detected = pmOverride ? [pmOverride] : detectPMs(ctx);
2067
+ const pms = allowed ? detected.filter((pm) => allowed.includes(pm)) : detected;
2068
+ if (pms.length > 0) return pms;
2069
+ if (!allowed) throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
2070
+ if (pmOverride) throw new UsageError(`--pm ${pmOverride} is not in siro.config.ts pms (${allowed.join(", ")}). Adjust the config or the flag.`);
2071
+ if (detected.length > 0) throw new UsageError(`Detected PMs (${detected.join(", ")}) do not match siro.config.ts pms (${allowed.join(", ")}). Adjust the config or remove the restriction.`);
2072
+ throw new UsageError(`No package manager detected, and siro.config.ts restricts pms to ${allowed.join(", ")}. Adjust the config or remove the restriction.`);
2073
+ };
2074
+ //#endregion
2075
+ //#region src/domain/services/decide-severity.ts
2076
+ /** Resolve the most specific severity signal for one finding. */
2077
+ const decideSeverity = (status, binding, rule, userOverride) => userOverride ?? status.severity ?? binding.severity ?? rule.severity;
2078
+ //#endregion
2079
+ //#region src/domain/services/parse-config-file.ts
2080
+ const EMPTY_FILE = Object.freeze({});
2081
+ /** Create a fresh parser and cache for one lint command. */
2082
+ const createConfigParser = (codecFor, ctx) => {
2083
+ const cache = /* @__PURE__ */ new Map();
2084
+ return (file) => {
2085
+ if (file === void 0) return EMPTY_FILE;
2086
+ const cacheKey = `${file.kind}:${file.path}`;
2087
+ const cached = cache.get(cacheKey);
2088
+ if (cached !== void 0) return cached;
2089
+ const text = ctx.readText(file.path);
2090
+ if (text === void 0) {
2091
+ cache.set(cacheKey, EMPTY_FILE);
2092
+ return EMPTY_FILE;
2093
+ }
2094
+ const parsed = wrapCodecError(file.path, () => codecFor(file.kind).parse(text));
2095
+ cache.set(cacheKey, parsed);
2096
+ return parsed;
2097
+ };
2098
+ };
2099
+ //#endregion
2100
+ //#region src/domain/services/render-version-note.ts
2101
+ const buildVersionNoteParts = (versionNote) => {
2102
+ const parts = [];
2103
+ if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
2104
+ if (typeof versionNote.defaultSafeSince !== "undefined") parts.push(`default safe since ${versionNote.defaultSafeSince}`);
2105
+ if (typeof versionNote.note !== "undefined") parts.push(versionNote.note);
2106
+ return parts;
2107
+ };
2108
+ const renderVersionNoteMessage = (message, versionNote) => {
2109
+ if (typeof versionNote === "undefined") return message;
2110
+ const parts = buildVersionNoteParts(versionNote);
2111
+ if (parts.length === 0) return message;
2112
+ return `${message} (${parts.join("; ")})`;
2113
+ };
2114
+ //#endregion
2115
+ //#region src/application/run-lint.ts
2116
+ const resolveBindingProjectType = (ctx, pm, parseConfig) => {
2117
+ if (ctx.projectType !== void 0) return ctx.projectType;
2118
+ if (pm === "deno") return resolveDenoProjectType(ctx, parseConfig(CONFIG_FILES.denoJson));
2119
+ return resolvePackageJsonProjectType(ctx);
2120
+ };
2121
+ /** Evaluate every applicable rule binding and collect violations. */
2122
+ const runLint = (opts) => {
2123
+ const { ctx, pms, ruleSet, severityOverrides, codecFor } = opts;
2124
+ const findings = [];
2125
+ const summary = {
2126
+ error: 0,
2127
+ info: 0,
2128
+ warn: 0
2129
+ };
2130
+ const parseConfig = createConfigParser(codecFor, ctx);
2131
+ const ruleContext = {
2132
+ ...ctx,
2133
+ readConfig: parseConfig
2134
+ };
2135
+ for (const rule of ruleSet) for (const pm of pms) {
2136
+ const binding = rule.bindings[pm];
2137
+ if (!binding || rule.projectTypes && !rule.projectTypes.includes(resolveBindingProjectType(ctx, pm, parseConfig))) continue;
2138
+ const status = binding.check(ruleContext, parseConfig(binding.file));
2139
+ if (!isCheckStatusShape(status)) throw new ConfigError(`Rule '${rule.id}' returned an invalid check result.`);
2140
+ if (status.state !== "violation") continue;
2141
+ const finding = {
2142
+ ruleId: rule.id,
2143
+ pm,
2144
+ severity: decideSeverity(status, binding, rule, severityOverrides?.get(rule.id)),
2145
+ message: renderVersionNoteMessage(status.message, binding.versionNote),
2146
+ file: status.file ?? binding.file?.path,
2147
+ docs: binding.docs ?? rule.docs,
2148
+ actual: status.actual,
2149
+ expected: status.expected,
2150
+ remediation: status.remediation
2151
+ };
2152
+ findings.push(finding);
2153
+ summary[finding.severity] += 1;
2154
+ }
2155
+ return {
2156
+ findings,
2157
+ summary
2158
+ };
2159
+ };
2160
+ //#endregion
2161
+ //#region src/application/lint.ts
2162
+ /** Evaluate a repository without reporting or executing configuration files. */
2163
+ const prepareLint = (options) => {
2164
+ if (!options || !isAbsPath(options.cwd)) throw new UsageError("cwd must be an absolute filesystem path.");
2165
+ if ("customRules" in options || "reporters" in options) throw new UsageError("Pass customRules and reporters inside the config option.");
2166
+ if (options.pm !== void 0 && !isPM(options.pm)) throw new UsageError(`Unknown package manager: ${String(options.pm)}`);
2167
+ if (options.projectType !== void 0 && !isProjectType(options.projectType)) throw new UsageError(`Unknown project type: ${String(options.projectType)}`);
2168
+ const config = options.config === void 0 ? void 0 : parseConfig(options.config);
2169
+ const ctx = createRepoContext(options.cwd, options.fs, options.projectType ?? config?.projectType);
2170
+ const pms = resolvePMs(ctx, {
2171
+ allowed: config?.pms,
2172
+ pmOverride: options.pm
2173
+ });
2174
+ if (pms.includes("deno") && !ctx.exists(asRelPath("deno.json")) && ctx.exists(asRelPath("deno.jsonc"))) throw new ConfigError("deno.jsonc is not supported. siro currently reads strict JSON from deno.json only.");
2175
+ const configured = applyConfig(rules, config);
2176
+ return {
2177
+ ctx,
2178
+ pms,
2179
+ ruleSet: configured.rules,
2180
+ severityOverrides: configured.severityOverrides,
2181
+ codecFor,
2182
+ reporters: config?.reporters ?? []
2183
+ };
2184
+ };
2185
+ const lint = (options) => runLint(prepareLint(options));
2186
+ //#endregion
2187
+ //#region src/application/commands/lint.ts
2188
+ /** Evaluate and report. Executable config loading belongs to the CLI adapter. */
2189
+ const lintCommand = async (options, io) => {
2190
+ if (!options) throw new UsageError("Lint options are required.");
2191
+ if (options.severity !== void 0 && !isSeverity(options.severity)) throw new UsageError(`Invalid severity: ${String(options.severity)}`);
2192
+ const prepared = prepareLint(options);
2193
+ const registry = createRegistry(prepared.reporters);
2194
+ const selection = options.reporter ?? DEFAULT_REPORTER_NAME;
2195
+ const reporter = typeof selection === "string" ? registry.get(selection) : selection;
2196
+ if (!isReporterShape(reporter)) throw new UsageError(`${typeof selection === "string" ? "Unknown" : "Invalid"} reporter: ${String(selection)} (available: ${[...registry.keys()].join(", ")})`);
2197
+ const result = runLint(prepared);
2198
+ const exitCode = exitCodeForLint(result, options.severity ?? "error");
2199
+ reporter.format(filterBySeverity(result, options.severity ?? "info"), io);
2200
+ return exitCode;
2201
+ };
2202
+ //#endregion
2203
+ //#region src/adapters/node-io.ts
2204
+ const nodeIO = {
2205
+ stderr: (line) => process.stderr.write(`${line}\n`),
2206
+ stdout: (line) => process.stdout.write(`${line}\n`)
2207
+ };
2208
+ //#endregion
2209
+ export { ConfigError as A, SUPPORTED_NODE_RANGE as C, isNodeError as D, nodeFileSystem as E, UsageError as M, asAbsPath as O, isSeverity as S, assertDirectory as T, PROJECT_TYPES as _, overrideBindings as a, SEVERITIES as b, DEFAULT_REPORTER_NAME as c, jsonReporter as d, version as f, getByPath as g, defineRule as h, CONFIG_FILES as i, SiroError as j, asRelPath as k, JSON_REPORTER_NAME as l, loadConfig as m, lintCommand as n, requireConfigKey as o, githubReporter as p, lint as r, BUILTIN_REPORTER_NAMES as s, nodeIO as t, prettyReporter as u, isProjectType as v, isSupportedNodeVersion as w, isPM as x, PMS as y };
2210
+
2211
+ //# sourceMappingURL=node-io-DIRcPNSN.mjs.map