Cybersecurity-Projects/PROJECTS/beginner/linux-cis-hardening-auditor/learn/03-IMPLEMENTATION.md

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

  1. 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]}"
    
  2. Check whether the fixture file exists and contains the expected content
  3. Verify that the check function is looking at the right file path under SYSROOT

JSON Report Is Invalid

  1. Pipe through python3 -m json.tool to see where the parser fails
  2. Check whether any evidence string contains unescaped quotes or newlines
  3. The json_escape function handles \, ", \n, \t, and \r. If a new character causes issues, add it to the function

Tests Pass Locally But Fail in CI

  1. Check the Bash version: bash --version. Some CI environments ship Bash 4.x where a check relies on Bash 5.x features
  2. Verify that the working directory is set correctly. SCRIPT_DIR and PROJECT_DIR in the test runner depend on ${BASH_SOURCE[0]}
  3. Ensure that fixture files have the correct line endings (LF, not CRLF)

Next Steps

You have seen how the code works. Now:

  1. Try the challenges - 04-CHALLENGES.md has extension ideas ranging from new output formats to multi-distro support
  2. Modify a check - Pick a control, change the expected value in a fixture, and verify the check fails as expected
  3. Add a control - Follow the three-step process above to add a control from the CIS benchmark that is not yet implemented