Chapter 29Lesson 02~230 minutes

Audit Logs, Security Policies, Compliance Evidence, Repository Governance, and Enterprise Controls: Guided Hands-On Workflow and Core Operations

Build a disposable governance-evidence repository, normalize realistic audit events, and map repository controls to expected evidence without requiring paid enterprise features.

Hands-onSECURITY.mdEvidence normalizationAudit fixtureChain of custody

Learning objectives

  • Create a disposable public governance-evidence repository.
  • Add human-readable policy and ownership files without confusing them with enforcement.
  • Normalize time-bounded audit events into an evidence report while retaining raw input.
  • Map expected GitHub audit events to ruleset/team/security-configuration changes.
  • Verify local Git state, hosted repository state, hashes, and optional organization audit-log evidence.

1. Scenario and availability

You are preparing evidence for a fictional service named service-api. The mandatory lab needs only GitHub Free, a public disposable personal repository, GitHub CLI, Git, and Python 3. Organization audit-log mutation/API access is not required. If you already control a disposable organization, an optional section lets you inspect its audit-log UI. If that organization is on Enterprise Cloud, you can additionally use the REST audit-log endpoint.

Safety boundary: use only synthetic identities such as learner-example. Do not export employer audit logs, collaborator identities, IP addresses, token metadata, or security-event details into a public training repository.

2. Preflight: prove the target before creating it

gh auth status
gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq '{login,id}'

OWNER="YOUR_LOGIN"
REPO_NAME="c29-governance-evidence-lab"
REPO="$OWNER/$REPO_NAME"

gh repo view "$REPO" --json nameWithOwner,visibility 2>/dev/null   || echo "Expected: disposable repo does not exist yet"

The first command proves which account the CLI will use. The repository lookup prevents accidentally reusing a valuable repository with a similar name. No organization is necessary because the live GitHub changes in the mandatory path are ordinary repository files only.

3. Create the disposable repository and capture baseline state

gh repo create "$REPO" --public --clone --description "Chapter 29 governance evidence lab"
cd "$REPO_NAME"
printf '# Chapter 29 governance evidence lab
' > README.md
git add README.md
git commit -m "Initialize Chapter 29 lab"
git push -u origin HEAD

BASE_SHA=$(git rev-parse HEAD)
printf 'baseline_commit=%s
' "$BASE_SHA"
gh repo view "$REPO" --json nameWithOwner,visibility,defaultBranchRef,url
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/rulesets" --jq '.'

Expected state: one public disposable repository, one initial commit, and normally an empty ruleset array. Record BASE_SHA; Git commit identity is evidence about repository content, not evidence about every hosted setting.

4. Add policy and ownership documents through Git reviewable state

SECURITY.md communicates vulnerability-reporting expectations. CODEOWNERS declares ownership. A control matrix connects policy intent to enforcement/evidence. These are intentionally separate files so their different roles remain visible.

mkdir -p .github governance/evidence
cat > SECURITY.md <<'EOF'
# Security Policy

## Supported versions
This disposable training repository supports only the current default branch.

## Reporting
Do not post real credentials or vulnerabilities here.
For the lab, use only fictional examples and learner@example.invalid.
EOF

printf '/governance/ @%s
/SECURITY.md @%s
' "$OWNER" "$OWNER" > .github/CODEOWNERS

cat > governance/CONTROL_MATRIX.md <<'EOF'
# Control Matrix

| Objective | Declared control | Enforcement | Evidence | Owner | Review |
|---|---|---|---|---|---|
| Security reports follow a private path | SECURITY.md | Human process | file SHA + reviewed commit | Security | quarterly |
| Governance changes have an owner | CODEOWNERS | Optional ruleset review | CODEOWNERS SHA + PR/check evidence | Platform | quarterly |
| Protected refs follow merge policy | Repository ruleset | GitHub ruleset | ruleset state + audit event where available | Platform | quarterly |
EOF

git add SECURITY.md .github/CODEOWNERS governance/CONTROL_MATRIX.md
git diff --cached --check
git commit -m "Add governance policy and ownership evidence"
git push
POLICY_SHA=$(git rev-parse HEAD)
printf 'policy_commit=%s
' "$POLICY_SHA"

