@noctcore/lint-meta-rules 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,8 @@ over the whole catalog.
44
44
 
45
45
  13 nightcore lint-meta rules are ported as 12 factories — nightcore's `web-file-size-ratchet` and
46
46
  `engine-file-size-ratchet` were byte-identical logic and collapse into a single
47
- `createFileSizeRatchetRule` (instantiated once per capped area).
47
+ `createFileSizeRatchetRule` (instantiated once per capped area). Five CI-hygiene rules (category
48
+ `ci`) are ported from a production NestJS + Vite monorepo, one factory each, for 17 factories in all.
48
49
 
49
50
  | Factory | Source rule(s) | Category | What it enforces |
50
51
  | --- | --- | --- | --- |
@@ -60,6 +61,11 @@ over the whole catalog.
60
61
  | [`createUiPrimitiveShapeRule`](./docs/rules/ui-primitive-shape.md) | `ui-primitive-shape` | source-text | A folder primitive ships its proof siblings; a flat primitive carries none at the ui root. |
61
62
  | [`createTestWorkspaceEnrollmentRule`](./docs/rules/test-workspace-enrollment.md) | `test-workspace-enrollment` | testing | Every tested package is enumerated in the aggregate test script. |
62
63
  | [`createTestRunnerSegregationRule`](./docs/rules/test-runner-segregation.md) | `test-runner-segregation` | testing | Bun-side and foreign-side test runners are never mixed within a package. |
64
+ | [`createGithubActionsShaPinnedRule`](./docs/rules/github-actions-sha-pinned.md) | `github-actions-sha-pinned` | ci | Workflow `uses:` refs are pinned to a full commit SHA with a `# vN` comment. |
65
+ | [`createGithubActionsRunnerPinnedRule`](./docs/rules/github-actions-runner-pinned.md) | `github-actions-runner-pinned` | ci | Workflow jobs run on a named runner image, never a `*-latest` label. |
66
+ | [`createServiceImageDigestPinRule`](./docs/rules/service-image-digest-pin.md) | `service-image-digest-pin` | ci | Workflow service/container images and compose images are pinned by `@sha256:` digest. |
67
+ | [`createDockerfileBaseImageDigestPinRule`](./docs/rules/dockerfile-base-image-digest-pin.md) | `dockerfile-base-image-digest-pin` | ci | Dockerfile `FROM` base images are pinned by `@sha256:` digest. |
68
+ | [`createSecurityScannerVersionParityRule`](./docs/rules/security-scanner-version-parity.md) | `security-scanner-version-parity` | ci | CI and the local pre-push hook pin the same secret-scanner version, and the hook checks it at run time. |
63
69
 
64
70
  Every factory is callable with no arguments (all options default), so `createAllRules()` and
65
71
  per-factory defaults work out of the box; supply options to retarget a rule at your own repo.
