FISMA Control Mapping for GIS Pipelines
When an assessor asks “show me that AU-3 operates on your reprojection service,” you do not want to answer from memory — you want a machine-readable map that binds each NIST 800-53 control to the exact lineage hook that produces its evidence, plus a validator that fails the build when a run skipped one. This how-to, a companion to the FISMA compliance for spatial systems overview, builds that map as a YAML file and a Python checker you can run in CI against a pipeline run’s emitted events.
Prerequisites
- Python 3.10+ with
PyYAML6.x installed (pip install pyyaml). - A pipeline that already emits structured lineage events as JSON lines — the
LineageAuditEventshape from the overview page (fields:event_type,occurred_at,component,actor,outcome,output_sha256,parameters). - Read access to the run’s
audit.jsonloutput and write access to a repo path for the mapping file. - Agreement on your auditable-events list, so the mapping’s
applies_toevent types are stable.
Implementation
The mapping declares, per control, which lineage fields must be present and non-empty, and which event_type values the control applies to. Keeping it in YAML means auditors can read it and change control lets you review edits to it.
# controls_to_hooks.yaml — NIST 800-53 control -> lineage evidence binding
version: 1
controls:
AU-3:
title: Content of audit records
applies_to: ["*"] # every auditable event must be AU-3 complete
require_fields: [event_type, occurred_at, component, actor, source_uri, outcome]
AU-8:
title: Time stamps
applies_to: ["*"]
require_fields: [occurred_at]
require_utc: [occurred_at] # must end in +00:00 or Z
CM-3:
title: Configuration change control
applies_to: ["raster.reproject", "vector.transform", "service.publish"]
require_fields: [parameters]
require_nonempty: [parameters] # tool version / settings must be captured
SI-7:
title: Software and information integrity
applies_to: ["raster.reproject", "raster.generate", "vector.transform"]
require_fields: [output_sha256]
require_hash: output_sha256 # 64 lowercase hex chars
AC-6:
title: Least privilege
applies_to: ["*"]
require_fields: [actor]
forbid_values:
actor: ["", "root", "shared", "anonymous"]
The validator loads this mapping, streams the run’s events, and checks every applicable control against every event. It returns a non-zero exit code so a CI job blocks a non-compliant pipeline run.
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import yaml
HEX64 = re.compile(r"^[0-9a-f]{64}$")
def _applies(rule: dict, event_type: str) -> bool:
targets = rule.get("applies_to", [])
return "*" in targets or event_type in targets
def _check_event(control: str, rule: dict, evt: dict) -> list[str]:
"""Return a list of human-readable findings for one control/event pair."""
findings: list[str] = []
for field_name in rule.get("require_fields", []):
if not evt.get(field_name):
findings.append(f"{control}: missing required field '{field_name}'")
for field_name in rule.get("require_nonempty", []):
value = evt.get(field_name)
if not value or (isinstance(value, (dict, list)) and len(value) == 0):
findings.append(f"{control}: field '{field_name}' must be non-empty")
for field_name in rule.get("require_utc", []):
value = str(evt.get(field_name, ""))
if not (value.endswith("+00:00") or value.endswith("Z")):
findings.append(f"{control}: '{field_name}' is not UTC ({value!r})")
hash_field = rule.get("require_hash")
if hash_field and not HEX64.match(str(evt.get(hash_field, ""))):
findings.append(f"{control}: '{hash_field}' is not a 64-char SHA-256 hex digest")
for field_name, bad_values in rule.get("forbid_values", {}).items():
if str(evt.get(field_name, "")) in bad_values:
findings.append(f"{control}: '{field_name}'={evt.get(field_name)!r} is forbidden")
return findings
def validate_run(mapping_path: Path, events_path: Path) -> list[str]:
mapping = yaml.safe_load(mapping_path.read_text(encoding="utf-8"))
controls: dict[str, dict] = mapping["controls"]
all_findings: list[str] = []
covered: dict[str, int] = {c: 0 for c in controls}
with events_path.open(encoding="utf-8") as fh:
for line_no, raw in enumerate(fh, start=1):
raw = raw.strip()
if not raw:
continue
evt = json.loads(raw)
etype = evt.get("event_type", "")
for control, rule in controls.items():
if not _applies(rule, etype):
continue
covered[control] += 1
for finding in _check_event(control, rule, evt):
all_findings.append(f"line {line_no} [{etype}]: {finding}")
# A control that never matched any event is itself a coverage gap.
for control, hits in covered.items():
if hits == 0:
all_findings.append(f"{control}: no events in run exercised this control")
return all_findings
if __name__ == "__main__":
findings = validate_run(Path("controls_to_hooks.yaml"), Path("audit.jsonl"))
if findings:
print(f"FISMA control mapping: {len(findings)} finding(s)")
for f in findings:
print(" -", f)
sys.exit(1)
print("FISMA control mapping: all applicable controls evidenced")
The two-layer design matters: _check_event proves each present event satisfies its controls, while the covered tally catches the opposite failure — a control that no event ever exercised, which usually means a pipeline stage silently stopped emitting. Both are findings an assessor would raise, so both fail the build.
The rule vocabulary is deliberately small — require_fields, require_nonempty, require_utc, require_hash, and forbid_values — because a mapping an auditor cannot read is a mapping no one trusts. Each predicate corresponds to something an assessor checks by eye: that the field exists, that it carries real content, that timestamps are in UTC for AU-8 ordering, that an integrity value is a genuine digest rather than a placeholder, and that the acting principal is not a shared or superuser identity. Resisting the urge to add a general-purpose expression language keeps the YAML declarative and the review of a control change trivial. When a control genuinely needs richer logic — say, that a service.publish event references an output whose hash appeared in an earlier raster.reproject event — implement it as a named cross-event check in Python rather than smuggling procedural logic into the data file, so the mapping remains something a compliance officer can approve without reading code.
Verification
Run the validator against a known-good run and confirm a clean exit, then deliberately corrupt an event to prove the checks bite.
$ python validate_controls.py
FISMA control mapping: all applicable controls evidenced
$ echo $?
0
Now blank out an output_sha256 on one raster.reproject line and rerun:
FISMA control mapping: 1 finding(s)
- line 42 [raster.reproject]: SI-7: 'output_sha256' is not a 64-char SHA-256 hex digest
A non-zero exit code confirms CI would block the merge. For a positive control, remove every service.publish event and you should see the CM-3 and coverage findings fire, proving the covered tally detects a stage that stopped emitting.
Gotchas & edge cases
applies_towildcards hide gaps. A control mapped to["*"]is exercised by any event, so its coverage tally is almost always non-zero even if the pipeline is broken. Reserve"*"for genuinely universal controls (AU-3, AU-8, AC-6) and pin integrity controls to the specific spatial event types that produce artifacts, so a missingraster.reprojectsurfaces as a real gap.- Timezone strings that look UTC but aren’t.
datetime.now().isoformat()withouttimezone.utcyields a naive string with no offset, which passes a naiveendswithcheck only if you are not careful — therequire_utcrule rejects anything not ending in+00:00orZ, so always construct timestamps withdatetime.now(timezone.utc). - Empty
parameterson reprojection. A CM-3 finding onraster.reprojectalmost always means the pipeline emitted the event before capturing the GDAL version and resampling method. Populateparametersat the same call site that runs the transform, not in a later enrichment pass that a failed run may never reach.
Keep the YAML mapping under the same change control as the pipeline code; when you add a control to the baseline in your System Security Plan, add it here in the same pull request so the validator and the assessment stay in lockstep.