Compliance profiles and configuration drift¶
Repod scans the machines in your fleet inventory in two distinct ways that are easy to confuse but answer different questions:
- Compliance profiles (CIS, DISA STIG) ask "does this machine follow a published security benchmark?" — the answer to each control is pass/fail against a fixed, external reference.
- Configuration drift asks "does this machine still match the state I declared?" — the answer is a diff between an expected value and the value actually observed, against a baseline you author yourself.
Both run over SSH against machines already registered in fleet inventory, and both are detection-only — neither ever changes anything on a scanned machine. This page explains what each one is and, more importantly, why they are two separate systems rather than one. For the step-by-step mechanics of writing and assigning a drift baseline, see Configuration drift baselines.
Part 1 — Compliance profiles (CIS / STIG)¶
What a profile is¶
A compliance profile is a named set of controls belonging to a
framework (cis or stig) and scoped to an operating system via
os_match. Three tables drive it:
compliance_profiles— one row per profile (id, framework, name,os_match, whether it's built-in, and an optionalcompiled_script)compliance_profile_controls— the controls belonging to a profile. A control whosecheck_scriptisNULLmeans the control's metadata (title, severity, category, official reference) has been imported, but no verification script has been written for it yet — it is reportednot_implemented, not silently droppedcompliance_profile_assignment— which machines get which profile, attributed by tag or by client
A machine's applicable profiles are resolved from this assignment table: a
client-level assignment replaces any tag-derived assignments for that
machine (it does not add to them); with no client override, the profiles
from every matching tag apply together, deduplicated. A machine with no
assignment at all — no tag match, no client override — still gets a
profile: the built-in CIS profile (cis-builtin-linux) applies by default.
A compliance scan therefore always evaluates at least CIS; this is
deliberately different from drift baselines, covered in Part 2.
Within the resolved set of profiles, each one is additionally filtered by
os_match against the target machine's detected distribution. A profile
that doesn't match the machine's OS is never executed — it's reported
not_applicable in the scan output, so it stays visible (you can see it
was considered) without risking a scan that runs Debian-authored checks
against a RHEL host.
Two execution modes¶
A profile runs in one of two modes, decided per profile, not per scan:
compiled_script — used only by the built-in CIS profile. This is the
original, monolithic 41-control CIS bash script, sent to the machine and
parsed exactly as it was before profiles existed. Keeping it as a single
opaque script — rather than splitting it into per-control scripts like
every profile imported afterward — guarantees the built-in CIS scan's
output is byte-identical to its pre-profile behavior. Nothing about
introducing STIG support was allowed to change what an existing CIS
deployment already reports.
Per-control check_script — used by every imported profile (STIG
today). Every control in the profile whose check_script is not NULL is
concatenated into a single script, wrapped in a common harness that emits
one COMPLIANCE_RESULT|CR|{id}|CR|{status}|CR|{detail} line per control,
and sent to the machine in one SSH round trip. Controls with a NULL
check_script never touch the network at all — they're reported
not_implemented directly in Python. This is what keeps a large imported
profile affordable: a freshly imported STIG profile can carry hundreds of
controls with no check script written yet, and none of them cost anything
to "run" until a real script is authored for them.
One SSH connection per scan¶
run_compliance_scan() opens exactly one SSH connection per machine,
regardless of how many profiles apply to it. Each applicable profile adds
at most one script execution over that same connection — never a second
connection. A machine assigned both the built-in CIS profile and an
imported STIG profile is scanned in one SSH session covering both.
flowchart TD
A["Scan triggered\n(manual or daily cron)"] --> B["Resolve assignment\nclient override > tag union > CIS fallback"]
B --> C{"For each\nresolved profile"}
C -->|"os_match fails"| D["not_applicable\n(not executed)"]
C -->|"os_match matches"| E{"Execution mode?"}
E -->|"compiled_script\n(built-in CIS)"| F["Legacy monolithic script\nover the shared SSH session"]
E -->|"per-control check_script\n(imported, e.g. STIG)"| G["Concatenated harness script\nover the shared SSH session"]
G --> H["Controls with NULL check_script:\nreported not_implemented,\nno network call"]
F --> I["Parsed results, tagged\nwith profile_id + framework"]
G --> I
H --> I
D --> J["Scan summary:\nresults[] + profiles[] (executed | not_applicable)"]
I --> J
Content provenance: never hand-authored¶
Compliance benchmark content — the check scripts, severities, titles, and
official references — is only ever loaded via
backend/scripts/import_compliance_profile.py, which imports a real
published benchmark document. Nobody writes or approximates CIS/STIG check
content from memory, and re-importing a profile to refresh its metadata
(title, severity, category) never overwrites an already-validated
check_script — a human-reviewed check is never silently replaced by a
metadata sync. This is what lets a scan report trace back to a real,
citable source rather than an invented interpretation of a benchmark.
Part 2 — Configuration drift¶
What "drift" means¶
Configuration drift detection compares a machine's actual, currently observed configuration against a desired state you declare yourself, in a baseline — modeled on the same idea as Ansible or Puppet, but strictly read-only: Repod only reports a difference, it never applies one.
A baseline is made of rules of four types:
| Rule type | Compared against |
|---|---|
package |
Installed package name/version |
service |
systemctl is-active / is-enabled state |
file |
SHA-256 hash of a file's contents |
permission |
File mode, owner, and/or group |
Each drift scan result carries a status (match, drift, error, or
not_applicable), together with the rule's expected_value and the
actual_value observed on the machine. See
Configuration drift baselines for the YAML
syntax and the full API walkthrough for writing, assigning, and scanning
baselines.
Why this is a separate system from compliance¶
This is the most important thing to understand about drift: it is a deliberate architectural fork away from the compliance engine, not an oversight that two similar-sounding features exist separately.
The compliance engine's check_script model produces an opaque
pass/fail: the shell script itself decides pass/fail/warn/skip,
and hands back a status plus a free-text detail string. The backend never
sees the value that was actually measured — only the script's own verdict.
That is entirely sufficient for "is this machine compliant with control
X?", but it cannot answer "what changed, and to what?" — there's no
structured old-value/new-value pair to diff, and no way to track a
specific value over time.
Drift needs exactly that structured value. So its wire format is
different by design: the SSH script for a drift scan only ever emits the
raw observed value — DRIFT_RESULT|DR|{rule_key}|DR|{observed} — and
never decides pass or fail itself. The comparison against the declared
expected value happens entirely in Python, on the backend, from the rule's
own definition. That's what makes a structured expected-vs-actual diff and
a queryable drift history possible, which an opaque check-script pass/fail
fundamentally cannot provide.
What's reused, and what's genuinely new¶
Given how closely related the two systems are, drift deliberately reuses several exact patterns from the compliance engine rather than reinventing them:
- One SSH connection per machine, regardless of rule count — the same
pattern
run_compliance_scan()uses; all SSH-eligible rules are concatenated into a single script and sent over one connection. - Tag/client assignment resolution — a client-level override on a
baseline replaces its tag-derived baselines entirely, exactly the
same convention
compliance_profile_assignmentuses. - RBAC and machine-access gating — the same
machine_accesschecks that gate every other client-scoped endpoint in inventory.
What's genuinely new to drift, not carried over from compliance:
- The raw-value wire format described above (
DRIFT_RESULTvsCOMPLIANCE_RESULT) — a structural requirement, not a stylistic choice. - No mandatory fallback baseline. Unlike compliance, which always falls
back to the built-in CIS profile when nothing is assigned, drift's
resolve_baselines_for_client()can legitimately return an empty list. A baseline is your own declared desired state, not a universal security policy the way CIS is — a machine with no tag match and no client override simply has no drift rules to check. That's a normal, expected outcome, not an error and not a gap to fill silently.
Package rules work without SSH; service/file/permission rules don't¶
Package rules are compared directly against the package list Repod already has on file for that machine — from a prior SSH scan or an agent check-in — with no additional network round trip. This is also the only drift rule type that works for agent-mode clients: since it needs no live command execution, just a read of already-collected data, it works identically whether the client reports over SSH or over the push-only agent protocol.
Service, file, and permission rules are SSH-only, by hard architectural
constraint, not by omission. Agent-mode clients are strictly push-only —
they call POST /inventory/agent/checkin and nothing calls back to them.
There is no channel through which the backend could ask an agent client to
run an arbitrary command, and none is planned; accepting a command from an
agent would break the push-only security guarantee the agent protocol is
built on. A baseline that mixes package rules with service/file/permission
rules still works for an agent-mode client — the package rules evaluate
normally, and the non-package rules simply report not_applicable for
that client, with an explicit detail message, never silently skipped
without a trace.
Drift results are append-only¶
Unlike compliance scans (which delete and re-insert results on every
scan), drift_results rows are never deleted or overwritten by a new
scan — every scan adds a new batch of rows sharing one scanned_at
timestamp. This is deliberate: drift is meant to be trackable over time
(has this machine been drifting on the same rule for the last two weeks,
or did it just start?), which a replace-per-scan table cannot answer. A
baseline that's later edited or deleted doesn't corrupt or hide historical
results — each result row keeps its own denormalized rule type, target,
and severity at the time it was recorded.
No auto-remediation¶
Detection and alerting only. A drift result never triggers any change on
the scanned machine — there is no "fix it for me" action anywhere in this
feature. This is an explicit, deliberate design choice for a first
rollout: automatically "fixing" a machine you've only just started
observing carries real risk of unintended damage, and nothing in this
system writes to a scanned machine under any circumstance.
Where each fits in the bigger picture¶
| Compliance (CIS/STIG) | Configuration drift | CVE scanning | |
|---|---|---|---|
| Question answered | Does this machine follow a published security benchmark? | Does this machine still match the state I declared? | Does this machine have known vulnerabilities? |
| Reference | A published CIS/STIG profile | A baseline you author | The CVE database (NVD/Grype) |
| Result shape | Pass/fail per control | Expected value vs. actual value per rule | CVE list with severity/EPSS/KEV |
| Default when unconfigured | Built-in CIS profile always runs | No baseline, no rules — normal, not an error | N/A — runs on every upload/import |
| History | Latest scan replaces the previous one | Append-only — full history retained | Snapshot replaced on every inventory scan |
See Compliance & Security for how these map to NIS2/SecNumCloud/ISO 27001 requirements, and Configuration drift baselines for the practical steps to write and assign a baseline.