|
| 1 | +# Reusable workflow: Attestation + Evidence Shipping |
| 2 | +# |
| 3 | +# Adds to your existing shared lint/scan workflows: |
| 4 | +# 1. Generates SLSA provenance via GitHub's built-in attestation |
| 5 | +# 2. Evaluates a deterministic OPA policy against scan results |
| 6 | +# 3. Ships the evidence bundle to the Cloudflare evidence-sink Worker |
| 7 | +# |
| 8 | +# Usage in a calling workflow: |
| 9 | +# |
| 10 | +# jobs: |
| 11 | +# security: |
| 12 | +# uses: esolia/.github/.github/workflows/security.yml@main |
| 13 | +# with: |
| 14 | +# package-manager: npm |
| 15 | +# source-paths: src/ |
| 16 | +# |
| 17 | +# attest: |
| 18 | +# needs: security |
| 19 | +# uses: esolia/.github/.github/workflows/attest-and-ship.yml@main |
| 20 | +# with: |
| 21 | +# scan-artifact-name: scan-results # artifact from security.yml |
| 22 | +# secrets: |
| 23 | +# EVIDENCE_SINK_URL: ${{ secrets.EVIDENCE_SINK_URL }} |
| 24 | +# |
| 25 | +# The upstream workflow must upload scan results as an artifact named |
| 26 | +# `scan-results` containing SARIF files and/or a summary JSON. |
| 27 | +# See the runbook for the required `collect-scan-results` bridge job. |
| 28 | + |
| 29 | +name: Attest and Ship Evidence |
| 30 | + |
| 31 | +on: |
| 32 | + workflow_call: |
| 33 | + inputs: |
| 34 | + scan-artifact-name: |
| 35 | + description: 'Name of the artifact containing scan results (SARIF, JSON)' |
| 36 | + required: false |
| 37 | + type: string |
| 38 | + default: 'scan-results' |
| 39 | + policy-version: |
| 40 | + description: 'OPA policy version tag (for deterministic replay)' |
| 41 | + required: false |
| 42 | + type: string |
| 43 | + default: 'v1' |
| 44 | + skip-attestation: |
| 45 | + description: 'Skip GitHub artifact attestation (for non-app repos)' |
| 46 | + required: false |
| 47 | + type: boolean |
| 48 | + default: false |
| 49 | + secrets: |
| 50 | + EVIDENCE_SINK_URL: |
| 51 | + description: 'URL of the Cloudflare evidence-sink Worker endpoint' |
| 52 | + required: true |
| 53 | + |
| 54 | +permissions: |
| 55 | + id-token: write # GitHub OIDC token for keyless signing + Worker auth |
| 56 | + contents: read |
| 57 | + attestations: write # Required for actions/attest-build-provenance |
| 58 | + |
| 59 | +jobs: |
| 60 | + attest-and-ship: |
| 61 | + runs-on: ubuntu-latest |
| 62 | + steps: |
| 63 | + - name: Checkout |
| 64 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 65 | + with: |
| 66 | + persist-credentials: false |
| 67 | + |
| 68 | + # ── Download scan results from the upstream job ── |
| 69 | + - name: Download scan results |
| 70 | + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 |
| 71 | + with: |
| 72 | + name: ${{ inputs.scan-artifact-name }} |
| 73 | + path: ./scan-results |
| 74 | + |
| 75 | + # ── Generate SLSA provenance (GitHub-native, records to Rekor) ── |
| 76 | + - name: Attest build provenance |
| 77 | + if: ${{ !inputs.skip-attestation }} |
| 78 | + uses: actions/attest-build-provenance@v2 |
| 79 | + with: |
| 80 | + subject-path: './scan-results/**' |
| 81 | + |
| 82 | + # ── Deterministic policy evaluation ── |
| 83 | + # |
| 84 | + # This step is the "decision as artifact" concept from the Nono-Gate |
| 85 | + # article, but using OPA/Rego — a mature, widely-adopted engine. |
| 86 | + # |
| 87 | + # The policy file lives in your shared .github repo so it's versioned |
| 88 | + # alongside the workflow. Same inputs + same policy = same output. |
| 89 | + # InfoSec: Pin OPA binary to version + SHA-256 to prevent supply chain attacks. |
| 90 | + # To update: download new binary, compute sha256sum, update both values. |
| 91 | + - name: Install OPA |
| 92 | + env: |
| 93 | + OPA_VERSION: "1.4.2" |
| 94 | + # sha256sum of opa_linux_amd64_static v1.4.2 |
| 95 | + # Verify at: https://github.com/open-policy-agent/opa/releases/tag/v1.4.2 |
| 96 | + OPA_SHA256: "FIXME_REPLACE_WITH_ACTUAL_SHA256_FROM_OPA_RELEASE_PAGE" |
| 97 | + run: | |
| 98 | + curl -fsSL -o /usr/local/bin/opa \ |
| 99 | + "https://openpolicyagent.org/downloads/v${OPA_VERSION}/opa_linux_amd64_static" |
| 100 | + echo "${OPA_SHA256} /usr/local/bin/opa" | sha256sum -c - |
| 101 | + chmod +x /usr/local/bin/opa |
| 102 | +
|
| 103 | + - name: Evaluate security policy |
| 104 | + id: policy |
| 105 | + env: |
| 106 | + # InfoSec: Use env vars instead of ${{ }} interpolation in run steps |
| 107 | + # to prevent injection via crafted input values (CWE-78). |
| 108 | + POLICY_VERSION: ${{ inputs.policy-version }} |
| 109 | + run: | |
| 110 | + # Collect scan summaries into a single input document. |
| 111 | + # Handles both SARIF files and a plain summary.json from your |
| 112 | + # existing scan steps. Add parsers here as your scan tooling grows. |
| 113 | + echo '{}' > /tmp/policy-input.json |
| 114 | +
|
| 115 | + # If a summary.json exists (your custom scan output), use it directly |
| 116 | + if [[ -f ./scan-results/summary.json ]]; then |
| 117 | + cp ./scan-results/summary.json /tmp/policy-input.json |
| 118 | + fi |
| 119 | +
|
| 120 | + # If SARIF files exist, extract critical/high counts. |
| 121 | + # Use jq's error-tolerant operators (?., //) to handle SARIF files |
| 122 | + # with missing .runs or .results arrays without failing. |
| 123 | + SARIF_FILES=$(find ./scan-results -name '*.sarif' -o -name '*.sarif.json' 2>/dev/null) |
| 124 | + if [[ -n "$SARIF_FILES" ]]; then |
| 125 | + CRITICAL=0 |
| 126 | + HIGH=0 |
| 127 | + while IFS= read -r f; do |
| 128 | + [[ -f "$f" ]] || continue |
| 129 | + C=$(jq '[.runs[]?.results[]? | select(.level == "error")] | length' "$f" 2>/dev/null || echo 0) |
| 130 | + H=$(jq '[.runs[]?.results[]? | select(.level == "warning")] | length' "$f" 2>/dev/null || echo 0) |
| 131 | + CRITICAL=$((CRITICAL + C)) |
| 132 | + HIGH=$((HIGH + H)) |
| 133 | + done <<< "$SARIF_FILES" |
| 134 | +
|
| 135 | + # Merge into policy input |
| 136 | + jq --argjson c "$CRITICAL" --argjson h "$HIGH" \ |
| 137 | + '. + {sarif_critical: $c, sarif_high: $h}' \ |
| 138 | + /tmp/policy-input.json > /tmp/policy-input-merged.json |
| 139 | + mv /tmp/policy-input-merged.json /tmp/policy-input.json |
| 140 | + fi |
| 141 | +
|
| 142 | + # Add metadata for reproducibility |
| 143 | + jq --arg repo "$GITHUB_REPOSITORY" \ |
| 144 | + --arg sha "$GITHUB_SHA" \ |
| 145 | + --arg ref "$GITHUB_REF" \ |
| 146 | + --arg run "$GITHUB_RUN_ID" \ |
| 147 | + --arg pv "$POLICY_VERSION" \ |
| 148 | + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| 149 | + '. + { |
| 150 | + _meta: { |
| 151 | + repo: $repo, |
| 152 | + commit_sha: $sha, |
| 153 | + ref: $ref, |
| 154 | + run_id: $run, |
| 155 | + policy_version: $pv, |
| 156 | + evaluated_at: $ts |
| 157 | + } |
| 158 | + }' /tmp/policy-input.json > /tmp/policy-input-final.json |
| 159 | +
|
| 160 | + # Evaluate policy |
| 161 | + DECISION=$(opa eval \ |
| 162 | + --data ".github/policies/ci-security-${POLICY_VERSION}.rego" \ |
| 163 | + --input /tmp/policy-input-final.json \ |
| 164 | + --format json \ |
| 165 | + 'data.esolia.ci.security' 2>&1) || true |
| 166 | +
|
| 167 | + echo "$DECISION" > /tmp/policy-decision.json |
| 168 | +
|
| 169 | + # Extract pass/fail |
| 170 | + ALLOW=$(echo "$DECISION" | jq -r '.result[0].expressions[0].value.allow // false') |
| 171 | + echo "allow=$ALLOW" >> "$GITHUB_OUTPUT" |
| 172 | + echo "policy_version=$POLICY_VERSION" >> "$GITHUB_OUTPUT" |
| 173 | + echo "Policy decision: allow=$ALLOW" |
| 174 | +
|
| 175 | + # ── Build evidence bundle ── |
| 176 | + - name: Build evidence bundle |
| 177 | + id: bundle |
| 178 | + env: |
| 179 | + POLICY_VERSION: ${{ inputs.policy-version }} |
| 180 | + POLICY_ALLOW: ${{ steps.policy.outputs.allow }} |
| 181 | + run: | |
| 182 | + BUNDLE_DIR=/tmp/evidence-bundle |
| 183 | + mkdir -p "$BUNDLE_DIR" |
| 184 | +
|
| 185 | + # Copy scan results |
| 186 | + cp -r ./scan-results/* "$BUNDLE_DIR/" 2>/dev/null || true |
| 187 | +
|
| 188 | + # Copy policy input and decision |
| 189 | + cp /tmp/policy-input-final.json "$BUNDLE_DIR/policy-input.json" |
| 190 | + cp /tmp/policy-decision.json "$BUNDLE_DIR/policy-decision.json" |
| 191 | +
|
| 192 | + # Generate manifest with content hashes |
| 193 | + MANIFEST=$(jq -n \ |
| 194 | + --arg schema "1.0" \ |
| 195 | + --arg repo "$GITHUB_REPOSITORY" \ |
| 196 | + --arg sha "$GITHUB_SHA" \ |
| 197 | + --arg ref "$GITHUB_REF" \ |
| 198 | + --arg run_id "$GITHUB_RUN_ID" \ |
| 199 | + --arg run_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ |
| 200 | + --arg actor "$GITHUB_ACTOR" \ |
| 201 | + --arg pv "$POLICY_VERSION" \ |
| 202 | + --arg allow "$POLICY_ALLOW" \ |
| 203 | + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| 204 | + '{ |
| 205 | + schema_version: $schema, |
| 206 | + report_type: "cicd_evidence", |
| 207 | + repository: $repo, |
| 208 | + commit_sha: $sha, |
| 209 | + ref: $ref, |
| 210 | + run_id: $run_id, |
| 211 | + run_url: $run_url, |
| 212 | + actor: $actor, |
| 213 | + policy_version: $pv, |
| 214 | + decision: $allow, |
| 215 | + generated_at: $ts, |
| 216 | + artifacts: {} |
| 217 | + }') |
| 218 | +
|
| 219 | + # Hash each file in the bundle and add to manifest |
| 220 | + for f in "$BUNDLE_DIR"/*; do |
| 221 | + [[ -f "$f" ]] || continue |
| 222 | + FNAME=$(basename "$f") |
| 223 | + HASH=$(sha256sum "$f" | cut -d' ' -f1) |
| 224 | + SIZE=$(stat -c%s "$f") |
| 225 | + MANIFEST=$(echo "$MANIFEST" | jq \ |
| 226 | + --arg key "$FNAME" \ |
| 227 | + --arg hash "$HASH" \ |
| 228 | + --argjson size "$SIZE" \ |
| 229 | + '.artifacts[$key] = {sha256: $hash, size_bytes: $size}') |
| 230 | + done |
| 231 | +
|
| 232 | + echo "$MANIFEST" | jq '.' > "$BUNDLE_DIR/manifest.json" |
| 233 | +
|
| 234 | + # Also hash the manifest itself for the shipping step |
| 235 | + MANIFEST_HASH=$(sha256sum "$BUNDLE_DIR/manifest.json" | cut -d' ' -f1) |
| 236 | + echo "manifest_hash=$MANIFEST_HASH" >> "$GITHUB_OUTPUT" |
| 237 | +
|
| 238 | + # ── Ship to Cloudflare evidence sink ── |
| 239 | + - name: Request GitHub OIDC token |
| 240 | + id: oidc |
| 241 | + run: | |
| 242 | + # Request a token scoped to our evidence-sink Worker |
| 243 | + OIDC_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ |
| 244 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=esolia-cicd-evidence" | jq -r '.value') |
| 245 | + echo "::add-mask::$OIDC_TOKEN" |
| 246 | + echo "token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT" |
| 247 | +
|
| 248 | + - name: Ship evidence bundle |
| 249 | + env: |
| 250 | + OIDC_TOKEN: ${{ steps.oidc.outputs.token }} |
| 251 | + MANIFEST_HASH: ${{ steps.bundle.outputs.manifest_hash }} |
| 252 | + EVIDENCE_SINK_URL: ${{ secrets.EVIDENCE_SINK_URL }} |
| 253 | + POLICY_VERSION: ${{ steps.policy.outputs.policy_version }} |
| 254 | + POLICY_ALLOW: ${{ steps.policy.outputs.allow }} |
| 255 | + run: | |
| 256 | + BUNDLE_DIR=/tmp/evidence-bundle |
| 257 | +
|
| 258 | + # Create a tarball of the evidence bundle |
| 259 | + tar czf /tmp/evidence-bundle.tar.gz -C "$BUNDLE_DIR" . |
| 260 | +
|
| 261 | + # Ship to the evidence-sink Worker. |
| 262 | + # X-Policy-Version and X-Policy-Decision headers let the Worker index |
| 263 | + # these values in D1 without decompressing the tarball. |
| 264 | + HTTP_STATUS=$(curl -sS -o /tmp/ship-response.json -w "%{http_code}" \ |
| 265 | + -X POST \ |
| 266 | + -H "Authorization: Bearer $OIDC_TOKEN" \ |
| 267 | + -H "Content-Type: application/gzip" \ |
| 268 | + -H "X-Evidence-Manifest-Hash: $MANIFEST_HASH" \ |
| 269 | + -H "X-GitHub-Repository: $GITHUB_REPOSITORY" \ |
| 270 | + -H "X-GitHub-Run-ID: $GITHUB_RUN_ID" \ |
| 271 | + -H "X-GitHub-SHA: $GITHUB_SHA" \ |
| 272 | + -H "X-Policy-Version: $POLICY_VERSION" \ |
| 273 | + -H "X-Policy-Decision: $POLICY_ALLOW" \ |
| 274 | + --data-binary @/tmp/evidence-bundle.tar.gz \ |
| 275 | + "$EVIDENCE_SINK_URL/api/v1/evidence") |
| 276 | +
|
| 277 | + echo "Response status: $HTTP_STATUS" |
| 278 | + cat /tmp/ship-response.json |
| 279 | +
|
| 280 | + if [[ "$HTTP_STATUS" -ge 400 ]]; then |
| 281 | + echo "::warning::Evidence shipping failed with status $HTTP_STATUS (non-blocking)" |
| 282 | + fi |
| 283 | +
|
| 284 | + # ── Fail the build if policy says no ── |
| 285 | + - name: Enforce policy decision |
| 286 | + if: steps.policy.outputs.allow != 'true' |
| 287 | + env: |
| 288 | + POLICY_VERSION: ${{ inputs.policy-version }} |
| 289 | + run: | |
| 290 | + echo "::error::Security policy evaluation failed. Check policy-decision.json in the evidence bundle." |
| 291 | + echo "::error::Run ID: $GITHUB_RUN_ID | Policy version: $POLICY_VERSION" |
| 292 | + exit 1 |
0 commit comments