Shell Lesson 40 of 42

Shell Compliance Scanning: CIS/STIG-as-Shell, Evidence Bundles, Signed Reports & The Discipline That Makes Auditors Sign Off Without A Round Of Questions

What Auditors Actually Want (And Why Shell Is Surprisingly Good At It)

Auditors want three things, in order:

  1. Reproducibility — “Show me the same check producing the same result on the same host today and 90 days ago.”
  2. Tamper-evidence — “Prove the report wasn’t edited after generation.”
  3. 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:

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:

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:

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:

  1. Have the public key (published, fingerprinted in your security policy).
  2. gpg --verify bundle.jsonl.sig bundle.jsonl → must show “Good signature from compliance@example.com.”
  3. 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:

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.”

shellcompliancecisstigopenscaposcalauditevidence-bundlegpgdrift-detectionsecurity-scanning
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments