@noctcore/lint-meta-rules 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/chunk-Z7TXSZR4.js +63 -0
- package/dist/i18n.cjs +317 -0
- package/dist/i18n.d.cts +60 -0
- package/dist/i18n.d.ts +60 -0
- package/dist/i18n.js +266 -0
- package/dist/index.cjs +439 -0
- package/dist/index.d.cts +127 -1
- package/dist/index.d.ts +127 -1
- package/dist/index.js +417 -19
- package/docs/rules/dockerfile-base-image-digest-pin.md +43 -0
- package/docs/rules/github-actions-runner-pinned.md +42 -0
- package/docs/rules/github-actions-sha-pinned.md +46 -0
- package/docs/rules/security-scanner-version-parity.md +61 -0
- package/docs/rules/service-image-digest-pin.md +52 -0
- package/docs/rules/translation-dead-keys.md +98 -0
- package/package.json +24 -3
package/dist/index.d.ts
CHANGED
|
@@ -52,6 +52,26 @@ interface CanonicalHelpersSingleHomeOptions {
|
|
|
52
52
|
*/
|
|
53
53
|
declare function createCanonicalHelpersSingleHomeRule(options?: CanonicalHelpersSingleHomeOptions): IMetaRule;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Options for {@link createDockerfileBaseImageDigestPinRule}.
|
|
57
|
+
*
|
|
58
|
+
* Ported from a private monorepo that walked the whole tree with a hardcoded Dockerfile
|
|
59
|
+
* name regex and skip list; both are now options.
|
|
60
|
+
*/
|
|
61
|
+
interface DockerfileBaseImageDigestPinOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Globs that find Dockerfiles. Default `Dockerfile`, `Dockerfile.*` and
|
|
64
|
+
* `*.Dockerfile` at any depth, dot-directories included.
|
|
65
|
+
*/
|
|
66
|
+
readonly dockerfileGlobs?: readonly string[];
|
|
67
|
+
/** A path with any of these segments is skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
68
|
+
readonly skipDirs?: readonly string[];
|
|
69
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
70
|
+
readonly ciCritical?: boolean;
|
|
71
|
+
}
|
|
72
|
+
/** Every Dockerfile FROM must pin its base image by digest. */
|
|
73
|
+
declare function createDockerfileBaseImageDigestPinRule(options?: DockerfileBaseImageDigestPinOptions): IMetaRule;
|
|
74
|
+
|
|
55
75
|
/**
|
|
56
76
|
* Options for {@link createFileSizeRatchetRule}.
|
|
57
77
|
*
|
|
@@ -96,6 +116,38 @@ interface FileSizeRatchetOptions {
|
|
|
96
116
|
*/
|
|
97
117
|
declare function createFileSizeRatchetRule(options?: FileSizeRatchetOptions): IMetaRule;
|
|
98
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Options for {@link createGithubActionsRunnerPinnedRule}.
|
|
121
|
+
*
|
|
122
|
+
* Ported from a private monorepo that hardcoded the `*-latest` label shape and read a
|
|
123
|
+
* pre-collected `workflowFiles` list; both are now options.
|
|
124
|
+
*/
|
|
125
|
+
interface GithubActionsRunnerPinnedOptions {
|
|
126
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
127
|
+
readonly workflowGlobs?: readonly string[];
|
|
128
|
+
/** A runner label matching this is floating. Default `/^[\w.-]+-latest$/u`. */
|
|
129
|
+
readonly floatingLabel?: RegExp;
|
|
130
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
131
|
+
readonly ciCritical?: boolean;
|
|
132
|
+
}
|
|
133
|
+
/** No workflow job may run on a floating (`*-latest`) runner label. */
|
|
134
|
+
declare function createGithubActionsRunnerPinnedRule(options?: GithubActionsRunnerPinnedOptions): IMetaRule;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Options for {@link createGithubActionsShaPinnedRule}.
|
|
138
|
+
*
|
|
139
|
+
* Ported from a private monorepo whose runner handed the rule a pre-collected
|
|
140
|
+
* `workflowFiles` list; that list is now the `workflowGlobs` option.
|
|
141
|
+
*/
|
|
142
|
+
interface GithubActionsShaPinnedOptions {
|
|
143
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
144
|
+
readonly workflowGlobs?: readonly string[];
|
|
145
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
146
|
+
readonly ciCritical?: boolean;
|
|
147
|
+
}
|
|
148
|
+
/** Every workflow `uses:` ref must be a full commit SHA with a `# vN` comment. */
|
|
149
|
+
declare function createGithubActionsShaPinnedRule(options?: GithubActionsShaPinnedOptions): IMetaRule;
|
|
150
|
+
|
|
99
151
|
/**
|
|
100
152
|
* Options for {@link createLayerRankRule}.
|
|
101
153
|
*
|
|
@@ -219,6 +271,75 @@ interface PackageShapeOptions {
|
|
|
219
271
|
*/
|
|
220
272
|
declare function createPackageShapeRule(options?: PackageShapeOptions): IMetaRule;
|
|
221
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Options for {@link createSecurityScannerVersionParityRule}.
|
|
276
|
+
*
|
|
277
|
+
* Ported from a private monorepo that hardcoded gitleaks, its `GITLEAKS_VERSION` /
|
|
278
|
+
* `GITLEAKS_IMAGE` variable names, the hook path `scripts/ci/pre-push.sh` and a
|
|
279
|
+
* pre-collected `workflowFiles` list. Each is now an option whose default is the
|
|
280
|
+
* original value, so the rule can pin any scanner that prints `<scanner> version`.
|
|
281
|
+
*/
|
|
282
|
+
interface SecurityScannerVersionParityOptions {
|
|
283
|
+
/** The scanner's binary name, as it appears in the hook and in its image name. Default `gitleaks`. */
|
|
284
|
+
readonly scanner?: string;
|
|
285
|
+
/** The env / shell variable both sides pin the version in. Default `GITLEAKS_VERSION`. */
|
|
286
|
+
readonly versionVariable?: string;
|
|
287
|
+
/** The hook's optional image variable, whose `<scanner>:vX.Y.Z` tag must agree. Default `GITLEAKS_IMAGE`. */
|
|
288
|
+
readonly imageVariable?: string;
|
|
289
|
+
/** The local hook script, repo-relative. Default `scripts/ci/pre-push.sh`. */
|
|
290
|
+
readonly hookFile?: string;
|
|
291
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
292
|
+
readonly workflowGlobs?: readonly string[];
|
|
293
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
294
|
+
readonly ciCritical?: boolean;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Different scanner versions ship different rulesets, so a push can pass the
|
|
298
|
+
* local hook and fail CI (or the reverse) with no code difference. This ties
|
|
299
|
+
* three things together:
|
|
300
|
+
*
|
|
301
|
+
* - every workflow that pins the version variable pins the same one,
|
|
302
|
+
* - the hook declares the same variable equal to it (and a literal image tag,
|
|
303
|
+
* if it has one, agrees too),
|
|
304
|
+
* - the hook actually runs `<scanner> version`, so a drifted native install is
|
|
305
|
+
* caught at run time.
|
|
306
|
+
*
|
|
307
|
+
* Dormant when neither side mentions the scanner.
|
|
308
|
+
*/
|
|
309
|
+
declare function createSecurityScannerVersionParityRule(options?: SecurityScannerVersionParityOptions): IMetaRule;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Options for {@link createServiceImageDigestPinRule}.
|
|
313
|
+
*
|
|
314
|
+
* Ported from a private monorepo that hardcoded a one-entry exemption list (an
|
|
315
|
+
* OpenTelemetry collector tag its registry does not serve), a compose file name
|
|
316
|
+
* regex, a skip list and a pre-collected `workflowFiles` list. All four are now
|
|
317
|
+
* options; the exemption list defaults to empty.
|
|
318
|
+
*/
|
|
319
|
+
interface ServiceImageDigestPinOptions {
|
|
320
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
321
|
+
readonly workflowGlobs?: readonly string[];
|
|
322
|
+
/** Globs that find compose files. Default every `compose` / `docker-compose` `.yml` / `.yaml` variant at any depth, dot-directories included. */
|
|
323
|
+
readonly composeGlobs?: readonly string[];
|
|
324
|
+
/** A compose path with any of these segments is skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
325
|
+
readonly skipDirs?: readonly string[];
|
|
326
|
+
/**
|
|
327
|
+
* Exact image refs that are allowed unpinned, for a ref the registry cannot
|
|
328
|
+
* serve a digest for. Each entry should say why in the consumer's config.
|
|
329
|
+
* Default none.
|
|
330
|
+
*/
|
|
331
|
+
readonly allowUnpinned?: readonly string[];
|
|
332
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
333
|
+
readonly ciCritical?: boolean;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Every workflow service/container image and every compose image must be pinned
|
|
337
|
+
* by digest. A tag (`postgres:17-alpine`) or `latest` names whatever the registry
|
|
338
|
+
* serves today, so a green build can change under an unchanged commit.
|
|
339
|
+
* `tag@sha256:<digest>` keeps the readable tag and fixes the content.
|
|
340
|
+
*/
|
|
341
|
+
declare function createServiceImageDigestPinRule(options?: ServiceImageDigestPinOptions): IMetaRule;
|
|
342
|
+
|
|
222
343
|
/**
|
|
223
344
|
* Options for {@link createTestRunnerSegregationRule}.
|
|
224
345
|
*
|
|
@@ -376,11 +497,16 @@ declare function countLines(text: string): number;
|
|
|
376
497
|
declare const RULE_FACTORIES: {
|
|
377
498
|
readonly 'agents-doc-presence': typeof createAgentsDocPresenceRule;
|
|
378
499
|
readonly 'canonical-helpers-single-home': typeof createCanonicalHelpersSingleHomeRule;
|
|
500
|
+
readonly 'dockerfile-base-image-digest-pin': typeof createDockerfileBaseImageDigestPinRule;
|
|
379
501
|
readonly 'file-size-ratchet': typeof createFileSizeRatchetRule;
|
|
502
|
+
readonly 'github-actions-runner-pinned': typeof createGithubActionsRunnerPinnedRule;
|
|
503
|
+
readonly 'github-actions-sha-pinned': typeof createGithubActionsShaPinnedRule;
|
|
380
504
|
readonly 'layer-rank': typeof createLayerRankRule;
|
|
381
505
|
readonly 'no-cloned-component-folders': typeof createNoClonedComponentFoldersRule;
|
|
382
506
|
readonly 'no-warn-severity': typeof createNoWarnSeverityRule;
|
|
383
507
|
readonly 'package-shape': typeof createPackageShapeRule;
|
|
508
|
+
readonly 'security-scanner-version-parity': typeof createSecurityScannerVersionParityRule;
|
|
509
|
+
readonly 'service-image-digest-pin': typeof createServiceImageDigestPinRule;
|
|
384
510
|
readonly 'test-runner-segregation': typeof createTestRunnerSegregationRule;
|
|
385
511
|
readonly 'test-sibling-enforcement': typeof createTestSiblingEnforcementRule;
|
|
386
512
|
readonly 'test-workspace-enrollment': typeof createTestWorkspaceEnrollmentRule;
|
|
@@ -392,4 +518,4 @@ declare const RULE_IDS: (keyof typeof RULE_FACTORIES)[];
|
|
|
392
518
|
/** Instantiate every catalog rule with its default options. */
|
|
393
519
|
declare function createAllRules(): IMetaRule[];
|
|
394
520
|
|
|
395
|
-
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type FileSizeRatchetOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createFileSizeRatchetRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
|
521
|
+
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type DockerfileBaseImageDigestPinOptions, type FileSizeRatchetOptions, type GithubActionsRunnerPinnedOptions, type GithubActionsShaPinnedOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type SecurityScannerVersionParityOptions, type ServiceImageDigestPinOptions, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createDockerfileBaseImageDigestPinRule, createFileSizeRatchetRule, createGithubActionsRunnerPinnedRule, createGithubActionsShaPinnedRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createSecurityScannerVersionParityRule, createServiceImageDigestPinRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
15
|
-
return lines.length;
|
|
16
|
-
}
|
|
17
|
-
function recursiveGlobs(roots, extensions) {
|
|
18
|
-
return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
|
|
19
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SKIP_DIRS,
|
|
3
|
+
DEFAULT_WORKFLOW_GLOBS,
|
|
4
|
+
anywhereGlobs,
|
|
5
|
+
baseName,
|
|
6
|
+
countLines,
|
|
7
|
+
dirOf,
|
|
8
|
+
escapeRegExp,
|
|
9
|
+
globFiles,
|
|
10
|
+
recursiveGlobs,
|
|
11
|
+
stripYamlComment,
|
|
12
|
+
unquote
|
|
13
|
+
} from "./chunk-Z7TXSZR4.js";
|
|
20
14
|
|
|
21
15
|
// src/rules/agents-doc-presence.ts
|
|
22
16
|
function createAgentsDocPresenceRule(options = {}) {
|
|
@@ -111,6 +105,63 @@ function createCanonicalHelpersSingleHomeRule(options = {}) {
|
|
|
111
105
|
};
|
|
112
106
|
}
|
|
113
107
|
|
|
108
|
+
// src/rules/dockerfile-base-image-digest-pin.ts
|
|
109
|
+
var RULE_ID = "dockerfile-base-image-digest-pin";
|
|
110
|
+
var DEFAULT_DOCKERFILE_GLOBS = anywhereGlobs(["Dockerfile", "Dockerfile.*", "*.Dockerfile"]);
|
|
111
|
+
var FROM_LINE = /^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?\s*$/iu;
|
|
112
|
+
var ARG_DEFAULT = /^\s*ARG\s+(\w+)=(\S+)\s*$/iu;
|
|
113
|
+
var DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
114
|
+
function checkDockerfileBases(file, text) {
|
|
115
|
+
const violations = [];
|
|
116
|
+
const stages = /* @__PURE__ */ new Set();
|
|
117
|
+
const argDefaults = /* @__PURE__ */ new Map();
|
|
118
|
+
let seenFrom = false;
|
|
119
|
+
text.split("\n").forEach((line, index) => {
|
|
120
|
+
const arg = ARG_DEFAULT.exec(line);
|
|
121
|
+
if (arg?.[1] !== void 0 && arg[2] !== void 0 && !seenFrom) {
|
|
122
|
+
argDefaults.set(arg[1], arg[2].replace(/^(['"])(.*)\1$/u, "$2"));
|
|
123
|
+
}
|
|
124
|
+
const from = FROM_LINE.exec(line);
|
|
125
|
+
if (from?.[1] === void 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
seenFrom = true;
|
|
129
|
+
const written = from[1];
|
|
130
|
+
const variable = /^\$\{?(\w+)\}?$/u.exec(written)?.[1];
|
|
131
|
+
const image = variable === void 0 ? written : argDefaults.get(variable) ?? written;
|
|
132
|
+
const isExempt = image.toLowerCase() === "scratch" || stages.has(image.toLowerCase());
|
|
133
|
+
if (from[2] !== void 0) {
|
|
134
|
+
stages.add(from[2].toLowerCase());
|
|
135
|
+
}
|
|
136
|
+
if (!isExempt && !DIGEST.test(image)) {
|
|
137
|
+
const reference = image.split("@")[0];
|
|
138
|
+
violations.push({
|
|
139
|
+
file,
|
|
140
|
+
rule: RULE_ID,
|
|
141
|
+
message: `line ${index + 1}: base image "${image}" is not pinned by digest. Write \`FROM ${reference}@sha256:<digest>\` (resolve it with \`docker buildx imagetools inspect ${reference}\`).`,
|
|
142
|
+
line: index + 1
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return violations;
|
|
147
|
+
}
|
|
148
|
+
function createDockerfileBaseImageDigestPinRule(options = {}) {
|
|
149
|
+
const dockerfileGlobs = options.dockerfileGlobs ?? DEFAULT_DOCKERFILE_GLOBS;
|
|
150
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
151
|
+
const ciCritical = options.ciCritical ?? true;
|
|
152
|
+
return {
|
|
153
|
+
id: RULE_ID,
|
|
154
|
+
category: "ci",
|
|
155
|
+
ciCritical,
|
|
156
|
+
description: "Dockerfile FROM base images must be pinned by `@sha256:` digest (scratch and earlier build stages exempt).",
|
|
157
|
+
run(ctx) {
|
|
158
|
+
return globFiles((p) => ctx.glob(p), dockerfileGlobs, skipDirs).flatMap(
|
|
159
|
+
(file) => checkDockerfileBases(file, ctx.read(file) ?? "")
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
114
165
|
// src/rules/file-size-ratchet.ts
|
|
115
166
|
import { DEFAULT_BASELINE_DIR, isGrandfathered, loadBaseline } from "@noctcore/harness";
|
|
116
167
|
var DEFAULT_EXCLUDE_CONTAINS = [".test.", ".spec.", ".stories."];
|
|
@@ -193,6 +244,121 @@ function createFileSizeRatchetRule(options = {}) {
|
|
|
193
244
|
};
|
|
194
245
|
}
|
|
195
246
|
|
|
247
|
+
// src/rules/github-actions-runner-pinned.ts
|
|
248
|
+
var RULE_ID2 = "github-actions-runner-pinned";
|
|
249
|
+
var RUNS_ON = /^(\s*)runs-on:\s*(.*)$/u;
|
|
250
|
+
var DEFAULT_FLOATING_LABEL = /^[\w.-]+-latest$/u;
|
|
251
|
+
function runnerLabels(lines, start, indent) {
|
|
252
|
+
const inline = stripYamlComment(RUNS_ON.exec(lines[start] ?? "")?.[2] ?? "").trim();
|
|
253
|
+
const collected = [inline];
|
|
254
|
+
if (inline === "") {
|
|
255
|
+
for (const line of lines.slice(start + 1)) {
|
|
256
|
+
const text = stripYamlComment(line);
|
|
257
|
+
if (text.trim() === "") {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if ((/^\s*/u.exec(text)?.[0].length ?? 0) <= indent) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
collected.push(text.replace(/^\s*(?:-\s+|labels:\s*)/u, ""));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return collected.filter((value) => !value.includes("${{")).flatMap((value) => value.split(/[\s,[\]'"]+/u)).filter((label) => label !== "");
|
|
267
|
+
}
|
|
268
|
+
function checkWorkflowRunnersPinned(file, text, floatingLabel = DEFAULT_FLOATING_LABEL) {
|
|
269
|
+
const violations = [];
|
|
270
|
+
const lines = text.split("\n");
|
|
271
|
+
lines.forEach((line, index) => {
|
|
272
|
+
const match = RUNS_ON.exec(line);
|
|
273
|
+
if (match === null || line.trimStart().startsWith("#")) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const label of runnerLabels(lines, index, match[1]?.length ?? 0)) {
|
|
277
|
+
if (floatingLabel.test(label)) {
|
|
278
|
+
violations.push({
|
|
279
|
+
file,
|
|
280
|
+
rule: RULE_ID2,
|
|
281
|
+
message: `line ${index + 1}: \`runs-on\` uses the floating runner label "${label}". Pin a named image (for example \`ubuntu-24.04\`) so a runner image change is a reviewed diff.`,
|
|
282
|
+
line: index + 1
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return violations;
|
|
288
|
+
}
|
|
289
|
+
function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
290
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
291
|
+
const floatingLabel = options.floatingLabel ?? DEFAULT_FLOATING_LABEL;
|
|
292
|
+
const ciCritical = options.ciCritical ?? true;
|
|
293
|
+
return {
|
|
294
|
+
id: RULE_ID2,
|
|
295
|
+
category: "ci",
|
|
296
|
+
ciCritical,
|
|
297
|
+
description: "GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label.",
|
|
298
|
+
run(ctx) {
|
|
299
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
300
|
+
(file) => checkWorkflowRunnersPinned(file, ctx.read(file) ?? "", floatingLabel)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/rules/github-actions-sha-pinned.ts
|
|
307
|
+
var RULE_ID3 = "github-actions-sha-pinned";
|
|
308
|
+
var USES_LINE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)['"]?\s*(#.*)?$/u;
|
|
309
|
+
var FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
310
|
+
var DOCKER_DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
311
|
+
var VERSION_COMMENT = /^#\s*v\d/u;
|
|
312
|
+
function checkWorkflowActionsPinned(file, text) {
|
|
313
|
+
const violations = [];
|
|
314
|
+
text.split("\n").forEach((line, index) => {
|
|
315
|
+
const match = USES_LINE.exec(line);
|
|
316
|
+
const ref = match?.[1];
|
|
317
|
+
if (ref === void 0 || ref.startsWith("./")) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const where = `line ${index + 1}`;
|
|
321
|
+
const report = (message) => {
|
|
322
|
+
violations.push({ file, rule: RULE_ID3, message, line: index + 1 });
|
|
323
|
+
};
|
|
324
|
+
if (ref.startsWith("docker://")) {
|
|
325
|
+
if (!DOCKER_DIGEST.test(ref)) {
|
|
326
|
+
report(`${where}: \`uses: ${ref}\` is not pinned by digest. Append \`@sha256:<digest>\`.`);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const at = ref.lastIndexOf("@");
|
|
331
|
+
const pin = at === -1 ? "" : ref.slice(at + 1);
|
|
332
|
+
if (!FULL_SHA.test(pin)) {
|
|
333
|
+
report(
|
|
334
|
+
`${where}: \`uses: ${ref}\` is not pinned to a 40-character commit SHA. Resolve the tag to its commit and write \`uses: ${at === -1 ? ref : ref.slice(0, at)}@<sha> # vN\`.`
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!VERSION_COMMENT.test(match?.[2] ?? "")) {
|
|
339
|
+
report(
|
|
340
|
+
`${where}: \`uses: ${ref}\` is SHA-pinned but has no \`# vN\` comment. Keep the version beside the SHA so the pin stays readable and Dependabot can bump it.`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
return violations;
|
|
345
|
+
}
|
|
346
|
+
function createGithubActionsShaPinnedRule(options = {}) {
|
|
347
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
348
|
+
const ciCritical = options.ciCritical ?? true;
|
|
349
|
+
return {
|
|
350
|
+
id: RULE_ID3,
|
|
351
|
+
category: "ci",
|
|
352
|
+
ciCritical,
|
|
353
|
+
description: "GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt).",
|
|
354
|
+
run(ctx) {
|
|
355
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
356
|
+
(file) => checkWorkflowActionsPinned(file, ctx.read(file) ?? "")
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
196
362
|
// src/rules/layer-rank.ts
|
|
197
363
|
var DEFAULT_SOURCE_GLOBS = [
|
|
198
364
|
"packages/*/src/**/*.ts",
|
|
@@ -405,6 +571,228 @@ function createPackageShapeRule(options = {}) {
|
|
|
405
571
|
};
|
|
406
572
|
}
|
|
407
573
|
|
|
574
|
+
// src/rules/security-scanner-version-parity.ts
|
|
575
|
+
var RULE_ID4 = "security-scanner-version-parity";
|
|
576
|
+
function createSecurityScannerVersionParityRule(options = {}) {
|
|
577
|
+
const scanner = options.scanner ?? "gitleaks";
|
|
578
|
+
const versionVariable = options.versionVariable ?? "GITLEAKS_VERSION";
|
|
579
|
+
const imageVariable = options.imageVariable ?? "GITLEAKS_IMAGE";
|
|
580
|
+
const hookFile = options.hookFile ?? "scripts/ci/pre-push.sh";
|
|
581
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
582
|
+
const ciCritical = options.ciCritical ?? true;
|
|
583
|
+
const name = escapeRegExp(scanner);
|
|
584
|
+
const versionVar = escapeRegExp(versionVariable);
|
|
585
|
+
const workflowVersion = new RegExp(
|
|
586
|
+
`^\\s*${versionVar}:\\s*['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
|
|
587
|
+
"mu"
|
|
588
|
+
);
|
|
589
|
+
const hookVersion = new RegExp(
|
|
590
|
+
`^\\s*(?:readonly\\s+)?${versionVar}=['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
|
|
591
|
+
"mu"
|
|
592
|
+
);
|
|
593
|
+
const hookImageTag = new RegExp(
|
|
594
|
+
`^\\s*${escapeRegExp(imageVariable)}=['"][^'"\\n]*${name}:v(\\d+\\.\\d+\\.\\d+)['"]`,
|
|
595
|
+
"mu"
|
|
596
|
+
);
|
|
597
|
+
const runtimeCheck = new RegExp(`\\b${name}\\s+version\\b`, "u");
|
|
598
|
+
const mentionsScanner = new RegExp(name, "iu");
|
|
599
|
+
return {
|
|
600
|
+
id: RULE_ID4,
|
|
601
|
+
category: "ci",
|
|
602
|
+
ciCritical,
|
|
603
|
+
description: `The ${scanner} version pinned in the workflows must equal the one in ${hookFile}, and the hook must compare a native ${scanner} against it at run time.`,
|
|
604
|
+
run(ctx) {
|
|
605
|
+
const hookText = ctx.read(hookFile) ?? "";
|
|
606
|
+
const ciVersions = /* @__PURE__ */ new Map();
|
|
607
|
+
for (const file of globFiles((p) => ctx.glob(p), workflowGlobs)) {
|
|
608
|
+
const version = workflowVersion.exec(ctx.read(file) ?? "")?.[1];
|
|
609
|
+
if (version !== void 0) {
|
|
610
|
+
ciVersions.set(version, [...ciVersions.get(version) ?? [], file]);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const hookUsesScanner = mentionsScanner.test(hookText);
|
|
614
|
+
if (ciVersions.size === 0 && !hookUsesScanner) {
|
|
615
|
+
return [];
|
|
616
|
+
}
|
|
617
|
+
const violations = [];
|
|
618
|
+
const report = (file, message) => {
|
|
619
|
+
violations.push({ file, rule: RULE_ID4, message });
|
|
620
|
+
};
|
|
621
|
+
if (ciVersions.size > 1) {
|
|
622
|
+
report(
|
|
623
|
+
[...ciVersions.values()].flat()[0] ?? hookFile,
|
|
624
|
+
`Workflows pin different ${scanner} versions (${[...ciVersions.keys()].join(", ")}). Use one \`${versionVariable}\` everywhere.`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const ciVersion = ciVersions.size === 1 ? [...ciVersions.keys()][0] : void 0;
|
|
628
|
+
if (ciVersions.size === 0) {
|
|
629
|
+
report(
|
|
630
|
+
hookFile,
|
|
631
|
+
`${hookFile} runs ${scanner} but no workflow pins \`${versionVariable}\`, so CI's scanner version is unknown. Pin it in the workflow env.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
if (!hookUsesScanner) {
|
|
635
|
+
report(
|
|
636
|
+
hookFile,
|
|
637
|
+
`Workflows pin ${scanner} ${ciVersion ?? ""} but ${hookFile} does not run ${scanner}, so the local hook cannot agree with CI.`
|
|
638
|
+
);
|
|
639
|
+
return violations;
|
|
640
|
+
}
|
|
641
|
+
const declared = hookVersion.exec(hookText)?.[1];
|
|
642
|
+
if (declared === void 0) {
|
|
643
|
+
report(
|
|
644
|
+
hookFile,
|
|
645
|
+
`${hookFile} does not declare \`${versionVariable}="x.y.z"\`. Declare the version CI pins (${ciVersion ?? "see the workflow"}) and derive the Docker image tag from it.`
|
|
646
|
+
);
|
|
647
|
+
} else if (ciVersion !== void 0 && declared !== ciVersion) {
|
|
648
|
+
report(
|
|
649
|
+
hookFile,
|
|
650
|
+
`${hookFile} pins ${scanner} ${declared} but CI pins ${ciVersion}. Bump them together.`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const imageTag = hookImageTag.exec(hookText)?.[1];
|
|
654
|
+
if (imageTag !== void 0 && ciVersion !== void 0 && imageTag !== ciVersion) {
|
|
655
|
+
report(
|
|
656
|
+
hookFile,
|
|
657
|
+
`${hookFile} \`${imageVariable}\` is tagged v${imageTag} but CI pins ${ciVersion}. Derive the tag from \`${versionVariable}\` or bump it.`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (!runtimeCheck.test(hookText)) {
|
|
661
|
+
report(
|
|
662
|
+
hookFile,
|
|
663
|
+
`${hookFile} never runs \`${scanner} version\`. A native ${scanner} of another version scans with another ruleset: compare it with \`${versionVariable}\` and refuse a mismatch.`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return violations;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/rules/service-image-digest-pin.ts
|
|
672
|
+
var RULE_ID5 = "service-image-digest-pin";
|
|
673
|
+
var DIGEST2 = /@sha256:[0-9a-f]{64}$/u;
|
|
674
|
+
var KEY_LINE = /^(\s*)(?:-\s+)?([\w-]+):\s*(.*)$/u;
|
|
675
|
+
var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
676
|
+
["compose", "docker-compose"].flatMap(
|
|
677
|
+
(stem) => ["", ".*", "-*"].flatMap((infix) => ["yml", "yaml"].map((ext) => `${stem}${infix}.${ext}`))
|
|
678
|
+
)
|
|
679
|
+
);
|
|
680
|
+
function indentOf(line) {
|
|
681
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
682
|
+
}
|
|
683
|
+
function workflowImages(text) {
|
|
684
|
+
const found = [];
|
|
685
|
+
const stack = [];
|
|
686
|
+
text.split("\n").forEach((line, index) => {
|
|
687
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const match = KEY_LINE.exec(line);
|
|
691
|
+
if (match === null) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const indent = match[1]?.length ?? 0;
|
|
695
|
+
const key = match[2] ?? "";
|
|
696
|
+
const value = unquote(stripYamlComment(match[3] ?? "").trim());
|
|
697
|
+
while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) {
|
|
698
|
+
stack.pop();
|
|
699
|
+
}
|
|
700
|
+
const insideService = stack.some((entry) => entry.key === "services");
|
|
701
|
+
const isContainerScalar = key === "container" && value !== "";
|
|
702
|
+
const isImageKey = key === "image" && value !== "";
|
|
703
|
+
const insideContainer = stack.some((entry) => entry.key === "container");
|
|
704
|
+
if (isContainerScalar || isImageKey && (insideService || insideContainer)) {
|
|
705
|
+
found.push({ line: index + 1, image: value });
|
|
706
|
+
}
|
|
707
|
+
stack.push({ indent, key });
|
|
708
|
+
});
|
|
709
|
+
return found;
|
|
710
|
+
}
|
|
711
|
+
function composeImages(text) {
|
|
712
|
+
const lines = text.split("\n");
|
|
713
|
+
const found = [];
|
|
714
|
+
const servicesAt = lines.findIndex((line) => /^services:\s*(?:#.*)?$/u.test(line));
|
|
715
|
+
if (servicesAt === -1) {
|
|
716
|
+
return found;
|
|
717
|
+
}
|
|
718
|
+
let serviceIndent = null;
|
|
719
|
+
let propertyIndent = null;
|
|
720
|
+
let current = null;
|
|
721
|
+
const flush = () => {
|
|
722
|
+
if (current !== null && !current.hasBuild) {
|
|
723
|
+
found.push(...current.images);
|
|
724
|
+
}
|
|
725
|
+
current = null;
|
|
726
|
+
};
|
|
727
|
+
for (let index = servicesAt + 1; index < lines.length; index += 1) {
|
|
728
|
+
const line = lines[index] ?? "";
|
|
729
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const indent = indentOf(line);
|
|
733
|
+
if (indent === 0) {
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
serviceIndent ??= indent;
|
|
737
|
+
const match = KEY_LINE.exec(line);
|
|
738
|
+
if (match === null) {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (indent === serviceIndent) {
|
|
742
|
+
flush();
|
|
743
|
+
current = { images: [], hasBuild: false };
|
|
744
|
+
propertyIndent = null;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
propertyIndent ??= indent;
|
|
748
|
+
if (current === null || indent !== propertyIndent) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const value = unquote(stripYamlComment(match[3] ?? "").trim());
|
|
752
|
+
if (match[2] === "build") {
|
|
753
|
+
current.hasBuild = true;
|
|
754
|
+
} else if (match[2] === "image" && value !== "") {
|
|
755
|
+
current.images.push({ line: index + 1, image: value });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
flush();
|
|
759
|
+
return found;
|
|
760
|
+
}
|
|
761
|
+
function violationsFor(file, images, allowUnpinned) {
|
|
762
|
+
return images.filter(({ image }) => !DIGEST2.test(image) && !allowUnpinned.has(image)).map(({ line, image }) => {
|
|
763
|
+
const reference = image.split("@")[0];
|
|
764
|
+
return {
|
|
765
|
+
file,
|
|
766
|
+
rule: RULE_ID5,
|
|
767
|
+
message: `line ${line}: image "${image}" is not pinned by digest. Write \`${reference}@sha256:<digest>\` (resolve it with \`docker buildx imagetools inspect ${reference}\`) so CI and local runs use the same bytes.`,
|
|
768
|
+
line
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
function createServiceImageDigestPinRule(options = {}) {
|
|
773
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
774
|
+
const composeGlobs = options.composeGlobs ?? DEFAULT_COMPOSE_GLOBS;
|
|
775
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
776
|
+
const allowUnpinned = new Set(options.allowUnpinned ?? []);
|
|
777
|
+
const ciCritical = options.ciCritical ?? true;
|
|
778
|
+
return {
|
|
779
|
+
id: RULE_ID5,
|
|
780
|
+
category: "ci",
|
|
781
|
+
ciCritical,
|
|
782
|
+
description: "Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt).",
|
|
783
|
+
run(ctx) {
|
|
784
|
+
const glob = (p) => ctx.glob(p);
|
|
785
|
+
const workflowViolations = globFiles(glob, workflowGlobs).flatMap(
|
|
786
|
+
(file) => violationsFor(file, workflowImages(ctx.read(file) ?? ""), allowUnpinned)
|
|
787
|
+
);
|
|
788
|
+
const composeViolations = globFiles(glob, composeGlobs, skipDirs).flatMap(
|
|
789
|
+
(file) => violationsFor(file, composeImages(ctx.read(file) ?? ""), allowUnpinned)
|
|
790
|
+
);
|
|
791
|
+
return [...workflowViolations, ...composeViolations];
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
408
796
|
// src/rules/test-runner-segregation.ts
|
|
409
797
|
function createTestRunnerSegregationRule(options = {}) {
|
|
410
798
|
const bunPackageGlobs = options.bunPackageGlobs ?? ["packages/*/package.json"];
|
|
@@ -671,11 +1059,16 @@ function createWorkspaceGraphParityRule(options = {}) {
|
|
|
671
1059
|
var RULE_FACTORIES = {
|
|
672
1060
|
"agents-doc-presence": createAgentsDocPresenceRule,
|
|
673
1061
|
"canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
|
|
1062
|
+
"dockerfile-base-image-digest-pin": createDockerfileBaseImageDigestPinRule,
|
|
674
1063
|
"file-size-ratchet": createFileSizeRatchetRule,
|
|
1064
|
+
"github-actions-runner-pinned": createGithubActionsRunnerPinnedRule,
|
|
1065
|
+
"github-actions-sha-pinned": createGithubActionsShaPinnedRule,
|
|
675
1066
|
"layer-rank": createLayerRankRule,
|
|
676
1067
|
"no-cloned-component-folders": createNoClonedComponentFoldersRule,
|
|
677
1068
|
"no-warn-severity": createNoWarnSeverityRule,
|
|
678
1069
|
"package-shape": createPackageShapeRule,
|
|
1070
|
+
"security-scanner-version-parity": createSecurityScannerVersionParityRule,
|
|
1071
|
+
"service-image-digest-pin": createServiceImageDigestPinRule,
|
|
679
1072
|
"test-runner-segregation": createTestRunnerSegregationRule,
|
|
680
1073
|
"test-sibling-enforcement": createTestSiblingEnforcementRule,
|
|
681
1074
|
"test-workspace-enrollment": createTestWorkspaceEnrollmentRule,
|
|
@@ -693,11 +1086,16 @@ export {
|
|
|
693
1086
|
createAgentsDocPresenceRule,
|
|
694
1087
|
createAllRules,
|
|
695
1088
|
createCanonicalHelpersSingleHomeRule,
|
|
1089
|
+
createDockerfileBaseImageDigestPinRule,
|
|
696
1090
|
createFileSizeRatchetRule,
|
|
1091
|
+
createGithubActionsRunnerPinnedRule,
|
|
1092
|
+
createGithubActionsShaPinnedRule,
|
|
697
1093
|
createLayerRankRule,
|
|
698
1094
|
createNoClonedComponentFoldersRule,
|
|
699
1095
|
createNoWarnSeverityRule,
|
|
700
1096
|
createPackageShapeRule,
|
|
1097
|
+
createSecurityScannerVersionParityRule,
|
|
1098
|
+
createServiceImageDigestPinRule,
|
|
701
1099
|
createTestRunnerSegregationRule,
|
|
702
1100
|
createTestSiblingEnforcementRule,
|
|
703
1101
|
createTestWorkspaceEnrollmentRule,
|