Mapping Audit Findings to Remediation Workflows
Problem Framing
A crawl report that lists three hundred findings is not an action plan — it is a wall of text that nobody owns. Without an explicit mapping from each finding to a severity, a responsible team, and a concrete remediation procedure, findings sit in a spreadsheet until someone notices the same broken canonical tag for the fourth consecutive quarter. The gap is rarely a lack of detection; automated crawlers and scoring pipelines are good at finding problems. The gap is triage: deciding fast, consistently, and without a human re-reading every row, who fixes what and how urgently.
This guide builds that triage layer as part of Technical Audit Fundamentals & Scope Mapping, the section covering how audits are scoped, baselined, and prioritized before remediation begins. The approach here sits downstream of scope and prioritization: it assumes findings already exist and focuses on turning each one into an owned, actionable workflow entry rather than a passive report line.
Prerequisites & Environment Setup
The triage router needs a structured crawl report as input, a versioned taxonomy file, and access to whatever ticketing system your teams use for remediation tracking. Everything below assumes Python 3.11 and a ticketing API reachable over HTTPS with a service-account token, not a personal credential.
Pinned tool versions
| Tool | Minimum version | Purpose |
|---|---|---|
| Python | 3.11.9 | Router runtime |
| PyYAML | 6.0.1 | Parsing the finding taxonomy |
| requests | 2.32.3 | Ticketing API calls |
| jsonschema | 4.22.0 | Validating the crawl report shape before routing |
| flock (util-linux) | 2.38 | Concurrency guard on scheduled runs |
Required environment variables
# /etc/audit-triage/env — sourced by the router's cron wrapper
export CRAWL_REPORT_BUCKET="gs://site-audit-reports-prod"
export TAXONOMY_PATH="/opt/triage/finding_taxonomy.yaml"
export TICKETING_API_URL="https://tickets.internal/api/v3"
export TICKETING_API_TOKEN="${TICKETING_SERVICE_TOKEN}"
export LEDGER_PATH="/opt/triage/ledger"
export TZ="UTC"
Keep finding_taxonomy.yaml in the same version-control repository as the crawler and scoring configs. A taxonomy change is a release, not a quiet edit — reviewers need to see a diff before a finding's owner or severity shifts.
Step 1 — Initialization: The Finding Taxonomy
Every finding code your crawler or scoring pipeline can emit needs exactly one row in the taxonomy. Ambiguity here — two rules that both match the same finding — is worse than a missing rule, because it makes routing nondeterministic depending on dictionary iteration order.
# /opt/triage/finding_taxonomy.yaml
# version-controlled — every change goes through code review
version: "1.4.0"
rules:
- finding_code: "REDIRECT_CHAIN_3PLUS"
match:
category: "crawlability"
pattern: "redirect_hops >= 3"
severity: "high"
owner_team: "platform-sre"
playbook: "fixing-broken-redirect-chains"
- finding_code: "SITEMAP_ORPHAN_404"
match:
category: "indexability"
pattern: "sitemap_entry AND status_code == 404"
severity: "medium"
owner_team: "seo-engineering"
playbook: "resolving-orphaned-sitemap-entries"
- finding_code: "CRAWL_BUDGET_FACET_EXPLOSION"
match:
category: "crawl_efficiency"
pattern: "facet_url_count > baseline_p95"
severity: "medium"
owner_team: "seo-engineering"
playbook: "recovering-from-crawl-budget-exhaustion"
- finding_code: "WCAG_CRITICAL_VIOLATION"
match:
category: "accessibility"
pattern: "wcag_level == 'A' AND violation_count > 0"
severity: "critical"
owner_team: "frontend-platform"
playbook: "accessibility-critical-remediation"
- finding_code: "LCP_REGRESSION_COMMERCIAL"
match:
category: "performance"
pattern: "lcp_p75_ms > 4000 AND template == 'commercial'"
severity: "high"
owner_team: "web-performance"
playbook: "performance-regression-triage"
defaults:
unmatched_severity: "unclassified"
unmatched_owner_team: "audit-lead"
unmatched_playbook: null
Treat the defaults block as a safety net, not a resting place. A finding that lands there should trigger a taxonomy review, not silent acceptance — see the assigning severity labels to audit findings guide for a deterministic scoring rubric you can wire in as an override when a finding's blast radius pushes it above its default severity.
Step 2 — Core Configuration: Routing Parameters
The router reads four fields per taxonomy row and needs a handful of global parameters to behave safely under retries and partial data.
Key parameters table
| Parameter | Type | Default | Purpose |
|---|---|---|---|
finding_code |
string | — | Stable identifier a crawler or scoring pipeline emits per finding type |
severity |
enum | medium |
One of critical, high, medium, low, unclassified |
owner_team |
string | audit-lead |
Team the ticket is assigned to; must match a team key the ticketing system recognizes |
playbook |
string | null | null |
Slug of the remediation playbook to link in the ticket body; null if no automated playbook exists yet |
dedup_window_days |
int | 14 | Days within which a repeat of the same finding_code + URL is treated as the same open issue, not a new ticket |
stale_ticket_days |
int | 30 | Days after which an open ticket with no activity is escalated one severity tier |
// /opt/triage/router_config.json
{
"taxonomy_version": "1.4.0",
"dedup_window_days": 14,
"stale_ticket_days": 30,
"batch_size": 200,
"fail_on_unmatched_pct": 0.05
}
The fail_on_unmatched_pct guard is deliberate: if more than 5% of findings in a single report fall through to the default rule, the router should exit non-zero rather than silently open two hundred audit-lead-owned tickets. That threshold usually means the crawler started emitting a new finding category the taxonomy has not caught up with yet.
Triage Flow
The diagram below shows how a raw finding moves from detection through classification to an owned, playbook-linked ticket.
Step 3 — Execution: The Triage Router
Run the router as a discrete step after crawl scoring completes, never inline with the crawl itself — a slow ticketing API should not block or fail a crawl run. The router reads the latest report, evaluates each finding against the taxonomy, and opens or updates tickets through the ticketing API's idempotent create endpoint.
# /opt/triage/router.py
# Requires: PyYAML==6.0.1, requests==2.32.3, jsonschema==4.22.0
from __future__ import annotations
import hashlib
import json
import os
import sys
from pathlib import Path
from typing import Any
import requests
import yaml
from jsonschema import validate, ValidationError
REPORT_SCHEMA = {
"type": "object",
"required": ["snapshot_date", "findings"],
"properties": {
"snapshot_date": {"type": "string"},
"findings": {"type": "array"},
},
}
def load_taxonomy(path: str) -> dict:
with Path(path).open() as fh:
return yaml.safe_load(fh)
def match_rule(finding: dict, taxonomy: dict) -> dict:
"""Return the first matching taxonomy rule, or the default row."""
for rule in taxonomy["rules"]:
if rule["finding_code"] == finding.get("finding_code"):
return rule
defaults = taxonomy["defaults"]
return {
"finding_code": finding.get("finding_code", "UNKNOWN"),
"severity": defaults["unmatched_severity"],
"owner_team": defaults["unmatched_owner_team"],
"playbook": defaults["unmatched_playbook"],
}
def external_id_for(finding: dict, snapshot_date: str) -> str:
"""Stable hash used as the ticketing system's idempotency key."""
key = f"{finding['finding_code']}|{finding['url']}|{snapshot_date}"
return hashlib.sha256(key.encode()).hexdigest()[:16]
def open_or_update_ticket(
finding: dict, rule: dict, external_id: str, api_url: str, token: str
) -> dict:
payload = {
"external_id": external_id,
"title": f"[{rule['severity'].upper()}] {finding['finding_code']} on {finding['url']}",
"owner_team": rule["owner_team"],
"severity": rule["severity"],
"playbook_slug": rule.get("playbook"),
"source_url": finding["url"],
}
resp = requests.put(
f"{api_url}/tickets/upsert",
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
resp.raise_for_status()
return resp.json()
def route_report(report_path: str, taxonomy_path: str, config: dict) -> list[dict]:
with Path(report_path).open() as fh:
report = json.load(fh)
try:
validate(instance=report, schema=REPORT_SCHEMA)
except ValidationError as exc:
raise SystemExit(f"Crawl report failed schema validation: {exc}") from exc
taxonomy = load_taxonomy(taxonomy_path)
snapshot_date = report["snapshot_date"]
findings = report["findings"]
results = []
unmatched = 0
for finding in findings:
rule = match_rule(finding, taxonomy)
if rule["severity"] == taxonomy["defaults"]["unmatched_severity"]:
unmatched += 1
ext_id = external_id_for(finding, snapshot_date)
ticket = open_or_update_ticket(
finding, rule, ext_id,
os.environ["TICKETING_API_URL"], os.environ["TICKETING_API_TOKEN"],
)
results.append({
"finding_code": finding["finding_code"],
"url": finding["url"],
"severity": rule["severity"],
"owner_team": rule["owner_team"],
"playbook": rule.get("playbook"),
"external_id": ext_id,
"ticket_id": ticket.get("id"),
})
unmatched_pct = unmatched / max(len(findings), 1)
if unmatched_pct > config["fail_on_unmatched_pct"]:
raise SystemExit(
f"{unmatched_pct:.1%} of findings unmatched — exceeds "
f"{config['fail_on_unmatched_pct']:.1%} threshold; taxonomy likely stale."
)
return results
if __name__ == "__main__":
cfg = json.loads(Path("/opt/triage/router_config.json").read_text())
outcome = route_report(sys.argv[1], os.environ["TAXONOMY_PATH"], cfg)
print(f"Routed {len(outcome)} findings", file=sys.stderr)
The open_or_update_ticket call uses PUT /tickets/upsert keyed on external_id rather than POST /tickets, which is the difference between an idempotent router and one that opens a fresh duplicate ticket every time a cron job retries after a transient network failure. If your ticketing platform only exposes a create endpoint, wrap it: check for an existing ticket by external_id first, and only create when none exists.
For the actual fix procedures a playbook slug points to, see Writing Remediation Playbooks for Audit Failures, which covers the runbook format the playbook_slug field references.
Step 4 — Artifact Capture: The Assignment Ledger
Every routing decision should be reproducible after the fact — not just "what is the ticket status now" but "what did the router decide, against which taxonomy version, on which run." Append each routed finding to a ledger rather than overwriting state, so a taxonomy change never erases the history of how a finding was previously classified.
#!/usr/bin/env bash
# /opt/triage/run_router.sh
set -euo pipefail
LOCKFILE="/var/lock/audit-triage.lock"
LEDGER_DIR="${LEDGER_PATH:-/opt/triage/ledger}"
REPORT_DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="/tmp/crawl_report_${REPORT_DATE}.json"
exec 200>"${LOCKFILE}"
flock -n 200 || { echo "Triage router already running — exiting." >&2; exit 1; }
gsutil cp "${CRAWL_REPORT_BUCKET}/reports/${REPORT_DATE}.json" "${REPORT_PATH}"
TAXONOMY_HASH=$(sha256sum "${TAXONOMY_PATH}" | awk '{print $1}')
mkdir -p "${LEDGER_DIR}/${REPORT_DATE}"
LEDGER_FILE="${LEDGER_DIR}/${REPORT_DATE}/assignments.ndjson"
python3 /opt/triage/router.py "${REPORT_PATH}" \
| tee >(jq -c --arg th "${TAXONOMY_HASH}" '. + {taxonomy_hash: $th}' >> "${LEDGER_FILE}")
echo "Routed findings for ${REPORT_DATE}; ledger at ${LEDGER_FILE}" >&2
Retention — keep the ledger for at least as long as the longest-lived compliance or audit-trail requirement your organization has; 13 months is a common baseline so a full year-over-year comparison stays intact. Because the ledger is append-only newline-delimited JSON, replaying it against a new taxonomy version is a straightforward diff, not a database migration.
Verification Checklist
Run these checks after every triage pass, before treating the routed tickets as the source of truth for what needs fixing:
- Confirm the router exited zero and printed
Routed N findingswhereNmatches the crawl report's finding count minus any findings explicitly excluded by scope. - Assert the unmatched percentage stayed under
fail_on_unmatched_pct(5% by default) — check the router's stderr output or grep the ledger for"severity": "unclassified". - Spot-check three routed tickets in the ticketing system: confirm
owner_team,severity, and the linkedplaybook_slugall match what the taxonomy specifies for thatfinding_code. - Re-run the router against the same report a second time and confirm no new ticket IDs are created — only existing tickets are touched. This validates the idempotency guarantee.
- Diff the current ledger file's row count against the report's finding count; they should be equal. A mismatch usually means the ticketing API call failed silently partway through the batch.
- Cross-reference the highest-severity findings against Risk Scoring Frameworks for Technical Debt to confirm severity assignments track with the broader risk model rather than drifting from it over successive taxonomy edits.
Troubleshooting
Every finding from a new crawler version lands in the default bucket
Root cause: the crawler started emitting a renamed or newly split finding_code (for example, splitting REDIRECT_CHAIN into REDIRECT_CHAIN_2HOP and REDIRECT_CHAIN_3PLUS) that has no taxonomy row yet.
# List finding codes present in the report but absent from the taxonomy
python3 -c "
import json, yaml
report = json.load(open('/tmp/crawl_report_$(date -u +%Y-%m-%d).json'))
taxonomy = yaml.safe_load(open('/opt/triage/finding_taxonomy.yaml'))
known = {r['finding_code'] for r in taxonomy['rules']}
seen = {f['finding_code'] for f in report['findings']}
print('Unmapped codes:', seen - known)
"
Fix: add a taxonomy rule for each unmapped code and re-run; do not raise fail_on_unmatched_pct to make the symptom disappear.
Router creates a duplicate ticket for the same issue every week
Root cause: external_id_for includes snapshot_date, and the ticketing system's dedup check is comparing on ticket title instead of external_id, so a persistent issue reported on seven consecutive daily snapshots opens seven tickets.
Fix: for issues that should stay open until resolved rather than re-opening per snapshot, derive external_id from finding_code and url only (drop snapshot_date), and instead update the ticket's last_seen field on each run.
A finding is routed to the wrong owner after a team reorganization
Root cause: owner_team values in the taxonomy are stale strings that no longer map to a real team in the ticketing system, so tickets land in a default or orphaned queue.
# Confirm every owner_team in the taxonomy resolves to an active team
python3 -c "
import yaml, requests, os
taxonomy = yaml.safe_load(open('/opt/triage/finding_taxonomy.yaml'))
teams = {t['id'] for t in requests.get(
os.environ['TICKETING_API_URL'] + '/teams',
headers={'Authorization': 'Bearer ' + os.environ['TICKETING_API_TOKEN']}
).json()}
for rule in taxonomy['rules']:
if rule['owner_team'] not in teams:
print('Stale owner_team:', rule['finding_code'], rule['owner_team'])
"
Fix: update the taxonomy's owner_team values as part of the reorganization's rollout checklist, not as an afterthought discovered from misrouted tickets.
The router exits with a schema validation error on an otherwise normal report
Root cause: the crawl report generator changed its output shape — a renamed top-level key, or findings nested one level deeper than the schema expects.
Fix: update REPORT_SCHEMA to match the new shape deliberately (do not loosen it to "additionalProperties": true as a permanent fix), and confirm with whoever owns the crawl report format that the change was intentional rather than a regression.
Unmatched percentage spikes right after a taxonomy edit, not a crawler change
Root cause: a rule's match.pattern was edited with a typo (for example, >= accidentally changed to >), narrowing which findings it catches without changing the finding_code a reviewer would notice in a diff.
Fix: add a unit test that runs the taxonomy against a fixture set of known findings and asserts the expected match count per rule before merging any taxonomy change.
Ledger file grows without bound and slows down the nightly run
Root cause: the ledger is written as one growing file per environment instead of being partitioned by date, so tee and downstream jq processing get slower every month.
Fix: confirm the ledger path includes the REPORT_DATE partition as shown in Step 4, and add a scheduled job that compresses ledger partitions older than 30 days.
FAQ
What happens to a finding that does not match any rule in the taxonomy?
It is routed to a default catch-all owner (usually the audit lead) with severity unclassified and flagged in the ledger. Treat any unclassified finding as a taxonomy gap and add a rule for it before the next scheduled run, rather than letting it sit unassigned indefinitely.
How do I avoid opening duplicate tickets when the router runs twice on the same report?
Derive a stable external_id from a hash of the finding_code, the affected URL, and the crawl report's snapshot date, then have the ticketing call check for an existing ticket with that external_id before creating a new one. This makes the router idempotent across retries and duplicate cron triggers.
Should severity be set by the taxonomy or recalculated per finding?
The taxonomy sets a default severity per finding code, but a router should allow an override when reach or blast radius pushes a normally low-severity finding above a threshold. Keep the override logic explicit and logged so severity is reproducible, not silently adjusted. The assigning severity labels to audit findings guide covers a deterministic rubric for exactly this kind of override.
Who should own a finding that spans two teams, like a redirect chain caused by a CMS migration?
Assign ownership to the team that controls the fix surface, not the team that caused the issue. A redirect chain is almost always fixed at the web server or CDN layer, so route it to platform or SRE even if a content migration introduced it, and reference the originating team in the ticket body for context.
Related
- Technical Audit Fundamentals & Scope Mapping — parent section covering audit scope, baselines, and prioritization
- Assigning Severity Labels to Audit Findings — a deterministic impact × reach × confidence rubric for severity overrides
- Risk Scoring Frameworks for Technical Debt — the broader risk model this triage layer's severities should track
- Monitoring, Alerting & Remediation — where routed findings become alerts, incidents, and executed playbooks