From f903f826e2295c743f8c17a3d6bf55fbe2f4bd92 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Wed, 7 Oct 2026 15:48:45 +0000 Subject: [PATCH] release: staging to main, 2026-10-07 Brings main to staging's tree (origin/staging c721476, v0.0.4): the shift-left security reusables, workflow template, security.yml and pre-commit hooks from #18. Closes #17 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNQLAMbXMXKgawsL7SKmD7 Signed-off-by: Claude --- .github/workflows/reusable-codeql.yml | 181 +++++++++++ .../workflows/reusable-dependency-audit.yml | 286 ++++++++++++++++++ .../workflows/reusable-dependency-review.yml | 112 +++++++ .github/workflows/security.yml | 32 ++ .pre-commit-config.yaml | 48 +++ README.md | 95 +++++- workflow-templates/security.properties.json | 6 + workflow-templates/security.yml | 58 ++++ 8 files changed, 808 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/reusable-codeql.yml create mode 100644 .github/workflows/reusable-dependency-audit.yml create mode 100644 .github/workflows/reusable-dependency-review.yml create mode 100644 .github/workflows/security.yml create mode 100644 .pre-commit-config.yaml create mode 100644 workflow-templates/security.properties.json create mode 100644 workflow-templates/security.yml diff --git a/.github/workflows/reusable-codeql.yml b/.github/workflows/reusable-codeql.yml new file mode 100644 index 0000000..61e4e74 --- /dev/null +++ b/.github/workflows/reusable-codeql.yml @@ -0,0 +1,181 @@ +# Reusable workflow: CodeQL static analysis (mzizi-dev/mzizi#62, the CI layer +# of shift-left security), as an advanced setup a repository calls. +# +# Call it from a workflow in the consuming repository: +# +# on: +# push: +# branches: [main, staging] +# pull_request: +# schedule: +# - cron: '27 5 * * 1' +# permissions: +# contents: read +# jobs: +# codeql: +# uses: mukoko-dev/.github/.github/workflows/reusable-codeql.yml@main +# permissions: +# actions: read +# contents: read +# security-events: write +# with: +# languages: rust, javascript-typescript, actions +# paths-ignore: | +# **/fixtures/** +# +# That publishes one check per language: `codeql / CodeQL (rust)` and so on. +# +# `languages` is comma- or space-separated. Each entry is a CodeQL language, +# optionally with its own build mode as `language:mode`: +# +# actions c-cpp csharp go java-kotlin javascript-typescript +# python ruby rust swift +# +# Aliases: javascript, typescript, js, ts -> javascript-typescript; +# java, kotlin -> java-kotlin; c, cpp -> c-cpp. +# +# Build modes: `none` (the default; analyses source without building it) and +# `autobuild`. go and swift do not support `none`: write `go:autobuild`. +# `manual` needs the repository's own build steps between init and analyze, +# which a reusable workflow cannot take, so a language that needs it belongs +# in the repository's own CodeQL workflow. +# +# `paths` and `paths-ignore` (newline-separated globs) narrow what is +# analysed. They apply to interpreted languages and to build-mode `none`; +# for compiled languages built with `autobuild`, CodeQL analyses what the +# build compiles. +# +# Two things the CALLER'S repository must have, or the upload fails: +# +# - CodeQL DEFAULT setup switched OFF (Settings -> Code security -> Code +# scanning). GitHub rejects advanced-setup results while default setup is +# on. +# - For a PRIVATE repository, GitHub Code Security (Advanced Security). +# Public repositories have code scanning free. +# +# No secret is needed. Results go to the repository's Security tab; a +# finding does not fail the job. To block merges on findings, add a +# code-scanning merge-protection rule ("Require code scanning results") to +# the ruleset, which is the setting that reads these results. + +name: Reusable / CodeQL + +on: + workflow_call: + inputs: + languages: + description: Comma- or space-separated CodeQL languages, each optionally `language:build-mode` (see header). + type: string + required: true + build-mode: + description: Build mode for languages that name none (none or autobuild). + type: string + default: none + paths: + description: Newline-separated globs to analyse. Empty means the whole repository. + type: string + default: '' + paths-ignore: + description: Newline-separated globs to leave out of the analysis. + type: string + default: '' + queries: + description: CodeQL query suites to run in addition to the default suite. + type: string + default: security-extended + +permissions: + contents: read + +jobs: + plan: + name: plan + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + config: ${{ steps.plan.outputs.config }} + steps: + - name: Build the language matrix and the CodeQL config + id: plan + env: + LANGUAGES: ${{ inputs.languages }} + DEFAULT_MODE: ${{ inputs.build-mode }} + PATHS: ${{ inputs.paths }} + PATHS_IGNORE: ${{ inputs.paths-ignore }} + run: | + set -euo pipefail + set -f # the split below must not glob + valid_mode() { + case "$1" in + none|autobuild) ;; + manual) + echo "::error::build-mode 'manual' needs build steps a reusable workflow cannot take. Use the repo's own CodeQL workflow." + exit 1 + ;; + *) echo "::error::Unknown build mode '$1'. Use none or autobuild."; exit 1 ;; + esac + } + valid_mode "$DEFAULT_MODE" + entries=() + seen=" " + for raw in ${LANGUAGES//,/ }; do + lang="${raw%%:*}" + mode="$DEFAULT_MODE" + [ "$lang" = "$raw" ] || mode="${raw#*:}" + case "${lang,,}" in + javascript|typescript|js|ts|javascript-typescript) lang=javascript-typescript ;; + java|kotlin|java-kotlin) lang=java-kotlin ;; + c|cpp|c-cpp) lang=c-cpp ;; + actions|csharp|go|python|ruby|rust|swift) lang="${lang,,}" ;; + *) echo "::error::Unknown CodeQL language '$lang'."; exit 1 ;; + esac + valid_mode "$mode" + case "$seen" in *" $lang "*) continue ;; esac + seen="$seen$lang " + runner=ubuntu-latest + [ "$lang" = swift ] && runner=macos-latest + entries+=("$(jq -cn --arg l "$lang" --arg m "$mode" --arg r "$runner" '{language: $l, "build-mode": $m, runner: $r}')") + done + if [ "${#entries[@]}" -eq 0 ]; then + echo "::error::No language given. Set the 'languages' input, for example 'rust, javascript-typescript'." + exit 1 + fi + matrix=$(printf '%s\n' "${entries[@]}" | jq -cs .) + lines() { printf '%s\n' "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | jq -R . | jq -cs .; } + config=$(jq -cn --argjson p "$(lines "$PATHS")" --argjson i "$(lines "$PATHS_IGNORE")" \ + '{} + (if ($p | length) > 0 then {paths: $p} else {} end) + (if ($i | length) > 0 then {"paths-ignore": $i} else {} end)') + [ "$config" != "{}" ] || config="" + { echo "matrix=$matrix"; echo "config=$config"; } >> "$GITHUB_OUTPUT" + echo "Languages: $matrix" + echo "Config: ${config:-(none)}" + + analyze: + name: CodeQL (${{ matrix.language }}) + needs: plan + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: ${{ inputs.queries }} + config: ${{ needs.plan.outputs.config }} + - if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/reusable-dependency-audit.yml b/.github/workflows/reusable-dependency-audit.yml new file mode 100644 index 0000000..fdcbdb5 --- /dev/null +++ b/.github/workflows/reusable-dependency-audit.yml @@ -0,0 +1,286 @@ +# Reusable workflow: dependency audit per ecosystem (mzizi-dev/mzizi#62, the +# CI layer of shift-left security). +# +# Where reusable-dependency-review.yml asks "does this pull request ADD a +# vulnerable dependency?", this asks "is what this repository depends on +# TODAY acceptable?". It audits the whole tree, not a diff, so it belongs on +# push and on a schedule as well as on pull requests: an advisory can be +# published against a dependency the repository already has, with no change +# here at all. +# +# Ecosystems (the `ecosystems` input, comma- or space-separated): +# +# rust cargo deny, against each manifest in `rust-manifests`, with the +# caller's `deny-config` (advisories, bans, licences, sources). With +# no deny.toml there is nothing to judge licences or sources against, +# so only advisories are checked and the job warns. +# js osv-scanner, against every JS lockfile (package-lock.json, +# npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock, bun.lock) found +# directly in each directory listed in `js-paths`. Ignores go in an +# osv-scanner.toml next to the lockfile. +# +# Each selected ecosystem is its own job, so the checks are +# ` / cargo deny` and ` / osv-scanner`. A selected +# ecosystem with nothing to audit (a missing manifest, no lockfile) FAILS: +# a gate that inspects nothing must not pass. +# +# Call it from a workflow in the consuming repository: +# +# on: +# push: +# branches: [main, staging] +# pull_request: +# schedule: +# - cron: '17 6 * * 1' +# permissions: +# contents: read +# jobs: +# audit: +# uses: mukoko-dev/.github/.github/workflows/reusable-dependency-audit.yml@main +# with: +# ecosystems: rust, js +# rust-manifests: | +# Cargo.toml +# tools/bench/Cargo.toml +# js-paths: | +# . +# web +# +# Both tools are release binaries checked against a SHA-256 pinned HERE, not +# one downloaded next to the binary, so a replaced release asset fails the +# job instead of being trusted. To move a version, change the version and +# the hash together. No secret is needed; the token is read-only and the +# checkout keeps no credentials. Both tools fetch advisory data from the +# network (the RustSec database from GitHub, OSV from api.osv.dev). + +name: Reusable / Dependency audit + +on: + workflow_call: + inputs: + ecosystems: + description: Comma- or space-separated ecosystems to audit. Supported - rust, js. + type: string + required: true + rust-manifests: + description: Newline-separated Cargo.toml paths to audit with cargo deny. + type: string + default: Cargo.toml + deny-config: + description: Path to the cargo-deny config, used for every manifest. + type: string + default: deny.toml + cargo-deny-checks: + description: Space-separated cargo deny checks (advisories, bans, licenses, sources). Empty means all. + type: string + default: '' + js-paths: + description: Newline-separated directories whose JS lockfiles osv-scanner audits. + type: string + default: . + cargo-deny-version: + description: cargo-deny release to install. Change together with cargo-deny-sha256. + type: string + default: 0.20.2 + cargo-deny-sha256: + description: SHA-256 of cargo-deny--x86_64-unknown-linux-musl.tar.gz. + type: string + default: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f + osv-scanner-version: + description: osv-scanner release to install (without the leading v). Change together with osv-scanner-sha256. + type: string + default: 2.6.0 + osv-scanner-sha256: + description: SHA-256 of osv-scanner_linux_amd64 for that release. + type: string + default: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 + +permissions: + contents: read + +jobs: + plan: + name: plan + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + outputs: + rust: ${{ steps.pick.outputs.rust }} + js: ${{ steps.pick.outputs.js }} + steps: + - name: Choose the ecosystems + id: pick + env: + ECOSYSTEMS: ${{ inputs.ecosystems }} + run: | + set -euo pipefail + set -f # the split below must not glob + rust=false + js=false + for e in ${ECOSYSTEMS//,/ }; do + case "${e,,}" in + rust|cargo) rust=true ;; + js|javascript|typescript|node|npm|pnpm|yarn|bun) js=true ;; + *) echo "::error::Unknown ecosystem '$e'. Supported: rust, js."; exit 1 ;; + esac + done + if [ "$rust" = false ] && [ "$js" = false ]; then + echo "::error::No ecosystem selected. Set the 'ecosystems' input, for example 'rust, js'." + exit 1 + fi + { echo "rust=$rust"; echo "js=$js"; } >> "$GITHUB_OUTPUT" + + cargo-deny: + name: cargo deny + needs: plan + if: needs.plan.outputs.rust == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install cargo-deny + env: + VERSION: ${{ inputs.cargo-deny-version }} + SHA256: ${{ inputs.cargo-deny-sha256 }} + run: | + set -euo pipefail + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Unexpected cargo-deny version '$VERSION'."; exit 1; } + [[ "$SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::cargo-deny-sha256 must be 64 lowercase hex characters."; exit 1; } + dir=$(mktemp -d) + name="cargo-deny-${VERSION}-x86_64-unknown-linux-musl" + curl -sSfL --retry 3 -o "$dir/$name.tar.gz" \ + "https://github.com/EmbarkStudios/cargo-deny/releases/download/${VERSION}/${name}.tar.gz" + echo "${SHA256} $dir/$name.tar.gz" | sha256sum --check --strict + tar -xzf "$dir/$name.tar.gz" -C "$dir" + sudo install -m 0755 "$dir/$name/cargo-deny" /usr/local/bin/cargo-deny + cargo-deny --version + + - name: cargo deny check + env: + MANIFESTS: ${{ inputs.rust-manifests }} + CONFIG: ${{ inputs.deny-config }} + CHECKS: ${{ inputs.cargo-deny-checks }} + run: | + set -uo pipefail + summary() { printf '%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"; } + read -r -a checks <<< "$CHECKS" + for c in "${checks[@]}"; do + case "$c" in + advisories|bans|licenses|sources|all) ;; + *) echo "::error::Unknown cargo deny check '$c'."; exit 1 ;; + esac + done + cfg=() + if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then + cfg=(--config "$CONFIG") + else + echo "::warning::No cargo-deny config at '$CONFIG': checking advisories only. Add a deny.toml for bans, licences, sources." + checks=(advisories) + fi + summary "### cargo deny" + summary "" + summary "| Manifest | Result |" + summary "|---|---|" + failed=0 + audited=0 + while IFS= read -r m; do + m="${m#"${m%%[![:space:]]*}"}" + m="${m%"${m##*[![:space:]]}"}" + [ -n "$m" ] || continue + if [ ! -f "$m" ]; then + echo "::error::Manifest '$m' does not exist." + summary "| \`$m\` | **missing** |" + failed=1 + continue + fi + audited=$((audited + 1)) + echo "::group::cargo deny check: $m" + if cargo-deny --manifest-path "$m" "${cfg[@]}" check "${checks[@]}"; then + summary "| \`$m\` | pass |" + else + failed=1 + echo "::error file=$m::cargo deny reported a problem with this manifest's dependency graph." + summary "| \`$m\` | **fail** |" + fi + echo "::endgroup::" + done <<< "$MANIFESTS" + if [ "$audited" -eq 0 ] && [ "$failed" -eq 0 ]; then + echo "::error::No manifest to audit. Set the 'rust-manifests' input." + exit 1 + fi + exit "$failed" + + osv-scanner: + name: osv-scanner + needs: plan + if: needs.plan.outputs.js == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install osv-scanner + env: + VERSION: ${{ inputs.osv-scanner-version }} + SHA256: ${{ inputs.osv-scanner-sha256 }} + run: | + set -euo pipefail + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::Unexpected osv-scanner version '$VERSION'."; exit 1; } + [[ "$SHA256" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::osv-scanner-sha256 must be 64 lowercase hex characters."; exit 1; } + dir=$(mktemp -d) + curl -sSfL --retry 3 -o "$dir/osv-scanner" \ + "https://github.com/google/osv-scanner/releases/download/v${VERSION}/osv-scanner_linux_amd64" + echo "${SHA256} $dir/osv-scanner" | sha256sum --check --strict + sudo install -m 0755 "$dir/osv-scanner" /usr/local/bin/osv-scanner + osv-scanner --version + + - name: osv-scanner on the JS lockfiles + env: + PATHS: ${{ inputs.js-paths }} + run: | + set -uo pipefail + lockfiles=() + while IFS= read -r d; do + d="${d#"${d%%[![:space:]]*}"}" + d="${d%"${d##*[![:space:]]}"}" + [ -n "$d" ] || continue + [ -d "$d" ] || { echo "::error::Directory '$d' in js-paths does not exist."; exit 1; } + found=0 + for n in package-lock.json npm-shrinkwrap.json pnpm-lock.yaml yarn.lock bun.lock; do + if [ -f "$d/$n" ]; then + lockfiles+=("$d/$n") + found=1 + fi + done + [ "$found" -eq 1 ] || echo "::warning::No JS lockfile in '$d'." + done <<< "$PATHS" + if [ "${#lockfiles[@]}" -eq 0 ]; then + echo "::error::No JS lockfile in js-paths, so nothing to audit. Commit the lockfile, or drop 'js' from 'ecosystems'." + exit 1 + fi + args=() + for l in "${lockfiles[@]}"; do args+=(--lockfile "$l"); done + { + echo "### osv-scanner" + echo "" + printf -- "- \`%s\`\n" "${lockfiles[@]}" + } >> "$GITHUB_STEP_SUMMARY" + # Actions runs this under `bash -e`, so the exit code is caught here. + rc=0 + osv-scanner scan source "${args[@]}" || rc=$? + case "$rc" in + 0) echo "No known vulnerability in the scanned lockfiles." >> "$GITHUB_STEP_SUMMARY" ;; + 1) echo "::error::osv-scanner found known vulnerabilities. Upgrade, or ignore with a reason in osv-scanner.toml." + echo "**Known vulnerabilities found** - see the job log." >> "$GITHUB_STEP_SUMMARY" ;; + *) echo "::error::osv-scanner failed (exit $rc) before it could report." ;; + esac + exit "$rc" diff --git a/.github/workflows/reusable-dependency-review.yml b/.github/workflows/reusable-dependency-review.yml new file mode 100644 index 0000000..3669b71 --- /dev/null +++ b/.github/workflows/reusable-dependency-review.yml @@ -0,0 +1,112 @@ +# Reusable workflow: dependency review on pull requests (mzizi-dev/mzizi#62, +# the CI layer of shift-left security). +# +# Fails a pull request that ADDS a dependency with a known advisory at or +# above `fail-on-severity`. It compares GitHub's dependency graph for the +# base and head commits, so it covers every ecosystem the graph understands +# (Cargo, npm, pnpm, Yarn, pip, uv, Poetry, Go, Actions, ...) at once and +# needs no ecosystem or path input: a change anywhere in the repository is +# reviewed. +# +# Call it from a workflow in the consuming repository: +# +# on: +# pull_request: +# merge_group: +# permissions: +# contents: read +# jobs: +# dependency-review: +# uses: mukoko-dev/.github/.github/workflows/reusable-dependency-review.yml@main +# +# That publishes the check `dependency-review / dependency review`. +# +# On any event other than pull_request and merge_group the job passes with a +# notice: there is no base to compare, and a required check that never +# reports would block the PR forever. Use reusable-dependency-audit.yml for +# push and schedule runs. +# +# The token is read-only. The summary goes to the job summary, never a PR +# comment, so the caller grants only `contents: read` and the workflow also +# works on pull requests from forks. +# +# A PRIVATE repository needs the dependency graph enabled and GitHub Code +# Security (Advanced Security) for the dependency-review API; without it the +# action fails with a 403/"not enabled" error. + +name: Reusable / Dependency review + +on: + workflow_call: + inputs: + fail-on-severity: + description: Lowest advisory severity that fails the PR (low, moderate, high, critical). + type: string + default: low + fail-on-scopes: + description: Dependency scopes that can fail the PR (runtime, development, unknown). + type: string + default: runtime, development + allow-ghsas: + description: Comma-separated GHSA IDs to accept anyway. Prefer fixing; record why in the caller. + type: string + default: '' + config-file: + description: >- + Optional path, in the calling repository, to a dependency-review-action + config file (licence allow/deny lists and the like). Empty means none. + type: string + default: '' + +permissions: + contents: read + +jobs: + review: + name: dependency review + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Resolve the range to review + id: range + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + MG_BASE: ${{ github.event.merge_group.base_sha }} + MG_HEAD: ${{ github.event.merge_group.head_sha }} + run: | + set -euo pipefail + case "$EVENT" in + pull_request|pull_request_target) base=$PR_BASE head=$PR_HEAD ;; + merge_group) base=$MG_BASE head=$MG_HEAD ;; + *) + echo "::notice::Dependency review compares a pull request with its base; nothing to do on '$EVENT'." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + for sha in "$base" "$head"; do + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Unexpected commit id '$sha'."; exit 1; } + done + { echo "base=$base"; echo "head=$head"; echo "skip=false"; } >> "$GITHUB_OUTPUT" + + # Only a local config file needs the repository on disk. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.range.outputs.skip == 'false' && inputs.config-file != '' + with: + persist-credentials: false + + # actions/dependency-review-action ships semver tags only (no rolling @v5). + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + if: steps.range.outputs.skip == 'false' + with: + base-ref: ${{ steps.range.outputs.base }} + head-ref: ${{ steps.range.outputs.head }} + fail-on-severity: ${{ inputs.fail-on-severity }} + fail-on-scopes: ${{ inputs.fail-on-scopes }} + allow-ghsas: ${{ inputs.allow-ghsas }} + config-file: ${{ inputs.config-file }} + comment-summary-in-pr: never diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..940b6d8 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,32 @@ +# This repository's own shift-left security, and the test of the reusable +# workflows it publishes: it calls them by LOCAL path, so a pull request that +# changes one runs the changed version. Other repositories call them at +# mukoko-dev/.github/...@main (see workflow-templates/security.yml). +# +# There is no dependency audit here: this repository has no Cargo.toml and +# no JS lockfile, and the audit fails on purpose when it has nothing to +# audit. There is no CodeQL job either: CodeQL default setup is on for this +# repository and already scans its workflows, and GitHub rejects +# advanced-setup results while default setup is on. + +name: Security + +on: + push: + branches: [main, staging] + pull_request: + merge_group: + schedule: + - cron: '41 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + dependency-review: + uses: ./.github/workflows/reusable-dependency-review.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ec80c62 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,48 @@ +# Pre-commit hooks: the commit layer of shift-left security (#17), so a +# problem is caught on the developer's machine before it reaches Git or CI. +# +# pipx install pre-commit # or: pip install pre-commit +# pre-commit install # once per clone; then it runs on every commit +# pre-commit run --all-files +# +# Each hook runs on the staged files only. The same checks run again in CI +# (org-lint.yml, and the enterprise-required Semgrep and dependency review), +# so skipping a hook locally never skips the gate; it only finds out later. +# +# Every `rev` is a commit SHA with its tag beside it, like the actions in +# .github/workflows/. The linter versions match the ones reusable-lint.yml +# pins in CI, so local and CI results agree. + +exclude: ^profile/canonical/ # the Founder's text, committed byte-for-byte + +repos: + # Secrets: the check that matters most before a commit, because a secret + # that reaches a pushed commit has to be rotated, not just deleted. + - repo: https://github.com/gitleaks/gitleaks + rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: detect-private-key + - id: check-merge-conflict + - id: check-added-large-files + - id: check-json + + - repo: https://github.com/rhysd/actionlint + rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12 + hooks: + - id: actionlint + + - repo: https://github.com/adrienverge/yamllint + rev: cba56bcde1fdd01c1deb3f945e69764c291a6530 # frozen: v1.38.0 + hooks: + - id: yamllint + args: [--strict] + + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: b82a6c8896e491b9cb377a99ff3412131920681b # frozen: v0.23.2 + hooks: + - id: markdownlint-cli2 diff --git a/README.md b/README.md index 33037d8..dfa85ce 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,96 @@ meant to avoid. Only add a workflow file here when the behaviour genuinely differs from the shared one. If you ever do copy a reusable workflow into this org, say so in -the PR and record why, so the divergence is deliberate and visible. +the PR and record why, so the divergence is deliberate and visible. The +shift-left security workflows below are the one such case so far. What this repository does hold, in `.github/workflows/`, is the org-required -lint workflow and three thin callers that also apply to this repository -itself: - -| File | Name | Trigger | -| ------------------- | ---------- | ------------------------------------------------------------- | -| `org-lint.yml` | `Org lint` | Every pull request in every `mukoko-dev` repository | -| `lint.yml` | `Lint` | Pull requests, and pushes to `main`/`master`/`scaffold` | -| `pr-title-lint.yml` | `PR title` | `pull_request_target` — opened, edited, reopened, synchronize | -| `stale.yml` | `Stale` | Daily at 01:23 UTC, and `workflow_dispatch` | +lint workflow, the shift-left security reusables, and thin callers that also +apply to this repository itself: + +| File | Name | Trigger | +| -------------------------------- | ------------------------------ | ------------------------------------------------------------- | +| `org-lint.yml` | `Org lint` | Every pull request in every `mukoko-dev` repository | +| `lint.yml` | `Lint` | Pull requests, and pushes to `main`/`master`/`scaffold` | +| `pr-title-lint.yml` | `PR title` | `pull_request_target` — opened, edited, reopened, synchronize | +| `stale.yml` | `Stale` | Daily at 01:23 UTC, and `workflow_dispatch` | +| `security.yml` | `Security` | This repository: PRs, pushes, weekly | +| `reusable-dependency-review.yml` | `Reusable / Dependency review` | `workflow_call` | +| `reusable-dependency-audit.yml` | `Reusable / Dependency audit` | `workflow_call` | +| `reusable-codeql.yml` | `Reusable / CodeQL` | `workflow_call` | [hub]: https://github.com/nyuchi/.github/tree/main/.github/workflows +## Shift-left security + +The CI layer of the shift-left plan in +[mzizi-dev/mzizi#62](https://github.com/mzizi-dev/mzizi/issues/62). Three +reusable workflows, each with least-privilege permissions, actions pinned to +a commit, no secrets, and tool binaries checked against a SHA-256 pinned in +the workflow: + +| Workflow | What it answers | Inputs | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `reusable-dependency-review.yml` | Does this pull request **add** a dependency with a known advisory? Every ecosystem in GitHub's dependency graph at once. Passes with a notice on other events. | `fail-on-severity` (default `low`), `fail-on-scopes`, `allow-ghsas`, `config-file` | +| `reusable-dependency-audit.yml` | Is what the repository depends on **today** acceptable? `rust`: `cargo deny check` per manifest. `js`: `osv-scanner` per lockfile. Fails if it finds nothing. | `ecosystems`, `rust-manifests`, `deny-config`, `cargo-deny-checks`, `js-paths`, tool pins | +| `reusable-codeql.yml` | Static analysis, one job per language, results in the Security tab. | `languages` (e.g. `rust, javascript-typescript, actions`), `build-mode`, `paths`, `paths-ignore`, `queries` | + +Each file's header documents its inputs in full. The minimal caller, which +also appears as **Shift-left security** under Actions → New workflow in +every `mukoko-dev` repository, is +[`workflow-templates/security.yml`](workflow-templates/security.yml): + +```yaml +jobs: + dependency-review: + uses: mukoko-dev/.github/.github/workflows/reusable-dependency-review.yml@main + audit: + uses: mukoko-dev/.github/.github/workflows/reusable-dependency-audit.yml@main + with: + ecosystems: rust + codeql: + uses: mukoko-dev/.github/.github/workflows/reusable-codeql.yml@main + permissions: + actions: read + contents: read + security-events: write + with: + languages: rust, actions +``` + +Run it on `pull_request`, `merge_group`, `push` to the default branch and +`staging`, and a weekly `schedule` (the template does all four). Before the +first run, switch CodeQL **default** setup off in the repository, or GitHub +rejects the results; a private repository also needs GitHub Code Security +for dependency review and CodeQL. + +**The commit layer.** [`.pre-commit-config.yaml`](.pre-commit-config.yaml) +runs gitleaks, actionlint, yamllint and markdownlint on staged files before +each commit (`pre-commit install` once per clone). Linter versions match +the ones CI pins. Any repository can copy it and add hooks for its own +languages. It is a local convenience; the CI checks above stay the gate. + +**Who can call them.** This repository is **public**, so any repository in +any organisation can call these workflows, private callers included: +`mukoko-dev`, `mzizi-dev`, `bundu-labs` and `nyuchi` alike. What does +**not** cross organisations is enforcement: a `mukoko-dev` org ruleset can +require these checks only in `mukoko-dev` repositories. To require them in +`mzizi-dev`, that org's own ruleset must name them, or an enterprise ruleset +must. + +**Why here and not in `nyuchi/.github`.** The shared library already has +`reusable-codeql.yml`, `reusable-dependency-review.yml`, and the +enterprise-required `dependency-review.yml` (diff-aware review plus +`npm`/`pnpm`/`cargo audit`/`pip-audit` on changed lockfiles). These differ on +purpose: the review needs only `contents: read` (no PR comment, so it works +on forks) and fails from `low` severity; the audit checks the **whole** +dependency tree on push and schedule, not just changed lockfiles, with +`cargo deny` (licences, bans and sources too, not only advisories) and +`osv-scanner`; CodeQL takes a plain language list, per-language build modes, +`paths`/`paths-ignore`, and drops `packages: read`. If they prove out, the +next step is to upstream them to `nyuchi/.github` and turn these into thin +pointers, as `mzizi-dev/mzizi-registry` did for Vite+. + ## Lint is required org-wide The org ruleset `org-wide-main-protection` has a "Require workflows to pass" diff --git a/workflow-templates/security.properties.json b/workflow-templates/security.properties.json new file mode 100644 index 0000000..dd482d3 --- /dev/null +++ b/workflow-templates/security.properties.json @@ -0,0 +1,6 @@ +{ + "name": "Shift-left security", + "description": "Dependency review on PRs, a cargo deny / osv-scanner audit per ecosystem, and CodeQL. Calls the reusable workflows in mukoko-dev/.github.", + "iconName": "octicon shield-check", + "categories": ["Code Scanning", "Rust", "JavaScript", "TypeScript"] +} diff --git a/workflow-templates/security.yml b/workflow-templates/security.yml new file mode 100644 index 0000000..31025f1 --- /dev/null +++ b/workflow-templates/security.yml @@ -0,0 +1,58 @@ +# Shift-left security for one repository (mzizi-dev/mzizi#62): dependency +# review on pull requests, a dependency audit per ecosystem, and CodeQL. +# Copy this file to .github/workflows/security.yml, then edit the two +# `with:` blocks marked EDIT to match the repository. +# +# The checks it publishes: +# +# dependency-review / dependency review +# audit / cargo deny (when ecosystems includes rust) +# audit / osv-scanner (when ecosystems includes js) +# codeql / CodeQL (), one per language +# +# Before the first run, switch CodeQL DEFAULT setup off for the repository +# (Settings -> Code security -> Code scanning): GitHub rejects these results +# while it is on. A private repository needs GitHub Code Security for +# dependency review and CodeQL. + +name: Security + +on: + push: + branches: [$default-branch, staging] + pull_request: + merge_group: + schedule: + # Weekly, because an advisory can land against a dependency already here. + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + dependency-review: + uses: mukoko-dev/.github/.github/workflows/reusable-dependency-review.yml@main + + audit: + uses: mukoko-dev/.github/.github/workflows/reusable-dependency-audit.yml@main + with: + # EDIT: keep the ecosystems this repository has, and point at them. + ecosystems: rust, js + rust-manifests: Cargo.toml + js-paths: . + + codeql: + uses: mukoko-dev/.github/.github/workflows/reusable-codeql.yml@main + permissions: + actions: read + contents: read + security-events: write + with: + # EDIT: the languages this repository is written in. `actions` scans + # the workflow files themselves. + languages: rust, javascript-typescript, actions