Skip to content

Configuration drift detection: defining and using baselines

Repod can detect when a machine's actual configuration diverges from a declared reference state ("baseline") — packages that should be a specific version, services that should be running, files that should match a known hash, permissions that should be set a certain way. This guide covers:

  1. What drift detection is (and isn't)
  2. Writing a baseline in YAML
  3. Creating a baseline via the API
  4. Assigning a baseline to machines (tags or a specific client)
  5. Running a scan and reading results
  6. Known limitations in this version

1. What drift detection is (and isn't)

Drift detection CVE scanning CIS/STIG compliance
Question answered "Does this machine still match the state I declared?" "Does this machine have known vulnerabilities?" "Does this machine follow a published security benchmark?"
Reference A baseline you author The CVE database (NVD/Grype) A published CIS/STIG profile
Scope Packages, services, files, permissions Installed package versions Whatever the benchmark defines

Drift detection is detection and alerting only — it never modifies the machine. There is no auto-remediation in this version; if a rule reports drift, fixing it is a manual (or separately-scripted) action.

Package rules compare against the packages Repod already has on file for that machine (SSH scan or agent check-in — no extra network call). Service, file, and permission rules require an SSH connection at scan time and are not available for agent-mode clients — the agent is push-only by design (it never accepts a command from the backend), so there is no channel to run an arbitrary check on it. A baseline mixing package and service/file/ permission rules still works fine for an agent-mode client; the service/file/permission rules will simply report not_applicable for it.


2. Writing a baseline in YAML

name: webservers
description: Reference configuration for production web servers

rules:
  - id: nginx-version
    type: package
    target: nginx
    expected: "1.18.*"     # exact string, a glob ("1.18.*"), "present", or "absent"
    severity: high

  - id: telnet-absent
    type: package
    target: telnet-server
    expected: absent
    severity: critical

  - id: sshd-running
    type: service
    target: sshd
    expected_state: active   # active | inactive | enabled | disabled
    severity: critical

  - id: sshd-config-hash
    type: file
    path: /etc/ssh/sshd_config
    expected_sha256: "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde"
    severity: high

  - id: shadow-permissions
    type: permission
    path: /etc/shadow
    expected_mode: "640"
    expected_owner: root
    expected_group: shadow
    severity: high

Field reference per rule type:

Type Target field Comparison fields Notes
package target (package name) expected: exact version, glob (1.18.*), present, or absent Compared against already-collected package data — no SSH/agent round trip
service target (systemd unit name) expected_state: active|inactive|enabled|disabled active/inactive checks systemctl is-active; enabled/disabled checks systemctl is-enabled — independent checks, pick the one relevant to your rule
file path expected_sha256 (hex digest), and/or expected: absent Omitting expected_sha256 with no expected: absent just checks the file exists
permission path Any of expected_mode (octal string, e.g. "640"), expected_owner, expected_group Only the fields you specify are checked — a rule with only expected_mode doesn't care who owns the file

Every rule needs a unique id within the baseline (used to identify it in scan results and to allow re-running import idempotently isn't supported — importing twice creates two baselines; delete or edit the existing one instead) and a severity: critical, high, medium, or low.

How to get a file's SHA-256 hash

sha256sum /etc/ssh/sshd_config

3. Creating a baseline via the API

Import the YAML document directly (requires maintainer or admin):

curl -X POST http://YOUR_HOST:8000/api/v1/drift/baselines/import \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(python3 -c 'import json,sys; print(json.dumps({"yaml": open(sys.argv[1]).read()}))' webservers.yaml)"

Or build it rule by rule through the API:

curl -X POST http://YOUR_HOST:8000/api/v1/drift/baselines \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "webservers", "description": "Reference configuration"}'
# → {"baseline": {"id": "...", ...}}

curl -X POST http://YOUR_HOST:8000/api/v1/drift/baselines/BASELINE_ID/rules \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"rule_key": "nginx-version", "type": "package", "target": "nginx",
       "params": {"expected": "1.18.*"}, "severity": "high"}'

Export an existing baseline back to YAML at any time:

curl http://YOUR_HOST:8000/api/v1/drift/baselines/BASELINE_ID/export \
  -H "Authorization: Bearer YOUR_TOKEN"

4. Assigning a baseline to machines

A baseline only takes effect once assigned — either to a machine tag (applies to every machine carrying that tag) or to a specific client (an override that replaces any tag-derived baselines for that one machine, it does not add to them). This requires admin:

# Apply to every machine tagged "web"
curl -X POST http://YOUR_HOST:8000/api/v1/drift/baselines/BASELINE_ID/assignment \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"principal_type": "tag", "principal_id": "web"}'

# Or override for one specific machine
curl -X POST http://YOUR_HOST:8000/api/v1/drift/baselines/BASELINE_ID/assignment \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"principal_type": "client", "principal_id": "CLIENT_ID"}'

A machine with no tag match and no client override simply has no baseline — that's a normal, expected state, not an error. Unlike CIS compliance (which always falls back to a built-in profile), there is no default baseline: a baseline is your own declared state, not a universal security policy, so nothing is silently assumed.

If a machine matches multiple tags, all their baselines apply together (the union of their rules).


5. Running a scan and reading results

# Trigger a scan (runs in the background)
curl -X POST http://YOUR_HOST:8000/api/v1/drift/clients/CLIENT_ID/scan \
  -H "Authorization: Bearer YOUR_TOKEN"

# Read the latest results
curl http://YOUR_HOST:8000/api/v1/drift/clients/CLIENT_ID/results \
  -H "Authorization: Bearer YOUR_TOKEN"

Each result carries status (match, drift, error, or not_applicable), expected_value, actual_value, and severity. A drift status means the rule's target diverged from what the baseline declares — that's the signal to act on.

Scans are also append-only history — every scan adds a new batch of results rather than overwriting the previous one, so you can track drift over time:

curl http://YOUR_HOST:8000/api/v1/drift/clients/CLIENT_ID/history \
  -H "Authorization: Bearer YOUR_TOKEN"

A scheduled scan also runs daily (06:30 UTC by default, configurable under settings.json["drift"]) against every enabled client with at least one resolved baseline.


6. Known limitations in this version

  • Detection only, no auto-remediation. A drift result never triggers any change on the machine — that's a deliberate choice to avoid the risk of an automated "fix" causing unintended damage on a first rollout of this feature.
  • Service/file/permission rules require SSH. Agent-mode clients only get package-rule coverage; extending the agent's push payload to support more fact types is a possible future addition, not implemented today.
  • File rules compare a SHA-256 hash, not full content diffs. Enough to detect that a file changed, not what changed inside it.
  • No Windows support. Repod's remote execution is SSH/Linux only — there's no registry-key rule type and none is planned without a Windows transport being added first.