Cybersecurity-Projects/PROJECTS/beginner/linux-cis-hardening-auditor/learn/02-ARCHITECTURE.md

18 KiB

System Architecture

This document breaks down how cisaudit is designed, how data flows through the system, and why the modular architecture makes it straightforward to add new controls, output formats, and benchmark versions.


High Level Architecture

┌──────────────────────────────────────────────────────────┐
│                    cisaudit.sh (entry point)              │
│  Parse CLI args  →  Load modules  →  Orchestrate audit   │
└────────────────┬──────────────────────────┬──────────────┘
                 │                          │
    ┌────────────▼────────────┐  ┌──────────▼──────────────┐
    │   controls/             │  │   lib/                   │
    │   registry_data.sh      │  │   constants.sh           │
    │                         │  │   utils.sh               │
    │   104 register_control  │  │   registry.sh            │
    │   calls defining every  │  │   engine.sh              │
    │   control's metadata    │  │   baseline.sh            │
    └────────────┬────────────┘  │   report_terminal.sh     │
                 │               │   report_json.sh         │
                 │               │   report_html.sh         │
    ┌────────────▼────────────┐  └──────────────────────────┘
    │   checks/               │
    │   01_initial_setup.sh   │
    │   02_services.sh        │
    │   03_network.sh         │
    │   04_logging.sh         │
    │   05_access.sh          │
    │   05_access_password.sh │
    │   06_maintenance.sh     │
    │                         │
    │   check_X_Y_Z()         │
    │   functions that        │
    │   inspect the system    │
    │   and call              │
    │   record_result()       │
    └─────────────────────────┘

Component Breakdown

cisaudit.sh (Entry Point)

  • Parses command-line arguments into global variables
  • Sources all library modules, control definitions, and check functions
  • Orchestrates the audit: detect OS, run checks, compute scores, emit report
  • Handles baseline save/diff and threshold exit codes

controls/registry_data.sh (Control Registry)

  • Contains 104 register_control calls, each defining one CIS benchmark control
  • Each call specifies: control ID, section name, title, level, scored status, description, and remediation command
  • This is the single source of truth for what the tool audits

