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_controlcalls, 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 callrecord_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 namesutils.sh: Logging, OS detection, file read helpers, sysctl helpers, service/package queriesregistry.sh:register_control()andrecord_result()functions, result storageengine.sh: Score computation by section and by levelreport_*.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_controlcall and onecheck_X_Y_Zfunction - 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:
- Misconfigured Linux systems going into production without hardening
- Configuration drift where a system's hardening regresses over time (detected via baseline comparison)
- 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 pipefailin every script: fails on undefined variables, pipe errors, and uncaught errorsjson_escapeandhtml_escapeprevent injection in JSON and HTML reportsSYSROOTdefaults 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, andsysctlto 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, orchestrationsrc/lib/constants.sh- All constants (version, colors, status codes, section names)src/lib/registry.sh-register_control()andrecord_result()functionssrc/lib/engine.sh- Score computation logicsrc/lib/utils.sh- System inspection helpers (get_sysctl,read_file,file_exists)src/controls/registry_data.sh- All 104 control definitionssrc/checks/01_initial_setup.sh- Filesystem and bootloader checkssrc/checks/03_network.sh- Network parameter and firewall checkssrc/lib/report_terminal.sh- ASCII art terminal reportsrc/lib/report_html.sh- Standalone HTML report with CSStests/test_helpers.sh- Test assertion functions
Next Steps
Now that you understand the architecture:
- Read 03-IMPLEMENTATION.md for a code walkthrough of each module
- Try running
cisaudit -t testdata/fixtures -f json | python3 -m json.tooland trace the output back through the code