Architecture¶
privmap is organized as five ingesters that all write into a single shared graph, followed by a traversal pass that reads from it and emits paths.
┌────────────────────┐
/etc/passwd ─> │ IdentityIngester │ ──┐
/etc/group └────────────────────┘ │
/etc/sudoers │
│
┌────────────────────┐ │
filesystem ─> │ FilesystemIngester │ ──┤
walk └────────────────────┘ │
│
┌────────────────────┐ │ ┌──────────────────┐
cron, systemd ─>│ ExecutionIngester │ ──┼──> │ PrivilegeGraph │
init.d └────────────────────┘ │ └──────────────────┘
│ │
┌────────────────────┐ │ v
getcap, /proc ─>│CapabilityIngester │ ──┤ ┌──────────────────────┐
└────────────────────┘ │ │ DFS traversal │
│ │ + validation │
┌────────────────────┐ │ │ + scoring │
/proc ─>│ ProcessIngester │ ──┘ └──────────────────────┘
└────────────────────┘ │
v
[escalation paths]
Source layout¶
privmap/
├── cli.py # entry point, argparse, snapshot extraction
├── __init__.py # public API re-exports, __version__
├── ingestion/
│ ├── identity.py # passwd, group, shadow, sudoers (with aliases)
│ ├── filesystem.py # perm walk, SUID/SGID, world/group-writable, ACLs, symlinks
│ ├── execution.py # cron, systemd units, init.d, config-arg INFLUENCES_EXEC
│ ├── capabilities.py # file caps via getcap, process caps via /proc
│ ├── processes.py # running process metadata
│ ├── boot.py # /etc/profile.d, ld.so.preload/conf, polkit rules, /etc/skel
│ ├── auth.py # doas, sudo version, sudoers permissions, sudo tokens, Kerberos
│ ├── ssh.py # sshd_config, host keys, per-user authorized_keys / id_*
│ ├── network.py # NFS exports, fstab, hosts.equiv, /etc/hosts, /proc/net/*
│ ├── container.py # Docker/LXC/k8s markers, writable bind mounts, full caps
│ ├── secrets.py # /proc/[pid]/environ credential scan
│ ├── path_abuse.py # writable PATH dirs, .sh on PATH, unqualified-binary chains
│ ├── pam.py # PAM bypass (rootok/permit/nullok/wheel)
│ ├── dbus.py # /etc/dbus-1/system.d/*.conf policy analysis
│ ├── inetd.py # /etc/inetd.conf, /etc/xinetd.d/*
│ └── apparmor.py # /etc/apparmor.d/* profile mode
├── graph/
│ ├── model.py # Node, Edge, PrivilegeGraph, EscalationPath
│ ├── builder.py # coordinates ingesters, emits progress events
│ └── traversal.py # DFS, validation filters, sink/source predicates
├── analysis/
│ ├── paths.py # public analyze_paths() (runs traversal + scoring)
│ ├── scoring.py # exploitability * impact -> severity
│ └── remediation.py # per-path fix suggestions
└── output/
├── cli_output.py # rich-based terminal renderer
├── json_export.py # structured JSON
└── markdown_export.py # GitHub-flavored markdown
Phase ordering¶
The builder runs ingesters in a fixed order because some emit nodes that later ingesters reference:
- Identity. Must run first. Creates
USERandGROUPnodes. Sudoers parsing emitsSUDO_RULEnodes andGRANTSedges. - Filesystem. Emits
SUID_BINARY,FILE,DIRECTORY, world- and group-writableCAN_WRITE, and ACL-basedCAN_WRITEedges. NeedsUSERandGROUPnodes from phase 1 for group-member expansion. - Execution. Emits
CRON_JOB,SYSTEMD_UNIT,INITD_SCRIPTnodes andRUNS_AS,EXECUTES, and config-argINFLUENCES_EXECedges. Periodic cron scripts have their contents parsed for embedded binary invocations and config arguments. - Capabilities. Emits
FILE,CAPABILITYnodes,HAS_CAPABILITYedges, and per-userCAN_EXECedges. The CAN_EXEC check consults the live filesystem in live mode or the capturedpermissions.txtin snapshot mode. - Processes. Emits
PROCESSnodes for non-root processes with elevated caps. Read-only relative to other phases. - Boot / login.
PROFILE_SCRIPT,LDPRELOAD_FILE,POLKIT_RULE,LOGIN_HOOKnodes withEXECUTED_AT_LOGINandINFLUENCES_EXECedges to root. - Auth.
DOAS_RULEnodes, sudo version capture, sudoers file- permission rechecks, Kerberos ticket files, active sudo timestamp tokens annotated on user nodes. - PAM.
PAM_FILEnodes for/etc/pam.d/*with findings for pam_rootok, pam_permit, nullok, pam_wheel issues. - SSH.
SSH_KEYnodes for host and per-user keys, sshd_config risky-setting detection. - Network.
NFS_EXPORT,NETWORK_LISTENERnodes, fstab and hosts.equiv parse, /etc/hosts writability. - Container.
CONTAINER_MARKERandMOUNTnodes for container detection and writable bind-mount detection. - PATH abuse.
PATH_DIRnodes for every directory on $PATH, plus chain wiring from PATH dirs to crons / systemd units that invoke unqualified binary names. - Secrets.
SECRET_FINDINGnodes for credentials surfaced via /proc/[pid]/environ. - D-Bus.
DBUS_POLICYnodes for over-permissive policy files. - Inetd / xinetd.
INETD_SERVICEnodes for legacy super-server configuration. - AppArmor.
APPARMOR_PROFILEnodes with profile-mode detection.
Live vs snapshot¶
Every ingester accepts a snapshot_mode: bool. When true, it reads from
a captured directory tree (produced by collect.sh) instead of running
live queries. The ingesters branch internally on this flag; the graph it
produces is structurally identical.
See Snapshot mode: Live vs snapshot for the per-ingester difference table.
Extension points¶
These are de facto extension points today. A formal plugin API is on the roadmap.
- Adding an ingester. Subclass nothing. Implement
ingest(graph: PrivilegeGraph) -> Noneand call it fromGraphBuilder.build(). The fixed phase order is enforced manually. - Adding a node or edge type. Extend the
NodeTypeandEdgeTypeenums ingraph/model.py. Add scoring rules inanalysis/scoring.pyand rendering in each output module. - Adding an output format. Write an
export_<format>(paths, graph) -> strfunction inoutput/, then wire it into the--outputchoice incli.py. - Adding to the known-safe lists. Edit
AUTH_REQUIRED_SUIDingraph/traversal.pyorKNOWN_SAFE_CAP_BINARIESiningestion/capabilities.py. PRs welcome.
Performance characteristics¶
| Phase | Bottleneck | Scaling |
|---|---|---|
| Identity | /etc/passwd parse |
O(users) |
| Filesystem walk | os.walk over scan paths |
O(files in scan paths). Dominant phase. |
| ACL ingestion | getfacl -R subprocess |
O(files with ACLs). 60s timeout. |
| Execution | systemd unit parse | O(unit files) |
| Capabilities | getcap -r / subprocess |
O(capability binaries). 120s timeout. |
| Processes | /proc enumeration |
O(running processes) |
| DFS traversal | Path explosion in dense graphs | Bounded by --max-depth |
On a typical Debian server (~80k files in default scan paths, ~50 users, ~200 SUID binaries, ~300 systemd units), a full run completes in 30 to 90 seconds. Snapshot mode is faster because subprocess-driven phases are replaced by file reads.