SOC2 Evidence Collection for Flag Changes
This how-to is part of Building Audit Trails for Compliance. An auditor asking “prove every production flag change was authorized, reviewed, and logged” is a routine SOC2 Type II request — and entirely answerable if your pipeline captures the right fields. This guide shows you how to capture change events with actor and approver context, export an immutable time-ordered evidence trail, map the events to SOC2 controls, and automate the pull so you are never scrambling at audit time.
Problem Statement
A SOC2 Type II audit for the period January–June 2026 asks: for every production flag change in that window, who made it, who approved it, what was the before state, what is the after state, and where is the system log that proves the record has not been altered? You need a reliable, automatable answer to that question before the audit begins — not a manual log trawl during the audit itself.
Prerequisites
- audit trail pipeline from the parent guide is in place (append-only log, hash-chained events)
actor,approver,before,after,reason, andenvironmentrelease-managerrole for production per your flag taxonomy RBAC policyAUDIT_TOKENwith read-only access to the evidence export API
Step-by-Step Procedure
Step 1 — Capture change events with actor, approver, and reason
Every flag write must record four fields that SOC2 auditors check directly: who made the change (actor), who authorized it (approver), the textual justification (reason), and the ticket or change-request reference (change_ref). Wire these at the control plane mutation hook:
// soc2-mutation-hook.go
type SOC2AuditEvent struct {
EventID string `json:"event_id"`
TimestampUTC time.Time `json:"timestamp_utc"`
Actor string `json:"actor"` // CC6.1, CC6.6
Approver string `json:"approver"` // CC8.1 — required for prod
Role string `json:"role"` // CC6.1
FlagKey string `json:"flag_key"`
Environment string `json:"environment"`
Action string `json:"action"` // create|update|rollout|delete
Before any `json:"before"` // CC8.1 — prior state
After any `json:"after"` // CC8.1 — new state
Reason string `json:"reason"` // CC8.1 — change justification
ChangeRef string `json:"change_ref"` // ticket ID, e.g. CHG-4821
Controls []string `json:"soc2_controls"` // populated by mapper
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
}
func populateControls(e *SOC2AuditEvent) {
// CC6.1: logical access — actor identity always
e.Controls = append(e.Controls, "CC6.1")
// CC6.6: authentication — role enforcement
e.Controls = append(e.Controls, "CC6.6")
// CC8.1: change management — any mutation with an approver
if e.Approver != "" && e.Environment == "production" {
e.Controls = append(e.Controls, "CC8.1")
}
}
For production changes, reject writes where approver is empty or matches actor — a self-approved change does not satisfy CC8.1’s separation-of-duties requirement:
if e.Environment == "production" && (e.Approver == "" || e.Approver == e.Actor) {
return errors.New("SOC2 CC8.1: production flag change requires a distinct approver")
}
Pitfall: capturing approver as a free-text field lets engineers write anything. Validate the approver against your identity provider at write time — the audit log should store the verified identity, not a self-reported string.
Step 2 — Export an immutable, time-ordered evidence trail
At audit time, pull a JSONL file covering the audit window. Every line is one event, ordered by timestamp_utc. The file must be signed so the auditor can verify it has not been modified after export:
#!/usr/bin/env bash
# soc2-evidence-pull.sh — produce a signed evidence export for a SOC2 audit window
set -euo pipefail
START="${1:?Usage: $0 YYYY-MM-DD YYYY-MM-DD}"
END="${2:?}"
OUTFILE="flag-audit-soc2_${START}_${END}.jsonl"
SIGFILE="${OUTFILE}.sig"
echo "Pulling audit events ${START} → ${END}…"
curl -sf "https://audit-api.internal/v1/export" \
-H "Authorization: Bearer ${AUDIT_TOKEN}" \
--data-urlencode "start=${START}" \
--data-urlencode "end=${END}" \
--data-urlencode "controls=CC6.1,CC6.6,CC8.1" \
-o "${OUTFILE}"
EVENT_COUNT=$(wc -l < "${OUTFILE}")
echo "Events exported: ${EVENT_COUNT}"
# Sign the export file
openssl dgst -sha256 -sign "${AUDIT_SIGNING_KEY_PATH}" \
-out "${SIGFILE}" "${OUTFILE}"
# Print the manifest for the auditor
echo "---"
echo "Evidence file : ${OUTFILE}"
echo "Signature file: ${SIGFILE}"
echo "SHA-256 : $(sha256sum "${OUTFILE}" | awk '{print $1}')"
echo "Verify with : openssl dgst -sha256 -verify audit-public.pem -signature ${SIGFILE} ${OUTFILE}"
Deliver ${OUTFILE} and ${SIGFILE} together with the public key (audit-public.pem) registered in your security controls documentation.
Step 3 — Map events to SOC2 controls (CC6/CC8)
The evidence file is more useful to an auditor when each event carries explicit control references. The table below shows the field-to-control mapping:
| Audit event field | SOC2 control | What it proves |
|---|---|---|
actor + role |
CC6.1 | Only authorized identities can mutate flags |
actor + role validated at write time |
CC6.6 | Authentication was enforced at each change |
approver ≠ actor |
CC8.1 | Separation of duties for production changes |
before + after |
CC8.1 | Full change record, not just a timestamp |
reason + change_ref |
CC8.1 | Business justification and ticket linkage |
hash + prev_hash |
CC8.1 | Tamper-evident: retroactive edits break the chain |
Run this mapping query against the JSONL export to produce a per-control summary for the auditor:
# soc2-control-summary.py — group events by control for the audit package
import json, sys
from collections import defaultdict
counts = defaultdict(int)
with open(sys.argv[1]) as f:
for line in f:
event = json.loads(line)
for ctrl in event.get("soc2_controls", []):
counts[ctrl] += 1
print("SOC2 control coverage summary:")
for ctrl, n in sorted(counts.items()):
print(f" {ctrl}: {n} events")
Step 4 — Automate the evidence pull for the audit window
Run this script in CI on a schedule so the evidence package is ready before the auditor asks. A weekly run catches drift early — if event counts drop unexpectedly, the audit pipeline may be broken:
# .github/workflows/soc2-evidence.yml
name: SOC2 evidence weekly export
on:
schedule:
- cron: '0 6 * * 1' # every Monday 06:00 UTC
workflow_dispatch:
inputs:
start_date: { required: true }
end_date: { required: true }
jobs:
export:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Pull evidence
env:
AUDIT_TOKEN: ${{ secrets.AUDIT_TOKEN }}
AUDIT_SIGNING_KEY_PATH: ${{ secrets.AUDIT_SIGNING_KEY_PATH }}
run: |
START="${{ github.event.inputs.start_date || '$(date -d "7 days ago" +%Y-%m-%d)' }}"
END="${{ github.event.inputs.end_date || '$(date +%Y-%m-%d)' }}"
bash scripts/soc2-evidence-pull.sh "$START" "$END"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: soc2-evidence-${{ github.run_id }}
path: flag-audit-soc2_*.jsonl*
retention-days: 90
Verification
Confirm the export is tamper-evident before submitting to the auditor:
# Verify chain integrity of the exported file
python3 - <<'EOF'
import hashlib, json, sys
GENESIS = "0" * 64
prev = GENESIS
with open("flag-audit-soc2_2026-01-01_2026-06-30.jsonl") as f:
for i, line in enumerate(f, 1):
rec = json.loads(line)
stored_hash = rec.pop("hash")
prev_ref = rec.pop("prev_hash")
if prev_ref != prev:
sys.exit(f"Chain break at record {i}: prev_hash mismatch")
payload = json.dumps(rec, sort_keys=True, separators=(',',':'))
computed = hashlib.sha256(f"{prev}:{payload}".encode()).hexdigest()
if computed != stored_hash:
sys.exit(f"Tamper detected at record {i}")
prev = stored_hash
print(f"Chain intact: {i} records, no tampering detected.")
EOF
And verify the signature:
openssl dgst -sha256 -verify audit-public.pem \
-signature flag-audit-soc2_2026-01-01_2026-06-30.jsonl.sig \
flag-audit-soc2_2026-01-01_2026-06-30.jsonl \
&& echo "Signature valid" || echo "FAIL: signature mismatch"
Gotchas & Edge Cases
- Self-approvals slip through free-text fields: validate the
approverfield against your IdP at write time, not at export time. Catching it at export means the audit evidence already contains the violation. - Gaps in the event sequence: a chain break does not always mean tampering — a relay restart with a new genesis sentinel creates a legitimate gap. Document every sentinel rotation with a meta-event in the log so the auditor can distinguish a restart from an edit.
- GDPR and SOC2 retention windows conflict: SOC2 requires 12–24 months; GDPR minimization pushes for shorter windows on PII-adjacent data. Resolve this by ensuring audit events carry hashed identifiers (not raw personal data) — they then fall under SOC2’s longer window without conflicting with GDPR’s data minimization principle.
Troubleshooting & FAQ
The auditor says the export has a gap — events from a specific day are missing.
Check whether the relay process that moves events from the transactional outbox to the immutable log was down or lagging on that day. Events written to the outbox but not yet relayed will appear late, not missing. If they were never written to the outbox (relay was down before the write), they are genuinely lost — check your outbox monitoring and retroactively document the gap in a signed meta-event.
How do I prove that the approver field was actually enforced, not just logged?
Include the API-layer rejection log alongside the evidence export: every attempted write that was blocked because approver was empty or matched actor produces a 403 with reason "SOC2 CC8.1: approver required". Those rejection events, timestamped and signed, show the control was enforced — not just recorded after the fact.
Which SOC2 trust service criterion covers flag changes?
CC8.1 (Change Management) is the primary control: it requires authorized, reviewed, and logged changes to system components. CC6.1 (Logical Access) and CC6.6 (Authentication) cover the identity and role-enforcement side. Some auditors also look at CC7.2 (System Monitoring) for the alerting you wire around unauthorized mutation attempts.