checks/*.sh (Check Functions)

  • One file per CIS benchmark section
  • Each file contains check_X_Y_Z() functions that inspect the system and call record_result()
  • Check functions read config files, query sysctl values, verify file permissions, and test for installed packages

lib/ (Core Libraries)

  • constants.sh: Version, benchmark name, ANSI color codes, status codes, section names
  • utils.sh: Logging, OS detection, file read helpers, sysctl helpers, service/package queries
  • registry.sh: register_control() and record_result() functions, result storage
  • engine.sh: Score computation by section and by level
  • report_*.sh: Three output formatters (terminal, JSON, HTML)
  • baseline.sh: Save results as JSON, load and diff against a previous baseline

Data Flow

Full Audit Flow

Step by step walkthrough of what happens when you run sudo cisaudit -l 1 -f json -o report.json:

1. cisaudit.sh starts
   └─ source lib/constants.sh, utils.sh, registry.sh, engine.sh
   └─ source lib/report_*.sh, baseline.sh
   └─ source controls/registry_data.sh (populates CTRL_* arrays)
   └─ source checks/*.sh (defines check_X_Y_Z functions)

2. parse_args() processes CLI arguments
   └─ OPT_LEVEL="1", OPT_FORMAT="json", OPT_OUTPUT="report.json"

3. detect_os() reads /etc/os-release
   └─ DETECTED_ID="debian", DETECTED_VERSION="12"

4. run_checks() iterates REGISTERED_IDS[]
   └─ For each control ID:
       ├─ should_run_check() filters by level and category
       ├─ Calls check_X_Y_Z() function via CTRL_CHECK_FN lookup
       └─ check function calls record_result(id, status, evidence)
           └─ Appends to RESULT_STATUS[], RESULT_EVIDENCE[], RESULT_ORDER[]
           └─ Increments TOTAL_PASS/FAIL/WARN/SKIP counters

5. compute_scores() aggregates results
   └─ Counts pass/fail per section → SCORE_BY_SECTION[]
   └─ Computes SCORE_OVERALL, SCORE_LEVEL1, SCORE_LEVEL2

6. generate_report() dispatches to emit_json_report()
   └─ Iterates RESULT_ORDER[] and SECTION_ORDER[]
   └─ Prints JSON with metadata, summary, sections, controls
   └─ Writes to report.json via OPT_OUTPUT

7. Threshold check: if SCORE_OVERALL < OPT_THRESHOLD, exit 1

Control Registration Flow

Every control follows the same path from definition to result:

registry_data.sh                    checks/0X_section.sh
       │                                   │
register_control(                    check_1_1_1() {
  "1.1.1",                              status = PASS
  "Initial Setup",                       evidence = ""
  "Ensure cramfs disabled",              # inspect system
  "1", "yes",                            # ...
  "description...",                      record_result(
  "remediation..."                         "1.1.1",
)                                          status,
       │                                   evidence
       ▼                                 )
CTRL_TITLE["1.1.1"]                  }
CTRL_SECTION["1.1.1"]                     │
CTRL_LEVEL["1.1.1"]                       ▼
CTRL_SCORED["1.1.1"]              RESULT_STATUS["1.1.1"]
CTRL_DESCRIPTION["1.1.1"]        RESULT_EVIDENCE["1.1.1"]
CTRL_REMEDIATION["1.1.1"]        RESULT_ORDER += "1.1.1"
CTRL_CHECK_FN["1.1.1"]           TOTAL_PASS++
  = "check_1_1_1"

The naming convention is automatic. register_control "1.1.1" generates the function name check_1_1_1 by replacing dots with underscores. The check function must exist with that exact name, or the engine logs a SKIP with "Check function check_1_1_1 not implemented."


Design Patterns

Registry Pattern

The core architectural pattern is a control registry. All 104 controls are registered through a single function (register_control) into associative arrays. The engine does not hardcode any control logic. It iterates REGISTERED_IDS[], looks up the check function name from CTRL_CHECK_FN[], and calls it dynamically.

This pattern means:

  • Adding a new control requires exactly two changes: one register_control call and one check_X_Y_Z function
  • The engine, reporters, and baseline modules are completely independent of which controls exist
  • Controls can be added or removed without modifying any core code
register_control "1.1.1" \
    "Initial Setup" \
    "Ensure mounting of cramfs is disabled" \
    "1" \
    "yes" \
    "The cramfs filesystem type is..." \
    "echo 'install cramfs /bin/true' >> /etc/modprobe.d/cramfs.conf"

This call populates six associative arrays and one indexed array. The function name is derived from the ID: check_1_1_1.

Strategy Pattern for Reporters

Three reporter modules (report_terminal.sh, report_json.sh, report_html.sh) each expose a single entry function: emit_terminal_report, emit_json_report, emit_html_report. The generate_report function in cisaudit.sh dispatches to the correct one based on OPT_FORMAT.

Each reporter reads the same global state (RESULT_STATUS, RESULT_EVIDENCE, SCORE_BY_SECTION, etc.) and transforms it into a different format. They share no code between them because each output format has fundamentally different structure.

Adding a new format (CSV, SARIF, Markdown) means writing one new file with one emit_X_report function and adding one case to generate_report. Nothing else changes.

SYSROOT Abstraction

Every file access goes through helper functions that prepend the SYSROOT variable:

file_exists() {
    [[ -f "${SYSROOT}${1}" ]]
}

read_file() {
    local path="${SYSROOT}${1}"
    if [[ -f "$path" ]]; then
        cat "$path"
    else
        return 1
    fi
}

get_sysctl() {
    local param="$1"
    local proc_path="${SYSROOT}/proc/sys/${param//\.//}"
    if [[ -f "$proc_path" ]]; then
        cat "$proc_path"
        return 0
    fi
    # ...
}

When SYSROOT="/", these functions access the real system. When SYSROOT="testdata/fixtures", they access the mock filesystem. This single abstraction enables the entire test suite to run without root and without a live system.


Layer Separation

┌──────────────────────────────────────┐
│  Layer 1: CLI / Orchestration        │
│  cisaudit.sh                         │
│  - Parses arguments                  │
│  - Controls execution order          │
│  - Does NOT inspect the system       │
└──────────────────────────────────────┘
           ↓
┌──────────────────────────────────────┐
│  Layer 2: Control Definitions        │
│  controls/registry_data.sh           │
│  - Declares what to check            │
│  - Contains no check logic           │
│  - Pure metadata                     │
└──────────────────────────────────────┘
           ↓
┌──────────────────────────────────────┐
│  Layer 3: Check Functions            │
│  checks/01_initial_setup.sh ...      │
│  - Inspects the system               │
│  - Calls record_result()             │
│  - Does NOT format output            │
└──────────────────────────────────────┘
           ↓
┌──────────────────────────────────────┐
│  Layer 4: Scoring and Reporting      │
│  lib/engine.sh, report_*.sh          │
│  - Computes scores from results      │
│  - Formats output                    │
│  - Does NOT know what was checked    │
└──────────────────────────────────────┘

Why Layers?

  • Testability: Check functions can be tested against mock filesystems without involving the CLI layer. The scoring engine can be tested with synthetic result data without running any checks.
  • Extensibility: A new CIS benchmark version means updating Layer 2 (control definitions) and Layer 3 (check functions). Layers 1 and 4 are unchanged.
  • Separation of concerns: A check function never touches formatting. A reporter never touches the filesystem. Each module has one job.

Data Models

cisaudit uses Bash associative arrays as its data model. There are no external data stores.

Control Registry (populated by register_control)

CTRL_TITLE["1.1.1"]       = "Ensure mounting of cramfs is disabled"
CTRL_SECTION["1.1.1"]     = "Initial Setup"
CTRL_LEVEL["1.1.1"]       = "1"
CTRL_SCORED["1.1.1"]      = "yes"
CTRL_DESCRIPTION["1.1.1"] = "The cramfs filesystem type is..."
CTRL_REMEDIATION["1.1.1"] = "echo 'install cramfs /bin/true' >> ..."
CTRL_CHECK_FN["1.1.1"]    = "check_1_1_1"
REGISTERED_IDS[]           = ("1.1.1" "1.1.2" "1.1.3" ...)

Result Storage (populated by record_result)

RESULT_STATUS["1.1.1"]    = "PASS"
RESULT_EVIDENCE["1.1.1"]  = "cramfs disabled via /etc/modprobe.d/cramfs.conf"
RESULT_ORDER[]            = ("1.1.1" "1.1.2" ...)
TOTAL_PASS                = 72
TOTAL_FAIL                = 18
TOTAL_WARN                = 6
TOTAL_SKIP                = 8

Score Aggregation (populated by compute_scores)

SECTION_PASS["Initial Setup"]    = 16
SECTION_FAIL["Initial Setup"]    = 2
SCORE_BY_SECTION["Initial Setup"] = "88.9"
SCORE_OVERALL                    = "80.0"
SCORE_LEVEL1                     = "82.5"
SCORE_LEVEL2                     = "75.0"

Test Architecture

Fixture-Based Testing

The test suite runs checks against two mock filesystems:

testdata/
├── fixtures/          # Hardened system (most checks PASS)
│   ├── etc/
│   │   ├── modprobe.d/    # cramfs.conf, dccp.conf, etc.
│   │   ├── ssh/sshd_config
│   │   ├── pam.d/
│   │   ├── audit/rules.d/cis.rules
│   │   ├── sysctl.conf
│   │   ├── fstab
│   │   ├── passwd, shadow, group, gshadow
│   │   └── ...
│   └── proc/sys/          # Simulated /proc values
│       ├── kernel/randomize_va_space  (contains "2")
│       ├── net/ipv4/ip_forward        (contains "0")
│       └── ...
│
└── fixtures_fail/     # Unhardened system (most checks FAIL)
    ├── etc/
    │   ├── modprobe.d/    # empty
    │   ├── ssh/sshd_config (PermitRootLogin yes, etc.)
    │   └── ...
    └── proc/sys/          # Insecure values
        ├── net/ipv4/ip_forward  (contains "1")
        └── ...

Each /proc/sys/ parameter is a plain file containing the expected value. The get_sysctl function reads the file at ${SYSROOT}/proc/sys/net/ipv4/ip_forward instead of calling sysctl -n net.ipv4.ip_forward. This lets the test fixtures simulate kernel parameters without running on a real kernel.

Test Framework

The test runner is a custom framework in test_helpers.sh with three assertion functions:

assert_status "1.1.1" "$STATUS_PASS"
assert_evidence_contains "1.1.1" "cramfs disabled"
assert_json_valid "$json_output"

Tests follow a consistent pattern:

test_cramfs_disabled_pass() {
    CURRENT_TEST="test_cramfs_disabled_pass"
    setup_test "${PROJECT_DIR}/testdata/fixtures"
    check_1_1_1
    assert_status "1.1.1" "$STATUS_PASS"
}

test_cramfs_disabled_fail() {
    CURRENT_TEST="test_cramfs_disabled_fail"
    setup_test "${PROJECT_DIR}/testdata/fixtures_fail"
    check_1_1_1
    assert_status "1.1.1" "$STATUS_FAIL"
}

Each test resets the result state via setup_test, sets SYSROOT to the appropriate fixture directory, calls the check function, and asserts the expected status.


Security Architecture

Threat Model

What we protect against:

  1. Misconfigured Linux systems going into production without hardening
  2. Configuration drift where a system's hardening regresses over time (detected via baseline comparison)
  3. Compliance gaps where an organization does not know which CIS controls they pass or fail

What we do NOT protect against:

  • Runtime exploitation of services (this is an audit tool, not an IDS)
  • Active attacks in progress (cisaudit is a point-in-time snapshot, not continuous monitoring)
  • Misrepresentation of results (someone could modify fixture files to fake a passing audit)

Defense in the Tool Itself

  • set -euo pipefail in every script: fails on undefined variables, pipe errors, and uncaught errors
  • json_escape and html_escape prevent injection in JSON and HTML reports
  • SYSROOT defaults to / so the tool cannot accidentally audit a test fixture when run as root without -t
  • The tool never modifies the system. It is read-only by design. All check functions use grep, cat, stat, and sysctl to inspect without changing state

Design Decisions

Why Bash?

CIS auditing needs to run on minimal systems where Python, Ruby, or Go might not be installed. Bash 4+ is present on every modern Linux distribution. The tool has zero external dependencies beyond standard GNU utilities (grep, awk, sed, stat, date). This means it can run on a freshly provisioned server before any configuration management or package installation has occurred.

The trade-off is that Bash's associative arrays are slower than hash tables in compiled languages, and string manipulation is verbose. For 104 controls, the performance difference is negligible. The audit completes in under 2 seconds on typical systems.

Why Not Use Existing Tools?

Tools like OpenSCAP and Lynis provide similar functionality. This project exists as a learning resource that demonstrates how compliance auditing works from first principles. OpenSCAP uses XCCDF/OVAL XML definitions that are difficult to read. Lynis is a monolithic 7000+ line script. cisaudit is intentionally modular and readable so that every design decision is visible.

Why Associative Arrays Instead of JSON/SQLite?

Bash associative arrays keep the tool dependency-free. Using jq for JSON manipulation or sqlite3 for storage would add external dependencies. The trade-off is that the data model is implicit (arrays must be declared and populated in the right order) rather than schema-enforced. For 104 controls, the simplicity is worth it.


Key Files Reference

Quick map of where to find things:

  • src/cisaudit.sh - CLI entry point, argument parsing, orchestration
  • src/lib/constants.sh - All constants (version, colors, status codes, section names)
  • src/lib/registry.sh - register_control() and record_result() functions
  • src/lib/engine.sh - Score computation logic
  • src/lib/utils.sh - System inspection helpers (get_sysctl, read_file, file_exists)
  • src/controls/registry_data.sh - All 104 control definitions
  • src/checks/01_initial_setup.sh - Filesystem and bootloader checks
  • src/checks/03_network.sh - Network parameter and firewall checks
  • src/lib/report_terminal.sh - ASCII art terminal report
  • src/lib/report_html.sh - Standalone HTML report with CSS
  • tests/test_helpers.sh - Test assertion functions

Next Steps

Now that you understand the architecture:

  1. Read 03-IMPLEMENTATION.md for a code walkthrough of each module
  2. Try running cisaudit -t testdata/fixtures -f json | python3 -m json.tool and trace the output back through the code