diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7b7af00 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: codeql + +on: + workflow_call: + inputs: + languages: + description: >- + JSON list of what to analyze, each {"language", "build-mode"} in CodeQL's own names + ("javascript-typescript", "java-kotlin", ...; "none" or "autobuild") + type: string + required: true + upload: + description: Whether the results become code scanning alerts of the repository ("always", "failure-only" or "never") + type: string + default: always + +permissions: + contents: read + +jobs: + analyze: + name: codeql (${{ matrix.language }}) + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(inputs.languages) }} + runs-on: ubuntu-latest + permissions: + # code scanning alerts are security events + security-events: write + # CodeQL reads the workflow run it is part of, which a private repository does not let it do otherwise + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + languages: ${{ matrix.language }} + # with "autobuild", the analysis below runs the build itself + build-mode: ${{ matrix.build-mode }} + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + category: /language:${{ matrix.language }} + upload: ${{ inputs.upload }} diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index 412276c..d13d46a 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -54,6 +54,7 @@ jobs: [kotlin]="fixtures/kotlin/ .github/workflows/stack-java.yml" [php]="fixtures/php/ .github/workflows/stack-php.yml" [ruby]="fixtures/ruby/ .github/workflows/stack-ruby.yml" + [codeql]="fixtures/node/ fixtures/go/ .github/workflows/codeql.yml" ) files=$(git diff --name-only "$BASE" "$HEAD") run="," @@ -203,3 +204,19 @@ jobs: ruby-versions: ${{ github.event_name == 'push' && '["3.3","3.4"]' || '["3.4"]' }} install-command: bundle install commands: '["bundle exec rake test"]' + # One language CodeQL reads from source, one it has to build, and the workflow files. Nothing is + # uploaded: the fixtures are not this repository's code, and their findings are not its alerts. + codeql: + needs: changes + if: contains(needs.changes.outputs.run, ',all,') || contains(needs.changes.outputs.run, ',codeql,') + permissions: + security-events: write + actions: read + contents: read + uses: ./.github/workflows/codeql.yml + with: + languages: >- + [{"language":"javascript-typescript","build-mode":"none"}, + {"language":"go","build-mode":"autobuild"}, + {"language":"actions","build-mode":"none"}] + upload: never diff --git a/README.md b/README.md index 5829f87..ef642a6 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ alone. Every write command accepts `--dry-run`. Dependabot security updates into `.repokeeper.yml`; `repokeeper github apply` then puts them in place. On GitLab it writes the protection of the default branch (Maintainers push and merge, no force push) and a merge request that needs a passing pipeline, for `repokeeper gitlab apply`; a - required review is not part of it, because approval rules are a paid-tier setting there. There is - no preset that promises code or secret scanning: repokeeper does not set those up. + required review is not part of it, because approval rules are a paid-tier setting there. No preset + switches code or secret scanning on: they are free on a public repository and paid on a private + one, so they are yours to ask for (see below). - `repokeeper init --interactive` (or `-i`) asks about each part in turn, naming the files it would write, shows the result, and asks once more before writing anything. Enter keeps the preset's answer. Add `--dry-run` to go through the questions without the possibility of writing. @@ -78,10 +79,17 @@ alone. Every write command accepts `--dry-run`. Either way the choice ends up as plain `modules:` switches in `.repokeeper.yml`, which you can change later and apply with `repokeeper update`. -One switch is in no preset: `modules.autoupdate: true` adds a weekly workflow that runs -`repokeeper update` with the newest repokeeper and opens a pull request when the standard has moved, so a -new standard reaches every repository without anyone running the command in each (GitHub only; see -[CI and releases](docs/ci-and-releases.md#update-pull-requests)). +Two switches are in no preset: + +- `modules.autoupdate: true` adds a weekly workflow that runs `repokeeper update` with the newest + repokeeper and opens a pull request when the standard has moved, so a new standard reaches every + repository without anyone running the command in each (GitHub only; see + [CI and releases](docs/ci-and-releases.md#update-pull-requests)). +- `modules.security: true` adds a workflow that runs CodeQL on every pull request, on every push to + the default branch and once a week, for the languages of your stacks (GitHub only; see + [CI and releases](docs/ci-and-releases.md#code-scanning)). Secret scanning is a setting of the + repository: `github.security.secret_scanning`, applied by `repokeeper github apply` + ([GitHub settings](docs/github-settings.md)). You can also start from the file instead of from detection: put a `.repokeeper.yml` you wrote yourself in the repository and run `repokeeper init`. When the file is there and repokeeper has not diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 25ce0bb..3c3c68d 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -102,6 +102,45 @@ The job needs a token: The switch is off unless you ask for it, and it is for GitHub: on GitLab it writes nothing, and `repokeeper update` run by hand does the same work. It needs repokeeper 0.9.0 or newer. +## Code scanning + +Set `modules.security: true` and repokeeper writes `.github/workflows/security.yml`. Its `codeql` job +calls the reusable workflow `codeql.yml`, which runs [CodeQL](https://codeql.github.com/) and reports +what it finds as code scanning alerts, on the pull request and under the repository's Security tab. +It runs on every pull request, on every push to the default branch, and once a week, because new +queries find things in code that has not changed. + +What is analyzed follows from the stacks in `.repokeeper.yml`: + +| Stack | CodeQL language | Build | +| --- | --- | --- | +| `node` | `javascript-typescript` | none | +| `python` | `python` | none | +| `java` | `java-kotlin` | none | +| `kotlin` | `java-kotlin` | CodeQL's own (`autobuild`) | +| `dotnet` | `csharp` | none | +| `go` | `go` | CodeQL's own (`autobuild`) | +| `ruby` | `ruby` | none | +| `rust` | `rust` | none | +| `dart`, `php`, `script` | not analyzed: CodeQL does not know them | | + +The workflow files themselves are always analyzed too (`actions`), so a repository with only Dart or +PHP still gets that. Most languages are read from source. Go and Kotlin are not: CodeQL has to watch a +build, and works out how to run one. When that build needs something it cannot guess, list +`.github/workflows/security.yml#jobs.codeql` under `owned` and call CodeQL with your own build steps. + +Three things to know: + +- **Public and private repositories.** Code scanning is free on a public repository. A private one + needs GitHub Code Security; without it the analysis runs and then fails when it uploads its results. + This is why no preset switches the module on. +- **Default setup.** If CodeQL's default setup is on in the repository's settings, GitHub refuses the + results of a workflow. Use one or the other. +- **GitLab.** The switch writes nothing there. GitLab has its own SAST and Secret Detection + templates, which you include in `.gitlab-ci.yml` yourself. + +It needs repokeeper 0.9.0 or newer. + --- Back to the [README](../README.md). diff --git a/docs/github-settings.md b/docs/github-settings.md index efb9b08..8ffc249 100644 --- a/docs/github-settings.md +++ b/docs/github-settings.md @@ -8,7 +8,11 @@ github: description: Keeps repositories on one standard topics: [cli, conventional-commits] merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true } - security: { dependabot_alerts: true, dependabot_security_updates: true } + security: + dependabot_alerts: true + dependabot_security_updates: true + secret_scanning: true # GitHub's scan for committed credentials + secret_scanning_push_protection: true # refuse a push that contains one; needs secret_scanning protect: # a ruleset named "repokeeper" on the default branch; false removes it require_pull_request: true required_approvals: 0 @@ -21,6 +25,11 @@ It prints every change first and applies them with `--yes`, or after you confirm access to the repository. Legacy branch protection, visibility, secrets and collaborators are never touched. +Secret scanning and push protection are free on a public repository, where GitHub usually has them +on already. A private repository needs GitHub Secret Protection; without it GitHub refuses the change +and `github apply` reports its answer. Code scanning is not a setting here but a workflow: see +`modules.security` in [CI and releases](ci-and-releases.md#code-scanning). + --- Back to the [README](../README.md). diff --git a/package.json b/package.json index af7fd8b..177a47f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ ".github/workflows/commitlint.yml", ".github/workflows/release-please.yml", ".github/workflows/repokeeper-check.yml", - ".github/workflows/repokeeper-update.yml" + ".github/workflows/repokeeper-update.yml", + ".github/workflows/codeql.yml" ], "engines": { "node": ">=22.12.0" diff --git a/playground/config.d.ts b/playground/config.d.ts index 61b5c93..fcb89e4 100644 --- a/playground/config.d.ts +++ b/playground/config.d.ts @@ -13,6 +13,7 @@ export interface PlaygroundInput { export const STANDARD: string; export const NEEDS_VERSION: string; export const AUTOUPDATE_NEEDS_VERSION: string; +export const SECURITY_NEEDS_VERSION: string; export const STACKS: Array<{ id: string; label: string }>; export const PLATFORM_STACKS: Record<"github" | "gitlab", string[]>; export const PRESETS: Array<{ id: string; summary: string }>; diff --git a/playground/config.js b/playground/config.js index f139d63..5b1436f 100644 --- a/playground/config.js +++ b/playground/config.js @@ -13,6 +13,9 @@ export const NEEDS_VERSION = "0.8.2"; /** The first repokeeper that knows the update pull requests; earlier ones refuse the line that asks for them. */ export const AUTOUPDATE_NEEDS_VERSION = "0.9.0"; +/** The same for code scanning. */ +export const SECURITY_NEEDS_VERSION = "0.9.0"; + export const STACKS = [ { id: "node", label: "Node.js" }, { id: "python", label: "Python" }, @@ -59,6 +62,11 @@ export const MODULES = [ label: "Update pull requests", hint: "a weekly workflow that opens a pull request when the standard has moved (GitHub)", }, + { + id: "security", + label: "Code scanning", + hint: "a workflow that runs CodeQL on pull requests, pushes and weekly (GitHub; free on public repositories)", + }, ]; /** What stands in for a detail the form was left empty on; `problems` reports each one still in use. */ @@ -68,13 +76,14 @@ export const PLACEHOLDERS = { codeowners: "@your-name", }; -const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate"]; +const ESSENTIAL_OFF = ["commits", "hooks", "release", "deps", "health", "drift", "autoupdate", "security"]; + +/** The switches no preset turns on. */ +const IN_NO_PRESET = ["drift", "autoupdate", "security"]; /** The module switches of a preset, `health` as a plain on or off. */ export function presetModules(preset) { - const modules = Object.fromEntries( - MODULES.map((module) => [module.id, module.id !== "drift" && module.id !== "autoupdate"]), - ); + const modules = Object.fromEntries(MODULES.map((module) => [module.id, !IN_NO_PRESET.includes(module.id)])); if (preset === "essential") for (const id of ESSENTIAL_OFF) modules[id] = false; if (preset === "strict") modules.drift = true; return modules; @@ -143,6 +152,7 @@ export function buildConfig(input) { gitignore: modules.gitignore, drift: modules.drift, ...(modules.autoupdate ? { autoupdate: true } : {}), + ...(modules.security ? { security: true } : {}), health: modules.health ? { license: health.license ? "MIT" : false, @@ -205,6 +215,13 @@ export function problems(input) { : "Update pull requests are written for GitHub only: on GitLab the switch writes nothing.", ); } + if (input.modules.security) { + found.push( + input.platform === "github" + ? `Code scanning needs repokeeper ${SECURITY_NEEDS_VERSION} or newer, and on a private repository GitHub Code Security.` + : "Code scanning is written for GitHub only: on GitLab the switch writes nothing.", + ); + } if (input.platform === "github") { const { mode, ref, source } = input.workflows; if ((mode === "ref" || (mode === "mirror" && ref.trim() !== "")) && !/^[A-Za-z0-9_./-]+$/.test(ref.trim())) { diff --git a/src/commands/presets.ts b/src/commands/presets.ts index dd5e21a..b6d589a 100644 --- a/src/commands/presets.ts +++ b/src/commands/presets.ts @@ -25,13 +25,15 @@ export const MODULE_IDS: ModuleId[] = [ "health", "drift", "autoupdate", + "security", ]; /** * Applies a preset to a freshly built default configuration. * - * A preset only switches on what repokeeper really does. There is no tier that promises code or - * secret scanning, because no module writes it. + * A preset only switches on what works in every repository. Code scanning (`modules.security`) is in no + * preset: it is free on a public repository and needs GitHub Code Security on a private one, where a + * preset would otherwise leave a failing workflow behind. */ export function applyPreset(config: RepokeeperConfig, preset: Preset): RepokeeperConfig { if (preset === "standard") return config; @@ -46,6 +48,7 @@ export function applyPreset(config: RepokeeperConfig, preset: Preset): Repokeepe deps: false, drift: false, autoupdate: false, + security: false, health: false, }, }; @@ -114,6 +117,7 @@ export async function chooseModules( gitignore: true, drift: true, autoupdate: true, + security: true, health, }, }; diff --git a/src/commands/report.ts b/src/commands/report.ts index 6c102ed..9b5026c 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -91,6 +91,12 @@ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncRe // lockfile that disagrees with it. A new standard moves such a range without touching lefthook.yml. steps.push("package.json changed; install dependencies and commit the lockfile with it"); } + // said once, when the workflow is new: whether the repository is public is not something repokeeper can see + if (result.decisions.some((d) => d.output.path === ".github/workflows/security.yml" && d.action === "create")) { + steps.push( + "code scanning is free on a public repository; on a private one, turn on GitHub Code Security, or the security workflow fails when it uploads its results", + ); + } if (wrote(".gitattributes")) { const crlf = await crlfTrackedFiles(root); if (crlf.length > 0) { @@ -117,6 +123,11 @@ export async function printWarnings(root: string, ctx: ModuleContext, io: Io): P "note: modules.autoupdate writes nothing on gitlab; run `repokeeper update` by hand when a new standard is out", ); } + if (ctx.platform.id === "gitlab" && ctx.config.modules.security) { + io.out( + "note: modules.security writes nothing on gitlab; include GitLab's SAST and Secret-Detection templates in .gitlab-ci.yml instead", + ); + } if (ctx.platform.id === "gitlab") { for (const stack of ctx.stacks) { const os = stack.ci?.with.os; diff --git a/src/config/load.ts b/src/config/load.ts index 9b01885..090fd2e 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -47,15 +47,19 @@ export function parseConfig(text: string): RepokeeperConfig { export function renderConfig(config: RepokeeperConfig): string { // written only when on: a file without the line is one every earlier repokeeper reads as well - const { autoupdate, ...modules } = config.modules; - const written = { ...config, modules: autoupdate ? orderedModules(config.modules) : modules }; + const { autoupdate, security, ...modules } = config.modules; + const asked = { ...(autoupdate ? { autoupdate } : {}), ...(security ? { security } : {}) }; + const written = { + ...config, + modules: Object.keys(asked).length > 0 ? orderedModules({ ...modules, ...asked }) : modules, + }; return `# repokeeper configuration: https://github.com/vannt-dev/repokeeper\n${stringify(written)}`; } /** The module switches with `health`, the one that is a block, last. */ -function orderedModules(modules: ModulesConfig): ModulesConfig { +function orderedModules>(modules: T): T { const { health, ...flags } = modules; - return { ...flags, health }; + return { ...flags, health } as T; } /** Rewrites `standard:` in place, keeping every comment and the rest of the layout. */ @@ -89,6 +93,7 @@ function withDefaults( gitignore: modules.gitignore ?? true, drift: modules.drift ?? false, autoupdate: modules.autoupdate ?? false, + security: modules.security ?? false, health: modules.health, }, owned: data.owned ?? [], diff --git a/src/config/schema.ts b/src/config/schema.ts index dea1f92..39bb4bb 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -26,6 +26,7 @@ export const configSchema = { gitignore: flag, drift: flag, autoupdate: flag, + security: flag, health: { anyOf: [ { const: false }, @@ -71,7 +72,12 @@ export const configSchema = { security: { type: "object", additionalProperties: false, - properties: { dependabot_alerts: flag, dependabot_security_updates: flag }, + properties: { + dependabot_alerts: flag, + dependabot_security_updates: flag, + secret_scanning: flag, + secret_scanning_push_protection: flag, + }, }, protect: { anyOf: [ diff --git a/src/config/types.ts b/src/config/types.ts index 2e97ba3..6ac927f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -38,6 +38,8 @@ export interface ModulesConfig { drift: boolean; /** A scheduled workflow that opens a pull request when the standard has moved; off unless asked for. */ autoupdate: boolean; + /** A workflow that runs code scanning (CodeQL) on pull requests, pushes and weekly; off unless asked for. */ + security: boolean; health: HealthConfig | false; } @@ -82,6 +84,7 @@ export function defaultConfig(input: { gitignore: true, drift: false, autoupdate: false, + security: false, health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners }, }, owned: [], diff --git a/src/github/settings.ts b/src/github/settings.ts index 19ce885..5bec12c 100644 --- a/src/github/settings.ts +++ b/src/github/settings.ts @@ -12,7 +12,14 @@ export interface GithubSettings { description?: string; topics?: string[]; merge?: { squash?: boolean; merge_commit?: boolean; rebase?: boolean; delete_branch_on_merge?: boolean }; - security?: { dependabot_alerts?: boolean; dependabot_security_updates?: boolean }; + security?: { + dependabot_alerts?: boolean; + dependabot_security_updates?: boolean; + /** GitHub's own scan of the repository for committed credentials. */ + secret_scanning?: boolean; + /** Refuses a push that contains a credential; needs `secret_scanning`. */ + secret_scanning_push_protection?: boolean; + }; /** The `repokeeper` ruleset on the default branch; false removes it. */ protect?: | { @@ -216,6 +223,32 @@ export async function planGithub( } } + // free on a public repository; a private one needs GitHub Secret Protection, and GitHub refuses without it + const analysis = (current.security_and_analysis ?? {}) as Record; + for (const key of ["secret_scanning", "secret_scanning_push_protection"] as const) { + const want = settings.security?.[key]; + // a token without admin access is not shown the switches; that reads as off, and the write then says so + const have = analysis[key]?.status === "enabled"; + if (want === undefined || have === want) continue; + changes.push({ + setting: `security.${key}`, + from: show(have), + to: show(want), + apply: async () => { + const response = await api.request("PATCH", base, { + security_and_analysis: { [key]: { status: want ? "enabled" : "disabled" } }, + }); + if (response.status === 422) { + const message = (response.data as { message?: string } | null)?.message ?? "HTTP 422"; + throw new ApiError( + `setting security.${key} failed: ${message} (free on a public repository; a private one needs GitHub Secret Protection)`, + ); + } + expect2xx(response, `setting security.${key}`, repo); + }, + }); + } + if (settings.protect !== undefined) { const list = (await get("/rulesets?includes_parents=false", "reading rulesets")).data as { id: number; diff --git a/src/model.ts b/src/model.ts index d419c3f..a2a74aa 100644 --- a/src/model.ts +++ b/src/model.ts @@ -160,6 +160,8 @@ export interface PlatformAdapter { ciWorkflow(ctx: ModuleContext): Output[]; /** A scheduled job that proposes the update when the standard has moved; empty where the platform has none. */ standardUpdates(ctx: ModuleContext): Output[]; + /** A workflow that scans the code for vulnerabilities; empty where the platform has none written. */ + codeScanning(ctx: ModuleContext): Output[]; /** release-please configuration, its manifest and the caller release workflow. `directory` is the released stack's folder. */ releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[]; } diff --git a/src/modules/index.ts b/src/modules/index.ts index 6abf57b..866e0c9 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,6 +8,7 @@ import { gitignoreModule } from "./gitignore.js"; import { healthModule } from "./health.js"; import { hooksModule } from "./hooks.js"; import { releaseModule } from "./release.js"; +import { securityModule } from "./security.js"; export const MODULES: Module[] = [ editorconfigModule, @@ -19,4 +20,5 @@ export const MODULES: Module[] = [ ciModule, releaseModule, autoupdateModule, + securityModule, ]; diff --git a/src/modules/security.ts b/src/modules/security.ts new file mode 100644 index 0000000..b392699 --- /dev/null +++ b/src/modules/security.ts @@ -0,0 +1,7 @@ +import type { Module } from "../model.js"; + +export const securityModule: Module = { + id: "security", + enabled: (config) => config.modules.security, + outputs: (ctx) => ctx.platform.codeScanning(ctx), +}; diff --git a/src/platforms/github.ts b/src/platforms/github.ts index e69c5e5..fb46f65 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -1,6 +1,6 @@ import { stringify } from "yaml"; import { CONFIG_FILE } from "../config/load.js"; -import { defaultBranch, STACK_IDS } from "../config/types.js"; +import { defaultBranch, STACK_IDS, type StackId } from "../config/types.js"; import { ConfigError } from "../errors.js"; import { type DependencyUpdate, @@ -89,6 +89,47 @@ if [ -z "$ids" ]; then echo "::notice::no held runs for $BRANCH"; fi for id in $ids; do gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$id/approve"; done `; +interface CodeqlLanguage { + /** CodeQL's identifier of the language. */ + language: string; + /** `none`: read from source. `autobuild`: CodeQL has to watch a build, and works out how to run one. */ + "build-mode": "none" | "autobuild"; +} + +/** + * What CodeQL calls each stack's language, or null for a stack it cannot analyze. A stack with no line + * here does not compile, so that a new one has to say. + * + * Go has no analysis without a build. Java is read from source but Kotlin is not, and both share one + * identifier, so a Kotlin stack asks for the build. + */ +export const CODEQL_LANGUAGES: Record = { + node: { language: "javascript-typescript", "build-mode": "none" }, + python: { language: "python", "build-mode": "none" }, + dart: null, + script: null, + java: { language: "java-kotlin", "build-mode": "none" }, + dotnet: { language: "csharp", "build-mode": "none" }, + go: { language: "go", "build-mode": "autobuild" }, + rust: { language: "rust", "build-mode": "none" }, + kotlin: { language: "java-kotlin", "build-mode": "autobuild" }, + php: null, + ruby: { language: "ruby", "build-mode": "none" }, +}; + +/** The languages to analyze, each once: those of the stacks, then the workflow files themselves. */ +function codeqlLanguages(ctx: ModuleContext): CodeqlLanguage[] { + const found = new Map(); + for (const stack of ctx.stacks) { + const entry = CODEQL_LANGUAGES[stack.id]; + if (!entry) continue; + // a language two stacks share is built when either of them needs the build + if (found.get(entry.language)?.["build-mode"] !== "autobuild") found.set(entry.language, entry); + } + // every repository this writes to has workflows: at the least, the one that runs the analysis + return [...found.values(), { language: "actions", "build-mode": "none" }]; +} + const RELEASE_PLEASE_SCHEMA = "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"; // release-please's own php sections (src/strategies/php.ts) with chore hidden, as every other strategy has it const PHP_CHANGELOG_SECTIONS = [ @@ -295,6 +336,34 @@ export const githubPlatform: PlatformAdapter = { ]; }, + codeScanning(ctx: ModuleContext): Output[] { + // not the name of the reusable workflow it calls: a repository that keeps its own copies has both + const path = ".github/workflows/security.yml"; + return [ + ...localWorkflows(ctx, "security", ["codeql.yml"]), + workflowKey("security", path, ["name"], "security"), + workflowKey("security", path, ["on"], { + pull_request: null, + push: { branches: [defaultBranch(ctx.config)] }, + // weekly as well: new queries find things in code that has not changed + schedule: [{ cron: "41 4 * * 3" }], + }), + workflowKey("security", path, ["permissions"], { contents: "read" }), + workflowKey("security", path, ["concurrency"], { + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + group: "${{ github.workflow }}-${{ github.ref }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + }), + workflowKey("security", path, ["jobs", "codeql"], { + uses: workflowRef(ctx, "codeql.yml"), + // what the called job asks for; a called workflow cannot have more than its caller grants + permissions: { "security-events": "write", actions: "read", contents: "read" }, + with: { languages: JSON.stringify(codeqlLanguages(ctx)) }, + }), + ]; + }, + releaseAutomation(ctx: ModuleContext, release: ReleaseInfo, directory?: string): Output[] { const path = ".github/workflows/release.yml"; const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0"; diff --git a/src/platforms/gitlab.ts b/src/platforms/gitlab.ts index 5c7c577..8ba640c 100644 --- a/src/platforms/gitlab.ts +++ b/src/platforms/gitlab.ts @@ -205,6 +205,12 @@ export const gitlabPlatform: PlatformAdapter = { return []; }, + // GitLab has its own SAST and secret detection templates, pulled in with `include:`. That key is a list the + // project shares with repokeeper, and nothing here manages part of a list yet. + codeScanning(): Output[] { + return []; + }, + ciWorkflow(ctx: ModuleContext): Output[] { const jobs: Output[] = []; const node = ctx.stacks.find((stack) => stack.id === "node")?.ci; diff --git a/src/templates.ts b/src/templates.ts index 0b3543c..14e6f5a 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -7,7 +7,8 @@ export function readTemplate(relativePath: string): string { } /** File names of the reusable workflows generated CI and release files can call. */ -export const REUSABLE_WORKFLOW = /^(stack-[a-z]+|commitlint|release-please|repokeeper-check|repokeeper-update)\.yml$/; +export const REUSABLE_WORKFLOW = + /^(stack-[a-z]+|commitlint|release-please|repokeeper-check|repokeeper-update|codeql)\.yml$/; /** Reads one of the reusable workflows shipped with the package, with LF endings. */ export function readWorkflow(file: string): string { diff --git a/test/config.test.ts b/test/config.test.ts index 30117e1..702c0d3 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -26,7 +26,7 @@ describe("parseConfig", () => { expect(parseConfig(renderConfig(base))).toEqual(base); }); - it("defaults boolean modules to true (drift and autoupdate to false) and optional lists to empty", () => { + it("defaults boolean modules to true (drift, autoupdate and security to false) and optional lists to empty", () => { const config = parseConfig( "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\nmodules:\n health: false\n", ); @@ -40,6 +40,7 @@ describe("parseConfig", () => { gitignore: true, drift: false, autoupdate: false, + security: false, health: false, }); expect(config.owned).toEqual([]); diff --git a/test/github-secret-scanning.test.ts b/test/github-secret-scanning.test.ts new file mode 100644 index 0000000..c018807 --- /dev/null +++ b/test/github-secret-scanning.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "../src/config/load.js"; +import type { ApiResponse, GitHubApi } from "../src/github/api.js"; +import { planGithub } from "../src/github/settings.js"; + +type Status = "enabled" | "disabled"; + +/** A fake GitHub holding the two switches the way `GET /repos/{owner}/{repo}` reports them. */ +function fakeApi(state: { scanning?: Status; pushProtection?: Status; refuse?: string }) { + const writes: { method: string; path: string; body?: unknown }[] = []; + const api: GitHubApi = { + async request(method, path, body): Promise { + if (method !== "GET") { + if (state.refuse) return { status: 422, data: { message: state.refuse } }; + writes.push({ method, path, body }); + return { status: 200, data: {} }; + } + if (path !== "/repos/o/r") return { status: 404, data: { message: "Not Found" } }; + const analysis: Record = {}; + if (state.scanning) analysis.secret_scanning = { status: state.scanning }; + if (state.pushProtection) analysis.secret_scanning_push_protection = { status: state.pushProtection }; + // a token without admin access does not see the key at all + return { status: 200, data: Object.keys(analysis).length > 0 ? { security_and_analysis: analysis } : {} }; + }, + }; + return { api, writes }; +} + +const apply = async (api: GitHubApi, changes: Awaited>) => { + for (const change of changes) await change.apply(api); +}; +const both = { security: { secret_scanning: true, secret_scanning_push_protection: true } }; + +describe("github apply: secret scanning", () => { + it("turns on secret scanning before push protection, which needs it", async () => { + const { api, writes } = fakeApi({ scanning: "disabled", pushProtection: "disabled" }); + const changes = await planGithub(api, "o", "r", both); + expect(changes.map((c) => [c.setting, c.from, c.to])).toEqual([ + ["security.secret_scanning", "false", "true"], + ["security.secret_scanning_push_protection", "false", "true"], + ]); + await apply(api, changes); + expect(writes).toEqual([ + { + method: "PATCH", + path: "/repos/o/r", + body: { security_and_analysis: { secret_scanning: { status: "enabled" } } }, + }, + { + method: "PATCH", + path: "/repos/o/r", + body: { security_and_analysis: { secret_scanning_push_protection: { status: "enabled" } } }, + }, + ]); + }); + + it("changes nothing when both are as asked, or when the configuration leaves them out", async () => { + const enabled = fakeApi({ scanning: "enabled", pushProtection: "enabled" }); + expect(await planGithub(enabled.api, "o", "r", both)).toEqual([]); + expect(await planGithub(fakeApi({ scanning: "disabled" }).api, "o", "r", { security: {} })).toEqual([]); + }); + + it("turns one off without touching the other", async () => { + const { api, writes } = fakeApi({ scanning: "enabled", pushProtection: "enabled" }); + const changes = await planGithub(api, "o", "r", { security: { secret_scanning_push_protection: false } }); + await apply(api, changes); + expect(writes).toEqual([ + { + method: "PATCH", + path: "/repos/o/r", + body: { security_and_analysis: { secret_scanning_push_protection: { status: "disabled" } } }, + }, + ]); + }); + + it("reads a repository that does not report the switches as having them off", async () => { + const changes = await planGithub(fakeApi({}).api, "o", "r", { security: { secret_scanning: true } }); + expect(changes.map((c) => [c.setting, c.from, c.to])).toEqual([["security.secret_scanning", "false", "true"]]); + }); + + it("says what GitHub answered when the repository's plan does not include it", async () => { + const { api } = fakeApi({ + scanning: "disabled", + refuse: "Secret scanning is not available for this repository.", + }); + const [change] = await planGithub(api, "o", "r", { security: { secret_scanning: true } }); + await expect(change?.apply(api)).rejects.toThrow( + /setting security\.secret_scanning failed: Secret scanning is not available for this repository\..*public.*GitHub Secret Protection/, + ); + }); + + it("is accepted in .repokeeper.yml", () => { + const config = parseConfig( + [ + "schema: 1", + "standard: 1.0.0", + "platform: github", + "stacks: [node]", + "modules: { health: false }", + "github:", + " security: { secret_scanning: true, secret_scanning_push_protection: true }", + "", + ].join("\n"), + ); + expect(config.github?.security).toEqual({ secret_scanning: true, secret_scanning_push_protection: true }); + }); +}); diff --git a/test/modules-security.test.ts b/test/modules-security.test.ts new file mode 100644 index 0000000..0e6140c --- /dev/null +++ b/test/modules-security.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig, renderConfig } from "../src/config/load.js"; +import { STACK_IDS } from "../src/config/types.js"; +import type { Output } from "../src/model.js"; +import { MODULES } from "../src/modules/index.js"; +import { securityModule } from "../src/modules/security.js"; +import { planOutputs } from "../src/plan.js"; +import { CODEQL_LANGUAGES } from "../src/platforms/github.js"; +import { listWorkflows } from "../src/templates.js"; +import { WORKFLOW_REF } from "../src/version.js"; +import { gitlabContext, makeContext, nodeResolved } from "./helpers.js"; + +const PATH = ".github/workflows/security.yml"; +const REUSABLE = ".github/workflows/codeql.yml"; +const keys = (outputs: Output[]) => + Object.fromEntries(outputs.flatMap((o) => (o.kind === "yaml" ? [[o.keyPath.join("."), o.value]] : []))); +const on = (overrides: Parameters[0] = {}) => + makeContext({ ...overrides, modules: { security: true, ...overrides.modules } }); +/** What the workflow is told to analyze, for a repository of these stacks. */ +const languages = (...ids: (typeof STACK_IDS)[number][]) => { + const job = keys(securityModule.outputs(on({ stacks: ids.map((id) => nodeResolved({ id })) })))["jobs.codeql"] as { + with: { languages: string }; + }; + return JSON.parse(job.with.languages) as { language: string; "build-mode": string }[]; +}; + +describe("security module", () => { + it("is off unless asked for, and round-trips through the configuration file", () => { + expect(makeContext().config.modules.security).toBe(false); + expect(securityModule.enabled(makeContext().config)).toBe(false); + expect(planOutputs(makeContext()).some((output) => output.path === PATH)).toBe(false); + + expect(securityModule.enabled(on().config)).toBe(true); + expect(parseConfig(renderConfig(on().config)).modules.security).toBe(true); + expect(MODULES).toContain(securityModule); + }); + + it("writes a workflow that calls the reusable CodeQL workflow on pull requests, pushes and weekly", () => { + const outputs = securityModule.outputs(on()); + // the caller only: the reusable workflow it calls stays in the repokeeper repository + expect(new Set(outputs.map((output) => output.path))).toEqual(new Set([PATH])); + expect(outputs.every((output) => output.module === "security")).toBe(true); + + const out = keys(outputs); + expect(out.name).toBe("security"); + // weekly as well: new queries find things in code that has not changed + expect(out.on).toEqual({ + pull_request: null, + push: { branches: ["main"] }, + schedule: [{ cron: "41 4 * * 3" }], + }); + expect(out.permissions).toEqual({ contents: "read" }); + expect(out.concurrency).toEqual({ + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + group: "${{ github.workflow }}-${{ github.ref }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + "cancel-in-progress": "${{ github.event_name == 'pull_request' }}", + }); + expect(out["jobs.codeql"]).toEqual({ + uses: `vannt-dev/repokeeper/.github/workflows/codeql.yml@${WORKFLOW_REF}`, + // what the called job asks for; a called workflow cannot have more than its caller grants + permissions: { "security-events": "write", actions: "read", contents: "read" }, + with: { + languages: JSON.stringify([ + { language: "javascript-typescript", "build-mode": "none" }, + { language: "actions", "build-mode": "none" }, + ]), + }, + }); + }); + + it("runs on the configured default branch", () => { + const out = keys(securityModule.outputs(on({ config: { github: { default_branch: "develop" } } }))); + expect(out.on).toMatchObject({ push: { branches: ["develop"] } }); + }); + + it("names each stack's language once, with a build only where CodeQL needs one", () => { + expect(languages("python", "ruby", "rust", "dotnet")).toEqual([ + { language: "python", "build-mode": "none" }, + { language: "ruby", "build-mode": "none" }, + { language: "rust", "build-mode": "none" }, + { language: "csharp", "build-mode": "none" }, + { language: "actions", "build-mode": "none" }, + ]); + // Go has no analysis without a build + expect(languages("go")).toEqual([ + { language: "go", "build-mode": "autobuild" }, + { language: "actions", "build-mode": "none" }, + ]); + // Java is read from source; Kotlin is not, and one database holds both + expect(languages("java")[0]).toEqual({ language: "java-kotlin", "build-mode": "none" }); + expect(languages("kotlin")[0]).toEqual({ language: "java-kotlin", "build-mode": "autobuild" }); + expect(languages("java", "kotlin")).toEqual([ + { language: "java-kotlin", "build-mode": "autobuild" }, + { language: "actions", "build-mode": "none" }, + ]); + expect(languages("node", "node")).toHaveLength(2); + }); + + it("still analyzes the workflows of a repository whose stacks CodeQL does not know", () => { + expect(languages("dart", "php", "script")).toEqual([{ language: "actions", "build-mode": "none" }]); + }); + + it("has an answer for every stack", () => { + // a new stack has to say what CodeQL calls it, or that CodeQL does not know it + expect(Object.keys(CODEQL_LANGUAGES).sort()).toEqual([...STACK_IDS].sort()); + }); + + it("follows where the reusable workflows are taken from", () => { + const self = keys(securityModule.outputs(on({ repo: { owner: "vannt-dev", name: "repokeeper" } }))); + expect(self["jobs.codeql"]).toMatchObject({ uses: "./.github/workflows/codeql.yml" }); + + const local = securityModule.outputs(on({ config: { github: { workflows: { source: "local" } } } })); + // a repository that keeps its own copies gets one of this workflow too + expect(local.some((output) => output.kind === "seed" && output.path === REUSABLE)).toBe(true); + expect(keys(local)["jobs.codeql"]).toMatchObject({ uses: `./${REUSABLE}` }); + expect(listWorkflows()).toContain("codeql.yml"); + }); + + it("writes nothing on GitLab", () => { + expect(securityModule.outputs(gitlabContext({ modules: { security: true } }))).toEqual([]); + }); +}); diff --git a/test/presets.test.ts b/test/presets.test.ts index 2355bf8..7f90374 100644 --- a/test/presets.test.ts +++ b/test/presets.test.ts @@ -71,6 +71,7 @@ describe("presets", () => { deps: false, drift: false, autoupdate: false, + security: false, health: false, }); expect(applyPreset(base(), "essential").github).toBeUndefined(); @@ -207,16 +208,30 @@ describe("init --interactive", () => { "Apply health?", "Apply drift?", "Apply autoupdate?", + "Apply security?", "Write these files?", ]; it("names the files of each part, takes the answers, and writes only what was chosen", async () => { const dir = await nodeRepo(); - // editor ignore commits hooks ci release deps health drift update write + // editor, ignore, commits, hooks, ci, release, deps, health, drift, update, security, write const init = await repokeeper( dir, ["init", "--interactive"], - [undefined, undefined, true, false, undefined, false, undefined, undefined, undefined, undefined, true], + [ + undefined, + undefined, + true, + false, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ], ); expect(init.err).toBe(""); expect(init.code).toBe(0); @@ -226,6 +241,7 @@ describe("init --interactive", () => { expect(init.out).toContain("release:\n .github/workflows/release.yml"); expect(init.out).toContain("a job that fails a pull request when a managed file was edited by hand"); expect(init.out).toContain("autoupdate:\n .github/workflows/repokeeper-autoupdate.yml"); + expect(init.out).toContain("security:\n .github/workflows/security.yml"); const config = parse(await readFile(join(dir, ".repokeeper.yml"), "utf8")); expect(config.modules).toMatchObject({ commits: true, hooks: false, ci: true, release: false, deps: true }); @@ -234,6 +250,9 @@ describe("init --interactive", () => { // the same for the update pull requests, and a switch that is off is not written at all expect(config.modules).not.toHaveProperty("autoupdate"); expect(existsSync(join(dir, ".github/workflows/repokeeper-autoupdate.yml"))).toBe(false); + // and for code scanning + expect(config.modules).not.toHaveProperty("security"); + expect(existsSync(join(dir, ".github/workflows/security.yml"))).toBe(false); expect(existsSync(join(dir, "lefthook.yml"))).toBe(false); expect(existsSync(join(dir, "release-please-config.json"))).toBe(false); expect(existsSync(join(dir, "commitlint.config.mjs"))).toBe(true); @@ -242,28 +261,32 @@ describe("init --interactive", () => { it("starts from the preset: Enter all the way through essential keeps it essential", async () => { const dir = await nodeRepo(); - const init = await repokeeper(dir, ["init", "-i", "--preset", "essential"], new Array(11).fill(undefined)); + const init = await repokeeper(dir, ["init", "-i", "--preset", "essential"], new Array(12).fill(undefined)); expect(init.code).toBe(0); expect(init.asked).toEqual(QUESTIONS); expect(existsSync(join(dir, "lefthook.yml"))).toBe(false); expect(existsSync(join(dir, ".github/workflows/ci.yml"))).toBe(true); // a part the preset left out can still be taken, with its usual settings const other = await nodeRepo(); - const answers = new Array(11).fill(undefined); + const answers = new Array(12).fill(undefined); answers[7] = true; // and the update pull requests can be asked for here answers[9] = true; + // and code scanning + answers[10] = true; await repokeeper(other, ["init", "-i", "--preset", "essential"], answers); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.autoupdate).toBe(true); expect(existsSync(join(other, ".github/workflows/repokeeper-autoupdate.yml"))).toBe(true); + expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.security).toBe(true); + expect(existsSync(join(other, ".github/workflows/security.yml"))).toBe(true); expect(parse(await readFile(join(other, ".repokeeper.yml"), "utf8")).modules.health.license).toBe("MIT"); expect(existsSync(join(other, "CONTRIBUTING.md"))).toBe(true); }); it("writes nothing when the last question is answered no", async () => { const dir = await nodeRepo(); - const answers = new Array(11).fill(undefined); - answers[10] = false; + const answers = new Array(12).fill(undefined); + answers[11] = false; const init = await repokeeper(dir, ["init", "--interactive"], answers); expect(init.code).toBe(0); expect(init.out).toContain("nothing written"); @@ -273,9 +296,9 @@ describe("init --interactive", () => { it("with --dry-run asks about the parts and shows the result without the last question", async () => { const dir = await nodeRepo(); - const init = await repokeeper(dir, ["init", "--interactive", "--dry-run"], new Array(10).fill(undefined)); + const init = await repokeeper(dir, ["init", "--interactive", "--dry-run"], new Array(11).fill(undefined)); expect(init.code).toBe(0); - expect(init.asked).toEqual(QUESTIONS.slice(0, 10)); + expect(init.asked).toEqual(QUESTIONS.slice(0, 11)); expect(init.out).toContain("dry run: nothing written"); expect(existsSync(join(dir, ".repokeeper.yml"))).toBe(false); }); diff --git a/test/report-security.test.ts b/test/report-security.test.ts new file mode 100644 index 0000000..2ee8697 --- /dev/null +++ b/test/report-security.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { nextSteps, printWarnings } from "../src/commands/report.js"; +import type { Output } from "../src/model.js"; +import type { SyncResult } from "../src/sync/sync.js"; +import { capture, gitlabContext, makeContext, tempDir } from "./helpers.js"; + +const workflow: Output = { + kind: "yaml", + module: "security", + path: ".github/workflows/security.yml", + keyPath: ["name"], + value: "security", +}; +const result = (...decisions: SyncResult["decisions"]): SyncResult => ({ decisions, removals: [] }); +const on = { modules: { security: true } }; + +describe("what repokeeper says about code scanning", () => { + it("says what a private repository needs, once: when the workflow is new", async () => { + const created = await nextSteps(".", makeContext(on), result({ output: workflow, action: "create" })); + expect(created).toEqual([ + "code scanning is free on a public repository; on a private one, turn on GitHub Code Security, or the security workflow fails when it uploads its results", + ]); + // a repository that has had the workflow for a while is not told again on every update + expect(await nextSteps(".", makeContext(on), result({ output: workflow, action: "unchanged" }))).toEqual([]); + expect(await nextSteps(".", makeContext(on), result({ output: workflow, action: "write" }))).toEqual([]); + }); + + it("stays quiet on GitHub when nothing was written", async () => { + const noted = capture(); + await printWarnings(await tempDir(), makeContext(on), noted.io); + expect(noted.out).toEqual([]); + }); + + it("says on GitLab that the switch writes nothing, and what does the work there", async () => { + const noted = capture(); + await printWarnings(await tempDir(), gitlabContext(on), noted.io); + expect(noted.out).toContain( + "note: modules.security writes nothing on gitlab; include GitLab's SAST and Secret-Detection templates in .gitlab-ci.yml instead", + ); + const quiet = capture(); + await printWarnings(await tempDir(), gitlabContext(), quiet.io); + expect(quiet.out.join("\n")).not.toContain("modules.security"); + }); +}); diff --git a/test/workflows.test.ts b/test/workflows.test.ts index d7c61ac..d5859f2 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -54,6 +54,7 @@ const REUSABLE = [ "stack-rust.yml", "stack-php.yml", "stack-ruby.yml", + "codeql.yml", ]; /** Fixture jobs in workflow-tests.yml and the pack that resolves each fixture. Each stack task appends its rows. */ @@ -131,6 +132,49 @@ describe.each(FIXTURES)("fixture $dir", ({ job, dir, pack }) => { }); }); +describe("codeql.yml", () => { + it("analyzes one language per job, with the build mode it was given", () => { + const wf = parse(read("codeql.yml")) as { + jobs: { analyze: { strategy: { matrix: { include: string } }; permissions: Record } }; + }; + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + expect(wf.jobs.analyze.strategy.matrix.include).toBe("${{ fromJSON(inputs.languages) }}"); + expect(wf.jobs.analyze.permissions).toEqual({ "security-events": "write", actions: "read", contents: "read" }); + const [init, analyze] = steps(workflow("codeql.yml")).filter((step) => step.uses?.includes("codeql-action")) as { + uses: string; + with: Record; + }[]; + expect(init?.uses).toMatch(/^github\/codeql-action\/init@/); + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + expect(init?.with).toEqual({ languages: "${{ matrix.language }}", "build-mode": "${{ matrix.build-mode }}" }); + expect(analyze?.uses).toMatch(/^github\/codeql-action\/analyze@/); + // both steps are one release of the action + expect(analyze?.uses.split("@")[1]).toBe(init?.uses.split("@")[1]); + // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions + expect(analyze?.with).toEqual({ category: "/language:${{ matrix.language }}", upload: "${{ inputs.upload }}" }); + }); + + it("is exercised by workflow-tests on a language read from source and one that is built, without uploading", () => { + const tests = workflow("workflow-tests.yml"); + const job = tests.jobs.codeql; + expect(job?.uses).toBe("./.github/workflows/codeql.yml"); + expect(job?.needs).toBe("changes"); + expect(job?.if).toBe( + "contains(needs.changes.outputs.run, ',all,') || contains(needs.changes.outputs.run, ',codeql,')", + ); + // what the called job asks for + expect(job?.permissions).toEqual({ "security-events": "write", actions: "read", contents: "read" }); + expect(job?.with?.upload).toBe("never"); + expect(JSON.parse(job?.with?.languages ?? "[]")).toEqual([ + { language: "javascript-typescript", "build-mode": "none" }, + { language: "go", "build-mode": "autobuild" }, + { language: "actions", "build-mode": "none" }, + ]); + const script = tests.jobs.changes?.steps?.map((s) => s.run ?? "").join("\n") ?? ""; + expect(script).toContain('[codeql]="fixtures/node/ fixtures/go/ .github/workflows/codeql.yml"'); + }); +}); + it("workflow-tests cancels superseded pull request runs", () => { expect(workflow("workflow-tests.yml").concurrency).toEqual({ // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions