Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/apps-api-security-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-api-security-deps-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-api-security-sast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-api-security-sast-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-api-security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-api-security-secrets-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-docs-security-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-docs-security-deps-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-docs-security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-docs-security-secrets-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-ui-security-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-ui-security-deps-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-ui-security-sast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-ui-security-sast-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/apps-ui-security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: apps-ui-security-secrets-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/infra-bootstrap-security-deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ on:

concurrency:
group: infra-bootstrap-security-deps-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/infra-bootstrap-security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ on:

concurrency:
group: infra-bootstrap-security-secrets-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/infra-compose-security-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:

concurrency:
group: infra-compose-security-secrets-security-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false

permissions:
contents: read
Expand Down
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@
"typescript": "6.0.3",
"typescript-eslint": "8.60.1"
},
"//overrides": {
"_": "Why these transitive deps are pinned. Keep each entry in sync with the matching `overrides` key — enforced by the package-override-parity lint-meta rule.",
"@typescript-eslint/utils": "Single @typescript-eslint/utils resolution across the workspace so the shared @boring-stack-pkg ESLint plugins all load the same utils version. The UI and docs apps mirror this exact pin; a mismatch makes the custom plugins resolve divergent utils copies and fail to load."
},
"overrides": {
"@typescript-eslint/utils": "8.60.1"
}
Expand Down
76 changes: 39 additions & 37 deletions apps/api/scripts/lint-meta/RULES.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/api/scripts/lint-meta/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ import { checkEnginePinParity } from "./rules/ci/engine-pin-parity";
import { checkWorkflowBunCache } from "./rules/ci/github-actions-bun-cache";
import { checkWorkflowConcurrencyExplicit } from "./rules/ci/github-actions-concurrency-explicit";
import { checkWorkflowExpressionSyntax } from "./rules/ci/github-actions-expression-syntax";
import { checkWorkflowSecurityNoCancel } from "./rules/ci/github-actions-security-no-cancel";
import { checkWorkflowServiceImageDigestPin } from "./rules/ci/github-actions-service-image-digest-pin";
import { checkWorkflowShas } from "./rules/ci/github-actions-permissions";
import { checkWorkflowTimeouts } from "./rules/ci/github-actions-timeout-required";
import { checkPrePushParity } from "./rules/ci/pre-push-ci-parity";
import { checkSecurityScannerVersionParity } from "./rules/ci/security-scanner-version-parity";
import { checkTofuBootstrapHardening } from "./rules/ci/tofu-bootstrap-hardening";
import { checkCanonicalHelpersSingleHome } from "./rules/source-text/canonical-helpers-single-home";
import { checkDocsNoRetiredCredentials } from "./rules/source-text/docs-no-retired-credentials";
Expand Down Expand Up @@ -126,6 +128,7 @@ export {
checkPackageOverrideParity,
checkPrePushParity,
checkRouteFilesHaveTests,
checkSecurityScannerVersionParity,
checkSharedToolVersionParity,
checkSkippedTestsHaveTracking,
checkTofuBootstrapHardening,
Expand All @@ -134,6 +137,7 @@ export {
checkWorkflowBunCache,
checkWorkflowConcurrencyExplicit,
checkWorkflowExpressionSyntax,
checkWorkflowSecurityNoCancel,
checkWorkflowServiceImageDigestPin,
checkWorkflowShas,
checkWorkflowTimeouts,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/scripts/lint-meta/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { githubActionsBunCacheRule } from "./rules/ci/github-actions-bun-cache";
import { githubActionsConcurrencyExplicitRule } from "./rules/ci/github-actions-concurrency-explicit";
import { githubActionsExpressionSyntaxRule } from "./rules/ci/github-actions-expression-syntax";
import { githubActionsPermissionsRule } from "./rules/ci/github-actions-permissions";
import { githubActionsSecurityNoCancelRule } from "./rules/ci/github-actions-security-no-cancel";
import { githubActionsServiceImageDigestPinRule } from "./rules/ci/github-actions-service-image-digest-pin";
import { githubActionsTimeoutRequiredRule } from "./rules/ci/github-actions-timeout-required";
import { prePushCiParityRule } from "./rules/ci/pre-push-ci-parity";
import { securityScannerVersionParityRule } from "./rules/ci/security-scanner-version-parity";
import { tofuBootstrapHardeningRule } from "./rules/ci/tofu-bootstrap-hardening";
import { eslintBanTypeAssertionsRule } from "./rules/config/eslint-ban-type-assertions";
import { eslintConfigNoWarnRule } from "./rules/config/eslint-config-no-warn";
Expand Down Expand Up @@ -41,9 +43,11 @@ export const META_RULES: readonly IMetaRule[] = [
githubActionsTimeoutRequiredRule,
githubActionsBunCacheRule,
githubActionsConcurrencyExplicitRule,
githubActionsSecurityNoCancelRule,
githubActionsExpressionSyntaxRule,
githubActionsServiceImageDigestPinRule,
prePushCiParityRule,
securityScannerVersionParityRule,
tofuBootstrapHardeningRule,
enginePinParityRule,
dockerfileBaseImageShaPinRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import { basename } from "node:path";

import type { IMetaRule, IViolation } from "../../types";

const TOP_LEVEL_KEY_REGEX = /^\S/u;
const SECURITY_WORKFLOW_REGEX = /-security-(?:sast|secrets|deps)\.ya?ml$/u;

/*
* Security scans (SAST, secret, dependency) must run to completion for every
* pushed ref. With `cancel-in-progress: true`, a follow-up push cancels the
* in-flight scan — the superseded commit is then neither passed nor failed,
* and a PR can read green over code that was never actually scanned. So any
* `*-security-{sast,secrets,deps}` workflow's concurrency block must set
* `cancel-in-progress: false`. Build/validate workflows are intentionally
* exempt: there, cancelling superseded runs is the desirable fast-feedback
* behaviour.
*/
export function checkWorkflowSecurityNoCancel(file: string): IViolation[] {
if (!SECURITY_WORKFLOW_REGEX.test(basename(file))) {
return [];
}

const lines = readFileSync(file, "utf8").split("\n");
let inConcurrency = false;
let cancelInProgress: boolean | null = null;

for (const line of lines) {
if (/^concurrency:\s*(?:#.*)?$/u.test(line)) {
inConcurrency = true;
continue;
}

if (!inConcurrency) {
continue;
}

if (TOP_LEVEL_KEY_REGEX.test(line)) {
inConcurrency = false;
continue;
}

const match = /^\s+cancel-in-progress:\s*(true|false)\s*(?:#.*)?$/u.exec(
line
);

if (match !== null) {
cancelInProgress = match[1] === "true";
}
}

if (cancelInProgress === true) {
return [
{
file,
rule: "github-actions-security-no-cancel",
message:
"Security scan workflow sets `cancel-in-progress: true` — a follow-up push cancels the in-flight scan and the superseded commit goes unscanned. Set `cancel-in-progress: false` so every pushed ref is scanned to completion.",
},
];
}

return [];
}

/**
* A cancelled security scan is neither a pass nor a fail, and the scanned
* commit may never be re-scanned. Security workflows must queue, not cancel.
*/
export const githubActionsSecurityNoCancelRule: IMetaRule = {
id: "github-actions-security-no-cancel",
category: "ci",
description:
"Security scan workflows (*-security-{sast,secrets,deps}) must set concurrency cancel-in-progress: false so no pushed ref goes unscanned.",
run({ workflowFiles }) {
return workflowFiles.flatMap(checkWorkflowSecurityNoCancel);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { readFileSync } from "node:fs";

import type { IMetaRule, IViolation } from "../../types";

/*
* Secret/SAST scanners encode their ruleset in their version: two workflows
* running different gitleaks or semgrep versions scan with different rules,
* so one can pass code the other would flag. The pinned version is duplicated
* across every `*-security-secrets`/`-sast` workflow with nothing forcing the
* copies to agree — a bump to one drifts silently from the rest. This rule
* requires a single gitleaks version and a single semgrep image pin across
* all workflows. (scripts/ci/pre-push-security.sh reads its gitleaks version
* from the workflow at runtime, so it is single-sourced by construction.)
*/

const GITLEAKS_VERSION_REGEX =
/^\s*GITLEAKS_VERSION:\s*["']?(\d+\.\d+\.\d+)["']?\s*(?:#.*)?$/u;
const SEMGREP_IMAGE_REGEX =
/semgrep\/semgrep:(\d+\.\d+\.\d+@sha256:[a-f0-9]+)/u;

const RULE_ID = "security-scanner-version-parity";

function addRef(
byValue: Map<string, string[]>,
value: string,
file: string
): void {
const existing = byValue.get(value);

if (existing === undefined) {
byValue.set(value, [file]);

return;
}

existing.push(file);
}

function parityViolations(
tool: string,
byValue: Map<string, string[]>
): IViolation[] {
if (byValue.size <= 1) {
return [];
}

const values = [...byValue.keys()].sort();
const expected = values[0];

if (expected === undefined) {
return [];
}

const out: IViolation[] = [];

for (const [value, files] of byValue) {
if (value === expected) {
continue;
}

for (const file of files) {
out.push({
file,
rule: RULE_ID,
message: `${tool} pinned to ${value} here but ${expected} elsewhere — single-source the ${tool} version across all security workflows so every scan runs the same ruleset.`,
});
}
}

return out;
}

export function checkSecurityScannerVersionParity(
files: readonly string[]
): IViolation[] {
const gitleaks = new Map<string, string[]>();
const semgrep = new Map<string, string[]>();

for (const file of files) {
for (const line of readFileSync(file, "utf8").split("\n")) {
const gitleaksMatch = GITLEAKS_VERSION_REGEX.exec(line);

if (gitleaksMatch?.[1] !== undefined) {
addRef(gitleaks, gitleaksMatch[1], file);
}

const semgrepMatch = SEMGREP_IMAGE_REGEX.exec(line);

if (semgrepMatch?.[1] !== undefined) {
addRef(semgrep, semgrepMatch[1], file);
}
}
}

return [
...parityViolations("gitleaks", gitleaks),
...parityViolations("semgrep", semgrep),
];
}

/**
* A scanner version bumped in one workflow but not its siblings silently runs
* divergent rulesets; pin every gitleaks/semgrep reference to one version.
*/
export const securityScannerVersionParityRule: IMetaRule = {
id: RULE_ID,
category: "ci",
description:
"All security workflows must pin a single gitleaks version and a single semgrep image so every scan runs the same ruleset.",
run({ workflowFiles }) {
return checkSecurityScannerVersionParity(workflowFiles);
},
};
Loading
Loading