Guide for contributors and developers working on the Apotropaios framework.
- Getting Started
- Development Workflow
- Coding Standards
- Testing
- Security Testing
- Known Pitfalls
- Adding a New Firewall Backend
- Adding a New Validator
- Makefile Reference
- CI/CD Pipeline
- Release Process
# Clone the repository
git clone https://github.com/Sandler73/Apotropaios-Firewall-Manager.git
cd apotropaios
# Automated setup (installs BATS, checks ShellCheck)
make dev-setup
# Or manual setup:
git clone --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats
sudo /tmp/bats/install.sh /usr/local
# Install ShellCheck
sudo apt-get install shellcheck # Debian/Ubuntu/Kali
sudo dnf install ShellCheck # RHEL family
sudo pacman -S shellcheck # Arch
# Run the full test suite
make test # 380 tests: lint + unit + integration + security
# Check all dependencies
make check-deps
# View project metrics
make metrics- Create a feature branch from
develop - Make changes following the coding standards below
- Run
make lint— fix all ShellCheck issues - Run
make test— all 380 tests must pass - Add tests for new functionality (unit, integration, or security as appropriate)
- Update documentation:
docs/changelog.md,docs/wiki/Changelog.md, help text, README if user-facing - Update
tasks/sync_function.mdif module dependencies changed - Submit a pull request to
developusing the PR template
- Target: Bash 4.0+
- Use
#!/usr/bin/env bashshebang - Use
set -euo pipefailin the main entry point only - All arithmetic under
set -emust use|| true:((count++)) || true - Never use brace expansion in Makefiles or
/bin/shcontext - Never use complex regex character classes in
[[ =~ ]]— they have version-dependent behavior (BUG-009). Use whitelist regex or glob patterns instead.
| Pattern | Usage | Example |
|---|---|---|
fw_BACKEND_action() |
Firewall backend functions | fw_iptables_add_rule() |
rule_action() |
Rule engine functions | rule_create() |
validate_type() |
Input validators | validate_port() |
log_level() |
Logging functions | log_info() |
security_action() |
Security functions | security_generate_uuid() |
util_action() |
Utility functions | util_trim() |
_internal_func() |
Private/internal functions | _fw_require_backend() |
UPPER_CASE |
Readonly constants | E_SUCCESS |
_UPPER_CASE |
Private globals | _CLEANUP_STACK |
- Single responsibility per function
- Parameter validation at the top:
local param="${1:?function_name requires param}" - Return codes as contracts: 0=success, non-zero=specific error code from constants
- Use
returnin library functions, neverexit(except in entry point) - Document with header block:
# ==============================================================================
# function_name()
# Description: What this function does.
# Parameters: $1 - Parameter description
# $2 - Optional parameter (default: value)
# Returns: 0 on success, E_CODE on failure
# ==============================================================================- Validate at the boundary — the moment user input enters the code
- Whitelist patterns, never blacklists
- Shell metacharacters detected via
_contains_shell_meta()using portable glob patterns - Never interpolate raw input into commands or file paths
- All firewall commands built using bash arrays, never string interpolation
Every library module must prevent double-sourcing:
[[ -n "${_APOTROPAIOS_MODULE_LOADED:-}" ]] && return 0
readonly _APOTROPAIOS_MODULE_LOADED=1The main entry point uses:
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fiQuote every variable expansion. The only exception is intentional word splitting, which must be commented.
- Log before and after significant operations
- Include context (module name) in every log call
- Never log sensitive data (passwords, keys, tokens are auto-masked)
- Use structured format:
[timestamp] [LEVEL] [context] [cid:ID] message
- Trap EXIT, not just signals
- Preserve exit codes in cleanup handlers
- Use idempotent cleanup (safe to call multiple times)
- Register cleanup functions via
error_register_cleanup() - Menu functions must validate input BEFORE calling engine functions (engine uses
${1:?}which exits the shell on empty params)
- All code must pass ShellCheck with zero warnings
- Use
.shellcheckrcfor project-wide suppression with documented rationale - Never suppress a warning you don't understand
tests/
├── helpers/
│ └── test_helper.bash # Sources libs at FILE level, resets globals in setup()
├── fixtures/
│ ├── sample_rules.conf # 5 valid rules for import testing
│ └── invalid_rules.conf # 5 invalid entries for rejection testing
├── unit/ # Pure function tests (234 tests, 8 files)
├── integration/ # Multi-function flow tests (98 tests, 4 files)
└── security/ # CWE-mapped security tests (48 tests, 1 file)
- Unit tests test pure functions directly — no stubs needed
- No test depends on another test's state
- Each test file loads the helper:
load '../helpers/test_helper' - Fixtures are read-only — copy to temp before modifying
- Test names follow
function: behaviorformat - Test the contract (return codes, output), not implementation details
# Simple return code test
@test "validate_port: rejects port above 65535" {
run validate_port "70000"
[ "$status" -eq 1 ]
}
# Output verification
@test "security_generate_uuid: returns 36-character UUID" {
local uuid
uuid="$(security_generate_uuid)"
[ "${#uuid}" -eq 36 ]
}For functions that modify global state, avoid run (which creates a subshell):
@test "rule_state_set: tracks active state" {
rule_state_set "${test_id}" "active" "permanent" "0" 2>/dev/null
local state
state="$(rule_state_get "${test_id}")"
[ "${state}" = "active" ]
}| Change Type | Required Tests |
|---|---|
| New function | Unit tests: valid input, invalid input, edge cases |
| Bug fix | Regression test that would have caught the bug |
| Security change | Tests in tests/security/ mapped to CWE IDs |
| New CLI flag | CLI integration test |
| Menu changes | Help system test (if help text updated) |
Security tests in tests/security/injection.bats are mapped to CWE IDs:
@test "CWE-78: sanitize_input strips semicolons" {
run sanitize_input "safe;whoami"
[[ "$output" != *";"* ]]
}
@test "CWE-732: security_create_temp_file has 600 permissions" {
local tmpf
tmpf="$(security_create_temp_file "test")"
local perms
perms="$(stat -c '%a' "${tmpf}")"
[ "${perms}" = "600" ]
rm -f "${tmpf}" 2>/dev/null || true
}| CWE | Category | Tests |
|---|---|---|
| CWE-78 | OS Command Injection | 12 |
| CWE-22 | Path Traversal | 5 |
| CWE-20 | Input Validation | 14 |
| CWE-117/532 | Log Injection / Sensitive Data | 6 |
| CWE-732 | Insecure Permissions | 4 |
| CWE-377 | Insecure Temp File | 2 |
| CWE-200 | Information Disclosure | 1 |
| — | Cryptographic Integrity | 2 |
| — | Advisory Locking | 2 |
make security-scan # 6 pattern checks (no external tools beyond grep)Checks for: eval with variable expansion, hardcoded /tmp paths, permissive file modes, unquoted variables in firewall commands, insecure downloads, hardcoded credentials.
Source libraries at file level in test_helper.bash, not inside setup(). Sourcing inside setup() creates arrays local to that function — they lose the -A attribute when setup returns.
Reset associative arrays in setup() with unset VAR; declare -gA VAR=(), never VAR=() (which strips the -A attribute in subshells).
Menu functions must validate input (UUID format, file path existence) BEFORE calling engine functions. Engine functions use ${1:?message} which causes bash to exit the entire shell on empty parameters — not just return.
Pattern: func args && _rc=0 || _rc=$? to catch errors without propagation.
Never use complex regex character classes like [;|&\$(){}\<>!#]in[[ =~ ]]` — they have version-dependent behavior across bash ERE engines. Use:
- Whitelist regex for path validation:
[[ "${path}" =~ ^[a-zA-Z0-9/_.-]+$ ]] - Glob patterns for metacharacter detection:
[[ "${s}" == *";"* ]](per-character)
When adding features that map to different native implementations per backend, validate the superset at the engine layer, then let each backend adapter translate to its native form. Never force one backend's limitations on the validation layer.
When a function creates multiple system resources (e.g., iptables creates separate LOG + terminal rules for log,drop), the corresponding removal function MUST remove ALL of them.
In tr character classes, hyphen (-) MUST be the first or last character to be treated as literal. Between any two characters, it creates a range. If the range is descending (higher ASCII → lower), GNU tr rejects the entire class and produces empty output — silently destroying all input data.
# WRONG — /-+ is a descending range (ASCII 47→43), tr errors out
tr -cd 'a-zA-Z0-9 .,_:/-+=@~%'
# CORRECT — hyphen at end is always literal
tr -cd 'a-zA-Z0-9 .,_:/+=@~%-'Avoid eval for file descriptor operations. Use literal FD numbers: exec 3>>"${file}" instead of eval "exec ${FD}>>'${file}'". The eval form embeds the file path inside an evaluated string where special characters (single quotes) can break quoting.
Never write user-influenced data to a file processed by nft -f. In file mode, nft interprets semicolons and newlines as command separators, creating a command injection vector. Use direct argument-based execution only.
Never use || fallback in security tests. If the function under test doesn't exist or fails to execute, the test must fail — not silently degrade to an untested code path.
Never use result="$(func_that_reads_tty)" — the $() creates a subshell where read -r var </dev/tty has I/O issues (prompt may not display, read may hang indefinitely). Use nameref parameters instead: func_that_reads_tty result_var "label" with local -n _ref="$1" inside the function.
util_confirm() writes its prompt to stderr. In the interactive menu, all wizard prompts write to stdout. If confirmation is mixed in, the stderr prompt is invisible — the user sees a hang. Use _wizard_read or direct stdout printf for all prompts within the wizard flow.
Both firewalld and ufw have "simple" rule paths that require a port. When no destination port is specified, always force the rich rule (firewalld) or extended syntax (ufw) path. Without this guard, the rule builder produces structurally invalid commands that the backend rejects.
Firewalld rich rules require at least one filtering element between the family declaration and the action. When no port is specified, add protocol value="tcp" (or udp/sctp). Without this, the rule rule family="ipv4" accept is rejected as structurally invalid.
- Create
lib/firewall/newfw.shwith source guard - Implement all required functions following the naming convention
fw_newfw_*:fw_newfw_add_rule,fw_newfw_remove_rule,fw_newfw_list_rulesfw_newfw_enable,fw_newfw_disable,fw_newfw_statusfw_newfw_block_all,fw_newfw_allow_all,fw_newfw_resetfw_newfw_save,fw_newfw_reload,fw_newfw_export_config
- Handle compound actions natively (see iptables vs nftables patterns)
- Handle connection tracking, log options, and rate limiting
- Add to arrays in
constants.sh:SUPPORTED_FW_LIST,SUPPORTED_FW_BINARIES,SUPPORTED_FW_SERVICES,SUPPORTED_FW_PACKAGES - Source the module in
apotropaios.sh - Add detection logic in
fw_detect.sh - Add backend config menu in
menu_main.sh - Write unit tests
- Update documentation
- Add the function to
lib/core/validation.shwith a documentation header - If it validates against a constant list, add the list to
constants.sh - Add the validation call to
rule_create()inrule_engine.shif it's a rule field - Add the field to the menu wizard in
menu_main.sh - Add the CLI flag to
apotropaios.sh - Add the field to the rule record in
rule_engine.sh - Update
help_cmd_add_rule()inhelp_system.sh - Write tests in
tests/unit/validation.bats - Update
tasks/sync_function.md
make help # Full target listing with descriptions
make test # Full suite: lint + unit + integration + security (380 tests)
make test-quick # Unit only (fast feedback)
make test-report # Detailed per-file pass/fail counts
make test-count # Quick count without execution
make test-list # List all test names
make test-sec # Security tests only (48 tests)
make security-scan # Static pattern analysis (6 checks)
make lint # Syntax check + ShellCheck
make dist # Build runtime distribution tarball
make dist-full # Build full distribution (includes tests, CI, tasks)
make dist-venv # Build venv package (portable, activate/deactivate)
make release # Build ALL packages + unified SHA256SUMS.txt
make install # Install to /opt/apotropaios (root required)
make uninstall # Remove installation (preserves data)
make verify # Check installed version
make dev-setup # Install BATS + check ShellCheck
make check-deps # Show all tool availability
make info # Quick project summary
make metrics # Detailed statistics
make clean # Remove build artifacts
make clean-all # Deep clean including all data| Stage | Description | Depends On |
|---|---|---|
| 1. Syntax | bash -n on 25 shell files |
— |
| 2. Lint | ShellCheck static analysis | Syntax |
| 3. Security Scan | Pattern detection + 48 security tests | Lint |
| 4. Unit Tests | Matrix: Ubuntu 22.04, 24.04 | Lint |
| 5. Integration Tests | Matrix: Ubuntu 22.04, 24.04 | Lint |
| 6. Distro Tests | Containers: Debian 12, Kali, Rocky 9, Alma 9, Arch | Lint |
| 7. Summary | Aggregated results, all-jobs gate | All above |
- Version tag verification (tag must match
APOTROPAIOS_VERSIONin constants.sh) - Full test suite + security gate
make release— build all distribution packages (runtime, full, venv) with unified SHA-256 checksums- GitHub Release with auto-generated notes and artifacts
fail-fast: false— all matrix entries run even if one fails--allowerasingfor RHEL minimal container imagesFORCE_JAVASCRIPT_ACTIONS_TO_NODE24: truefor action compatibility- Test artifacts uploaded on all runs (14/30/90 day retention)
maketargets as single source of truth — CI never duplicates Makefile logic- Concurrency control: cancel-in-progress for same-branch pushes
- Update
APOTROPAIOS_VERSIONinlib/core/constants.sh - Update version in lifecycle test:
tests/integration/lifecycle.bats - Update
docs/changelog.mdanddocs/wiki/Changelog.md - Run full test suite:
make test - Run security scan:
make security-scan - Update
tasks/todo.mdandtasks/todo_complete.md - Commit:
git commit -m "release: v1.x.x" - Tag:
git tag v1.x.x - Push:
git push origin main --tags - The release workflow automatically builds packages and creates a GitHub Release