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