Automate SOC 2 Evidence Collection from AWS, GitHub Actions, and Okta
For developers who need repeatable SOC 2 evidence without screenshots and spreadsheet chasing. This walks you through pulling evidence from cloud, CI, and identity systems on a schedule, storing immutable JSON artifacts, and verifying the pipeline end to end.
TL;DR — Build one scheduled job that calls your cloud, CI, and identity APIs, writes timestamped JSON evidence files, and stores them in versioned object storage. The most common failure is missing read-only API scopes, so start by testing each token with a single
curlbefore wiring the scheduler. Reading time: ~5 min
Goal
When you finish, a scheduled evidence job will collect SOC 2-relevant data from your cloud account, CI system, and identity provider, write dated JSON files, and upload them to an evidence bucket/container so you can prove controls without manual screenshots.
Prerequisites
- AWS account with read access to IAM, CloudTrail, Config, and S3; AWS CLI v2 installed — check with
aws --version - GitHub repository or org with access to Actions and branch protection data; GitHub CLI installed — check with
gh --version - Okta org with an API token that can read users, groups, and system logs;
curlandjq >= 1.6— check withjq --version - A Linux runner, container host, or cron-capable VM with
bash >= 5— check withbash --version - An object store location for evidence, for example
s3://soc2-evidence-prod - Secrets available as environment variables:
AWS_PROFILEor AWS role credentials,GITHUB_TOKEN,OKTA_ORG,OKTA_TOKEN - Your AWS account ID and primary GitHub org/repo names
Steps
Step 1: Create a versioned evidence bucket
⚠️ If you point this at an existing bucket and enable lifecycle expiration carelessly, you can delete prior evidence. Use a dedicated bucket/container.
export AWS_PROFILE=prod-audit
export AWS_REGION=us-east-1
export EVIDENCE_BUCKET=soc2-evidence-prod
aws s3api create-bucket \
--bucket "$EVIDENCE_BUCKET" \
--region "$AWS_REGION"
aws s3api put-bucket-versioning \
--bucket "$EVIDENCE_BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket "$EVIDENCE_BUCKET" \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
You should see no error output, and aws s3api get-bucket-versioning --bucket "$EVIDENCE_BUCKET" should return {"Status":"Enabled"}.
Step 2: Verify each API credential before automating
aws sts get-caller-identity
gh api /user
curl -sS -H "Authorization: SSWS $OKTA_TOKEN" \
-H "Accept: application/json" \
"https://$OKTA_ORG.okta.com/api/v1/users?limit=1" | jq '.[0].id'
You should see an AWS account/ARN, a GitHub login JSON object, and one Okta user ID string.
Step 3: Create the evidence collector script
mkdir -p ~/soc2-evidence/bin ~/soc2-evidence/out
cat > ~/soc2-evidence/bin/collect.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
: "${EVIDENCE_BUCKET:?missing EVIDENCE_BUCKET}"
: "${AWS_REGION:?missing AWS_REGION}"
: "${OKTA_ORG:?missing OKTA_ORG}"
: "${OKTA_TOKEN:?missing OKTA_TOKEN}"
: "${GITHUB_TOKEN:?missing GITHUB_TOKEN}"
TS="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
BASE="${HOME}/soc2-evidence/out/${TS}"
mkdir -p "$BASE/aws" "$BASE/github" "$BASE/okta"
aws iam get-account-summary > "$BASE/aws/iam-account-summary.json"
aws iam list-users > "$BASE/aws/iam-users.json"
aws iam list-roles > "$BASE/aws/iam-roles.json"
aws cloudtrail describe-trails > "$BASE/aws/cloudtrail-trails.json"
aws configservice describe-configuration-recorders > "$BASE/aws/config-recorders.json"
aws s3api list-buckets > "$BASE/aws/s3-buckets.json"
gh api /orgs/YOUR_GITHUB_ORG/actions/permissions > "$BASE/github/actions-permissions.json"
gh api /repos/YOUR_GITHUB_ORG/YOUR_REPO/branches/main/protection > "$BASE/github/main-branch-protection.json"
gh api /repos/YOUR_GITHUB_ORG/YOUR_REPO/actions/runs?per_page=20 > "$BASE/github/actions-runs.json"
curl -sS -H "Authorization: SSWS $OKTA_TOKEN" -H "Accept: application/json" \
"https://$OKTA_ORG.okta.com/api/v1/users?limit=200" > "$BASE/okta/users.json"
curl -sS -H "Authorization: SSWS $OKTA_TOKEN" -H "Accept: application/json" \
"https://$OKTA_ORG.okta.com/api/v1/groups?limit=200" > "$BASE/okta/groups.json"
SINCE="$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || python3 - <<'py'
from datetime import datetime, timedelta, timezone
print((datetime.now(timezone.utc)-timedelta(days=7)).strftime('%Y-%m-%dT%H:%M:%SZ'))
py
)"
curl -sS -H "Authorization: SSWS $OKTA_TOKEN" -H "Accept: application/json" \
"https://$OKTA_ORG.okta.com/api/v1/logs?since=$SINCE&limit=200" > "$BASE/okta/system-log.json"
find "$BASE" -type f -name '*.json' -print0 | xargs -0 -I{} sh -c 'jq empty "$1"' _ {}
aws s3 sync "$BASE" "s3://$EVIDENCE_BUCKET/$TS/"
echo "uploaded_prefix=s3://$EVIDENCE_BUCKET/$TS/"
bash
chmod +x ~/soc2-evidence/bin/collect.sh
You should have an executable script at ~/soc2-evidence/bin/collect.sh.
Step 4: Replace literal placeholders and run once
sed -i.bak 's/YOUR_GITHUB_ORG/acme-inc/g; s/YOUR_REPO/platform-api/g' ~/soc2-evidence/bin/collect.sh
export AWS_PROFILE=prod-audit
export AWS_REGION=us-east-1
export EVIDENCE_BUCKET=soc2-evidence-prod
export GITHUB_TOKEN=ghp_xxx
export OKTA_ORG=acme
export OKTA_TOKEN=00xxx
~/soc2-evidence/bin/collect.sh
You should see a final line shaped like uploaded_prefix=s3://soc2-evidence-prod/2026-10-30T14-22-11Z/.
Step 5: Schedule it daily with cron
(crontab -l 2>/dev/null; echo '17 2 * * * AWS_PROFILE=prod-audit AWS_REGION=us-east-1 EVIDENCE_BUCKET=soc2-evidence-prod GITHUB_TOKEN=ghp_xxx OKTA_ORG=acme OKTA_TOKEN=00xxx /home/ubuntu/soc2-evidence/bin/collect.sh >> /home/ubuntu/soc2-evidence/cron.log 2>&1') | crontab -
crontab -l
You should see the new cron entry printed back with the exact schedule and environment variables.
Step 6: Add a simple evidence manifest for auditors
cat > ~/soc2-evidence/bin/manifest.sh <<'bash'
#!/usr/bin/env bash
set -euo pipefail
PREFIX="$(aws s3api list-objects-v2 --bucket "$EVIDENCE_BUCKET" --delimiter '/' | jq -r '.CommonPrefixes[-1].Prefix')"
aws s3 ls "s3://$EVIDENCE_BUCKET/$PREFIX" --recursive | awk '{print $4}' | jq -R -s -c 'split("\n")[:-1] | {collected_at: now|todate, files: .}'
bash
chmod +x ~/soc2-evidence/bin/manifest.sh
~/soc2-evidence/bin/manifest.sh
You should see one JSON object listing the files in the latest evidence prefix.
Verify it works
Run these checks end to end:
~/soc2-evidence/bin/collect.sh
aws s3 ls "s3://$EVIDENCE_BUCKET/" | tail -n 3
LATEST="$(aws s3api list-objects-v2 --bucket "$EVIDENCE_BUCKET" --delimiter '/' | jq -r '.CommonPrefixes[-1].Prefix')"
aws s3 cp "s3://$EVIDENCE_BUCKET/${LATEST}aws/iam-users.json" - | jq '.Users[0].UserName'
aws s3 cp "s3://$EVIDENCE_BUCKET/${LATEST}github/actions-runs.json" - | jq '.workflow_runs[0].status'
aws s3 cp "s3://$EVIDENCE_BUCKET/${LATEST}okta/system-log.json" - | jq '.[0].eventType'
Expected results:
aws s3 lsshows a new timestamped prefixjqprints one IAM username, one GitHub Actions run status such as"completed", and one Okta event type such as"user.session.start"- Exit code is
0; ifecho $?prints anything else, the collection failed
A typical auth failure looks like this:
curl -sS -H "Authorization: SSWS badtoken" "https://acme.okta.com/api/v1/users?limit=1"
{"errorCode":"E0000011","errorSummary":"Invalid token provided","errorLink":"E0000011","errorId":"oae...","errorCauses":[]}
Common pitfalls
GitHub token missing repo/admin scopes
Mistake: using a token that can read /user but not branch protection or Actions settings.
Symptom:
gh api /repos/acme-inc/platform-api/branches/main/protection
{"message":"Resource not accessible by personal access token"}
Fix: create a token with repository administration read access and Actions read access, then rerun Step 2.
Okta org URL includes -admin
Mistake: setting OKTA_ORG=acme-admin or calling the admin hostname.
Symptom:
curl -I "https://acme-admin.okta.com/api/v1/users?limit=1"
HTTP/2 302
location: https://login.okta.com/
Fix: set OKTA_ORG to the base org subdomain only, for example acme, and call https://acme.okta.com/....
AWS Config or CloudTrail not enabled in the account
Mistake: collecting evidence for controls that were never turned on.
Symptom:
aws configservice describe-configuration-recorders
{"ConfigurationRecorders":[]}
Fix: enable the service in AWS first; empty evidence is still valid technically, but it proves the control is absent.
Cron runs with a smaller environment than your shell
Mistake: the script works interactively but fails under cron because tokens or PATH are missing.
Symptom in cron.log:
/home/ubuntu/soc2-evidence/bin/collect.sh: line 7: GITHUB_TOKEN: missing GITHUB_TOKEN
Fix: put every required variable inline in the crontab entry exactly as shown in Step 5, or source a dedicated env file from the script.
JSON validation fails because an API returned HTML
Mistake: an auth redirect, proxy block page, or rate-limit page got saved as .json.
Symptom:
parse error: Invalid numeric literal at line 1, column 10
Fix: inspect the file with head -20 file.json; if it starts with <html>, fix the token, URL, or proxy and rerun.
Evidence gets overwritten instead of retained
Mistake: uploading to a fixed path like s3://bucket/latest/ only.
Symptom: yesterday's files disappear from the object listing.
Fix: keep the timestamped prefix from Step 3 and enable bucket versioning from Step 1.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI