22 KiB
Implementation Guide
This document walks through the actual code. We will trace how controls are registered, how check functions inspect the system, how scores are computed, and how reports are generated. Code snippets come directly from the project source files.
File Structure Walkthrough
src/
├── cisaudit.sh # Entry point and CLI orchestration
├── lib/
│ ├── constants.sh # All constants and color codes
│ ├── utils.sh # System inspection helpers
│ ├── registry.sh # Control registration and result storage
│ ├── engine.sh # Score aggregation
│ ├── report_terminal.sh # Terminal output with progress bars
│ ├── report_json.sh # Machine-readable JSON
│ ├── report_html.sh # Standalone HTML with embedded CSS
│ └── baseline.sh # Save/load/diff baselines
├── controls/
│ └── registry_data.sh # 104 control definitions
└── checks/
├── 01_initial_setup.sh # 20 checks for Section 1
├── 02_services.sh # 16 checks for Section 2
├── 03_network.sh # 20 checks for Section 3
├── 04_logging.sh # 18 checks for Section 4
├── 05_access.sh # 14 checks for Section 5 (cron, SSH)
├── 05_access_password.sh # 4 checks for Section 5 (passwords, PAM)
└── 06_maintenance.sh # 10 checks for Section 6
Building the Control Registry
The register_control Function
Every CIS control starts as a call to register_control in registry_data.sh. The function in registry.sh stores control metadata into associative arrays and derives the check function name from the control ID:
register_control() {
local id="$1"
local section="$2"
local title="$3"
local level="$4"
local scored="$5"
local description="$6"
local remediation="$7"
CTRL_TITLE["$id"]="$title"
CTRL_SECTION["$id"]="$section"
CTRL_LEVEL["$id"]="$level"
CTRL_SCORED["$id"]="$scored"
CTRL_DESCRIPTION["$id"]="$description"
CTRL_REMEDIATION["$id"]="$remediation"
local fn_name="check_${id//\./_}"
CTRL_CHECK_FN["$id"]="$fn_name"
REGISTERED_IDS+=("$id")
}
The expression ${id//\./_} replaces every dot with an underscore. Control ID "1.1.1" becomes function name "check_1_1_1". Control ID "5.2.14" becomes "check_5_2_14". This naming convention is enforced by the registration system, not by documentation. If a check function does not exist, the engine skips it with evidence explaining why.
A Registration Call
In registry_data.sh, each control definition looks like this:
register_control "1.2.1" \
"Initial Setup" \
"Ensure /tmp is a separate partition" \
"1" \
"yes" \
"The /tmp directory is a world-writable location used for temporary file storage..." \
"echo 'tmpfs /tmp tmpfs defaults,rw,nosuid,nodev,noexec,relatime 0 0' >> /etc/fstab && mount -o remount /tmp"
The seven positional arguments are: ID, section, title, level, scored, description, remediation. The description is stored but not displayed in terminal reports (only in JSON and HTML). The remediation string is shown to the user when a control fails, so they know exactly what command to run.
The record_result Function
After a check function inspects the system, it records its finding:
record_result() {
local id="$1"
local status="$2"
local evidence="$3"
RESULT_STATUS["$id"]="$status"
RESULT_EVIDENCE["$id"]="$evidence"
RESULT_ORDER+=("$id")
case "$status" in
"$STATUS_PASS") ((TOTAL_PASS++)) || true ;;
"$STATUS_FAIL") ((TOTAL_FAIL++)) || true ;;
"$STATUS_WARN") ((TOTAL_WARN++)) || true ;;
"$STATUS_SKIP") ((TOTAL_SKIP++)) || true ;;
esac
}
The || true after the increment is necessary because ((TOTAL_PASS++)) returns exit code 1 when TOTAL_PASS was 0 (because 0 is falsy in arithmetic context), and set -e would terminate the script. This is a common Bash gotcha that silently kills scripts using strict mode.
Writing Check Functions
Pattern 1: Shared Helper with Per-Control Wrappers
Many controls in the same section follow an identical check pattern with different parameters. Instead of duplicating logic, a private helper function does the work and thin wrappers call it.
The filesystem module disable checks all follow this pattern. The helper _check_module_disabled in 01_initial_setup.sh handles the full logic:
_check_module_disabled() {
local id="$1"
local module="$2"
local status="$STATUS_PASS"
local evidence=""
local modprobe_conf="${SYSROOT}/etc/modprobe.d/${module}.conf"
if run_cmd lsmod | grep -q "^${module} "; then
status="$STATUS_FAIL"
evidence="${module} module is currently loaded"
elif [[ -f "$modprobe_conf" ]] && grep -q "install ${module} /bin/true\|install ${module} /bin/false" "$modprobe_conf"; then
evidence="${module} disabled via ${modprobe_conf}"
else
local found_disabled="false"
for conf in "${SYSROOT}"/etc/modprobe.d/*.conf; do
[[ -f "$conf" ]] || continue
if grep -q "install ${module} /bin/true\|install ${module} /bin/false\|blacklist ${module}" "$conf"; then
found_disabled="true"
evidence="${module} disabled via ${conf}"
break
fi
done
if [[ "$found_disabled" == "false" ]]; then
status="$STATUS_FAIL"
evidence="${module} is not disabled"
fi
fi
record_result "$id" "$status" "$evidence"
}
The function checks three things in order: (1) is the module currently loaded in memory, (2) does a dedicated modprobe config file exist, (3) does any modprobe config file contain a disable or blacklist directive for this module. If none of these succeed, the control fails.
Each control wraps the helper in a one-liner:
check_1_1_1() { _check_module_disabled "1.1.1" "cramfs"; }
check_1_1_2() { _check_module_disabled "1.1.2" "freevxfs"; }
check_1_1_3() { _check_module_disabled "1.1.3" "jffs2"; }
The same pattern appears throughout the codebase. SSH checks use _check_ssh_value and _check_ssh_max_int. Password policy checks use _check_login_defs_value. File permission checks use _check_file_permissions.
Pattern 2: Standalone Complex Check
Some controls have unique logic that does not generalize. The MTA local-only check (check_2_2_15) inspects multiple mail transfer agents in priority order:
check_2_2_15() {
local id="2.2.15"
local status="$STATUS_PASS"
local evidence=""
local listening_external=""
if run_cmd ss -lntp | grep -qE ':25\s' 2>/dev/null; then
local listeners
listeners=$(run_cmd ss -lntp | grep -E ':25\s' 2>/dev/null) || true
if echo "$listeners" | grep -qvE '127\.0\.0\.1:25|::1:25|\[::1\]:25|\*:25'; then
local bound_addrs
bound_addrs=$(echo "$listeners" | awk '{print $4}')
if echo "$bound_addrs" | grep -qvE '^127\.0\.0\.1:|^\[::1\]:|^::1:'; then
listening_external="true"
fi
fi
fi
# ... continues with Postfix, Exim, Sendmail checks ...
}
This function first checks whether anything is listening on port 25 on a non-loopback interface (the definitive test). Then it falls back to reading Postfix's inet_interfaces setting, then Exim's dc_local_interfaces, then checks for Sendmail. The layered approach handles different MTA configurations without hardcoding assumptions about which MTA is installed.
Pattern 3: Audit Rule Verification
The _check_audit_rules function in 04_logging.sh takes a list of patterns and verifies that each one appears in at least one audit rules file:
_check_audit_rules() {
local id="$1"
shift
local description="$1"
shift
local search_patterns=("$@")
local status="$STATUS_PASS"
local evidence=""
local rules_dir="${SYSROOT}/etc/audit/rules.d"
local audit_rules="${SYSROOT}/etc/audit/audit.rules"
local missing=()
for pattern in "${search_patterns[@]}"; do
local found="false"
if [[ -d "$rules_dir" ]]; then
for rule_file in "$rules_dir"/*.rules; do
[[ -f "$rule_file" ]] || continue
if grep -q "$pattern" "$rule_file"; then
found="true"
break
fi
done
fi
if [[ "$found" == "false" && -f "$audit_rules" ]]; then
if grep -q "$pattern" "$audit_rules"; then
found="true"
fi
fi
if [[ "$found" == "false" ]]; then
missing+=("$pattern")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
status="$STATUS_FAIL"
evidence="Missing audit rules for: ${missing[*]}"
else
evidence="${description} audit rules are configured"
fi
record_result "$id" "$status" "$evidence"
}
Callers pass variable-length pattern lists:
check_4_1_5() {
_check_audit_rules "4.1.5" "Time change" \
"adjtimex" "settimeofday" "clock_settime" "/etc/localtime"
}
If any pattern is missing from all rules files, the control fails with evidence listing exactly which patterns were not found. This gives the user actionable information about what audit rules to add.
The SYSROOT Abstraction
How It Works
The SYSROOT variable defaults to "/". When running in test mode (-t testdata/fixtures), it is set to the fixture directory path. Every system inspection function prepends SYSROOT to file paths:
file_exists() {
[[ -f "${SYSROOT}${1}" ]]
}
read_file() {
local path="${SYSROOT}${1}"
if [[ -f "$path" ]]; then
cat "$path"
else
return 1
fi
}
The get_sysctl function is where this gets interesting. Sysctl values are normally read with sysctl -n net.ipv4.ip_forward, but that requires a running kernel. In test mode, the function translates the dotted sysctl parameter name into a /proc/sys/ path:
get_sysctl() {
local param="$1"
local proc_path="${SYSROOT}/proc/sys/${param//\.//}"
if [[ -f "$proc_path" ]]; then
cat "$proc_path"
return 0
fi
if run_cmd sysctl -n "$param"; then
return 0
fi
return 1
}
The expression ${param//\.//} replaces dots with slashes. So net.ipv4.ip_forward becomes net/ipv4/ip_forward, and the full path becomes testdata/fixtures/proc/sys/net/ipv4/ip_forward. That file contains 0 (the expected secure value).
The run_cmd function explicitly blocks command execution in test mode:
run_cmd() {
if [[ "$SYSROOT" != "/" ]]; then
return 1
fi
"$@" 2>/dev/null
}
When SYSROOT is not /, run_cmd always returns failure. This means checks that rely on systemctl, dpkg-query, lsmod, or iptables will skip in test mode because those commands cannot run against a fixture directory. Only file-based checks produce results.
Score Computation
The compute_scores function in engine.sh aggregates results per section and per level:
compute_scores() {
local section
for section in "${SECTION_ORDER[@]}"; do
SECTION_PASS["$section"]=0
SECTION_FAIL["$section"]=0
SECTION_WARN["$section"]=0
SECTION_SKIP["$section"]=0
done
local id
for id in "${RESULT_ORDER[@]}"; do
section="${CTRL_SECTION[$id]}"
case "${RESULT_STATUS[$id]}" in
"$STATUS_PASS") ((SECTION_PASS["$section"]++)) || true ;;
"$STATUS_FAIL") ((SECTION_FAIL["$section"]++)) || true ;;
"$STATUS_WARN") ((SECTION_WARN["$section"]++)) || true ;;
"$STATUS_SKIP") ((SECTION_SKIP["$section"]++)) || true ;;
esac
done
# ...
}
Scores are computed as (pass / (pass + fail)) * 100. WARN and SKIP results are excluded from the percentage because CIS scoring guidelines only count definitively passed or failed controls. A control that was skipped (because it requires a live system) should not penalize the score.
The awk call for percentage calculation avoids Bash's limitation of integer-only arithmetic:
SCORE_OVERALL=$(awk "BEGIN { printf \"%.1f\", ($TOTAL_PASS / $scored_total) * 100 }")
Report Generation
Terminal Report
The terminal reporter in report_terminal.sh produces a structured ASCII output with colored sections. The main function chains four rendering steps:
emit_terminal_report() {
_print_banner
_print_summary_cards
_print_section_table
_print_detail_results
_print_footer
}
The progress bar function _progress_bar renders a 20-character wide bar using block characters:
_progress_bar() {
local percentage="$1"
local width=20
local filled
local empty
if [[ "$percentage" == "N/A" ]]; then
printf '%s' "░░░░░░░░░░░░░░░░░░░░"
return
fi
filled=$(awk "BEGIN { printf \"%d\", ($percentage / 100) * $width + 0.5 }")
empty=$((width - filled))
local bar=""
local i
for (( i = 0; i < filled; i++ )); do
bar+="█"
done
for (( i = 0; i < empty; i++ )); do
bar+="░"
done
printf '%s' "$bar"
}
The detail results section shows per-control status with evidence and remediation for failed controls:
if [[ "$status" == "$STATUS_FAIL" ]]; then
local evidence="${RESULT_EVIDENCE[$id]:-}"
local remediation="${CTRL_REMEDIATION[$id]:-}"
if [[ -n "$evidence" ]]; then
printf ' %b Evidence: %s%b\n' "$DIM" "$evidence" "$RESET"
fi
if [[ -n "$remediation" ]]; then
printf ' %b Fix: %s%b\n' "$YELLOW" "$remediation" "$RESET"
fi
fi
JSON Report
The JSON reporter in report_json.sh uses printf to build valid JSON without jq. The json_escape function handles the five characters that must be escaped in JSON strings:
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
printf '%s' "$s"
}
The report includes metadata (version, benchmark, timestamp, hostname, OS), a summary object, an array of section scores, and an array of all control results. Each control result includes the ID, section, title, level, scored status, result status, evidence, and remediation. This makes the JSON output usable for ingestion into SIEMs, dashboards, or compliance platforms.
HTML Report
The HTML reporter in report_html.sh generates a standalone HTML document with embedded CSS. The entire report is a single self-contained file with no external dependencies, which means it can be emailed, archived, or viewed offline.
The CSS uses a dark color scheme with CSS custom properties for theme colors. Failed controls are rendered with <details open> so they are expanded by default, while passing controls are collapsed. The report includes responsive breakpoints for mobile viewing and a print stylesheet that switches to a light theme.
Baseline Comparison
The baseline module in baseline.sh lets users save a JSON snapshot and compare future runs against it.
Saving is straightforward: it calls emit_json_report and writes the output to a file.
Loading and diffing uses pure Bash regex matching to extract control IDs and statuses from the JSON without requiring jq:
load_baseline() {
local file="$1"
BASELINE_STATUS=()
local current_id=""
local id_pattern='"id":[[:space:]]*"([^"]+)"'
local status_pattern='"status":[[:space:]]*"([^"]+)"'
while IFS= read -r line; do
if [[ "$line" =~ $id_pattern ]]; then
current_id="${BASH_REMATCH[1]}"
fi
if [[ -n "$current_id" && "$line" =~ $status_pattern ]]; then
BASELINE_STATUS["$current_id"]="${BASH_REMATCH[1]}"
current_id=""
fi
done < "$file"
}
The diff output categorizes changes as regressions (PASS to FAIL), improvements (FAIL to PASS), unchanged, new controls, and removed controls. Regressions are highlighted in red and generate a warning.
The Test Framework
Test Runner
The test runner in test_runner.sh sources all project modules, then discovers test files by globbing test_*.sh (excluding test_helpers.sh and test_runner.sh). For each file, it sources it, discovers functions matching ^test_, calls each one, and then unsets them to prevent collisions with the next file:
run_test_file() {
local test_file="$1"
source "$test_file"
local test_fns
test_fns=$(declare -F | awk '{print $3}' | grep '^test_')
local fn
for fn in $test_fns; do
echo " Running: ${fn}"
"$fn"
done
for fn in $test_fns; do
unset -f "$fn"
done
}
Assertion Helpers
The setup_test function resets all result state and sets SYSROOT to a fixture directory:
setup_test() {
local fixtures_dir="$1"
reset_results
SYSROOT="$fixtures_dir"
DETECTED_ID="debian"
DETECTED_VERSION="12"
}
assert_status compares the recorded result status for a control ID against the expected value:
assert_status() {
local id="$1" expected="$2"
local actual="${RESULT_STATUS[$id]:-UNSET}"
((TEST_TOTAL++)) || true
if [[ "$actual" == "$expected" ]]; then
((TEST_PASS++)) || true
else
((TEST_FAIL++)) || true
echo " FAIL: ${CURRENT_TEST} — ${id}: expected ${expected}, got ${actual}" >&2
fi
}
assert_evidence_contains verifies that the evidence string contains an expected substring. This is more robust than exact matching because evidence strings often include variable details like file paths or numeric values.
Adding a New Control
To add a new CIS control, you need exactly two changes:
Step 1: Register the control in controls/registry_data.sh:
register_control "1.6.1" \
"Initial Setup" \
"Ensure SELinux or AppArmor is installed" \
"1" \
"yes" \
"Mandatory access control frameworks restrict process capabilities beyond DAC permissions" \
"apt-get install apparmor apparmor-utils"
Step 2: Write the check function in the appropriate checks/0X_section.sh file:
check_1_6_1() {
local id="1.6.1"
local status="$STATUS_PASS"
local evidence=""
if package_is_installed "apparmor"; then
evidence="AppArmor is installed"
elif package_is_installed "selinux-basics"; then
evidence="SELinux is installed"
elif file_exists "/usr/sbin/apparmor_parser"; then
evidence="AppArmor binary found"
else
status="$STATUS_FAIL"
evidence="Neither AppArmor nor SELinux is installed"
fi
record_result "$id" "$status" "$evidence"
}
Step 3: Add tests in the appropriate tests/test_0X_section.sh file:
test_selinux_apparmor_pass() {
CURRENT_TEST="test_selinux_apparmor_pass"
setup_test "${PROJECT_DIR}/testdata/fixtures"
# add /usr/sbin/apparmor_parser to fixtures first
check_1_6_1
assert_status "1.6.1" "$STATUS_PASS"
}
The engine, reporters, baseline module, and scoring engine automatically pick up the new control. No other files need modification.
Common Implementation Pitfalls
Pitfall 1: Arithmetic Increment Under set -e
Symptom: Script exits silently when incrementing a counter that is currently 0.
Cause:
((count++))
When count is 0, ((count++)) evaluates to 0 (the pre-increment value), which is falsy in Bash arithmetic. Under set -e, this non-zero exit code terminates the script.
Fix:
((count++)) || true
The || true swallows the false exit code. This pattern appears throughout the codebase wherever a counter is incremented.
Pitfall 2: Grep Failure Under set -e
Symptom: Script exits when grep finds no matches.
Cause: grep returns exit code 1 when it finds no matches. Under set -e, this is treated as a fatal error.
Fix:
local result
result=$(grep "pattern" "$file") || true
The || true ensures the script continues when grep finds nothing. The variable result will be empty, and the check function should handle that case.
Pitfall 3: Missing Quote Around Variable in Array Key
Symptom: Associative array lookups return empty for keys containing special characters.
Cause:
RESULT_STATUS[$id]="PASS"
If $id contains characters that Bash might interpret (spaces, special characters), the lookup fails.
Fix:
RESULT_STATUS["$id"]="PASS"
Always quote associative array keys. The codebase consistently quotes all array key accesses.
Debugging Tips
Control Returns Unexpected Status
- Run the specific control in isolation against a fixture:
source src/lib/constants.sh src/lib/utils.sh src/lib/registry.sh source src/controls/registry_data.sh source src/checks/01_initial_setup.sh SYSROOT="testdata/fixtures" QUIET="true" check_1_1_1 echo "${RESULT_STATUS[1.1.1]}: ${RESULT_EVIDENCE[1.1.1]}" - Check whether the fixture file exists and contains the expected content
- Verify that the check function is looking at the right file path under
SYSROOT
JSON Report Is Invalid
- Pipe through
python3 -m json.toolto see where the parser fails - Check whether any evidence string contains unescaped quotes or newlines
- The
json_escapefunction handles\,",\n,\t, and\r. If a new character causes issues, add it to the function
Tests Pass Locally But Fail in CI
- Check the Bash version:
bash --version. Some CI environments ship Bash 4.x where a check relies on Bash 5.x features - Verify that the working directory is set correctly.
SCRIPT_DIRandPROJECT_DIRin the test runner depend on${BASH_SOURCE[0]} - Ensure that fixture files have the correct line endings (LF, not CRLF)
Next Steps
You have seen how the code works. Now:
- Try the challenges - 04-CHALLENGES.md has extension ideas ranging from new output formats to multi-distro support
- Modify a check - Pick a control, change the expected value in a fixture, and verify the check fails as expected
- Add a control - Follow the three-step process above to add a control from the CIS benchmark that is not yet implemented