Skip to content

Commit af5bf92

Browse files
committed
fix: harden CI/CD evidence pipeline after code review
Review and fix the evidence pipeline code in docs/sbom/ that was developed outside this repo without full context. Critical: fix base64url JWT decoding in oidc.ts, symmetric fail-closed policy for missing secrets data in Rego, OPA binary integrity check. High: fix CWE-78 injection in workflow run steps (Semgrep finding), fix compile-breaking src/ path mismatches, populate policy_version and decision in D1 via request headers. Medium: full observability config, nbf claim validation, robust SARIF parsing, pin actions to SHA hashes. Docs: align runbook with existing security.yml workflow, document Trivy/Grype overlap and collect-scan-results bridge requirement. Closes #7 InfoSec: fixes JWT verification bug, OPA supply chain integrity, GH Actions injection (CWE-78), and policy logic gap for secrets
1 parent 2c0e3b5 commit af5bf92

10 files changed

Lines changed: 1547 additions & 0 deletions

docs/sbom/attest-and-ship.yml

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

docs/sbom/ci-security-v1.rego

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# CI Security Policy — v1
2+
#
3+
# Deterministic evaluation of security scan results.
4+
# Same inputs + this policy version = same decision, every time.
5+
#
6+
# This file lives at: .github/policies/ci-security-v1.rego
7+
# Version it alongside your workflows. When you change policy logic,
8+
# create ci-security-v2.rego and update the workflow input.
9+
#
10+
# To test locally:
11+
# opa eval --data ci-security-v1.rego \
12+
# --input test-input.json \
13+
# 'data.esolia.ci.security'
14+
15+
package esolia.ci.security
16+
17+
import rego.v1
18+
19+
# ── Decision ──
20+
21+
default allow := false
22+
23+
allow if {
24+
no_critical_findings
25+
no_leaked_secrets
26+
}
27+
28+
# ── Rules ──
29+
30+
# Block on any critical SARIF findings
31+
no_critical_findings if {
32+
input.sarif_critical == 0
33+
}
34+
35+
# Also block if critical count is missing (scan didn't run = not safe to pass)
36+
no_critical_findings if {
37+
not input.sarif_critical
38+
input.scan_status == "skipped_not_applicable"
39+
}
40+
41+
# Block if secrets were detected — or if secret scanning didn't run.
42+
# InfoSec: Default to fail (same as no_critical_findings) so missing scan
43+
# data is never silently treated as safe.
44+
default no_leaked_secrets := false
45+
46+
no_leaked_secrets if {
47+
input.secrets_found == 0
48+
}
49+
50+
# Allow pass if secret scanning was explicitly marked as not applicable
51+
no_leaked_secrets if {
52+
not input.secrets_found
53+
input.scan_status == "skipped_not_applicable"
54+
}
55+
56+
# Deny if secrets were detected
57+
no_leaked_secrets := false if {
58+
input.secrets_found > 0
59+
}
60+
61+
# ── Metadata (included in decision output for audit) ──
62+
63+
policy_version := "v1"
64+
65+
decision_reasons contains reason if {
66+
input.sarif_critical > 0
67+
reason := sprintf("blocked: %d critical SARIF findings", [input.sarif_critical])
68+
}
69+
70+
decision_reasons contains reason if {
71+
input.secrets_found > 0
72+
reason := sprintf("blocked: %d secrets detected", [input.secrets_found])
73+
}
74+
75+
decision_reasons contains reason if {
76+
allow
77+
reason := "all checks passed"
78+
}
79+
80+
# ── Summary (the structured output consumed by the workflow) ──
81+
82+
summary := {
83+
"allow": allow,
84+
"policy_version": policy_version,
85+
"reasons": decision_reasons,
86+
"evaluated_at": input._meta.evaluated_at,
87+
"commit_sha": input._meta.commit_sha,
88+
}

0 commit comments

Comments
 (0)