@pho9ubenaa/siro 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2578 +0,0 @@
1
- import pc from "picocolors";
2
- import ini from "ini";
3
- import { parse } from "smol-toml";
4
- import { parseDocument } from "yaml";
5
- import * as vb from "valibot";
6
- import { accessSync, constants, readFileSync } from "node:fs";
7
- import path from "node:path";
8
- import { pathToFileURL } from "node:url";
9
-
10
- //#region src/shared/paths.ts
11
- /** Tag a string as absolute. The caller vouches for the shape. */
12
- const asAbsPath = (path) => path;
13
- /** Tag a string as repo-root-relative. The caller vouches for the shape. */
14
- const asRelPath = (path) => path;
15
-
16
- //#endregion
17
- //#region src/shared/siro-error.ts
18
- /**
19
- * Structured errors used across siro. The CLI maps them to exit codes in one
20
- * place so individual commands never have to think about exit semantics.
21
- * The exit-code table lives in docs/configuration.md S"Exit codes" and
22
- * src/cli/help.ts (HELP_LINT) -- not restated here.
23
- */
24
- var SiroError = class extends Error {
25
- /** Recommended process exit code for this error. */
26
- exitCode;
27
- constructor(message, exitCode) {
28
- super(message);
29
- this.name = "SiroError";
30
- this.exitCode = exitCode;
31
- }
32
- };
33
-
34
- //#endregion
35
- //#region src/shared/config-error.ts
36
- const CONFIG_EXIT_CODE = 2;
37
- var ConfigError = class extends SiroError {
38
- constructor(message) {
39
- super(message, CONFIG_EXIT_CODE);
40
- this.name = "ConfigError";
41
- }
42
- };
43
-
44
- //#endregion
45
- //#region src/shared/usage-error.ts
46
- const USAGE_EXIT_CODE = 2;
47
- var UsageError = class extends SiroError {
48
- constructor(message) {
49
- super(message, USAGE_EXIT_CODE);
50
- this.name = "UsageError";
51
- }
52
- };
53
-
54
- //#endregion
55
- //#region src/shared/errors.ts
56
- /**
57
- * Run `fn` and wrap any non-`ConfigError` failure as a `ConfigError` prefixed
58
- * with `filePath`. Codec `parse` calls hit raw libraries that
59
- * throw bare `Error`s; without this wrap the CLI would surface those as
60
- * exit-1 internal errors and break CI that branches on exit codes. A
61
- * `ConfigError` that bubbles up from a nested call is re-thrown unchanged so
62
- * the original `path: message` framing is preserved.
63
- */
64
- const wrapCodecError = (filePath, fn) => {
65
- try {
66
- return fn();
67
- } catch (error) {
68
- if (error instanceof ConfigError) throw error;
69
- let msg = String(error);
70
- if (error instanceof Error) msg = error.message;
71
- throw new ConfigError(`${filePath}: ${msg}`);
72
- }
73
- };
74
-
75
- //#endregion
76
- //#region src/domain/entities/pms.ts
77
- /**
78
- * Single source of truth for package managers and severities.
79
- * Types, runtime sets, and ordering are all derived from these tuples — add a
80
- * package manager here and the rest of the codebase follows.
81
- */
82
- const PMS = [
83
- "npm",
84
- "pnpm",
85
- "yarn",
86
- "bun",
87
- "deno",
88
- "aube"
89
- ];
90
- const SEVERITIES = [
91
- "error",
92
- "warn",
93
- "info"
94
- ];
95
- /**
96
- * Numeric ordering of severities for threshold comparisons. Co-located with
97
- * `Severity` so `filter`, `runLint`, and any future severity-aware code share
98
- * one source of truth — drifting the order in one place silently changes the
99
- * fail-on-threshold contract everywhere.
100
- */
101
- const SEVERITY_RANK = {
102
- error: 3,
103
- info: 1,
104
- warn: 2
105
- };
106
- const PM_SET = new Set(PMS);
107
- const SEVERITY_SET = new Set(SEVERITIES);
108
- const isPM = (value) => PM_SET.has(value);
109
- const isSeverity = (value) => SEVERITY_SET.has(value);
110
- const PACKAGE_MANAGER_FIELD = /^(?<pmName>[a-z]+)(?:@.+)?$/u;
111
- const parsePackageManagerField = (value) => {
112
- const name = PACKAGE_MANAGER_FIELD.exec(value.trim())?.groups?.pmName;
113
- if (typeof name !== "undefined" && isPM(name)) return name;
114
- };
115
-
116
- //#endregion
117
- //#region src/domain/ports/reporter.ts
118
- /**
119
- * Structural guard for an embedder-supplied reporter. The `Reporter` contract
120
- * is compile-time only (`defineConfig` / typed options), so a hand-written JS
121
- * config or a dynamically-built object can still pass a malformed value; this
122
- * lets each boundary reject it with its own error type before `format` is
123
- * called on something that isn't a function.
124
- */
125
- const isReporterShape = (value) => {
126
- if (typeof value !== "object" || value === null) return false;
127
- if (!("name" in value) || typeof value.name !== "string") return false;
128
- return "format" in value && typeof value.format === "function";
129
- };
130
-
131
- //#endregion
132
- //#region src/adapters/reporters/github.ts
133
- const COMMAND = {
134
- error: "error",
135
- info: "notice",
136
- warn: "warning"
137
- };
138
- const escapeProp = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
139
- const escapeData = (raw) => raw.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
140
- /** Emit GitHub Actions workflow commands (annotations on PRs). */
141
- const githubReporter = {
142
- format(result, io) {
143
- for (const finding of result.findings) {
144
- let file = "";
145
- if (finding.file) file = `file=${escapeProp(finding.file)},`;
146
- let body = `[${finding.pm}] ${finding.message}`;
147
- if (finding.docs) body = `[${finding.pm}] ${finding.message} (${finding.docs})`;
148
- io.stdout(`::${COMMAND[finding.severity]} ${file}title=${escapeProp(finding.ruleId)}::${escapeData(body)}`);
149
- }
150
- },
151
- name: "github"
152
- };
153
-
154
- //#endregion
155
- //#region src/version.ts
156
- const version = "0.3.0";
157
-
158
- //#endregion
159
- //#region src/adapters/reporters/json.ts
160
- /**
161
- * Versioned machine-readable output — the public contract consumed by
162
- * external fixers (see docs/json-output.md). Bump `schemaVersion` on any
163
- * breaking shape change and update that doc in the same commit.
164
- */
165
- const SCHEMA_VERSION = 1;
166
- const JSON_INDENT = 2;
167
- const jsonReporter = {
168
- format(result, io) {
169
- io.stdout(JSON.stringify({
170
- schemaVersion: 1,
171
- siroVersion: version,
172
- ...result
173
- }, void 0, JSON_INDENT));
174
- },
175
- name: "json"
176
- };
177
-
178
- //#endregion
179
- //#region src/adapters/reporters/pretty.ts
180
- const GLYPH = {
181
- error: "✖ error",
182
- info: "ℹ info",
183
- warn: "⚠ warn"
184
- };
185
- /**
186
- * Decide colour at call time, not at import time.
187
- *
188
- * picocolors snapshots `isColorSupported` when imported, so a later
189
- * `process.env.NO_COLOR = '1'` (or a different stdout) is ignored. The
190
- * reporter is invoked per `siro lint` run from many contexts (TTY, CI
191
- * with FORCE_COLOR, redirected output, …) — checking each call is the
192
- * only way NO_COLOR can truly win, as https://no-color.org/ requires.
193
- */
194
- const colorSupportedNow = () => {
195
- const { env } = process;
196
- if (typeof env.NO_COLOR !== "undefined" && env.NO_COLOR !== "") return false;
197
- if ("FORCE_COLOR" in env) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "";
198
- return pc.isColorSupported;
199
- };
200
- const renderFinding = (finding, ctx) => {
201
- let where = "";
202
- if (finding.file) where = ctx.colors.dim(` (${finding.file})`);
203
- ctx.io.stdout(`${ctx.tag[finding.severity](GLYPH[finding.severity])} [${finding.pm}] ${ctx.colors.bold(finding.ruleId)}${where}`);
204
- ctx.io.stdout(` ${finding.message}`);
205
- for (const step of finding.manualSteps ?? []) ctx.io.stdout(` ↳ ${step}`);
206
- if (finding.docs) ctx.io.stdout(ctx.colors.dim(` → ${finding.docs}`));
207
- };
208
- const buildRenderCtx = (io) => {
209
- const colors = pc.createColors(colorSupportedNow());
210
- return {
211
- colors,
212
- io,
213
- tag: {
214
- error: colors.red,
215
- info: colors.cyan,
216
- warn: colors.yellow
217
- }
218
- };
219
- };
220
- const prettyReporter = {
221
- format(result, io) {
222
- const ctx = buildRenderCtx(io);
223
- if (result.findings.length === 0) {
224
- io.stdout(ctx.colors.green("✔ No security best-practice issues found."));
225
- return;
226
- }
227
- for (const finding of result.findings) renderFinding(finding, ctx);
228
- const { error, warn, info } = result.summary;
229
- io.stdout("");
230
- io.stdout(`Summary: ${error} error, ${warn} warn, ${info} info`);
231
- },
232
- name: "pretty"
233
- };
234
-
235
- //#endregion
236
- //#region src/adapters/reporters/registry.ts
237
- const BUILTINS = [
238
- prettyReporter,
239
- jsonReporter,
240
- githubReporter
241
- ];
242
- const DEFAULT_REPORTER_NAME = prettyReporter.name;
243
- const JSON_REPORTER_NAME = jsonReporter.name;
244
- /** Names of every built-in reporter, in stable display order. */
245
- const BUILTIN_REPORTER_NAMES = BUILTINS.map((rep) => rep.name);
246
- /** Build a registry from the builtins plus any extras (later wins on collision). */
247
- const createRegistry = (extras = []) => {
248
- const map = /* @__PURE__ */ new Map();
249
- for (const rep of [...BUILTINS, ...extras]) {
250
- if (!isReporterShape(rep)) throw new TypeError("createRegistry: each reporter needs a string 'name' and a 'format' function.");
251
- map.set(rep.name, rep);
252
- }
253
- return {
254
- get: (name) => map.get(name),
255
- list: () => [...map.keys()]
256
- };
257
- };
258
-
259
- //#endregion
260
- //#region src/shared/node-version.ts
261
- const SUPPORTED_NODE_RANGE = "^22.18.0 || ^23.6.0 || >=24.0.0";
262
- const MAJOR_22 = 22;
263
- const MINOR_22 = 18;
264
- const MAJOR_23 = 23;
265
- const MINOR_23 = 6;
266
- const ALWAYS_SUPPORTED_SINCE = 24;
267
- const isSupportedNodeVersion = (version) => {
268
- const [majorRaw = "", minorRaw = "0"] = version.replace(/^v/u, "").split(".");
269
- const major = Number(majorRaw);
270
- const minor = Number(minorRaw);
271
- if (major >= ALWAYS_SUPPORTED_SINCE) return true;
272
- if (major === MAJOR_23) return minor >= MINOR_23;
273
- if (major === MAJOR_22) return minor >= MINOR_22;
274
- return false;
275
- };
276
-
277
- //#endregion
278
- //#region src/domain/entities/project-type.ts
279
- const PROJECT_TYPES = ["application", "package"];
280
- const isProjectType = (value) => PROJECT_TYPES.some((projectType) => projectType === value);
281
-
282
- //#endregion
283
- //#region src/adapters/node-errors.ts
284
- /**
285
- * Node-runtime error helpers. Lives in the adapters layer so the shared
286
- * module can stay runtime-agnostic — domain code transitively imports
287
- * `shared/errors.ts`, and pulling Node's global type namespace through that
288
- * path would couple every layer to `@types/node` for no reason beyond a
289
- * single type guard.
290
- */
291
- /**
292
- * Type guard for Node FS / system errors. `catch` exposes `unknown`, so every
293
- * `errno`-aware branch needs to prove the shape before reading `code` —
294
- * centralized here so callers don't sprinkle `as NodeJS.ErrnoException`.
295
- */
296
- const isNodeError = (value) => value instanceof Error && "code" in value && typeof value.code === "string";
297
-
298
- //#endregion
299
- //#region src/domain/services/filter.ts
300
- const EXIT_SUCCESS = 0;
301
- const EXIT_FAILURE = 1;
302
- const meetsThreshold = (severity, threshold) => SEVERITY_RANK[severity] >= SEVERITY_RANK[threshold];
303
- /** Keep only findings at or above `threshold`, recomputing the summary. */
304
- const filterBySeverity = (result, threshold) => {
305
- const findings = [];
306
- const summary = {
307
- error: 0,
308
- info: 0,
309
- warn: 0
310
- };
311
- for (const finding of result.findings) if (meetsThreshold(finding.severity, threshold)) {
312
- findings.push(finding);
313
- summary[finding.severity] += EXIT_FAILURE;
314
- }
315
- return {
316
- findings,
317
- summary
318
- };
319
- };
320
- /**
321
- * Exit code for a lint run. Non-zero when any finding meets `threshold`
322
- * (default: only `error` fails the run).
323
- */
324
- const exitCodeForLint = (result, threshold = "error") => {
325
- if (result.findings.some((finding) => meetsThreshold(finding.severity, threshold))) return EXIT_FAILURE;
326
- return EXIT_SUCCESS;
327
- };
328
-
329
- //#endregion
330
- //#region src/domain/entities/config-value.ts
331
- const CODEC_KINDS = [
332
- "json",
333
- "npmrc",
334
- "toml",
335
- "yaml"
336
- ];
337
- const isParsedConfigObject = (value) => {
338
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
339
- const proto = Object.getPrototypeOf(value);
340
- return proto === null || proto === Object.prototype;
341
- };
342
- /**
343
- * Look up a nested value by key path; `undefined` if any segment is missing.
344
- *
345
- * Part of the public surface (re-exported from `src/index.ts`): embedders
346
- * writing custom rules receive the same `config: ParsedConfig` shape that
347
- * built-in rules see, so they need the canonical traversal helper rather
348
- * than re-implementing `null`-vs-missing semantics per rule.
349
- */
350
- const getByPath = (config, keyPath) => {
351
- let current = config;
352
- for (const key of keyPath) {
353
- if (!isParsedConfigObject(current)) return;
354
- if (!Object.hasOwn(current, key)) return;
355
- current = current[key];
356
- }
357
- return current;
358
- };
359
- /**
360
- * Single trust boundary between an external parser and siro's types — the
361
- * `as ParsedConfig` cast lives here only, after a runtime shape check.
362
- * Casting in each codec would scatter the boundary and let runtime checks
363
- * drift out of sync. A deep sanitiser (recursing to keep only known scalars)
364
- * was rejected because YAML legitimately yields `Date` and other host values
365
- * that rules can inspect — silently dropping them would look like missing
366
- * config keys. Throwing on non-objects was rejected so a root-level array or
367
- * scalar behaves the same as an empty file: `{}`, letting `getByPath` return
368
- * `undefined` instead of crashing every rule.
369
- */
370
- const toParsedConfig = (value) => {
371
- if (isParsedConfigObject(value)) return value;
372
- return {};
373
- };
374
-
375
- //#endregion
376
- //#region src/domain/entities/rule.ts
377
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
378
- const isOptionalString = (value) => typeof value === "undefined" || typeof value === "string";
379
- const CONFIG_FILE_KINDS = new Set([...CODEC_KINDS, "fileGlob"]);
380
- const isConfigFileRefShape = (value) => {
381
- if (!isRecord(value) || typeof value.kind !== "string" || typeof value.path !== "string") return false;
382
- return CONFIG_FILE_KINDS.has(value.kind);
383
- };
384
- const isVersionNoteShape = (value) => typeof value === "undefined" || isRecord(value) && isOptionalString(value.configAvailableSince) && isOptionalString(value.defaultSafeSince) && isOptionalString(value.note);
385
- const isRuleBindingShape = (value) => {
386
- if (!isRecord(value)) return false;
387
- return isConfigFileRefShape(value.file) && (value.fixKind === "auto" || value.fixKind === "advisory") && isOptionalString(value.docs) && (typeof value.severity === "undefined" || typeof value.severity === "string" && isSeverity(value.severity)) && isVersionNoteShape(value.versionNote) && typeof value.check === "function" && typeof value.fix === "function";
388
- };
389
- const isBindingsShape = (value) => isRecord(value) && Object.entries(value).every(([pm, binding]) => isPM(pm) && isRuleBindingShape(binding));
390
- const isProjectTypeValue = (value) => typeof value === "string" && isProjectType(value);
391
- const isProjectTypesShape = (value) => typeof value === "undefined" || Array.isArray(value) && Array.from(value).every((projectType) => isProjectTypeValue(projectType));
392
- const isRuleShape = (value) => {
393
- if (!isRecord(value)) return false;
394
- 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);
395
- };
396
- const defineRule = (rule) => rule;
397
-
398
- //#endregion
399
- //#region src/adapters/codecs/ini.ts
400
- const INTEGER_PATTERN = /^-?\d+$/u;
401
- const coerceBoolean = (str) => {
402
- if (str === "true") return true;
403
- if (str === "false") return false;
404
- };
405
- const coerceInteger = (str) => {
406
- if (INTEGER_PATTERN.test(str)) return Number(str);
407
- };
408
- const coerce = (raw) => {
409
- if (typeof raw === "boolean" || typeof raw === "number") return raw;
410
- let str = String(raw ?? "");
411
- if (typeof raw === "string") str = raw;
412
- return coerceBoolean(str) ?? coerceInteger(str) ?? str;
413
- };
414
- /**
415
- * Coerces unquoted scalars on read so rule checks can match by typed
416
- * value (`true === true`, `7 === 7`). Strings whose lexeme matches
417
- * `true`/`false`/`-?\d+` lose their string-ness — this is by design for
418
- * the npm schema, where rule values are booleans / integers / the empty
419
- * string. Don't model a customRule around a string like `"0"` on this
420
- * codec; pick yaml/json if you need lossless arbitrary-string reads.
421
- */
422
- const iniCodec = { parse(text) {
423
- const raw = toParsedConfig(ini.parse(text));
424
- const config = {};
425
- for (const [key, value] of Object.entries(raw)) if (Array.isArray(value)) config[key] = value.map((elem) => coerce(elem));
426
- else if (typeof value === "object" && value !== null) config[key] = toParsedConfig(value);
427
- else config[key] = coerce(value);
428
- return config;
429
- } };
430
-
431
- //#endregion
432
- //#region src/adapters/codecs/json.ts
433
- /** Codec for strict JSON (deno.json, package.json). Comments are not supported. */
434
- const jsonCodec = { parse(text) {
435
- const trimmed = text.trim();
436
- if (trimmed === "") return {};
437
- return toParsedConfig(JSON.parse(trimmed));
438
- } };
439
-
440
- //#endregion
441
- //#region src/adapters/codecs/toml.ts
442
- const tomlCodec = { parse(text) {
443
- if (text.trim() === "") return {};
444
- return toParsedConfig(parse(text.replace(/^\uFEFF/u, "")));
445
- } };
446
-
447
- //#endregion
448
- //#region src/adapters/codecs/yaml.ts
449
- const yamlCodec = { parse(text) {
450
- if (text.trim() === "") return {};
451
- const document = parseDocument(text);
452
- if (document.errors.length > 0) throw document.errors[0];
453
- return toParsedConfig(document.toJS({ onAnchor: (value) => {
454
- JSON.stringify(value);
455
- } }));
456
- } };
457
-
458
- //#endregion
459
- //#region src/adapters/codecs/store.ts
460
- const CODECS = {
461
- json: jsonCodec,
462
- npmrc: iniCodec,
463
- toml: tomlCodec,
464
- yaml: yamlCodec
465
- };
466
- /** Look up the codec for a parseable kind. Total — every CodecKind has one. */
467
- const codecFor = (kind) => CODECS[kind];
468
-
469
- //#endregion
470
- //#region src/domain/services/apply-config.ts
471
- const stripDynamicSeverity = (check) => (ctx, config) => {
472
- const status = check(ctx, config);
473
- if (status.state === "violation" && typeof status.severity !== "undefined") return {
474
- actual: status.actual,
475
- expected: status.expected,
476
- manualSteps: status.manualSteps,
477
- message: status.message,
478
- state: "violation"
479
- };
480
- return status;
481
- };
482
- /**
483
- * Rewrite all bindings of a rule to strip dynamic and per-binding severity,
484
- * so the user's config-level severity override is the sole authority.
485
- */
486
- const rewriteBindingsForSeverityOverride = (rule, override) => {
487
- const bindings = {};
488
- for (const pm of PMS) {
489
- const binding = rule.bindings[pm];
490
- if (typeof binding !== "undefined") bindings[pm] = {
491
- ...binding,
492
- check: stripDynamicSeverity(binding.check),
493
- severity: void 0
494
- };
495
- }
496
- return {
497
- ...rule,
498
- bindings,
499
- severity: override
500
- };
501
- };
502
- /**
503
- * Merge a user `SiroConfig` into the base ruleset, returning a fresh array
504
- * (the inputs are not mutated). `base` is the already-resolved rule list —
505
- * typically builtins plus any programmatic `customRules` — and `config` is
506
- * the loaded `SiroConfig` whose `customRules` extend `base` and whose
507
- * `rules` map applies severity overrides (or `'off'` to drop).
508
- */
509
- const applyOverride = (rule, overrides, out) => {
510
- const override = overrides[rule.id];
511
- if (override === "off") return;
512
- if (typeof override === "undefined") out.push(rule);
513
- else out.push(rewriteBindingsForSeverityOverride(rule, override));
514
- };
515
- const mergeBase = (base, config) => {
516
- const customRules = config?.customRules;
517
- return [...base, ...customRules ?? []];
518
- };
519
- const applyConfig = (base, config) => {
520
- const merged = mergeBase(base, config);
521
- const overrides = config?.rules;
522
- if (typeof overrides === "undefined") return merged;
523
- const out = [];
524
- for (const rule of merged) applyOverride(rule, overrides, out);
525
- return out;
526
- };
527
-
528
- //#endregion
529
- //#region src/domain/entities/config-files.ts
530
- /**
531
- * Canonical {@link ConfigFileRef}s for every package-manager config file siro
532
- * knows how to read. Centralizing them keeps each rule module from hardcoding
533
- * `kind` / `path` pairs, so renaming a file (or fixing a typo in its path) is a
534
- * single-line change. This is also the one place `RelPath` is minted for a
535
- * config ref — every downstream binding inherits the brand, so no rule needs
536
- * an ad-hoc `asRelPath(ref.path)` cast at the FS boundary.
537
- *
538
- * Add a new entry here when introducing support for a new package manager.
539
- */
540
- const CONFIG_FILES = {
541
- aubeWorkspace: {
542
- kind: "yaml",
543
- path: asRelPath("aube-workspace.yaml")
544
- },
545
- bunfig: {
546
- kind: "toml",
547
- path: asRelPath("bunfig.toml")
548
- },
549
- denoJson: {
550
- kind: "json",
551
- path: asRelPath("deno.json")
552
- },
553
- npmrc: {
554
- kind: "npmrc",
555
- path: asRelPath(".npmrc")
556
- },
557
- packageJson: {
558
- kind: "json",
559
- path: asRelPath("package.json")
560
- },
561
- pnpmWorkspace: {
562
- kind: "yaml",
563
- path: asRelPath("pnpm-workspace.yaml")
564
- },
565
- yarnrc: {
566
- kind: "yaml",
567
- path: asRelPath(".yarnrc.yml")
568
- }
569
- };
570
-
571
- //#endregion
572
- //#region src/domain/rules/builders/require-config-key.ts
573
- /**
574
- * Replace one or more bindings on an existing rule, returning a fresh Rule
575
- * object. Used by rules whose shape outgrows {@link requireConfigKey} for a
576
- * subset of PMs — they build the simple slots via the builder, then splice
577
- * the hand-written bindings in via this helper rather than rebuilding from
578
- * scratch or mutating the source rule.
579
- */
580
- const overrideBindings = (rule, overrides) => ({
581
- ...rule,
582
- bindings: {
583
- ...rule.bindings,
584
- ...overrides
585
- }
586
- });
587
- const isDefaultOk = (spec) => {
588
- if (spec.accept) return spec.accept(spec.documentedDefault);
589
- return spec.documentedDefault === spec.value;
590
- };
591
- const checkDocumentedDefault = (spec, actual, message) => {
592
- if (typeof actual !== "undefined" || typeof spec.documentedDefault === "undefined") return;
593
- if (!isDefaultOk(spec)) return;
594
- const sev = spec.defaultSatisfiedSeverity ?? "info";
595
- if (sev === "off") return { state: "ok" };
596
- return {
597
- actual,
598
- expected: spec.value,
599
- message,
600
- severity: sev,
601
- state: "violation"
602
- };
603
- };
604
- const checkExtraFixes = (spec, config, message) => {
605
- for (const extra of spec.extraFix ?? []) {
606
- const extraActual = getByPath(config, extra.keyPath);
607
- if (extraActual !== extra.value) return {
608
- actual: extraActual,
609
- expected: extra.value,
610
- message,
611
- state: "violation"
612
- };
613
- }
614
- };
615
- const checkPrimary = (spec, actual, message) => {
616
- let primaryOk = actual === spec.value;
617
- if (spec.accept) primaryOk = spec.accept(actual);
618
- if (!primaryOk) return {
619
- actual,
620
- expected: spec.value,
621
- message,
622
- state: "violation"
623
- };
624
- };
625
- const checkKeyValue = (spec, config, message) => {
626
- const actual = getByPath(config, spec.keyPath);
627
- const advisory = checkDocumentedDefault(spec, actual, message);
628
- if (typeof advisory === "undefined") {
629
- const primary = checkPrimary(spec, actual, message);
630
- if (typeof primary !== "undefined") return primary;
631
- }
632
- return checkExtraFixes(spec, config, message) ?? advisory ?? { state: "ok" };
633
- };
634
- const buildBinding = (spec, applies) => ({
635
- check(ctx, config) {
636
- if (typeof applies !== "undefined" && !applies(ctx)) return { state: "na" };
637
- return checkKeyValue(spec, config, spec.message);
638
- },
639
- docs: spec.docs,
640
- file: spec.file,
641
- fix() {
642
- const ops = [{
643
- file: spec.file,
644
- keyPath: spec.keyPath,
645
- op: "setKey",
646
- value: spec.value
647
- }];
648
- for (const extra of spec.extraFix ?? []) ops.push({
649
- file: spec.file,
650
- keyPath: extra.keyPath,
651
- op: "setKey",
652
- value: extra.value
653
- });
654
- return ops;
655
- },
656
- fixKind: "auto",
657
- severity: spec.severity,
658
- versionNote: spec.versionNote
659
- });
660
- /** Build a Rule from a per-PM table of {file, keyPath, value, message}. */
661
- const requireConfigKey = (options) => {
662
- const bindings = {};
663
- for (const pm of PMS) {
664
- const spec = options.bindings[pm];
665
- if (typeof spec !== "undefined") bindings[pm] = buildBinding(spec, options.applies);
666
- }
667
- return {
668
- bindings,
669
- description: options.description,
670
- docs: options.docs,
671
- id: options.id,
672
- projectTypes: options.projectTypes,
673
- severity: options.severity,
674
- title: options.title
675
- };
676
- };
677
-
678
- //#endregion
679
- //#region src/domain/rules/advisory-check.ts
680
- const { aubeWorkspace: aubeWorkspace$9 } = CONFIG_FILES;
681
- const advisoryCheck = requireConfigKey({
682
- bindings: { aube: {
683
- accept: (value) => value === "on" || value === "required",
684
- docs: "https://aube.jdx.dev/security.html",
685
- file: aubeWorkspace$9,
686
- keyPath: ["advisoryCheck"],
687
- message: "Set `advisoryCheck: on` (or `required`) in aube-workspace.yaml to check dependencies against known-malicious advisories.",
688
- value: "on"
689
- } },
690
- description: "Query the OSV database for known-malicious packages during dependency resolution.",
691
- docs: "https://aube.jdx.dev/security.html",
692
- id: "advisory-check",
693
- severity: "warn",
694
- title: "Enable malicious-package advisory checks"
695
- });
696
-
697
- //#endregion
698
- //#region src/domain/rules/approved-git-repos.ts
699
- const { yarnrc: yarnrc$9 } = CONFIG_FILES;
700
- const message = "Set `approvedGitRepositories: []` in .yarnrc.yml to block all git: protocol dependencies (or list approved repository globs).";
701
- const yarnBinding$2 = {
702
- check(_ctx, config) {
703
- const value = getByPath(config, ["approvedGitRepositories"]);
704
- if (Array.isArray(value)) return { state: "ok" };
705
- return {
706
- actual: value,
707
- expected: "[]",
708
- message,
709
- state: "violation"
710
- };
711
- },
712
- docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
713
- file: yarnrc$9,
714
- fix() {
715
- return [{
716
- file: yarnrc$9,
717
- message: "Add `approvedGitRepositories: []` to .yarnrc.yml to block all git deps, or list specific approved repository URL globs.",
718
- op: "note"
719
- }];
720
- },
721
- fixKind: "advisory",
722
- versionNote: { configAvailableSince: "yarn 4.14.0" }
723
- };
724
- const approvedGitRepos = defineRule({
725
- bindings: { yarn: yarnBinding$2 },
726
- description: "Restrict git: protocol dependencies to an explicit allowlist of approved repository URL patterns.",
727
- docs: "https://yarnpkg.com/configuration/yarnrc#approvedGitRepositories",
728
- id: "approved-git-repos",
729
- severity: "warn",
730
- title: "Approve git repository dependencies"
731
- });
732
-
733
- //#endregion
734
- //#region src/domain/rules/audit-suppression.ts
735
- const { yarnrc: yarnrc$8 } = CONFIG_FILES;
736
- const isNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
737
- const SUPPRESSION_KEYS = ["npmAuditIgnoreAdvisories", "npmAuditExcludePackages"];
738
- const checkSuppression = (config) => {
739
- const present = SUPPRESSION_KEYS.filter((key) => isNonEmptyArray(getByPath(config, [key])));
740
- const [first] = present;
741
- if (typeof first === "undefined") return { state: "ok" };
742
- return {
743
- actual: getByPath(config, [first]),
744
- message: `Review audit suppression in .yarnrc.yml (${present.join(", ")}). Broad glob patterns can silently hide future vulnerabilities.`,
745
- state: "violation"
746
- };
747
- };
748
- const yarnBinding$1 = {
749
- check(_ctx, config) {
750
- return checkSuppression(config);
751
- },
752
- docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
753
- file: yarnrc$8,
754
- fix() {
755
- return [{
756
- file: yarnrc$8,
757
- message: "Review entries in `npmAuditIgnoreAdvisories` and `npmAuditExcludePackages` — remove stale suppressions and overly broad glob patterns.",
758
- op: "note"
759
- }];
760
- },
761
- fixKind: "advisory"
762
- };
763
- const auditSuppression = defineRule({
764
- bindings: { yarn: yarnBinding$1 },
765
- description: "Flag audit advisory suppressions that may silently hide future vulnerabilities via broad glob patterns.",
766
- docs: "https://yarnpkg.com/configuration/yarnrc#npmAuditIgnoreAdvisories",
767
- id: "audit-suppression",
768
- severity: "info",
769
- title: "Review audit suppression entries"
770
- });
771
-
772
- //#endregion
773
- //#region src/domain/rules/block-auto-install.ts
774
- const { bunfig: bunfig$5 } = CONFIG_FILES;
775
- const blockAutoInstall = requireConfigKey({
776
- bindings: { bun: {
777
- docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
778
- file: bunfig$5,
779
- keyPath: ["install", "auto"],
780
- message: "Set `auto = \"disable\"` under [install] in bunfig.toml to prevent auto-install from bypassing security guards.",
781
- value: "disable"
782
- } },
783
- description: "Disable auto-install so dependencies are only installed through an explicit install step where security guards apply.",
784
- docs: "https://bun.sh/docs/runtime/bunfig#install-auto",
785
- id: "block-auto-install",
786
- severity: "warn",
787
- title: "Block automatic package installation"
788
- });
789
-
790
- //#endregion
791
- //#region src/domain/rules/block-exotic-subdeps.ts
792
- const { aubeWorkspace: aubeWorkspace$8, npmrc: npmrc$6, pnpmWorkspace: pnpmWorkspace$10 } = CONFIG_FILES;
793
- const isRootOrNone = (value) => value === "root" || value === "none";
794
- const npmMessage = "Set `allow-git=root` and `allow-remote=root` in .npmrc to block git/tarball URLs in transitive dependencies.";
795
- const npmBinding = {
796
- check(_ctx, config) {
797
- const git = getByPath(config, ["allow-git"]);
798
- if (!isRootOrNone(git)) return {
799
- actual: git,
800
- expected: "root",
801
- message: npmMessage,
802
- state: "violation"
803
- };
804
- const remote = getByPath(config, ["allow-remote"]);
805
- if (!isRootOrNone(remote)) return {
806
- actual: remote,
807
- expected: "root",
808
- message: npmMessage,
809
- state: "violation"
810
- };
811
- return { state: "ok" };
812
- },
813
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#allow-git",
814
- file: npmrc$6,
815
- fix() {
816
- return [{
817
- file: npmrc$6,
818
- keyPath: ["allow-git"],
819
- op: "setKey",
820
- value: "root"
821
- }, {
822
- file: npmrc$6,
823
- keyPath: ["allow-remote"],
824
- op: "setKey",
825
- value: "root"
826
- }];
827
- },
828
- fixKind: "auto"
829
- };
830
- const builtRule$3 = requireConfigKey({
831
- bindings: {
832
- aube: {
833
- docs: "https://aube.jdx.dev/security.html",
834
- documentedDefault: true,
835
- file: aubeWorkspace$8,
836
- keyPath: ["blockExoticSubdeps"],
837
- message: "Set `blockExoticSubdeps: true` in aube-workspace.yaml to block git/tarball URLs in transitive dependencies.",
838
- value: true
839
- },
840
- pnpm: {
841
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
842
- documentedDefault: true,
843
- file: pnpmWorkspace$10,
844
- keyPath: ["blockExoticSubdeps"],
845
- message: "Set `blockExoticSubdeps: true` in pnpm-workspace.yaml to block git/tarball URLs in transitive dependencies.",
846
- value: true,
847
- versionNote: {
848
- configAvailableSince: "pnpm 10.26.0",
849
- defaultSafeSince: "pnpm 10.26.0"
850
- }
851
- }
852
- },
853
- description: "Refuse to install transitive dependencies sourced from git or tarball URLs, which bypass registry integrity checking.",
854
- docs: "https://pnpm.io/settings#blockexoticsubdeps",
855
- id: "block-exotic-subdeps",
856
- severity: "warn",
857
- title: "Block exotic subdependencies"
858
- });
859
- const blockExoticSubdeps = overrideBindings(builtRule$3, { npm: npmBinding });
860
-
861
- //#endregion
862
- //#region src/domain/rules/bun-security-scanner.ts
863
- const { bunfig: bunfig$4 } = CONFIG_FILES;
864
- const bunScannerBinding = {
865
- check(_ctx, config) {
866
- const scanner = getByPath(config, [
867
- "install",
868
- "security",
869
- "scanner"
870
- ]);
871
- if (typeof scanner === "string" && scanner.length > 0) return { state: "ok" };
872
- return {
873
- actual: scanner,
874
- message: "Configure `[install.security] scanner = \"...\"` in bunfig.toml (e.g. `@socketsecurity/bun-security-scanner`) to scan new packages on install.",
875
- state: "violation"
876
- };
877
- },
878
- docs: "https://bun.com/docs/pm/security-scanner-api",
879
- file: bunfig$4,
880
- fix() {
881
- return [{
882
- file: bunfig$4,
883
- message: "Add `[install.security] scanner = \"@socketsecurity/bun-security-scanner\"` (or another bun-compatible scanner) to bunfig.toml.",
884
- op: "note"
885
- }];
886
- },
887
- fixKind: "advisory",
888
- versionNote: { configAvailableSince: "bun 1.3.0" }
889
- };
890
- const bunSecurityScanner = defineRule({
891
- bindings: { bun: bunScannerBinding },
892
- description: "Bun supports a Security Scanner API that intercepts new packages at install time (e.g. Socket Firewall).",
893
- docs: "https://github.com/bodadotsh/npm-security-best-practices#preinstall-scanners",
894
- id: "bun-security-scanner",
895
- severity: "info",
896
- title: "Enable a bun install-time security scanner"
897
- });
898
-
899
- //#endregion
900
- //#region src/domain/rules/checksum-verification.ts
901
- const { yarnrc: yarnrc$7 } = CONFIG_FILES;
902
- const checksumVerification = requireConfigKey({
903
- bindings: { yarn: {
904
- docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
905
- documentedDefault: "throw",
906
- file: yarnrc$7,
907
- keyPath: ["checksumBehavior"],
908
- message: "Set `checksumBehavior: throw` in .yarnrc.yml to reject packages whose checksum does not match the lockfile.",
909
- value: "throw"
910
- } },
911
- description: "Throw on checksum mismatches so tampered or corrupted packages are never silently installed.",
912
- docs: "https://yarnpkg.com/configuration/yarnrc#checksumBehavior",
913
- id: "checksum-verification",
914
- severity: "warn",
915
- title: "Verify package checksums"
916
- });
917
-
918
- //#endregion
919
- //#region src/domain/entities/signals.ts
920
- const PM_SIGNALS = {
921
- aube: {
922
- configs: [CONFIG_FILES.aubeWorkspace.path],
923
- lockfiles: ["aube-lock.yaml"],
924
- reusesLockfiles: [
925
- "pnpm-lock.yaml",
926
- "package-lock.json",
927
- "npm-shrinkwrap.json",
928
- "yarn.lock",
929
- "bun.lock",
930
- "bun.lockb",
931
- "deno.lock"
932
- ]
933
- },
934
- bun: {
935
- configs: [CONFIG_FILES.bunfig.path],
936
- lockfiles: ["bun.lock", "bun.lockb"]
937
- },
938
- deno: {
939
- configs: [CONFIG_FILES.denoJson.path],
940
- lockfiles: ["deno.lock"]
941
- },
942
- npm: {
943
- configs: [],
944
- lockfiles: ["package-lock.json", "npm-shrinkwrap.json"]
945
- },
946
- pnpm: {
947
- configs: [CONFIG_FILES.pnpmWorkspace.path],
948
- lockfiles: ["pnpm-lock.yaml"]
949
- },
950
- yarn: {
951
- configs: [CONFIG_FILES.yarnrc.path],
952
- lockfiles: ["yarn.lock"]
953
- }
954
- };
955
-
956
- //#endregion
957
- //#region src/domain/rules/commit-lockfile.ts
958
- const LOCKFILE_DOCS = {
959
- aube: "https://aube.en.dev/package-manager/lockfiles",
960
- bun: "https://bun.com/docs/install/lockfile",
961
- deno: "https://docs.deno.com/runtime/fundamentals/modules/#integrity-checking-and-lock-files",
962
- npm: "https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json",
963
- pnpm: "https://pnpm.io/git#lockfiles",
964
- yarn: "https://yarnpkg.com/getting-started/qa#should-lockfiles-be-committed-to-the-repository"
965
- };
966
- const LOCKFILE_VERSION_NOTES = { deno: { configAvailableSince: "deno 1.28.0" } };
967
- const lockfileBinding = (pm) => {
968
- const { lockfiles, reusesLockfiles } = PM_SIGNALS[pm];
969
- const [primary] = lockfiles;
970
- const primaryRef = {
971
- kind: "fileGlob",
972
- path: asRelPath(primary)
973
- };
974
- const accepted = [...lockfiles, ...reusesLockfiles ?? []];
975
- return {
976
- check(ctx) {
977
- if (accepted.some((lf) => ctx.exists(asRelPath(lf)))) return { state: "ok" };
978
- return {
979
- message: `No lockfile found. Generate and commit ${primary}.`,
980
- state: "violation"
981
- };
982
- },
983
- docs: LOCKFILE_DOCS[pm],
984
- file: primaryRef,
985
- fix() {
986
- return [{
987
- file: primaryRef,
988
- message: `Install dependencies to generate ${primary}, then commit it.`,
989
- op: "ensureFileTracked"
990
- }];
991
- },
992
- fixKind: "advisory",
993
- versionNote: LOCKFILE_VERSION_NOTES[pm]
994
- };
995
- };
996
- const commitLockfile = defineRule({
997
- bindings: {
998
- aube: lockfileBinding("aube"),
999
- bun: lockfileBinding("bun"),
1000
- deno: lockfileBinding("deno"),
1001
- npm: lockfileBinding("npm"),
1002
- pnpm: lockfileBinding("pnpm"),
1003
- yarn: lockfileBinding("yarn")
1004
- },
1005
- description: "Lockfiles pin the full dependency tree and integrity hashes, enabling reproducible, verifiable installs (e.g. `npm ci`).",
1006
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1007
- id: "commit-lockfile",
1008
- severity: "error",
1009
- title: "Commit a lockfile"
1010
- });
1011
-
1012
- //#endregion
1013
- //#region src/domain/rules/dependency-overrides.ts
1014
- const { aubeWorkspace: aubeWorkspace$7, pnpmWorkspace: pnpmWorkspace$9 } = CONFIG_FILES;
1015
- const isNonEmptyObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1016
- const checkOverrides = (config, file) => {
1017
- const value = getByPath(config, ["overrides"]);
1018
- if (!isNonEmptyObject$2(value)) return { state: "ok" };
1019
- return {
1020
- actual: value,
1021
- message: `Review \`overrides\` in ${file}. Overrides can replace transitive dependencies with arbitrary versions or forks.`,
1022
- state: "violation"
1023
- };
1024
- };
1025
- const makeBinding = (file, fileName, docs) => ({
1026
- check(_ctx, config) {
1027
- return checkOverrides(config, fileName);
1028
- },
1029
- docs,
1030
- file,
1031
- fix() {
1032
- return [{
1033
- file,
1034
- message: "Review each entry in `overrides` — verify pinned versions address a known CVE and are not redirecting to untrusted packages.",
1035
- op: "note"
1036
- }];
1037
- },
1038
- fixKind: "advisory"
1039
- });
1040
- const dependencyOverrides = defineRule({
1041
- bindings: {
1042
- aube: makeBinding(aubeWorkspace$7, "aube-workspace.yaml", "https://aube.jdx.dev/settings.html"),
1043
- pnpm: makeBinding(pnpmWorkspace$9, "pnpm-workspace.yaml", "https://pnpm.io/settings#overrides")
1044
- },
1045
- description: "Flag dependency overrides that can replace transitive packages with arbitrary versions or forks — a supply-chain injection vector.",
1046
- docs: "https://pnpm.io/settings#overrides",
1047
- id: "dependency-overrides",
1048
- severity: "info",
1049
- title: "Review dependency overrides"
1050
- });
1051
-
1052
- //#endregion
1053
- //#region src/domain/rules/disable-lifecycle-scripts.ts
1054
- const { npmrc: npmrc$5, pnpmWorkspace: pnpmWorkspace$8, yarnrc: yarnrc$6, aubeWorkspace: aubeWorkspace$6, bunfig: bunfig$3 } = CONFIG_FILES;
1055
- const pnpmBinding$4 = {
1056
- check(_ctx, config) {
1057
- const bypass = getByPath(config, ["dangerouslyAllowAllBuilds"]);
1058
- if (bypass === true) return {
1059
- actual: bypass,
1060
- expected: false,
1061
- manualSteps: ["Remove `dangerouslyAllowAllBuilds: true` from pnpm-workspace.yaml (or set it to `false`). Setting `strictDepBuilds: true` alone has no effect while the bypass remains."],
1062
- message: "`dangerouslyAllowAllBuilds: true` in pnpm-workspace.yaml bypasses strictDepBuilds — remove it (or set it to false) to restore lifecycle-script gating.",
1063
- state: "violation"
1064
- };
1065
- const strict = getByPath(config, ["strictDepBuilds"]);
1066
- if (strict === true) return { state: "ok" };
1067
- if (typeof strict === "undefined") return {
1068
- actual: strict,
1069
- expected: true,
1070
- message: "Set `strictDepBuilds: true` in pnpm-workspace.yaml to pin lifecycle-script gating across versions.",
1071
- severity: "info",
1072
- state: "violation"
1073
- };
1074
- return {
1075
- actual: strict,
1076
- expected: true,
1077
- message: "Set `strictDepBuilds: true` in pnpm-workspace.yaml to block silent skips of un-approved dep builds.",
1078
- state: "violation"
1079
- };
1080
- },
1081
- docs: "https://pnpm.io/settings#strictdepbuilds",
1082
- file: pnpmWorkspace$8,
1083
- fix() {
1084
- return [{
1085
- file: pnpmWorkspace$8,
1086
- keyPath: ["strictDepBuilds"],
1087
- op: "setKey",
1088
- value: true
1089
- }];
1090
- },
1091
- fixKind: "auto",
1092
- versionNote: {
1093
- configAvailableSince: "pnpm 10.3.0",
1094
- defaultSafeSince: "pnpm 11.0.0"
1095
- }
1096
- };
1097
- const aubeBinding = {
1098
- check(_ctx, config) {
1099
- const jail = getByPath(config, ["jailBuilds"]);
1100
- if (jail !== true) return {
1101
- actual: jail,
1102
- expected: true,
1103
- message: "Set `jailBuilds: true` in aube-workspace.yaml to sandbox build scripts.",
1104
- state: "violation"
1105
- };
1106
- const strict = getByPath(config, ["strictDepBuilds"]);
1107
- if (strict !== true) return {
1108
- actual: strict,
1109
- expected: true,
1110
- message: "Set `strictDepBuilds: true` in aube-workspace.yaml to require explicit allow/deny for lifecycle scripts.",
1111
- state: "violation"
1112
- };
1113
- return { state: "ok" };
1114
- },
1115
- docs: "https://aube.jdx.dev/security.html",
1116
- file: aubeWorkspace$6,
1117
- fix() {
1118
- return [{
1119
- file: aubeWorkspace$6,
1120
- keyPath: ["jailBuilds"],
1121
- op: "setKey",
1122
- value: true
1123
- }, {
1124
- file: aubeWorkspace$6,
1125
- keyPath: ["strictDepBuilds"],
1126
- op: "setKey",
1127
- value: true
1128
- }];
1129
- },
1130
- fixKind: "auto"
1131
- };
1132
- 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).";
1133
- const bunBinding = {
1134
- check(ctx, config) {
1135
- const ignoreScripts = getByPath(config, ["install", "ignoreScripts"]);
1136
- if (ignoreScripts === true) return { state: "ok" };
1137
- const trusted = ctx.packageJson?.trustedDependencies;
1138
- if (typeof trusted !== "undefined" && trusted.length === 0) return { state: "ok" };
1139
- return {
1140
- actual: ignoreScripts,
1141
- expected: true,
1142
- message: bunMessage,
1143
- state: "violation"
1144
- };
1145
- },
1146
- docs: "https://bun.com/docs/pm/lifecycle",
1147
- file: bunfig$3,
1148
- fix() {
1149
- return [{
1150
- file: bunfig$3,
1151
- keyPath: ["install", "ignoreScripts"],
1152
- op: "setKey",
1153
- value: true
1154
- }];
1155
- },
1156
- fixKind: "auto",
1157
- severity: "info",
1158
- versionNote: { configAvailableSince: "bun 1.2.0" }
1159
- };
1160
- const builtRule$2 = requireConfigKey({
1161
- bindings: {
1162
- npm: {
1163
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#ignore-scripts",
1164
- file: npmrc$5,
1165
- keyPath: ["ignore-scripts"],
1166
- message: "Set `ignore-scripts=true` in .npmrc to block dependency lifecycle scripts.",
1167
- value: true
1168
- },
1169
- yarn: {
1170
- docs: "https://yarnpkg.com/configuration/yarnrc#enableScripts",
1171
- documentedDefault: false,
1172
- file: yarnrc$6,
1173
- keyPath: ["enableScripts"],
1174
- message: "Set `enableScripts: false` in .yarnrc.yml to pin the policy across versions.",
1175
- value: false,
1176
- versionNote: {
1177
- configAvailableSince: "yarn 2.0.0",
1178
- defaultSafeSince: "yarn 4.14.0"
1179
- }
1180
- }
1181
- },
1182
- description: "Malicious postinstall scripts are a primary supply-chain attack vector. Prevent automatic execution of dependency lifecycle scripts.",
1183
- docs: "https://github.com/bodadotsh/npm-security-best-practices#3-disable-lifecycle-scripts",
1184
- id: "disable-lifecycle-scripts",
1185
- severity: "error",
1186
- title: "Disable dependency lifecycle scripts"
1187
- });
1188
- const disableLifecycleScripts = overrideBindings(builtRule$2, {
1189
- aube: aubeBinding,
1190
- bun: bunBinding,
1191
- pnpm: pnpmBinding$4
1192
- });
1193
-
1194
- //#endregion
1195
- //#region src/domain/rules/enforce-strict-ssl.ts
1196
- const { npmrc: npmrc$4, yarnrc: yarnrc$5 } = CONFIG_FILES;
1197
- const yarnMessage = "Set `enableStrictSsl: true` in .yarnrc.yml to enforce SSL certificate validation for registry connections.";
1198
- const yarnBinding = {
1199
- check(_ctx, config) {
1200
- const strictSsl = getByPath(config, ["enableStrictSsl"]);
1201
- if (typeof strictSsl !== "undefined" && strictSsl !== true) return {
1202
- actual: strictSsl,
1203
- expected: true,
1204
- message: yarnMessage,
1205
- state: "violation"
1206
- };
1207
- const whitelist = getByPath(config, ["unsafeHttpWhitelist"]);
1208
- if (Array.isArray(whitelist) && whitelist.length > 0) return {
1209
- actual: whitelist,
1210
- expected: "",
1211
- manualSteps: ["Review and remove entries from `unsafeHttpWhitelist` in .yarnrc.yml. Each entry permits unencrypted HTTP to that hostname."],
1212
- message: "`unsafeHttpWhitelist` in .yarnrc.yml allows unencrypted HTTP connections — remove entries or clear the list to enforce HTTPS-only registry access.",
1213
- state: "violation"
1214
- };
1215
- if (typeof strictSsl === "undefined") return {
1216
- actual: strictSsl,
1217
- expected: true,
1218
- message: yarnMessage,
1219
- severity: "info",
1220
- state: "violation"
1221
- };
1222
- return { state: "ok" };
1223
- },
1224
- docs: "https://yarnpkg.com/configuration/yarnrc#enableStrictSsl",
1225
- file: yarnrc$5,
1226
- fix() {
1227
- return [{
1228
- file: yarnrc$5,
1229
- keyPath: ["enableStrictSsl"],
1230
- op: "setKey",
1231
- value: true
1232
- }];
1233
- },
1234
- fixKind: "auto"
1235
- };
1236
- const builtRule$1 = requireConfigKey({
1237
- bindings: { npm: {
1238
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1239
- documentedDefault: true,
1240
- file: npmrc$4,
1241
- keyPath: ["strict-ssl"],
1242
- message: "Set `strict-ssl=true` in .npmrc to enforce SSL certificate validation.",
1243
- value: true
1244
- } },
1245
- description: "Require SSL certificate validation so registry traffic cannot be intercepted or tampered with.",
1246
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-ssl",
1247
- id: "enforce-strict-ssl",
1248
- severity: "warn",
1249
- title: "Enforce SSL for registry connections"
1250
- });
1251
- const enforceStrictSsl = overrideBindings(builtRule$1, { yarn: yarnBinding });
1252
-
1253
- //#endregion
1254
- //#region src/domain/services/project-type.ts
1255
- const EMPTY$1 = 0;
1256
- const isPublishableName = (value) => typeof value === "string" && value.trim().length > EMPTY$1;
1257
- const resolveProjectType = (selected, inferredPackage) => {
1258
- if (typeof selected !== "undefined") return selected;
1259
- if (inferredPackage) return "package";
1260
- return "application";
1261
- };
1262
- const resolvePackageJsonProjectType = (ctx) => {
1263
- const pkg = ctx.packageJson;
1264
- const inferredPackage = pkg?.private !== true && isPublishableName(pkg?.name);
1265
- return resolveProjectType(ctx.projectType, inferredPackage);
1266
- };
1267
- const resolveDenoProjectType = (ctx, denoConfig) => resolveProjectType(ctx.projectType, isPublishableName(getByPath(denoConfig, ["name"])));
1268
-
1269
- //#endregion
1270
- //#region src/domain/rules/publishable.ts
1271
- const isPublishable = (ctx) => resolvePackageJsonProjectType(ctx) === "package";
1272
-
1273
- //#endregion
1274
- //#region src/domain/rules/files-field.ts
1275
- const { packageJson: packageJson$1, denoJson: denoJson$3 } = CONFIG_FILES;
1276
- const packageJsonFilesBinding = {
1277
- check(ctx) {
1278
- if (!isPublishable(ctx)) return { state: "na" };
1279
- const files = ctx.packageJson?.files;
1280
- if (Array.isArray(files) && files.length > 0) return { state: "ok" };
1281
- return {
1282
- actual: files,
1283
- message: "Add a `files` allow-list to package.json to limit what gets published.",
1284
- state: "violation"
1285
- };
1286
- },
1287
- docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#files",
1288
- file: packageJson$1,
1289
- fix() {
1290
- return [{
1291
- file: packageJson$1,
1292
- message: "Add a `files` array to package.json (e.g. [\"dist\"]) and verify with `npm pack --dry-run`.",
1293
- op: "note"
1294
- }];
1295
- },
1296
- fixKind: "advisory"
1297
- };
1298
- const denoPublishBinding = {
1299
- check(ctx, config) {
1300
- if (resolveDenoProjectType(ctx, config) !== "package") return { state: "na" };
1301
- const include = getByPath(config, ["publish", "include"]);
1302
- if (Array.isArray(include) && include.length > 0) return { state: "ok" };
1303
- return {
1304
- actual: include,
1305
- message: "Add `publish.include` to deno.json to limit what gets published.",
1306
- state: "violation"
1307
- };
1308
- },
1309
- docs: "https://docs.deno.com/runtime/reference/cli/publish/#how-publishing-works",
1310
- file: denoJson$3,
1311
- fix() {
1312
- return [{
1313
- file: denoJson$3,
1314
- message: "Add `publish.include` (and/or `publish.exclude`) to deno.json.",
1315
- op: "note"
1316
- }];
1317
- },
1318
- fixKind: "advisory"
1319
- };
1320
- const filesField = defineRule({
1321
- bindings: {
1322
- aube: packageJsonFilesBinding,
1323
- bun: packageJsonFilesBinding,
1324
- deno: denoPublishBinding,
1325
- npm: packageJsonFilesBinding,
1326
- pnpm: packageJsonFilesBinding,
1327
- yarn: packageJsonFilesBinding
1328
- },
1329
- description: "An explicit `files` array in package.json restricts what gets published, preventing accidental inclusion of secrets or local files.",
1330
- docs: "https://github.com/bodadotsh/npm-security-best-practices#11-review-published-files",
1331
- id: "files-field",
1332
- projectTypes: ["package"],
1333
- severity: "info",
1334
- title: "Declare a published files allow-list"
1335
- });
1336
-
1337
- //#endregion
1338
- //#region src/domain/rules/frozen-lockfile.ts
1339
- const { pnpmWorkspace: pnpmWorkspace$7, yarnrc: yarnrc$4, bunfig: bunfig$2, denoJson: denoJson$2, aubeWorkspace: aubeWorkspace$5 } = CONFIG_FILES;
1340
- const denoLockMessage = "Set `lock.frozen: true` in deno.json for reproducible, verified installs.";
1341
- const denoBinding$1 = {
1342
- check(_ctx, config) {
1343
- if (getByPath(config, ["lock", "frozen"]) === true) return { state: "ok" };
1344
- const lock = getByPath(config, ["lock"]);
1345
- const lockIsScalar = typeof lock !== "undefined" && (typeof lock !== "object" || lock === null || Array.isArray(lock));
1346
- const base = {
1347
- actual: getByPath(config, ["lock", "frozen"]),
1348
- expected: true,
1349
- message: denoLockMessage,
1350
- state: "violation"
1351
- };
1352
- if (lockIsScalar) return {
1353
- ...base,
1354
- manualSteps: ["deno.json `lock` is set to a non-object value; writing `lock.frozen: true` under it would overwrite that value. Set it yourself, or make `lock` an object first."]
1355
- };
1356
- return base;
1357
- },
1358
- docs: "https://docs.deno.com/runtime/fundamentals/configuration/#lock",
1359
- file: denoJson$2,
1360
- fix() {
1361
- return [{
1362
- file: denoJson$2,
1363
- keyPath: ["lock", "frozen"],
1364
- op: "setKey",
1365
- value: true
1366
- }];
1367
- },
1368
- fixKind: "auto"
1369
- };
1370
- const builtRule = requireConfigKey({
1371
- bindings: {
1372
- aube: {
1373
- docs: "https://aube.en.dev/settings/",
1374
- documentedDefault: true,
1375
- file: aubeWorkspace$5,
1376
- keyPath: ["preferFrozenLockfile"],
1377
- message: "aube defaults preferFrozenLockfile to true — set it explicitly in aube-workspace.yaml to pin the policy across versions.",
1378
- value: true
1379
- },
1380
- bun: {
1381
- docs: "https://bun.com/docs/runtime/bunfig#install-frozenlockfile",
1382
- file: bunfig$2,
1383
- keyPath: ["install", "frozenLockfile"],
1384
- message: "Set `frozenLockfile = true` under [install] in bunfig.toml so installs fail when the lockfile would change.",
1385
- value: true,
1386
- versionNote: { configAvailableSince: "bun 0.6.10" }
1387
- },
1388
- pnpm: {
1389
- docs: "https://pnpm.io/settings#frozenlockfile",
1390
- documentedDefault: true,
1391
- file: pnpmWorkspace$7,
1392
- keyPath: ["frozenLockfile"],
1393
- message: "pnpm defaults frozenLockfile to true in CI — set it explicitly in pnpm-workspace.yaml to pin the policy outside CI too.",
1394
- value: true
1395
- },
1396
- yarn: {
1397
- docs: "https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls",
1398
- documentedDefault: true,
1399
- file: yarnrc$4,
1400
- keyPath: ["enableImmutableInstalls"],
1401
- message: "Set `enableImmutableInstalls: true` in .yarnrc.yml to pin the policy outside CI too.",
1402
- value: true,
1403
- versionNote: {
1404
- configAvailableSince: "yarn 2.0.0",
1405
- defaultSafeSince: "yarn 3.0.0 in CI"
1406
- }
1407
- }
1408
- },
1409
- description: "Refuse to mutate the lockfile on install so unexpected dependency changes fail loudly.",
1410
- docs: "https://github.com/bodadotsh/npm-security-best-practices#2-include-lockfiles",
1411
- id: "frozen-lockfile",
1412
- severity: "warn",
1413
- title: "Freeze the lockfile"
1414
- });
1415
- const frozenLockfile = overrideBindings(builtRule, { deno: denoBinding$1 });
1416
-
1417
- //#endregion
1418
- //#region src/domain/rules/frozen-store.ts
1419
- const { pnpmWorkspace: pnpmWorkspace$6 } = CONFIG_FILES;
1420
- const pnpmBinding$3 = {
1421
- check(_ctx, config) {
1422
- const value = getByPath(config, ["frozenStore"]);
1423
- if (value === true) return { state: "ok" };
1424
- return {
1425
- actual: value,
1426
- message: "Consider enabling `frozenStore` in pnpm-workspace.yaml. Without it, the content-addressable store can be mutated after initial population, weakening integrity guarantees.",
1427
- state: "violation"
1428
- };
1429
- },
1430
- docs: "https://pnpm.io/settings#frozenstore",
1431
- file: pnpmWorkspace$6,
1432
- fix() {
1433
- return [{
1434
- file: pnpmWorkspace$6,
1435
- message: "Set `frozenStore: true` to prevent writes to the store after initial population — especially useful in CI and deployment pipelines.",
1436
- op: "note"
1437
- }];
1438
- },
1439
- fixKind: "advisory"
1440
- };
1441
- const frozenStore = defineRule({
1442
- bindings: { pnpm: pnpmBinding$3 },
1443
- description: "Recommend enabling frozenStore to prevent mutations to the content-addressable store, strengthening supply-chain integrity in CI and deploy environments.",
1444
- docs: "https://pnpm.io/settings#frozenstore",
1445
- id: "frozen-store",
1446
- severity: "info",
1447
- title: "Enable frozen store"
1448
- });
1449
-
1450
- //#endregion
1451
- //#region src/domain/rules/hardened-mode.ts
1452
- const { yarnrc: yarnrc$3 } = CONFIG_FILES;
1453
- const hardenedMode = requireConfigKey({
1454
- bindings: { yarn: {
1455
- docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1456
- documentedDefault: true,
1457
- file: yarnrc$3,
1458
- keyPath: ["enableHardenedMode"],
1459
- 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.",
1460
- value: true,
1461
- versionNote: {
1462
- configAvailableSince: "yarn 4.0.0",
1463
- defaultSafeSince: "yarn 4.0.0 (conditional: auto-enabled for PRs on public repositories)"
1464
- }
1465
- } },
1466
- description: "Yarn 4's enableHardenedMode performs end-to-end checksum, lockfile, and version verification at install time.",
1467
- docs: "https://yarnpkg.com/configuration/yarnrc#enableHardenedMode",
1468
- id: "hardened-mode",
1469
- severity: "warn",
1470
- title: "Enable hardened mode (yarn)"
1471
- });
1472
-
1473
- //#endregion
1474
- //#region src/domain/rules/minimum-release-age.ts
1475
- const HOURS_PER_DAY = 24;
1476
- const MINUTES_PER_HOUR = 60;
1477
- const SECONDS_PER_MINUTE = 60;
1478
- const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
1479
- const SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE;
1480
- const RECOMMENDED_RELEASE_AGE_DAYS = 3;
1481
- const RECOMMENDED_RELEASE_AGE_MINUTES = RECOMMENDED_RELEASE_AGE_DAYS * MINUTES_PER_DAY;
1482
- const RECOMMENDED_RELEASE_AGE_SECONDS = RECOMMENDED_RELEASE_AGE_DAYS * SECONDS_PER_DAY;
1483
- const DOCUMENTED_DEFAULT_MINUTES = MINUTES_PER_DAY;
1484
- const { npmrc: npmrc$3, pnpmWorkspace: pnpmWorkspace$5, yarnrc: yarnrc$2, bunfig: bunfig$1, denoJson: denoJson$1, aubeWorkspace: aubeWorkspace$4 } = CONFIG_FILES;
1485
- const isPositiveNumber = (value) => typeof value === "number" && value > 0;
1486
- const DENO_DURATION = /^P(?=.*\d)(?:\d+(?:[.,]\d+)?Y)?(?:\d+(?:[.,]\d+)?M)?(?:\d+(?:[.,]\d+)?W)?(?:\d+(?:[.,]\d+)?D)?(?:T(?=\d)(?:\d+(?:[.,]\d+)?H)?(?:\d+(?:[.,]\d+)?M)?(?:\d+(?:[.,]\d+)?S)?)?$/u;
1487
- const DENO_ZERO_DURATION = /^P(?=.*\d)(?:0+(?:[.,]0+)?[YMWD])*(?:T(?:0+(?:[.,]0+)?[HMS])*)?$/u;
1488
- const DENO_DATE = /^\d{4}-\d{2}-\d{2}$/u;
1489
- const DENO_RFC3339_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/u;
1490
- const DENO_DATE_LENGTH = 10;
1491
- const isNonDisabledDenoDuration = (value) => {
1492
- if (typeof value === "number") return value > 0;
1493
- if (typeof value === "string") {
1494
- const isDuration = DENO_DURATION.test(value);
1495
- const date = value.slice(0, DENO_DATE_LENGTH);
1496
- const dateTimestamp = Date.parse(`${date}T00:00:00Z`);
1497
- return isDuration && !DENO_ZERO_DURATION.test(value) || !isDuration && (DENO_DATE.test(value) || DENO_RFC3339_TIMESTAMP.test(value)) && !Number.isNaN(dateTimestamp) && new Date(dateTimestamp).toISOString().slice(0, date.length) === date && (DENO_DATE.test(value) || !Number.isNaN(Date.parse(value)));
1498
- }
1499
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1500
- return !("age" in value) || isNonDisabledDenoDuration(value.age);
1501
- };
1502
- const minimumReleaseAge = requireConfigKey({
1503
- bindings: {
1504
- aube: {
1505
- accept: isPositiveNumber,
1506
- docs: "https://aube.en.dev/settings/",
1507
- documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1508
- file: aubeWorkspace$4,
1509
- keyPath: ["minimumReleaseAge"],
1510
- message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in aube-workspace.yaml.`,
1511
- value: RECOMMENDED_RELEASE_AGE_MINUTES
1512
- },
1513
- bun: {
1514
- accept: isPositiveNumber,
1515
- docs: "https://bun.com/docs/runtime/bunfig#install-minimumreleaseage",
1516
- file: bunfig$1,
1517
- keyPath: ["install", "minimumReleaseAge"],
1518
- message: `Set install.minimumReleaseAge (bun) to ~${RECOMMENDED_RELEASE_AGE_SECONDS} seconds to quarantine brand-new releases.`,
1519
- value: RECOMMENDED_RELEASE_AGE_SECONDS,
1520
- versionNote: { configAvailableSince: "bun 1.3.0" }
1521
- },
1522
- deno: {
1523
- accept: isNonDisabledDenoDuration,
1524
- docs: "https://docs.deno.com/runtime/reference/deno_json/",
1525
- documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1526
- file: denoJson$1,
1527
- keyPath: ["minimumDependencyAge"],
1528
- message: `Set minimumDependencyAge (e.g. "P3D" for a ${RECOMMENDED_RELEASE_AGE_DAYS}-day cooldown) in deno.json.`,
1529
- value: "P3D",
1530
- versionNote: {
1531
- defaultSafeSince: "deno 2.9.0 (1440 minutes)",
1532
- note: "object age optional since deno 2.9.4"
1533
- }
1534
- },
1535
- npm: {
1536
- accept: isPositiveNumber,
1537
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#min-release-age",
1538
- file: npmrc$3,
1539
- keyPath: ["min-release-age"],
1540
- message: `Set min-release-age to ~${RECOMMENDED_RELEASE_AGE_DAYS} days to quarantine brand-new releases.`,
1541
- value: RECOMMENDED_RELEASE_AGE_DAYS,
1542
- versionNote: { configAvailableSince: "npm 11.10.0" }
1543
- },
1544
- pnpm: {
1545
- accept: isPositiveNumber,
1546
- docs: "https://pnpm.io/settings#minimumreleaseage",
1547
- documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1548
- file: pnpmWorkspace$5,
1549
- keyPath: ["minimumReleaseAge"],
1550
- message: `Set minimumReleaseAge (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in pnpm-workspace.yaml.`,
1551
- value: RECOMMENDED_RELEASE_AGE_MINUTES,
1552
- versionNote: {
1553
- configAvailableSince: "pnpm 10.16.0",
1554
- defaultSafeSince: "pnpm 11.0.0 (1440 minutes)"
1555
- }
1556
- },
1557
- yarn: {
1558
- accept: isPositiveNumber,
1559
- docs: "https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate",
1560
- documentedDefault: DOCUMENTED_DEFAULT_MINUTES,
1561
- file: yarnrc$2,
1562
- keyPath: ["npmMinimalAgeGate"],
1563
- message: `Set npmMinimalAgeGate (~${RECOMMENDED_RELEASE_AGE_MINUTES} minutes for a 3-day cooldown) in .yarnrc.yml.`,
1564
- value: RECOMMENDED_RELEASE_AGE_MINUTES,
1565
- versionNote: {
1566
- configAvailableSince: "yarn 4.10.0",
1567
- defaultSafeSince: "yarn 4.15.0 (1440 minutes)"
1568
- }
1569
- }
1570
- },
1571
- description: "Refuse to install releases newer than a cooldown window so freshly published (possibly compromised) versions are skipped.",
1572
- docs: "https://github.com/bodadotsh/npm-security-best-practices#set-minimal-release-age",
1573
- id: "minimum-release-age",
1574
- severity: "warn",
1575
- title: "Set a minimum release age"
1576
- });
1577
-
1578
- //#endregion
1579
- //#region src/domain/rules/named-registries.ts
1580
- const { pnpmWorkspace: pnpmWorkspace$4 } = CONFIG_FILES;
1581
- const isNonEmptyObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1582
- const pnpmBinding$2 = {
1583
- check(_ctx, config) {
1584
- const value = getByPath(config, ["namedRegistries"]);
1585
- if (!isNonEmptyObject$1(value)) return { state: "ok" };
1586
- return {
1587
- actual: value,
1588
- message: "Review `namedRegistries` in pnpm-workspace.yaml. Custom registry mappings can redirect package resolution away from the public registry, enabling dependency confusion attacks.",
1589
- state: "violation"
1590
- };
1591
- },
1592
- docs: "https://pnpm.io/settings#namedregistries",
1593
- file: pnpmWorkspace$4,
1594
- fix() {
1595
- return [{
1596
- file: pnpmWorkspace$4,
1597
- message: "Verify each named registry points to a trusted source and that scoped packages cannot be hijacked via a public registry of the same name.",
1598
- op: "note"
1599
- }];
1600
- },
1601
- fixKind: "advisory"
1602
- };
1603
- const namedRegistries = defineRule({
1604
- bindings: { pnpm: pnpmBinding$2 },
1605
- description: "Flag named registry mappings that redirect package resolution to custom registries, which may enable dependency confusion attacks.",
1606
- docs: "https://pnpm.io/settings#namedregistries",
1607
- id: "named-registries",
1608
- severity: "info",
1609
- title: "Review named registry mappings"
1610
- });
1611
-
1612
- //#endregion
1613
- //#region src/domain/rules/paranoid-mode.ts
1614
- const { aubeWorkspace: aubeWorkspace$3 } = CONFIG_FILES;
1615
- const paranoidMode = requireConfigKey({
1616
- bindings: { aube: {
1617
- docs: "https://aube.jdx.dev/security.html",
1618
- file: aubeWorkspace$3,
1619
- keyPath: ["paranoid"],
1620
- message: "Set `paranoid: true` in aube-workspace.yaml to enable the strict-security bundle (trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, advisoryCheck).",
1621
- value: true
1622
- } },
1623
- description: "Activate the strict-security setting bundle that forces trustPolicy, jailBuilds, minimumReleaseAgeStrict, strictStoreIntegrity, strictDepBuilds, and advisoryCheck on in one switch.",
1624
- docs: "https://aube.jdx.dev/security.html",
1625
- id: "paranoid-mode",
1626
- severity: "info",
1627
- title: "Enable paranoid security mode"
1628
- });
1629
-
1630
- //#endregion
1631
- //#region src/domain/rules/patched-dependencies.ts
1632
- const { pnpmWorkspace: pnpmWorkspace$3 } = CONFIG_FILES;
1633
- const isNonEmptyObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
1634
- const pnpmBinding$1 = {
1635
- check(_ctx, config) {
1636
- const value = getByPath(config, ["patchedDependencies"]);
1637
- if (!isNonEmptyObject(value)) return { state: "ok" };
1638
- return {
1639
- actual: value,
1640
- message: "Review `patchedDependencies` in pnpm-workspace.yaml. Patches modify dependency source code directly and bypass registry integrity checks.",
1641
- state: "violation"
1642
- };
1643
- },
1644
- docs: "https://pnpm.io/settings#patcheddependencies",
1645
- file: pnpmWorkspace$3,
1646
- fix() {
1647
- return [{
1648
- file: pnpmWorkspace$3,
1649
- message: "Review each entry in `patchedDependencies` — verify patches address a known issue and have not been tampered with.",
1650
- op: "note"
1651
- }];
1652
- },
1653
- fixKind: "advisory"
1654
- };
1655
- const patchedDependencies = defineRule({
1656
- bindings: { pnpm: pnpmBinding$1 },
1657
- description: "Flag patched dependencies whose source code is modified by local patch files, bypassing registry integrity verification.",
1658
- docs: "https://pnpm.io/settings#patcheddependencies",
1659
- id: "patched-dependencies",
1660
- severity: "info",
1661
- title: "Review patched dependencies"
1662
- });
1663
-
1664
- //#endregion
1665
- //#region src/domain/rules/pin-exact-versions.ts
1666
- const { npmrc: npmrc$2, pnpmWorkspace: pnpmWorkspace$2, yarnrc: yarnrc$1, bunfig, denoJson } = CONFIG_FILES;
1667
- const isConfigObject = (value) => !Array.isArray(value);
1668
- /**
1669
- * Specifiers without any `@version` part fall into "no range" by design: this
1670
- * is a deliberate, best-effort static-check stance. Treating "latest"-style
1671
- * imports as violations would need a different mental model (and ideally
1672
- * resolution against the lockfile), so we leave them alone here.
1673
- */
1674
- /** Wildcard segments (`1.x`, `1.*`, `1.2.X`) are ranges too. */
1675
- const SPLIT_LIMIT_FIRST = 1;
1676
- const hasWildcardSegment = (spec) => {
1677
- const [core = spec] = spec.split("-", SPLIT_LIMIT_FIRST);
1678
- return core.split(".").some((seg) => seg === "x" || seg === "X" || seg === "*");
1679
- };
1680
- const RANGE_OPERATOR = /[\^~]|>=|<=|<|>|\|\||\s/u;
1681
- const INCOMPLETE_NUMERIC_VERSION = /^\d+(?:\.\d+)?(?:-|$)/u;
1682
- const isRangeSpec = (spec) => spec === "" || spec === "*" || RANGE_OPERATOR.test(spec) || INCOMPLETE_NUMERIC_VERSION.test(spec) || hasWildcardSegment(spec);
1683
- const AFTER_AT_SIGN = 1;
1684
- const hasSemverRange = (specifier) => {
1685
- if (specifier.startsWith("http://") || specifier.startsWith("https://")) return false;
1686
- const at = specifier.lastIndexOf("@");
1687
- if (at <= 0) return false;
1688
- return isRangeSpec(specifier.slice(at + AFTER_AT_SIGN));
1689
- };
1690
- const collectRangedImports = (imports) => {
1691
- const offenders = [];
1692
- for (const [name, value] of Object.entries(imports)) if (typeof value === "string" && hasSemverRange(value)) offenders.push(`${name}=${value}`);
1693
- return offenders;
1694
- };
1695
- const MAX_SAMPLE_COUNT = 3;
1696
- const formatOffenders = (offenders) => {
1697
- const sample = offenders.slice(0, MAX_SAMPLE_COUNT).join(", ");
1698
- let more = "";
1699
- if (offenders.length > MAX_SAMPLE_COUNT) more = ` (and ${offenders.length - MAX_SAMPLE_COUNT} more)`;
1700
- return {
1701
- message: `${offenders.length} deno imports use semver ranges: ${sample}${more}. Use \`deno add --save-exact\` or pin manually.`,
1702
- state: "violation"
1703
- };
1704
- };
1705
- const OK = { state: "ok" };
1706
- const extractImportsRecord = (config) => {
1707
- const imports = getByPath(config, ["imports"]);
1708
- if (typeof imports !== "object" || imports === null || !isConfigObject(imports)) return;
1709
- return imports;
1710
- };
1711
- const denoBinding = {
1712
- check(_ctx, config) {
1713
- const imports = extractImportsRecord(config);
1714
- if (!imports) return OK;
1715
- const offenders = collectRangedImports(imports);
1716
- if (offenders.length === 0) return OK;
1717
- return formatOffenders(offenders);
1718
- },
1719
- docs: "https://docs.deno.com/runtime/reference/cli/add/",
1720
- file: denoJson,
1721
- fix() {
1722
- return [{
1723
- file: denoJson,
1724
- message: "Run `deno add --save-exact <pkg>` for each ranged import in deno.json, or rewrite the `imports` entries to use exact versions.",
1725
- op: "note"
1726
- }];
1727
- },
1728
- fixKind: "advisory",
1729
- versionNote: { configAvailableSince: "deno 1.30.0" }
1730
- };
1731
- const baseRule = requireConfigKey({
1732
- bindings: {
1733
- bun: {
1734
- docs: "https://bun.com/docs/runtime/bunfig#install-exact",
1735
- file: bunfig,
1736
- keyPath: ["install", "exact"],
1737
- message: "Set `exact = true` under [install] in bunfig.toml to pin exact versions.",
1738
- value: true,
1739
- versionNote: { configAvailableSince: "bun 0.6.10" }
1740
- },
1741
- npm: {
1742
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#save-exact",
1743
- extraFix: [{
1744
- keyPath: ["save-prefix"],
1745
- value: ""
1746
- }],
1747
- file: npmrc$2,
1748
- keyPath: ["save-exact"],
1749
- message: "Set `save-exact=true` (and `save-prefix=`) in .npmrc to pin exact versions.",
1750
- value: true
1751
- },
1752
- pnpm: {
1753
- docs: "https://pnpm.io/settings#saveprefix",
1754
- file: pnpmWorkspace$2,
1755
- keyPath: ["savePrefix"],
1756
- message: "Set `savePrefix: ''` in pnpm-workspace.yaml to pin exact versions.",
1757
- value: ""
1758
- },
1759
- yarn: {
1760
- docs: "https://yarnpkg.com/configuration/yarnrc#defaultSemverRangePrefix",
1761
- file: yarnrc$1,
1762
- keyPath: ["defaultSemverRangePrefix"],
1763
- message: "Set `defaultSemverRangePrefix: ''` in .yarnrc.yml to pin exact versions.",
1764
- value: "",
1765
- versionNote: { configAvailableSince: "yarn 2.0.0" }
1766
- }
1767
- },
1768
- description: "Semver ranges (^, ~) auto-adopt new releases, including compromised ones. Save exact versions by default.",
1769
- docs: "https://github.com/bodadotsh/npm-security-best-practices#1-pin-dependency-versions",
1770
- id: "pin-exact-versions",
1771
- severity: "error",
1772
- title: "Pin exact dependency versions"
1773
- });
1774
- const pinExactVersions = overrideBindings(baseRule, { deno: denoBinding });
1775
-
1776
- //#endregion
1777
- //#region src/domain/rules/provenance.ts
1778
- const { npmrc: npmrc$1, yarnrc } = CONFIG_FILES;
1779
- const npmrcProvenance = {
1780
- file: npmrc$1,
1781
- keyPath: ["provenance"],
1782
- message: "Set `provenance=true` (and publish from CI) to attest releases.",
1783
- value: true
1784
- };
1785
- const provenance = requireConfigKey({
1786
- applies: isPublishable,
1787
- bindings: {
1788
- bun: {
1789
- ...npmrcProvenance,
1790
- docs: "https://github.com/oven-sh/bun/issues/15601",
1791
- 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)."
1792
- },
1793
- npm: {
1794
- ...npmrcProvenance,
1795
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#provenance",
1796
- versionNote: { configAvailableSince: "npm 9.5.0" }
1797
- },
1798
- pnpm: {
1799
- ...npmrcProvenance,
1800
- docs: "https://pnpm.io/cli/publish"
1801
- },
1802
- yarn: {
1803
- docs: "https://yarnpkg.com/configuration/yarnrc#npmPublishProvenance",
1804
- file: yarnrc,
1805
- keyPath: ["npmPublishProvenance"],
1806
- message: "Set `npmPublishProvenance: true` (and publish from CI) to attest releases.",
1807
- value: true
1808
- }
1809
- },
1810
- description: "Provenance statements (via Sigstore) tie a release to its source and build, letting consumers verify it was not tampered with.",
1811
- docs: "https://github.com/bodadotsh/npm-security-best-practices#10-generate-provenance-statements",
1812
- id: "provenance",
1813
- projectTypes: ["package"],
1814
- severity: "warn",
1815
- title: "Publish with provenance"
1816
- });
1817
-
1818
- //#endregion
1819
- //#region src/domain/rules/publish-access.ts
1820
- const { packageJson } = CONFIG_FILES;
1821
- const publishAccessBinding = {
1822
- check(ctx) {
1823
- if (!isPublishable(ctx)) return { state: "na" };
1824
- const access = ctx.packageJson?.publishConfig?.access;
1825
- if (access === "public" || access === "restricted") return { state: "ok" };
1826
- return {
1827
- actual: access,
1828
- message: "Set `publishConfig.access` in package.json to `public` or `restricted` to declare publish scope explicitly.",
1829
- state: "violation"
1830
- };
1831
- },
1832
- docs: "https://docs.npmjs.com/cli/v11/configuring-npm/package-json#publishconfig",
1833
- file: packageJson,
1834
- fix() {
1835
- return [{
1836
- file: packageJson,
1837
- message: "Add `\"publishConfig\": { \"access\": \"public\" }` (or `\"restricted\"`) to package.json so publishes never default unexpectedly.",
1838
- op: "note"
1839
- }];
1840
- },
1841
- fixKind: "advisory"
1842
- };
1843
- const publishAccess = defineRule({
1844
- bindings: {
1845
- aube: publishAccessBinding,
1846
- bun: publishAccessBinding,
1847
- npm: publishAccessBinding,
1848
- pnpm: publishAccessBinding,
1849
- yarn: publishAccessBinding
1850
- },
1851
- description: "Set `publishConfig.access` so a misconfigured scope or registry never accidentally publishes an internal package publicly.",
1852
- docs: "https://github.com/bodadotsh/npm-security-best-practices#for-maintainers",
1853
- id: "publish-access",
1854
- projectTypes: ["package"],
1855
- severity: "info",
1856
- title: "Declare publish access explicitly"
1857
- });
1858
-
1859
- //#endregion
1860
- //#region src/domain/rules/store-server.ts
1861
- const { pnpmWorkspace: pnpmWorkspace$1 } = CONFIG_FILES;
1862
- const pnpmBinding = {
1863
- check(_ctx, config) {
1864
- const value = getByPath(config, ["useRunningStoreServer"]);
1865
- if (value !== true) return { state: "ok" };
1866
- return {
1867
- actual: value,
1868
- 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.",
1869
- state: "violation"
1870
- };
1871
- },
1872
- docs: "https://pnpm.io/settings#userunningStoreserver",
1873
- file: pnpmWorkspace$1,
1874
- fix() {
1875
- return [{
1876
- file: pnpmWorkspace$1,
1877
- message: "Verify the store server is running in a trusted environment and that its communication channel is not exposed to untrusted networks.",
1878
- op: "note"
1879
- }];
1880
- },
1881
- fixKind: "advisory"
1882
- };
1883
- const storeServer = defineRule({
1884
- bindings: { pnpm: pnpmBinding },
1885
- description: "Flag use of an external store server process, which introduces a trust boundary where tampered packages could be served.",
1886
- docs: "https://pnpm.io/settings#userunningStoreserver",
1887
- id: "store-server",
1888
- severity: "info",
1889
- title: "Review store server usage"
1890
- });
1891
-
1892
- //#endregion
1893
- //#region src/domain/rules/strict-allow-scripts.ts
1894
- const { npmrc } = CONFIG_FILES;
1895
- const strictAllowScripts = requireConfigKey({
1896
- bindings: { npm: {
1897
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-allow-scripts",
1898
- file: npmrc,
1899
- keyPath: ["strict-allow-scripts"],
1900
- message: "Set `strict-allow-scripts=true` in .npmrc to block packages with unapproved install scripts.",
1901
- value: true
1902
- } },
1903
- description: "Turn install-script policy warnings into hard errors so unapproved lifecycle scripts block installation.",
1904
- docs: "https://docs.npmjs.com/cli/v11/using-npm/config#strict-allow-scripts",
1905
- id: "strict-allow-scripts",
1906
- severity: "warn",
1907
- title: "Require explicit script allow-listing"
1908
- });
1909
-
1910
- //#endregion
1911
- //#region src/domain/rules/strict-release-age.ts
1912
- const { aubeWorkspace: aubeWorkspace$2 } = CONFIG_FILES;
1913
- const strictReleaseAge = requireConfigKey({
1914
- bindings: { aube: {
1915
- docs: "https://aube.jdx.dev/settings/",
1916
- file: aubeWorkspace$2,
1917
- keyPath: ["minimumReleaseAgeStrict"],
1918
- message: "Set `minimumReleaseAgeStrict: true` in aube-workspace.yaml to make the release age gate a hard block.",
1919
- value: true
1920
- } },
1921
- description: "Make the minimum release age gate a hard failure instead of falling back to the next-oldest satisfying version.",
1922
- docs: "https://aube.jdx.dev/security.html",
1923
- id: "strict-release-age",
1924
- severity: "info",
1925
- title: "Enforce strict release age gate"
1926
- });
1927
-
1928
- //#endregion
1929
- //#region src/domain/rules/strict-store-integrity.ts
1930
- const { aubeWorkspace: aubeWorkspace$1 } = CONFIG_FILES;
1931
- const strictStoreIntegrity = requireConfigKey({
1932
- bindings: { aube: {
1933
- docs: "https://aube.jdx.dev/settings/",
1934
- file: aubeWorkspace$1,
1935
- keyPath: ["strictStoreIntegrity"],
1936
- message: "Set `strictStoreIntegrity: true` in aube-workspace.yaml to refuse tarballs that lack integrity metadata.",
1937
- value: true
1938
- } },
1939
- description: "Refuse to import tarballs from the registry when the packument lacks a dist.integrity field, preventing silent integrity bypass.",
1940
- docs: "https://aube.jdx.dev/security.html",
1941
- id: "strict-store-integrity",
1942
- severity: "warn",
1943
- title: "Require tarball integrity metadata"
1944
- });
1945
-
1946
- //#endregion
1947
- //#region src/domain/rules/trust-policy.ts
1948
- const { aubeWorkspace, pnpmWorkspace } = CONFIG_FILES;
1949
- const trustPolicy = requireConfigKey({
1950
- bindings: {
1951
- aube: {
1952
- docs: "https://aube.jdx.dev/security.html",
1953
- file: aubeWorkspace,
1954
- keyPath: ["trustPolicy"],
1955
- message: "Set `trustPolicy: no-downgrade` in aube-workspace.yaml to block packages whose trust level has decreased.",
1956
- value: "no-downgrade"
1957
- },
1958
- pnpm: {
1959
- docs: "https://pnpm.io/settings#trustpolicy",
1960
- file: pnpmWorkspace,
1961
- keyPath: ["trustPolicy"],
1962
- message: "Set `trustPolicy: no-downgrade` in pnpm-workspace.yaml to block packages whose trust level has decreased.",
1963
- value: "no-downgrade",
1964
- versionNote: { configAvailableSince: "pnpm 10.21.0" }
1965
- }
1966
- },
1967
- description: "Fail installation when a package trust level has decreased compared to previous releases, catching publisher credential downgrades.",
1968
- docs: "https://pnpm.io/settings#trustpolicy",
1969
- id: "trust-policy",
1970
- severity: "warn",
1971
- title: "Enforce trust policy on dependency updates"
1972
- });
1973
-
1974
- //#endregion
1975
- //#region src/domain/builtin-rules.ts
1976
- const rules = [
1977
- advisoryCheck,
1978
- approvedGitRepos,
1979
- auditSuppression,
1980
- blockAutoInstall,
1981
- blockExoticSubdeps,
1982
- bunSecurityScanner,
1983
- checksumVerification,
1984
- commitLockfile,
1985
- dependencyOverrides,
1986
- disableLifecycleScripts,
1987
- enforceStrictSsl,
1988
- filesField,
1989
- frozenLockfile,
1990
- frozenStore,
1991
- hardenedMode,
1992
- minimumReleaseAge,
1993
- namedRegistries,
1994
- paranoidMode,
1995
- patchedDependencies,
1996
- pinExactVersions,
1997
- provenance,
1998
- publishAccess,
1999
- storeServer,
2000
- strictAllowScripts,
2001
- strictReleaseAge,
2002
- strictStoreIntegrity,
2003
- trustPolicy
2004
- ];
2005
-
2006
- //#endregion
2007
- //#region src/domain/services/validate-rule-ids.ts
2008
- /**
2009
- * Why both checks in one pass? `duplicates` must be detected against the
2010
- * builtin set *before* `unknown` consults the merged (builtin ∪ custom)
2011
- * set — otherwise a colliding customRule id silently widens the known
2012
- * set and a typo in `rules` aimed at that id is no longer flagged.
2013
- */
2014
- const detectDuplicates = (customRules, builtinIds) => {
2015
- const duplicateSet = /* @__PURE__ */ new Set();
2016
- const seen = /* @__PURE__ */ new Set();
2017
- for (const rule of customRules) {
2018
- if (builtinIds.has(rule.id) || seen.has(rule.id)) duplicateSet.add(rule.id);
2019
- seen.add(rule.id);
2020
- }
2021
- return [...duplicateSet];
2022
- };
2023
- const detectUnknown = (rules, known) => {
2024
- const unknown = [];
2025
- for (const id of Object.keys(rules)) if (!known.has(id)) unknown.push(id);
2026
- return unknown;
2027
- };
2028
- const validateRuleIds = (config, builtins, extraKnownIds = []) => {
2029
- const builtinIds = new Set(builtins.map((rule) => rule.id));
2030
- const customRules = config.customRules ?? [];
2031
- const duplicates = detectDuplicates(customRules, builtinIds);
2032
- const known = new Set(builtinIds);
2033
- for (const id of extraKnownIds) known.add(id);
2034
- for (const rule of customRules) known.add(rule.id);
2035
- return {
2036
- duplicates,
2037
- unknown: detectUnknown(config.rules ?? {}, known)
2038
- };
2039
- };
2040
-
2041
- //#endregion
2042
- //#region src/application/validate-config-rules.ts
2043
- const SINGLE = 1;
2044
- const throwUnknownIds = (unknown) => {
2045
- let plural = "";
2046
- if (unknown.length > SINGLE) plural = "s";
2047
- throw new ConfigError(`siro.config: unknown rule id${plural} in rules: ${unknown.map((id) => `'${id}'`).join(", ")}`);
2048
- };
2049
- const assertConfigRuleIdsKnown = (userConfig, programmaticRules) => {
2050
- if (typeof userConfig === "undefined") return;
2051
- const { unknown } = validateRuleIds(userConfig, rules, (programmaticRules ?? []).map((rule) => rule.id));
2052
- if (unknown.length > 0) throwUnknownIds(unknown);
2053
- };
2054
-
2055
- //#endregion
2056
- //#region src/domain/schemas/package-json.ts
2057
- /**
2058
- * Subset of `package.json` that siro actually reads. We keep the schema
2059
- * intentionally narrow — every field rules consume is here, everything else
2060
- * passes through unvalidated under `[key: string]: unknown` semantics.
2061
- *
2062
- * Every field is wrapped in `v.fallback` so one type-mismatched field can't
2063
- * blank out the entire typed view (and silently N/A every publish rule).
2064
- * A malformed value reads as "absent" — EXCEPT `private`, which falls back to
2065
- * `true`: a security tool must never flip a broken `private` into publishable.
2066
- */
2067
- const ABSENT = void 0;
2068
- const optionalString = vb.fallback(vb.optional(vb.string()), ABSENT);
2069
- const PackageJsonSchema = vb.looseObject({
2070
- files: vb.fallback(vb.optional(vb.array(vb.string())), ABSENT),
2071
- name: optionalString,
2072
- packageManager: optionalString,
2073
- private: vb.optional(vb.fallback(vb.boolean(), true)),
2074
- publishConfig: vb.fallback(vb.optional(vb.looseObject({ access: vb.fallback(vb.optional(vb.union([vb.literal("public"), vb.literal("restricted")])), ABSENT) })), ABSENT),
2075
- trustedDependencies: vb.fallback(vb.optional(vb.array(vb.string())), ABSENT)
2076
- });
2077
- const safeParsePackageJson = (value) => {
2078
- const result = vb.safeParse(PackageJsonSchema, value);
2079
- if (result.success) return result.output;
2080
- };
2081
-
2082
- //#endregion
2083
- //#region src/adapters/node-file-system.ts
2084
- const nodeFileSystem = {
2085
- exists(filePath) {
2086
- try {
2087
- accessSync(filePath, constants.F_OK);
2088
- return true;
2089
- } catch (error) {
2090
- if (isNodeError(error) && error.code === "ENOENT") return false;
2091
- throw error;
2092
- }
2093
- },
2094
- readText(filePath) {
2095
- try {
2096
- return readFileSync(filePath, "utf8");
2097
- } catch (error) {
2098
- if (isNodeError(error) && error.code === "ENOENT") return;
2099
- throw error;
2100
- }
2101
- }
2102
- };
2103
- const resolveIn = (root, relPath) => asAbsPath(path.join(root, relPath));
2104
-
2105
- //#endregion
2106
- //#region src/adapters/repo-context.ts
2107
- const tryParseJson = (text) => {
2108
- try {
2109
- return JSON.parse(text);
2110
- } catch (error) {
2111
- let errMsg = String(error);
2112
- if (error instanceof Error) errMsg = error.message;
2113
- throw new ConfigError(`package.json: invalid JSON — ${errMsg}`);
2114
- }
2115
- };
2116
- const parsePackageJson = (raw) => {
2117
- return safeParsePackageJson(tryParseJson(raw.trim()));
2118
- };
2119
- const createRepoContext = (root, fs = nodeFileSystem, projectType) => {
2120
- const readText = (relPath) => fs.readText(resolveIn(root, relPath));
2121
- const exists = (relPath) => fs.exists(resolveIn(root, relPath));
2122
- const raw = readText(asRelPath("package.json"));
2123
- let packageJson = void 0;
2124
- if (typeof raw !== "undefined") packageJson = parsePackageJson(raw);
2125
- return {
2126
- exists,
2127
- packageJson,
2128
- projectType,
2129
- readText,
2130
- root
2131
- };
2132
- };
2133
-
2134
- //#endregion
2135
- //#region src/adapters/config-loader.ts
2136
- const MIN_PMS_LENGTH = 1;
2137
- const CONFIG_NAMES = [
2138
- "siro.config.ts",
2139
- "siro.config.mjs",
2140
- "siro.config.js"
2141
- ];
2142
- const ConfigSchema = vb.strictObject({
2143
- customRules: vb.optional(vb.array(vb.custom(isRuleShape, "must be a structurally valid rule"))),
2144
- pms: vb.optional(vb.pipe(vb.array(vb.picklist(PMS)), vb.minLength(MIN_PMS_LENGTH, "must not be empty (omit the key to auto-detect, or list at least one PM)"))),
2145
- projectType: vb.optional(vb.picklist(PROJECT_TYPES)),
2146
- reporters: vb.optional(vb.array(vb.custom(isReporterShape, "must be a { name, format } reporter"))),
2147
- rules: vb.optional(vb.record(vb.string(), vb.union([vb.picklist(SEVERITIES), vb.literal("off")])))
2148
- }, "unknown config key (check for a typo)");
2149
- const describeError = (err) => {
2150
- if (err instanceof Error) return err.message;
2151
- return String(err);
2152
- };
2153
- const formatIssues = (issues) => issues.map((issue) => {
2154
- const keyPath = (issue.path ?? []).map((seg) => seg.key).filter((key) => typeof key === "string" || typeof key === "number").join(".");
2155
- if (keyPath) return `${keyPath}: ${issue.message}`;
2156
- return issue.message;
2157
- }).join("; ");
2158
- let loadCounter = 0;
2159
- const INCREMENT = 1;
2160
- const importConfig = (modulePath, name) => {
2161
- const url = pathToFileURL(modulePath);
2162
- loadCounter += INCREMENT;
2163
- url.searchParams.set("siro-load", String(loadCounter));
2164
- return import(url.href).catch((error) => {
2165
- throw new ConfigError(`Failed to load ${name}: ${describeError(error)}`);
2166
- });
2167
- };
2168
- const extractDefault = (mod) => {
2169
- if (mod !== null && typeof mod === "object" && "default" in mod) return mod.default;
2170
- return mod;
2171
- };
2172
- const validateCandidateShape = (candidate, name) => {
2173
- if (typeof candidate === "undefined" || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
2174
- let got = typeof candidate;
2175
- if (Array.isArray(candidate)) got = "an array";
2176
- throw new ConfigError(`${name} must export a config object (got ${got}).`);
2177
- }
2178
- return candidate;
2179
- };
2180
- const validateSchema = (candidate, name) => {
2181
- const result = vb.safeParse(ConfigSchema, candidate);
2182
- if (!result.success) throw new ConfigError(`${name}: ${formatIssues(result.issues)}`);
2183
- return result.output;
2184
- };
2185
- const rejectDuplicateCustomRuleIds = (config, name) => {
2186
- const SINGLE = 1;
2187
- const { duplicates } = validateRuleIds(config, rules);
2188
- if (duplicates.length > 0) {
2189
- let plural = "";
2190
- if (duplicates.length > SINGLE) plural = "s";
2191
- throw new ConfigError(`${name}: duplicate customRule id${plural}: ${duplicates.map((id) => `'${id}'`).join(", ")}. Pick a unique id (or use 'rules' to override an existing rule's severity).`);
2192
- }
2193
- };
2194
- const parseCandidate = (configPath, name) => importConfig(configPath, name).then((mod) => {
2195
- const config = validateSchema(validateCandidateShape(extractDefault(mod), name), name);
2196
- rejectDuplicateCustomRuleIds(config, name);
2197
- return config;
2198
- });
2199
- /**
2200
- * Locate and load `siro.config.{ts,mjs,js}` under `cwd`. Returns undefined when
2201
- * no config exists. Throws ConfigError on malformed exports.
2202
- */
2203
- const loadConfig = (cwd, fs = nodeFileSystem, nodeVersion = process.versions.node) => {
2204
- const candidate = CONFIG_NAMES.find((name) => fs.exists(asAbsPath(path.join(cwd, name))));
2205
- if (typeof candidate === "undefined") return Promise.resolve(void 0);
2206
- if (candidate.endsWith(".ts") && !isSupportedNodeVersion(nodeVersion)) return Promise.reject(new ConfigError(`${candidate} 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.`));
2207
- return parseCandidate(asAbsPath(path.join(cwd, candidate)), candidate);
2208
- };
2209
-
2210
- //#endregion
2211
- //#region src/domain/services/merge-programmatic-rules.ts
2212
- /**
2213
- * Compose builtin rules with embedder-supplied programmatic rules. Reject
2214
- * any programmatic rule whose id collides with a builtin, a config-supplied
2215
- * customRule, or another programmatic rule in the same call — embedders need
2216
- * to hear about collisions explicitly, the same way loadConfig surfaces
2217
- * intra-config duplicates (otherwise two rules with the same id race through
2218
- * applyConfig and double-count findings).
2219
- */
2220
- const detectCollisions = (programmatic, taken) => {
2221
- const seen = /* @__PURE__ */ new Set();
2222
- const collisions = /* @__PURE__ */ new Set();
2223
- for (const rule of programmatic) {
2224
- if (taken.has(rule.id) || seen.has(rule.id)) collisions.add(rule.id);
2225
- seen.add(rule.id);
2226
- }
2227
- return collisions;
2228
- };
2229
- const SINGLE_COLLISION = 1;
2230
- const throwCollisionError = (collisions) => {
2231
- const ids = [...collisions];
2232
- let noun = "rule id collides";
2233
- if (ids.length > SINGLE_COLLISION) noun = "rule ids collide";
2234
- throw new ConfigError(`customRules: ${noun} with builtin, config, or another programmatic rule: ${ids.map((id) => `'${id}'`).join(", ")}. Pick a unique id (or use 'rules' to override an existing rule's severity).`);
2235
- };
2236
- const mergeProgrammaticRules = (builtins, programmatic, configCustom) => {
2237
- if (typeof programmatic === "undefined" || programmatic.length === 0) return builtins;
2238
- const collisions = detectCollisions(programmatic, new Set([...builtins.map((rule) => rule.id), ...(configCustom ?? []).map((rule) => rule.id)]));
2239
- if (collisions.size > 0) throwCollisionError(collisions);
2240
- return [...builtins, ...programmatic];
2241
- };
2242
-
2243
- //#endregion
2244
- //#region src/domain/services/detect-pms.ts
2245
- /**
2246
- * Per-PM list of filenames whose presence identifies that PM: its own
2247
- * `lockfiles` plus `configs`. `reusesLockfiles` is deliberately excluded —
2248
- * those are other PMs' shapes (see PMSignals), so they must not count as
2249
- * detection evidence. With reused lockfiles split out, every detection signal
2250
- * is owned by exactly one PM; the IIFE asserts that invariant at module load
2251
- * so a future signal collision fails loudly here rather than silently
2252
- * mis-attributing a file.
2253
- */
2254
- const registerSignals = (signals, pm, owner) => {
2255
- for (const file of signals) {
2256
- const prior = owner.get(file);
2257
- if (typeof prior !== "undefined") throw new TypeError(`detection signal '${file}' is claimed by both '${prior}' and '${pm}'`);
2258
- owner.set(file, pm);
2259
- }
2260
- };
2261
- const buildDetectionSignals = () => {
2262
- const out = /* @__PURE__ */ new Map();
2263
- const owner = /* @__PURE__ */ new Map();
2264
- for (const pm of PMS) {
2265
- const { lockfiles, configs } = PM_SIGNALS[pm];
2266
- const signals = [...lockfiles, ...configs];
2267
- registerSignals(signals, pm, owner);
2268
- out.set(pm, signals);
2269
- }
2270
- return out;
2271
- };
2272
- const DETECTION_SIGNALS = buildDetectionSignals();
2273
- const addDeclaredPM = (ctx, found) => {
2274
- const declared = ctx.packageJson?.packageManager;
2275
- if (typeof declared === "string") {
2276
- const pm = parsePackageManagerField(declared);
2277
- if (typeof pm !== "undefined") found.add(pm);
2278
- }
2279
- };
2280
- const detectPMs = (ctx) => {
2281
- const found = /* @__PURE__ */ new Set();
2282
- addDeclaredPM(ctx, found);
2283
- for (const pm of PMS) {
2284
- const signals = DETECTION_SIGNALS.get(pm);
2285
- if (signals && signals.some((file) => ctx.exists(asRelPath(file)))) found.add(pm);
2286
- }
2287
- return PMS.filter((pm) => found.has(pm));
2288
- };
2289
-
2290
- //#endregion
2291
- //#region src/domain/services/resolve-pms.ts
2292
- const throwEmptyPMsError = (detected, opts, allowed) => {
2293
- if (allowed) {
2294
- if (opts.pmOverride) throw new UsageError(`--pm ${opts.pmOverride} is not in siro.config.ts pms (${allowed.join(", ")}). Adjust the config or the flag.`);
2295
- let msg = `No package manager detected, and siro.config.ts restricts pms to ${allowed.join(", ")}. Adjust the config or remove the restriction.`;
2296
- if (detected.length > 0) msg = `Detected PMs (${detected.join(", ")}) do not match siro.config.ts pms (${allowed.join(", ")}). Adjust the config or remove the restriction.`;
2297
- throw new UsageError(msg);
2298
- }
2299
- throw new UsageError(`No package manager detected. Pass --pm <${PMS.join("|")}> to be explicit.`);
2300
- };
2301
- /**
2302
- * Pick the set of PMs to operate on for a single CLI command, applying the
2303
- * same three-stage policy (`override → detect → restrict`) that `lint`
2304
- * needs. Throws `UsageError` for the two empty-set cases so the
2305
- * caller never has to invent its own messaging.
2306
- *
2307
- * Empty-set branches are usage errors, not a silent npm fallback.
2308
- */
2309
- const computePMs = (ctx, opts) => {
2310
- let detected = detectPMs(ctx);
2311
- if (opts.pmOverride) detected = [opts.pmOverride];
2312
- const { allowed } = opts;
2313
- let pms = detected;
2314
- if (allowed) {
2315
- const allowedSet = new Set(allowed);
2316
- pms = detected.filter((pm) => allowedSet.has(pm));
2317
- }
2318
- return {
2319
- detected,
2320
- pms
2321
- };
2322
- };
2323
- const resolvePMs = (ctx, opts) => {
2324
- const { detected, pms } = computePMs(ctx, opts);
2325
- if (pms.length > 0) return pms;
2326
- throw throwEmptyPMsError(detected, opts, opts.allowed);
2327
- };
2328
-
2329
- //#endregion
2330
- //#region src/application/prepare-context.ts
2331
- /**
2332
- * Shared composition-root preamble for `lint` (kept separate so a future
2333
- * command cannot drift from the load → context → resolve → merge → validate →
2334
- * applyConfig protocol).
2335
- */
2336
- const resolveRuleSet = (userConfig, customRules) => {
2337
- const configCustomRules = userConfig?.customRules;
2338
- const base = mergeProgrammaticRules(rules, customRules, configCustomRules);
2339
- assertConfigRuleIdsKnown(userConfig, customRules);
2340
- return applyConfig(base, userConfig);
2341
- };
2342
- const buildRunResult = (options, userConfig) => {
2343
- const { cwd, fs, pm, customRules } = options;
2344
- const ctx = createRepoContext(cwd, fs, options.projectType ?? userConfig?.projectType);
2345
- const allowedPms = userConfig?.pms;
2346
- return {
2347
- ctx,
2348
- pms: resolvePMs(ctx, {
2349
- allowed: allowedPms,
2350
- pmOverride: pm
2351
- }),
2352
- ruleSet: resolveRuleSet(userConfig, customRules),
2353
- userConfig
2354
- };
2355
- };
2356
- const prepareRun = (options) => loadConfig(options.cwd, options.fs).then((userConfig) => buildRunResult(options, userConfig));
2357
-
2358
- //#endregion
2359
- //#region src/domain/services/evaluate-bindings.ts
2360
- const appliesToProject = (rule, projectType) => typeof projectType === "undefined" || typeof rule.projectTypes === "undefined" || rule.projectTypes.some((candidate) => candidate === projectType);
2361
- const resolveBindingProjectType = (ctx, pm, parseConfig) => {
2362
- if (typeof ctx.projectType !== "undefined") return ctx.projectType;
2363
- if (pm === "deno") {
2364
- const { parsed } = parseConfig(ctx, CONFIG_FILES.denoJson);
2365
- return resolveDenoProjectType(ctx, parsed);
2366
- }
2367
- return resolvePackageJsonProjectType(ctx);
2368
- };
2369
- const appliesToBinding = (rule, pm, opts) => {
2370
- const { ctx, parseConfig } = opts;
2371
- return typeof rule.projectTypes === "undefined" || appliesToProject(rule, resolveBindingProjectType(ctx, pm, parseConfig));
2372
- };
2373
- const evaluateRule = (rule, opts) => {
2374
- const { ctx, pms, parseConfig, onViolation } = opts;
2375
- for (const pm of pms) {
2376
- const binding = rule.bindings[pm];
2377
- if (typeof binding !== "undefined" && appliesToBinding(rule, pm, opts)) {
2378
- const { parsed } = parseConfig(ctx, binding.file);
2379
- const status = binding.check(ctx, parsed);
2380
- if (status.state === "violation") onViolation({
2381
- binding,
2382
- pm,
2383
- rule,
2384
- status
2385
- });
2386
- }
2387
- }
2388
- };
2389
- /**
2390
- * Iterate every applicable `(rule, pm)` binding, run its check, and hand
2391
- * each violation to `onViolation`. Folding the loop into one place keeps
2392
- * the rule-evaluation contract (parse once, run check, gate on violation)
2393
- * in a single location so the call site (`runLint`) stays declarative.
2394
- */
2395
- const evaluateBindings = (opts) => {
2396
- const { ruleSet } = opts;
2397
- for (const rule of ruleSet) evaluateRule(rule, opts);
2398
- };
2399
-
2400
- //#endregion
2401
- //#region src/domain/services/parse-config-file.ts
2402
- const EMPTY_FILE = Object.freeze({ parsed: Object.freeze({}) });
2403
- const parseAndCache = (ctx, file, deps) => {
2404
- const text = ctx.readText(file.path) ?? "";
2405
- const entry = { parsed: wrapCodecError(file.path, () => deps.codecFor(file.kind).parse(text)) };
2406
- deps.cache.set(deps.cacheKey, entry);
2407
- return entry;
2408
- };
2409
- /** Create a fresh, command-scoped parser. Always call once per command. */
2410
- const createConfigParser = (codecFor) => {
2411
- const cache = /* @__PURE__ */ new Map();
2412
- return (ctx, file) => {
2413
- if (file.kind === "fileGlob") return EMPTY_FILE;
2414
- const cacheKey = `${file.kind}:${file.path}`;
2415
- const hit = cache.get(cacheKey);
2416
- if (typeof hit !== "undefined") return hit;
2417
- return parseAndCache(ctx, file, {
2418
- cache,
2419
- cacheKey,
2420
- codecFor
2421
- });
2422
- };
2423
- };
2424
-
2425
- //#endregion
2426
- //#region src/domain/services/decide-severity.ts
2427
- /**
2428
- * A user `rules` override is NOT consulted here: `applyConfig` rewrites
2429
- * `rule.severity` to the user's choice AND strips `binding.severity` +
2430
- * dynamic `status.severity` before this function ever runs, so a single
2431
- * three-source resolution suffices at the call site.
2432
- */
2433
- const decideSeverity = (status, binding, rule) => status.severity ?? binding.severity ?? rule.severity;
2434
-
2435
- //#endregion
2436
- //#region src/domain/services/render-version-note.ts
2437
- const buildVersionNoteParts = (versionNote) => {
2438
- const parts = [];
2439
- if (typeof versionNote.configAvailableSince !== "undefined") parts.push(`available since ${versionNote.configAvailableSince}`);
2440
- if (typeof versionNote.defaultSafeSince !== "undefined") parts.push(`default safe since ${versionNote.defaultSafeSince}`);
2441
- if (typeof versionNote.note !== "undefined") parts.push(versionNote.note);
2442
- return parts;
2443
- };
2444
- const renderVersionNoteMessage = (message, versionNote) => {
2445
- if (typeof versionNote === "undefined") return message;
2446
- const parts = buildVersionNoteParts(versionNote);
2447
- if (parts.length === 0) return message;
2448
- return `${message} (${parts.join("; ")})`;
2449
- };
2450
-
2451
- //#endregion
2452
- //#region src/application/run-lint.ts
2453
- const EMPTY = 0;
2454
- const resolveManualSteps = (raw) => {
2455
- if (typeof raw !== "undefined" && raw.length > 0) return raw;
2456
- };
2457
- const buildFinding = (visit, ctx) => {
2458
- const { rule, pm, binding, status } = visit;
2459
- const severity = decideSeverity(status, binding, rule);
2460
- const manualSteps = resolveManualSteps(status.manualSteps);
2461
- let filePath = void 0;
2462
- if (binding.file.kind !== "fileGlob") filePath = binding.file.path;
2463
- let fix = [];
2464
- if (typeof manualSteps === "undefined") fix = binding.fix(ctx);
2465
- return {
2466
- actual: status.actual,
2467
- docs: binding.docs ?? rule.docs,
2468
- expected: status.expected,
2469
- file: filePath,
2470
- fix,
2471
- fixable: binding.fixKind === "auto" && typeof manualSteps === "undefined" && fix.length > EMPTY,
2472
- manualSteps,
2473
- message: renderVersionNoteMessage(status.message, binding.versionNote),
2474
- pm,
2475
- ruleId: rule.id,
2476
- severity
2477
- };
2478
- };
2479
- /** Evaluate every rule binding for the given PMs and collect violations. */
2480
- const runLint = (opts) => {
2481
- const { ctx, pms, ruleSet, codecFor } = opts;
2482
- const findings = [];
2483
- const summary = {
2484
- error: 0,
2485
- info: 0,
2486
- warn: 0
2487
- };
2488
- evaluateBindings({
2489
- ctx,
2490
- onViolation: (visit) => {
2491
- const finding = buildFinding(visit, ctx);
2492
- findings.push(finding);
2493
- summary[finding.severity] += 1;
2494
- },
2495
- parseConfig: createConfigParser(codecFor),
2496
- pms,
2497
- ruleSet
2498
- });
2499
- return {
2500
- findings,
2501
- summary
2502
- };
2503
- };
2504
-
2505
- //#endregion
2506
- //#region src/application/commands/lint.ts
2507
- const resolveReporter = (value, registry) => {
2508
- if (typeof value === "undefined") return registry.get(DEFAULT_REPORTER_NAME);
2509
- if (typeof value === "string") return registry.get(value);
2510
- if (!isReporterShape(value)) throw new UsageError("The 'reporter' option must be a name or a { name, format } object.");
2511
- return value;
2512
- };
2513
- const validateEmbedderReporters = (reporters) => {
2514
- if (typeof reporters !== "undefined" && !Array.isArray(reporters)) throw new UsageError("The 'reporters' option must be an array of reporter objects.");
2515
- for (const rep of reporters ?? []) if (!isReporterShape(rep)) throw new UsageError("A reporter in 'reporters' needs a string 'name' and a 'format' function.");
2516
- };
2517
- const validateSelection = (value, isValid, errorMessage) => {
2518
- if (typeof value !== "undefined" && (typeof value !== "string" || !isValid(value))) throw new UsageError(errorMessage);
2519
- };
2520
- const validateCustomRules = (customRules) => {
2521
- if (typeof customRules !== "undefined" && (!Array.isArray(customRules) || !Array.from(customRules).every((rule) => isRuleShape(rule)))) throw new UsageError("The 'customRules' option must contain structurally valid rules.");
2522
- };
2523
- const validateLintOptions = (options) => {
2524
- validateCustomRules(options.customRules);
2525
- validateSelection(options.projectType, isProjectType, `Unknown project type: ${String(options.projectType)} (expected ${PROJECT_TYPES.join("|")})`);
2526
- validateSelection(options.pm, isPM, `Unknown package manager: ${String(options.pm)} (expected one of: ${PMS.join(", ")})`);
2527
- validateSelection(options.severity, isSeverity, `Invalid severity: ${String(options.severity)} (expected ${SEVERITIES.join("|")})`);
2528
- };
2529
- const resolveAndValidateReporter = (value, registry) => {
2530
- const reporter = resolveReporter(value, registry);
2531
- if (typeof reporter === "undefined") throw new UsageError(`Unknown reporter: ${String(value)} (available: ${registry.list().join(", ")})`);
2532
- return reporter;
2533
- };
2534
- const formatAndExit = (args) => {
2535
- const result = runLint({
2536
- codecFor,
2537
- ctx: args.ctx,
2538
- pms: args.pms,
2539
- ruleSet: args.ruleSet
2540
- });
2541
- const displayThreshold = args.options.severity ?? "info";
2542
- const failThreshold = args.options.severity ?? "error";
2543
- args.reporter.format(filterBySeverity(result, displayThreshold), args.io);
2544
- return exitCodeForLint(result, failThreshold);
2545
- };
2546
- /** `siro lint`: detect PMs, evaluate rules, report findings. */
2547
- const lintCommand = async (options, io) => {
2548
- validateLintOptions(options);
2549
- const { userConfig, ctx, pms, ruleSet } = await prepareRun({
2550
- customRules: options.customRules,
2551
- cwd: options.cwd,
2552
- fs: options.fs,
2553
- pm: options.pm,
2554
- projectType: options.projectType
2555
- });
2556
- validateEmbedderReporters(options.reporters);
2557
- const configReporters = userConfig?.reporters ?? [];
2558
- const registry = createRegistry([...options.reporters ?? [], ...configReporters]);
2559
- return formatAndExit({
2560
- ctx,
2561
- io,
2562
- options,
2563
- pms,
2564
- reporter: resolveAndValidateReporter(options.reporter, registry),
2565
- ruleSet
2566
- });
2567
- };
2568
-
2569
- //#endregion
2570
- //#region src/adapters/node-io.ts
2571
- const nodeIO = {
2572
- stderr: (line) => process.stderr.write(`${line}\n`),
2573
- stdout: (line) => process.stdout.write(`${line}\n`)
2574
- };
2575
-
2576
- //#endregion
2577
- export { isSeverity as A, jsonReporter as C, PMS as D, isReporterShape as E, asAbsPath as F, asRelPath as I, UsageError as M, ConfigError as N, SEVERITIES as O, SiroError as P, prettyReporter as S, githubReporter as T, isSupportedNodeVersion as _, nodeFileSystem as a, JSON_REPORTER_NAME as b, requireConfigKey as c, exitCodeForLint as d, filterBySeverity as f, SUPPORTED_NODE_RANGE as g, isProjectType as h, detectPMs as i, wrapCodecError as j, isPM as k, CONFIG_FILES as l, PROJECT_TYPES as m, lintCommand as n, PM_SIGNALS as o, isNodeError as p, renderVersionNoteMessage as r, overrideBindings as s, nodeIO as t, getByPath as u, BUILTIN_REPORTER_NAMES as v, version as w, createRegistry as x, DEFAULT_REPORTER_NAME as y };
2578
- //# sourceMappingURL=node-io-CUcMwlGH.mjs.map