From 89c617311eb9cc8830a108f523e38c773d432ac5 Mon Sep 17 00:00:00 2001 From: Tiago0507 Date: Sun, 20 Sep 2026 19:43:33 -0500 Subject: [PATCH 1/4] test(security): specify the continuous security assessment Adds tests/workflows/continuous-security.bats, the contract for the scheduled vulnerability assessment gitops spec 009 T146 must provide and T143 must test. It follows tests/workflows/mirror-platform-images.bats: grep the guarantees the task requires, and equally the things it forbids. The contract requires a reusable workflow that its callers schedule, because GitHub never fires schedule for a reusable workflow and a cron here would silently never run; the three surfaces T146 names, with the image surface reading the digests the GitOps repository declares through a read-only App token as mirror-platform-images.yml already does and the cluster surface reusing the lane's existing read-only verify-security.sh; an explicit PASS, FAIL or BLOCKED per surface, so a surface without credentials is never counted as a pass; routing into one deduplicated issue in the repository that owns the artifact, updated rather than reopened when a finding recurs and closed when it clears; OIDC with no static credentials; the declared account rather than a hard-coded one; no cluster-mutating verb; and every action pinned by full SHA. It also requires the self-test that runs this contract on every change that could break it. It fails as required: .github/workflows/continuous-security.yml is missing. --- tests/workflows/continuous-security.bats | 115 +++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100755 tests/workflows/continuous-security.bats diff --git a/tests/workflows/continuous-security.bats b/tests/workflows/continuous-security.bats new file mode 100755 index 0000000..dd13390 --- /dev/null +++ b/tests/workflows/continuous-security.bats @@ -0,0 +1,115 @@ +#!/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. +require_literal "workflow_call" +require_literal "workflow_dispatch" +forbid_literal "schedule:" + +# --- 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" +require_literal "scan-type: image" + +# 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 account is the declared one, never a hard-coded literal ------------ +require_literal "vars.AWS_ACCOUNT_ID" +forbid_literal "916491575487" +forbid_literal "995253610162" + +# --- 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." From 41218991cca690fcea92116bcca1497b33720545 Mon Sep 17 00:00:00 2001 From: Tiago0507 Date: Sun, 20 Sep 2026 19:47:49 -0500 Subject: [PATCH 2/4] test(security): assert the schedule rule and the registry as a key, not as text Three corrections to the contract added in the previous commit, all found by running it against a real implementation. The schedule check forbade the bare word "schedule:", so it reported the header comment that explains why the schedule belongs in the caller. It now matches a YAML key at the start of a line, which is the thing it meant to forbid. The image surface asserted "scan-type: image", which assumes the Trivy action. The set of declared digests is resolved at run time and an action cannot iterate it, so the implementation installs the checksum-locked Trivy release the toolchain lock records, the way mirror-platform-images.yml installs its tooling. The contract now asserts that release, its checksum, and the verified install instead. The account check required a vars.AWS_ACCOUNT_ID reference. Taking the registry as an input, the shape ci.yml already uses for ecr-repository, is stronger: the workflow then holds no account at all. The contract now requires that input and forbids any twelve-digit registry literal. The contract still fails for the right reason: .github/workflows/continuous-security.yml is missing. --- tests/workflows/continuous-security.bats | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/workflows/continuous-security.bats b/tests/workflows/continuous-security.bats index dd13390..2ba7701 100755 --- a/tests/workflows/continuous-security.bats +++ b/tests/workflows/continuous-security.bats @@ -35,9 +35,12 @@ forbid_regex() { # --- 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" -forbid_literal "schedule:" +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. @@ -53,7 +56,13 @@ require_literal "image-reader-role-arn" require_literal "actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547" require_literal "microservice-app-gitops" require_literal "profiles" -require_literal "scan-type: image" +# 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" @@ -84,10 +93,13 @@ 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 account is the declared one, never a hard-coded literal ------------ -require_literal "vars.AWS_ACCOUNT_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 From 136c3d1ae88bdf07bfc7d200e426f84012c72fb6 Mon Sep 17 00:00:00 2001 From: Tiago0507 Date: Sun, 20 Sep 2026 19:49:24 -0500 Subject: [PATCH 3/4] feat(security): assess source, image and cluster on a schedule Makes tests/workflows/continuous-security.bats pass (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 vulnerability is disclosed days later. This closes that gap. 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. Three surfaces, each recorded as PASS, FAIL or BLOCKED, the vocabulary the lane's collectors already use. Source scans the caller's own checkout and needs no credential, so it works from the first run. Image resolves the digests the GitOps repository declares for the service, through a read-only App token scoped to that one repository, and scans each distinct one with the checksum-locked Trivy release the toolchain lock records. Cluster runs the lane's existing read-only verify-security.sh rather than opening a second path into a cluster. A surface with no credentials is BLOCKED and says why; it is never counted as a pass and never fails the run as though the platform were broken. Findings route into one issue in the repository that owns the artifact. A recurring finding comments on the open issue instead of filing a new one, because a daily schedule that opened an issue per run would bury the signal in its own noise, and a clean run closes it. The registry is an input, the shape ci.yml already uses, so no account literal lives in this file. 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 the cluster surface needs a cluster with Argo CD (T063-T065). --- .../continuous-security-self-test.yml | 32 ++ .github/workflows/continuous-security.yml | 348 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 .github/workflows/continuous-security-self-test.yml create mode 100644 .github/workflows/continuous-security.yml diff --git a/.github/workflows/continuous-security-self-test.yml b/.github/workflows/continuous-security-self-test.yml new file mode 100644 index 0000000..73bd4c1 --- /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" From fdda94b9885d038755d632491bb76e0acd7c1ee4 Mon Sep 17 00:00:00 2001 From: Tiago0507 Date: Sun, 20 Sep 2026 19:49:53 -0500 Subject: [PATCH 4/4] style(security): drop the em dash from the new security artifacts The suite writes no em dash. Replaces the three the previous two commits introduced, in the contract's summary line and the self-test header, with plain punctuation. No assertion and no behavior changes; the contract still passes. --- .github/workflows/continuous-security-self-test.yml | 4 ++-- tests/workflows/continuous-security.bats | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/continuous-security-self-test.yml b/.github/workflows/continuous-security-self-test.yml index 73bd4c1..1834e51 100644 --- a/.github/workflows/continuous-security-self-test.yml +++ b/.github/workflows/continuous-security-self-test.yml @@ -1,9 +1,9 @@ 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 +# 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. +# credentials, a SHA pin, the read-only boundary) fails its own pull request. on: pull_request: diff --git a/tests/workflows/continuous-security.bats b/tests/workflows/continuous-security.bats index 2ba7701..60aaa6b 100755 --- a/tests/workflows/continuous-security.bats +++ b/tests/workflows/continuous-security.bats @@ -124,4 +124,4 @@ 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." +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."