One static Rust binary. Sub-second Linux + Docker audit. No agents, no Python, no kernel modules.
Finds reverse shells, fileless malware, ghost PIDs (LKM rootkits), container escapes, dangerous defaults and CIS gaps — then exits cleanly. Nothing stays on the box. Nothing phones home.
curl -L https://github.com/OWLZOPS/owlzops-mapper/releases/latest/download/owlzops-mapper-linux-x86_64.tar.gz | tar xz
sudo ./owlzops-mapper auditPrefer signature verification handled for you? See Install — the install script checks SHA256 and GPG before it writes anything.
Most scanners force you to choose:
| Tool | Typical trade-off |
|---|---|
| Lynis | Excellent checklist, almost no live IoC or memory forensics |
| osquery / Falco | Powerful, but needs agents or eBPF and a heavier footprint |
| chkrootkit / rkhunter | Aging signatures, noisy false positives |
| Trivy / kube-bench | Strong on images and Kubernetes, weak on live host runtime state |
owlzops-mapper takes the opposite trade-off:
one musl binary → drop via SSH → deep host + Docker state in seconds → gone.
Built because running six different tools still left the question "is something already inside?" unanswered.
- Active compromise — reverse shells, memfd/fileless implants, library injection, bind-mount masking, known miners
- Ghost PIDs / LKM rootkits (
--deep) — true hidden processes via/procdiscrepancies - Container escapes —
docker.sock/ Podman socket mounts, privileged containers, dangerous capabilities, missing resource limits - Docker published ports bypassing the firewall — container ports reachable from the network even when
ufw statusreportsactive(SEC-065/SEC-066, see below) - Account & credential hygiene — weak password hashes, empty password fields, duplicate UID 0, system accounts with a login shell, unsafe permissions on
/etc/shadow,~/.ssh/*,/etc/sudoers.d/*(SEC-070…079, see below) - systemd hardening posture — running services that skip
NoNewPrivileges,ProtectSystem,ProtectHomeand the rest of the project baseline (SEC-081, see below) - Classic landmines — SSH root login + password auth,
sudo NOPASSWD:ALL, wide-open listeners, disabled firewall - CIS-mapped findings with evidence and penalty scores
- Drift — snapshot → compare → colour-coded Excel or terminal diff
Risk Score is split into Security / Reliability / Hygiene so one noisy category cannot hide a real problem.
🦉 Owlzops Mapper v0.5.37
🔍 Scan completed in 2.50s
🔒 Risk Score: 80/100 (High Risk)
Security −60 Reliability −5 Hygiene −15
🛡 Security Findings
╭───────────────────────────┬──────────┬──────────────────────────────────────────────────────╮
│ CIS / Ref ┆ Penalty ┆ Finding │
╞═══════════════════════════╪══════════╪══════════════════════════════════════════════════════╡
│ SEC-014 ┆ -30 ┆ Cleartext credentials in process memory │
│ SEC-012 (CIS 5.3.4) ┆ -15 ┆ Passwordless sudo to ALL commands │
│ DOCK-005 ┆ -15 ┆ Container bind-mounts docker.sock │
│ SEC-002 (CIS 5.2.10) ┆ -25 ┆ SSH root login allowed │
│ OPS-002 (CIS 4.1.1.1) ┆ -10 ┆ Intrusion detection & audit logging inactive │
│ SEC-008 (CIS 5.2.4) ┆ -10 ┆ SSH password authentication enabled │
│ DOCK-001 (CIS 5.10) ┆ -5 ┆ Containers run without memory limits │
│ DOCK-002 (CIS 5.11) ┆ -3 ┆ Containers run without CPU limits │
│ … ┆ ┆ │
╰───────────────────────────┴──────────┴──────────────────────────────────────────────────────╯
Full evidence column, memory-forensics tables and suppression counts appear with --deep.
- Not a continuous EDR or runtime monitoring agent — this is point-in-time
- Not a replacement for Falco, Tetragon or other eBPF-based tooling
- No active blocking or prevention — detection and reporting only
--deepmemory forensics requires root- The macOS binary is an orchestrator only; it cannot scan the local machine
- No Windows support
- Not a full CIS benchmark runner — findings are mapped to CIS rules where applicable, not every control is implemented
- A clean result is not proof of absence. No scanner can give you that.
Direct download — verify by hand, see Verifying releases:
curl -L https://github.com/OWLZOPS/owlzops-mapper/releases/latest/download/owlzops-mapper-linux-x86_64.tar.gz | tar xz
sudo ./owlzops-mapper auditInstall script — resolves the latest release, verifies SHA256, and verifies the GPG signature when gpg is available. The script is served from owlzops.com; it is the same file as install.sh in this repository, and it pulls the binary from GitHub Releases either way:
curl -fsSL https://owlzops.com/install.sh | sh
sudo ./owlzops-mapper auditEither way the binary lands in the current directory and nothing is added to your PATH. To make the examples below work verbatim:
sudo install -m 755 owlzops-mapper /usr/local/bin/
If piping a script into a shell isn't something you do — reasonable, given what this tool is for — use the direct download and verify manually. SECURITY.md lists every command the script runs that matters, plus the signing key fingerprint.
# 1. Install the macOS orchestrator curl -fsSL https://owlzops.com/install.sh | sh # 2. Extract the Linux agent under a different name — a plain `tar xz` here # would overwrite the orchestrator you just installed curl -L https://github.com/OWLZOPS/owlzops-mapper/releases/latest/download/owlzops-mapper-linux-x86_64.tar.gz \ | tar xzO owlzops-mapper > owlzops-agent-linux chmod +x owlzops-agent-linux # 3. Scan a remote host ./owlzops-mapper audit --deep \ --host 192.168.1.10 \ --ssh-user operator \ --ssh-key ~/.ssh/id_rsa \ --remote-path /tmp/owlzops-mapper \ --copy-binary \ --local-binary ./owlzops-agent-linux \ --ask-sudo-pass
The macOS binary is an orchestrator only — it cannot scan the machine it runs on. Extracting the Linux agent with a plain
tar xzin the same directory replaces the orchestrator with a Linux ELF, and the next command fails withcannot execute binary file.tar xzO … > owlzops-agent-linuxavoids that entirely. Apple Silicon only; there is no Intel macOS build.
/usr/local must not be group- or world-writable for tool resolution to work. From v0.5.39, resolve_tool rejects any system tool whose path (lexical or canonical) contains a directory writable by a non-root principal. On a Debian layout, /usr/local is commonly root:staff 2775 — group-writable — so tools the scanner invokes from there (systemctl, last, dpkg-query, …) are skipped with a coverage warning. This is deliberate: before v0.5.39, a member of staff could plant /usr/local/bin/systemctl and have the audit execute it as root. To restore resolution: chmod g-w /usr/local, or install those tools under /usr/bin / /usr/sbin.
sudo owlzops-mapper audit # fast path sudo owlzops-mapper audit --deep # + memory forensics + ghost PID detection sudo owlzops-mapper audit --deep -v # full per-region VMA detail sudo owlzops-mapper audit --deep --format excel -o report.xlsx sudo owlzops-mapper audit --format json > snapshot.json sudo owlzops-mapper audit --offline # guaranteed zero outbound calls
# Single host owlzops-mapper audit --host 192.168.1.10 --ssh-user operator --copy-binary # Fleet, one host per line in hosts.txt owlzops-mapper audit \ --hosts hosts.txt \ --ssh-user ubuntu \ --copy-binary \ --ask-sudo-pass \ --format excel \ --output fleet-report.xlsx
The binary uploads itself over SSH, runs, collects JSON, removes itself from each host, and writes one multi-sheet Excel report. No agent install, no open ports beyond SSH.
--ask-sudo-pass forwards the sudo password securely over the SSH channel — no NOPASSWD sudoers rule required.
For remote scans that require a password, use --sudo-pass-fd N (most secure),
--ask-sudo-pass with a pipe, or the deprecated environment variable
OWLZOPS_SUDO_PASS.
# Most secure: feed the fd from a pipe, never a here-string. # `<<<` writes a temp file in $TMPDIR on bash < 5.1, which puts the fleet # password on disk (R27-23). owlzops-mapper audit --host 192.0.2.10 --sudo-pass-fd 3 \ 3< <(printf '%s' "$SUDO_PASS") # via stdin (recommended for scripts) printf '%s' "$SUDO_PASS" | owlzops-mapper audit --host 192.0.2.10 --ask-sudo-pass # deprecated: environment variable (one-shot) OWLZOPS_SUDO_PASS="$SUDO_PASS" owlzops-mapper audit --host 192.0.2.10 --ask-sudo-pass
The password is held in a protected SecretString (zeroized on drop, locked
from swap and core dumps where supported) and removed from the process
environment as early as possible. See SECURITY.md for details.
Recurring scans lose their value the moment the operator stops reading them.
--accept lets you formally accept a finding you have looked at and decided
against remediating — so it stops appearing in the score, without disappearing
from the report.
[
{ "finding_id": "SEC-001", "host": "web-*",
"justification": "cloud SG is the firewall",
"expires": "2027-01-01", "ticket": "OPS-1234" }
]sudo owlzops-mapper audit --deep --accept accept.json
What a rule does:
- Matching findings are marked
suppressedand excluded from the Risk Score and the exit verdict. - They stay in the dashboard (line
Accepted risks: N finding(s)) and in JSON export with the full acceptance text — the policy is auditable. justificationmust be ≥ 10 characters.expiresis required,YYYY-MM-DDor RFC 3339. Expired rules are ignored and reported on stderr — the finding comes back live.hostis a glob overhost.hostname(*,?, anchored).
Compromise-class findings cannot be accepted. SEC-015…024, SEC-028,
SEC-040, DOCK-010, plus SEC-041 and COV-001, are refused at load. An
accepted rootkit is not a risk decision, it is a cover-up. The load fails with
EXIT_USAGE (64) naming the offending rule.
If nothing matches, or no --accept was passed, the report is identical to
before — this flag never changes behaviour on its own.
docker run -p 8080:80 writes its own iptables rules into the DOCKER
chain, which the kernel traverses before the INPUT chain that ufw /
firewalld manage. On a host where ufw status reports active, the
container's published port is still reachable from the network — the
firewall says on, the port is open. The same is true for a publish on a
specific host address (-p 203.0.113.5:6379:6379): Docker DNATs it exactly
like -p 6379:6379, ahead of the firewall. The scanner surfaces this
contradiction directly, next to the containers table:
⚠️ Docker published ports bypassing the host firewall (SEC-065 / SEC-066):
🛡️ host firewall is active, but Docker writes its own iptables rules
ahead of it — these ports are reachable from the network.
╭─────────────┬──────────────────────┬───────────────────┬──────╮
│ Container ┆ Published ┆ Firewall ┆ Port │
╞═════════════╪══════════════════════╪═══════════════════╪══════╡
│ nginx-proxy ┆ 0.0.0.0:80->80/tcp ┆ active (bypassed) ┆ 80 │
│ nginx-proxy ┆ :::80->80/tcp ┆ active (bypassed) ┆ 80 │
│ nginx-proxy ┆ 0.0.0.0:443->443/tcp ┆ active (bypassed) ┆ 443 │
│ nginx-proxy ┆ :::443->443/tcp ┆ active (bypassed) ┆ 443 │
╰─────────────┴──────────────────────┴───────────────────┴──────╯
💡 FIX: bind to loopback (`-p 127.0.0.1:PORT:PORT`), set
`"ip": "127.0.0.1"` in /etc/docker/daemon.json, or add a rule to
the DOCKER-USER chain.
Two finding IDs, split on the firewall state at scan time because the remediation differs:
| ID | Condition | Penalty | Fix |
|---|---|---|---|
| SEC-065 | Host firewall active, Docker bypasses it | +20 (35 for a database / cache / admin port) | Loopback bind, daemon.json override, or a DOCKER-USER rule |
| SEC-066 | No host firewall, port exposed because nothing protects it | +15 (25 for a sensitive port) | Enable a firewall |
Loopback bindings (127.0.0.1:*, [::1]:*) are the only exclusion: they are
already unreachable from the network. A publish on any non-loopback host
address — 0.0.0.0, [::], a specific public IP, a LAN IP — bypasses the
firewall the same way and fires the finding. The firewall state is read from
the same flag that drives network.firewall_active; the finding is a
cross-reference of topology.containers and the network scanner, not a new
scan.
If the publish is intentional — a reverse proxy on 80/443, for example —
the finding is accepted with an --accept rule rather than silenced by the
scanner:
[
{ "finding_id": "SEC-065", "host": "proxy-*",
"justification": "nginx-proxy is the intended edge; UFW sits inside",
"expires": "2026-12-31", "ticket": "OPS-4242" }
]Weak password hashes, an empty password field, duplicate UID 0 accounts,
system accounts with a login shell, and unsafe permissions on
/etc/shadow, ~/.ssh/*, /etc/sudoers.d/*. The set of checks a
security auditor opens with — every one of them a concrete remediation
(chmod, passwd -l, usermod -s /usr/sbin/nologin, switch to
yescrypt).
The dashboard renders one table with only the categories that fired; a clean host shows nothing.
⚠️ Account & Credential Hygiene (SEC-070…079):
╭──────────────────┬──────────────┬─────────────────────────────────────────────╮
│ Account Hygiene ┆ Issue ┆ Details │
╞══════════════════╪══════════════╪═════════════════════════════════════════════╡
│ Empty password ┆ 2 user(s) ┆ backup, sync │
│ Weak hash ┆ 3 user(s) ┆ alice ($1$ (MD5-crypt)), bob ($5$ (...)), … │
│ Permissions ┆ 1 file(s) ┆ /etc/sudoers.d/90-deploy (0664) │
│ auth_keys wri… ┆ 1 file(s) ┆ deploy: /home/deploy/.ssh/authorized_keys … │
╰──────────────────┴──────────────┴─────────────────────────────────────────────╯
| ID | Finding | Weight |
|---|---|---|
| SEC-070 | Weak password hash (MD5/DES/SHA-256) | +10 (MD5/DES +20) |
| SEC-071 | Empty password in /etc/shadow |
+40 |
| SEC-072 | Non-root account with UID 0 | +25 |
| SEC-073 | System account with interactive shell | +5 |
| SEC-074 | /etc/shadow readable by non-root |
+15 |
| SEC-075 | /etc/gshadow readable by non-root |
+10 |
| SEC-076 | /etc/sudoers.d/* unsafe permissions |
+15 |
| SEC-077 | SSH private key readable by group/other | +20 |
| SEC-078 | authorized_keys writable by non-owner |
+25 |
| SEC-079 | /root dotfile readable by non-root |
+10 |
None of these are compromise-class: they are hygiene, not evidence of an active compromise. The scanner never escalates them to exit code 3.
Non-root scans: /etc/shadow is unreadable, which is recorded as a
coverage warning and not counted as "no weak hashes". The permission
and SSH key checks still run — stat() needs x on every parent
directory, so a 0700 home or NFS root_squash produces a coverage
warning, not a false "clean".
A legitimate second root (BSD-style toor, or a break-glass account) is
accepted with an --accept rule on SEC-072, not by silencing the check:
[
{ "finding_id": "SEC-072", "host": "*",
"justification": "break-glass recovery account, login audited",
"expires": "2026-12-31", "ticket": "OPS-4242" }
]Running systemd units whose effective hardening is below the project
baseline: NoNewPrivileges, ProtectSystem, ProtectKernelTunables,
ProtectKernelModules, ProtectHome, PrivateTmp, PrivateDevices,
ProtectControlGroups, RestrictAddressFamilies. The scanner reads the
effective value from systemctl show — drop-in overrides, instance
templates and vendor defaults are all resolved — and reports the units below
baseline, worst first:
⚠️ Systemd Hardening (SEC-081): 12 service(s) below baseline
╭────────────────────┬──────┬──────────────────────────────────────────────────────────────────────────────────╮
│ Unit ┆ User ┆ Missing │
╞════════════════════╪══════╪══════════════════════════════════════════════════════════════════════════════════╡
│ atd.service ┆ root ┆ ProtectSystem, ProtectKernelTunables, ProtectKernelModules, PrivateTmp, │
│ ┆ ┆ PrivateDevices, ProtectControlGroups │
│ containerd.service ┆ root ┆ ProtectSystem, ProtectKernelTunables, ProtectKernelModules, PrivateTmp, … │
│ cron.service ┆ root ┆ ProtectSystem, ProtectKernelTunables, ProtectKernelModules, PrivateTmp, … │
│ docker.service ┆ root ┆ ProtectSystem, ProtectKernelTunables, ProtectKernelModules, PrivateTmp, … │
╰────────────────────┴──────┴──────────────────────────────────────────────────────────────────────────────────╯
…and 8 more — run with -v or see JSON export
Weight is flat 5, not tiered by count. Every default distro host has
10–35 vendor units below baseline — Fedora Workstation, a stock Ubuntu
server, a minimal VPS — and charging per-unit-count tiers scored all three
the same anyway. The finding is posture, not compromise; the count and the
worst unit are in the evidence so the operator can see the scope, but the
score does not pretend 34 unhardened units are three times worse than 12.
Drift is where this finding earns its keep. A flat weight means the
score does not move when a new unit falls below baseline, or when one unit
is hardened and another regresses in the same window. compare tracks the
below-baseline set by unit name, not by count:
- New unit below baseline →
Degraded - Unit leaves the list →
Changed— hardened or removed; the scanner cannot distinguish, and a decommissioned service is not a security improvement (same rule as containers,R28-21) - Missing set grew on the same unit →
Degraded - Missing set shrank on the same unit →
Improved
A 34→34 swap with one unit hardened and one regressed shows as one
Changed and one Degraded, not as "no change".
Exclusion is by provenance, not by name. systemd's own units
(systemd-*.service) and per-user managers (user@*.service) are skipped
only when the .service file lives under a vendor directory
(/usr/lib/systemd/system/ or /lib/systemd/system/). A persistence unit
named systemd-update.service in /etc/systemd/system/ is an operator
file — its hardening posture is the operator's responsibility, and it
enters drift like any other unit. Transient units under
/run/systemd/transient/ are skipped as runtime artifacts: they are
created and destroyed by D-Bus activation and are not operator-configurable
posture.
--accept works on SEC-081 like on any other posture finding:
[
{ "finding_id": "SEC-081", "host": "lab-*",
"justification": "development workstation, hardening deferred to image bake",
"expires": "2027-01-01", "ticket": "OPS-4242" }
]sudo owlzops-mapper snapshot
owlzops-mapper dir-compare ~/.owlzops/snapshots/hostname
owlzops-mapper compare before.json after.json --format excel -o drift.xlsxA nightly fleet scan is only useful if you can tell the operator what
changed, not what the full state is. --baseline pairs each host in the
second file with the same hostname in the first, and words the output as
drift from a known-good snapshot:
# 1. On a good day, run audit, review the result, and commit it as baseline owlzops-mapper audit --hosts prod.txt --format json --output golden.jsonl git add golden.jsonl && git commit -m "baseline: 2026-10-09" # 2. Every night thereafter owlzops-mapper audit --hosts prod.txt --format json --output today.jsonl owlzops-mapper compare --baseline golden.jsonl today.jsonl
Output is per host, only for hosts that changed:
Baseline drift: 50 host(s) — 3 changed, 47 unchanged
Host: web-05
╭─────────────────────────┬─────────┬──────────────┬────────────╮
│ Field ┆ Before ┆ After ┆ Severity │
╞═════════════════════════╪═════════╪══════════════╪════════════╡
│ network.listening_ports ┆ - ┆ 0.0.0.0:4444 ┆ ↓ Degraded │
╰─────────────────────────┴─────────┴──────────────┴────────────╯
A host present only in today.jsonl is tagged [not yet baselined] — it has
no baseline to compare against. A host present only in golden.jsonl is
tagged [decommissioned]. Neither counts as Degraded by default: a new
server is not worse than an old one, and a removed server is not inherently a
regression.
To make this a CI gate, add --fail-on-drift:
owlzops-mapper compare --baseline golden.jsonl today.jsonl --fail-on-drift
Exit code is 1 when any compared host has at least one Degraded change,
0 otherwise. The same gate works on the single-host path:
owlzops-mapper compare before.json after.json --fail-on-drift
Without --fail-on-drift, compare always exits 0 — historical behaviour
is preserved. A pipeline that never opted in keeps working.
Threat hunting & deep forensics
- Full
/procsweep for reverse shells, fileless/memfd implants, library injection, bind-mount masking --deep:process_vm_readvmemory reading, pointer resolution, Shannon entropy, binary header detection- Ghost PID / LKM rootkit detection, ftrace syscall hook attribution
- Content-bound verdict cache — no static allowlists
Container-aware
- Docker + Podman (including rootless) socket detection
- Privileged containers, dangerous capabilities, sensitive host mounts
- Missing resource limits, OOM kills, restart loops, health status
- Published ports cross-referenced with the host firewall state (SEC-065 / SEC-066)
Account & credential hygiene
- Weak password hashes (
$1$,$5$, DES), empty password fields, duplicate UID 0 - System accounts with an interactive shell
- Credential-file permissions:
/etc/shadow,/etc/gshadow,/etc/sudoers.d/* - SSH private keys readable by group/other;
authorized_keyswritable by non-owner /rootdotfiles readable by non-root (SEC-070…079)
Operational
- Agentless SSH fleet orchestration — parallel, automatic cleanup
- Snapshot + drift monitoring, terminal or colour-coded Excel
- JSONL / Excel export for SIEM ingestion
- Context-aware Risk Score — Docker/kubelet hosts are not punished for
ip_forward=1 - Separate Security / Reliability / Hygiene sub-scores
Trust & supply chain
- Single static musl binary, zero runtime dependencies
- Read-only, zero permanent footprint, no telemetry
- GPG-signed releases + SHA256 checksums + SBOM
- CI pins every GitHub Action by commit SHA, runs
cargo auditandcargo deny, and checks that remediation IDs in commit messages are reflected in the changed files - Source-available under Apache 2.0 with Commons Clause
The design commitments above are stated as testable properties in SECURITY.md. A violation of any of them is treated as a vulnerability, not a bug.
| Flag | Description |
|---|---|
--deep |
Memory forensics + ghost PID detection (root only) |
-f, --format |
text (default), json, excel / xlsx |
-o, --output |
Output file for Excel reports |
--offline |
Disable all network calls, overrides other flags |
--host / --hosts |
Remote target(s): comma-separated list or file |
--hosts-from-ssh-config <PATTERN> |
Select Host blocks from ~/.ssh/config by alias glob; each contributes its HostName to the fleet. May be repeated |
--ssh-user / --ssh-key |
SSH credentials for remote scanning |
--copy-binary |
Upload the static binary automatically |
--local-binary |
Path to the static binary to upload instead of the running one |
--ask-sudo-pass |
Prompt for sudo password, forwarded over SSH |
--sudo-pass-fd N |
Read sudo password from an already-open file descriptor (most secure) |
--keep-binary |
Skip cleanup, leave the binary on the remote host. Requires --remote-path when used with --copy-binary |
--fail-on-incomplete |
Exit with code 4 when coverage is incomplete (failed scanner, missing host, non-root, warnings) |
--accept <FILE> |
Policy file: matching findings leave the score and the exit verdict, but stay visible in the report. See Accepting known risks |
--baseline |
compare: treat the first input as a known-good baseline; pair hosts by hostname and word output as drift. Implies --multi-host. See Drift against a baseline |
--fail-on-drift |
compare: exit 1 when any compared host has a Degraded change. Without it, compare always exits 0 |
--external-ip |
Opt-in public IP lookup |
-v, --verbose |
Full per-region memory detail |
Subcommands: audit, snapshot, compare <before> <after>, dir-compare <dir>.
Full list: owlzops-mapper --help.
| Code | Meaning |
|---|---|
| 0 | Clean — full coverage, no critical or compromised findings |
| 1 | Critical findings present — full coverage |
| 2 | Degraded — incomplete coverage: not running as root, warnings, failed scanner(s), missing host(s), or JSONL write errors |
| 3 | Active compromise detected — regardless of coverage |
| 4 | No verdict at all, or --fail-on-incomplete was used and coverage was incomplete |
| 64 | Usage error: invalid CLI arguments or input files |
| 130 | Interrupted by SIGINT/SIGTERM |
Codes 0–3 are a stable public contract from v0.6.0.
Their meaning does not change between releases from v0.6.0 onwards.
New failure modes get new codes; they never override the existing band.
--accept can suppress a finding that would otherwise drive code 1 or 2, but
never code 3: compromise IDs are refused at load.
compare uses the same band: 0 when nothing drifted, 1 when
--fail-on-drift saw at least one Degraded change. Without the flag a diff
still exits 0, so an existing pipeline keeps its old behaviour.
In fleet mode, hosts that produced no report are listed by address in stderr.
hosts_missing in the exit-code path remains a count; the stderr line carries
the actual address list for operator follow-up.
sudo owlzops-mapper audit || echo "Security scan failed — check the report"
0–100, lower is better, split into three capped sub-scores:
| Category | Cap | Examples |
|---|---|---|
| Security | 60 | Firewall, SSH, updates, Docker risks, malware/IoC |
| Reliability | 30 | Failed services, missing backups, OOM, container health |
| Hygiene | 10 | NTP |
Colour legend: green < 40 · yellow 40–69 · red ≥ 70.
--accept can remove a finding from the score entirely — see Accepting known
risks. The scanner-level suppression lines
(SEC-027, SEC-029, SEC-034, SEC-037) that appear under the findings
table are a different mechanism and are not affected by --accept.
Active-compromise indicators escalate to exit code 3 regardless of score. When comparing snapshots taken with different scoring engine versions, score changes are reported as ~ Changed rather than improved or degraded.
Full penalty table — every finding and what it costs you
Weights are published so you can argue with them. If a penalty looks wrong for your environment, that's a legitimate issue to open.
Baseline configuration
| Finding | Penalty |
|---|---|
| Firewall inactive | +30 |
| SSH root login allowed | +25 (prohibit-password reduces weight) |
| Pending security updates | +20 (stepped: 10/15/20 by count) |
| No backup tools detected | +20 |
| SSL certificate expires within 7 days | +15 (max) |
| Sudo NOPASSWD entries found | +5 (restricted commands) / +15 (ALL). R34-01: when SEC-012 also fires on the same sudoers line, SEC-005 drops to +5 (plain tier) — the passwordless-root class is charged once |
| SEC-061 – Passwordless root via group membership (bypasses sudo auth) | +15 (weight 0 when SEC-005 or SEC-012 already carry the same class) |
| SSH password authentication enabled | +10 |
| Failed systemd services | +10 |
| OOM kills present | +10 |
| NTP not synchronized | +10 |
| Root login with password (combo) | +5 |
| Sudoers permissions not 0440 | +5 |
| Sysctl security issues | +5 per issue (context-sensitive) |
| SEC-081 – systemd unit hardening posture below baseline | +5 (flat; count and worst unit in evidence) |
Account & credential hygiene
| Finding | Penalty |
|---|---|
Empty password in /etc/shadow |
+40 |
| Non-root UID 0 account | +25 |
authorized_keys writable by non-owner |
+25 |
| Weak password hash (MD5/DES) | +20 |
| SSH private key readable by non-owner | +20 |
/etc/shadow or /etc/sudoers.d/* unsafe permissions |
+15 |
/etc/gshadow unsafe permissions, /root dotfiles readable |
+10 |
| Weak password hash (SHA-256-crypt) | +10 |
| System account with interactive shell | +5 |
Containers
| Finding | Penalty |
|---|---|
| Container mounts runtime control socket or host root | +15 |
| Container mounts sensitive host path (writable) | +10 |
| Privileged containers | +10 |
| Dangerous capabilities | +10 |
| Containers killed by OOM | +10 |
| Unhealthy containers (failing healthcheck) | +10 |
| Containers in restart loop | +5 |
| Containers without memory limits | +5 |
| Containers without CPU limits | +3 |
| SEC-065 – Docker published port on a non-loopback host address, firewall active | +20 (+35 for a database / cache / admin port) |
| SEC-066 – Docker published port on a non-loopback host address, no host firewall | +15 (+25 for a sensitive port) |
Active compromise (IoC) — these escalate to exit code 3
| Finding | Penalty |
|---|---|
| SEC-015 – Privileged non-root implant on network | +60 |
| SEC-016 – Known malicious process (by name) | +60 |
| SEC-017 – Fileless malware (deleted executable / memfd) | +60 |
| SEC-019 – Fileless malware with critical kernel caps | +60 |
| SEC-020 – Kernel-thread masquerading process | +60 |
| SEC-021 – Bind-mount / overlay masking | +60 |
| SEC-022 – Reverse shell / C2 connection | +60 |
| SEC-023 – Userspace rootkit / library injection | +60 |
| SEC-024 – True Ghost PID (LKM rootkit) | +60 |
SEC-028 – Unattributed executable payload in memory (--deep) |
+60 |
| SEC-040 – Hidden kernel module (Diamorphine-class LKM rootkit) | +55 |
| SEC-041 – Ftrace syscall hook by hidden module | +55 (via SEC-040) |
| SEC-018 – Suspicious cron job (persistence) | +20 |
| SEC-025 – Downgraded PID visibility mismatch | +20 (no exit-code escalation) |
| SEC‑042 - System‑wide LD_PRELOAD injected (volatile or corroborated) | +55 or +60 |
| SEC‑049 - System‑wide LD_PRELOAD present (ownership unverifiable) | +20 |
| SEC‑050 - System‑wide LD_PRELOAD injected (unpackaged, not yet mapped) | +30 |
| SEC‑055 – PAM module outside trusted directory / writable by non‑root | +55 |
| SEC‑056 – Unpackaged or root‑only PAM execution target (script) | +30 |
| SEC‑057 – PAM slot writable by non‑root (staged injection) | +30 |
| Kernel & confinement |
| Finding | Penalty |
|---|---|
| SEC-060 – CPU speculative-execution mitigations disabled or absent | 0 informational (escalates as drift: on→off = Degraded) |
| SEC-041 – Unexplained ftrace syscall hook (visible module) | +30 (verify EDR?) |
| SEC-039 – SELinux running permissive | +15 |
| SEC-038 – Kernel tainted by unsigned / force-loaded module | 0 informational (e.g. NVIDIA driver) / 10 forced load-unload / 25 correlated with SEC-040 |
SEC-041 – Unattributed ftrace hook under kptr_restrict |
0 (informational, drift still detected) |
| SEC-039 – AppArmor profiles in complain mode | 0 (enforce→complain reported as Degraded by compare) |
Capabilities
| Finding | Penalty |
|---|---|
| CAP-001 – Non-root process with critical capabilities | +8 (loopback) / +20 (wildcard exposure) |
| CAP-002 – Ambient capabilities with NoNewPrivs disabled | 0 benign (IPC_LOCK, SYS_TIME) / 5 (NET_RAW) / 12 escalation-capable (SYS_ADMIN, SYS_PTRACE) |
Visible but unpenalised — surfaced for the operator, weight 0 by design
| Finding | Penalty |
|---|---|
| SEC-029 – Provisional trust (binary attributed, memory unverified) | 0 (auditable) |
| Category | Details |
|---|---|
| Malware & Intrusion | /proc sweep, memfd, deleted executables, reverse shells, library injection, hidden PIDs, bind-mount masking |
| Security | SSH config, root login, password auth, sudo/NOPASSWD, sudoers permissions, sysctl, fail2ban/auditd, account & credential hygiene (SEC-070…079), systemd unit hardening (SEC-081) |
| Network | Listening ports + bind address, firewall state, DNS, TLS certificate expiry |
| Docker / Podman | Privileged flag, capabilities, sensitive mounts, resource limits, OOM, health, socket exposure, published ports vs host firewall |
| Kernel | Taint flags, hidden modules, ftrace syscall hooks, LSM confinement downgrade |
Memory (--deep) |
process_vm_readv, entropy, binary headers, origin attribution, verdict cache |
| Packages | Security updates (apt/dnf/yum/pacman/zypper) |
| Databases | PostgreSQL, MySQL/MariaDB, Redis, MongoDB |
| Internals | Cron (severity-classified), systemd timers, /etc/hosts overrides, failed units |
| System | OS, kernel, uptime, CPU/RAM, LSM, disk/inode usage |
| Backups | restic, borg, duplicati, rsync patterns |
Full field reference: docs/FIELDS.md
Read-only. No telemetry. Nothing stays resident. Scan output goes where you tell it and nowhere else.
To report a vulnerability in the tool itself, email [email protected] — please don't open a public issue. Scope, response times, safe harbour and design commitments are in SECURITY.md.
False positives are not security issues — open a normal issue for those. They matter and they get fixed, and a good false-positive report is one of the more useful things you can send.
Every release ships, per target (linux-x86_64, linux-arm64, macos-arm64): the tarball, its SHA256 checksum, a GPG signature over each, and an SBOM.
gpg --import gpg-public-key.asc
gpg --fingerprint 63C349F81ACBB9929EF8E73EB47BCE304E7C265E
gpg --verify owlzops-mapper-linux-x86_64.tar.gz.sha256.asc \
owlzops-mapper-linux-x86_64.tar.gz.sha256
sha256sum -c owlzops-mapper-linux-x86_64.tar.gz.sha256
gpg --verify owlzops-mapper-linux-x86_64.tar.gz.asc \
owlzops-mapper-linux-x86_64.tar.gzSigning key fingerprint: 63C3 49F8 1ACB B992 9EF8 E73E B47B CE30 4E7C 265E. Check it — a key fetched from the same repo as the binary proves nothing on its own. Full detail in SECURITY.md.
git clone https://github.com/OWLZOPS/owlzops-mapper
cd owlzops-mapper
cargo build --release
sudo ./target/release/owlzops-mapper auditStatic musl build, recommended for remote use:
rustup target add x86_64-unknown-linux-musl cargo build --release --target x86_64-unknown-linux-musl
Requires Rust 1.85+, Linux target.
The scanner tells you something is wrong. Owlzops tells you how wrong — then closes it. Fixed price, defined scope, and the engineer who scopes the work is the one who does it.
| What the mapper found | What it means | What we do |
|---|---|---|
| Exit code 3 — SEC‑015…024, SEC‑028, SEC‑040, DOCK‑010 | Evidence of a rootkit, backdoor or fileless implant. Something is likely already inside. | Infrastructure Security Audit — read-only, point-in-time, IoC review and attack-surface map, ranked by what an attacker reaches first |
Risk Score ≥ 70, firewall off, docker.sock mounted, privileged containers |
Systemic architectural exposure. Automated scanners will find this before a human does. | Infrastructure Hardening — staged, reversible remediation with a before/after diff, without breaking production |
| Pending security updates, CIS gaps, drift since last snapshot | Accumulating debt, and the thing a SOC 2 / ISO 27001 auditor will ask you about | Covered inside the audit — the risk matrix ships with CIS references as engineering evidence. We are not an accredited auditor and don't sign anything off. |
Run the scanner first. If it comes back clean, we'll tell you so — and we won't sell you the audit. We'd rather lose the sale than charge for confirming what a free tool already told you.
If it didn't come back clean, send the JSON report. You get a free 30-minute review call: we read the report before we talk, and you leave with the remediation order and what we'd do first — whether or not you hire us.
→ Send your report · no pitch, just engineering facts
Apache 2.0 with Commons Clause — free to use, not to resell.
You may use owlzops-mapper for commercial purposes, internal audits and security checks without restriction, forever. The Commons Clause only prevents third parties from taking the codebase and selling it as their own product or SaaS.
This is source-available, not open source in the strict sense — the Commons Clause fails the Open Source Definition, and we're not going to pretend otherwise. The full scanning engine is readable: you can review every line that will run as root on your servers before you run it. That's the point.
See LICENSE for the full text.
If the tool found something real on your box — star the repo. It's the only distribution this project has. Issues and false-positive reports are equally welcome.