From e63fbd1854ddd519ea0d52304faeb83081058e45 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 13 Oct 2026 20:53:49 +0700 Subject: [PATCH 1/2] fix: ship the update workflow in the package modules.autoupdate with `github.workflows.source: local` gives the repository its own copy of repokeeper-update.yml, read from the installed package. The file was missing from the package's `files`, so that copy could not be read: it worked from a checkout of this repository and would have failed from npm. A test now checks that every reusable workflow a repository can be given a copy of is covered by `files`. --- package.json | 3 ++- test/package-files.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 test/package-files.test.ts diff --git a/package.json b/package.json index c6131b7..af7fd8b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ ".github/workflows/stack-*.yml", ".github/workflows/commitlint.yml", ".github/workflows/release-please.yml", - ".github/workflows/repokeeper-check.yml" + ".github/workflows/repokeeper-check.yml", + ".github/workflows/repokeeper-update.yml" ], "engines": { "node": ">=22.12.0" diff --git a/test/package-files.test.ts b/test/package-files.test.ts new file mode 100644 index 0000000..d8dbec3 --- /dev/null +++ b/test/package-files.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { listWorkflows } from "../src/templates.js"; + +const { files } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { files: string[] }; + +/** Whether a `files` entry of package.json covers `path`; `*` stands for part of one file name. */ +const shipped = (path: string) => + files.some((pattern) => { + const source = pattern + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join("[^/]*"); + return new RegExp(`^${source}$`).test(path); + }); + +describe("the published package", () => { + it("ships every reusable workflow a repository can be given a copy of", () => { + // `github.workflows.source: local` reads them from the installed package, not from the repository + const missing = listWorkflows().filter((name) => !shipped(`.github/workflows/${name}`)); + expect(missing).toEqual([]); + }); +}); From ca7cd1e498837b5bccd0b0235df7ca7b2366ef4b Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 13 Oct 2026 21:02:57 +0700 Subject: [PATCH 2/2] feat: code scanning with CodeQL, and secret scanning as a setting repokeeper set up no scanning of any kind, and its README said so. modules.security, off unless asked for, writes .github/workflows/security.yml. Its codeql job calls the new reusable workflow codeql.yml on every pull request, on every push to the default branch and once a week. The languages come from the stacks: node, python, java, dotnet, ruby and rust are read from source, go and kotlin are built by CodeQL itself, and the workflow files are always analyzed too. Dart, PHP and scripts have no CodeQL language. github.security takes secret_scanning and secret_scanning_push_protection, which `repokeeper github apply` reads and sets like the Dependabot switches. When GitHub refuses, its answer is reported with what a private repository needs. No preset switches either on: both are free on a public repository and paid on a private one, where a preset would leave a failing workflow behind. On GitLab the module writes nothing and says what does the work there. workflow-tests runs codeql.yml on a language read from source and on one that is built, without uploading the fixtures' findings. --- .github/workflows/codeql.yml | 46 ++++++++++ .github/workflows/workflow-tests.yml | 17 ++++ README.md | 20 +++-- docs/ci-and-releases.md | 39 +++++++++ docs/github-settings.md | 11 ++- package.json | 3 +- playground/config.d.ts | 1 + playground/config.js | 25 +++++- src/commands/presets.ts | 8 +- src/commands/report.ts | 11 +++ src/config/load.ts | 13 ++- src/config/schema.ts | 8 +- src/config/types.ts | 3 + src/github/settings.ts | 35 +++++++- src/model.ts | 2 + src/modules/index.ts | 2 + src/modules/security.ts | 7 ++ src/platforms/github.ts | 71 +++++++++++++++- src/platforms/gitlab.ts | 6 ++ src/templates.ts | 3 +- test/config.test.ts | 3 +- test/github-secret-scanning.test.ts | 107 +++++++++++++++++++++++ test/modules-security.test.ts | 123 +++++++++++++++++++++++++++ test/presets.test.ts | 39 +++++++-- test/report-security.test.ts | 44 ++++++++++ test/workflows.test.ts | 44 ++++++++++ 26 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 src/modules/security.ts create mode 100644 test/github-secret-scanning.test.ts create mode 100644 test/modules-security.test.ts create mode 100644 test/report-security.test.ts 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