Skip to content

Bulwark — Linux Host Security Scanner

TL;DR

Bulwark is a Tauri + Rust desktop app that scans a Linux host for security misconfigurations, intrusion indicators, and hardening gaps, using a native Rust engine that evaluates declarative YAML rules against collected system facts, then explains findings in plain language with a one-line fix. The single most important design decision: rules are native and declarative (a Sigma/Falco-style condition DSL), not a wrapper around Lynis/rkhunter — so the rule set can grow without recompiling, stays open to community contribution, and can cover attack patterns (like tunnel-service egress) that no existing tool names as a control.

Status update

This document was written before implementation and has been revised to describe what actually shipped, not just what was planned. As of v0.1:

  • 65 rules across all 11 planned categories (SSH/remote access, persistence, privilege escalation, defense evasion, credential/secrets exposure via filesystem permissions, network egress, kernel/sysctl hardening, logging/auditing, accounts & services, rootkit/malware indicators, file integrity), plus a dedicated 20-rule AI agent-security pass.
  • Both front-doors work end-to-end: bulwarkctl (scan, rules list/validate, history) and bulwark-app (Tauri v2 + React GUI), sharing one bulwark-core engine and one local SQLite store, exactly as designed in §4.
  • Real ClamAV integration — not a placeholder: live streamed per-file scan progress, real engine/database version and staleness reporting, and a distro-aware install command shown when ClamAV isn't installed at all.
  • File-integrity monitoring for a curated set of security-relevant files, plus a background monitoring loop that re-scans on an interval and reconciles findings across runs.
  • A system tray icon so closing the window hides the app instead of killing the monitoring loop — verified live against the real org.kde.StatusNotifierWatcher D-Bus registration, not just "no error thrown."
  • A Lynis-style hardening index score on the Compliance view, computed the same way Lynis computes its own (skipped/privileged-only checks don't count against the score).
  • A profile system (OS + opt-in "needs") is now built into the rule/collector model, with macOS and Windows collector skeletons wired end-to-end but not yet real — see §17.

Sections below are the original design reasoning, updated in place where the as-shipped decision differs from the draft (notably §16's color decision). Section numbers are preserved since other project docs (AGENTS.md) reference them by number.


1. Problem

What's broken today

Linux desktops are compromised through a well-worn, well-documented playbook: persistence via a rogue systemd unit tunneling remote access out through a service like ngrok, a remote-desktop service (VNC) left exposed with no password, SSH brute-forced after password auth gets quietly re-enabled, browser/OS-keyring credential theft, and exfiltration of cloud and API secrets sitting in plaintext .env files and shell history. Every one of these leaves static, after-the-fact traces — but is rarely caught at the time, because the tools that could catch it (Lynis, rkhunter, auditd, Wazuh) are individually solid yet CLI-only, snapshot-oriented, and not something a non-SOC developer actually installs and reads day to day. The gap isn't detection technology — it's that none of it is a friendly, GUI-native tool this audience will actually run.

Who feels it

  • Primary: solo Linux desktop developers and AI power users — people running increasingly capable local tooling and AI agents on their own machines, with no SOC, no existing HIDS deployment, but technical enough to act on a finding once it's explained.
  • Secondary: future OSS adopters with the same profile, across more distros than just Debian/Ubuntu.

Why now

The research groundwork is already done — a checklist grounded in Lynis's test categories, MITRE ATT&CK, and HackTricks/linPEAS (research/2026-07-11-linux-security-checklist/report.md) is directly enumerable into rules today. The marginal cost of starting now vs. later is pure execution time, not more research.


2. Goals and non-goals

Goals (ranked)

  1. Catch real-world intrusion indicators with plain-language, actionable output — breadth grounded in established frameworks (Lynis, MITRE ATT&CK Persistence/Credential Access/Defense Evasion, HackTricks/linPEAS), not a narrow, ad hoc list. See the research page.
  2. Be genuinely usable by a non-SOC developer — GUI-native, explains why a finding matters and how to fix it.
  3. Ship as a real, installable, personal-tool-first OSS project.deb/.rpm/AppImage builds, without needing a team to maintain it.

Update: all three goals were met for v0.1 — see the Status update above and the research page for the sourced comparison.

Non-goals (explicit)

  • Real-time eBPF/syscall monitoring in v1 (Falco-class complexity) — v1 is on-demand/periodic scanning, "Lynis with a GUI."
  • A full EDR/antivirus replacement — shells out to the system's own ClamAV installation for signature-based malware rather than reimplementing it.
  • Wrapping Lynis/rkhunter as a backend — native Rust checks only (§13, Option A).
  • Working macOS/Windows support in v1 — Linux desktop only, Debian/Ubuntu-first packaging, for actual detection coverage. The rule/collector model is now OS-aware (§17) precisely so this can change without an engine rewrite, but no macOS/Windows collector does anything real yet — there's no machine to build and verify one against in this project's current environment.
  • A hosted/cloud dashboard or telemetry phone-home — fully local, no data leaves the machine, ever.
  • Sandboxed untrusted-code execution and autonomous "agents" are explicitly out of v1 scope, but the architecture (crate boundaries, a generic executor pattern, Channel-based event streaming) is deliberately shaped so adding them later is a new workspace member, not a rewrite. See §4 and §14. Still true as of this update — neither has been started.

Success metrics

  • Rule coverage — v1 ships rules across all 11 categories in the research checklist. Met: 65 rules, 11/11 categories.
  • Time-to-first-finding — an unprivileged baseline scan completes and renders results in under 10 seconds; this explicitly excludes any time spent on a human entering a pkexec/sudo password, which is outside the app's control (see §10).
  • Dogfooding — a fixture set of known attack-pattern indicators (rogue systemd persistence, exposed VNC, re-enabled SSH password auth, browser credential exposure, etc.) is encoded as rule unit tests and run against real machines.

3. Constraints

ConstraintValue / limit
DeadlineNone fixed — personal tool sharpened into OSS, no external deadline pressure
Team / headcountSolo-maintained
Budget$0 — OSS; distribution via GitHub Releases
Existing stackTauri + Rust backend, React + Vite frontend. Cargo workspace ships both a GUI (bulwark-app) and a CLI (bulwarkctl) over the same bulwark-core library, so headless/SSH-only boxes are scannable without a display session.
Compliance / securityNo formal compliance target, but findings reference CIS/MITRE ATT&CK IDs where applicable, leaving headroom for compliance-mapping later
SLANone — desktop app, no uptime commitment

4. High-level architecture

System context (C4 L1)

Container view (C4 L2)

bulwark-core is not one engine but four, sharing a finding model and a store. Each answers a question the others structurally cannot:

Why the agent scanner and the antivirus are not collectors. A collector is a fast, sub-second read of a fixed host path, evaluated against a boolean YAML condition. Neither of those two fits: av_scan shells out to a minutes-long ClamAV pass, and ai_scan walks a discovered, machine-specific set of project directories, matches secrets with capturing regexes to compute redaction spans, parses MCP JSON, and inspects files for invisible Unicode. None of that is expressible in the condition DSL, and forcing it in would mean either breaking the "a scan is under 10 seconds" budget or growing the DSL into a general-purpose language. They keep the shape of a rule (id, severity, plain-language explanation, one-line fix, references) without pretending to be one.

bulwark-core has zero UI/Tauri/CLI-specific code. Both bulwark-app (Tauri GUI) and bulwarkctl are thin front-doors over the same library crate — same collectors, same rule engine, same Finding/Rule/ScanRun model, same local SQLite history. A scan run from the CLI shows up in the GUI's history and vice versa, since they share one on-disk store rather than each keeping their own. This also means the CLI can ship and be dogfooded before the GUI is polished, and it's the only form factor that can reach a headless box (ssh host 'bulwarkctl scan') — the case that matters most for catching lateral movement on a local network.

Privilege model decision: the GUI uses pkexec with a bundled polkit policy (auth_admin, so each privileged scan prompts — chosen over auth_admin_keep because caching authorization for the generic org.freedesktop.policykit.exec action would leave a several-minute window in which any pkexec could run without a fresh prompt; for a security tool an explicit prompt per elevation is the right trade). The exact binary run as root is pinned app-side (resolve_cli_binary resolves only the sidecar beside the app executable, no env/PATH override), since pkexec's generic action does not itself pin the target. The CLI does not use pkexecpkexec depends on a running polkit authentication agent, which is normally GUI-session-bound and typically absent on a box reached only over SSH. Instead bulwarkctl requires the elevated subset to be run as sudo bulwarkctl scan --privileged; unprivileged checks run either way without elevation. This is a deliberate, resolved decision, not an open question — sudo has no GUI-agent dependency and is the one elevation path guaranteed to work identically in a local terminal and over plain SSH.

Since v0.1 (implemented, not just reserved): a system tray icon (apps/bulwark-app/src-tauri/src/tray.rs) keeps the app resident when the window is closed — the window's CloseRequested event is intercepted and the window is hidden rather than destroyed, so a background monitoring loop that's mid-interval keeps running. The monitoring loop periodically re-invokes the same bulwark-core scan path used by a manual "Scan" click and reconciles findings across runs (see §5).

Future extension seam (still not built, deliberately): bulwark-sandbox and bulwark-agent remain reserved as future workspace members, not started. bulwark-core's executor and event-streaming pattern (a Tauri Channel for the GUI, a plain iterator/stream for the CLI) is written generically enough that a sandboxed-execution job or an agent action could plug into the same pattern instead of requiring a parallel system — see §14.

Remote scanning over SSH (bulwarkctl scan --ssh [user@]host) lives in the CLI front-door (crates/bulwarkctl/src/remote.rs), never in bulwark-core — the core's no-network invariant (§10) is what backs the "fully local, no telemetry" claim, so anything that crosses the network is a front-door concern. It shells out to the system ssh/scp rather than bundling an SSH library, which reuses the operator's existing ~/.ssh/config, agent, known_hosts, and jump hosts and keeps a security tool from reimplementing auth; the host spec is always a separate argv element, never interpolated into a shell string. The engine is untouched: we run the same bulwarkctl scan --json on the far side and deserialize its stdout back into a ScanRun — the identical round-trip the GUI's pkexec path already depends on. Bootstrap is "prefer installed, else push": if command -v bulwarkctl/bulwark finds a binary, run it in place; otherwise verify the remote arch (uname -m) matches, scp this binary + rule pack into a mktemp -d, run with an explicit --rules-dir, and rm -rf it afterward. Because the findings table has no host column (the local DB is single-host by design), a remote scan must not persist into it — doing so would let reconciliation resolve local findings whose rules the remote run happened to evaluate. Remote runs therefore persist to an isolated per-host database (~/.local/share/bulwark/remotes/<host>.db), which keeps each host's reconciliation correct and the local dashboard pristine.

Autofixes (bulwarkctl fix …, plus ssh protect and ai redact) turn the safe, mechanical subset of each finding's one-line fix into a one-command remediation. The logic is in bulwark-core::remediation (filesystem-only, still no network): permissions.rs tightens over-permissive modes for ~/.ssh (user-scoped) and sensitive /etc files (root), and sshd.rs hardens sshd_config. Every fixer follows the same discipline the existing ssh_keys passphrase fix and ai_scan redaction already do — dry-run by default, reversible (permission changes record the prior mode; the sshd rewrite keeps a backup and validates with sshd -t before keeping it, rolling back on failure), never widens access, never follows a symlink, and fails loud. The sshd rewrite inserts an idempotent # BEGIN/END bulwark-hardening block at the top of the file so that under OpenSSH's first-value-wins semantics it beats any Include drop-in without editing them, and only writes a directive when the effective config is actually insecure per the matching BLWK-SSH-* rule. The two directives that can lock an operator out of a password-only box (PasswordAuthentication no, PermitRootLogin no) are gated behind an explicit --include-auth and excluded from fix all.


5. Data model

Rule is YAML source, not DB-stored — modeled here alongside the two real tables for the engine's sake, since a Finding is what you get when a Rule's condition matches a fact row from a given ScanRun.

Condition grammar (v1)

A condition is a boolean expression over the named fields a collector produces — no cross-collector joins in v1 (one rule reads one collector's output; matches Sigma's per-logsource scoping). Grammar: field references (sshd.password_authentication), comparison operators (==, !=, in, contains, matches for regex, and < > <= >= for numeric thresholds like password-aging policy), and boolean combinators (and, or, not), parenthesized for precedence — deliberately a subset of Falco's filter syntax, not a new language.

yaml
id: BLWK-SSH-001
title: SSH password authentication is enabled
category: ssh-remote-access
severity: critical
collector: sshd_config
condition: sshd.password_authentication == "yes"
explain: >
  PasswordAuthentication is set to "{{ sshd.password_authentication }}" in sshd_config,
  which allows brute-force login attempts.
fix: "Set 'PasswordAuthentication no' in /etc/ssh/sshd_config and restart sshd."
references: [CIS-5.2.10, ATTACK-T1110]

A collector's output is a flat map of fields ({password_authentication: "yes", permit_root_login: "yes", ...}), so writing a new rule against an existing collector never touches collector code, only YAML. Collectors that produce lists (listening ports, cron entries) expose them the same way, evaluated one row at a time. {{ }} interpolation applies to both explain and title — templating title too (not just explain) was added after list-shaped rules sharing one static title read as duplicates in the UI even though each row was a genuinely distinct finding.

Reconciliation (implemented)

A finding is considered "the same" across scan runs if its previously-stored context is a subset of the newly-collected context (store::is_context_subset), not exact equality. This tolerates a collector gaining new fact fields over time — extending a collector no longer silently breaks identity-matching for its existing rules and producing a spurious duplicate row, which is exactly what happened before this was fixed: login_defs.rs gained two new fields and the exact-string match immediately started duplicating BLWK-ACCT-002. On a match, both last_seen/scan_run_id and context are updated (previously only the former), so a finding's displayed context always reflects its most recent scan.

Access patterns

  • Read path: the UI/CLI queries the latest ScanRun's findings, grouped by severity/category; prior runs are diffed to show new-vs-resolved findings over time.
  • Write path: the scan engine emits Findings over a stream as they're produced (streamed, not batched); on completion, findings are persisted to local SQLite in one transaction.
  • Index strategy: local SQLite via Diesel, indexed on (rule_id, status) and (scan_run_id) — single-host scale, at most a few thousand rows; nothing heavier is needed.
  • Why an ORM. Queries go through Diesel's typed DSL rather than SQL strings, so the columns a query touches are checked against schema.rs at compile time: renaming a column fails the build at every site that needed updating, instead of at runtime on a user's machine. That is not hypothetical — it is how a column previously drifted out of sync with the code reading it. The only raw SQL left is PRAGMA and schema introspection, neither of which is a query over the schema.
  • Migrations are embedded and append-only (crates/bulwark-core/migrations/). A database already stamped with a migration will never re-run it, so editing one silently splits users into two different schemas depending on when they first installed. Add a new migration directory instead.
  • Foreign keys are enabled explicitly per connection. SQLite leaves them off by default, which had quietly made findings.scan_run_id → scan_runs.id decoration rather than a constraint.

Log-analysis pipeline (decode → detect → correlate)

The config scanner above answers "is this machine's state wrong right now." A second, parallel pipeline (bulwark-core::logs, driven by bulwarkctl logs scan) answers "what happened over time" — the class of intrusion signals that only exist in logs: SSH brute-force, invalid-user scans, sudo/su abuse, direct root logins. It is modeled on OSSEC's decode→detect→correlate design (the part of OSSEC that aged well and Wazuh kept) while deliberately avoiding OSSEC's dated internals (XML rule language, a single global backward-scanned event list, mutable counters on shared rule objects, frequency + 2 semantics).

Three stages, each stage reusing the config engine's machinery where it fits:

  • Source (logs::source) — a LogSource trait yielding normalized RawEvents. JournaldSource shells out to journalctl -o json (the shipped driver on a systemd host; no libsystemd linkage); SyslogLinesSource parses classic Mon DD HH:MM:SS host prog[pid]: msg from any reader, powering --from-file, tests, and a future offset-tracking file tailer.
  • Decode (logs::decoder) — YAML decoders under decoders/ own "get srcip/user out of this vendor's line": a coarse program bucket → optional cheap prematch guard → ordered capture-regex patterns (named captures become fact fields; the matched pattern's tags become the event's tags array). A decoded line is a Fact — the same BTreeMap<String, Value> collectors produce — so the detect stage is pure reuse.
  • Detect + correlate (logs::rule, logs::correlate, logs::run_log_scan) — YAML log rules under log-rules/ match decoded facts with the existing condition DSL, unchanged (tags contains "authentication_failed" is OSSEC's if_group, no new operator). A rule with no correlate block fires per matching event; with one, matches feed a keyed sliding-window correlator: (rule_id, group_key) → VecDeque<event_timestamp>, evict older than now − window_secs, fire when the window reaches count. That's an honest group-by (per-srcip, per-user), O(1) amortized per event, and shardable — not OSSEC's O(history) global scan. The clock is always the event's own timestamp, never wall-clock, so replaying a batch of old logs correlates exactly as it would have live, and every test is deterministic. suppress_secs gives per-key flood control; MissingField on a rule's condition is a non-match (log events are heterogeneous), not an error.

Output is a LogScanRun of LogFindings (event-shaped: timestamped, carrying the correlation key and count), persisted to their own log_scan_runs/log_findings tables (schema migration V3) and reconciled on (rule_id, group_key) — a recurring brute-force from one IP is one row whose occurrences climbs, not a flood of duplicates. Kept separate from the config scan_runs/findings because the two have genuinely different lifecycles (config findings persist until fixed; log alerts recur) and reconciliation identities.

Scope (v1): one-shot batch only, and threshold correlation (N events per key in a window). Deliberately deferred: sequence correlation (A-then-B across groups, e.g. "auth failures followed by a success"); continuous follow mode — which is just run_log_scan driven by a following JournaldSource, and is the concrete first job of the currently-empty bulwark-agent daemon; a persisted-offset file tailer; and any active response (log findings stay advisory, like every other Bulwark finding).


6. API / contracts

There's no network API — the "client" and "server" are the same process, exposed two ways.

Tauri IPC commands (GUI)

CommandPurposeAuth
scan_startKick off a scan; streams findings via Channel as they're producednone (local single-user)
scan_get_historyList past ScanRunsnone
finding_get_by_runFetch findings for a given runnone
finding_update_statusMark a finding acknowledged/resolvednone
rule_listList loaded rules (built-in + user-added), including any that failed to load, with explain/fix text for the Rules browsernone
privileged_collectRuns the subset of collectors that need root, via pkexecpolkit prompt
run_virus_scanStreams ClamAV progress (FileScanned/ThreatFound/Complete/Error) via Channelnone
clamav_infoReports installed engine/database version, or a distro-aware install command if absentnone
monitoring_get_status / monitoring_set_intervalQuery/configure the background re-scan loopnone
history_countTotal past scan runs, backing the sidebar's "N scans recorded" linenone

CLI commands (bulwarkctl)

CommandPurposeExit code
bulwarkctl scanRun unprivileged checks only, print a table to stdout0 clean, 1 findings ≥ medium, 2 findings ≥ critical (CI-friendly)
bulwarkctl scan --privilegedFull scan; must be run under sudo (see §4)same as above
bulwarkctl scan --jsonSame as scan, machine-readable outputsame as above
bulwarkctl rules listList loaded rules, including load failures0 / 1 if any rule failed to load
bulwarkctl rules validate <path>Lint a rule file without running a scan (used in CI for the bundled pack)0 valid / 1 invalid
bulwarkctl fim baselineRecord the current hashes of the watched files as the known-good baseline. Never runs automatically — a baseline taken after a compromise would enshrine it as clean0
bulwarkctl fim baseline --privilegedAlso baseline the root-only paths (/etc/shadow, /etc/sudoers); refuses unless run as root0
bulwarkctl historyList past ScanRuns (shared with the GUI's history)0
bulwarkctl logs scanDecode + correlate the current boot's journal (or --since=<spec> / --from-file <path>); --json for machine-readable output0 clean / 1 ≥medium / 2 critical, like scan
bulwarkctl logs rules list / rules validate <path>Inspect the log-rule pack; validate also cross-checks each rule's decoder: names a real decoder0 / 1
bulwarkctl logs decoders list / decoders validate <path>Inspect the decoder pack0 / 1

There is no traditional authn/authz layer — this is a single-user local app; the OS login session is the trust boundary, and pkexec (GUI) / sudo (CLI) are the only elevation gates (see §10).

Streamed event shape

json
{
  "event": "finding",
  "data": {
    "rule_id": "BLWK-SSH-001",
    "severity": "critical",
    "title": "SSH password authentication is enabled",
    "explanation": "PasswordAuthentication is set to 'yes' in sshd_config, which allows brute-force login attempts.",
    "fix_hint": "Set 'PasswordAuthentication no' in /etc/ssh/sshd_config and restart sshd.",
    "context": { "file": "/etc/ssh/sshd_config", "line": 42, "value": "yes" }
  }
}

Error shapes

Collector failures (e.g. sshd_config unreadable, elevation denied) surface as explicit collector_error events alongside findings, never a silent drop — a check that fails silently is worse than a check that doesn't exist, because it creates false confidence.


7. Sequence flow — happy path

The CLI path is identical minus the Tauri/React layer: bulwarkctl calls bulwark-core directly and prints each finding as it arrives instead of streaming it over a Channel. The background monitoring loop reuses this exact same path on a timer, so there is no separate "monitoring" code path to keep in sync with the manual scan path.


8. Failure modes

FailureLikelihoodImpactDetectionMitigation
Privileged collector's elevation denied or not run (sudo omitted on CLI, polkit prompt denied on GUI)MediumPartial scan — some categories skippedExplicit "N checks skipped (no privilege)" banner/line, never silentRe-run just the privileged subset without a full rescan
Rule file has invalid YAML/condition syntaxLow-medium (rises as community rules grow)That rule silently fails to loadRule-load validation at startup; rules_failed count surfaced in ScanRunbulwarkctl rules validate (§6); CI lint on the bundled rule pack
A collector hangs (e.g. a spawned process never returns)LowWhole scan stallsPer-collector timeout (5s default)Collector reported as timed-out; scan continues without it
Host has an unusual layout (non-systemd init, non-Debian distro)Medium (v1 targets Debian/Ubuntu)Irrelevant checks or false negativesEach collector declares its own applicability precondition (e.g. "requires systemd")Collector skips gracefully, excluded from coverage stats — never reported as false "clean"
A collector's fact shape changes (new fields added)Realized once, fixedWould silently duplicate existing findings on the next scanReconciliation tests (reconcile_tolerates_a_collector_gaining_new_context_fields)Subset-match reconciliation (§5), not exact-string context equality
The rule pack can't be found — mistyped --rules-dir, emptied directory, mispackaged buildRealized, fixedA scan of nothing reported as a clean host: exit 0, empty findings, no error — and persisting it resolved every open finding, wiping the dashboardrules_loaded == 0 is now a hard error in both front-doors (rules_dir_guard.rs)An explicit --rules-dir/BULWARK_RULES_DIR that isn't a directory fails outright rather than falling back to the auto-detected pack

That last row is the invariant of §8 in its purest form. "0 findings" from a scan that evaluated nothing is not a clean bill of health — it is the absence of an opinion, and it is indistinguishable from a genuinely healthy host, which makes it the most dangerous thing this tool can say. The log pipeline had guarded against it since it shipped; the config scan did not, and a rules directory that simply wasn't there produced a confident, silent, green result. Both front-doors now refuse.


9. Scalability

Reframed as rule-set and scan-performance growth, since there's no multi-tenant traffic.

  • Current load: single host, 65 rules.
  • Expected load: community-contributed rules could grow into the hundreds over time — Lynis's own documentation describes "hundreds" of individual tests, a directionally useful reference ceiling.
  • Breaking point: naively re-running a collector for every rule that references it duplicates work once dozens of rules share one collector (e.g. many SSH rules all reading sshd_config).
  • Scale-out plan: not distributed — collectors are memoized per scan run (collect once, evaluate N rules against the cached fact), and independent collectors run concurrently.

The config scan is cheap and bounded (tens of files, ~0.1s). The AI scan is the one that has to scale, because its input is the user's whole history: a developer with a year of Claude Code use has ~1,800 transcripts and ~900 MB of them, and every one gets run through a 261-rule secret pack.

The leading-wildcard trap (realized, fixed)

Most rules in the vendored gitleaks pack open with an optional variable-length wildcard:

text
(?i)[\w.-]{0,50}?(?:cohere|CO_API_KEY)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|:)…([a-zA-Z0-9]{40})
     ^^^^^^^^^^^^^ matches no useful text — and costs ~6000× the runtime

That prefix exists only to pull neighbouring characters into the reported match, but it is catastrophic for the regex crate: a pattern that begins with a variable-length character class has no literal prefix, so the engine cannot build a memchr/Teddy prefilter and must attempt a match at every byte offset, while the nested bounded repeats blow out the lazy-DFA cache until it falls back to a far slower engine. Measured on one real 4 MB transcript, a single such rule took 2.9 s; with the prefix stripped it took 0.5 ms and found exactly the same matches. Across ~40 candidate rules and ~1,800 transcripts that was the difference between a scan that finishes in seconds and one that pegs half the machine's cores for hours — the actual "Bulwark eats my CPU" bug.

secrets::drop_leading_wildcard removes that prefix when the pack is built, so the vendored rule file stays pristine and re-syncable with upstream gitleaks. The rewrite is sound because the prefix is optional and leading: for an unanchored search, PQ matches iff Q matches, so no detection can appear or disappear — only the start offset of the whole match moves, and that offset feeds nothing but overlap dedup, where a narrower span can suppress strictly fewer findings. The secret itself is capture group 1 and is untouched.

The danger of an optimisation like this is a scanner that silently stops finding keys, so it is pinned by a differential test (rewriting_a_pattern_never_changes_what_it_matches) that compiles both the original and rewritten form of every rule and asserts they capture identical secrets at identical offsets over a corpus built to make the pack fire. Anything not matching the exact expected shape — a required ({5,50}) prefix, an exact count, a rule with no capture group whose whole match is the reported secret — is left alone: a rule that stays slow is a bug, a rule that quietly stops matching is a vulnerability.

Validating the rule pack (and what that surfaced)

262 vendored regexes are not self-evidently correct, and until recently nothing checked that any individual rule still worked. every_bundled_rule_compiles proved a pattern parses — it said nothing about whether the rule could still catch its own key. Two tests, modelled on gitleaks' Validate(rule, tps, fps), close that:

  • True positives are generated, never written down. A secret is synthesised from each rule's own pattern (rand_regex, mirroring gitleaks' secrets.NewSecret), dropped into the kind of line it really leaks in, and the pack must report it. Every rule gets a real sample, the corpus re-syncs itself when the pack does, and — because a 40-character high-entropy literal sitting next to the word aws is indistinguishable from a live key to every scanner on earth, GitHub's push protection included — nothing secret-shaped is ever committed.
  • False positives are vendored (tests/data/gitleaks_false_positives.toml): the placeholders, documentation keys, wrong-length tokens and low-entropy dummies gitleaks records for each rule. A scanner that flags everything is as useless as one that flags nothing, and the way a rule usually breaks is by getting broader — which every "does it catch the key" test in the world will pass.

The first run of these tests failed, and each failure was a real defect:

DefectConsequenceFix
The pack's allowlists were never parsedEvery rule inherited gitleaks' false positives without gitleaks' suppression: curl -u "${user}:${pass}" reported as leaked credentials, Google's published AIzaSy… doc keys reported as live GCP keysAllowlist — the regexes/stopwords were shipping in secret_rules.toml and read by nobody
One allowlist regex silently failed to compilegitleaks writes RE2, where a literal {{ is legal; Rust's regex rejects it. The one pattern suppressing ${{ env.PASS }} was dropped by a filter_map(…ok())literalize_braces, plus every_allowlist_regex_compiles — a dropped allowlist is a silent false positive, which is why it's an assertion and not a log line
path conditions were ignorednuget-config-password means something in a nuget.config and nothing anywhere else; applied to every file, any sk_…-shaped string in a chat transcript was a "leaked credential"Rule::applies_to — the scan and redaction now both pass the artifact's real path

On a real home directory the result is ten fewer false positives — every one of them a placeholder or a test fixture quoted inside a transcript (-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIB..., a dummy ghp_0123…uvwxyz) — with no true positive lost.


10. Security & privacy

  • Authn: N/A — single local user; OS-level login is the trust boundary.
  • Authz: privilege boundary via pkexec/polkit (GUI) or sudo (CLI), scoped per-collector — root is only required for specific operations, never for the whole app.
  • Data classification: a finding's context can itself contain sensitive material (a finding may quote the exact secret pattern it detected). Never logged externally, never transmitted — local SQLite only, no network calls from bulwark-core by design (a hard invariant, not just a default).
  • Threat model: Bulwark is itself an attractive target if compromised — broad read access across the system, and elevated write access for any future "apply fix" action. Rule files (and any future "apply fix") are a supply-chain-sensitive surface; signed/provenance-checked rule packs are required before any "install rules from the internet" feature ships (see §14).
  • Known limitation — root-level compromise: Bulwark cannot defend against an attacker who already has root on the machine it's running on. Such an attacker can disable rules, tamper with the local SQLite findings store, or replace the Bulwark binary itself, exactly as easily as they'd disable any other local defense — a local-only tool cannot be tamper-evident against the privilege level it's trying to detect. This is a structural limit of the v1 design (no phone-home, see Non-goals), not an oversight; it's stated here explicitly rather than left implicit. A future opt-in mode that ships scan results to a destination the same attacker doesn't control (e.g. remote syslog) would close this gap but is out of v1 scope.
  • Audit logging: every privileged action is logged locally with timestamp and exact command, viewable in-app — useful for review, though subject to the same limitation above if the attacker already has root.

11. Observability

  • Metrics: none exported — no telemetry, by design (non-goal). Local-only counters (scan duration, rules loaded, collectors failed) shown in-app only.
  • Logs: local rotating log file (~/.local/share/bulwark/logs/), never transmitted.
  • Traces: N/A — single-process desktop app, not a distributed system.
  • Alerts: a desktop notification on new findings from a background monitoring run, via Tauri's notification API — implemented, not just an analog. No other alerting channel exists or is planned for v1.

12. Rollout plan

Reframed as release channels, since there's no %-traffic rollout for a desktop app.

StepWhatStatus
1bulwarkctl only, dogfood on real machines, including over SSH✅ Done
2v0.1 GitHub Release, CLI binary only (.deb/.rpm/tarball)bulwark-core stable, CLI builds clean
3bulwark-app (Tauri GUI) joins the release, full .deb/.rpm/AppImage line-up✅ GUI built, packaged, and dogfooded end-to-end including the pkexec privileged path and tray icon
4Public announce (README only, no paid promo)Not yet — repository is currently private

Shipping the CLI first validated bulwark-core — the actual hard part — fast, with far less UI work, and it's the only form factor that can reach a headless/remote box at all.

Rollback

Uninstalling via the package manager removes the binary only. The local SQLite findings DB and logs under ~/.local/share/bulwark/ are never auto-deleted, since scan history has ongoing forensic value — cleanup is always a deliberate user action, never automatic.


13. Alternatives considered

Option A — Wrap Lynis/rkhunter as a backend

  • Pros: instant access to Lynis's large, battle-tested test suite; far less initial engineering.
  • Cons: GPLv3 coupling; can't easily add checks Lynis doesn't have (e.g. tunnel-service egress detection — this project's most differentiated check); output-parsing is brittle against Lynis version drift.
  • Why rejected: the whole point is covering patterns no surveyed tool names as a control, and community extensibility is a ranked goal — a rule engine you can extend beats a report parser you can't.

Option B — Full policy language (OPA/Rego)

  • Pros: more expressive than a flat condition DSL, handles complex hierarchical policy composition, proven at scale in Kubernetes/cloud security.
  • Cons: steep learning curve for a "personal tool sharpened into OSS" whose contribution bar should be "add a YAML file," not "learn a new declarative logic language."
  • Why rejected: over-engineered for the actual rule shape needed (mostly flat "does fact X match condition Y" checks); Sigma/Falco's simpler condition-expression model covers the real cases with far less contributor friction.

Option C — Real-time eBPF monitoring (Falco-style) as v1

  • Pros: catches persistence/exfil at the moment it happens, not on the next scan.
  • Cons: major engineering lift (kernel-level event capture, an always-on daemon, much higher blast radius if buggy); works against goal #3 (ship something real, soon).
  • Why rejected: explicitly deferred to v2. Every indicator in the attack-pattern fixture set (§2) is still detectable after the fact from static host state — v1's periodic-scan model already covers what matters, without daemon complexity. Still deferred as of this update.

14. Open questions

  • [ ] Exact workspace crate boundaries for the future sandbox/agent extension (bulwark-sandbox, bulwark-agent) — still only reserved as a direction. Safely sandboxing untrusted code on Linux (namespaces/seccomp/cgroups, or a microVM approach) is a materially different privilege model than per-collector elevation, and deserves its own design doc once it becomes real scope.

  • [ ] Rule-signing/provenance story for community-contributed rules — still deferred until there's an actual external contributor, but §10 flags it as required before any "install rules from the internet" feature ships.

  • [ ] Whether findings context needs at-rest encryption in the local SQLite store, or whether OS-level disk encryption is the sufficient boundary — still leaning toward the latter, not yet decided.

  • [ ] Exact collector memoization/caching strategy across rules — flagged as necessary in §9, not designed in detail.

  • [x] A compliance-coverage view (HIPAA/ISO27001/PCI-DSS-style, mirroring Lynis's own compliance-testing angle)implemented, in two layers. The Compliance view groups findings by the references field every rule already carries, and shows a Lynis-style hardening index headline score (passing / evaluated, excluding privileged-and-skipped checks — same convention Lynis itself uses so skipped tests don't count against the score). On top of that, bulwark-core::compliance carries real, editorially-owned mappings onto PCI DSS 4.0.1, the HIPAA Security Rule and ISO 27001:2022 (control definitions embedded via include_str!, covering 58 of 65 rules), scored per standard and surfaced through the compliance_report command in "All checks → Framework compliance".

    Three constraints shape that view, and all three are deliberate rather than incidental:

    1. A skipped check is not a passing one. A control is scored only when one of its mapped rules is in the scan's rules_evaluated set; otherwise it is NotAssessed and leaves the denominator entirely. The failure this prevents is an unprivileged scan — which skips most collectors — scoring higher than a privileged one, i.e. the score rising as visibility falls. That set was in-memory-only until the 2026-07-19-000000_scan_rules_evaluated migration persisted it; a database predating that migration reports no evidence and is rendered as "not assessed", never as a clean bill of health.
    2. No score is shown without its denominator and scope. StandardReport carries scope_note, mapped_controls, catalog_size, assessed and not_assessed, and the UI renders them in the same glance as the percentage. None of these standards is mostly host-testable — HIPAA and ISO 27001 are largely administrative — so a bare "PCI DSS 87%" would be a compliance verdict a host scanner cannot produce.
    3. HIPAA's standard / required / addressable distinction is preserved. Under 45 CFR §164.306(c)–(d) an addressable implementation specification may be satisfied by a documented equivalent alternative, so a failing addressable control is a prompt to produce that documentation rather than automatically a violation — and the UI must not present it identically to a failing required one.

    CIS is mapping only. CIS control IDs appear as coverage annotations on rules and are never given a compliance percentage or level, which CIS's non-member terms forbid representing. MITRE ATT&CK likewise carries no ratio: it is a technique taxonomy, not a compliance standard.

  • [ ] An sshd -T (effective-config, defaults-resolved) collector path — the current sshd_config collector only sees directives explicitly written to the file, so a directive relying on its OpenSSH-compiled-in default is invisible to every SSH rule. Needs a real sshd binary to dogfood against; not yet done.

  • [ ] Real macOS/Windows collectors — see §17. Blocked on access to a real machine for each OS to build and verify against; skeletons exist, nothing behind them does yet.

  • [ ] Persisting the GUI's active profile ("needs") selection across restarts, and threading it through to the background monitoring loop (which currently always scans with the default profile — see §17 and monitoring.rs's run_tick).


15. Decisions referenced

  • ADR-0001 — Native Rust rule engine over wrapping Lynis/rkhunter (§13, Option A)
  • ADR-0002 — Sigma/Falco-style condition DSL over OPA/Rego (§13, Option B)
  • ADR-0003 — Tauri Channels over the global event system for finding-stream delivery, per Tauri's documented guidance on ordered, high-throughput streaming
  • ADR-0004 — sudo (not pkexec) as the CLI's elevation path, since pkexec depends on a GUI-session-bound polkit agent that's typically absent over plain SSH (§4)

16. Visual design & interaction language (addendum)

Not part of the standard template — included because "a GUI-native alternative to Lynis" is core to this project's reason for existing, so the interaction model is a first-class design decision, not a styling afterthought.

Primary color: a deliberate blue/green synthesis (teal), not a coin flip between the two. The draft version of this document argued for plain blue, reasoning that green was already claimed as a status color (pass/safe) across nearly every dashboard convention and would collide with severity color-coding. In practice, a pure blue read as generic and undifferentiated once built. The shipped primary is a deep teal (oklch hue ~194 — roughly halfway between blue's ~255 and green's ~149 in this token set): it keeps blue's calm, professional read while leaning toward green's trust association, and sits far enough in hue from the "resolved" status green (~149) that the original brand/status collision concern still doesn't reoccur, even though both are technically in the blue-green family. Flat/no-gradient throughout the app UI (the docs site's hero text is the one deliberate exception, as a way to differentiate marketing surfaces from the product itself).

Motion principles:

  • Findings stream in one at a time as a scan runs (via the Tauri Channel in §4); each animates in with a short (150–200ms) fade + slide — not a jarring pop — so a fast scan doesn't read as an unreadable flicker of rows.
  • Status changes (e.g. marking a finding "resolved") animate the row's color/icon transition rather than an instant swap, so the state change registers as an event, not a silent re-render.
  • No animation on content the user is actively reading or scrolling — motion is reserved for state transitions, never decorative idle motion.
  • prefers-reduced-motion is respected everywhere — all micro-animations degrade to instant state changes when the OS signals reduced motion.

As shipped: shadcn/ui (Radix primitives) + Tailwind v4, styled entirely off the token system above rather than shadcn's default palette. Screens: Overview, Compliance, Antivirus, Agent Security, File integrity, Rules, Analytics, Settings — a frameless window with a custom title bar, and a system tray icon for background residency (§4). A dedicated animation/motion tuning pass (beyond the principles above being followed ad hoc per component) remains open — see AGENTS.md's current-status notes.


17. Profiles — OS and needs (addendum)

Also not part of the standard template — added when the rule/collector model was extended to be OS-aware, since it changes how every future rule and collector gets authored, not just one feature.

Every rule and collector in v0.1 was implicitly Linux-only. This section documents the extension that makes "implicitly" explicit and adds a second, orthogonal axis on top of it — without changing behavior for any of the original rules by default.

Two axes, not one

  • os (hard filter) — which operating system(s) a rule or collector targets: linux, macos, or windows. A rule tagged os: [macos] never runs on a Linux host, full stop, regardless of anything else. Defaults to [linux] on a Rule with no os field, so every pre-existing rule file needed zero changes.
  • profiles (opt-in "needs") — free-form tags like server, desktop, developer that a user opts into. A rule with no profiles tag is universal and always runs (once its os matches). A rule tagged profiles: [server] only runs when "server" is in the active profile's needs — e.g. BLWK-LOG-003 (kernel-level process accounting) is real but mostly a server-hardening concern, not something a laptop user needs surfaced by default. There's deliberately no fixed enum of valid tags — adding a new need is a YAML change, matching the "no Rust required to add a rule" philosophy that already governs everything else about the rule format.
yaml
id: BLWK-PERSIST-003
title: Unexpected LaunchAgent/LaunchDaemon found
collector: launchd_persistence
os: [macos]
profiles: [desktop]
condition: recognized == false
...

Where the gate lives

Both axes are enforced in engine::run_scan, via a Profile { os, needs } passed alongside the existing privileged: bool parameter:

  • Rules are filtered before anything else: rule.os.contains(&profile.os) && (rule.profiles.is_empty() || rule.profiles.iter().any(|p| profile.needs.contains(p))).
  • Collectors get a second, independent gate: Collector::supported_os() (default [linux]) is checked before is_applicable()/collect() are ever called — so a macOS-only collector's collect() is structurally unreachable on a Linux host, not just conventionally skipped by a well-behaved rule author. Defense in depth: even a rule that's mistagged can't cause a collector to run facts-gathering code for the wrong OS.

Profile::default() (host OS, no opted-in needs) reproduces the exact pre-profile scan behavior bit-for-bit on a Linux host, which is why this shipped with zero regressions against the existing rule test suite.

What's real today, and what's a skeleton

  • The schema, engine filtering, and collector gate are fully real — tested (crates/bulwark-core/src/engine.rs's rule_matches_profile, plus per-collector tests), and live in both front-doors: the CLI's bulwarkctl scan --needs server,... flag and the GUI Dashboard's need-toggle chips (currently just "Server," since that's the only tag with a real rule behind it).
  • macos_launchd::LaunchdPersistenceCollector and windows_persistence::WindowsRunKeysCollector are intentionally-honest skeletons: they declare their target OS, is_applicable() unconditionally returns false, and collect() returns an explicit "not yet implemented" error rather than fabricating plausible-looking facts. There is no macOS or Windows machine available in this project's environment to build and verify a real plist parser or registry/Task-Scheduler reader against — a stub that silently claimed to work would be worse than one that's honest about not having run for real yet. Two demonstration rules (BLWK-PERSIST-003, BLWK-PERSIST-004) exist so the OS-tagging pipeline has real end-to-end test coverage (rule loads, validates, and is correctly excluded from a Linux scan), even though neither can fire on any machine this project currently builds on.
  • Turning a skeleton into a real collector is "implement collect() against real OS APIs, verified on a real machine of that OS" — it does not require touching the engine, the rule schema, or any existing Linux collector.

Not done as part of this

  • Only one rule (BLWK-LOG-003) carries a profiles tag today; a full pass deciding which of the other 55 Linux rules are genuinely server-only, desktop-only, etc. is future work, not attempted here — mistagging a broadly-relevant rule as opt-in-only would be a real regression (it would silently stop running for most users by default), so this was deliberately left conservative rather than guessed at in bulk.
  • The GUI's active-needs selection isn't persisted across restarts, and the background monitoring loop doesn't know about it yet (see §14).
  • CI does not yet build/test on macOS or Windows runners — see the research on cheap options for that (GitHub Actions macos-latest/windows-latest runners, free once this repository is public) — so the two skeleton collectors are only ever compiled on Linux CI today, never actually exercised on their target OS.

Released under the Apache License 2.0.