diff --git a/.github/workflows/continuous-security-self-test.yml b/.github/workflows/continuous-security-self-test.yml new file mode 100644 index 0000000..1834e51 --- /dev/null +++ b/.github/workflows/continuous-security-self-test.yml @@ -0,0 +1,32 @@ +name: continuous-security contract self-test + +# Runs the continuous-security contract (gitops spec 009 T143) at this commit, +# so a change to the scheduled assessment that drops a required guarantee (a +# surface, an explicit BLOCKED, deduplicated issue routing, OIDC without static +# credentials, a SHA pin, the read-only boundary) fails its own pull request. + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/continuous-security.yml" + - ".github/workflows/continuous-security-self-test.yml" + - "tests/workflows/continuous-security.bats" + push: + branches: [main] + paths: + - ".github/workflows/continuous-security.yml" + - ".github/workflows/continuous-security-self-test.yml" + - "tests/workflows/continuous-security.bats" + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run the continuous-security contract + run: tests/workflows/continuous-security.bats diff --git a/.github/workflows/continuous-security.yml b/.github/workflows/continuous-security.yml new file mode 100644 index 0000000..07beae7 --- /dev/null +++ b/.github/workflows/continuous-security.yml @@ -0,0 +1,348 @@ +name: continuous security + +# Scheduled source, image and cluster vulnerability assessment with actionable +# issue routing (gitops spec 009 T146, FR-035). Trivy in ci.yml scans what a +# push builds, and Trivy Operator scans what a cluster runs; neither re-assesses +# an artifact that was already published when a CVE is disclosed days later. +# This workflow closes that gap on a schedule. +# +# The schedule lives in each CALLER, never here: GitHub does not fire +# `schedule` for a reusable workflow, so a cron in this file would silently +# never run. A caller declares `on: schedule:` and calls this workflow, and +# grants `issues: write` so a finding can be routed. +# +# Three surfaces, each reported as PASS, FAIL or BLOCKED, the same explicit +# vocabulary the lane's collectors use (microservice-app-gitops +# scripts/managed/lib/verify-common.sh). A surface without credentials is +# BLOCKED and says so: it is never counted as a pass, and it never fails the run +# as though the platform were broken. +# +# source - the caller's own checkout. Always runs; needs no credential. +# image - the digests the GitOps repository declares for this service, which +# is what "running artifact" means here. Read through a read-only +# App token scoped to that one repository, the same pattern +# mirror-platform-images.yml uses for the locked image set. +# cluster - the lane's existing read-only collector, verify-security.sh. This +# workflow starts no new imperative path into a cluster. +# +# Like mirror-platform-images.yml, the parts that need infrastructure nobody has +# built yet are authored and contract-tested but inactive: the image surface +# needs the reader role and published digests, and the cluster surface needs a +# cluster with Argo CD (spec 009 T063-T065). Both report BLOCKED until then, +# while the source surface runs from the first scheduled execution. + +on: + workflow_dispatch: + inputs: + service-name: + description: Service whose declared digests to re-assess; empty for a repository with no image + type: string + default: "" + image-repository: + description: Registry repository holding that service's images; empty leaves the image surface BLOCKED + type: string + default: "" + aws-region: + description: Region of the image registry + type: string + default: us-east-1 + image-reader-role-arn: + description: Read-only ECR role assumed through OIDC; empty leaves the image surface BLOCKED + type: string + default: "" + cluster-assessment: + description: Run the GitOps read-only cluster collector; requires a reachable cluster + type: boolean + default: false + gitops-ref: + description: The reviewed microservice-app-gitops ref to read declared digests from + type: string + default: main + workflow_call: + inputs: + service-name: + type: string + default: "" + image-repository: + type: string + default: "" + aws-region: + type: string + default: us-east-1 + image-reader-role-arn: + type: string + default: "" + cluster-assessment: + type: boolean + default: false + gitops-ref: + type: string + default: main + secrets: + gitops-reader-app-id: + description: GitHub App id with read-only contents on microservice-app-gitops + required: false + gitops-reader-app-key: + description: GitHub App private key (PEM) + required: false + +permissions: + contents: read + +jobs: + assess: + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write # OIDC to the read-only image role, no static credentials + issues: write # route an actionable finding into the owning repository + env: + FINDINGS: ${{ github.workspace }}/continuous-security-findings.md + ISSUE_LABEL: continuous-security + ISSUE_TITLE: "Continuous security: unresolved HIGH/CRITICAL findings" + steps: + - name: Check out the repository under assessment + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Start the assessment record + shell: bash + run: | + set -euo pipefail + : > "$FINDINGS" + + # --- source ------------------------------------------------------------ + # Fixable HIGH and CRITICAL only, the same threshold spec 008 chose for + # Trivy Operator: an unfixable finding is not something this repository's + # owner can act on today, and routing it would be noise, not ownership. + - name: Assess the source for newly disclosed vulnerabilities + id: source + continue-on-error: true + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 + with: + scan-type: fs + scan-ref: . + format: table + output: source-findings.txt + exit-code: "1" + severity: HIGH,CRITICAL + ignore-unfixed: true + version: v0.72.0 + + - name: Record the source result + shell: bash + env: + OUTCOME: ${{ steps.source.outcome }} + run: | + set -euo pipefail + if [[ "$OUTCOME" == success ]]; then + echo "PASS: source - no fixable HIGH or CRITICAL finding" >> "$FINDINGS" + else + echo "FAIL: source - fixable HIGH or CRITICAL findings below" >> "$FINDINGS" + echo '```' >> "$FINDINGS" + head -c 20000 source-findings.txt >> "$FINDINGS" || true + echo '```' >> "$FINDINGS" + fi + + # --- image ------------------------------------------------------------- + # The `secrets` context is not available in an `if`, so whether the + # read-only App credentials were wired is resolved in a step, where the + # context is available, and read back as a step output. + - name: Detect the read-only GitOps credentials + id: gitopscreds + shell: bash + env: + APP_ID: ${{ secrets.gitops-reader-app-id }} + APP_KEY: ${{ secrets.gitops-reader-app-key }} + run: | + set -euo pipefail + if [[ -n "$APP_ID" && -n "$APP_KEY" ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Mint a read-only GitOps token + id: gitopstoken + if: ${{ inputs.service-name != '' && inputs.image-reader-role-arn != '' && steps.gitopscreds.outputs.present == 'true' }} + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.gitops-reader-app-id }} + private-key: ${{ secrets.gitops-reader-app-key }} + owner: ${{ github.repository_owner }} + repositories: microservice-app-gitops + + - name: Check out the declared desired state (read-only, GitOps only) + if: ${{ steps.gitopstoken.outcome == 'success' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository_owner }}/microservice-app-gitops + ref: ${{ inputs.gitops-ref }} + token: ${{ steps.gitopstoken.outputs.token }} + persist-credentials: false + path: gitops + + - name: Resolve the digests the GitOps repository declares + id: digests + if: ${{ steps.gitopstoken.outcome == 'success' }} + shell: bash + env: + SERVICE_NAME: ${{ inputs.service-name }} + run: | + set -euo pipefail + # Every overlay that declares a digest for this service, deduplicated: + # one image is promoted unchanged across environments, so scanning + # each distinct digest once is the whole running set. + grep -rhoE 'digest: sha256:[a-f0-9]{64}' \ + "gitops/apps/${SERVICE_NAME}/profiles"/*/overlays/*/kustomization.yaml 2>/dev/null \ + | awk '{print $2}' | sort -u > declared-digests.txt || true + count="$(wc -l < declared-digests.txt | tr -d ' ')" + echo "count=${count}" >> "$GITHUB_OUTPUT" + + - name: Assume the read-only image role + if: ${{ steps.digests.outputs.count != '' && steps.digests.outputs.count != '0' }} + uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5 + with: + role-to-assume: ${{ inputs.image-reader-role-arn }} + aws-region: ${{ inputs.aws-region }} + + - name: Log in to the image registry + if: ${{ steps.digests.outputs.count != '' && steps.digests.outputs.count != '0' }} + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2 + + - name: Assess every declared image digest + id: image + if: ${{ steps.digests.outputs.count != '' && steps.digests.outputs.count != '0' }} + continue-on-error: true + shell: bash + env: + IMAGE_REPOSITORY: ${{ inputs.image-repository }} + run: | + set -euo pipefail + # The checksum-locked Trivy binary, the release and checksum + # microservice-app-gitops scripts/managed/full-profile-toolchain.lock + # records, installed the same way mirror-platform-images.yml installs + # its tooling. A loop over digests is why this is the binary and not + # the action: an action cannot iterate a resolved list. + bindir="$RUNNER_TEMP/bin"; mkdir -p "$bindir" + trivyurl="https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz" + trivysum="2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a" + curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location "$trivyurl" -o trivy.tgz + printf '%s %s\n' "$trivysum" trivy.tgz | sha256sum --check --strict - + tar -xzf trivy.tgz -C "$bindir" trivy + export PATH="$bindir:$PATH" + + status=0 + : > image-findings.txt + while IFS= read -r digest; do + [[ -n "$digest" ]] || continue + echo "== ${IMAGE_REPOSITORY}@${digest}" >> image-findings.txt + trivy image --severity HIGH,CRITICAL --ignore-unfixed --format table \ + --exit-code 1 "${IMAGE_REPOSITORY}@${digest}" >> image-findings.txt 2>&1 || status=1 + done < declared-digests.txt + exit "$status" + + - name: Record the image result + shell: bash + env: + SERVICE_NAME: ${{ inputs.service-name }} + IMAGE_REPOSITORY: ${{ inputs.image-repository }} + ROLE_ARN: ${{ inputs.image-reader-role-arn }} + DIGEST_COUNT: ${{ steps.digests.outputs.count }} + OUTCOME: ${{ steps.image.outcome }} + run: | + set -euo pipefail + if [[ -z "$SERVICE_NAME" || -z "$IMAGE_REPOSITORY" ]]; then + echo "BLOCKED: image - this repository declares no service image to assess" >> "$FINDINGS" + elif [[ -z "$ROLE_ARN" ]]; then + echo "BLOCKED: image - no read-only image role is configured for this caller" >> "$FINDINGS" + elif [[ -z "$DIGEST_COUNT" ]]; then + echo "BLOCKED: image - the declared desired state could not be read" >> "$FINDINGS" + elif [[ "$DIGEST_COUNT" == 0 ]]; then + echo "BLOCKED: image - the GitOps repository declares no digest for ${SERVICE_NAME} yet" >> "$FINDINGS" + elif [[ "$OUTCOME" == success ]]; then + echo "PASS: image - ${DIGEST_COUNT} declared digest(s) carry no fixable HIGH or CRITICAL finding" >> "$FINDINGS" + else + echo "FAIL: image - a declared digest carries fixable HIGH or CRITICAL findings" >> "$FINDINGS" + echo '```' >> "$FINDINGS" + head -c 20000 image-findings.txt >> "$FINDINGS" || true + echo '```' >> "$FINDINGS" + fi + + # --- cluster ----------------------------------------------------------- + # The lane already owns a read-only cluster collector with this exact + # vocabulary; this workflow runs it rather than opening a second path into + # a cluster. It exits non-zero when it cannot reach one, which is BLOCKED + # in its own output and reported as such here. + - name: Assess the cluster through the read-only collector + id: cluster + if: ${{ inputs.cluster-assessment && steps.gitopstoken.outcome == 'success' }} + continue-on-error: true + shell: bash + run: | + set -euo pipefail + bash gitops/scripts/managed/verify-security.sh 2>&1 | tee cluster-findings.txt + + - name: Record the cluster result + shell: bash + env: + REQUESTED: ${{ inputs.cluster-assessment }} + OUTCOME: ${{ steps.cluster.outcome }} + run: | + set -euo pipefail + if [[ "$REQUESTED" != true ]]; then + echo "BLOCKED: cluster - no cluster assessment is configured for this caller" >> "$FINDINGS" + elif [[ "$OUTCOME" != success && "$OUTCOME" != failure ]]; then + echo "BLOCKED: cluster - the collector did not run" >> "$FINDINGS" + elif grep -q 'VERDICT: BLOCKED' cluster-findings.txt 2>/dev/null; then + echo "BLOCKED: cluster - the collector could not reach a cluster" >> "$FINDINGS" + elif [[ "$OUTCOME" == success ]]; then + echo "PASS: cluster - the read-only collector reported no failure" >> "$FINDINGS" + else + echo "FAIL: cluster - the read-only collector reported a failure" >> "$FINDINGS" + echo '```' >> "$FINDINGS" + head -c 20000 cluster-findings.txt >> "$FINDINGS" || true + echo '```' >> "$FINDINGS" + fi + + # --- routing ----------------------------------------------------------- + # One issue per repository, reused. A daily schedule that opened a new + # issue for the same CVE would bury the signal in its own noise, so a + # recurring finding comments on the open issue instead, and a clean run + # closes it. + - name: Route the findings into the owning repository + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + echo "--- assessment ---" + cat "$FINDINGS" + + existing="$(gh issue list --repo "$TARGET_REPO" --label "$ISSUE_LABEL" --state open \ + --json number --jq '.[0].number // empty' || true)" + + if grep -q '^FAIL:' "$FINDINGS"; then + { + echo "Assessed on $(date -u +%Y-%m-%dT%H:%M:%SZ) by [this run]($RUN_URL)." + echo + cat "$FINDINGS" + } > issue-body.md + if [[ -n "$existing" ]]; then + gh issue comment "$existing" --repo "$TARGET_REPO" --body-file issue-body.md + echo "recurring findings recorded on issue #${existing}" + else + gh issue create --repo "$TARGET_REPO" --title "$ISSUE_TITLE" \ + --label "$ISSUE_LABEL" --body-file issue-body.md + fi + exit 1 + fi + + if [[ -n "$existing" ]]; then + gh issue close "$existing" --repo "$TARGET_REPO" \ + --comment "No fixable HIGH or CRITICAL finding remains as of [this run]($RUN_URL)." + fi + echo "no actionable finding" diff --git a/tests/workflows/continuous-security.bats b/tests/workflows/continuous-security.bats new file mode 100755 index 0000000..60aaa6b --- /dev/null +++ b/tests/workflows/continuous-security.bats @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Contract test for the scheduled continuous-security workflow (gitops spec 009 +# T143, for T146). Same grep-the-contract style as +# tests/workflows/mirror-platform-images.bats: it asserts the guarantees the +# task requires and, just as important, that the workflow does NOT reintroduce +# what it forbids (static credentials, a mutating cluster verb, an unpinned +# action). Executable bash, run directly; no bats framework, matching the +# repository's other contract tests. +# +# FR-035 is the requirement behind it: running artifacts and their host +# environments are assessed continuously for newly disclosed vulnerabilities, +# and an actionable finding is routed into the review process rather than left +# in a log nobody reads. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +workflow="$repo_root/.github/workflows/continuous-security.yml" +self_test="$repo_root/.github/workflows/continuous-security-self-test.yml" + +fail() { echo "continuous-security-contract: $*" >&2; exit 1; } + +[[ -f "$workflow" ]] || fail "workflow is missing: $workflow" +[[ -f "$self_test" ]] || fail "self-test workflow is missing: $self_test" + +require_literal() { + grep -Fq -- "$1" "$workflow" || fail "missing required contract literal: $1" +} +forbid_literal() { + grep -Fq -- "$1" "$workflow" && fail "forbidden literal present: $1" || true +} +forbid_regex() { + grep -Eq -- "$1" "$workflow" && fail "forbidden pattern present: $1" || true +} + +# --- reusable, and schedulable by its callers ------------------------------- +# The schedule lives in each caller, not here: GitHub never fires `schedule` for +# a reusable workflow, so a cron in this file would silently never run. +# Matched as a YAML key on its own line, not as text: the header explains the +# rule in prose, and forbidding the bare word would report that explanation. +require_literal "workflow_call" +require_literal "workflow_dispatch" +grep -Eq '^[[:space:]]*schedule:' "$workflow" \ + && fail "the reusable workflow must not declare a schedule; its callers do" || true + +# --- the three surfaces T146 names ------------------------------------------ +# Source: the caller's own checkout, every run, needing no credential. +require_literal "aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1" +require_literal "scan-type: fs" +require_literal "HIGH,CRITICAL" + +# Image: the digests the GitOps repository declares for this service, which is +# what "running artifact" means here, read through a read-only App token, the +# same way mirror-platform-images.yml reads the locked image set. +require_literal "service-name" +require_literal "image-reader-role-arn" +require_literal "actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547" +require_literal "microservice-app-gitops" +require_literal "profiles" +# Scanned by the checksum-locked Trivy release the toolchain lock records, not +# by the action: the set of declared digests is resolved at run time and an +# action cannot iterate it. +require_literal "trivy image" +require_literal "2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a" +require_literal "sha256sum" +require_literal "--check" + +# Cluster: the lane's existing read-only collector, never a new imperative path. +require_literal "verify-security.sh" + +# --- every surface reports PASS, FAIL or BLOCKED, never a silent skip ------- +# A surface with no credentials is BLOCKED and says so; it is never counted as +# a pass, and it never fails the run as though the platform were broken. +require_literal "BLOCKED" +require_literal "PASS" +require_literal "FAIL" + +# --- actionable routing: one issue, in the repository that owns the artifact - +require_literal "issues: write" +require_literal "gh issue" +require_literal "continuous-security" + +# --- a recurring finding updates its issue instead of opening a new one ----- +# Without this a daily schedule files the same CVE 365 times and the signal is +# lost in its own noise. +require_literal "--state open" +require_literal "gh issue comment" + +# --- a run that finds nothing closes the issue it opened -------------------- +require_literal "gh issue close" + +# --- OIDC only, no static credentials anywhere ------------------------------ +require_literal "id-token: write" +require_literal "aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708" +forbid_regex "aws_access_key_id|AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID" + +# --- the registry is an input, so no account lives in this file ------------- +# ci.yml already takes `ecr-repository` from its caller; the same shape here +# means this workflow holds no account at all, and no retired one can rot in it. +require_literal "image-repository" +forbid_literal "916491575487" +forbid_literal "995253610162" +forbid_regex "[0-9]{12}\.dkr\.ecr\." + +# --- read-only: this workflow assesses, it never changes a cluster ---------- +if grep -Eq 'kubectl[[:space:]]+(apply|patch|delete|scale|replace|create|edit|annotate|label)' "$workflow"; then + fail "cluster-mutating kubectl verb present" +fi + +# --- every `uses:` is pinned by full 40-hex SHA ----------------------------- +while IFS= read -r line; do + ref="$(sed -E 's/.*uses:[[:space:]]*//' <<<"$line" | awk '{print $1}')" + [[ "$ref" == ./* ]] && continue + [[ "$ref" =~ @[0-9a-f]{40}$ ]] || fail "unpinned action: $ref" +done < <(grep -E '^\s*(-\s*)?uses:' "$workflow") + +# --- the self-test runs this contract on every change that could break it --- +for path in \ + ".github/workflows/continuous-security.yml" \ + ".github/workflows/continuous-security-self-test.yml" \ + "tests/workflows/continuous-security.bats"; do + grep -Fq -- "$path" "$self_test" \ + || fail "the self-test must run on changes to $path" +done +grep -Fq "tests/workflows/continuous-security.bats" "$self_test" \ + || fail "the self-test must execute this contract" + +echo "continuous-security-contract: OK: reusable and caller-scheduled, source/image/cluster surfaces with explicit PASS/FAIL/BLOCKED, digests read from GitOps through a read-only App token, findings routed to one deduplicated issue in the owning repository, OIDC with no static credentials, SHA-pinned actions, and no cluster mutation."