ubuntu-sec-agent
v0.31.0
Published
面向 Ubuntu 的安全检测/加固/防护 skill,供 Claude Code / Codex 等 AI agent 调用,支持 npm/npx。命令:scan / run / status / harden / prune / fw-log / fw-rules / fw-optimize / fw-plan / fw-monitor / fw-verify / sensor-setup / sensor-collect / sensor-status / sensor-tune / correla
Maintainers
Readme
ubuntu-sec
AI-agent security toolkit for Ubuntu. Four-layer detection, guided hardening, and OpenSnitch firewall analysis — as a CLI and a Claude Code skill. It generates remediation scripts but never runs them, and sanitizes every output before it reaches an LLM.
English · 中文
ubuntu-sec turns an AI coding agent (Claude Code, Codex, and similar) into a careful Ubuntu security auditor. It detects intrusions and misconfigurations across four layers, generates hardening scripts for an administrator to review, and analyzes OpenSnitch firewall logs and rules — under one strict design:
Two layers. Deterministic scripts (no LLM) collect read-only facts and parse them into structured findings; the host agent then judges each finding ✅ healthy / ❓ unverified / ❗ dangerous against an explicit criteria contract. Deterministic where the machine is authoritative, LLM only where judgment is needed.
Installation
npm / npx (standalone CLI)
npx ubuntu-sec-agent --help # try without installing
npm i -g ubuntu-sec-agent # install globally → `ubuntu-sec` command
ubuntu-sec tools verify # verify bundled-tool integrityClaude Code plugin
This repository is its own plugin marketplace:
/plugin marketplace add sdsrss/ubuntu-sec
/plugin install ubuntu-sec
/reload-pluginsThen trigger with /ubuntu-sec:ubuntu-sec (the skill is defined in root SKILL.md).
Codex CLI
Codex has no plugin marketplace — install the npm package globally and Codex can call ubuntu-sec scan / fw-log / fw-rules as ordinary commands. Add a short note to your AGENTS.md pointing at the triggers and the safety contract in SKILL.md.
Red line for Codex users: the generated
fix/harden/fw-block/fw-optimize/collect-sudo.shscripts are generated, never auto-executed. Do not let Codex--full-auto(or any auto-approve mode) run them; a human must review and run them manually. Collection and analysis (scan/fw-log/fw-rulesthemselves) are read-only and safe to automate.
Requirements: Ubuntu 22.04 / 24.04 · python3 ≥ 3.10 with PyYAML (pip install pyyaml or apt install python3-yaml) · node ≥ 18. Third-party scanners (lynis / debsums / yara) are installed via apt; their source/version/GPG details are recorded in tools/manifest.json and not bundled in the npm package. A YARA baseline ruleset ships with the package and is sha256-verified by ubuntu-sec tools verify. OpenSnitch is required only for fw-log / fw-rules.
Features
Twenty-six commands, one privacy-safe workflow:
| Command | What it does |
|---|---|
| ubuntu-sec scan [--layer app\|sys\|kernel\|net] [--no-verify] | Four-layer security analysis: unprivileged read-only collection → sanitize → parse → judge (host agent) → report. Root-only checks are emitted into collect-sudo.sh for an administrator to run. --no-verify skips the tools verify integrity precheck that otherwise runs before every scan (offline/debugging only, at your own risk) — it is consumed by the Node wrapper and never forwarded to scan.py. |
| ubuntu-sec harden | Locate the latest scan findings and print hardening-script generation guidance — script generation itself is a host-agent judgment step (gen_script.py, never this command), reviewed by the multi-agent pipeline, never auto-run. |
| ubuntu-sec fw-log [--connections FILE] | Analyze OpenSnitch connection logs → suspicious-connection report + fw-block.sh generation guidance. Without data, emits a read-only collection script; --csv-dir DIR ingests a GUI CSV export (for in-memory DBs) through the same sanitize + leak-gate pipeline. Also reconciles a GUI-vs-daemon firewall config-posture comparison; add --daemon-config FILE (a sudo-copied default-config.json) to complete the daemon side, otherwise it's reported partial — with no --connections/--csv-dir data, --daemon-config alone is a legal posture-only run that emits just the config-posture finding. When a proxy is present (a detected xray/v2ray connection, parsed v2rayN config endpoints, or an explicit --proxy-ports), a proxy-aware profile is produced automatically (egress drift vs. v2rayN config, direct-connection bypass, tunnel blindspot, proxy-port client visibility — the last degrades to partial since loopback traffic isn't seen by OpenSnitch); --proxy-ports PORTS additionally supplies the port set the proxy-port-client pillar checks against. Independent of proxy presence, it always emits three egress-hygiene candidate checks (DNS-bypass, QUIC-candidate on UDP/443, bare-IP direct connects — candidates can be zero) and a per-process dispersion profile (host/IP/port fan-out, sorting-hint only). |
| ubuntu-sec fw-rules [--rules-dir DIR] | Static analysis of OpenSnitch rules (mechanical issue classification incl. loose host-regexp patterns, three-way disk/DB/daemon diff). --identity FILE (a sanitized process-identity facts file from collect) joins process-identity checks — a rule's anchored binary path no longer existing, or an allow rule anchored to a user-writable binary directory. |
| ubuntu-sec fw-optimize | Locate the latest fw-log/fw-rules findings and print firewall-optimization script generation guidance — locate only, same contract as harden. |
| ubuntu-sec fw-plan [--findings F] [--output DIR] [--out DIR] [--apply] [--rules-dir D] | Evidence-driven rule-draft generation: reads the latest fw findings, splits observations into three mutually-exclusive classifications (proxy_core / direct / high_risk_direct), and emits installable-shaped OpenSnitch rule drafts + a plan-manifest.json (per-draft evidence citations, sanitize-placeholder tokens, blocked_on_precedence / low_confidence_window flags) — drafts only, never installed, never touches /etc; installing is a manual, human-reviewed step. Allow drafts also carry evidence-direction flags, judged per destination on the direct / high_risk_direct branches: destinations whose every observation was a deny are split into a held NNN-allow-<slug>-denied.json sibling (enabled:false), the surviving draft is flagged denied_targets_split_off, and evidence_has_denied is raised whenever the process has denied connections at all — including ones outside what this rule covers, because a destination seen with a DNS name and the same one seen as a bare IP are two different strings to this gate.--apply additionally emits two 0700 scripts into the same plan/ directory: apply-rules.sh (installs the installable drafts into --rules-dir) and rollback-rules.sh. Both scripts and every installable draft are recorded in sha256sums.txt — the draft JSONs are what actually reach the rules directory, so signing only the two .sh files signs the wrapper and not the cargo; the apply script separately re-checks each draft's hash against generation time before touching anything, and refuses the whole batch if a draft was edited after it was generated.Backups go to rules-backup-ubuntu-sec/<per-run stamp>/, a sibling of the rules directory — not a subdirectory of it, so that backups never sit inside the working set of anything that reads rules by directory (the daemon itself would not pick them up: v1.8.0 globs <rules>/*.json, and Go's * does not cross path separators; the point is the asymmetric cost of a human or a future directory reader mistaking a backup for a rule). Backups are written one directory per run, and an existing one makes the script refuse to run again: sharing a backup directory would let a second apply record the tool's own freshly installed rules as "the state before", so rollback would restore the tool's output and report success. Before writing anything the script records a pre-state ledger, and rollback verifies against that ledger — comparing the just-copied file to the backup it came from validates the copy, not the rollback. The ledger is sealed after one rollback, so a second run is refused. A half-completed install prints the rollback command before exiting. Still generated, never executed: the rules directory is root-600, so an administrator recomputes the sha256, reads the script, and runs it under sudo themselves. There is deliberately no --run — proxy-plan has one because it edits a database you own; that analogy does not hold here. Only enabled:true drafts go into the script; drafts carrying sanitize placeholders or held drafts are printed with a per-draft exclusion reason (silently installing one fewer leaves you with no way to know where it went). With zero installable drafts no script is written at all — a script that installs nothing would exit 0 and read as "it was applied". Reconcile afterwards with ubuntu-sec fw-verify --drafts. |
| ubuntu-sec fw-monitor setup [--interval hourly\|daily] / fw-monitor report | Scheduled incremental OpenSnitch monitoring: setup generates a collection script + systemd user service/timer (generated only, never installed — install command is printed; both collector scripts pass gate ① (static-check.sh) before anything is written, and on rejection the scripts, the units and state.json are all left untouched and the command exits 4); the timer periodically exports+sanitizes an increment to <base>/monitor/, and the same round also runs proxy-collect (proxy access log → <base>/monitor/proxy/, read by proxy-log; a proxy-side failure never affects the firewall collection). report aggregates unconsumed increments through the same fw-log judgment pipeline, plus three self-monitoring seed findings (collection-gap past 2× the interval, sanitize-gate leak with raw retained, and a five-class rule-posture diff versus the previous snapshot). |
| ubuntu-sec fw-verify [--rules-dir D] [--daemon-config C] [--json] [--drafts [DIR]] | Read-only rule-set posture gate: asserts fail-closed, precedence completeness, no shadowing (H11), no erroneous rules (name/action and name/duration mismatches, malformed compounds, placeholder residue, and operand/type mismatches that either kill the daemon or never match), and reports InterceptUnknown posture (informational, never gates); exit codes 0=the five gating dimensions are clean, 1=drift detected, 2=unable to confirm (the --drafts dimension deliberately does not affect the exit code, so an UNVERIFIED reconciliation row can share a screen with exit 0 — the two statements have different scopes) — either an assertion could not read its authoritative source (no sudo), or a terminal rule's match set lives outside the rule itself (a lists.nets / lists.domains_regexp list file, or a network alias), whose contents the static analysis cannot see. Run it after changing rules; sudo is recommended to read the full mode-0600 rule set. Decoupled from any single deployment — complements fw-rules (which emits findings for judgment) with a pass/fail exit code.--drafts [DIR] adds one reconciliation dimension (auto-locating the most recent firewall/plan/ when given no value): it reads the draft package and reports, per draft, whether that rule has shown up in the live rule set. Four states: landed / same name, different content / absent / unverifiable. When no draft package can be located — a mistyped path, or fw-plan never run — the run does not produce a clean reconciliation of zero drafts; it leaves an UNVERIFIED reconciliation dimension saying so, with the reason on stderr as well. The exit code is unchanged either way.The anchor is the rule's canonical name plus its operator and its enabled flag, never the filename — renaming a rule on install does not change the verdict, but matching on the name alone would wave through a false healthy: rule names look like NNN-allow-<process-slug>, entirely predictable, and this tool's own advice for excluded drafts is to fill in the placeholders and write that rule by hand. enabled belongs in the anchor for the same reason: the daemon does not compile a disabled rule at all (loader.go, "exclude not enabled rules"), so traffic still falls through to DefaultAction — reporting such a rule as landed is precisely the false healthy this dimension exists to prevent. A same-named rule whose content differs gets its own bucket, folded into neither landed nor absent; the judgment is left to you.The baseline is the delivery manifest, not the draft file as it stands now. The draft and the installed rule are two sides of one source: swap the draft, install it, and both move together so the anchor matches on everything. Drafts are therefore checked against sha256sums.txt first; on a mismatch the whole dimension drops to UNVERIFIED and names the drafts, with no four-state verdict offered. A package with no manifest (--apply never run) still reconciles normally and simply says that it cannot tell whether the drafts are still the ones that were generated — an alarm that fires for everyone is not an alarm. The gate's reach is defined by whether the manifest still has something to say about that draft: if it does, tampering is caught. Any way it could stop having something to say is refused rather than waved through — the draft's line deleted, the manifest deleted outright, the manifest present but unreadable, or a draft the manifest never covered (one presenting as installable with no manifest line is UNVERIFIED). The test whitelists the benign state and raises everything else, so a form nobody has thought of yet fails closed instead of relying on this list being complete. That whitelisted cell holds two world states, not one: no manifest was ever produced (the normal case), and the manifest was carried off together with the apply script — the predicate is "neither file is there" and the tool cannot tell them apart, so this gate does not stop rm sha256sums.txt apply-rules.sh (one more rm than deleting the manifest alone, and no hashing at all). That cell refuses to vouch for anything rather than reporting clean, but it is not a raised one; closing the rm path is precisely what pushed the cheapest bypass to chmod. The three ways a manifest can be absent are reported distinctly: never produced, removed while the apply script remains, or present but unreadable. What escapes is cheaper than this text used to claim: editing one draft and the one line that covers it in the manifest (a single sha256sum, not a rewrite of the whole package); there a self-consistency check proves nothing, and the anchor is the digest you recomputed yourself at delivery time."Unverifiable" states which of its two causes applies (the rules directory is missing, or unreadable without sudo) and is never conflated with "the directory read fine and is simply empty". When the rules directory is only partly readable, drafts not seen in the readable subset also land in "unverifiable" — not installed and not seen are different things. It does not affect the exit code — a draft you chose not to install is a choice, not drift, and gating on it would make this a permanently red gate. "Landed" is not the same as "that finding is gone": confirming the latter needs a fresh collection round, which this command does not do. |
| ubuntu-sec sensor-setup | Generate an endpoint-sensor (auditd + Falco) install-and-configure script → <base>/sensors/setup-sensors.sh + sha256. Seven sections (preflight / backup / auditd rules / Falco Modern-eBPF + JSONL with its own logrotate policy — the falco package ships none, so configuring file_output means owning that file's lifecycle / adm-group permission model / joint acceptance / rollback). Generated, never executed — same high-risk review path as harden; the Falco APT-repo GPG fingerprint is fail-closed (never invented — a placeholder the administrator fills after verifying against the official value, and the Falco section is skipped if unset). The install script is what the user runs (with sudo) to gain command-execution audit trails and real-time behavioral alerting; the consuming auditd/Falco checks and correlation follow once sensors are installed. |
| ubuntu-sec sensor-collect [--sensors-dir D] [--output DIR] [--ausearch BIN] [--falco-log PATH] [--audit-keys KEYS] | Incremental endpoint-sensor (auditd + Falco) shard collection → <base>/monitor/sensors/shards/. Audit is read one key at a time through ausearch --checkpoint; the execve firehose (user_exec) is stored only as a per-exe aggregate (measured on this host: 28,561 events in an 11-minute window versus 4 for the other nine keys combined — keeping it verbatim is not affordable; the aggregate still keeps auid≠euid privilege transitions). Falco JSONL uses a content-timestamp cursor and reads .1/.1.gz to catch up across a rotation. Both sources share fw-monitor's sanitize map, because consistent placeholders across sources are what makes correlate's cross-source join possible. Why it exists: the audit log's real retention window on this host is about 11 minutes (~8 MB/min), so without collection there is nothing left to trace. fw-monitor setup also generates a 10-minute timer, and sensor-setup raises the auditd budget to 480 MB for headroom. Exit codes: 0 normal / 3 sanitize leak (fail-closed) / 5 audit cursor unusable (the broken checkpoint is quarantined and the fact recorded in state) / 7 a previous round is still running. Which audit keys get collected comes from sensors-manifest.json in the shard directory; --audit-keys overrides it (comma-separated, falling back to a built-in default when there is no manifest), and --ausearch / --falco-log pin the binary and the JSONL path for tests and sandboxes. Read-only, no sudo. |
| ubuntu-sec sensor-status [--json] | Read-only endpoint-sensor posture: whether auditd/Falco are installed and running. Exit codes 0=active (both installed and running), 1=drift (installed but a service is stopped), 2=not-deployed (absent / unable to confirm — same "visibility gap, not a regression" framing as the sensor-coverage check). Read-only, no sudo; deep posture (audit lost counts) is left to the auditd check. Mirrors fw-verify's three-state exit contract. |
| ubuntu-sec correlate [--findings F …] [--output DIR] [--local-offset S] [--shards] [--json] [--save] | Three-source root-cause correlation engine — the first cross-source synthesis. Reads findings.json from both sides — audit/Falco live in the scan report, OpenSnitch in the firewall one, so a single file can never hold all three sources. --findings is repeatable and optional: omit it and the most recent scan and fw findings are located for you (which files were used is printed to stderr, with a warning when the two sides come from different dates); pass it explicitly and nothing is ever added behind your back. It stitches audit (execve/keyed), Falco (alerts), and OpenSnitch (connections + action) traces of one action into a story ("dropped → launched → connected → denied"). Join = time-window ±2s ∧ (exe-path equality ∨ ppid parent-chain). PID-reuse-safe: a bare pid match is never sufficient (pid recycles). Read-only synthesis, never judges (verdict left to the agent). Time normalization: audit/Falco are epoch (TZ-free); OpenSnitch local wall-clock is anchored to UTC via --local-offset. Anti-false-healthy: no source events → partial; only one kind of source present → partial naming which one arrived and that a second findings file is probably missing (a cross-source link is structurally impossible with one source, and reporting "no correlation" would read identically to having actually looked); any of the three kinds missing → partial too, but the correlation still runs and the stories it did find are still reported — a correlation_coverage observation carries sources_present/sources_missing, and the reason states both possibilities for the absent side (that findings file was never passed, or that sensor is not installed/collecting — tell them apart with sensor-status); all three present but no link → non-partial + stories=0 (not a false positive). --shards additionally pulls the sensor shards under <base>/monitor/sensors/shards/ into the correlation (off by default: the audit log's retention window here is about 11 minutes, so anything older exists only in the shards); --save archives the verdict to <base>/posture/<date>/correlate.json (nothing is written without it). Exit codes: 0 normal / 2 a findings file could not be read, or auto-discovery found nothing. |
| ubuntu-sec sensor-tune [--sensors-dir D] [--output DIR] [--json] | Sensor noise profile: aggregates the Falco shards sensor-collect writes into clusters by (rule, exepath), plus an observation-window confidence figure (spans under a day are never extrapolated into a daily rate; a dominant peak-hour share means the window is driven by a few hours of activity, and thresholds set from it would bake interactive behaviour into the host baseline). Two attribution tiers, self_referential/unattributed, are an attribution hint for a human only — never a filter or allow-list input downstream (the exepath it keys on is attacker-controlled). Read-only; generates no Falco rules and never touches the rule files. Exit codes: 0 normal / 2 argument error / 3 shards unreadable or zero events (partial — anti-false-healthy: zero events does not mean "this host is clean"). |
| ubuntu-sec proxy-collect [--proxy-dir D] [--output DIR] [--proc-root P] [--deny-hosts PATH] [--redact-domains] | One incremental collection round for the xray/v2rayN access log: locates the active log via the running core's fd table, filters new lines by cursor, and writes shards (sanitize.py --fmt proxyaccess --module proxy) — the shards proxy-log analyzes. Destination domains are kept in plaintext by default since this release (the verdict on whether a destination is worth blocking is handed to the agent/human reading the shard, not pre-decided by the sanitizer) — the first plaintext shard prints a one-time stderr notice, and whichever human-readable command you run first — proxy-log or proxy-plan — shows it once too (they share one acknowledgement bit, so it never repeats), independently of the timer's unattended run. Destinations that are this machine's own internal hosts stay redacted (to INTHOST_N) either way. --redact-domains restores the pre-flip behavior of redacting every domain to DOMAIN_N (also reachable directly via sanitize.py --proxy-redact-domains). Local IPs/identifiers are still stripped either way. Exit codes: 0 normal, 3 sanitize leak (fail-closed, cursor does not advance), 4 nothing collected this round — stderr states which of four states it was (core not running / access log not enabled / /proc could not be enumerated / a core's fd table was unreadable); the last two mean could not tell, not not enabled, so do not go change your access-log settings on seeing them. The collection script generated by fw-monitor setup runs this every round, so the existing timer covers it; you can also run it by hand. |
| ubuntu-sec proxy-plan [--proxy-dir D] [--output DIR] [--proc-root P] [--proxy-port PORT] [--emit DEST,...] [--emit-recommended] [--apply-db [DB_PATH]] [--rollback-db [DB_PATH]] [--run] [--json] | Turns the proxy-log egress profile into a block list. The command generates only — this command's own process never writes v2rayN's database, never edits proxy configuration, and never starts or stops any process (--apply-db/--rollback-db only generate one more script; stopping v2rayN, writing the database and starting it again all happen when you run that script — --run presses that button for you, printing the script's path and sha256 first). plan-manifest.json (schema version: 2, always written) lists every candidate with its destination field in whatever form the source shards used — plaintext by default, or the legacy DOMAIN_N/IP_* placeholders on shards collected with --redact-domains; destinations that are this machine's own internal hosts stay redacted to INTHOST_N in either mode; judging which destinations to block is left to the reader, not decided in advance. --emit DEST,... (plaintext domains, or placeholders that get reversed through the sanitize map) or --emit-recommended (every candidate the classifier tagged recommended in one shot; exits non-zero and writes nothing if there are none) additionally writes routing-rules.json, a rule set importable into v2rayN that points those domains at the existing block outbound (mode 0600 under the 0700 raw/ directory, same as plan-manifest.json), plus APPLY.md and sha256sums.txt. --apply-db [DB_PATH] (needs --emit/--emit-recommended) additionally generates apply-routing.sh, a one-command alternative to the GUI import. Run with no arguments it stays as conservative as the command that generated it: it refuses to run while v2rayN is still up, backs up the database first, merges the rules into the active rule group idempotently, and touches no process at all. Run as bash apply-routing.sh --auto it stops and restarts v2rayN for you (5-6 seconds of downtime, measured): capture the GUI's whole environment (/proc/<pid>/environ, reproduced verbatim with env -i on relaunch, so the caller's shell cannot leak TZ and friends into xray), SIGTERM the GUI, reap the xray core (it does not die with its parent, so leaving it alive would collide on the proxy port), back up and merge, relaunch the GUI with the captured environment, wait for the core, then make one connection to the destination just blocked and run a collection round to confirm it. Any failure after the GUI has been stopped still relaunches it before exiting non-zero — a single exit funnel guarded by a trap, not a step each new code path has to remember — and the script says what state it left behind (rule applied or not, backup path). Exit codes: 0 applied and confirmed, 3 applied but not confirmed (which is not the same as "not blocked"), 2 refused before touching anything. --rollback-db [DB_PATH] generates rollback-routing.sh, which restores the most recent backup, saving the current database first so the rollback is itself reversible. It picks one of two paths by itself: if v2rayN is not running it restores the bytes and starts nothing — that is the state in which overwriting the database is safe, and the state someone recovering from a bad apply is most likely to be in — and if it is running it goes through the same stop/relaunch choreography, with the same always-relaunch guarantee. An unusable backup is refused before anything is touched. --run is the single explicit opening in the generate-only rule: it executes the script that was just generated, printing that script's path and sha256 first — the artifact still does the work, --run only presses the button, and without it nothing is ever executed. No outbound is created, so the credential-bearing outbounds node is never touched. xray has no hot reload, so the core must be restarted after importing (brief loss of connectivity), and the only authoritative proof that a block took effect is the [... -> block] line in the access log — client-side errors cannot tell a blackhole apart from an unreachable network. xray / v2rayN only. |
| ubuntu-sec proxy-log | Egress profile for traffic inside the xray tunnel, which OpenSnitch cannot see. Reads the access-log shards written by ubuntu-sec proxy-collect and forwards each destination in whatever form the shard already has it — plaintext by default (domain or bare WAN IP, matching proxy-collect's default), or the legacy DOMAIN_N/IP_WAN_N placeholders on shards collected with --redact-domains; destinations that are this machine's own internal hosts (single-label or host-identity) are redacted to INTHOST_N even in plaintext mode; this command has no flag of its own, it never reads the sanitize map and never reverses a placeholder. Each destination carries a known-service / telemetry / unknown tag; bare-IP destinations (tunnelled SSH and the like) are tagged ip-literal. When shards from both redaction modes are present on disk it emits a [PARTIAL]: a destination that changed form between the two generations occupies two rows and has its connection count split across them, so the table's ordering must not be used directly as a blocking priority (run proxy-plan for a merged candidate list — it reads both mapping tables and is fail-closed on cross-table collisions). Read-only; verdicts are left to the agent. |
| ubuntu-sec run [--mark STATE --module M] [--no-verify] | Start or advance the orchestration to the next actionable stage (state machine in run.json). Before it starts it runs the same tools verify integrity precheck as a standalone scan and aborts on FAIL — run drives scan collection in-process, so gating only the standalone entry would leave the recommended path less protected than the alternative; --no-verify skips it (consumed by the Node wrapper, never forwarded). The bare ubuntu-sec entry is the same function and the same gate. On start it drives scan collection and in-process firewall connection collection, then run --resume auto-produces the fw-rules/fw-log reports. An in-memory GUI DB degrades to fw-log --csv-dir. The combined pass also cross-checks rules against observed traffic — zero-hit rules, narrowing candidates for over-broad allow rules, temporary-rule-promotion suggestions — each flagged low_confidence_window when the observation window is short. Once --resume reaches analyze it also runs all six read-only posture commands (fw-verify / sensor-status / sensor-tune / correlate / proxy-log / sec-events, one --save each), prints a posture page, and appends an orchestration-coverage list. fw-verify deliberately keeps its own default authoritative source (/etc/opensnitchd/rules) rather than the sanitized copy in the run tree — unprivileged it honestly exits 2 (inconclusive), which beats "never ran". --skip-analysis turns off the six commands and the posture page, but not the coverage list (that is a separate feature, and status prints it too without having such a flag). |
| ubuntu-sec prune [--days N] [--apply] [--include-raw] [--raw-days N] | Delete expired dated run directories (<base>/YYYY-MM-DD[-N]/). Lists without deleting by default — what it removes is analysis you ran yourself and cannot be undone, so --apply is required (retention defaults to 30 days). Age comes from the newest file inside, not the directory's own mtime, which later reads and writes disturb; symlinks are skipped; monitor/ (the collection tree), map/ (the cross-batch placeholder table) and sensors/ (generated install scripts) are never touched — collection shards have their own 90-day and size gates. No timer ever calls it: disposal stays the user's call.--include-raw (off by default) additionally clears raw/ (unsanitized collection) from run directories, judged by --raw-days (default 3, the reparse window) — independent of --days, so it touches runs that are still within retention and were meant to be kept. Once cleared that run can no longer be scan --reparsed (a _pruned.json is left in raw/ recording this); report/ and logs/ are always preserved. Exit codes: 0 normal / 1 some entries could not be examined (the traversal hit errors — the listing is incomplete, not empty). |
| ubuntu-sec status | Read-only view of current orchestration state: phase, per-module state, this round's read-only analysis results, and the orchestration-coverage list. |
| ubuntu-sec posture [--output DIR] [--json] | Global posture overview: aggregates whatever fw-verify / sensor-status / sensor-tune / correlate / proxy-log / sec-events have archived via --save, plus the most recent scan/fw findings, onto one page. It relays, it does not judge — the verdict is left to the agent. Anti-false-healthy is its core duty: a side with no archive is spelled out as "never ran <cmd> --save; this cell is empty, which is not the same as this side being fine", and every archive shows its generated_at and how long ago that was (staleness never changes the exit code, but it is always visible). On an empty base it exits 0 with onboarding guidance instead of erroring (contrast status, which exits 2 there). Exit codes: 0 report produced / 2 bad arguments.The six commands feed it via a --save flag, which archives that run's conclusion (the very object --json prints) to <base>/posture/<date>/<cmd>.json. Off by default — without --save those six behave byte-for-byte as before, so the read-only, zero-side-effect contract is not broken. The envelope carries exit_code, because half the posture semantics of these commands lives there (fw-verify 2=UNVERIFIED, sensor-status 2=not-deployed, sensor-tune 3=partial — none of which are errors); without it an aggregator can only guess. |
| ubuntu-sec sec-fold [--local-offset S] [--digest-only] [--json] | Fold the per-event shards under monitor/ (L1) into a normalized event layer (L2, monitor/events/<date>.jsonl) and a daily digest (L3, monitor/digest/<date>.json). Adds only, never deletes — deletion is sec-gc's job. Measured by running the shipped sec-fold over a copy of this machine's real shards: falco 274.3:1, OpenSnitch connections 59.9:1, proxy access 4.9:1 — 294,219,065 B → 3,645,570 B, 80.7:1 overall. The two audit families are verbatim, and audit-exec is 0.7:1: its L2 is larger than its L1, because a verbatim record keeps every original line and adds a JSON envelope. Folding is not the same as shrinking. The two audit families are not folded — they are multi-line records whose type=EXECVE arguments are the only forensic value those high-signal keys have, and folding them would save 0.4% of the tree; they go into L2 verbatim. Day buckets are always UTC: the sources do not agree on what "a day" is (falco writes UTC, the xray access log writes local wall clock — the same instant can differ by a calendar day), so wall-clock sources are anchored back through --local-offset. |
| ubuntu-sec sec-gc [--apply] [--budget BYTES] [--json] | Tiered cleanup of L1/L2/L3 under monitor/. Lists without deleting by default; --apply is required. Fail-closed — within this command: sec-gc deletes an L1 shard only once that (source, day) actually has a record in L2, never merely because the day's file exists. This gate does not cover every deleter of L1: each collector also sweeps its own shards every round on a 90-day age gate and a 512 MB budget gate. Only the age half ignores L2 — letting a documented 90-day expiry fail closed would let a machine that never runs sec-fold grow without bound. The budget half does consult L2: each collector passes paths.l2_backstopped_names(...) in, and a shard L2 has not backstopped is held rather than evicted, recorded one by one in state.json's budget_holds. Better to blow the budget than to create a window where the raw data is gone and the normalized form was never written. So sec-fold must run first, or this command deletes nothing and says so. L3 is never evicted for budget (only at 400 days). Every --apply appends to monitor/gc-ledger.jsonl, and posture reads that ledger to report which windows were cleaned and which layer now covers them. Run directories (<base>/YYYY-MM-DD[-N]/) are not its business — that is prune. |
| ubuntu-sec sec-events [--source ID]… [--since D] [--until D] [--top N] [--output DIR] [--json] [--save] | Reads the normalized event layer (L2) that sec-fold writes, listing events and counts by (source, signature). Until this command existed, monitor/events/ had exactly one reader — sec-gc's coverage gate — so the layer this project produces was a dead-letter box that nothing showed you. Read-only, writes no file without --save, and always exits 0 (2 is reserved for bad arguments); coverage gaps live in partial, which posture surfaces. Anti-false-healthy is the whole of its judgement, because in a table of counts three different things render identically: no L2 at all says so and names sec-fold rather than printing an empty table; windows whose L2 sec-gc already deleted are named day by day, read out of gc-ledger.jsonl through gc_ledger.read_ledger() (no second parser) — and the window that decides this is the explicit --since/--until when given, otherwise the span of the day files actually on disk, so that a user who has ever run sec-gc --apply does not get a permanently lit partial; lines that could not be parsed are counted rather than skipped. The unparsed / unmatched counts are printed even when they are zero — "I did not understand 0 of them" and "I did not mention it" must not look the same. An unrecognized --source exits 2 and lists the valid set, because silently returning an empty table lets a typo'd filter fabricate "this source has no data". proxy-access signatures are the destination, forwarded exactly as the L1 shard had it (plaintext, or DOMAIN_N placeholders if that shard was collected with proxy-collect --redact-domains) — the command neither unwraps nor adds placeholders, and says so in a footnote whenever such rows are present. |
| ubuntu-sec system-collect [--tier slow\|daily\|all] [--system-dir D] [--output DIR] [--journalctl BIN] [--apt-history PATH] [--systemctl BIN] [--json] | Incremental collection for five system-layer sources. --tier slow (the default all includes it): journal-auth (journalctl SYSLOG_FACILITY=10 --cursor-file), journal-kernel and apparmor-denied (one shared _TRANSPORT=kernel stream and cursor — apparmor-denied is a derived, parse-time filter on the same events, not a second journalctl call), and pkg-events (/var/log/apt/history.log, an offset+content cursor: a transaction whose tail hasn't been written yet is neither collected nor skipped-as-done this round; a mid-stream block truncated by a blank line — one that will never be completed — is instead collected as-is, the cursor advances past it, and the shard text gets an Incomplete-Transaction: no-end-date marker appended). --tier daily additionally runs persistence-snap (a periodic snapshot + diff of systemd units, user systemd, XDG autostart, and system cron — a shard is written only when the posture hash changes). The two journal sources and pkg-events need the adm group; not being a member is reported as skipped_reason=not-in-adm-group, never as zero events. Shards land under <base>/monitor/system/shards/ (persistence-snap's own snapshots go to snapshots/); read-only, no sudo, never modifies the system. Exit codes: 0 normal / 3 sanitize leak (fail-closed) / 5 a journal cursor is unusable (broken checkpoint quarantined) / 7 a previous round is still running. |
| ubuntu-sec watch install [--print-only] [--unit-dir D] [--output DIR] / watch status [--json] [--unit-dir D] / watch stop | Install/query/stop three systemd user timers in one command: fast (every 10 minutes — audit-*/falco, reusing the existing sensor-collect script), slow (hourly — osn-*/proxy-access plus system-collect --tier slow, spliced in ahead of the existing OpenSnitch collection step), and daily (03:07 — system-collect --tier daily → sec-fold → posture archives → sec-gc --apply, in that fixed order; the posture step runs every posture facet except fw-verify (the single source of truth for that list is watch.POSTURE_SAVE_COMMANDS; this document deliberately keeps no second copy) with --output <base> --json --save, which is what finally gives posture an automatic producer — it sits between fold and gc because sensor-tune and proxy-log read the very L1 shards sec-gc --apply is about to delete, and --json because proxy-log's human-readable branch would otherwise consume the user's one-time plaintext-mode notice; fw-verify is deliberately excluded, since the timers run unprivileged and it needs sudo; if the collect step loses the round lock to the slow tier and exits 7, it is retried once after fold/gc. The daily timer deliberately sits off the hour: hourly expands to *:00:00, so 03:00 would start in the same second as the slow tier and contend for the same lock). install really installs by default (daemon-reload + enable --now on all three); --print-only only writes the scripts and unit files and never touches systemctl. If either of the two legacy timers (ubuntu-sec-fw-monitor.timer / ubuntu-sec-sensor-collect.timer) is enabled, install prints a migration notice with the literal disable command — it never disables them for you, and the two generations can coexist during migration — both run the same monitor-collect.sh, and an overlapping round is turned away by the script's flock (exit 7 plus a line in raw/_collect.log), so it neither collects twice nor corrupts a cursor. status answers on one screen, section by section (deliberately not counted as "N questions" — that number had been written differently in three places, so the count itself became the defect): per-source coverage across all 11 registered sources (not-deployed / skipped with a reason / failing with the verbatim failure reason / stale / has-data / pending, plus a leak-pending flag; stale means last_ok is older than 3× that tier's timer interval — both the threshold and the reference "now" are printed), whether each of the three timers is enabled, whether the installed artifacts still match the current templates, disk usage of L1/L2/L3 against the sec-gc budget (plus journald's own usage), and adm-group membership; the drift question compares all nine on-disk artifacts (the three tier scripts plus their .service/.timer units, in the directory given by --unit-dir) byte for byte against what this version renders, in four states — match / drift / absent / unreadable, with unreadable kept separate from match because "could not check" is not "checked and fine". Why it exists: watch install overwrites, so upgrading ubuntu-sec without re-running it leaves the machine on the old scripts and units, and until now no command said so; the comparison uses the strictest possible criterion so that misjudgement can only err toward over-reporting (whose cost is one idempotent re-run of install). All nine missing renders as "never installed on this machine" rather than "running an old version" — the next action differs. The comparison and install share one rendering function, so the two sides cannot drift apart and leave the check permanently green. it always states the firewall posture (fw-verify) is not covered by the unattended path, since all three timers run as an unprivileged user and fw-verify needs sudo to read the mode-0600 rule directory. status always exits 0 and never writes a file. stop only touches the new three timers. The three collector scripts pass gate ① (static-check.sh) before anything is written to disk: on rejection none of the nine artifacts is written and systemctl is never called, and the command exits 4 — install overwrites, so writing first and checking afterwards would clobber the previously vetted script with unvetted bytes. Exit codes: install/stop 0 normal / 2 missing or unknown subcommand (argparse) / 4 gate ① rejected a collector script that was about to be written (install only) / 5 systemctl unavailable or a daemon-reload/enable/disable call failed; status always 0. |
| ubuntu-sec tools verify | Verify tools/ integrity (manifest sha256 comparison). |
Coverage: 47 checks across four layers — application (10), system (26), kernel (6), network (5) — with 33 structured parsers. Detections include endpoint-sensor coverage (auditd/Falco install state — prompting one-click deployment when missing), auditd runtime posture (enabled/lost/backlog and whether the desktop audit ruleset is actually loaded — installed ≠ auditing), auditd keyed change attribution (who — by login UID — modified identity/privilege/persistence/kernel-module/audit-rule objects, and with which binary), auditd execution profiling (per-binary counts, interpreters and privilege transitions run from temp/writable paths), and Falco real-time behavioral alerts (eBPF — temp-directory execution, sensitive network tools, network-tool-spawned reverse shells), SUID/SGID and file-capability privilege vectors, world-writable and recently-modified system binaries, empty-password and UID-0 accounts, SSH/sudo/su/PAM authentication and privilege-escalation events (brute-force, invalid-user probes, failed sudo), login-session forensics (successful and failed logins via wtmp/btmp) and SSH authorized_keys backdoor/tamper detection, package-install history (unexpected/suspicious software) and log-tamper / journal-persistence checks (truncated critical logs, non-persistent journald), cron / systemd / XDG-autostart hijacking, LD_PRELOAD and /proc/*/maps runtime injection, deleted-but-running executables, AppArmor state, eBPF programs, listening ports and process attribution, known-CVE exposure (apt-security, Ubuntu Pro/ESM), browser DoH policy detection (whether Firefox/Chrome built-in DNS-over-HTTPS is explicitly disabled and locked), and OpenSnitch firewall log/rule review.
Highlights
- Never executes. Every remediation script is generated for review only — three orthogonal gates guard it: policy hard-blocks, a
static-checkshell linter, and multi-agent isolated review. Delivery requires the administrator to re-compute and compare a sha256. - Sanitize before LLM (fail-closed). Local hostnames, IPs (v4 and v6), and identifiers are stripped before any data enters an agent context; the reverse map is written to
map/inside the run directory (0700, file 0600) and never sent to the model. A leak assertion aborts the pipeline instead of shipping raw data. - Deterministic facts, agent judgment. Parsers only report facts (
status: pending) and cite evidence lines; the agent judges under a conservative rule — insufficient evidence stays at ❓, never escalates to ❗ without a cited line. - Agent-native by design. Structured
findings.json, an explicit judgment contract (SKILL.md), and anti-injection handling make it built for AI agents — not a human report bolted onto an LLM. - Read-only and least-privilege. The tool itself never runs sudo/root/state-changing commands; filesystem scans use
find -xdevand never cross mount points; allow-listed software is exempt from removal suggestions but still checked for abuse. - Ships everywhere. npm/npx CLI, Claude Code plugin, and Codex-compatible — one tool, three surfaces.
How it's different
| | ubuntu-sec | lynis / audit scripts | Generic AI agent running commands |
|---|---|---|---|
| Built for AI agents | ✅ structured findings + judgment contract | ❌ human-readable report | ⚠️ ad-hoc, unstructured |
| Sanitizes before LLM | ✅ fail-closed, v4+v6 | ❌ n/a | ❌ raw output to model |
| Generates fixes | ✅ risk-tiered, reviewed | ❌ suggestions only | ⚠️ may run unreviewed |
| Auto-executes fixes | 🚫 never (by design) | 🚫 n/a | ⚠️ risk of yes-to-all |
| Firewall analysis | ✅ OpenSnitch log + rules | ❌ | ⚠️ ad-hoc |
| Injection-aware | ✅ anti-injection contract | ❌ | ❌ |
ubuntu-sec is not a replacement for lynis — it calls lynis and other scanners, then adds the agent-safety layer around them: structured parsing, sanitization, judgment contract, and never-execute delivery.
Usage
The main line is one orchestration plus one unattended surface; every single-purpose command still runs on its own.
# 1) Orchestration (the recommended entry point). On start it drives scan collection
# and firewall connection collection in-process
ubuntu-sec run
# Root-only checks are never executed by this tool: they are assembled into
# collect-sudo.sh for an administrator to re-compute the sha256 of and run by
# hand, while the orchestration waits in await-root for you to come back.
ubuntu-sec run --resume # after the root batch: produce the reports; once it
# reaches analyze it runs the six read-only posture
# commands (one --save each) and prints a posture page
ubuntu-sec status # phase + per-module state + orchestration coverage (read-only)
# 2) Unattended: three systemd user timers (every 10 min / hourly / daily at 03:07)
ubuntu-sec watch install # really installs by default (--print-only writes files only)
ubuntu-sec watch status # per-source coverage for all 11 sources + timer state + disk usage
ubuntu-sec watch stop
# 3) Posture overview: aggregates what the read-only commands archived with --save
ubuntu-sec posture
# 4) One piece at a time
ubuntu-sec scan # four-layer analysis (unprivileged, read-only)
ubuntu-sec scan --layer net # one layer only
ubuntu-sec scan --reparse 2026-07-12 # re-render from existing raw data (after a root batch)
ubuntu-sec harden # locate the latest scan findings, print generation guidance
ubuntu-sec fw-log --connections dump.json
ubuntu-sec fw-rules --rules-dir /etc/opensnitchd/rulesBoth posture and watch status relay, they do not judge: "not collected" / "could not confirm"
mean there is no conclusion here, not this side is fine — the verdict is left to the agent or
the human reading it.
Reports are written to ~/.sec/YYYY-MM-DD/{scan,firewall}/report/ (override with --output DIR or $UBUNTU_SEC_HOME; same-day re-runs get a -2, -3 suffix). Un-sanitized raw data and the sanitization map live inside that same run directory, next to the shareable output, at <run>/<module>/{raw,map}/ — isolated only by permissions (directories 700, map file 600), never entering LLM context or report/. They follow --output wherever it points, so aiming it inside a project tree brings the un-sanitized data with it.
Since 0.20.0 the collected archives under raw/ are gzipped once a scan --reparse or run --resume finishes successfully. Tool usage is unchanged — the pipeline decompresses on entry and recompresses on exit — but reading them by hand now needs zcat/zgrep rather than cat/grep. Set UBUNTU_SEC_RAW_COMPRESS=0 to turn compression off entirely; sanitization failures never compress in the first place, so raw/ is left in the form you found it.
Security model (non-negotiable)
- Scripts are generated, never auto-executed. All fix/harden/fw-block/fw-optimize/collect-sudo scripts pass multi-agent isolated review +
static-check, and delivery requires the administrator to re-compute and compare a sha256 before running by hand.The single explicit opening in this rule isproxy-plan --run: it executes the script that was just generated (which must passstatic-checkfirst — on a refusal nothing is executed and the artifact is left on disk — and whose path and sha256 are printed before it runs), it edits a v2rayN database you own, and it never uses sudo; without--runnothing is ever executed.fw-plandeliberately has no--run— the rules directory is root-600 and this tool never runs sudo on your behalf. - Sanitize before analysis. Data entering an agent context is sanitized first; external connection IPs/domains are preserved (needed for analysis), everything local-side is stripped, and the map lives in
map/inside the run directory (0700), out of the LLM. - System disk only. Filesystem scans use
find -xdevand never cross mount points; allow-listed software is exempt from removal/disable suggestions. - The tool never runs sudo/root commands itself, and runs no state-changing command either — with the single exception in rule 1 (
proxy-plan --runstops and restarts your v2rayN and writes your own v2rayN database; still no sudo, and the thing doing the work is the generated artifact whose sha256 you can re-compute).
FAQ
Does it need root? No. Scanning and firewall analysis run unprivileged and read-only. Root-only checks are emitted into a collect-sudo.sh for an administrator to run separately.
Will it change my system? No. The tool only generates scripts. Nothing is applied until a human reviews and runs it.
Is my data sent to a cloud? The tool sends nothing itself. When used inside an agent, only sanitized findings enter the agent context; the reverse map stays local. You control which model the agent uses.
Does it work without OpenSnitch? Yes — scan and harden are independent. Only fw-log / fw-rules require OpenSnitch.
Which Ubuntu versions? Tested on 22.04 and 24.04.
Why is a check [PARTIAL]? A required tool was missing, timed out, or its output wasn't collected (e.g. a root-only check run unprivileged). [PARTIAL] is honest degradation, never a fabricated result.
Can I use it with Codex instead of Claude Code? Yes. Install the npm package and Codex calls it as a CLI. Read the red line above — never let auto-approve run the generated scripts.
Documentation
| Doc | Contents |
|---|---|
| SKILL.md | Host-agent judgment & root-cause contract (how pending becomes ✅/❓/❗) |
| checks/README.md | Check definition schema (checks/*.yaml) |
| rules/README.md | OpenSnitch rule templates (agent egress allow-list baseline) |
| scripts/parse/SCHEMA.md | findings.json contract |
| CHANGELOG.md | Per-version changes |