Prediction before pushing: the Git ref will advance and the three files will exist at the new commit, but adding Markdown and CODEOWNERS does not automatically create a branch ruleset. Verify that prediction independently.

git show --stat --oneline "$POLICY_SHA"
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/contents/SECURITY.md?ref=$POLICY_SHA" --jq '{path,sha,size}'
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/contents/.github/CODEOWNERS?ref=$POLICY_SHA" --jq '{path,sha,size}'
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/rulesets" --jq '[.[] | {id,name,enforcement}]'

5. Build a realistic but synthetic raw audit-event set

The following action names and field families are documented GitHub audit events. The actors, organization, repository, timestamps, IDs, and request IDs are fictional. Save the raw fixture exactly as collected; normalization happens into a separate file.

[
  {
    "_document_id": "fixture-team-add-001",
    "@timestamp": 1787175000000,
    "action": "team.add_repository",
    "actor": "learner-example",
    "org": "octo-c29-fixture",
    "repo": "octo-c29-fixture/service-api",
    "team": "octo-c29-fixture/release",
    "permission": "push",
    "request_id": "REQ-FIXTURE-001"
  },
  {
    "_document_id": "fixture-team-update-002",
    "@timestamp": 1787175600000,
    "action": "team.update_repository_permission",
    "actor": "learner-example",
    "org": "octo-c29-fixture",
    "repo": "octo-c29-fixture/service-api",
    "team": "octo-c29-fixture/release",
    "old_repo_permission": "push",
    "new_repo_permission": "maintain",
    "request_id": "REQ-FIXTURE-002"
  },
  {
    "_document_id": "fixture-ruleset-003",
    "@timestamp": 1787176200000,
    "action": "repository_ruleset.create",
    "actor": "learner-example",
    "org": "octo-c29-fixture",
    "repo": "octo-c29-fixture/service-api",
    "ruleset_name": "main-governance",
    "ruleset_enforcement": "active",
    "request_id": "REQ-FIXTURE-003"
  },
  {
    "_document_id": "fixture-security-config-004",
    "@timestamp": 1787176800000,
    "action": "security_configuration.create",
    "actor": "learner-example",
    "org": "octo-c29-fixture",
    "security_configuration_name": "public-baseline",
    "request_id": "REQ-FIXTURE-004"
  }
]

Save it as governance/evidence/audit-raw.json. The raw file is evidence input. Do not “clean” it in place.

6. Normalize fields without destroying the source

This standard-library Python script hashes the raw input, parses it, sorts by timestamp, writes a stable CSV report, then hashes the report. It intentionally excludes high-risk fields such as token identifiers from the normalized report; the raw source remains the place to revisit additional metadata.

from __future__ import annotations
import csv
import hashlib
import json
from pathlib import Path
from datetime import datetime, timezone

raw_path = Path("governance/evidence/audit-raw.json")
out_path = Path("governance/evidence/audit-normalized.csv")
manifest_path = Path("governance/evidence/SHA256SUMS.txt")

def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()

