FISMA Control Mapping for GIS Pipelines

Part of: FISMA Compliance for Spatial Systems

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 PyYAML 6.x installed (pip install pyyaml).
  • A pipeline that already emits structured lineage events as JSON lines — the LineageAuditEvent shape from the overview page (fields: event_type, occurred_at, component, actor, outcome, output_sha256, parameters).
  • Read access to the run’s audit.jsonl output and write access to a repo path for the mapping file.
  • Agreement on your auditable-events list, so the mapping’s applies_to event types are stable.
One binding file, three consumers A YAML binding of controls to lineage fields drives the SSP narrative, the CI validation rules and the coverage report, keeping all three consistent. controls_to_hooks.yaml versioned beside the pipeline code SSP control narrative generated, not transcribed cannot drift from the code CI validation required fields enforced per event Coverage dashboard mapped · enforced · tested three distinct numbers Generating the narrative is what stops the SSP describing a system that stopped existing two releases ago.

Generating the control narrative from the binding is the highest-leverage part of this, and the least commonly done. System security plans drift because they are prose maintained on a different cadence from the code they describe — a field gets renamed, a hook moves, and the narrative keeps claiming the old arrangement until someone reads it carefully during an assessment. When the narrative paragraph for AU-3 is rendered from the binding that CI also enforces, the two cannot disagree, and updating the plan becomes a side effect of updating the pipeline.

Reporting three separate coverage numbers rather than one is the other discipline worth adopting. Mapped counts controls with a binding; enforced counts those whose required fields are validated at emission; tested counts those with a dated negative test. Collapsing them into a single percentage hides exactly the gap that matters, since a control can be mapped and unenforced indefinitely without anything looking wrong.

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.

Which Control Families Actually Reach a GIS Pipeline

Control families by where they are actually implemented Three tiers: families the pipeline implements directly, families shared with platform teams, and families that are organisational and inherited. YOUR PIPELINE IMPLEMENTS THESE AU — audit and accountability every lineage event is an AU-2 auditable event SI — system and information integrity digests and validation gates are SI-7 evidence SHARED WITH PLATFORM — say who owns which half AC — access control platform authenticates; you record the actor CM — configuration management platform pins images; you record which one ran ORGANISATIONAL — do not map lineage fields to these PE physical · PS personnel · AT training · PL planning — cite the programme, not a column Over-mapping is a real cost: every spurious binding is a row somebody must maintain and defend.

The bottom tier matters because mapping registries have a tendency to grow toward completeness for its own sake. A binding from a personnel-security control to a lineage field is not evidence of anything — the field cannot become false when the control fails — and every such row is future maintenance plus a claim an assessor may probe. Map only where a pipeline artefact genuinely demonstrates the control, and cite the organisational programme for the rest.

The shared tier is where the useful precision lives. Access control is not implemented by your pipeline, but your pipeline is where the record of who acted is produced, and an AC narrative that cannot point to per-activity actor attribution is weaker for it. State the split explicitly in the mapping: platform enforces, pipeline evidences. That sentence resolves most of the ambiguity assessors probe on shared controls.

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.

Keeping the Binding File Honest

Three states a control binding can be in Enforced and tested, declared but untested, and orphaned — with the check that distinguishes each. ENFORCED — the field exists, is populated, and a negative test rejects the only state that constitutes evidence DECLARED — the field exists and nothing has ever failed the check indistinguishable from a check that cannot fail — run a negative test to find out ORPHANED — the binding names a field the schema no longer has caught by validating the binding file against the live schema in CI, not by reading it

The orphaned state is the one that accumulates silently, because a YAML binding file has no compiler. Rename a lineage column and the binding keeps pointing at the old name, the coverage report keeps counting the control as mapped, and nothing complains until an assessor asks to see the field. Validate the binding file against the actual schema in CI — every referenced field must exist — and orphaning becomes a failed build on the day the rename lands.

Distinguishing enforced from merely declared needs the negative test described elsewhere on this site: strip the field from a sample event and confirm the validation reports non-compliance. Record the date that test last ran alongside the binding, so the coverage report can show not just which controls are mapped but which mappings have been demonstrated to work. A mapping registry that reports one hundred percent coverage and has never rejected anything is reporting on itself, not on the pipeline.

Gotchas & edge cases

  • applies_to wildcards 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 missing raster.reproject surfaces as a real gap.
  • Timezone strings that look UTC but aren’t. datetime.now().isoformat() without timezone.utc yields a naive string with no offset, which passes a naive endswith check only if you are not careful — the require_utc rule rejects anything not ending in +00:00 or Z, so always construct timestamps with datetime.now(timezone.utc).
  • Empty parameters on reprojection. A CM-3 finding on raster.reproject almost always means the pipeline emitted the event before capturing the GDAL version and resampling method. Populate parameters at 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.