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