Generating SOC 2 and ISO 22301 Evidence
This page builds one concrete deliverable from the broader compliance evidence and control mapping guide: a Python pipeline that turns raw DR-drill telemetry into a signed evidence bundle mapped to the SOC 2 Availability criteria (A1.2 backup and recovery, A1.3 recovery-plan testing) and ISO 22301 clauses 8.4 (business continuity procedures) and 8.5 (exercising and testing). It complements the NIST projection in mapping backup validation to NIST SP 800-34, reads the same outcome stream your checksum validation pipeline and restore drills emit, and — because a drill only counts as passing when it recovered inside the window your RTO and RPO mapping defines — carries each record’s recorded verdict forward untouched. The pipeline hashes every evidence record, rolls the records up per trust-service criterion and per ISO clause, and emits a single detached-signed bundle an auditor can verify offline, exiting non-zero when any mapped criterion has no passing evidence.
Architecture and Execution Model
Figure. Drill telemetry is normalized, mapped to the Availability criteria and continuity clauses, hashed per record, rolled up per criterion, and emitted as one signed bundle.
Two frameworks are served by one pass because their recovery-testing expectations rhyme. SOC 2’s Availability category asks, through A1.2, that the entity maintain backup and recovery infrastructure, and through A1.3 that recovery procedures be tested; ISO 22301 asks, through clause 8.4, that continuity procedures be documented and operable, and through clause 8.5 that they be exercised and evaluated. The same drill outcome therefore substantiates a SOC 2 criterion and an ISO clause at once, and fanning each telemetry event out to every criterion it supports — rather than forcing an auditor to cross-walk two separate reports — keeps the evidence internally consistent. A single restore drill that recovered inside its recovery-time envelope is one fact expressed against four coordinates, not four independent claims that could drift apart.
Prerequisites
-
Python 3.9+ on the executor host.
-
The
cryptographypackage for per-record hashing and Ed25519 bundle signing:bash pip install cryptography -
An Ed25519 signing key held by the compliance function, kept out of the drill environment so evidence cannot be self-signed by the system under test.
-
A drill telemetry file (JSON array) with one record per validation or restore run, each carrying
component,artifact_uri,drill_id,verdict, and an ISO-8601 UTCcompleted_at.
Production Implementation
The mapping table declares which criterion each component’s evidence supports. A backup-integrity component feeds A1.2 and clause 8.4; a restore-drill component feeds A1.3 and clause 8.5. Normalization then projects each telemetry event into a canonical evidence record and hashes it, so any later mutation of the record is detectable independent of the bundle signature. The hash is computed over a canonical JSON encoding — keys sorted, whitespace stripped — because a record that hashes differently after a harmless re-serialization is worthless as tamper evidence. Sorting the keys makes the digest a function of the record’s meaning, not of the incidental order a dictionary happened to iterate in.
#!/usr/bin/env python3
"""Transform DR-drill telemetry into a signed SOC 2 / ISO 22301 evidence bundle.
Usage:
python3 evidence_bundle.py <telemetry.json> <signing_key.pem> <out_bundle.json>
Exit codes:
0 bundle emitted; every mapped criterion has passing evidence
1 bundle emitted, but at least one mapped criterion is uncovered
2 usage / configuration / key error
"""
from __future__ import annotations
import hashlib
import json
import sys
from dataclasses import dataclass, asdict
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# component -> criteria it supplies evidence for
CRITERION_MAP = {
"checksum-validator": ["SOC2-A1.2", "ISO-8.4"],
"restore-drill-runner": ["SOC2-A1.3", "ISO-8.5"],
}
# every criterion we claim to cover; a claimed criterion with no pass is a gap
CLAIMED_CRITERIA = ["SOC2-A1.2", "SOC2-A1.3", "ISO-8.4", "ISO-8.5"]
@dataclass(frozen=True)
class EvidenceRecord:
criterion: str
component: str
artifact: str
drill_id: str
timestamp: str
verdict: str
record_hash: str
def _hash(payload: dict) -> str:
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def normalize(telemetry: list[dict]) -> list[EvidenceRecord]:
"""Fan each telemetry event out to one record per criterion it supports."""
records: list[EvidenceRecord] = []
for event in telemetry:
criteria = CRITERION_MAP.get(event["component"], [])
for criterion in criteria:
core = {
"criterion": criterion,
"component": event["component"],
"artifact": event["artifact_uri"],
"drill_id": event["drill_id"],
"timestamp": event["completed_at"],
"verdict": event["verdict"],
}
records.append(EvidenceRecord(record_hash=_hash(core), **core))
return records
The rollup groups records by criterion and marks a criterion covered when it holds at least one pass record. It reports the passing count alongside the total, so an auditor can see not just that a criterion was met but how many independent drills met it — a criterion covered by a single record is weaker evidence than one covered by a dozen, even though both clear the boolean bar. The bundle serializes the ordered records and the rollup, hashes that canonical payload, and signs the digest with the detached Ed25519 key. Signing the whole ordered payload — rather than each record individually — means reordering or dropping a record invalidates the signature just as surely as editing one. Ed25519 is chosen over RSA here for the compactness of its signatures and its resistance to the padding and key-size footguns that make RSA signing error-prone in a hand-rolled pipeline.
def rollup(records: list[EvidenceRecord]) -> dict:
"""Per-criterion coverage: covered iff a passing record exists."""
summary: dict[str, dict] = {}
for criterion in CLAIMED_CRITERIA:
matching = [r for r in records if r.criterion == criterion]
passing = [r for r in matching if r.verdict == "pass"]
summary[criterion] = {
"records": len(matching),
"passing": len(passing),
"covered": bool(passing),
}
return summary
def build_bundle(records: list[EvidenceRecord], summary: dict,
key: Ed25519PrivateKey) -> dict:
body = {
"schema": "dr-evidence-bundle/v1",
"criteria": summary,
"records": [asdict(r) for r in records],
}
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
signature = key.sign(canonical.encode("utf-8"))
return {
"body": body,
"body_sha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
"signature_hex": signature.hex(),
"algorithm": "ed25519",
}
def main() -> int:
if len(sys.argv) != 4:
print("usage: evidence_bundle.py <telemetry.json> <key.pem> <out.json>",
file=sys.stderr)
return 2
try:
telemetry = json.loads(open(sys.argv[1], "r", encoding="utf-8").read())
key_bytes = open(sys.argv[2], "rb").read()
key = load_pem_private_key(key_bytes, password=None)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"config error: {exc}", file=sys.stderr)
return 2
if not isinstance(key, Ed25519PrivateKey):
print("signing key must be Ed25519", file=sys.stderr)
return 2
try:
records = normalize(telemetry)
except (KeyError, UnsupportedAlgorithm) as exc:
print(f"malformed telemetry: {exc}", file=sys.stderr)
return 2
summary = rollup(records)
bundle = build_bundle(records, summary, key)
with open(sys.argv[3], "w", encoding="utf-8") as fh:
json.dump(bundle, fh, indent=2)
gaps = [c for c, s in summary.items() if not s["covered"]]
for criterion, stats in summary.items():
mark = "COVERED" if stats["covered"] else "GAP "
print(f"{mark} {criterion:<10} passing={stats['passing']}/{stats['records']}")
if gaps:
print(f"UNCOVERED CRITERIA: {', '.join(gaps)}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Step-by-Step Execution Walkthrough
- Generate the signing key once. Have the compliance function create an Ed25519 private key in PEM form and publish only the matching public key to auditors; never store the private key in the drill environment.
- Export drill telemetry. Collect the run outcomes into a JSON array where each element carries
component,artifact_uri,drill_id,verdict, andcompleted_at. - Run the pipeline.
python3 evidence_bundle.py telemetry.json signing_key.pem bundle.json. The per-criterion rollup prints to stdout and the signed bundle is written tobundle.json. - Read the exit code.
echo $?—0means every claimed criterion has passing evidence,1means the bundle was still written but a criterion is uncovered,2signals a bad key, telemetry, or arguments. - Hand the bundle to the auditor. They verify
signature_hexagainstbodywith the published public key, then confirm each record’srecord_hashmatches its content, establishing chain of custody without contacting the drill system.
Verification and Expected Output
A fully covered run prints the rollup and exits 0:
COVERED SOC2-A1.2 passing=3/3
COVERED SOC2-A1.3 passing=1/1
COVERED ISO-8.4 passing=3/3
COVERED ISO-8.5 passing=1/1
If no restore drill ran in the window, the two recovery-testing criteria fall out and the process exits 1 while still writing the bundle for the record:
COVERED SOC2-A1.2 passing=3/3
GAP SOC2-A1.3 passing=0/0
COVERED ISO-8.4 passing=3/3
GAP ISO-8.5 passing=0/0
UNCOVERED CRITERIA: SOC2-A1.3, ISO-8.5
Success means the telemetry parsed, the key loaded as Ed25519, every record hashed, and each claimed criterion held a passing record. An auditor re-verifying the bundle recomputes the SHA-256 over body, checks it equals body_sha256, and verifies signature_hex — any of those failing proves the bundle was altered after signing.
Failure Modes and Troubleshooting
| Symptom | Cause | Remediation |
|---|---|---|
Exit 2, signing key must be Ed25519 |
PEM holds an RSA or EC key | Regenerate the key as Ed25519; the signer is fixed to that algorithm |
Exit 2, config error on the key |
Key is encrypted or password-protected | Provide an unencrypted PEM, or extend load_pem_private_key to pass the password |
| A component’s evidence never appears | Component name absent from CRITERION_MAP |
Add the component to the map with the criteria it supplies |
Criterion shows passing=0 but records exist |
Records present with a non-pass verdict |
Confirm failed and errored runs are not written with a pass verdict upstream |
| Auditor signature check fails | Bundle edited, reordered, or re-indented after signing | Re-run the pipeline to regenerate; never hand-edit a signed bundle |
record_hash mismatch on one record |
Record content mutated in transit | Discard the tampered record and re-export from source telemetry |
Integration Notes
The pipeline exits with clean POSIX codes, so it slots into any scheduler as a gate. Run it as the terminal task of an Airflow evidence DAG so a 1 exit surfaces an uncovered criterion in the run history; dispatch it from a Celery task that raises on non-zero so a completed drill triggers immediate bundling; or invoke it from cron and archive each bundle.json into the immutable audit trails store. Route uncovered criteria back into drill scheduling and orchestration so the next cycle exercises exactly the recovery-testing controls that lapsed. For authoritative definitions, consult the AICPA Trust Services Criteria for the Availability criteria, the ISO 22301 standard for clauses 8.4 and 8.5, and the cryptography Ed25519 documentation for the signing primitives.
Frequently Asked Questions
Why sign the whole bundle instead of each evidence record?
Per-record hashes detect mutation of an individual record, but a single signature over the ordered, canonicalized bundle also detects reordering or removal of records — attacks that per-record hashes alone miss. The auditor verifies one signature over body and then spot-checks the record_hash values, giving both whole-set integrity and per-record traceability.
Why keep the signing key out of the drill environment?
If the system under test could sign its own evidence, the signature would prove only that the drill host produced the bundle, not that an independent control function attested to it. Holding the Ed25519 private key with the compliance function and publishing only the public key preserves the separation an auditor expects between the system being tested and the party vouching for the results.
Why does the pipeline still write a bundle when a criterion is uncovered?
An uncovered criterion is itself an audit fact worth recording. The pipeline writes the signed bundle and exits 1 so the gap is both preserved in the chain of custody and surfaced to the gating scheduler, rather than discarding evidence of the very lapse an auditor needs to see.
Related
- Compliance evidence and control mapping — the parent guide defining the evidence model this bundle serializes.
- Mapping Backup Validation to NIST SP 800-34 — the sibling projection for the contingency-planning control family.
- Immutable audit trails — the write-once store each signed bundle is archived into.
- Validation telemetry and metrics — the upstream source of the drill records this pipeline normalizes.
This pipeline is one component of the broader compliance evidence and control mapping guide.