Skip to content

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 optional compiled_script)
  • compliance_profile_controls — the controls belonging to a profile. A control whose check_script is NULL means the control's metadata (title, severity, category, official reference) has been imported, but no verification script has been written for it yet — it is reported not_implemented, not silently dropped
  • compliance_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 valueDRIFT_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_assignment uses.
  • RBAC and machine-access gating — the same machine_access checks 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_RESULT vs COMPLIANCE_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.