Security model #
What Zap trusts, what it refuses to trust, and where the walls are.
The threat model in one paragraph
The operator is trusted: whoever runs zap apply --wetrun already holds SSH and sudo on the target, and could do anything by hand. Zap's job is to make what
that person (or the assistant working for them) does reviewable, bounded, and
observable: a config can't act on its own, nothing Zap does to the target
goes unshown except the checks a config declares (they run as written on every
pass, before any review, and Zap can't make
them read-only), a read that fails never passes as "fine", and secrets don't
leak into the places people and agents read. Zap is not a sandbox for hostile
configs you didn't write; see the config is first-party.
The config can only describe
What this means for writing a config is in configs.md. The walls themselves: zap.ts runs in fgro eval, Gro's runtime, a separate binary Zap runs once per evaluation. Its
walls are its own, set up before any of the config's code, or V8 itself, runs:
- a kernel box: Landlock admits reads only under the config's project and
the packages linked into its
node_modules, and seccomp narrows the system calls. No write anywhere, no network socket, no exec, no read outside those directories (/procand/devincluded; metadata such as whether a path exists stays visible), and no signal to another process. There is no setting that weakens it, and on a host that can't hold it (Linux below Landlock ABI 6, an architecture its filters aren't built for) the evaluation fails rather than run without it; - no environment: the evaluator starts with
TZ=UTCand nothing else of yours, reads no variable for its own settings, and gives the config noprocessand no environment API. A variable in your shell can't loosen the sandbox or reach the declaration except throughzap.env(), which is a reference resolved later by Zap, never a value; - only static imports: every
import()is refused, and anode:built-in doesn't resolve. Resolution is strict ESM: a relative import names its extension, and a bare import resolves through thenode_modulesof the project and theexportsof the package found there (never itsmain), under the conditionsgro,importanddefault; - no clock, no randomness, no timers: reading the clock, a random source, or
a fact of the machine throws, and there are no timers; the time zone is UTC
and the locale
en-US(configs.md lists what is refused); - jitless: V8 runs in its interpreter, so
WebAssemblyis absent; - no code from strings:
evalandnew Functionthrow; - bounds: a capped heap and process memory, a 30 second timeout, and a cap on the JSON it may emit.
The config's only output is a JSON declaration that Zap validates against a strict schema. It cannot run commands, write files, or call back into the engine.
The config needs a project. The project is the nearest directory at or above
the config whose package.json holds more than "type" and "sideEffects" (a {"type": "module"} marker in a subdirectory is passed over), outside any node_modules, looking only through directories you own that no other user can
write. Zap finds it before the evaluator runs and refuses, exit 2, when:
- the config file or its directory is writable by every user (for a config reached through a link, the mode of the file it leads to);
- a
package.jsonon the way up can't be read or isn't valid: not a JSON object, a"name"or"type"that isn't a string, a FIFO, socket or device (refused unopened), a link to nothing, or a path through something that isn't a directory; - the walk stops below a directory another user owns (the evaluator's own walk
would go past it) or every user can write, or reaches
/, with no projectpackage.jsonfound. The refusal names where it stopped and why.
The evaluator then finds the project itself and must report the same one, or
nothing it wrote is used. Its box admits the project and the packages its node_modules links in from outside it; a package reached through a directory
every user can write isn't read. A package that isn't installed in the project
doesn't resolve: the search for a bare import stops at the project's package.json, so a node_modules above the project is never searched. npm and
yarn workspaces aren't supported: a workspace member with its own package.json is its own project, and the workspace's node_modules above it
isn't searched.
"You own" means the user Zap runs as. Run as root, that is root, and under sudo also the user who ran sudo (SUDO_UID), so sudo zap apply on a
config in your own project walks your directories as it would without sudo.
The evaluator never runs as root. Run as root, Zap runs it as the user who
ran sudo when that user owns the project, and otherwise as the project's
owner; a project root owns is refused, exit 2, since no user who could already
edit the config is there to run it as. A project that is the evaluating user's
home directory, or holds it, is refused too: the box would admit all of it. The
user it runs as must be able to search every directory down to the project, and
is never dropped into group 0: a drop whose group would be root's is refused.
Dropped to the user who ran sudo (who owns the project), its group is SUDO_GID (the group you ran sudo with; unset or not a number is refused);
otherwise, dropped to the project's owner, that owner's primary group. A drop
keeps no supplementary groups. Its home, and the primary group, come from the
passwd database; a static (musl) zap reads only /etc/passwd and nscd, so a user
known only through LDAP, sssd, or systemd-homed can have no entry there, and the
evaluation is then refused.
The evaluator is pinned. Zap runs the fgro binary --fgro or ZAP_FGRO names, by absolute path, never one found on PATH, and only when its blake3 is
the toolchain pin in the config's zap.lock; zap lock is the one command that
records the pin rather than checking it (lock.md).
The binary is opened once and run through the descriptor that was checked, so
a binary replaced at its path afterward isn't what runs. Its blake3 is read
through that descriptor, or, once the file has settled, from the pin cache
(~/.zap/cache/fgro_pins/, private to the user Zap runs as): an entry there
names the blake3 of a file by its device, inode, size, and modification and
change times, and a file whose metadata matches an entry isn't hashed again.
A write moves the change time, but only to the filesystem's timestamp
granularity, so an entry is written only for a file whose times are a couple of
seconds old, and a clock set back across that margin, or a network filesystem
whose server's clock lags, defeats it. A write to the file itself between the
check and the exec, by anyone who can write it, is caught only on a best-effort
basis. A link at the binary's own name, a wrapper
script, or a set-ID binary is refused.
Reads are the project's. A config reads nothing but the modules it imports,
from its project and the packages the project links. What stays the operator's:
the files in the project and the linked packages are trusted as they are, a
package installed as a directory included. The ownership walk doesn't read ACLs,
and the evaluator reads the disk again after Zap's walk: a project that changed
in between is caught by its report, not prevented. With ZAP_EVAL_CACHE=skip, a
cache hit is served without running the evaluator, so the project walk and the
evaluator's report aren't checked on a hit; the fgro binary is still held to
its pin (cli.md).
The config is first-party
Zap assumes the config and everything it imports are yours. If you ever evaluate
a config you don't trust, the plan it emits is the attack surface: review it as
you would a shell script, because its script resources are shell. A config
can't pull code from the network or import dynamically, and its imports stay
inside its project and the packages it links, so it can't take a project or a
package from a directory every user can write. A linked package is otherwise
trusted wherever it lives and whoever owns it.
What the gates reject before anything runs
Every command that reads a config, including config validate, runs two machine-free gates on the
declaration:
- the secret gate: a secret written literally into the config is rejected when Zap can tell (the same value is both referenced and resolvable in the current environment);
- the structural gate: a malformed or contradictory declaration, such as a NUL byte in a string a shell would see, a duplicate id, or a dangling or cyclic dependency. The full list is in configs.md.
A wetrun adds an env gate: any referenced variable that is unset or empty fails
the run before any side effect, so an empty secret never gets written into a .env file.
Zap changes nothing without a wetrun
No command changes the target unless you pass --wetrun, and no flag approves a
plan for you. A dry run connects, reads, and renders. Reading runs Zap's own
read-only probes and the commands the config declares as checks (a script's check, a verify's assertions, a package's command check, an apt source line's expansion), before any review. Zap
runs them as written: a check that writes, writes. Its only other network
traffic is fetching a missing content pin into the cache on your machine. This
holds with no terminal, in CI, and in an agent loop. zap lock and zap update write zap.lock on your machine, which is their job.
A wetrun executes only the plan you reviewed
A wetrun's fresh plan must match a reviewed one hash for hash, or it refuses
before any side effect; on an interactive terminal it asks you to type the start
of the hash instead, a question nothing typed earlier can answer. The rule, and
what plan_hash binds (the host, the connecting user, a local HOME, every
plain reference's value), is in concepts.md; zap deploy binds its own plan the same way
(deploy.md). So an agent given --expect-plan with the hash you approved, or a pipeline given the hash approved
in a pull request, can execute only that plan. A plan whose host, connecting
user, or deploy health domain resolves through a masked reference gets no hash,
and its wetrun is an error (env.md).
The host a hash binds is the name the config resolves it to, not where ssh takes that name: an ssh config whose HostName, User, or Port for it
changes between the review and the wetrun sends the reviewed plan to the machine
it now names, if that machine's key is a known one. What ssh -G prints is
configuration, not the machine, so the hash doesn't bind it; the machine itself
is held by host-key checking. Binding the machine to a plan would take its host
key's fingerprint, which the hash doesn't hold.
What the hash covers that a preview doesn't print
A preview prints the target and, under each change, what it declares and every
step, with a file's content as a descriptor: its kind, length, and the start of
its SHA-512 (for a template, of the template text as written). plan_hash also
covers:
- a
file's content bytes, and any literal credential; - a resource's
depends_on, and trigger sources; - package names for managers the target doesn't use;
- the text of the scripts Zap's own operations run (the put, link, directory,
extract, snapshot,
pg_hba.conf, and swap-path scripts, and an installer's fetch) and the command each runs in, with its arguments, privilege, and limits, so azapbuild whose scripts, or the way it calls them, differ from the one that previewed computes a different hash.
The hash is not a fingerprint of the zap binary: what it does natively on a
local target, and the few commands it composes directly around a path (the rm -f that clears a pending marker or removes what a failed validated write
created), are its own.
What an approved hash doesn't fix
What Zap can't know when it plans:
- a secret's value (the plan says only that the reference is one);
- the state of the target between the read and the change;
- what the target's shell expands inside a command (
$HOME, a variable in an aptsourceline), which on a local target is Zap's own environment, whoseHOMEan--env-filedoesn't set; - the bytes of a
sourcefile already on the target; - what an
installerURL serves that day; - the path PostgreSQL reports for its
pg_hba.conf; - in a deploy, the artifact bytes and the environment your
buildandpreparecommands run in.
Some commands also run before any review, as the connecting user, on every pass
that reads the machine (plan, detect, apply dry or wet, the deploy
preflight): a script's check, a verify's command assertions, a package's check when it is a command, and an apt source line, which the target's shell expands
to compare with the list, so a $(…) in it runs at zap plan. This is one
reason the config is first-party.
Escaped output
A review is only as good as the text it reads. Everything Zap prints as text
writes what it takes from the config, the environment, the command line, zap.lock, a file under ~/.zap, or the target with control, bidirectional,
and default-ignorable characters (every control character but tab, the line and
paragraph separators, zero-width characters and the like) spelled out as <U+XXXX>, and a tab as spaces, so such text can't move the cursor, overwrite
output, reorder what is shown, or sit in a line unseen. Text that is a block (a
command, a script, what a failed command printed) keeps its lines, each
indented under its first. The --json event stream writes the same characters
as JSON \u escapes. The escape doesn't make look-alikes distinguishable:
homoglyphs, wide or blank spaces, and combining marks print as they are, a long
line still wraps, and text that spells <U+000D> or {env:NAME} prints the
same as what Zap writes for one.
Secrets
- A config never reads the environment. Values are references
(
zap.env('NAME')), resolved by Zap when it acts. A reference holds a name, never a value. No value a reference resolves to, nothing a command prints, and no file's bytes can turn into one. - Which references are secrets, and the forms masking looks for: env.md. A secret's value is masked out of every
output: the text report, the JSON events, warnings, held reasons, drift
observations, deploy step output, and errors. Masking is by value: a secret a
tool prints in another form (the address
sshresolved a secret host name to) isn't found, and a secret under an ordinary name with an ordinary-looking value isn't known to be one untilmask()lists it. - Zap hands every command to its shell on stdin, detection and apply alike.
No command text,
envvalue, or working directory is on the argv ofssh, the login shell, orsh, on either machine, so none shows inps. File content travels on stdin too. A program a command starts still shows its own arguments while it runs, and that includes the ones Zap starts (curl <url>,psql -c <sql>). Give a program a secret through anenvvalue or a file it reads, not as an argument. - A value is substituted into a command as text, into whatever quoting
surrounds the reference. Zap refuses a value holding a single quote, a
NUL, or a reference delimiter (U+FDD0 or U+FDD1) in a command, and a double
quote in a
databaseidentifier, before anything runs: a wetrun checks every planned command first and exits 2 with nothing changed;zap planand a dry run warn. That makes a reference safe inside single quotes, where a shell reads every other byte literally, and Zap and the shipped recipes put every value there, except the options documented as shell words (psql_args,command_args, a stop or reload command). A reference a config author writes outside single quotes, bare or inside double quotes, is read by the shell like the text around it, and keeping it safe there is the author's job. Anenvvalue is quoted after it resolves and carries anything but a NUL and the reference delimiters U+FDD0 and U+FDD1. - The last-run report and
zap.lockhash a secret reference as a fixed token, and the report masks every registered secret out of any other value, so neither can be used to test a guess at a secret. A secret the rule doesn't recognize is hashed by value untilmask()lists it (lock.md). - A fetch error names the URL as the config declares it, its references unresolved, and the content cache names a file from the declared URL, so a token in a URL reaches neither a message nor a path.
remoteandbuildsources can carry a bearerauthtoken; it attaches as a request header to the source's own origin (scheme, host, and port) only: asignature_urlon another origin is fetched without it, and a redirect to another origin drops it. Its resolved value is never hashed and never reaches the lock.- There is no
--show-secrets.
Content Zap fetches
Every URL in a config is https://: a plain http://, file://, or any other
scheme doesn't validate, and neither does a URL carrying user:password@. The
one allowance is http:// to a loopback host for the fetches Zap makes itself,
for a local mirror. Zap follows a redirect only to another https:// URL (a
fetch that has stayed on loopback may be redirected on loopback), so a remote
server can't send a fetch to a plain-http port of the machine Zap runs on,
and a bearer auth goes only to the origin the config names.
Fetched bytes are pinned by blake3, verified against the pin before placement,
and cached by content address. A vendored pin is your own statement of which
bytes to trust. A tracked pin is trust on first fetch: zap lock, zap update,
or the first zap apply --wetrun records the hash of what the URL served then, and every
later fetch must match it. What authenticates that first fetch is TLS, plus a
minisign signature when a build source declares verify, checked before the
bytes are cached or pinned. A signature doesn't fix the version: an older
release the same key signed is caught only by the pin
(resources.md). The recorded pin is in zap.lock and in the plan hash, so it is reviewed like any other change. minisign's exit status is that verdict, so the binary is found once (the
absolute path ZAP_MINISIGN names, else PATH) and run by that path, and one
inside a node_modules directory, or where it or any directory on the way to it
(every one up to /, links followed) is in reach of a group, another account,
or anyone but root and you, is refused; a refusal or a failed check names its
path. With those directories trusted, the gap between the check and the run is
only root's and yours.
zap.lock is reviewed input, like the config: its config_hash detects
staleness, not forgery, so review the lock's diff before committing it
(lock.md).
One thing reaches the target with no pin. An installer is a script fetched
whole over https and then run: whatever its URL serves today, gated only by a check path. A fetch that fails fails the step, and so does one that
serves an empty script; nothing runs. Prefer a build or a pinned remote file when the upstream offers one. An apt_repo's signing key is pinned like remote content: Zap installs only bytes whose blake3 is the pin, and
detection compares the installed key with it
(resources.md).
Writes on the host
Zap installs a file by staging it under a new name in the file's own directory,
setting owner and mode on the open file, and renaming it into place; it never
follows a link at the file's name or at the staged name, and a file's or directory's mode and owner are never set by name. The bytes are checked
against their SHA-512 before the rename, so a transfer cut short installs nothing. A directory's owner
and mode are set on the directory itself; a link at its name is refused. A file keeps the owner, group, and mode it leaves unset from the regular file it
replaces, read from the name without following a link, never with a set-id or
sticky bit; a new one takes its directory's owner and group. A file with a second
name (a hard link) keeps nothing, and neither does anything below a directory
with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep — Zap walks up from the file's directory by .. to the
root, and checks every directory on the path as written, a link read as what it
leads to: there a file takes what Zap's own write gives, as root over SSH or as
you locally. A mode is never kept from a file with an extended POSIX access list
(ACL), whose mode bits show the list's mask: the write is refused until the
config declares mode. Zap sees a list through getfacl, else through GNU ls's +; a target with neither (stock Alpine) can't show one, and keeps the
mode. Elsewhere, whoever can write the directory can choose what is
kept, as they could replace the file; the files Zap's steps install never keep a
planted owner. So a file with a mode but no owner, written into a directory
another account can write, can end up readable by that account: it inherits that
account's directory, or the account plants a file there of its own for Zap to
keep. Declare mode, owner, and group on any file whose content the
directory's owner or group must not read: a planted file's mode is kept even
when the owner and group are declared, so without a mode the content can land
readable by everyone. A user's authorized_keys is written as that user, and a sudoers file is
installed only after visudo -c accepts it.
A file with a validate command is read before its write, so a failed check can
put it back. One script holds the file open and reads its bytes, mode, and
owner through that descriptor: as root over SSH, and locally as you, escalating
when refused. The change fails with nothing written when the name holds a link,
anything but a regular file (a fifo is refused without waiting on it), or a file
over 16 MiB. A rollback restores the bytes with the owner and group they had,
and their mode without any set-id or sticky bit the declared mode doesn't
carry, so root never makes a file set-id that the config didn't.
One window stays open: between the read's checks and its open, a link swapped in is opened (not read) and the read refused, unless it leads to a fifo, which holds the open until the read's five-minute timeout fails the change. A directory's owner and mode change has the same window and timeout.
Missing directories are made one at a time, each inside the one before it held
open, and given, through its descriptor, the owner and group of the directory it
was made in, so it belongs to someone who could have made it; a link swapped in
for one is refused, with nothing made through it. The owner is given only to the
empty directory Zap just made: a directory renamed over it before it was opened,
by the owner of the directory it was made in, is refused and given nothing.
Anywhere below a directory with its sticky bit set or that every account may
write (/tmp), however deep, on the path as written or where its links lead,
what Zap makes stays its own, as root over SSH or as you locally: an account
that made /tmp/cache, as a directory or as a link to one of its own, can't
have the directories Zap makes there given to it.
Reads of a file's bytes, as root over SSH, read only a regular file of its own:
a name that is a symbolic link, a directory, a device, or a fifo is refused
before it is opened, and what was opened is read only when it is a regular file
and still the file at the name, so no byte comes through a symbolic link. The
checks can't close the instant before the open: an account that can write where
a managed file lives and swaps the name for a symbolic link in that instant has
Zap open what the link names, though not read it, and opening some devices acts
on the machine (a watchdog's), while a fifo holds the read until its timeout. A
regular file with more than one hard link is not read either, whoever owns it:
a hard link is the same file under another name, which an account can make in a
directory it writes, so a root read of the name could read a file that account
linked there. The link count is read from what was opened, and such a file
holds its resource, with the fix: replace it with a copy of its own (a single
link). Keep managed files in directories only root can write all the same. A file's source is read the same way, and a user's own authorized_keys is
read as that user.
Zap does follow a link in a directory component of the path, so a managed path
under a directory someone else can write can be redirected to another
directory: keep root-managed files in directories only root can write. One
such redirection also slips past the shared-directory checks above: a link on
the path that leads through a second link sitting in a directory every account
may write, off both the path as written and the directories it resolves to
(/srv/app a link to /var/shared/app, itself a link the account that can
write /var/shared points at a directory of its own). Neither check passes
through /var/shared, so what is kept there, or given to directories made
there, is that account's choice. Keep the links on a managed path out of
directories others can write. The staged file is made by mktemp, mode 600 whatever the umask or a default access list on the directory would give, and
held open; before a byte is written it must be a regular file of the writer's,
empty, with one name, and still the file at the staged name. A local user who
can write the directory could make the write fail, or stall it by swapping the
staged name for a fifo; they could not send the content, mode, or owner
anywhere, nor have root create a file where a link of theirs points. A run killed outright, or one refused at
its stage check, can leave a .zap.* file beside its target. A target needs /proc.
The same holds for the files Zap's steps install: an apt sources list, the
nftables ruleset, and /etc/fstab are each installed whole by rename, never
edited in place, and so is an apt_repo's signing key. pg_hba.conf is too,
but as the postgres account, not root:
PostgreSQL names the file and postgres owns its directory, so a rewrite as
root would write wherever that account pointed it. The rewrite keeps the file's
group and mode, and refuses a file that doesn't belong to postgres or a
directory it can't write; detection reads the file as postgres as well.
A remote file's or build source's archive is extracted only into a directory
at its own name: a link there is refused. tar runs in a new directory inside a
private staging directory (mode 0700, the extracting user's) beside the
destination. The staging directory is held open from the moment it is checked:
a fresh, empty directory of the extracting user's, mode 0700, at its name,
and not a link. Everything inside it is then reached through the descriptor, so
an account that can write the destination's parent may rename the staging
directory, or put a link at its name, but can't steer where tar writes or
what is removed. What tar extracts belongs to the extracting user, with no
set-id bit on a file and nothing group- or world-writable, whatever the archive
records. Before the rename, still inside the staging directory, the new tree is
given the owner, group, and mode of the destination it replaces, read without
following a link and set on the directory held open, never with a set-id or
sticky bit; then it is renamed into place. Below a directory with its sticky bit
set or that every account may write, checked as for a file (the parent's path
as written and where it resolves), nothing is kept: the new tree stays the
extracting user's, at the mode mkdir gives it. A destination that has an
extended or default access list is refused, a declared mode or not; the list
must be removed (setfacl -b). Such an account can still move the destination
itself, or the tree once it is in place: a parent another account can write is
trusted to that extent. The
archive's contents are trusted as pinned: its author chooses the names inside
it. Neither tar writes through a symbolic link the archive carries. BusyBox tar will make a hard link to a file outside the archive that a member names, so
a new tree holding a file linked from outside it is refused, and removed before
it is renamed into place; hard links among the archive's own files extract.
A remote or build archive is uploaded as the connecting user into a
private staging directory of the run, then extracted as root. The extraction
opens it once and goes on only when the file it holds is a regular file, not a
link, owned by the account that ran sudo, with one link and writable by no one
else. It then copies what it holds into its own staging directory, which only
root reaches, and extracts that copy only when its SHA-512 is the digest of the
pinned bytes Zap passed, so a process running as the connecting user can't
swap or rewrite the archive between the upload and the extraction.
hardened_openrc_service never sources an env_file: its init script runs as
root, so root never parses it: start_pre has the service user check every
line (su, under a 10-second timeout), assigning nothing, and the daemon's own
process, already dropped to that user, exports the values before it runs the
command (recipes.md). A service user that
can write its env file changes its own environment, as under systemd, and can
make a start wait out the timeout or fail; the refusal start_pre prints is
that user's process's words.
A release archive sits in no shared directory on either machine. On yours it is
a file in a private directory of the run; on the target it is installed into
the app path as the connecting user, and the release script extracts only the
file it has open, after checking that it belongs to that user, is writable by
no one else, and has the digest Zap sent. So another account on the host can't
swap or alter it between the upload and the extraction. Nothing checks what
your build put in it. A deploy trusts whoever can write the app path as much as
it trusts the connecting user: they can re-point current. Deploying as root into a path another account can write lets that account redirect what root
creates there, so deploy as the path's owner. A run killed outright, or a
connection dropped between the upload and the script, can leave the archive at
mode 600 in the app path or in your temporary directory.
Authority on the host
Zap's authority is SSH plus the host's sudo. There is no agent, server, API,
or role system to compromise. Privileged commands carry an explicit sudo; on a
local target file operations escalate only when denied, and over SSH they run
under sudo (targets.md). Host
keys are trusted on first use and refused when changed, which holds only while ssh records them: Zap reads your effective ssh config once per target
(ssh -G) and refuses to connect when every UserKnownHostsFile is /dev/null. Every session has agent, X11, and port forwarding, a tunnel
device, a terminal, and LocalCommand turned off, whatever your config says. The host's own sudo I/O log (Defaults log_input, replayed with sudoreplay) is the audit trail of
what ran as root: the plain sudo log records one constant wrapper per
detection batch, and the I/O log captures the script, resolved reference values
included. It never sees the unprivileged login lane, where operator commands
run. A file write appears in the sudo log as one sh -c command carrying
Zap's constant install script, with the path as its argument; the mode and owner
are arguments too, and the content and its digest arrive on stdin, so a host
that logs sudo's input records them.
Fail closed
- A resource Zap can't read is held, not guessed. The run exits 1, and
deploy's preflight refuses to ship (exit 2). A "converged" that wasn't established is never reported. - A pin that isn't current is withheld, so a stale lock can't make a changed resource read converged.
- A reference the comparison needs but can't resolve makes the whole pass unverifiable, exit 1: an empty string can both hide drift and invent it.
- A
filewrite withvalidateis rolled back when the check fails, so a service is never reloaded onto a rejected config. - A failed reload is remembered on the target and re-fired on the next plan.
Security controls are read live, every time
firewall, user, and database guard the host's access surface and are
exactly what gets changed by hand: a ufw default allow incoming at the console,
or a key appended to authorized_keys. Zap keeps no detection cache, so such a
change shows on the next plan rather than hiding behind a stored verdict. The
same holds for security-relevant files: an sshd drop-in, a fail2ban jail, an
nginx site carrying a CSP. The exception is the lists Zap treats as additive
(below): a declared entry that went missing
drifts, but an extra one, such as an injected pg_hba trust line, isn't
read.
Limits and defaults to know
- Firewall allow rules, a database's
pg_hbarules, and a user's groups are additive. Zap asserts that each declared entry is present (and, for a firewall, that the default policies match); an entry added out of band is neither detected nor removed. - A
databaseresource writes nopg_hbarule unless it declaresauth.hosts, and never atrustone: the role is reached over the Unix socket by the OS account of the same name. Atrustrule already in the file (one an older config added) stays until it is removed by hand; on Alpine,alpine_postgres_setuprewritesinitdb's, and any othertrustline, topeer/scram-sha-256. - A
linkwhose path is a regular file reads as absent and is replaced. - A
filewith no managed mode, owner, or group whose path is a symlink to matching content reads converged; the link is left as it is. - Zap follows a symlink in a directory component of a managed path (see Writes on the host).
- A
swap'ssizeis not compared after creation.
Reporting a vulnerability
See the repository's security policy for how to report an issue privately.