What Auditors Actually Want (And Why Shell Is Surprisingly Good At It)
Auditors want three things, in order:
- Reproducibility — “Show me the same check producing the same result on the same host today and 90 days ago.”
- Tamper-evidence — “Prove the report wasn’t edited after generation.”
- Coverage — “Show me a control list with each control mapped to a specific check that ran.”
A shell script that runs CIS/STIG checks, dumps the results to a structured JSON file, signs that file with GPG, and stores the signature alongside it satisfies all three. This is what enterprise compliance tools do under the hood — but for many controls, a 200-line shell script is more transparent and easier to audit than a SaaS UI.
The four discipline patterns:
| Pattern | What it does | Why auditors care |
|---|---|---|
| Controls-as-tests | Each CIS/STIG control is a shell function with pass/fail/skip output | Maps directly to control catalog |
| Evidence bundle | JSON record per control: id, status, evidence, timestamp, host | Reproducible, machine-readable |
| Signed reports | GPG signature over the bundle | Tamper-evident |
| Drift detection | Diff today’s bundle vs. last week’s | “What changed since last audit?” |
This lesson teaches each pattern with shell scripts and a lib/compliance.sh you can source.
The Controls-As-Tests Model
A CIS control like “1.1.1.1: Ensure mounting of cramfs filesystems is disabled” maps to a shell function:
# Each control is a function returning 0=PASS, 1=FAIL, 2=SKIP/NA
control_1_1_1_1() {
local title="Ensure mounting of cramfs filesystems is disabled"
local description="The cramfs filesystem type is a compressed read-only Linux filesystem..."
# Check 1: cramfs not loadable
if modprobe -n -v cramfs 2>&1 | grep -q "install /bin/true"; then
: # PASS
else
compliance_record "1.1.1.1" "$title" "FAIL" "modprobe cramfs is not blacklisted"
return 1
fi
# Check 2: cramfs not currently loaded
if lsmod | grep -q "^cramfs"; then
compliance_record "1.1.1.1" "$title" "FAIL" "cramfs module currently loaded"
return 1
fi
compliance_record "1.1.1.1" "$title" "PASS" "cramfs blacklisted and not loaded"
return 0
}
The structure is rigid by design:
- One function per control — easy to audit, easy to disable individually.
- Three outcomes: PASS, FAIL, SKIP (where SKIP means “not applicable” — e.g., “USB controls don’t apply to this VM”).
- Evidence is always recorded — both for PASS and FAIL. Auditors need positive evidence, not just absence of failure.
- Title and description in the function body — auditor reading the script gets full context inline.
The PASS / FAIL / SKIP Trichotomy
Tools that only have PASS/FAIL force you to mark inapplicable checks as PASS, which lies to the auditor. SKIP is the third state:
control_2_1_1_pcsc() {
# Skip if PC/SC daemon is not installed (not applicable to this OS)
if ! command -v pcscd >/dev/null; then
compliance_record "2.1.1" "PC/SC daemon" "SKIP" "pcscd not installed"
return 2
fi
# ...real check
}
Skips are first-class evidence: the auditor sees “5 of 200 checks were SKIP because pcscd is not installed on this host” and accepts it.
Pillar 1: The Assertion Library
Most controls are variations of a few patterns:
- “File X has mode/owner/group Y.”
- “Sysctl X has value Y.”
- “Service X is enabled / disabled / masked.”
- “Package X is installed / not installed.”
- “Mount Y has option Z.”
Encoding these as helper functions makes the controls themselves trivially short:
assert_file_mode() {
local path="$1" expected="$2"
[[ -e "$path" ]] || return 2 # missing → SKIP
local actual
actual=$(stat -c '%a' "$path")
if [[ "$actual" == "$expected" ]]; then
return 0
else
printf 'expected=%s actual=%s\n' "$expected" "$actual"
return 1
fi
}
assert_file_owner() {
local path="$1" expected="$2"
[[ -e "$path" ]] || return 2
local actual
actual=$(stat -c '%U' "$path")
[[ "$actual" == "$expected" ]] || { echo "owner=$actual expected=$expected"; return 1; }
return 0
}
assert_sysctl() {
local key="$1" expected="$2"
local actual
actual=$(sysctl -n "$key" 2>/dev/null) || return 2
[[ "$actual" == "$expected" ]] || { echo "sysctl $key=$actual expected=$expected"; return 1; }
return 0
}
assert_systemctl_enabled() {
local unit="$1"
systemctl is-enabled --quiet "$unit"
}
assert_systemctl_disabled() {
local unit="$1"
! systemctl is-enabled --quiet "$unit" 2>/dev/null
}
assert_package_installed() {
local pkg="$1"
if command -v dpkg >/dev/null; then
dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"
elif command -v rpm >/dev/null; then
rpm -q "$pkg" >/dev/null
else
return 2
fi
}
assert_package_not_installed() {
! assert_package_installed "$1"
}
assert_mount_option() {
local mount_point="$1" option="$2"
findmnt --noheadings --output=OPTIONS "$mount_point" 2>/dev/null \
| tr ',' '\n' | grep -qx "$option"
}
assert_grep_in_file() {
local pattern="$1" file="$2"
[[ -f "$file" ]] || return 2
grep -q "$pattern" "$file"
}
With these in place, controls become one-liners:
control_3_1_1_ip_forward() {
if assert_sysctl "net.ipv4.ip_forward" "0"; then
compliance_record "3.1.1" "Disable IP forwarding" "PASS" "ip_forward=0"
else
compliance_record "3.1.1" "Disable IP forwarding" "FAIL" "ip_forward not 0"
fi
}
control_5_1_2_cron_perms() {
if assert_file_mode "/etc/crontab" "600" && assert_file_owner "/etc/crontab" "root"; then
compliance_record "5.1.2" "/etc/crontab perms" "PASS" "mode=600 owner=root"
else
compliance_record "5.1.2" "/etc/crontab perms" "FAIL" "perms incorrect"
fi
}
The whole CIS Level 1 benchmark for Ubuntu 22.04 (~200 controls) fits in ~2000 lines of shell when expressed this way. Compare to OpenSCAP’s XCCDF/OVAL XML which is 50,000+ lines for the same coverage — vastly less readable, vastly harder to audit.
Pillar 2: The Evidence Bundle (Structured JSON Output)
Each control records a structured record. The bundle format is JSON Lines (JSONL): one record per control:
compliance_record() {
local id="$1" title="$2" status="$3" evidence="$4"
jq -nc \
--arg ts "$(date -Iseconds)" \
--arg host "$(hostname)" \
--arg id "$id" \
--arg title "$title" \
--arg status "$status" \
--arg evidence "$evidence" \
--arg framework "$COMPLIANCE_FRAMEWORK" \
--arg version "$COMPLIANCE_VERSION" \
'{ts:$ts, host:$host, framework:$framework, version:$version, control_id:$id, title:$title, status:$status, evidence:$evidence}' \
>> "$COMPLIANCE_BUNDLE"
}
Sample bundle line:
{"ts":"2026-06-22T14:00:00Z","host":"web-01","framework":"CIS-Ubuntu-2204","version":"v1.0.0","control_id":"3.1.1","title":"Disable IP forwarding","status":"PASS","evidence":"ip_forward=0"}
JSONL is the right format because:
- Each line is independently parseable (corruption of one line doesn’t kill the whole file).
- Streamable (large bundles can be processed line-by-line).
- Indexable by Loki / OpenSearch / CloudWatch / Splunk.
- Trivially diffable (next pillar).
Pillar 3: GPG-Signed Reports
The bundle is generated; now sign it so an auditor (or your future self) can prove it wasn’t edited:
compliance_sign_bundle() {
local bundle="$1"
local sig="${bundle}.sig"
# Detached signature (preserves the bundle as-is)
gpg --batch --yes --output "$sig" \
--detach-sign --armor \
--local-user "compliance@example.com" \
"$bundle"
# Also store the signing metadata
cat > "${bundle}.meta" <<EOF
{
"bundle": "$(basename "$bundle")",
"sha256": "$(sha256sum "$bundle" | cut -d' ' -f1)",
"signature": "$(basename "$sig")",
"signer_keyid": "$(gpg --list-secret-keys --with-colons compliance@example.com | awk -F: '/^sec/ {print $5; exit}')",
"signed_at": "$(date -Iseconds)",
"host": "$(hostname)"
}
EOF
}
compliance_verify_bundle() {
local bundle="$1"
local sig="${bundle}.sig"
gpg --batch --verify "$sig" "$bundle" 2>&1 \
&& echo "OK: $bundle signature verified" \
|| { echo "FAIL: signature mismatch"; return 1; }
}
The detached signature (.sig) is separate from the bundle (.jsonl). Auditors verify by:
- Have the public key (published, fingerprinted in your security policy).
gpg --verify bundle.jsonl.sig bundle.jsonl→ must show “Good signature from compliance@example.com.”- The signature’s timestamp is part of the GPG-signed payload — proves when it was signed.
Why Detached Over Inline
Inline GPG signatures (gpg --clearsign) modify the file by wrapping it in BEGIN PGP MESSAGE/END markers. Detached keeps the original bundle unchanged, which is critical for downstream tools that don’t grok PGP.
Hardware-Backed Signing With YubiKey
For higher assurance, the signing key lives on a hardware token:
gpg --card-status # confirm YubiKey is detected
gpg --batch --yes --output "$sig" \
--detach-sign --armor \
--local-user "compliance-yubi@example.com" \
"$bundle"
The YubiKey doesn’t release the private key; it computes the signature on-card. Even compromise of the compliance host can’t extract the key.
Pillar 4: Drift Detection — What Changed Since Last Run
Auditors love this question: “Show me what changed in your compliance posture since last quarter.”
A shell-only diff between two bundles is trivial because of the JSONL format:
compliance_drift() {
local prev="$1" current="$2"
# Sort by control_id for deterministic compare
jq -c 'select(.status != "SKIP") | {id: .control_id, status, evidence}' "$prev" \
| sort > /tmp/prev.sorted
jq -c 'select(.status != "SKIP") | {id: .control_id, status, evidence}' "$current" \
| sort > /tmp/current.sorted
# Show only differences
diff -u /tmp/prev.sorted /tmp/current.sorted
}
Run it weekly and feed the output into a “compliance drift” dashboard. The control IDs that flipped from PASS to FAIL get prioritized; the ones that flipped from FAIL to PASS celebrate progress.
Drift Alerting
Wire drift into Prometheus:
# Count of PASS→FAIL drifts in last 7 days
fail_drift=$(diff -u /var/compliance/last-week.jsonl /var/compliance/today.jsonl \
| grep '^+.*"FAIL"' | grep -v '^+++' | wc -l)
pass_drift=$(diff -u /var/compliance/last-week.jsonl /var/compliance/today.jsonl \
| grep '^+.*"PASS"' | grep -v '^+++' | wc -l)
cat > /var/lib/node_exporter/textfile_collector/compliance.prom.tmp <<EOF
# HELP compliance_drift_to_fail Controls newly failing this week
# TYPE compliance_drift_to_fail gauge
compliance_drift_to_fail{framework="CIS-Ubuntu-2204"} $fail_drift
# HELP compliance_drift_to_pass Controls newly passing this week
# TYPE compliance_drift_to_pass gauge
compliance_drift_to_pass{framework="CIS-Ubuntu-2204"} $pass_drift
EOF
mv /var/lib/node_exporter/textfile_collector/compliance.prom{.tmp,}
Alert on compliance_drift_to_fail > 0 — any new failure deserves investigation, even if total compliance percentage is unchanged.
The Drop-In lib/compliance.sh
# lib/compliance.sh — sourced helpers for compliance scan scripts.
#
# Required env (set by the calling script):
# COMPLIANCE_FRAMEWORK — e.g., "CIS-Ubuntu-2204"
# COMPLIANCE_VERSION — e.g., "v1.0.0"
#
# Optional env:
# COMPLIANCE_DIR — default /var/compliance
# COMPLIANCE_KEYID — GPG key for signing
set -o errexit -o nounset -o pipefail
: "${COMPLIANCE_FRAMEWORK:?COMPLIANCE_FRAMEWORK must be set}"
: "${COMPLIANCE_VERSION:?COMPLIANCE_VERSION must be set}"
: "${COMPLIANCE_DIR:=/var/compliance}"
: "${COMPLIANCE_KEYID:=compliance@example.com}"
readonly COMPLIANCE_STAMP=$(date +%Y-%m-%dT%H%M%S)
readonly COMPLIANCE_BUNDLE="$COMPLIANCE_DIR/$(hostname)-$COMPLIANCE_FRAMEWORK-$COMPLIANCE_STAMP.jsonl"
mkdir -p "$COMPLIANCE_DIR"
compliance_log() {
printf '[%s] [compliance] %s\n' "$(date -Iseconds)" "$*"
}
compliance_record() {
local id="$1" title="$2" status="$3" evidence="$4"
jq -nc \
--arg ts "$(date -Iseconds)" \
--arg host "$(hostname)" \
--arg id "$id" \
--arg title "$title" \
--arg status "$status" \
--arg evidence "$evidence" \
--arg framework "$COMPLIANCE_FRAMEWORK" \
--arg version "$COMPLIANCE_VERSION" \
'{ts:$ts, host:$host, framework:$framework, version:$version, control_id:$id, title:$title, status:$status, evidence:$evidence}' \
>> "$COMPLIANCE_BUNDLE"
}
# Assertion helpers — return 0=PASS, 1=FAIL, 2=SKIP/NA
assert_file_mode() {
local path="$1" expected="$2"
[[ -e "$path" ]] || return 2
local actual
actual=$(stat -c '%a' "$path")
[[ "$actual" == "$expected" ]] || { printf 'mode=%s expected=%s\n' "$actual" "$expected"; return 1; }
}
assert_file_owner() {
local path="$1" expected="$2"
[[ -e "$path" ]] || return 2
local actual; actual=$(stat -c '%U' "$path")
[[ "$actual" == "$expected" ]] || { printf 'owner=%s expected=%s\n' "$actual" "$expected"; return 1; }
}
assert_file_group() {
local path="$1" expected="$2"
[[ -e "$path" ]] || return 2
local actual; actual=$(stat -c '%G' "$path")
[[ "$actual" == "$expected" ]] || { printf 'group=%s expected=%s\n' "$actual" "$expected"; return 1; }
}
assert_sysctl() {
local key="$1" expected="$2"
local actual; actual=$(sysctl -n "$key" 2>/dev/null) || return 2
[[ "$actual" == "$expected" ]] || { printf '%s=%s expected=%s\n' "$key" "$actual" "$expected"; return 1; }
}
assert_systemctl_enabled() { systemctl is-enabled --quiet "$1"; }
assert_systemctl_disabled() { ! systemctl is-enabled --quiet "$1" 2>/dev/null; }
assert_systemctl_masked() { [[ "$(systemctl is-enabled "$1" 2>/dev/null)" == "masked" ]]; }
assert_package_installed() {
local pkg="$1"
if command -v dpkg >/dev/null; then
dpkg -l "$pkg" 2>/dev/null | grep -q "^ii $pkg"
elif command -v rpm >/dev/null; then
rpm -q "$pkg" >/dev/null 2>&1
else
return 2
fi
}
assert_package_not_installed() {
! assert_package_installed "$1"
}
assert_mount_option() {
local mp="$1" opt="$2"
findmnt --noheadings --output=OPTIONS "$mp" 2>/dev/null \
| tr ',' '\n' | grep -qx "$opt"
}
assert_grep_in_file() {
local pattern="$1" file="$2"
[[ -f "$file" ]] || return 2
grep -q "$pattern" "$file"
}
assert_no_grep_in_file() {
local pattern="$1" file="$2"
[[ -f "$file" ]] || return 2
! grep -q "$pattern" "$file"
}
# Run a control function with auto-recording. Args: control_id, title, function_name
compliance_run_control() {
local id="$1" title="$2" fn="$3"
local out rc
out=$("$fn" 2>&1) && rc=0 || rc=$?
case $rc in
0) compliance_record "$id" "$title" "PASS" "${out:-OK}" ;;
1) compliance_record "$id" "$title" "FAIL" "${out:-FAIL}" ;;
2) compliance_record "$id" "$title" "SKIP" "${out:-NA}" ;;
*) compliance_record "$id" "$title" "FAIL" "rc=$rc out=$out" ;;
esac
}
# Sign and bundle. Call once after all controls have run.
compliance_finalize() {
local sig="${COMPLIANCE_BUNDLE}.sig"
local meta="${COMPLIANCE_BUNDLE}.meta"
if command -v gpg >/dev/null; then
gpg --batch --yes --output "$sig" \
--detach-sign --armor \
--local-user "$COMPLIANCE_KEYID" \
"$COMPLIANCE_BUNDLE" 2>/dev/null \
&& compliance_log "Signed: $sig"
else
compliance_log "WARN: gpg not present, skipping signature"
fi
cat > "$meta" <<EOF
{
"bundle": "$(basename "$COMPLIANCE_BUNDLE")",
"sha256": "$(sha256sum "$COMPLIANCE_BUNDLE" | cut -d' ' -f1)",
"signature": "$(basename "$sig")",
"framework": "$COMPLIANCE_FRAMEWORK",
"version": "$COMPLIANCE_VERSION",
"host": "$(hostname)",
"stamp": "$COMPLIANCE_STAMP",
"control_count": $(wc -l < "$COMPLIANCE_BUNDLE")
}
EOF
# Summary
local pass fail skip
pass=$(grep -c '"PASS"' "$COMPLIANCE_BUNDLE" || true)
fail=$(grep -c '"FAIL"' "$COMPLIANCE_BUNDLE" || true)
skip=$(grep -c '"SKIP"' "$COMPLIANCE_BUNDLE" || true)
compliance_log "SUMMARY: PASS=$pass FAIL=$fail SKIP=$skip bundle=$COMPLIANCE_BUNDLE"
}
# Drift between two bundles. Args: prev_bundle, current_bundle
compliance_drift() {
local prev="$1" current="$2"
jq -c 'select(.status != "SKIP") | {id: .control_id, status, evidence}' "$prev" \
| sort > /tmp/prev.sorted
jq -c 'select(.status != "SKIP") | {id: .control_id, status, evidence}' "$current" \
| sort > /tmp/current.sorted
diff -u /tmp/prev.sorted /tmp/current.sorted
}
Worked Example: Mini CIS Scan
#!/usr/bin/env bash
# cis-scan.sh — runs a subset of CIS Ubuntu 22.04 Level 1 controls
set -euo pipefail
COMPLIANCE_FRAMEWORK="CIS-Ubuntu-2204"
COMPLIANCE_VERSION="v1.0.0"
source /usr/local/lib/compliance.sh
# 1.1.1.1: cramfs disabled
control_1_1_1_1() {
modprobe -n -v cramfs 2>&1 | grep -q "install /bin/true" \
&& ! lsmod | grep -q "^cramfs"
}
compliance_run_control "1.1.1.1" "Ensure cramfs filesystem is disabled" control_1_1_1_1
# 1.1.21: /tmp partition with nodev,nosuid,noexec
control_1_1_21() {
assert_mount_option /tmp nodev \
&& assert_mount_option /tmp nosuid \
&& assert_mount_option /tmp noexec
}
compliance_run_control "1.1.21" "/tmp mount options" control_1_1_21
# 3.1.1: IP forwarding disabled
control_3_1_1() {
assert_sysctl "net.ipv4.ip_forward" "0"
}
compliance_run_control "3.1.1" "IP forwarding disabled" control_3_1_1
# 5.1.1: cron daemon enabled
control_5_1_1() {
assert_systemctl_enabled cron
}
compliance_run_control "5.1.1" "cron daemon enabled" control_5_1_1
# 5.1.2: /etc/crontab perms
control_5_1_2() {
assert_file_mode /etc/crontab 600 \
&& assert_file_owner /etc/crontab root
}
compliance_run_control "5.1.2" "/etc/crontab permissions" control_5_1_2
# 6.2.1: /etc/passwd perms
control_6_2_1() {
assert_file_mode /etc/passwd 644 \
&& assert_file_owner /etc/passwd root \
&& assert_file_group /etc/passwd root
}
compliance_run_control "6.2.1" "/etc/passwd permissions" control_6_2_1
# Finalize: sign and emit summary
compliance_finalize
Run output:
[2026-06-22T14:00:01Z] [compliance] Signed: /var/compliance/web-01-CIS-Ubuntu-2204-2026-06-22T140000.jsonl.sig
[2026-06-22T14:00:01Z] [compliance] SUMMARY: PASS=5 FAIL=1 SKIP=0 bundle=...
The bundle, signature, and metadata sit in /var/compliance/. Ship them daily to a centralized append-only S3 bucket (with Object Lock!) for audit retention.
Integrating With OpenSCAP And OSCAL
For more rigorous compliance frameworks (FedRAMP, DoD STIG with formal POA&M tracking), shell-only is insufficient. OpenSCAP and OSCAL are the formal frameworks:
- OpenSCAP runs SCAP content (XCCDF + OVAL XML files) and produces XML/HTML reports.
- OSCAL is NIST’s JSON/YAML format for catalogs, profiles, and assessment results.
The shell-script bundle from this lesson can be converted to OSCAL Assessment Results JSON:
# Convert lib/compliance.sh JSONL bundle to OSCAL AR
jq -s '
{
"assessment-results": {
"uuid": "'"$(uuidgen)"'",
"metadata": {
"title": "Compliance scan: " + .[0].framework + " " + .[0].version,
"last-modified": .[0].ts,
"version": .[0].version
},
"results": [
.[] | {
"uuid": "'"$(uuidgen)"'",
"title": .title,
"status": (if .status == "PASS" then "satisfied" elif .status == "FAIL" then "not-satisfied" else "not-applicable" end),
"subject-references": [{"subject-uuid": "'"$(hostname)"'", "type": "component"}],
"remarks": .evidence
}
]
}
}
' bundle.jsonl > oscal-ar.json
This makes shell-script results consumable by OSCAL-aware tools. For most internal compliance work, the JSONL bundle is sufficient; OSCAL is the upgrade for federal contracts.
When To Use OpenSCAP vs. Shell
| Need | Tool |
|---|---|
| Quick compliance check | Shell |
| Formal SCAP-validated content | OpenSCAP |
| Custom controls not in any catalog | Shell |
| Federal/DoD/HIPAA with auditor demands | OpenSCAP + OSCAL |
| Daily fleet drift detection | Shell |
| One-time pre-audit baseline | OpenSCAP |
The two are complementary: shell for daily ops, OpenSCAP for formal artifacts. Many shops run both.
Centralized Aggregation: Fleet-Wide Compliance Dashboard
A bundle on every host is useful but the fleet view is what management asks for. Push bundles to S3 and aggregate:
# On each host, after compliance_finalize
aws s3 cp "$COMPLIANCE_BUNDLE" \
"s3://compliance-archive/$(hostname)/$(date +%Y/%m/%d)/" \
--metadata "framework=$COMPLIANCE_FRAMEWORK,version=$COMPLIANCE_VERSION"
aws s3 cp "$COMPLIANCE_BUNDLE.sig" \
"s3://compliance-archive/$(hostname)/$(date +%Y/%m/%d)/"
Aggregator script (runs centrally, weekly):
# Pull all today's bundles, summarize per control across fleet
aws s3 sync s3://compliance-archive/ /tmp/bundles/ \
--exclude '*' --include "*$(date +%Y-%m-%d)*"
cat /tmp/bundles/**/*.jsonl \
| jq -c '{control_id, status, host}' \
| jq -s '
group_by(.control_id) |
map({
control_id: .[0].control_id,
total: length,
pass: (map(select(.status == "PASS")) | length),
fail: (map(select(.status == "FAIL")) | length),
skip: (map(select(.status == "SKIP")) | length),
failing_hosts: [map(select(.status == "FAIL")) | .[].host]
})
' > /tmp/fleet-summary.json
Display this as a dashboard: control_id × pass percentage, with hover-to-see failing hosts. Engineers fix the lowest-percentage control first.
The 8 Footguns
1. Treating false Return Code As The Same For All Failure Reasons
A control that returns 1 because of “value mismatch” vs. one that returns 1 because the file doesn’t exist are different. The first is a real failure; the second is “we can’t even check.” Fix: The PASS/FAIL/SKIP trichotomy. SKIP is for “can’t determine,” not “looks fine.”
2. Privileged Bundle Generation On Untrusted Hosts
If the compliance script runs as root and writes the bundle to a directory the local app can read, the local app can edit the bundle before it’s signed. Fix: Sign immediately after writing each line, or write to a directory only the compliance user can read; sign before flushing to shared paths.
3. Using set -e Without +e Around Assertions
set -e means a failing grep aborts the script — so your assert_grep_in_file aborts before compliance_record even runs. Fix: Wrap assertions in compliance_run_control (which captures rc explicitly) instead of relying on set -e to flow through.
4. Storing The GPG Private Key On The Host Being Scanned
If the host is compromised, the private key is too. The attacker can sign a fraudulent bundle saying everything is PASS. Fix: GPG key on a separate “compliance signing” host, scanned hosts upload unsigned bundles, signing host signs them. Or use YubiKey + dedicated signer.
5. Drift Compare With Different Frameworks Or Versions
Today you ran CIS v2.0; last week’s bundle was CIS v1.0. Many controls moved or were renumbered. The diff is noise. Fix: Always compare bundles with same framework and version strings; if you upgrade the framework, do a one-time baseline.
6. Bundle Path Includes Spaces Or Special Chars
Hostnames like “WEB 01” generate paths with spaces, which break aws s3 cp and unquoted shell expansions. Fix: Sanitize hostname when generating filenames: hostname=$(hostname | tr -c '[:alnum:].-' '_').
7. Forgetting --batch On gpg
Without --batch, GPG may prompt for passphrase, hanging the script. With --batch, it errors out cleanly if passphrase is missing. Fix: Always gpg --batch --yes .... Use a passphrase-less signing key (acceptable for a hardened compliance host) or a passphrase agent.
8. Not Including Evidence For PASS
A bundle where PASS records have empty evidence is half-useful — the auditor sees “PASS” but can’t verify what was checked. Fix: Every PASS record includes the actual measured value (ip_forward=0), not just OK.
Quick-Reference Card
CONTROL STRUCTURE
function control_X_Y_Z():
return 0 if PASS, 1 if FAIL, 2 if SKIP
compliance_run_control "X.Y.Z" "title" control_X_Y_Z
ASSERTION HELPERS
assert_file_mode PATH MODE (e.g., 600)
assert_file_owner PATH USER
assert_sysctl KEY VALUE
assert_systemctl_enabled UNIT
assert_systemctl_disabled UNIT
assert_package_installed PKG
assert_mount_option /tmp noexec
assert_grep_in_file PATTERN FILE
EVIDENCE BUNDLE
Format: JSONL, one record per control
Fields: ts, host, framework, version, control_id, title, status, evidence
Sign: gpg --batch --yes --detach-sign --armor
Verify: gpg --verify bundle.sig bundle
DRIFT
jq sort prev/current → diff
Alert on PASS→FAIL flips
Track in Prometheus textfile collector
INTEGRATION
OSCAL: jq transform JSONL → AR JSON
OpenSCAP: parallel — formal SCAP content
Centralized S3 with Object Lock for audit retention
THREAT-MODEL
Don't store signing key on scanned host
Append-only storage for bundles (immutability)
Sign immediately, before flush to shared paths
What’s Next
You can now produce signed, drift-tracked compliance evidence at fleet scale. The next dimension is forensics & incident response: when something has already gone wrong, the script that captures evidence — process state, memory, network connections, file artifacts — before it disappears, with chain-of-custody discipline that survives in court.
In the next lesson — Forensics & Incident Response: Triage Scripts, Ephemeral-Process Capture & Evidence Chain — we’ll build lib/forensics.sh covering the order-of-volatility capture (memory before disk before network before logs), hash-and-store discipline, the SHA-tree manifest that proves evidence integrity, the read-only mount pattern for examining a compromised host without altering it, and the five-step IR triage that ought to start within 60 seconds of “something is wrong.”