Note: this documentation was generated by Claude. Instead of expecting all devs to fully grasp bash, this is inclusive documentation. Reading the bash files feels relatively straightforward due to naming conventions, but some details are nice to have explained, such as set -euo pipefail.
This walks through the bash concepts you'll run into while reading lib/ and bin/, roughly in the order you'd encounter them if you read the files top to bottom starting with lib/common.sh. It's not a full bash tutorial — just enough to make sense of this codebase if you don't normally write shell scripts. A closing section explains how the .bats tests in test/ work.
The one-line rule to keep in mind throughout: everything in bash is either a command or a string, and most of the "syntax" you'll see below is just different ways of building/checking/combining strings and running commands.
This file is never run directly — it's a library of shared functions that every script in bin/ loads. Read it first because everything else builds on it.
source "${SCRIPT_DIR}/../lib/common.sh"source (or its shorthand .) runs a script's contents inside the current shell, as if you'd typed those lines yourself. That's different from executing a script (./common.sh), which spawns a brand-new shell process. Sourcing is how common.sh's functions and variables become available to collect.sh, report.sh, etc. — it's the bash equivalent of an import.
common.sh's own comment calls this out explicitly:
# Requires: jq, curl. Callers are expected to `set -euo pipefail` themselves;
# this file avoids doing so on source so it can be safely sourced by bats too.Every executable script in bin/ starts with:
set -euo pipefailThis is bash's closest equivalent to "treat warnings as errors." Each flag does something specific:
| Flag | Effect |
|---|---|
-e |
Exit immediately if any command fails (returns non-zero), instead of bash's normal behavior of plowing ahead. |
-u |
Treat use of an unset variable as an error, instead of silently substituting an empty string. |
-o pipefail |
In a pipeline (a | b | c), fail if any stage fails — not just the last one. |
Without this, a typo'd variable name or a failed curl call would silently continue and could corrupt the JSON payload or report false success. common.sh deliberately does not set this on its own — it's only sourced, never run standalone, and forcing -e onto every caller (including the bats test runner) could have surprising side effects outside our control.
: "${CVE_TRACKER_PLUGIN_CONFIG:=/etc/cve-tracker-plugin/config.json}": is a no-op command (it does nothing but "succeed"). The real work happens inside ${VAR:=default}: if VAR is unset or empty, set it to default and use that value. This is how the config path and log directory become overridable via environment variables (handy for tests — see test/test_helper.bash) while still having a sensible production default.
log_info() { printf '[%s] INFO %s\n' "$(date -Iseconds)" "$*" >&2; }Bash functions don't declare parameter names — arguments show up as $1, $2, etc. inside the function, and $*/$@ mean "all arguments." Inside a function, always declare working variables with local:
require_cmd() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: $cmd"
done
}Without local, a variable assigned inside a function leaks into the caller's scope and can silently clobber a variable with the same name elsewhere in the script — a common source of hard-to-find bugs in larger shell scripts.
"$*" joins all arguments into a single string; "$@" keeps each argument as a separate word — critical when an argument might contain spaces. The rule of thumb used throughout this codebase: always quote variable expansions ("$var", not $var), unless you specifically want bash to word-split and glob-expand it (rare, and usually a bug when it happens by accident).
log_error() { printf '[%s] ERROR %s\n' "$(date -Iseconds)" "$*" >&2; }
die() {
log_error "$1"
exit "${2:-1}"
}>&2 redirects output to stderr (file descriptor 2) instead of stdout (file descriptor 1). This matters a lot here: collect.sh writes its JSON payload to stdout and its log messages to stderr, so callers can do PAYLOAD=$(collect.sh) and capture only the JSON, with warnings still visible on the terminal.
exit "${2:-1}" uses the same default-value syntax as before (${var:-default}, without the = this time so it doesn't assign — it just substitutes for this one use). Every command in Unix returns a numeric exit code: 0 means success, anything else means failure. die logs an error and exits with that failure code, which is what makes command || die "message" work — || runs the right-hand side only if the left-hand side failed.
value=$(jq -r "${filter} // empty" "$(config_path)" 2>/dev/null)$(command) runs command and substitutes its stdout output as a string. It's used constantly here to capture the output of jq, git, curl, date, etc. into variables. You'll also see it nested, as above — config_path (itself a function) runs inside the jq call to supply the file path.
config_exists() {
[ -f "$(config_path)" ]
}[ ... ] is actually a command (historically the test program) — the spaces around the brackets are mandatory because it's parsed like any other command name and arguments. -f tests "is this a regular file." Other common tests you'll see: -d (directory), -r (readable), -t (is this a terminal — used by is_interactive), -z/-n (string is empty/non-empty).
[[ ... ]] (double brackets) is a bash-specific upgrade with fewer quoting gotchas and support for pattern matching / regex (=~); it shows up in the test suite (test/*.bats) more than in the scripts themselves.
case "$DISTRO" in
debian) DISTRO="Debian" ;;
ubuntu) DISTRO="Ubuntu" ;;
esacBash's case is a pattern-matching switch. Each pattern can use globs (*, ?, [...]), which is how argument parsing works in several scripts — e.g. report.sh's case "$STATUS" in ''|*[!0-9]*) ... matches "empty string OR contains any non-digit character."
config_write() {
local json="$1"
local dir
dir=$(dirname "$(config_path)")
mkdir -p "$dir"
local tmp
tmp=$(mktemp "${dir}/.config.json.XXXXXX")
printf '%s' "$json" | jq '.' > "$tmp"
chmod 600 "$tmp"
mv "$tmp" "$(config_path)"
}This writes to a uniquely-named temp file first (mktemp fills in the XXXXXX with random characters, guaranteeing no collisions), sets permissions, and only then mvs it over the real config path. A mv within the same filesystem is atomic — the config file is either the old complete version or the new complete version, never a half-written file, even if the process is killed mid-write. This matters because the config file holds the shared bearer token (hence chmod 600 — owner read/write only).
response=$(printf '%s' "$body" | curl -sS -w '\n%{http_code}' -X "$method" \
-H "Authorization: Bearer ${token}" \
--data-binary @- \
"${api_url}${path}")curl's --data-binary @- tells it to read the request body from stdin (- is the conventional "read from stdin" placeholder) instead of from a command-line argument. This was a real bug we hit: dpkg's full package list can be several hundred KB, and passing that as a normal --data "$body" argument blew past the operating system's argument-size limit (ARG_MAX), failing with Argument list too long. Piping it in via stdin has no such limit.
The -w '\n%{http_code}' flag appends the HTTP status code after a newline, which is how the function later splits the response into status and body:
status="${response##*$'\n'}"
body="${response%$'\n'*}"${response##*$'\n'} and ${response%$'\n'*} are bash's built-in "trim from string" operators — no need to call out to sed/awk for simple cases:
| Form | Meaning |
|---|---|
${var#pattern} |
Remove the shortest match of pattern from the front |
${var##pattern} |
Remove the longest match of pattern from the front |
${var%pattern} |
Remove the shortest match of pattern from the back |
${var%%pattern} |
Remove the longest match of pattern from the back |
So ${response##*$'\n'} ("greedy match from the front, up to and including the last newline, remove it") leaves just the last line (the status code), and ${response%$'\n'*} ("shortest match from the back") leaves everything before that last newline (the body).
These are the executables an admin actually runs. They all follow the same skeleton, then diverge based on what they do.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/common.sh"Bash scripts don't automatically know their own location the way, say, __dirname works in Node. ${BASH_SOURCE[0]} is the path the script was invoked with (which might be relative, like ./collect.sh); dirname strips the filename, leaving the containing directory; cd ... && pwd resolves that to an absolute, symlink-free path. This pattern is what lets every script in bin/ reliably find lib/common.sh and its sibling scripts regardless of the admin's current working directory.
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/../lib/common.sh"ShellCheck is the standard bash linter (we run it via npx shellcheck -x). SC1091 fires because ShellCheck can't always statically resolve a dynamically-built source path. Since we know the file exists, we suppress just that one warning rather than disabling linting wholesale. If you ever see # shellcheck disable=SC____ in this codebase, it's a narrow, intentional suppression — check the comment next to it (or https://www.shellcheck.net/wiki/SC____) for why.
OS_PACKAGES_FILE=$(mktemp)
trap 'rm -f "$OS_PACKAGES_FILE"' EXITtrap 'command' EXIT registers command to run automatically when the script exits — whether it finishes normally, hits an error under set -e, or is killed by a signal. This is collect.sh's way of guaranteeing its temporary file gets deleted no matter how the script ends, without needing a cleanup call at every possible exit point.
collect.sh only has one optional flag, so a simple loop suffices:
for arg in "$@"; do
case "$arg" in
--non-interactive) NON_INTERACTIVE=true ;;
*) die "Unknown argument: $arg" ;;
esac
doneschedule-cron.sh has flags that take a value (--hour 9), so it uses the more general while/shift pattern instead:
while [ $# -gt 0 ]; do
case "$1" in
--hour) HOUR="$2"; shift 2 ;;
--minute) MINUTE="$2"; shift 2 ;;
--remove) REMOVE=true; shift ;;
*) die "Unknown argument: $1" ;;
esac
done$# is "how many arguments are left"; shift 2 discards the first two (the flag and its value) so the loop can look at $1 again on the next iteration. shift alone (equivalent to shift 1) discards just the flag.
collect_projects() {
...
while [ "$i" -lt "$count" ]; do
...
jq -n --arg repoName "$repo_name" ... '{repoName: $repoName, ...}'
i=$((i + 1))
done
}
PROJECTS_JSON=$(collect_projects | jq -s '.')collect_projects prints one JSON object per line for each configured project (or skips it with a warning to stderr — see below). Piping that stream into jq -s '.' ("slurp" mode) wraps all those separate JSON values into a single JSON array. This "emit one item at a time, slurp at the end" pattern shows up for projects, pinned services, and OS packages — it keeps each function simple (it doesn't need to build an array itself, track commas, etc.) while still producing correct JSON.
i=$((i + 1)) is arithmetic expansion — $(( )) evaluates a C-style arithmetic expression and substitutes the result, used here as a manual "for" loop counter since bash's C-style for ((i=0; i<n; i++)) loops don't local-scope cleanly the same way across all shells this might run under.
An early version of collect.sh had a bug worth knowing about, because it's a common bash trap: under set -e, a command that "fails" (returns non-zero) for a perfectly normal reason — like "no ClamAV scan log exists yet" — can silently kill the entire script.
clamav_last_scan_at() {
if [ -f "$CLAMAV_SCAN_LOG" ]; then
tail -n 1 "$CLAMAV_SCAN_LOG" 2>/dev/null | awk '{print $1}'
fi
return 0
}That trailing return 0 is load-bearing. Without it, if the if condition is false (log doesn't exist yet — a totally normal state), the function's exit status is whatever the last executed command returned — here, the failed [ -f ... ] test itself — and CLAMAV_LAST_SCAN_AT=$(clamav_last_scan_at) would abort the whole script under set -e. The fix is to make these "this might legitimately find nothing" functions always explicitly return 0.
The same class of bug applies to short-circuit chains like:
[ "$CLAMAV_INSTALLED" = true ] && [ "$CLAMAV_DAEMON_RUNNING" = true ] && return 0This is fine as a standalone statement (only used for its side effect of an early return), but assigning the result of a function built entirely from &&/|| chains to a variable is risky under set -e unless every branch is guaranteed to end in something that returns 0.
read -r -p "CVE Tracker API URL (e.g. https://cve-tracker.example.com): " API_URL
...
read -r -s -p "SERVER_PLUGIN_TOKEN: " TOKENread -p "prompt" VAR shows a prompt and stores the typed line in VAR. -r disables backslash escape processing (almost always what you want — without it, a path like C:\Users\foo would get mangled). -s (silent) is added for the token prompt so it isn't echoed to the terminal, the same way password prompts work.
collect.sh uses this same read -p ... (Y/n) pattern for the ClamAV setup offer, guarded so it only ever fires on an interactive terminal, never from cron:
maybe_offer_clamav_setup() {
[ "$NON_INTERACTIVE" = true ] && return 0
is_interactive || return 0
...
read -r -p "We have detected ClamAV is not installed or not configured! ..." reply
case "$reply" in
[nN]*) return 0 ;;
*) "${SCRIPT_DIR}/setup-clamav.sh" ;;
esac
}schedule-cron.sh and setup-clamav.sh both generate files (a cron entry, a scan-wrapper script) using a heredoc:
cat > "$CRON_FILE" <<EOF
# Managed by cve-tracker server-plugin's schedule-cron.sh — do not edit by hand.
${MINUTE} ${HOUR} * * * root ${REPORT_SCRIPT} >> ${LOG_FILE} 2>&1
EOFEverything between <<EOF and the matching EOF is fed to the command as if it were typed at stdin — here, redirected by cat > into a file. Because the delimiter is unquoted (<<EOF, not <<'EOF'), bash does expand variables inside it, which is how ${MINUTE}/${HOUR}/etc. get substituted into the generated cron line.
setup-clamav.sh's heredoc needs the opposite behavior for some lines — it's generating a script, and doesn't want ${SCAN_LOG} expanded by the outer shell twice (once now, once when the generated script runs):
cat > "$SCAN_WRAPPER" <<EOF
#!/usr/bin/env bash
set -euo pipefail
FINDINGS=\$(clamscan -r --infected --no-summary / 2>/dev/null | wc -l || true)
printf '%s %s\n' "\$(date -Iseconds)" "\${FINDINGS}" >> "${SCAN_LOG}"
EOFNotice \$(...) and \${FINDINGS} — the backslash escapes the $, telling the outer shell "don't expand this now, leave it literal so the generated script expands it when it runs." Compare that to ${SCAN_LOG} on the last line, deliberately left unescaped because we do want the outer shell's current value baked into the generated file.
if ! config_exists; then
exec "${SCRIPT_DIR}/init.sh"
fiexec replaces the currently-running script with the new command, in the same process, rather than starting a child process and waiting for it. manage.sh uses this so that "not configured yet → run init.sh" behaves exactly as if the admin had run init.sh directly (same exit code, no extra shell hanging around).
You'll see two different ways of handing JSON to jq throughout collect.sh:
--argjson pinnedServices "$PINNED_SERVICES_JSON" # small: pass as a normal argument
--slurpfile osPackages "$OS_PACKAGES_FILE" # large: pass as a file path instead--argjson embeds the value directly into jq's command line — fine for small arrays like projects or pinnedServices (an admin manually configures a handful of these). osPackages can be a full dpkg listing, thousands of entries — the same ARG_MAX problem as the curl case above. --slurpfile name file instead has jq read the JSON straight from disk, so the shell's argument-size limit never comes into play. One quirk: --slurpfile always produces an array of every JSON value found in the file (in case there's more than one), so a file containing a single JSON array [...] becomes $osPackages = [ [...] ] — hence $osPackages[0] when building the final payload.
[ "$(id -u)" -eq 0 ] || die "setup-clamav.sh must be run as root"id -u prints the current user's numeric ID; root is always 0. Any script that installs system packages or writes to /etc/cron.d checks this up front and fails fast with a clear message, rather than letting individual apt-get/mv commands fail with a more confusing "permission denied" partway through.
Bats ("Bash Automated Testing System") lets you write tests for shell scripts using near-normal bash syntax, with one extra keyword: @test. There's no separate DSL to learn — a .bats file is a bash script.
@test "config_get reads a nested value" {
write_config '{"projects": [{"repoName": "my-repo"}]}'
run config_get '.projects[0].repoName'
[ "$status" -eq 0 ]
[ "$output" = "my-repo" ]
}Each @test "description" { ... } block is one test case. Inside it, run <command> executes the command without letting a failure abort the test (unlike normal set -e behavior) and captures two special variables for you to assert against:
$status— the command's exit code$output— its combined stdout+stderr
setup() {
common_setup
}
teardown() {
common_teardown
}Bats automatically calls a function named setup before every @test in the file, and teardown after every one — the same before/after-each pattern you'd find in most testing frameworks. We use this to create a fresh scratch directory and config file per test (common_setup in test_helper.bash), so tests never touch the real /etc/cve-tracker-plugin/config.json and never leak state between test cases.
load 'test_helper'load is bats' version of sourcing a shared file — it pulls in test/test_helper.bash, which defines common_setup/common_teardown, make_test_repo (spins up a throwaway git repo to point collect.sh at), write_config, and start_mock_api/stop_mock_api.
report.sh needs to talk to a real HTTP server to be tested meaningfully (rather than just checking it tried to call curl). test/fixtures/mock_api.py is a tiny Python HTTP server that stands in for the real CVE Tracker API: it checks the bearer token, can be told to return a specific status code, and otherwise echoes back a canned success response.
start_mock_api() {
local port
port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')
python3 "${PLUGIN_ROOT}/test/fixtures/mock_api.py" "$port" "$expected_token" "$forced_status" &
MOCK_API_PID=$!
...
}Binding to port 0 asks the OS to pick any free port, which the Python snippet then reports — this avoids tests colliding with each other or anything else running on a fixed port. Appending & runs the server in the background; $! captures the PID of that just-started background process, which stop_mock_api later uses to kill it in teardown.
Bats isn't installed system-wide in this environment; run the suite via npx, which downloads and caches it on first use without needing sudo:
cd submodules/server-plugin
npx --yes bats test/You can also lint the scripts themselves the same way, since shellcheck isn't installed system-wide either:
npx --yes shellcheck -x lib/common.sh bin/*.sh