Skip to content

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/scan returns 400 for them).
  • Reading profiles/results requires the auditor, maintainer, or admin role. Assigning a profile to a tag or client requires admin.
  • Triggering a scan requires any authenticated role with machine access to that client, and the backend replica must currently be the HA leader (POST returns 503 on a passive replica in a multi-replica deployment — see require_leader in 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.

Profil 'stig-rhel9-disa' : 412 créé(s), 0 mis à jour, 412 au total.

List the profiles now available:

curl -s http://localhost:8000/api/v1/compliance/profiles \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "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"
{ "message": "Scan de conformité lancé en arrière-plan", "client_id": "3f2b1a90-..." }

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

  1. POST .../compliance/scan returned 202 with the expected client_id.
  2. GET .../compliance/profiles shows a fresh scanned_at timestamp for every profile you expect to have run (built-in CIS plus any assigned profile whose os_match matches this machine's distro — a profile whose os_match doesn't match is reported not_applicable and never executed, also expected, not an error).
  3. Backend logs show the scan completing:
    docker compose logs backend-api | grep '\[compliance\] Scan terminé'
    
  4. For an imported profile, spot-check that a control you know has a real check_script reports pass/fail, not not_implemented — a whole profile stuck on not_implemented usually means the JSON source had no check_script values for it yet, not a scan bug.