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:
- What drift detection is (and isn't)
- Writing a baseline in YAML
- Creating a baseline via the API
- Assigning a baseline to machines (tags or a specific client)
- Running a scan and reading results
- 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.
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
driftresult 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.