Detect (DE) · Approval gating
Approval gating at the server side layer
external · Outside the agent entirely
If this concern is breached, how do we know?
What this cell does
GitHub webhook for branch-protection bypass; hourly drift-detection job; deployment-freeze breach alerts; audit on changes to branch protection itself.
Artifacts (3)
audit-branch-protection.ymlview on GitHub# ABOUTME: Hourly workflow that diffs live branch protection against a checked-in expected JSON. Drift fires a SIEM event.
# ABOUTME: The expected JSON lives at controls/approval-gating/server-side/branch-protection-expected.json and is CODEOWNERS-protected.
name: Audit Branch Protection
on:
schedule:
- cron: '0 * * * *' # hourly
workflow_dispatch: {}
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Get current protection
env:
GH_TOKEN: ${{ secrets.AUDIT_PAT }}
run: |
gh api repos/${{ github.repository }}/branches/main/protection > current.json
- name: Compare to expected
id: diff
run: |
if diff -u controls/approval-gating/server-side/branch-protection-expected.json current.json > /tmp/drift.diff 2>&1; then
echo "drift=false" >> $GITHUB_OUTPUT
else
echo "drift=true" >> $GITHUB_OUTPUT
fi
- name: Ship drift to SIEM
if: steps.diff.outputs.drift == 'true'
env:
SIEM_TOKEN: ${{ secrets.SIEM_TOKEN }}
SIEM_URL: ${{ vars.SIEM_URL }}
run: |
DIFF_JSON=$(jq -R -s . < /tmp/drift.diff)
curl -sS -X POST "$SIEM_URL/agent-sentinel-bp/_doc" \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"event\": \"branch_protection_drift\",
\"repo\": \"${{ github.repository }}\",
\"diff\": $DIFF_JSON,
\"ts\": \"$(date -Iseconds)\"
}"
- name: Fail the job on drift
if: steps.diff.outputs.drift == 'true'
run: |
echo "::error::Branch protection drift detected. See ship-to-SIEM step output."
exit 1
freeze-breach-step.ymlview on GitHub# ABOUTME: Workflow-step snippet for the IaC apply job that ships a freeze_breach_attempt event when DEPLOY_FREEZE=true.
# ABOUTME: Drop into the apply job in controls/blast-radius/server-side/iac-gated-pipeline.yml before the existing freeze-deny step.
- name: Freeze breach detection
if: vars.DEPLOY_FREEZE == 'true'
env:
SIEM_TOKEN: ${{ secrets.SIEM_TOKEN }}
SIEM_URL: ${{ vars.SIEM_URL }}
run: |
curl -sS -X POST "$SIEM_URL/agent-sentinel-freeze/_doc" \
-H "Authorization: Bearer $SIEM_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"event\": \"freeze_breach_attempt\",
\"repo\": \"${{ github.repository }}\",
\"actor\": \"${{ github.actor }}\",
\"workflow\": \"${{ github.workflow }}\",
\"run_id\": \"${{ github.run_id }}\",
\"ts\": \"$(date -Iseconds)\"
}"
echo "::error::Deployment freeze active. Apply blocked. SIEM event shipped."
exit 1
webhook-receiver.pyview on GitHub#!/usr/bin/env python3
# ABOUTME: Lambda-style GitHub webhook receiver that verifies HMAC signature and ships bypass events to SIEM.
# ABOUTME: HMAC verification is the load-bearing line; without it any actor can spoof events into the SIEM.
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request
SIEM_URL = os.environ.get("SIEM_URL", "https://siem.example.com:9200/agent-sentinel-bp/_doc")
SIEM_TOKEN = os.environ["SIEM_TOKEN"] # required
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode() # required
def verify_signature(headers: dict, body: bytes) -> bool:
"""GitHub computes HMAC-SHA256 over the body using the configured secret."""
sig = headers.get("X-Hub-Signature-256") or headers.get("x-hub-signature-256")
if not sig:
return False
expected = "sha256=" + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
def ship_alert(event_type: str, payload: dict) -> None:
body = json.dumps(
{
"event": event_type,
"data": payload,
"ts": payload.get("created_at") or payload.get("pushed_at"),
}
).encode()
req = urllib.request.Request(
SIEM_URL,
data=body,
headers={
"Authorization": f"Bearer {SIEM_TOKEN}",
"Content-Type": "application/json",
},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=10).read()
except urllib.error.URLError as exc:
# Fail-open on SIEM unavailability is less bad than dropping the event;
# log to stderr/CloudWatch so operations sees the gap.
print(f"webhook-receiver: SIEM unreachable: {exc}", flush=True)
def handler(event: dict, _context=None) -> dict:
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
body_str = event.get("body", "") or ""
body_bytes = body_str.encode() if isinstance(body_str, str) else body_str
if not verify_signature(headers, body_bytes):
return {"statusCode": 401, "body": "invalid signature"}
delivery_id = headers.get("x-github-delivery", "unknown")
event_name = headers.get("x-github-event", "unknown")
try:
payload = json.loads(body_str) if body_str else {}
except json.JSONDecodeError:
return {"statusCode": 400, "body": "malformed JSON"}
# branch_protection_rule events: deleted, edited.
if event_name == "branch_protection_rule":
action = payload.get("action")
if action in {"deleted", "edited"}:
ship_alert(f"branch_protection_{action}", {**payload, "delivery_id": delivery_id})
# push events: forced is True for `git push -f`. `--force-with-lease`
# also surfaces as forced=true on the API; both are bypass attempts on
# protected branches.
if event_name == "push":
if payload.get("forced") and payload.get("ref", "").startswith("refs/heads/"):
ship_alert("force_push", {**payload, "delivery_id": delivery_id})
# Branch deletion on a protected branch is also worth surfacing.
if payload.get("deleted") and payload.get("ref") in {"refs/heads/main", "refs/heads/master"}:
ship_alert("protected_branch_delete", {**payload, "delivery_id": delivery_id})
return {"statusCode": 200, "body": "ok"}
Cell notes
Sentinels, Approval gating / Server-side
Control. GitHub webhook for branch-protection bypass and force-push events. Hourly drift-detection job comparing live branch protection to a checked-in expected JSON. Deployment-freeze breach alerts. Audit log on changes to branch protection itself.
Strength. Deterministic. Failure modes: webhook secret not set or not verified (anyone can spoof events to the SIEM); audit cron at 24h interval (a bypass-then-restore cycle goes undetected); expected protection JSON not version-controlled (drift detection uses a stale baseline); webhook receiver discards push events with forced: false even though --force-with-lease still bypasses required review.
Tooling
- - GitHub webhook target (Lambda or service that verifies HMAC signatures).
- - A scheduled GitHub Actions workflow for hourly drift detection.
- - A SIEM that accepts JSON events.
Files in this directory
- -
webhook-receiver.py, Lambda-style handler that verifies the webhook signature, then ships filtered events (branch_protection_rule.deleted|edited, force-pushes, branch deletions on protected branches) to the SIEM. - -
audit-branch-protection.yml, hourly GitHub Actions workflow that fetches the live branch protection and diffs against../../../controls/approval-gating/server-side/branch-protection-expected.json. Drift fires a SIEM event. - -
freeze-breach-step.yml, drop-in workflow step (snippet) for the IaC apply job that posts a SIEM event when an apply attempts to run whileDEPLOY_FREEZE=true.
Verification
# 1. Webhook receives a force-push event
git push --force-with-lease origin main:test-branch
# expected: SIEM receives event within seconds
# 2. Audit job detects drift
gh api -X PUT repos/:owner/:repo/branches/main/protection -F enforce_admins=false # introduce drift
# Wait for next hourly run; SIEM should receive branch_protection_drift event
gh api -X PUT repos/:owner/:repo/branches/main/protection -F enforce_admins=true # restore
# 3. Freeze breach alert
gh variable set DEPLOY_FREEZE -b true
# Trigger an apply via PR merge; SIEM gets freeze_breach_attempt
gh variable set DEPLOY_FREEZE -b false
Common mistakes
- - Webhook secret not set or not verified, anyone can spoof events.
- - Audit cron at 24h interval. Bypass-then-restore goes undetected.
- - Expected protection JSON not version-controlled. Drift detection uses a stale baseline.
- - Webhook receiver discards push events with
forced: false.--force-with-leaseis also a bypass and presents differently.
Citation
NIST CSF 2.0 DE.CM-09, DE.AE-02, GV.RR-02. EU AI Act Art. 14 (human oversight obligations).
Primary failure modes
Documented, not hypothetical. A control whose bypass is undocumented is worse than no control, because somebody trusted it.
- webhook secret not verified (anyone spoofs events)
- audit cron at 24h interval (bypass-then-restore undetected)
- expected protection JSON not version-controlled
Crosswalk
| NIST CSF 2 0 | DE.CM-09, DE.AE-02, GV.RR-02 |
|---|---|
| OTHER | EU AI Act Art. 14 |
Cite this cell:
https://agenticcovenants.com/detect/approval-gating/server-side/