Skip to content

ADR-0009: Security Event Handler Architecture

ADR-0009: Security Event Handler Architecture

Section titled “ADR-0009: Security Event Handler Architecture”
  • Status: Accepted
  • Date: 2026-05-05
  • Decision makers: Scott Schreckengaust

GitHub emits security and code quality events (code_scanning_alert, secret_scanning_alert, dependabot_alert, security_advisory) that currently fall through to the stub handler (logged only). We need dedicated handlers that take action — comment on PRs, create issues, block merges, and notify — based on severity. The system must be tool-agnostic, extensible, and configurable per-repository.

  1. Per-type handler Lambdas — each event type gets its own handler that parses its unique payload and normalizes into a shared SecurityFinding interface
  2. Shared action executor — takes a SecurityFinding + resolved config, executes the appropriate actions
  3. Cascading config resolution — per-repo .github/ai3-mvp.json overrides org-level config (DynamoDB) overrides app defaults (hardcoded)

All handlers normalize events into:

interface SecurityFinding {
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
htmlUrl: string;
repo: { owner: string; name: string };
ref?: { pr?: number; commit?: string };
tool?: string;
alertNumber?: number;
source: string; // handler name
}

Actions are composable via + delimiter in config:

ActionWhat it does
blockFail a Check Run → prevents merge if check is required
issueCreate a GitHub Issue with finding details
commentPost a comment on the relevant PR or commit
notifyPublish to SNS topic (Slack/email integration)
annotateCreate a Check Run annotation (inline code marker)
ignoreLog metric only, take no action

Actions are specified as JSON arrays. Example: "critical": ["block", "issue", "notify"]

Conflicts are resolved deterministically at runtime:

  1. ignore present → discard all other actions, result is ["ignore"]. Schema enforces ignore must be the sole element; runtime also enforces this as a safety net.
  2. block + annotate both present → remove annotate. Both create a Check Run named ai3-mvp/security; block sets conclusion failure, annotate sets neutral. Last-write-wins on same-named Check Runs means annotate after block would silently unblock merges. block is a strict superset (includes output/summary).
  3. Duplicates → deduplicated automatically.
SeverityDefault Actions
critical["block", "issue", "notify"]
high["comment", "issue"]
medium["annotate"]
low["ignore"]

Repos can override defaults via a JSON file validated against a published JSON Schema:

{
"$schema": "https://raw.githubusercontent.com/scottschreckengaust/framework-for-github-app-on-aws/main/schemas/ai3-mvp-config.schema.json",
"security": {
"code_scanning": {
"critical": ["block", "issue", "notify"],
"high": ["comment"],
"medium": ["ignore"]
}
}
}

Schema validation (via oneOf) rejects ["ignore", "issue"] at edit time. Runtime resolveConflicts() handles any edge cases that bypass schema validation.

repo (.github/ai3-mvp.json) → org (DynamoDB) → app defaults

Most specific wins. Resolution is cached (5-minute TTL) per repo to avoid excessive GitHub API calls. This resolution layer is designed for reuse beyond security handlers (Expo app, future features).

All security event handlers use installation tokens only (not user OAuth tokens). These are system-generated events with no human actor to attribute. The installation token is fetched via the existing getInstallationToken() pattern.

The block action creates/updates a Check Run named ai3-mvp/security with status failure. Repos that want hard blocking must add this as a required status check in branch protection rules. This is opt-in — the handler creates the signal, repo admins decide enforcement.

Rejected. Different event types have different payload structures and different severity sources. Separate handlers allow independent scaling, deployment, and error isolation.

Rejected. Security events are system-generated (no user in the loop). Installation tokens have the correct permissions and avoid token-unavailable failures.

Rejected. JSON has native schema validation (JSON Schema + $schema for IDE support), no parser ambiguity (YAML’s Norway problem), and aligns with TypeScript’s JSON.parse at runtime.

String-based action expressions ("block+issue+notify")

Section titled “String-based action expressions ("block+issue+notify")”

Initially implemented, then replaced with JSON arrays. The +-joined string format prevented per-element schema validation, couldn’t enforce uniqueItems, and gave opaque regex errors. JSON arrays with oneOf provide precise validation and catch ignore-combination errors at edit time.

Rejected as the final state. We ship with sensible hardcoded defaults but design the interface to accept external config from day one. The config resolution layer slots in without handler changes.

  • Adding a new scanning tool (SARIF upload) requires zero handler code changes
  • Adding a new event type requires only a new normalizer to SecurityFinding
  • Per-repo customization is self-service (PR to add .github/ai3-mvp.json)
  • Config resolution is reusable for non-security features
  • JSON Schema provides IDE autocompletion for config authors
  • Four new Lambdas increase cold-start surface (mitigated: security events are low-frequency)
  • Config resolution adds one GitHub API call per invocation (mitigated: 5-min cache)
  • block action requires repo admins to configure required checks separately
  • Noisy repos (many medium findings) could generate excessive comments — mitigated by ignore and annotate being low-noise actions
  • Config file could be deleted mid-processing — fall back to org/defaults gracefully