@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/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
- // src/rules/shared.ts
2
- function dirOf(rel, basename = "package.json") {
3
- return rel.replace(new RegExp(`/${escapeRegExp(basename)}$`), "");
4
- }
5
- function baseName(pathLike) {
6
- const parts = pathLike.split("/").filter(Boolean);
7
- return parts[parts.length - 1] ?? pathLike;
8
- }
9
- function escapeRegExp(value) {
10
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
- }
12
- function countLines(text) {
13
- const lines = text.split("\n");
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,