Run a CIS/STIG compliance scan on a machine¶
Repod scores machines in your fleet inventory against published security benchmarks (CIS, DISA STIG). This guide covers assigning a profile to machines and running a scan. For the underlying model — how profile assignment resolves, why the built-in CIS profile always runs, and how this differs from configuration drift — see Compliance profiles and configuration drift.
1. Prerequisites¶
- The machine is already registered in fleet inventory with
connection_type: ssh. Compliance scans require an SSH connection — agent-mode clients cannot be scanned (POST /clients/{id}/compliance/scanreturns400for them). - Reading profiles/results requires the
auditor,maintainer, oradminrole. Assigning a profile to a tag or client requiresadmin. - Triggering a scan requires any authenticated role with
machine access to that client, and the backend
replica must currently be the HA leader (
POSTreturns503on a passive replica in a multi-replica deployment — seerequire_leaderin the backend architecture notes). - If you plan to use a STIG (or any non-built-in) profile, it must already be imported — see step 2.
The built-in CIS profile always runs
Every scan evaluates the built-in CIS profile (cis-builtin-linux) even if
a machine has no profile assignment at all — there is no "no compliance
coverage" state. Assigning STIG or another imported profile adds to this,
it never replaces the CIS check.
2. Import a profile (optional, STIG only)¶
Compliance benchmark content — check scripts, severities, titles, official
references — is never written by hand or approximated from memory. It is only
ever loaded from a real published benchmark document via
backend/scripts/import_compliance_profile.py. There is no UI path for
authoring check content.
docker compose exec backend-api python3 scripts/import_compliance_profile.py \
data/compliance/stig_rhel9_disa.json \
stig-rhel9-disa stig "DISA STIG — Red Hat Enterprise Linux 9" \
--os-match rhel9 \
--source-ref "RedHatOfficial/ansible-role-rhel9-stig (GitHub)"
The script is idempotent: re-running it against an updated JSON file refreshes
control metadata (title, severity, category, reference) but never
overwrites an already-written check_script — a human-validated check is
never silently replaced by a metadata refresh. Controls with no check_script
in the source file are imported anyway and reported not_implemented when
scanned, rather than being silently dropped.
List the profiles now available:
{
"profiles": [
{ "id": "cis-builtin-linux", "framework": "cis", "name": "CIS Benchmark (built-in)", "os_match": null, "is_builtin": true },
{ "id": "stig-rhel9-disa", "framework": "stig", "name": "DISA STIG — Red Hat Enterprise Linux 9", "os_match": "rhel9", "is_builtin": false }
]
}
3. Assign a profile to machines¶
Assignment is by tag or by client (a specific machine). A client-level assignment is an override: it replaces that machine's tag-derived assignments entirely rather than adding to them. Without an override, assignments from every matching tag apply together. This is the same override-replaces-not-merges convention used throughout Repod's fleet RBAC — see Compliance profiles and configuration drift for the full resolution rules.
Assign to every machine tagged rhel9-prod:
curl -s -X POST http://localhost:8000/api/v1/compliance/profiles/stig-rhel9-disa/assignments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"principal_type": "tag", "principal_id": "rhel9-prod"}' | jq .
Assign to one specific client, overriding its tag-derived assignments:
curl -s -X POST http://localhost:8000/api/v1/compliance/profiles/stig-rhel9-disa/assignments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"principal_type": "client", "principal_id": "3f2b1a90-..."}' | jq .
Response (201):
{
"assignment": {
"id": "a1b2c3d4-...",
"profile_id": "stig-rhel9-disa",
"principal_type": "tag",
"principal_id": "rhel9-prod",
"assigned_by": "admin",
"assigned_at": "2026-08-20T09:00:00Z"
}
}
List current assignments for a profile:
curl -s http://localhost:8000/api/v1/compliance/profiles/stig-rhel9-disa/assignments \
-H "Authorization: Bearer $TOKEN" | jq .
Remove an assignment:
curl -s -X DELETE http://localhost:8000/api/v1/compliance/profiles/assignments/a1b2c3d4-... \
-H "Authorization: Bearer $TOKEN"
4. Trigger a scan¶
curl -s -X POST http://localhost:8000/api/v1/inventory/clients/3f2b1a90-.../compliance/scan \
-H "Authorization: Bearer $TOKEN"
The scan runs in the background (202 Accepted) over a single SSH connection,
regardless of how many profiles apply — the built-in CIS script and any
imported profile's concatenated check scripts all run over that one session.
Force a specific subset of profiles instead of the automatic tag/client
resolution, by passing profile_ids in the request body:
curl -s -X POST http://localhost:8000/api/v1/inventory/clients/3f2b1a90-.../compliance/scan \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"profile_ids": ["cis-builtin-linux", "stig-rhel9-disa"]}'
Scan every machine matching a tag in one call:
curl -s -X POST http://localhost:8000/api/v1/inventory/compliance/scan-by-tag \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"tags": ["rhel9-prod"], "match": "any"}'
This resolves every accessible, SSH-connected client matching the tag(s) and
triggers the same per-client scan for each, best-effort — agent-mode clients
in the matched set are silently skipped (skipped count in the response),
never errored on.
5. Read results¶
Legacy, CIS-only view (unchanged response shape, historical behavior):
curl -s http://localhost:8000/api/v1/inventory/clients/3f2b1a90-.../compliance \
-H "Authorization: Bearer $TOKEN" | jq .
{
"client_id": "3f2b1a90-...",
"results": [ { "control_id": "1.1.1.1", "status": "pass", "detail": "..." } ],
"score": { "passed": 38, "failed": 2, "not_applicable": 1, "percent": 95.0 },
"scanned_at": "2026-08-20T09:03:41Z"
}
Full multi-profile view — every profile that applies to this machine, each with its own score:
curl -s http://localhost:8000/api/v1/inventory/clients/3f2b1a90-.../compliance/profiles \
-H "Authorization: Bearer $TOKEN" | jq .
{
"client_id": "3f2b1a90-...",
"profiles": [
{
"profile_id": "cis-builtin-linux",
"profile_name": "CIS Benchmark (built-in)",
"framework": "cis",
"results": [ "..." ],
"score": { "passed": 38, "failed": 2, "not_applicable": 1, "percent": 95.0 },
"scanned_at": "2026-08-20T09:03:41Z"
},
{
"profile_id": "stig-rhel9-disa",
"profile_name": "DISA STIG — Red Hat Enterprise Linux 9",
"framework": "stig",
"results": [ "..." ],
"score": { "passed": 120, "failed": 5, "not_applicable": 287, "percent": 96.0 },
"scanned_at": "2026-08-20T09:03:41Z"
}
]
}
A large fraction of an imported profile's controls showing not_applicable
is expected: any control with no check_script written yet is reported that
way rather than executed. It is not a scan failure.
Verify it worked¶
POST .../compliance/scanreturned202with the expectedclient_id.GET .../compliance/profilesshows a freshscanned_attimestamp for every profile you expect to have run (built-in CIS plus any assigned profile whoseos_matchmatches this machine's distro — a profile whoseos_matchdoesn't match is reportednot_applicableand never executed, also expected, not an error).- Backend logs show the scan completing:
- For an imported profile, spot-check that a control you know has a real
check_scriptreportspass/fail, notnot_implemented— a whole profile stuck onnot_implementedusually means the JSON source had nocheck_scriptvalues for it yet, not a scan bug.