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.

Flag change to SOC2 control mapping to evidence export flow A flag change event flows through control tagging (CC6 and CC8), is written to the immutable log, and is exported as signed evidence for the audit window. Flag Change actor + approver Control Mapper tag CC6 / CC8 Immutable Log hash-chained Evidence Export signed JSONL SOC2 controls: CC6.1 (access) · CC6.6 (auth) · CC8.1 (change mgmt) mapped from actor + approver + before/after fields
Flag change events are tagged with SOC2 control IDs, written to an immutable log, and exported as a signed evidence package for the audit window.

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

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
approveractor 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

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.