events = json.loads(raw_path.read_text(encoding="utf-8"))
events.sort(key=lambda e: e.get("@timestamp", 0))
fields = ["timestamp_utc", "document_id", "action", "actor", "org", "repo", "target", "request_id"]
with out_path.open("w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=fields)
    w.writeheader()
    for e in events:
        ms = int(e.get("@timestamp", 0))
        stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
        target = e.get("team") or e.get("ruleset_name") or e.get("security_configuration_name") or e.get("user") or ""
        w.writerow({
            "timestamp_utc": stamp,
            "document_id": e.get("_document_id", ""),
            "action": e.get("action", ""),
            "actor": e.get("actor", ""),
            "org": e.get("org", ""),
            "repo": e.get("repo", ""),
            "target": target,
            "request_id": e.get("request_id", ""),
        })
manifest_path.write_text(
    f"{sha256(raw_path)}  {raw_path.as_posix()}\n"
    f"{sha256(out_path)}  {out_path.as_posix()}\n",
    encoding="utf-8",
)
print(f"normalized_events={len(events)}")
print(manifest_path.read_text(encoding="utf-8"), end="")

Expected output begins with normalized_events=4 followed by two SHA-256 lines. The manifest proves whether your retained local copies changed after collection; it does not magically authenticate the origin of a synthetic fixture.

7. Optional live organization audit-log inspection

If you control a disposable organization, organization owners can inspect the audit log in the current GitHub UI. Use a narrow time range and action qualifier. Do not perform privileged changes merely to manufacture an event. If you already made a harmless lab action, search for its documented event category.

Organization → Settings → Logs → Audit log

Example search qualifiers:
action:team.add_repository created:>=2026-08-19
repo:YOUR_ORG/service-api created:2026-08-19..2026-08-20

Enterprise Cloud optional REST path: the current organization audit-log REST endpoint requires organization-owner authorization. The query below is read-only, versioned, time-bounded, and paginated.

ORG="YOUR_DISPOSABLE_ORG"
gh api --method GET --paginate   -H "Accept: application/vnd.github+json"   -H "X-GitHub-Api-Version: 2026-03-10"   -f phrase='created:>=2026-08-19 action:team'   -f include='web'   -f per_page=100   "orgs/$ORG/audit-log"   --jq '.[] | {timestamp:."@timestamp",action,actor,org,repo,team,request_id}'

If this returns 403/404, distinguish product availability from authorization. A GitHub Free/Team organization can use the owner UI for ordinary organization audit events, but the documented REST organization audit-log endpoint is Enterprise Cloud. Do not “fix” that by creating a broader token.

8. Map control changes to expected evidence

Change Expected hosted state Documented audit family when applicable Independent evidence
Team gains repo access Team permission appears on repository team.add_repository Access API/UI + event + ticket/approval
Team permission changes Effective role changes team.update_repository_permission Before/after permission + event
Repository ruleset created Ruleset object exists with enforcement/conditions repository_ruleset.create Ruleset GET + event + reviewed change record
Security configuration created Configuration object exists security_configuration.create Configuration GET + event + coverage report
SECURITY.md committed Repository file/ref changes Do not assume a dedicated policy-file audit event Git commit + content/blob SHA + PR/review evidence

The final row is important: Git history itself is often the primary evidence for repository-file policy changes. Governance is not “audit log or nothing.” Choose evidence that matches the resource being controlled.

9. Challenge: choose the correct evidence surface

A reviewer asks: “Who approved the wording change in SECURITY.md, and who changed the release team's repository role two days later?” Do not answer both with one query. The SECURITY.md question starts with Git commit/PR/review evidence. The team-role question starts with effective-access state plus team.update_repository_permission audit evidence if the organization surface makes it available. Write the two collection plans before running any commands.

10. Verification and cleanup

git status --short
git log --oneline -5
sha256sum governance/evidence/audit-raw.json governance/evidence/audit-normalized.csv
# PowerShell alternative:
# Get-FileHash governance/evidence/audit-raw.json -Algorithm SHA256

gh repo archive "$REPO" --yes

Archiving is reversible and keeps the evidence available for review. Repository deletion is intentionally not part of the mandatory lab. If you used a disposable organization, remove only the lab-specific grants/settings you added; do not delete an organization merely for cleanup.

Knowledge check

Why does the lab save raw JSON and normalized CSV separately?

A SECURITY.md commit exists, but the organization audit query has no “security policy changed” row. Is the policy change unevidenced?

Why is widening a token not the correct response to a Free organization failing the Enterprise Cloud audit-log REST endpoint?

Which event would you expect when a team is given repository access?

What does hashing the evidence files prove?

Summary

You now have a reproducible evidence pipeline: create observable repository policy state, preserve raw audit input, normalize it without deleting source context, hash both forms, and map each control to the evidence surface that can actually prove it. Lesson 3 turns those mechanics into policy choices about retention, enforcement scope, and cost.

Next lesson

Audit Logs, Security Policies, Compliance Evidence, Repository Governance, and Enterprise Controls: Configuration, Design Choices, and Tradeoffs

Further reading — current primary sources

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.