package/dist/index.cjs CHANGED
@@ -26,11 +26,16 @@ __export(index_exports, {
26
26
  createAgentsDocPresenceRule: () => createAgentsDocPresenceRule,
27
27
  createAllRules: () => createAllRules,
28
28
  createCanonicalHelpersSingleHomeRule: () => createCanonicalHelpersSingleHomeRule,
29
+ createDockerfileBaseImageDigestPinRule: () => createDockerfileBaseImageDigestPinRule,
29
30
  createFileSizeRatchetRule: () => createFileSizeRatchetRule,
31
+ createGithubActionsRunnerPinnedRule: () => createGithubActionsRunnerPinnedRule,
32
+ createGithubActionsShaPinnedRule: () => createGithubActionsShaPinnedRule,
30
33
  createLayerRankRule: () => createLayerRankRule,
31
34
  createNoClonedComponentFoldersRule: () => createNoClonedComponentFoldersRule,
32
35
  createNoWarnSeverityRule: () => createNoWarnSeverityRule,
33
36
  createPackageShapeRule: () => createPackageShapeRule,
37
+ createSecurityScannerVersionParityRule: () => createSecurityScannerVersionParityRule,
38
+ createServiceImageDigestPinRule: () => createServiceImageDigestPinRule,
34
39
  createTestRunnerSegregationRule: () => createTestRunnerSegregationRule,
35
40
  createTestSiblingEnforcementRule: () => createTestSiblingEnforcementRule,
36
41
  createTestWorkspaceEnrollmentRule: () => createTestWorkspaceEnrollmentRule,
@@ -58,6 +63,36 @@ function countLines(text) {
58
63
  function recursiveGlobs(roots, extensions) {
59
64
  return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
60
65
  }
66
+ var DEFAULT_WORKFLOW_GLOBS = [
67
+ ".github/workflows/*.yml",
68
+ ".github/workflows/*.yaml"
69
+ ];
70
+ var DEFAULT_SKIP_DIRS = [
71
+ "node_modules",
72
+ ".git",
73
+ "dist",
74
+ ".turbo",
75
+ "coverage"
76
+ ];
77
+ function globFiles(glob, globs, skipDirs = []) {
78
+ const skip = new Set(skipDirs);
79
+ const found = /* @__PURE__ */ new Set();
80
+ for (const pattern of globs) {
81
+ for (const rel of glob(pattern)) {
82
+ if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
83
+ }
84
+ }
85
+ return [...found].sort();
86
+ }
87
+ function stripYamlComment(line) {
88
+ return line.replace(/(^|\s)#.*$/u, "");
89
+ }
90
+ function unquote(value) {
91
+ return value.trim().replace(/^(['"])(.*)\1$/u, "$2");
92
+ }
93
+ function anywhereGlobs(baseNames) {
94
+ return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
95
+ }
61
96
 
62
97
  // src/rules/agents-doc-presence.ts
63
98
  function createAgentsDocPresenceRule(options = {}) {
@@ -152,6 +187,63 @@ function createCanonicalHelpersSingleHomeRule(options = {}) {
152
187
  };
153
188
  }
154
189
 
190
+ // src/rules/dockerfile-base-image-digest-pin.ts
191
+ var RULE_ID = "dockerfile-base-image-digest-pin";
192
+ var DEFAULT_DOCKERFILE_GLOBS = anywhereGlobs(["Dockerfile", "Dockerfile.*", "*.Dockerfile"]);
193
+ var FROM_LINE = /^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?\s*$/iu;
194
+ var ARG_DEFAULT = /^\s*ARG\s+(\w+)=(\S+)\s*$/iu;
195
+ var DIGEST = /@sha256:[0-9a-f]{64}$/u;
196
+ function checkDockerfileBases(file, text) {
197
+ const violations = [];
198
+ const stages = /* @__PURE__ */ new Set();
199
+ const argDefaults = /* @__PURE__ */ new Map();
200
+ let seenFrom = false;
201
+ text.split("\n").forEach((line, index) => {
202
+ const arg = ARG_DEFAULT.exec(line);
203
+ if (arg?.[1] !== void 0 && arg[2] !== void 0 && !seenFrom) {
204
+ argDefaults.set(arg[1], arg[2].replace(/^(['"])(.*)\1$/u, "$2"));
205
+ }
206
+ const from = FROM_LINE.exec(line);
207
+ if (from?.[1] === void 0) {
208
+ return;
209
+ }
210
+ seenFrom = true;
211
+ const written = from[1];
212
+ const variable = /^\$\{?(\w+)\}?$/u.exec(written)?.[1];
213
+ const image = variable === void 0 ? written : argDefaults.get(variable) ?? written;
214
+ const isExempt = image.toLowerCase() === "scratch" || stages.has(image.toLowerCase());
215
+ if (from[2] !== void 0) {
216
+ stages.add(from[2].toLowerCase());
217
+ }
218
+ if (!isExempt && !DIGEST.test(image)) {
219
+ const reference = image.split("@")[0];
220
+ violations.push({
221
+ file,
222
+ rule: RULE_ID,
223
+ 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}\`).`,
224
+ line: index + 1
225
+ });
226
+ }
227
+ });
228
+ return violations;
229
+ }
230
+ function createDockerfileBaseImageDigestPinRule(options = {}) {
231
+ const dockerfileGlobs = options.dockerfileGlobs ?? DEFAULT_DOCKERFILE_GLOBS;
232
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
233
+ const ciCritical = options.ciCritical ?? true;
234
+ return {
235
+ id: RULE_ID,
236
+ category: "ci",
237
+ ciCritical,
238
+ description: "Dockerfile FROM base images must be pinned by `@sha256:` digest (scratch and earlier build stages exempt).",
239
+ run(ctx) {
240
+ return globFiles((p) => ctx.glob(p), dockerfileGlobs, skipDirs).flatMap(
241
+ (file) => checkDockerfileBases(file, ctx.read(file) ?? "")
242
+ );
243
+ }
244
+ };
245
+ }
246
+
155
247
  // src/rules/file-size-ratchet.ts
156
248
  var import_harness = require("@noctcore/harness");
157
249
  var DEFAULT_EXCLUDE_CONTAINS = [".test.", ".spec.", ".stories."];
@@ -234,6 +326,121 @@ function createFileSizeRatchetRule(options = {}) {
234
326
  };
235
327
  }
236
328
 
329
+ // src/rules/github-actions-runner-pinned.ts
330
+ var RULE_ID2 = "github-actions-runner-pinned";
331
+ var RUNS_ON = /^(\s*)runs-on:\s*(.*)$/u;
332
+ var DEFAULT_FLOATING_LABEL = /^[\w.-]+-latest$/u;
333
+ function runnerLabels(lines, start, indent) {
334
+ const inline = stripYamlComment(RUNS_ON.exec(lines[start] ?? "")?.[2] ?? "").trim();
335
+ const collected = [inline];
336
+ if (inline === "") {
337
+ for (const line of lines.slice(start + 1)) {
338
+ const text = stripYamlComment(line);
339
+ if (text.trim() === "") {
340
+ continue;
341
+ }
342
+ if ((/^\s*/u.exec(text)?.[0].length ?? 0) <= indent) {
343
+ break;
344
+ }
345
+ collected.push(text.replace(/^\s*(?:-\s+|labels:\s*)/u, ""));
346
+ }
347
+ }
348
+ return collected.filter((value) => !value.includes("${{")).flatMap((value) => value.split(/[\s,[\]'"]+/u)).filter((label) => label !== "");
349
+ }
350
+ function checkWorkflowRunnersPinned(file, text, floatingLabel = DEFAULT_FLOATING_LABEL) {
351
+ const violations = [];
352
+ const lines = text.split("\n");
353
+ lines.forEach((line, index) => {
354
+ const match = RUNS_ON.exec(line);
355
+ if (match === null || line.trimStart().startsWith("#")) {
356
+ return;
357
+ }
358
+ for (const label of runnerLabels(lines, index, match[1]?.length ?? 0)) {
359
+ if (floatingLabel.test(label)) {
360
+ violations.push({
361
+ file,
362
+ rule: RULE_ID2,
363
+ 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.`,
364
+ line: index + 1
365
+ });
366
+ }
367
+ }
368
+ });
369
+ return violations;
370
+ }
371
+ function createGithubActionsRunnerPinnedRule(options = {}) {
372
+ const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
373
+ const floatingLabel = options.floatingLabel ?? DEFAULT_FLOATING_LABEL;
374
+ const ciCritical = options.ciCritical ?? true;
375
+ return {
376
+ id: RULE_ID2,
377
+ category: "ci",
378
+ ciCritical,
379
+ description: "GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label.",
380
+ run(ctx) {
381
+ return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
382
+ (file) => checkWorkflowRunnersPinned(file, ctx.read(file) ?? "", floatingLabel)
383
+ );
384
+ }
385
+ };
386
+ }
387
+
388
+ // src/rules/github-actions-sha-pinned.ts
389
+ var RULE_ID3 = "github-actions-sha-pinned";
390
+ var USES_LINE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)['"]?\s*(#.*)?$/u;
391
+ var FULL_SHA = /^[0-9a-f]{40}$/u;
392
+ var DOCKER_DIGEST = /@sha256:[0-9a-f]{64}$/u;
393
+ var VERSION_COMMENT = /^#\s*v\d/u;
394
+ function checkWorkflowActionsPinned(file, text) {
395
+ const violations = [];
396
+ text.split("\n").forEach((line, index) => {
397
+ const match = USES_LINE.exec(line);
398
+ const ref = match?.[1];
399
+ if (ref === void 0 || ref.startsWith("./")) {
400
+ return;
401
+ }
402
+ const where = `line ${index + 1}`;
403
+ const report = (message) => {
404
+ violations.push({ file, rule: RULE_ID3, message, line: index + 1 });
405
+ };
406
+ if (ref.startsWith("docker://")) {
407
+ if (!DOCKER_DIGEST.test(ref)) {
408
+ report(`${where}: \`uses: ${ref}\` is not pinned by digest. Append \`@sha256:<digest>\`.`);
409
+ }
410
+ return;
411
+ }
412
+ const at = ref.lastIndexOf("@");
413
+ const pin = at === -1 ? "" : ref.slice(at + 1);
414
+ if (!FULL_SHA.test(pin)) {
415
+ report(
416
+ `${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\`.`
417
+ );
418
+ return;
419
+ }
420
+ if (!VERSION_COMMENT.test(match?.[2] ?? "")) {
421
+ report(
422
+ `${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.`
423
+ );
424
+ }
425
+ });
426
+ return violations;
427
+ }
428
+ function createGithubActionsShaPinnedRule(options = {}) {
429
+ const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
430
+ const ciCritical = options.ciCritical ?? true;
431
+ return {
432
+ id: RULE_ID3,
433
+ category: "ci",
434
+ ciCritical,
435
+ description: "GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt).",
436
+ run(ctx) {
437
+ return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
438
+ (file) => checkWorkflowActionsPinned(file, ctx.read(file) ?? "")
439
+ );
440
+ }
441
+ };
442
+ }
443
+
237
444
  // src/rules/layer-rank.ts
238
445
  var DEFAULT_SOURCE_GLOBS = [
239
446
  "packages/*/src/**/*.ts",
@@ -446,6 +653,228 @@ function createPackageShapeRule(options = {}) {
446
653
  };
447
654
  }
448
655
 
656
+ // src/rules/security-scanner-version-parity.ts
657
+ var RULE_ID4 = "security-scanner-version-parity";
658
+ function createSecurityScannerVersionParityRule(options = {}) {
659
+ const scanner = options.scanner ?? "gitleaks";
660
+ const versionVariable = options.versionVariable ?? "GITLEAKS_VERSION";
661
+ const imageVariable = options.imageVariable ?? "GITLEAKS_IMAGE";
662
+ const hookFile = options.hookFile ?? "scripts/ci/pre-push.sh";
663
+ const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
664
+ const ciCritical = options.ciCritical ?? true;
665
+ const name = escapeRegExp(scanner);
666
+ const versionVar = escapeRegExp(versionVariable);
667
+ const workflowVersion = new RegExp(
668
+ `^\\s*${versionVar}:\\s*['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
669
+ "mu"
670
+ );
671
+ const hookVersion = new RegExp(
672
+ `^\\s*(?:readonly\\s+)?${versionVar}=['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
673
+ "mu"
674
+ );
675
+ const hookImageTag = new RegExp(
676
+ `^\\s*${escapeRegExp(imageVariable)}=['"][^'"\\n]*${name}:v(\\d+\\.\\d+\\.\\d+)['"]`,
677
+ "mu"
678
+ );
679
+ const runtimeCheck = new RegExp(`\\b${name}\\s+version\\b`, "u");
680
+ const mentionsScanner = new RegExp(name, "iu");
681
+ return {
682
+ id: RULE_ID4,
683
+ category: "ci",
684
+ ciCritical,
685
+ 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.`,
686
+ run(ctx) {
687
+ const hookText = ctx.read(hookFile) ?? "";
688
+ const ciVersions = /* @__PURE__ */ new Map();
689
+ for (const file of globFiles((p) => ctx.glob(p), workflowGlobs)) {
690
+ const version = workflowVersion.exec(ctx.read(file) ?? "")?.[1];
691
+ if (version !== void 0) {
692
+ ciVersions.set(version, [...ciVersions.get(version) ?? [], file]);
693
+ }
694
+ }
695
+ const hookUsesScanner = mentionsScanner.test(hookText);
696
+ if (ciVersions.size === 0 && !hookUsesScanner) {
697
+ return [];
698
+ }
699
+ const violations = [];
700
+ const report = (file, message) => {
701
+ violations.push({ file, rule: RULE_ID4, message });
702
+ };
703
+ if (ciVersions.size > 1) {
704
+ report(
705
+ [...ciVersions.values()].flat()[0] ?? hookFile,
706
+ `Workflows pin different ${scanner} versions (${[...ciVersions.keys()].join(", ")}). Use one \`${versionVariable}\` everywhere.`
707
+ );
708
+ }
709
+ const ciVersion = ciVersions.size === 1 ? [...ciVersions.keys()][0] : void 0;
710
+ if (ciVersions.size === 0) {
711
+ report(
712
+ hookFile,
713
+ `${hookFile} runs ${scanner} but no workflow pins \`${versionVariable}\`, so CI's scanner version is unknown. Pin it in the workflow env.`
714
+ );
715
+ }
716
+ if (!hookUsesScanner) {
717
+ report(
718
+ hookFile,
719
+ `Workflows pin ${scanner} ${ciVersion ?? ""} but ${hookFile} does not run ${scanner}, so the local hook cannot agree with CI.`
720
+ );
721
+ return violations;
722
+ }
723
+ const declared = hookVersion.exec(hookText)?.[1];
724
+ if (declared === void 0) {
725
+ report(
726
+ hookFile,
727
+ `${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.`
728
+ );
729
+ } else if (ciVersion !== void 0 && declared !== ciVersion) {
730
+ report(
731
+ hookFile,
732
+ `${hookFile} pins ${scanner} ${declared} but CI pins ${ciVersion}. Bump them together.`
733
+ );
734
+ }
735
+ const imageTag = hookImageTag.exec(hookText)?.[1];
736
+ if (imageTag !== void 0 && ciVersion !== void 0 && imageTag !== ciVersion) {
737
+ report(
738
+ hookFile,
739
+ `${hookFile} \`${imageVariable}\` is tagged v${imageTag} but CI pins ${ciVersion}. Derive the tag from \`${versionVariable}\` or bump it.`
740
+ );
741
+ }
742
+ if (!runtimeCheck.test(hookText)) {
743
+ report(
744
+ hookFile,
745
+ `${hookFile} never runs \`${scanner} version\`. A native ${scanner} of another version scans with another ruleset: compare it with \`${versionVariable}\` and refuse a mismatch.`
746
+ );
747
+ }
748
+ return violations;
749
+ }
750
+ };
751
+ }
752
+
753
+ // src/rules/service-image-digest-pin.ts
754
+ var RULE_ID5 = "service-image-digest-pin";
755
+ var DIGEST2 = /@sha256:[0-9a-f]{64}$/u;
756
+ var KEY_LINE = /^(\s*)(?:-\s+)?([\w-]+):\s*(.*)$/u;
757
+ var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
758
+ ["compose", "docker-compose"].flatMap(
759
+ (stem) => ["", ".*", "-*"].flatMap((infix) => ["yml", "yaml"].map((ext) => `${stem}${infix}.${ext}`))
760
+ )
761
+ );
762
+ function indentOf(line) {
763
+ return /^\s*/u.exec(line)?.[0].length ?? 0;
764
+ }
765
+ function workflowImages(text) {
766
+ const found = [];
767
+ const stack = [];
768
+ text.split("\n").forEach((line, index) => {
769
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
770
+ return;
771
+ }
772
+ const match = KEY_LINE.exec(line);
773
+ if (match === null) {
774
+ return;
775
+ }
776
+ const indent = match[1]?.length ?? 0;
777
+ const key = match[2] ?? "";
778
+ const value = unquote(stripYamlComment(match[3] ?? "").trim());
779
+ while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) {
780
+ stack.pop();
781
+ }
782
+ const insideService = stack.some((entry) => entry.key === "services");
783
+ const isContainerScalar = key === "container" && value !== "";
784
+ const isImageKey = key === "image" && value !== "";
785
+ const insideContainer = stack.some((entry) => entry.key === "container");
786
+ if (isContainerScalar || isImageKey && (insideService || insideContainer)) {
787
+ found.push({ line: index + 1, image: value });
788
+ }
789
+ stack.push({ indent, key });
790
+ });
791
+ return found;
792
+ }
793
+ function composeImages(text) {
794
+ const lines = text.split("\n");
795
+ const found = [];
796
+ const servicesAt = lines.findIndex((line) => /^services:\s*(?:#.*)?$/u.test(line));
797
+ if (servicesAt === -1) {
798
+ return found;
799
+ }
800
+ let serviceIndent = null;
801
+ let propertyIndent = null;
802
+ let current = null;
803
+ const flush = () => {
804
+ if (current !== null && !current.hasBuild) {
805
+ found.push(...current.images);
806
+ }
807
+ current = null;
808
+ };
809
+ for (let index = servicesAt + 1; index < lines.length; index += 1) {
810
+ const line = lines[index] ?? "";
811
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
812
+ continue;
813
+ }
814
+ const indent = indentOf(line);
815
+ if (indent === 0) {
816
+ break;
817
+ }
818
+ serviceIndent ??= indent;
819
+ const match = KEY_LINE.exec(line);
820
+ if (match === null) {
821
+ continue;
822
+ }
823
+ if (indent === serviceIndent) {
824
+ flush();
825
+ current = { images: [], hasBuild: false };
826
+ propertyIndent = null;
827
+ continue;
828
+ }
829
+ propertyIndent ??= indent;
830
+ if (current === null || indent !== propertyIndent) {
831
+ continue;
832
+ }
833
+ const value = unquote(stripYamlComment(match[3] ?? "").trim());
834
+ if (match[2] === "build") {
835
+ current.hasBuild = true;
836
+ } else if (match[2] === "image" && value !== "") {
837
+ current.images.push({ line: index + 1, image: value });
838
+ }
839
+ }
840
+ flush();
841
+ return found;
842
+ }
843
+ function violationsFor(file, images, allowUnpinned) {
844
+ return images.filter(({ image }) => !DIGEST2.test(image) && !allowUnpinned.has(image)).map(({ line, image }) => {
845
+ const reference = image.split("@")[0];
846
+ return {
847
+ file,
848
+ rule: RULE_ID5,
849
+ 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.`,
850
+ line
851
+ };
852
+ });
853
+ }
854
+ function createServiceImageDigestPinRule(options = {}) {
855
+ const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
856
+ const composeGlobs = options.composeGlobs ?? DEFAULT_COMPOSE_GLOBS;
857
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
858
+ const allowUnpinned = new Set(options.allowUnpinned ?? []);
859
+ const ciCritical = options.ciCritical ?? true;
860
+ return {
861
+ id: RULE_ID5,
862
+ category: "ci",
863
+ ciCritical,
864
+ description: "Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt).",
865
+ run(ctx) {
866
+ const glob = (p) => ctx.glob(p);
867
+ const workflowViolations = globFiles(glob, workflowGlobs).flatMap(
868
+ (file) => violationsFor(file, workflowImages(ctx.read(file) ?? ""), allowUnpinned)
869
+ );
870
+ const composeViolations = globFiles(glob, composeGlobs, skipDirs).flatMap(
871
+ (file) => violationsFor(file, composeImages(ctx.read(file) ?? ""), allowUnpinned)
872
+ );
873
+ return [...workflowViolations, ...composeViolations];
874
+ }
875
+ };
876
+ }
877
+
449
878
  // src/rules/test-runner-segregation.ts
450
879
  function createTestRunnerSegregationRule(options = {}) {
451
880
  const bunPackageGlobs = options.bunPackageGlobs ?? ["packages/*/package.json"];
@@ -712,11 +1141,16 @@ function createWorkspaceGraphParityRule(options = {}) {
712
1141
  var RULE_FACTORIES = {
713
1142
  "agents-doc-presence": createAgentsDocPresenceRule,
714
1143
  "canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
1144
+ "dockerfile-base-image-digest-pin": createDockerfileBaseImageDigestPinRule,
715
1145
  "file-size-ratchet": createFileSizeRatchetRule,
1146
+ "github-actions-runner-pinned": createGithubActionsRunnerPinnedRule,
1147
+ "github-actions-sha-pinned": createGithubActionsShaPinnedRule,
716
1148
  "layer-rank": createLayerRankRule,
717
1149
  "no-cloned-component-folders": createNoClonedComponentFoldersRule,
718
1150
  "no-warn-severity": createNoWarnSeverityRule,
719
1151
  "package-shape": createPackageShapeRule,
1152
+ "security-scanner-version-parity": createSecurityScannerVersionParityRule,
1153
+ "service-image-digest-pin": createServiceImageDigestPinRule,
720
1154
  "test-runner-segregation": createTestRunnerSegregationRule,
721
1155
  "test-sibling-enforcement": createTestSiblingEnforcementRule,
722
1156
  "test-workspace-enrollment": createTestWorkspaceEnrollmentRule,
@@ -735,11 +1169,16 @@ function createAllRules() {
735
1169
  createAgentsDocPresenceRule,
736
1170
  createAllRules,
737
1171
  createCanonicalHelpersSingleHomeRule,
1172
+ createDockerfileBaseImageDigestPinRule,
738
1173
  createFileSizeRatchetRule,
1174
+ createGithubActionsRunnerPinnedRule,
1175
+ createGithubActionsShaPinnedRule,
739
1176
  createLayerRankRule,
740
1177
  createNoClonedComponentFoldersRule,
741
1178
  createNoWarnSeverityRule,
742
1179
  createPackageShapeRule,
1180
+ createSecurityScannerVersionParityRule,
1181
+ createServiceImageDigestPinRule,
743
1182
  createTestRunnerSegregationRule,
744
1183
  createTestSiblingEnforcementRule,
745
1184
  createTestWorkspaceEnrollmentRule,
package/dist/index.d.cts 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 };