@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 +7 -1
- package/dist/index.cjs +439 -0
- package/dist/index.d.cts +127 -1
- package/dist/index.d.ts +127 -1
- package/dist/index.js +434 -0
- 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/package.json +2 -2
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
|
@@ -17,6 +17,36 @@ function countLines(text) {
|
|
|
17
17
|
function recursiveGlobs(roots, extensions) {
|
|
18
18
|
return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
|
|
19
19
|
}
|
|
20
|
+
var DEFAULT_WORKFLOW_GLOBS = [
|
|
21
|
+
".github/workflows/*.yml",
|
|
22
|
+
".github/workflows/*.yaml"
|
|
23
|
+
];
|
|
24
|
+
var DEFAULT_SKIP_DIRS = [
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"dist",
|
|
28
|
+
".turbo",
|
|
29
|
+
"coverage"
|
|
30
|
+
];
|
|
31
|
+
function globFiles(glob, globs, skipDirs = []) {
|
|
32
|
+
const skip = new Set(skipDirs);
|
|
33
|
+
const found = /* @__PURE__ */ new Set();
|
|
34
|
+
for (const pattern of globs) {
|
|
35
|
+
for (const rel of glob(pattern)) {
|
|
36
|
+
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...found].sort();
|
|
40
|
+
}
|
|
41
|
+
function stripYamlComment(line) {
|
|
42
|
+
return line.replace(/(^|\s)#.*$/u, "");
|
|
43
|
+
}
|
|
44
|
+
function unquote(value) {
|
|
45
|
+
return value.trim().replace(/^(['"])(.*)\1$/u, "$2");
|
|
46
|
+
}
|
|
47
|
+
function anywhereGlobs(baseNames) {
|
|
48
|
+
return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
|
|
49
|
+
}
|
|
20
50
|
|
|
21
51
|
// src/rules/agents-doc-presence.ts
|
|
22
52
|
function createAgentsDocPresenceRule(options = {}) {
|
|
@@ -111,6 +141,63 @@ function createCanonicalHelpersSingleHomeRule(options = {}) {
|
|
|
111
141
|
};
|
|
112
142
|
}
|
|
113
143
|
|
|
144
|
+
// src/rules/dockerfile-base-image-digest-pin.ts
|
|
145
|
+
var RULE_ID = "dockerfile-base-image-digest-pin";
|
|
146
|
+
var DEFAULT_DOCKERFILE_GLOBS = anywhereGlobs(["Dockerfile", "Dockerfile.*", "*.Dockerfile"]);
|
|
147
|
+
var FROM_LINE = /^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?\s*$/iu;
|
|
148
|
+
var ARG_DEFAULT = /^\s*ARG\s+(\w+)=(\S+)\s*$/iu;
|
|
149
|
+
var DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
150
|
+
function checkDockerfileBases(file, text) {
|
|
151
|
+
const violations = [];
|
|
152
|
+
const stages = /* @__PURE__ */ new Set();
|
|
153
|
+
const argDefaults = /* @__PURE__ */ new Map();
|
|
154
|
+
let seenFrom = false;
|
|
155
|
+
text.split("\n").forEach((line, index) => {
|
|
156
|
+
const arg = ARG_DEFAULT.exec(line);
|
|
157
|
+
if (arg?.[1] !== void 0 && arg[2] !== void 0 && !seenFrom) {
|
|
158
|
+
argDefaults.set(arg[1], arg[2].replace(/^(['"])(.*)\1$/u, "$2"));
|
|
159
|
+
}
|
|
160
|
+
const from = FROM_LINE.exec(line);
|
|
161
|
+
if (from?.[1] === void 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
seenFrom = true;
|
|
165
|
+
const written = from[1];
|
|
166
|
+
const variable = /^\$\{?(\w+)\}?$/u.exec(written)?.[1];
|
|
167
|
+
const image = variable === void 0 ? written : argDefaults.get(variable) ?? written;
|
|
168
|
+
const isExempt = image.toLowerCase() === "scratch" || stages.has(image.toLowerCase());
|
|
169
|
+
if (from[2] !== void 0) {
|
|
170
|
+
stages.add(from[2].toLowerCase());
|
|
171
|
+
}
|
|
172
|
+
if (!isExempt && !DIGEST.test(image)) {
|
|
173
|
+
const reference = image.split("@")[0];
|
|
174
|
+
violations.push({
|
|
175
|
+
file,
|
|
176
|
+
rule: RULE_ID,
|
|
177
|
+
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}\`).`,
|
|
178
|
+
line: index + 1
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return violations;
|
|
183
|
+
}
|
|
184
|
+
function createDockerfileBaseImageDigestPinRule(options = {}) {
|
|
185
|
+
const dockerfileGlobs = options.dockerfileGlobs ?? DEFAULT_DOCKERFILE_GLOBS;
|
|
186
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
187
|
+
const ciCritical = options.ciCritical ?? true;
|
|
188
|
+
return {
|
|
189
|
+
id: RULE_ID,
|
|
190
|
+
category: "ci",
|
|
191
|
+
ciCritical,
|
|
192
|
+
description: "Dockerfile FROM base images must be pinned by `@sha256:` digest (scratch and earlier build stages exempt).",
|
|
193
|
+
run(ctx) {
|
|
194
|
+
return globFiles((p) => ctx.glob(p), dockerfileGlobs, skipDirs).flatMap(
|
|
195
|
+
(file) => checkDockerfileBases(file, ctx.read(file) ?? "")
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
114
201
|
// src/rules/file-size-ratchet.ts
|
|
115
202
|
import { DEFAULT_BASELINE_DIR, isGrandfathered, loadBaseline } from "@noctcore/harness";
|
|
116
203
|
var DEFAULT_EXCLUDE_CONTAINS = [".test.", ".spec.", ".stories."];
|
|
@@ -193,6 +280,121 @@ function createFileSizeRatchetRule(options = {}) {
|
|
|
193
280
|
};
|
|
194
281
|
}
|
|
195
282
|
|
|
283
|
+
// src/rules/github-actions-runner-pinned.ts
|
|
284
|
+
var RULE_ID2 = "github-actions-runner-pinned";
|
|
285
|
+
var RUNS_ON = /^(\s*)runs-on:\s*(.*)$/u;
|
|
286
|
+
var DEFAULT_FLOATING_LABEL = /^[\w.-]+-latest$/u;
|
|
287
|
+
function runnerLabels(lines, start, indent) {
|
|
288
|
+
const inline = stripYamlComment(RUNS_ON.exec(lines[start] ?? "")?.[2] ?? "").trim();
|
|
289
|
+
const collected = [inline];
|
|
290
|
+
if (inline === "") {
|
|
291
|
+
for (const line of lines.slice(start + 1)) {
|
|
292
|
+
const text = stripYamlComment(line);
|
|
293
|
+
if (text.trim() === "") {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if ((/^\s*/u.exec(text)?.[0].length ?? 0) <= indent) {
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
collected.push(text.replace(/^\s*(?:-\s+|labels:\s*)/u, ""));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return collected.filter((value) => !value.includes("${{")).flatMap((value) => value.split(/[\s,[\]'"]+/u)).filter((label) => label !== "");
|
|
303
|
+
}
|
|
304
|
+
function checkWorkflowRunnersPinned(file, text, floatingLabel = DEFAULT_FLOATING_LABEL) {
|
|
305
|
+
const violations = [];
|
|
306
|
+
const lines = text.split("\n");
|
|
307
|
+
lines.forEach((line, index) => {
|
|
308
|
+
const match = RUNS_ON.exec(line);
|
|
309
|
+
if (match === null || line.trimStart().startsWith("#")) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
for (const label of runnerLabels(lines, index, match[1]?.length ?? 0)) {
|
|
313
|
+
if (floatingLabel.test(label)) {
|
|
314
|
+
violations.push({
|
|
315
|
+
file,
|
|
316
|
+
rule: RULE_ID2,
|
|
317
|
+
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.`,
|
|
318
|
+
line: index + 1
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return violations;
|
|
324
|
+
}
|
|
325
|
+
function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
326
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
327
|
+
const floatingLabel = options.floatingLabel ?? DEFAULT_FLOATING_LABEL;
|
|
328
|
+
const ciCritical = options.ciCritical ?? true;
|
|
329
|
+
return {
|
|
330
|
+
id: RULE_ID2,
|
|
331
|
+
category: "ci",
|
|
332
|
+
ciCritical,
|
|
333
|
+
description: "GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label.",
|
|
334
|
+
run(ctx) {
|
|
335
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
336
|
+
(file) => checkWorkflowRunnersPinned(file, ctx.read(file) ?? "", floatingLabel)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/rules/github-actions-sha-pinned.ts
|
|
343
|
+
var RULE_ID3 = "github-actions-sha-pinned";
|
|
344
|
+
var USES_LINE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)['"]?\s*(#.*)?$/u;
|
|
345
|
+
var FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
346
|
+
var DOCKER_DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
347
|
+
var VERSION_COMMENT = /^#\s*v\d/u;
|
|
348
|
+
function checkWorkflowActionsPinned(file, text) {
|
|
349
|
+
const violations = [];
|
|
350
|
+
text.split("\n").forEach((line, index) => {
|
|
351
|
+
const match = USES_LINE.exec(line);
|
|
352
|
+
const ref = match?.[1];
|
|
353
|
+
if (ref === void 0 || ref.startsWith("./")) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const where = `line ${index + 1}`;
|
|
357
|
+
const report = (message) => {
|
|
358
|
+
violations.push({ file, rule: RULE_ID3, message, line: index + 1 });
|
|
359
|
+
};
|
|
360
|
+
if (ref.startsWith("docker://")) {
|
|
361
|
+
if (!DOCKER_DIGEST.test(ref)) {
|
|
362
|
+
report(`${where}: \`uses: ${ref}\` is not pinned by digest. Append \`@sha256:<digest>\`.`);
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const at = ref.lastIndexOf("@");
|
|
367
|
+
const pin = at === -1 ? "" : ref.slice(at + 1);
|
|
368
|
+
if (!FULL_SHA.test(pin)) {
|
|
369
|
+
report(
|
|
370
|
+
`${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\`.`
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!VERSION_COMMENT.test(match?.[2] ?? "")) {
|
|
375
|
+
report(
|
|
376
|
+
`${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.`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
return violations;
|
|
381
|
+
}
|
|
382
|
+
function createGithubActionsShaPinnedRule(options = {}) {
|
|
383
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
384
|
+
const ciCritical = options.ciCritical ?? true;
|
|
385
|
+
return {
|
|
386
|
+
id: RULE_ID3,
|
|
387
|
+
category: "ci",
|
|
388
|
+
ciCritical,
|
|
389
|
+
description: "GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt).",
|
|
390
|
+
run(ctx) {
|
|
391
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
392
|
+
(file) => checkWorkflowActionsPinned(file, ctx.read(file) ?? "")
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
196
398
|
// src/rules/layer-rank.ts
|
|
197
399
|
var DEFAULT_SOURCE_GLOBS = [
|
|
198
400
|
"packages/*/src/**/*.ts",
|
|
@@ -405,6 +607,228 @@ function createPackageShapeRule(options = {}) {
|
|
|
405
607
|
};
|
|
406
608
|
}
|
|
407
609
|
|
|
610
|
+
// src/rules/security-scanner-version-parity.ts
|
|
611
|
+
var RULE_ID4 = "security-scanner-version-parity";
|
|
612
|
+
function createSecurityScannerVersionParityRule(options = {}) {
|
|
613
|
+
const scanner = options.scanner ?? "gitleaks";
|
|
614
|
+
const versionVariable = options.versionVariable ?? "GITLEAKS_VERSION";
|
|
615
|
+
const imageVariable = options.imageVariable ?? "GITLEAKS_IMAGE";
|
|
616
|
+
const hookFile = options.hookFile ?? "scripts/ci/pre-push.sh";
|
|
617
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
618
|
+
const ciCritical = options.ciCritical ?? true;
|
|
619
|
+
const name = escapeRegExp(scanner);
|
|
620
|
+
const versionVar = escapeRegExp(versionVariable);
|
|
621
|
+
const workflowVersion = new RegExp(
|
|
622
|
+
`^\\s*${versionVar}:\\s*['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
|
|
623
|
+
"mu"
|
|
624
|
+
);
|
|
625
|
+
const hookVersion = new RegExp(
|
|
626
|
+
`^\\s*(?:readonly\\s+)?${versionVar}=['"]?(\\d+\\.\\d+\\.\\d+)['"]?\\s*(?:#.*)?$`,
|
|
627
|
+
"mu"
|
|
628
|
+
);
|
|
629
|
+
const hookImageTag = new RegExp(
|
|
630
|
+
`^\\s*${escapeRegExp(imageVariable)}=['"][^'"\\n]*${name}:v(\\d+\\.\\d+\\.\\d+)['"]`,
|
|
631
|
+
"mu"
|
|
632
|
+
);
|
|
633
|
+
const runtimeCheck = new RegExp(`\\b${name}\\s+version\\b`, "u");
|
|
634
|
+
const mentionsScanner = new RegExp(name, "iu");
|
|
635
|
+
return {
|
|
636
|
+
id: RULE_ID4,
|
|
637
|
+
category: "ci",
|
|
638
|
+
ciCritical,
|
|
639
|
+
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.`,
|
|
640
|
+
run(ctx) {
|
|
641
|
+
const hookText = ctx.read(hookFile) ?? "";
|
|
642
|
+
const ciVersions = /* @__PURE__ */ new Map();
|
|
643
|
+
for (const file of globFiles((p) => ctx.glob(p), workflowGlobs)) {
|
|
644
|
+
const version = workflowVersion.exec(ctx.read(file) ?? "")?.[1];
|
|
645
|
+
if (version !== void 0) {
|
|
646
|
+
ciVersions.set(version, [...ciVersions.get(version) ?? [], file]);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const hookUsesScanner = mentionsScanner.test(hookText);
|
|
650
|
+
if (ciVersions.size === 0 && !hookUsesScanner) {
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
const violations = [];
|
|
654
|
+
const report = (file, message) => {
|
|
655
|
+
violations.push({ file, rule: RULE_ID4, message });
|
|
656
|
+
};
|
|
657
|
+
if (ciVersions.size > 1) {
|
|
658
|
+
report(
|
|
659
|
+
[...ciVersions.values()].flat()[0] ?? hookFile,
|
|
660
|
+
`Workflows pin different ${scanner} versions (${[...ciVersions.keys()].join(", ")}). Use one \`${versionVariable}\` everywhere.`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
const ciVersion = ciVersions.size === 1 ? [...ciVersions.keys()][0] : void 0;
|
|
664
|
+
if (ciVersions.size === 0) {
|
|
665
|
+
report(
|
|
666
|
+
hookFile,
|
|
667
|
+
`${hookFile} runs ${scanner} but no workflow pins \`${versionVariable}\`, so CI's scanner version is unknown. Pin it in the workflow env.`
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (!hookUsesScanner) {
|
|
671
|
+
report(
|
|
672
|
+
hookFile,
|
|
673
|
+
`Workflows pin ${scanner} ${ciVersion ?? ""} but ${hookFile} does not run ${scanner}, so the local hook cannot agree with CI.`
|
|
674
|
+
);
|
|
675
|
+
return violations;
|
|
676
|
+
}
|
|
677
|
+
const declared = hookVersion.exec(hookText)?.[1];
|
|
678
|
+
if (declared === void 0) {
|
|
679
|
+
report(
|
|
680
|
+
hookFile,
|
|
681
|
+
`${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.`
|
|
682
|
+
);
|
|
683
|
+
} else if (ciVersion !== void 0 && declared !== ciVersion) {
|
|
684
|
+
report(
|
|
685
|
+
hookFile,
|
|
686
|
+
`${hookFile} pins ${scanner} ${declared} but CI pins ${ciVersion}. Bump them together.`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const imageTag = hookImageTag.exec(hookText)?.[1];
|
|
690
|
+
if (imageTag !== void 0 && ciVersion !== void 0 && imageTag !== ciVersion) {
|
|
691
|
+
report(
|
|
692
|
+
hookFile,
|
|
693
|
+
`${hookFile} \`${imageVariable}\` is tagged v${imageTag} but CI pins ${ciVersion}. Derive the tag from \`${versionVariable}\` or bump it.`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (!runtimeCheck.test(hookText)) {
|
|
697
|
+
report(
|
|
698
|
+
hookFile,
|
|
699
|
+
`${hookFile} never runs \`${scanner} version\`. A native ${scanner} of another version scans with another ruleset: compare it with \`${versionVariable}\` and refuse a mismatch.`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return violations;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/rules/service-image-digest-pin.ts
|
|
708
|
+
var RULE_ID5 = "service-image-digest-pin";
|
|
709
|
+
var DIGEST2 = /@sha256:[0-9a-f]{64}$/u;
|
|
710
|
+
var KEY_LINE = /^(\s*)(?:-\s+)?([\w-]+):\s*(.*)$/u;
|
|
711
|
+
var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
712
|
+
["compose", "docker-compose"].flatMap(
|
|
713
|
+
(stem) => ["", ".*", "-*"].flatMap((infix) => ["yml", "yaml"].map((ext) => `${stem}${infix}.${ext}`))
|
|
714
|
+
)
|
|
715
|
+
);
|
|
716
|
+
function indentOf(line) {
|
|
717
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
718
|
+
}
|
|
719
|
+
function workflowImages(text) {
|
|
720
|
+
const found = [];
|
|
721
|
+
const stack = [];
|
|
722
|
+
text.split("\n").forEach((line, index) => {
|
|
723
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const match = KEY_LINE.exec(line);
|
|
727
|
+
if (match === null) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const indent = match[1]?.length ?? 0;
|
|
731
|
+
const key = match[2] ?? "";
|
|
732
|
+
const value = unquote(stripYamlComment(match[3] ?? "").trim());
|
|
733
|
+
while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) {
|
|
734
|
+
stack.pop();
|
|
735
|
+
}
|
|
736
|
+
const insideService = stack.some((entry) => entry.key === "services");
|
|
737
|
+
const isContainerScalar = key === "container" && value !== "";
|
|
738
|
+
const isImageKey = key === "image" && value !== "";
|
|
739
|
+
const insideContainer = stack.some((entry) => entry.key === "container");
|
|
740
|
+
if (isContainerScalar || isImageKey && (insideService || insideContainer)) {
|
|
741
|
+
found.push({ line: index + 1, image: value });
|
|
742
|
+
}
|
|
743
|
+
stack.push({ indent, key });
|
|
744
|
+
});
|
|
745
|
+
return found;
|
|
746
|
+
}
|
|
747
|
+
function composeImages(text) {
|
|
748
|
+
const lines = text.split("\n");
|
|
749
|
+
const found = [];
|
|
750
|
+
const servicesAt = lines.findIndex((line) => /^services:\s*(?:#.*)?$/u.test(line));
|
|
751
|
+
if (servicesAt === -1) {
|
|
752
|
+
return found;
|
|
753
|
+
}
|
|
754
|
+
let serviceIndent = null;
|
|
755
|
+
let propertyIndent = null;
|
|
756
|
+
let current = null;
|
|
757
|
+
const flush = () => {
|
|
758
|
+
if (current !== null && !current.hasBuild) {
|
|
759
|
+
found.push(...current.images);
|
|
760
|
+
}
|
|
761
|
+
current = null;
|
|
762
|
+
};
|
|
763
|
+
for (let index = servicesAt + 1; index < lines.length; index += 1) {
|
|
764
|
+
const line = lines[index] ?? "";
|
|
765
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const indent = indentOf(line);
|
|
769
|
+
if (indent === 0) {
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
serviceIndent ??= indent;
|
|
773
|
+
const match = KEY_LINE.exec(line);
|
|
774
|
+
if (match === null) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (indent === serviceIndent) {
|
|
778
|
+
flush();
|
|
779
|
+
current = { images: [], hasBuild: false };
|
|
780
|
+
propertyIndent = null;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
propertyIndent ??= indent;
|
|
784
|
+
if (current === null || indent !== propertyIndent) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
const value = unquote(stripYamlComment(match[3] ?? "").trim());
|
|
788
|
+
if (match[2] === "build") {
|
|
789
|
+
current.hasBuild = true;
|
|
790
|
+
} else if (match[2] === "image" && value !== "") {
|
|
791
|
+
current.images.push({ line: index + 1, image: value });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
flush();
|
|
795
|
+
return found;
|
|
796
|
+
}
|
|
797
|
+
function violationsFor(file, images, allowUnpinned) {
|
|
798
|
+
return images.filter(({ image }) => !DIGEST2.test(image) && !allowUnpinned.has(image)).map(({ line, image }) => {
|
|
799
|
+
const reference = image.split("@")[0];
|
|
800
|
+
return {
|
|
801
|
+
file,
|
|
802
|
+
rule: RULE_ID5,
|
|
803
|
+
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.`,
|
|
804
|
+
line
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
function createServiceImageDigestPinRule(options = {}) {
|
|
809
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
810
|
+
const composeGlobs = options.composeGlobs ?? DEFAULT_COMPOSE_GLOBS;
|
|
811
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
812
|
+
const allowUnpinned = new Set(options.allowUnpinned ?? []);
|
|
813
|
+
const ciCritical = options.ciCritical ?? true;
|
|
814
|
+
return {
|
|
815
|
+
id: RULE_ID5,
|
|
816
|
+
category: "ci",
|
|
817
|
+
ciCritical,
|
|
818
|
+
description: "Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt).",
|
|
819
|
+
run(ctx) {
|
|
820
|
+
const glob = (p) => ctx.glob(p);
|
|
821
|
+
const workflowViolations = globFiles(glob, workflowGlobs).flatMap(
|
|
822
|
+
(file) => violationsFor(file, workflowImages(ctx.read(file) ?? ""), allowUnpinned)
|
|
823
|
+
);
|
|
824
|
+
const composeViolations = globFiles(glob, composeGlobs, skipDirs).flatMap(
|
|
825
|
+
(file) => violationsFor(file, composeImages(ctx.read(file) ?? ""), allowUnpinned)
|
|
826
|
+
);
|
|
827
|
+
return [...workflowViolations, ...composeViolations];
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
408
832
|
// src/rules/test-runner-segregation.ts
|
|
409
833
|
function createTestRunnerSegregationRule(options = {}) {
|
|
410
834
|
const bunPackageGlobs = options.bunPackageGlobs ?? ["packages/*/package.json"];
|
|
@@ -671,11 +1095,16 @@ function createWorkspaceGraphParityRule(options = {}) {
|
|
|
671
1095
|
var RULE_FACTORIES = {
|
|
672
1096
|
"agents-doc-presence": createAgentsDocPresenceRule,
|
|
673
1097
|
"canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
|
|
1098
|
+
"dockerfile-base-image-digest-pin": createDockerfileBaseImageDigestPinRule,
|
|
674
1099
|
"file-size-ratchet": createFileSizeRatchetRule,
|
|
1100
|
+
"github-actions-runner-pinned": createGithubActionsRunnerPinnedRule,
|
|
1101
|
+
"github-actions-sha-pinned": createGithubActionsShaPinnedRule,
|
|
675
1102
|
"layer-rank": createLayerRankRule,
|
|
676
1103
|
"no-cloned-component-folders": createNoClonedComponentFoldersRule,
|
|
677
1104
|
"no-warn-severity": createNoWarnSeverityRule,
|
|
678
1105
|
"package-shape": createPackageShapeRule,
|
|
1106
|
+
"security-scanner-version-parity": createSecurityScannerVersionParityRule,
|
|
1107
|
+
"service-image-digest-pin": createServiceImageDigestPinRule,
|
|
679
1108
|
"test-runner-segregation": createTestRunnerSegregationRule,
|
|
680
1109
|
"test-sibling-enforcement": createTestSiblingEnforcementRule,
|
|
681
1110
|
"test-workspace-enrollment": createTestWorkspaceEnrollmentRule,
|
|
@@ -693,11 +1122,16 @@ export {
|
|
|
693
1122
|
createAgentsDocPresenceRule,
|
|
694
1123
|
createAllRules,
|
|
695
1124
|
createCanonicalHelpersSingleHomeRule,
|
|
1125
|
+
createDockerfileBaseImageDigestPinRule,
|
|
696
1126
|
createFileSizeRatchetRule,
|
|
1127
|
+
createGithubActionsRunnerPinnedRule,
|
|
1128
|
+
createGithubActionsShaPinnedRule,
|
|
697
1129
|
createLayerRankRule,
|
|
698
1130
|
createNoClonedComponentFoldersRule,
|
|
699
1131
|
createNoWarnSeverityRule,
|
|
700
1132
|
createPackageShapeRule,
|
|
1133
|
+
createSecurityScannerVersionParityRule,
|
|
1134
|
+
createServiceImageDigestPinRule,
|
|
701
1135
|
createTestRunnerSegregationRule,
|
|
702
1136
|
createTestSiblingEnforcementRule,
|
|
703
1137
|
createTestWorkspaceEnrollmentRule,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# `dockerfile-base-image-digest-pin`
|
|
2
|
+
|
|
3
|
+
> Dockerfile `FROM` base images are pinned by `@sha256:` digest.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`FROM node:22-slim` names whatever the registry serves at build time, so two builds of one commit can
|
|
8
|
+
differ and a moved tag ships a change nobody reviewed. `tag@sha256:<digest>` keeps the tag for readers
|
|
9
|
+
and fixes the bytes.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
Every `FROM` whose image has no `@sha256:` digest. Exempt: `FROM scratch` and `FROM <earlier stage>`.
|
|
14
|
+
An image chosen through a build arg (`FROM ${BASE}`) is resolved from its `ARG BASE=<default>` before
|
|
15
|
+
the first `FROM`; one that cannot be resolved is reported, since the text does not show what is
|
|
16
|
+
pulled. Each violation carries the 1-indexed line.
|
|
17
|
+
|
|
18
|
+
```dockerfile
|
|
19
|
+
# Bad
|
|
20
|
+
FROM node:22-slim AS deps
|
|
21
|
+
|
|
22
|
+
# Good
|
|
23
|
+
FROM node:22-slim@sha256:<digest> AS deps
|
|
24
|
+
FROM deps AS build
|
|
25
|
+
FROM scratch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Factory
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
createDockerfileBaseImageDigestPinRule(options?: DockerfileBaseImageDigestPinOptions): IMetaRule
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Option | Type | Default | Meaning |
|
|
35
|
+
| --- | --- | --- | --- |
|
|
36
|
+
| `dockerfileGlobs` | `string[]` | `Dockerfile`, `Dockerfile.*`, `*.Dockerfile` at any depth, dot-directories included | Dockerfiles to scan. |
|
|
37
|
+
| `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | A path with any of these segments is skipped. |
|
|
38
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
39
|
+
|
|
40
|
+
## Limits
|
|
41
|
+
|
|
42
|
+
A `FROM` split with a line continuation (`\`) is not read. Add a `Containerfile` glob if you use
|
|
43
|
+
Podman naming.
|