Compare commits

...
46 Commits
Author SHA1 Message Date
branderson 9c61a28e86 Merge pull request 'Say what changed, hold alerts during expected changes, and shorten the commands' (#27) from review-and-windows into main
Reviewed-on: #27
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 15:40:54 -07:00
branderson ed2a843406 Say what changed, hold alerts during expected changes, and shorten the commands
Alerts now name the kind of change and the watched area it falls under, from
a fixed list, and report lists each path as added, modified or removed.
ignore_paths keeps a change in the report without alerting on it.

dejima-guard expect opens a window that holds change and unreachable alerts,
then accepts at its end. Critical paths and containment still alert, and a
window never accepts a critical path.

accept now runs a check afterwards so the heartbeat resumes, status
summarises the guard's state, and bin/dejima-guard-docker runs any command in
the container from any directory.
2026-10-15 15:09:56 -07:00
branderson fe9db52f68 Merge pull request 'Describe how the GitHub mirror shows the wordmark' (#26) from github-mirror into main
Reviewed-on: #26
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:32:04 -07:00
branderson 50f0f6980f Accept the GitHub mirror's form of the attribution link 2026-10-14 16:25:03 -07:00
branderson 674d77d788 Say how the GitHub mirror shows the wordmark 2026-10-14 16:24:58 -07:00
branderson aa4d7c9207 Merge pull request 'Refuse an argument this copy does not know, rather than ignoring it' (#25) from reject-unknown-args into main
Reviewed-on: #25
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 12:01:41 -07:00
branderson 4b700a24cc Refuse an argument this copy does not know, rather than ignoring it
`dejima-guard report` and `dejima-guard report --finding` gave identical output
on a live host, which read as the new flag doing nothing. What it did was not
exist there: the container had the previous image, whose dispatch was
`report) cmd_report ;;` and dropped every argument before the function saw one.

A container makes that state easy to be in and hard to notice, because `git
pull` changes the checkout and not the image. Silently ignoring the flag is what
turned "you are running an older copy" into "this feature is broken".

An unknown argument is an error now, and the message says which copy is
answering and how to replace it. That is the whole value: the failure diagnoses
itself instead of looking like a defect in the thing being asked for.
2026-09-03 12:00:45 -07:00
branderson 051c096c0d Merge pull request 'Keep the cycle that found something, past the quiet one after it' (#24) from keep-the-finding into main
Reviewed-on: #24
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 11:47:17 -07:00
branderson f86a6b5c75 Keep the cycle that found something, past the quiet one after it
last-report is overwritten every cycle, so a clean cycle erases the evidence of
the alert before it. Reported from a live host: alerts arriving every half hour,
and `report` showing "no changes against the baseline" - which reads as the
alert having been imaginary rather than as the report having been replaced.

Anything that alternates hits this every time, and a flapping line alternates by
definition, so the case where the evidence matters most is the case where it is
least likely to still be there.

The last cycle that found something is kept beside the last cycle. `report`
still shows the most recent, and says when an older finding is still available
rather than leaving the operator to wonder - but only when the two differ, since
after an alerting cycle they are the same file and pointing at it would be
noise.
2026-09-03 11:27:36 -07:00
branderson df590bf8c9 Merge pull request 'Let the entrypoint run one command, and say so for every command' (#23) from compose-commands into main
Reviewed-on: #23
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 16:19:16 -07:00
branderson 21ebd4709e Let the entrypoint run one command, and say so for every command
`docker compose run SERVICE CMD` replaces the command and keeps the entrypoint.
The entrypoint here is dejima-guard-loop, which ignored its arguments - so the
documented setup step

    docker compose run --rm dejima-guard dejima-guard trust-host

discarded both words and started an infinite check loop, in a container the
operator had to interrupt, while they waited for a fingerprint that was never
going to print. Nobody hit it because the only person running this used exec.

Arguments now mean "run this once", which also shortens the form: the entrypoint
is already the guard, so `run --rm dejima-guard accept` is the whole of it.
Naming the binary again passes it to itself.

The Use section listed bare command names, which is the host path. On a
container host there is no dejima-guard at all, or worse there is one from a
second deployment with its own baseline. Both container forms are documented
with the rule for choosing: exec into the container that is already there, run
a new one when it is not. Plus a shell function for anyone tired of the prefix.

The rebuild test is scoped to the setup block now. --build is right for the
commands that follow a git pull and wrong for a one-shot accept, which would
rebuild the image every time somebody read a report.
2026-08-29 16:00:38 -07:00
branderson aec0100ce6 Merge pull request 'Tell the kinds of change apart, instead of counting them together' (#22) from detect-categories into main
Reviewed-on: #22
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 15:29:52 -07:00
branderson e41cc06ecc Tell the kinds of change apart, instead of counting them together
Every finding arrived as one "unexplained changes" at high: a checksum that
moved because logrotate ran, a port that was not open yesterday, and the audit
binary itself being replaced were the same push.

A change under a watched path is its own alert now, at max. The audit's own
binary is the sharp one - if it changed, everything this guard has been told
since is suspect, including the report saying so, and it used to be
indistinguishable from a config edit among a hundred others. The forced command,
the sudoers rule and authorized_keys are the same argument one layer out: they
are what makes this access read-only, so a change to them changes what a stolen
key is worth. Configurable, with a default naming the machinery this design
rests on.

A newly present PORT or UNIT is max as well. Both are the shape of a thing
arranging to run again or to be reached, which is not a config edit. A port that
went away is not: removal is drift worth reporting and is not this shape.

Still one alert per cycle, ordered by what the operator has to do about it. And
still no path in an alert body: the devbox chose those strings, and a count plus
a category is enough to say go and read the report.

LINK lines are counted, since the devbox now collects symlinks - hashing cannot
see one, so a substitution would otherwise be reported as nothing at all.
2026-08-29 15:13:57 -07:00
branderson 551e814f81 Merge pull request 'Keep the push URL out of the compose file, since it is a credential' (#21) from heartbeat-credential into main
Reviewed-on: #21
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 14:06:55 -07:00
branderson 74761e0376 Keep the push URL out of the compose file, since it is a credential
An Uptime Kuma push URL carries its token in the path, which this project
already says: it is why the URL is passed to curl through a config on stdin
rather than in argv. The compose example then put it in an environment block,
where `docker inspect` prints it and anything that can read /proc can see it -
the same exposure the token was already being kept out of.

What it grants is worth stating rather than leaving as "a URL": anyone holding
it can tell Kuma this guard is healthy, which suppresses the one signal that
reports a guard that has died. That is the channel a compromised devbox cannot
reach, and the whole reason there is a second server in this design.

It goes in the mounted config beside ntfy_token now, with a test that neither
appears in the compose environment and that the setting is still documented
where it moved to.
2026-08-29 14:04:37 -07:00
branderson a837dd7255 Merge pull request 'Harden the host path, and describe the risk this actually carries' (#20) from unhardened-host into main
Reviewed-on: #20
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 13:54:03 -07:00
branderson 26d29e92f2 Harden the host path, and describe the risk this actually carries
From a security review by the agent working on dejima, prompted by the operator
asking whether the three gaps this page listed were really the risks, given the
guard usually runs on an ordinary VM with none of the devbox's hardening. They
were not. Five findings, all confirmed by reading the code.

THE UNIT RAN AS ROOT. The container path has always run unprivileged, on the
argument that root would be granting root to a program holding an SSH key to
another machine, for no reason at all. The unit disagreed with its own project:
no User=, and NoNewPrivileges does not drop capabilities for root. So a bug here
yielded root on a machine nothing in this repository hardens. SECURITY.md listed
"runs as a non-root uid" among the stronger properties, which was true of the
container and read as a property of the whole. Now an account of its own,
StateDirectory, an empty capability bounding set, and the rest of what a unit
that opens two connections and writes one directory can give up.

COLLECTION WAS UNBOUNDED AND THE DEVBOX CHOSE THE SIZE. The answer went into a
variable, then a file, then both sides of a diff. That is memory pressure and a
full /var/lib on a host running other things, which is the clearest thing the
watched machine can do to the watcher's. Capped now, with an over-long answer
treated as a finding rather than truncated: truncation would hand the devbox a
way to manufacture a diff, or to cut the state off just before what it added.
pipefail is off across that pipeline on purpose, since head closing the pipe
SIGPIPEs ssh and would report a capped read as a failed connection.

THE SSH CLIENT INHERITED A CONFIG THIS PROJECT DOES NOT OWN. ForwardAgent,
PermitLocalCommand, ClearAllForwardings and ControlMaster are named explicitly
now, on a connection to a host the design assumes may be hostile. Not -F
/dev/null, which would discard a ProxyJump an operator may need to reach the
devbox at all.

THE FIRST BASELINE WAS TAKEN UNATTENDED. accept exists because that is a
decision, and the timer's first fire bypassed it five minutes after boot, with
the warning going to a journal nobody reads yet. check refuses without one now
and sends no heartbeat, so a guard that has not been told what normal looks like
reports as down, which is what it is.

THE CONFIG FILE IS EXECUTABLE-EQUIVALENT, since alert_command runs from it.
Refused if writable by group or others, the way ssh treats a private key.

And the framing, which is what prompted the review: the page led with three
comparisons against dejima. Those are accurate and none of them is what a reader
most needs. It now leads with the two things that bound this program - the host
it happens to run on, and a collector that runs on the machine under suspicion -
and keeps the dejima comparison below them, where it belongs.
2026-08-29 13:07:47 -07:00
branderson 1012abe6fa Merge pull request 'Drop the em-dashes, and stop the ntfy section growing' (#19) from no-emdash into main
Reviewed-on: #19
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 01:52:42 -07:00
branderson cb84f51944 Drop the em-dashes, and stop the ntfy section growing
House style across the organisation, and the punctuation most reliably read as
machine-written, which is a poor look on a project about noticing what changed.
The shell already used " - " throughout; only the markdown disagreed with it.
Replaced by what each one was standing in for rather than by one substitute: a
colon where an explanation follows, a comma before a conjunction, a full stop
where the clause was doing too much. Two were paired dashes around a
parenthetical and became brackets.

A test keeps them out, building the character from its bytes rather than
writing it, since a check containing the thing it looks for reports itself and
can only pass by being exempted.

The ntfy section was 1138 words, a third of the README, for configuring
somebody else's service. It grew across three changes that each added rather
than consolidated: the warning, the mechanism and the server-wide note all
said overlapping halves of one thing, the diagnostics were two blocks, and four
one-line rules were four paragraphs. 757 now, with nothing operational dropped
- the four rules are a list, the two curl blocks are one, and the mechanism is
stated once, inside the warning where it is the reason to read it.
2026-08-29 01:44:24 -07:00
branderson 44d9a88fd1 Merge pull request 'Keep the secrets off the command line, and state what this holds' (#18) from secrets-off-argv into main
Reviewed-on: #18
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 01:28:20 -07:00
branderson c5a04ea89b Keep the secrets off the command line, and state what this holds
/proc/<pid>/cmdline is world-readable, so `-H "Authorization: Bearer $tok"`
hands the ntfy token to every local account for the length of the call. On the
systemd path this runs as root on a machine that may have other people on it.
The same is true of the heartbeat URL, which is a credential rather than a
destination: an Uptime Kuma push URL carries its token in the path.

Both move to a curl config on stdin, which no other process can read. The body
and the title stay in argv, being counts and a hostname. A token containing a
quote or a backslash is refused, since a curl config is still a parser being
handed a value from a file, and check_url already closed that door for URLs.

The test stub records argv and stdin separately, because which of the two a
secret travels in is the whole difference. It caught the change immediately -
the heartbeat assertions were reading argv - which is the behaviour wanted from
a suite that stubs the thing under discussion.

SECURITY.md, which this did not have and both sister projects do. It states
what the guard holds and what taking it would get, which is the risk running it
introduces rather than the one it addresses; and where the posture is weaker
than dejima's, since that comparison is the one a reader of both will make.
Three gaps, named rather than left to be found: no egress bound where dejima's
central claim is an allowlist, the private key inside the container where
dejima keeps its credential out entirely, and no logging of connections.

The egress gap is left open on purpose and says so. Closing it inside the
container would mean granting NET_ADMIN to something that currently drops every
capability, which is a worse trade than the gap.
2026-08-29 01:21:10 -07:00
branderson b9221c4dba Merge pull request 'Warn that deny-all takes unrelated notifications down with it' (#17) from denyall-consequences into main
Reviewed-on: #17
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 01:00:28 -07:00
branderson b2e578c7e1 Warn that deny-all takes unrelated notifications down with it
The page recommended a server-wide setting for one topic's sake and described
only the effect on that topic. Applied to a real server it closes every other
one, and one unreadable topic denies the rest along with it: the apps subscribe
to all of a server's topics over a single request, and ntfy authorises that by
looping the list and returning 403 for the whole request on the first topic the
account cannot read. Nothing reports which one. So a stray subscription takes
down alerts and notifications together, both correctly configured, and it reads
as the server being broken.

The symptom hides it further. A denied subscribe fails the WebSocket upgrade,
which the apps report as WebSocketNotSupportedException or a 403 on the upgrade
- which points at the reverse proxy. Publishing keeps working the whole time,
since that path carries a token, so a test alert arriving proves nothing about
whether anybody can read it.

The warning sits between the setting and the commands, where a reader meets it
before running them rather than after, and a test holds it in that order. The
grant block gives the reading account read on "*" rather than on the alert
topic alone - the narrow version is the configuration that took everything
else down, so the block somebody pastes should not be it.

Also stated, because it is the natural worry and the answer is not obvious: an
everyone grant does cover a logged-in user, on topics where that user has no
entry of their own. Logging in to read the alert topic does not cost you the
anonymous grant on dejima's.

Behaviour read from ntfy's source. The documentation covers neither the
multi-topic authorisation loop nor the everyone-versus-authenticated precedence.
2026-08-29 00:43:32 -07:00
branderson 40ebbecda4 Merge pull request 'Say how to close the ntfy topic and set the Kuma monitor up' (#16) from alerting-setup into main
Reviewed-on: #16
Reviewed-by: branderson <brad@branderson.io>
2026-08-29 00:01:47 -07:00
branderson 03e5617d24 Say how to close the ntfy topic and set the Kuma monitor up
The alerting section named the two services and left the setup to the reader,
on the two settings that decide whether an alert reaches anyone.

ntfy, on a server you control: auth-default-access deny-all, a write-only
account for the guard and a read-only one for the phone. deny-all is
server-wide - there is no per-topic default - so dejima's own topic is granted
back to everyone by name. dejima-notify-watch has no token to offer and does
not need one; nothing about dejima changes, and the devbox still ends up with
no access to the alert topic at all.

Neither account may be an admin, which is the caveat that undoes the rest of
it: an admin is not subject to the ACL and has read-write everywhere, so an
admin token is a key to the alert topic that no entry can take away.

On a server that is not yours to reconfigure, one topic cannot be closed
properly, and the page says so rather than pretending. Access falls back to the
default whenever no entry matches - for authenticated users as much as for
anonymous ones - so a deny list covers the accounts that exist and not the next
one somebody adds, and existing admins keep access regardless. What is left is
an unguessable topic name, which is not access control and is named as such, a
second server for the one topic, or alert_command.

Checked against docs.ntfy.sh rather than written from memory: the permission
keywords, the everyone pseudo-user, that auth-default-access is the fallback
for non-admin accounts too, and that admins bypass the ACL.

Uptime Kuma: the monitor type, where the push URL comes from, and the two
settings that matter. The interval has to exceed this program's own, since Kuma
counts from the last ping and the timer adds up to 60s of jitter on top - a
monitor set to match goes down on a healthy guard, which is the false alarm
that gets a monitor deleted. Retries at zero, because a missed ping is the
finding rather than a symptom to confirm.

The host path gets a heading of its own, so a reader who wants the container
can see where to start skipping.

Tests hold the documented numbers against the Dockerfile and the timer, and
hold the deny-all advice to carrying both the grant that keeps dejima working
and the admin caveat.
2026-08-28 23:50:00 -07:00
branderson aed2a2b2fa Merge pull request 'Carry the dejima marks, and say who owns them' (#15) from branding into main
Reviewed-on: #15
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 23:07:09 -07:00
branderson d30a5b8d8e Carry the dejima marks, and say who owns them
The wordmark and the badge, matching the two hardener repositories. The
comment above the tag comes with them: the wordmark is not preference-aware
on purpose, because prefers-color-scheme reports the browser's setting rather
than the page's, and a forge that renders dark either way would show dark ink
on a dark page to anyone whose browser prefers light. Both modes ship, so a
mirror on a light host edits one line rather than needing a file it does not
have.

The marks are not code and the GPL says nothing about them, so the attribution
is here and in assets/README.md, linking absolutely to dejima's ATTRIBUTION.md
- a fork resolves an absolute link to the canonical terms rather than to its
own copy.

Checked by rendering it: both images load and the wordmark reads on a dark
background.
2026-08-28 22:58:06 -07:00
branderson 5f0e853eae Merge pull request 'Rebuild the image, since nothing else will' (#14) from compose-build into main
Reviewed-on: #14
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 22:15:34 -07:00
branderson 9d7d82c6d2 Rebuild the image, since nothing else will
There is no registry for this: the image is built from the Dockerfile in the
checkout. `docker compose up -d` reuses whatever image already exists, so after
a git pull it starts the old code and reports success - an update that silently
did not happen, on the program whose job is to notice things.

`--build` on both commands that need an image. `exec` does not.
2026-08-28 22:14:43 -07:00
branderson d87e8d5e6d Merge pull request 'Treat the report as what it is, and cut the setup prose' (#13) from untrusted-text into main
Reviewed-on: #13
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 22:05:48 -07:00
branderson a21be2e4b4 Treat the report as what it is, and cut the setup prose
THE REPORT IS AN UNTRUSTED DOCUMENT. Alert bodies are composed here from counts
and that rule held, but the alert exists to say go and read the report, and the
report is the devbox's own words: file paths, unit names, whatever ssh said. A
path is a filename and a filename may contain an escape sequence.

`dejima-guard report` cats that to a terminal on the machine holding the SSH key
to the box under suspicion. ESC[2K ESC[A erases the line above and writes over
it, so a compromised devbox could name a file such that the evidence of it
scrolls past as an all-clear. Control characters are replaced with "?" as the
text is collected - replaced rather than deleted, since a filename containing an
escape sequence is itself a finding, and sanitised on the way in rather than on
display, so the baseline and every diff against it are clean too. Printable
bytes are untouched, so a legitimate non-ASCII path still reads as itself.

The setup sections lost a third of their words without losing a step: a
paragraph duplicating step 2 in full, the Alpine rationale that is already in
the Dockerfile, trust-host explained twice at length, and a second copy of "sent
is not received" that What it does not do already makes.

The rewrite found the install block never installed dejima-guard-install-key,
which the instructions tell you to run. A test now holds the two together.
2026-08-28 21:39:47 -07:00
branderson 5c3f9c7c2e Merge pull request 'Call it dejima-guard, not the watcher' (#12) from name-the-guard into main
Reviewed-on: #12
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 21:09:29 -07:00
branderson 55fd8d2562 Call it dejima-guard, not the watcher
It has a name. "The watcher" names no machine, reads as a role rather than
something to run, and in an alert it identified neither end of the connection
the alert was about.

The generic uses are reworded rather than renamed: "a fleet of watchers" is now
a fleet of dejima-guards, and the places describing what any guard does say
guard. What is renamed is every place the program was being referred to as
though "the watcher" were its name.

A test holds it, excluding the one assertion that quotes the old wording on
purpose.
2026-08-28 21:04:01 -07:00
branderson 9ea1fbaa85 Merge pull request 'Accept the URLs this project tells you to use' (#11) from url-charset into main
Reviewed-on: #11
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 21:01:50 -07:00
branderson 179498ff0e Accept the URLs this project tells you to use
The URL check permitted a short list of characters that did not include "&", so
it refused any URL with two query parameters - including the push URL Uptime
Kuma hands out, which is the one value heartbeat_url is documented to hold. The
project rejected the setting the project recommends.

Refusing a legitimate URL is not the safe direction. It costs the heartbeat,
and the heartbeat is the one signal a compromised devbox cannot suppress, so an
over-strict rule removes the control that does not depend on the watched
machine cooperating - and removes it quietly, since a guard that never pings is
indistinguishable from one that was never configured to.

The set is RFC 3986's now: unreserved, gen-delims, sub-delims and %. What stays
refused is what cannot appear in a URL anyway - whitespace, control characters,
quotes, backslash and < > ^ ` { | }.

Whitespace and control characters are checked separately, because the test that
deletes permitted characters cannot see a trailing newline: command
substitution strips it, so the remainder reads as empty and the value passes.

The message names the characters it found. "contains a character that is not
allowed here" was the whole report, on values long enough that finding it meant
guessing.

The shipped example carried a path with no query, so it passed a check that
would have refused the real thing. It is the whole URL now.
2026-08-28 20:58:05 -07:00
branderson 95efc90497 Merge pull request 'Check the guard can read its own key, and say so when it cannot' (#10) from key-readable into main
Reviewed-on: #10
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 20:48:13 -07:00
branderson f92ef81250 Check the guard can read its own key, and say so when it cannot
The container runs as uid 10001, compose mounts a file secret with the host's
ownership and mode, and the setup instructions said to chmod 600 a key owned by
the operator. So a deployment built exactly as documented could not open its own
key, and ssh reports that as "Permission denied (publickey,password)" - the same
thing a devbox that rejected the key says. The operator is sent to the far end
to debug a file on this one.

The key is checked before connecting, with the uid this runs as, the file's
ownership and mode, and the chown that fixes it. The setup does the chown now,
and says why it is not optional.

The uid is pinned in the Dockerfile with an explicit gid rather than whatever
useradd picks, because it is now a number written in the README that operators
chown to. A test holds the two together: a uid that moved between them would
produce this exact failure with the documentation insisting it was handled.

The cycle stops rather than alerting. A file on this machine is not a finding
about that one, and no heartbeat goes out - a guard that cannot use its key is
not watching, which is what the dead-man's switch exists to report.
2026-08-28 20:36:24 -07:00
branderson 4ea3827188 Merge pull request 'Record the devbox host key, and name both machines in alerts' (#9) from trust-host into main
Reviewed-on: #9
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 20:21:33 -07:00
branderson 01be9885ef Record the devbox host key, and name both machines in alerts
Nothing ever wrote the guard's known_hosts. StrictHostKeyChecking is left on by
design and BatchMode turns its prompt into a failure, so an empty file means
every cycle reports the devbox unreachable at max priority - permanently, which
is the false alarm that gets a topic muted. A fresh container is exactly that
state. The code described where the file should live and why it must persist;
nothing put anything in it. dejima-guard-install-key connects as the operator,
so it uses the operator's own known_hosts on a different account.

trust-host writes it, and stays a separate command: learning a key
automatically on the connection whose purpose is noticing that machine changed
would defeat the check it supports. It prints the fingerprint, says plainly
that trust on first use verified nothing, takes --fingerprint to check before
writing, and refuses to replace a key that has changed rather than choosing
between a rebuild and an intrusion.

check no longer sends ssh's stderr to /dev/null. The one failure that stops
this program working arrived with its reason already discarded and three
guesses in its place. The reason is logged here and never put in the push,
since it is the one string near an alert the far end has any say in. A host key
failure names the command that fixes it.

Alerts name both machines. "The watcher could not collect from the devbox"
identifies neither, and an operator watching two boxes cannot tell the pushes
apart. Titles use the short hostname, which is what survives truncation on a
lock screen; bodies use the configured name in full. Both come from this
machine's config, not from the watched host, which is what makes them safe to
interpolate.

Two bugs found while testing this: fingerprinting at the head of a pipeline
took the whole command down under pipefail when it could not read a key, and an
unparseable existing known_hosts entry compared equal to everything and
reported the host already trusted.
2026-08-28 20:17:30 -07:00
branderson 969d50e373 Merge pull request 'Read the refusal instead of the status of the thing that refused' (#8) from refusal-status into main
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 19:56:36 -07:00
branderson 00dc315d8e Read the refusal instead of the status of the thing that refused
The two negative checks piped ssh into `grep -q refused`. Under pipefail a
pipeline takes the rightmost non-zero status, and a refusal is dejima-guard-shell
exiting 64 relayed through ssh - so grep matched, the pipeline still came back
64, and a correctly restricted devbox was reported as having no restriction at
all. The positive checks were unaffected because they have no pipe.

Output is captured and tested for both halves of a refusal: the message, which
is what the runbook tells an operator to look for, and a non-zero status.
Neither alone separates a refusal from an ordinary failure, since cat
/etc/shadow on an unrestricted key also fails.

A failing check now prints what the devbox actually said. The evidence that
this check was wrong was in output it had already captured and discarded.

The ssh stub could not have caught either this or the IdentitiesOnly bug before
it: it shifted once and ran the key's path as the command, so all four checks
ran garbage and the phase had no coverage. It now answers as a devbox with the
forced command in place, and the new tests fail against both previous versions.
2026-08-28 19:55:08 -07:00
branderson 385ed1e9e3 Merge pull request 'Name the devbox section rather than numbering it' (#7) from stable-section-name into main
Reviewed-on: #7
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 19:16:36 -07:00
branderson 66c03d6c02 Name the devbox section rather than numbering it
The runbook's headings are numbered, so a section inserted above the guard one
renames it. This repository does not get renumbered along with that one, so a
number here goes stale silently and points a reader at whatever now occupies
the slot. The section has a stable name.
2026-08-28 18:12:56 -07:00
branderson 8badca1df1 Merge pull request 'Offer only the guard's key, or the restriction is decorative' (#6) from identities-only into main
Reviewed-on: #6
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 18:01:08 -07:00
branderson e7daa5c53b Offer only the guard's key, or the restriction is decorative
Reported from the first real run: install-key put the key on the devbox and
then reported all four checks wrong - the two the guard needs FAILED, and the
two that must be refused were NOT REFUSED.

`ssh -i` ADDS a key to those offered. It does not restrict to it. So with the
operator's own key to that devbox present, or an agent holding one, ssh
authenticated with that instead and no forced command applied. All four
results follow exactly: reach and drift ran as the operator without sudo and
failed, and cat /etc/shadow ran and said permission denied, which contains no
"refused".

The same line was in the guard itself, and there it is the worse one. The
installer at least made a noise. The guard would have collected happily on
the operator's unrestricted key, delivered its alerts, and left the forced
command protecting nothing - with nothing anywhere saying so, because
everything works.

Both now pass -o IdentitiesOnly=yes whenever a key is named, and not when one
is not, since with no -i there is nothing to restrict to and forcing it would
refuse identities the operator arranged another way.

The NOT REFUSED message now names the two causes worth checking first,
including a second unrestricted entry for the same key above the good one -
first match wins.

`bats test/` 44 passing, 3 new. None of the existing tests could have caught
this: ssh is stubbed, and a stub does not care which key it was not given.
2026-08-28 17:59:57 -07:00
branderson 28117389c6 Merge pull request 'Install the guard's key with its restriction attached, in one step' (#5) from install-key into main
Reviewed-on: #5
Reviewed-by: branderson <brad@branderson.io>
2026-08-28 17:54:37 -07:00
17 changed files with 2806 additions and 206 deletions
+9 -2
View File
@@ -20,8 +20,15 @@ RUN apt-get update \
# A uid of its own. It needs no privilege: it opens outbound connections and
# writes one directory. Running as root would be granting root to a program
# holding an SSH key to another machine, for no reason at all.
RUN useradd --system --create-home --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin --uid 10001 guard
#
# 10001 IS PART OF THE INTERFACE, not an implementation detail, and the gid is
# pinned rather than left to whatever useradd picks. Compose bind-mounts a file
# secret with the host's ownership and mode, so the key has to be chown'd to
# this uid on the host - a number that moved between builds would break every
# deployment that had done so.
RUN groupadd --system --gid 10001 guard \
&& useradd --system --create-home --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin --uid 10001 --gid 10001 guard
COPY bin/dejima-guard bin/dejima-guard-loop bin/dejima-guard-install-key /usr/local/bin/
RUN chmod 0755 /usr/local/bin/dejima-guard \
+341 -111
View File
@@ -1,6 +1,32 @@
<!-- Fixed, not preference-aware, and that is the decision rather than an
oversight. `prefers-color-scheme` reports the browser's setting, never the
site's, and this is read on a forge that renders dark whichever way the
browser is set - so anything conditional shows dark ink on a dark page to
a reader whose browser prefers light, and does it silently.
The suffix names the mode a file is FOR, not the colour of its ink:
-darkmode is the pale wordmark that belongs on a dark page. GitHub does
follow its own theme setting through <picture>, so the GitHub mirror
replaces the tag below with one that falls back to
dejima-wordmark-lightmode.png; see dejima's mirror/github-mirror.sh.
Every &nbsp; here is spacing, and it has to be a character rather than CSS
or a presentation attribute. Gitea's sanitizer strips `style` from the tag
and from any wrapper, `hspace`/`vspace` survive sanitizing only to be
ignored by browsers that dropped HTML4 presentation attributes, and a bare
<br> ends a line nothing follows, which browsers collapse to no height.
&nbsp; survives as a real U+00A0: on its own line it fills a paragraph
that would otherwise be empty, and immediately before the tag it sits
inline and indents the image by one space. -->
&nbsp;
&nbsp;<img src="assets/dejima-wordmark-darkmode.png" alt="dejima" height="72">
---
&nbsp;
# dejima-guard
Notices when a devbox changes, and tells you — from a machine that is not the
Notices when a devbox changes, and tells you, from a machine that is not the
devbox.
A devbox has no upper bound on how long a compromise lasts. Its sister project,
@@ -14,7 +40,7 @@ made the box report what changed. This is the part that reads those reports and
wakes you up.
**It does not run on the devbox.** That is the entire design, not a deployment
detail — see [Why it pulls](#why-it-pulls).
detail: see [Why it pulls](#why-it-pulls).
| | |
| --- | --- |
@@ -22,6 +48,8 @@ detail — see [Why it pulls](#why-it-pulls).
| [Why it pulls](#why-it-pulls) | The three things the inversion buys |
| [What it does not do](#what-it-does-not-do) | Read this before relying on it |
[![Developed in dejima](assets/dejima-badge.svg)](https://gitbox.branderson.io/dejima/dejima)
---
## Before you use it
@@ -32,7 +60,9 @@ the person who wrote it. Every security property this describes is a claim
awaiting scrutiny, not one that has survived it.
**One maintainer, no support.** Nothing about this is promised: not a reply, not
a fix, not a release.
a fix, not a release. [SECURITY.md](SECURITY.md) has what this claims, what it
holds, and where it is weaker than dejima. It is also where to report a
vulnerability rather than in a public issue.
**It is a second opinion, not a boundary.** Nothing here prevents anything. It
watches, and everything it can tell you is something that has already happened.
@@ -40,48 +70,84 @@ watches, and everything it can tell you is something that has already happened.
## Setting it up
It needs a machine that is always on and is not the devbox. A NAS, a home
server, a Pi, an existing VM — anything already running. **Not a new VM**: this
server, a Pi, an existing VM: anything already running. **Not a new VM**: this
is not a service, and a machine of its own would be a poor trade for something
whose whole job is to notice.
### On the host, with a systemd timer
```sh
sudo install -m 0755 bin/dejima-guard /usr/local/sbin/dejima-guard
sudo install -d -m 0750 /etc/dejima-guard /var/lib/dejima-guard
sudo install -m 0640 etc/config.example /etc/dejima-guard/config
sudo useradd --system --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin dejima-guard
sudo install -m 0755 bin/dejima-guard bin/dejima-guard-install-key /usr/local/sbin/
sudo install -d -m 0750 -o dejima-guard -g dejima-guard \
/etc/dejima-guard /var/lib/dejima-guard
sudo install -m 0640 -o root -g dejima-guard etc/config.example /etc/dejima-guard/config
sudoedit /etc/dejima-guard/config
```
Then a key of its own, restricted at the devbox end so it can run the two
read-only subcommands and nothing else:
**An account of its own, because the unit runs as it.** This holds an SSH key to
another machine, and a bug in it should yield that account rather than root on
whatever host you put it on. The config stays root-owned and group-readable: it
names a command the guard runs, so write access to it is code execution as the
guard, and `dejima-guard` refuses to start if it is writable by group or others.
A key of its own, put on the devbox restricted so it can run the two read-only
subcommands and nothing else:
```sh
sudo ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
sudo cat /var/lib/dejima-guard/id_ed25519.pub
sudo -u dejima-guard ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
sudo -u dejima-guard dejima-guard-install-key
```
`etc/config.example` has the `authorized_keys` line to pair it with. Then:
It authenticates to the devbox the ordinary way, so expect a password prompt:
the guard's key is what it installs, not what it uses. It then tries four
commands with the new key and reports whether the restriction holds. It needs
`dejima-guard-shell` and a sudoers rule on the devbox, both from
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access);
if either is missing it says which.
Then the devbox's host key, which nothing else records and every other command
needs:
```sh
sudo dejima-guard check # first run records the baseline, alerts nothing
sudo -u dejima-guard dejima-guard trust-host
sudo -u dejima-guard dejima-guard accept # the baseline, taken deliberately
sudo install -m 0644 systemd/dejima-guard.{service,timer} /etc/systemd/system/
sudo sed -i 's|@@BIN_DIR@@|/usr/local/sbin|' /etc/systemd/system/dejima-guard.service
sudo systemctl daemon-reload && sudo systemctl enable --now dejima-guard.timer
```
**Take the first baseline on a box you believe is clean.** One taken on a
compromised machine records the compromise as normal and reports clean forever.
**Take the baseline on a box you believe is clean.** One taken on a compromised
machine records the compromise as normal and reports clean forever. `check` will
not take one for you: under the timer that would happen five minutes after boot,
unattended, and everything the guard says afterwards rests on that file. Until
you run `accept` it reports as down, which is what a guard that has not been
told what normal looks like is.
`trust-host` prints the fingerprint before writing it: compare it against
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the devbox, or pass
`--fingerprint SHA256:...` and have it refuse anything else. Skipping it is not
a quiet failure: host key checking is left on, batch mode turns its prompt into
an error, and every cycle then reports the devbox unreachable at max priority.
### Or as a container
**1. Copy the example and edit it.** Every variable in it is commented with
what goes in it.
**1. Copy the example and edit it.** Every variable is commented with what goes
in it. Your copy is gitignored, and the example will not start as shipped: its
host is a placeholder the guard refuses, so it cannot become a live config by
being copied and forgotten.
```sh
cp docker-compose.example.yml docker-compose.yml
$EDITOR docker-compose.yml
```
**2. Make a key for the guard and put it on the devbox.**
**A copy, not an override.** `docker compose up` merges
`docker-compose.override.yml` with the file it overrides rather than replacing
it, so an override would leave the example's values in play.
**2. Make a key and put it on the devbox**, as described above.
```sh
mkdir -p secrets
@@ -89,95 +155,74 @@ ssh-keygen -t ed25519 -N '' -f secrets/guard_key -C dejima-guard
bin/dejima-guard-install-key --pub secrets/guard_key.pub you@devbox
```
That connects **as you**, with your own credentials — the guard's key is what
it installs, not what it uses. It then tries four commands with the new key and
tells you whether the restriction holds.
**Not `ssh-copy-id`.** It copies the bare key with no options, and a bare key
here is a shell on the devbox for whoever eventually takes *this* machine:
the one holding the evidence about that one. The line needs a forced command,
which `ssh-copy-id` cannot attach, and doing it by hand means copy-then-edit
with an unrestricted key live in between.
**Not `ssh-copy-id`.** That copies the bare key with no options, and a bare key
here is a shell on the devbox for whoever eventually takes *this* machine —
which is the machine holding the evidence about that one. The line needs a
forced command, and `ssh-copy-id` has no way to attach one. Doing it by hand
instead means copy-then-edit, with a window in between where an unrestricted
key is live; this writes the restricted line in a single append.
It needs `dejima-guard-shell` on the devbox and a sudoers rule permitting the
two audit commands — both from
[dejima-devbox-hardener §7](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md).
If either is missing the check above says which.
**3. Write the secret config.** This is the one file that is not a copy of
anything: create `secrets/config` and put in it whatever must not be in the
environment — on a self-hosted ntfy with access control, that is the token.
**3. Write the secret config.** The one file that is not a copy of anything:
whatever must not sit in the environment, which on a self-hosted ntfy is the
token.
```
ntfy_token = <the token, from your ntfy server>
heartbeat_url = <the Push URL Uptime Kuma gave you>
```
If your ntfy needs no token, the file can be empty, but it still has to exist:
compose will refuse to start with a secret it cannot find.
**Both of these are credentials, which is why neither is in the compose file.**
The token is obvious. The push URL is less so: it carries its token in the path,
and anyone holding it can tell Kuma this guard is healthy, which suppresses the
one signal that reports a guard that has died. Everything in a compose
`environment:` block shows up in `docker inspect` and to anything that can read
`/proc`.
An ntfy needing no token can leave that line out, but the file must exist:
compose will not start with a secret it cannot find.
```sh
chmod 600 secrets/guard_key secrets/config
sudo chown 10001:10001 secrets/guard_key secrets/config
```
**4. Start it, then prove the alert path works.**
**The `chown` is not optional.** The image runs as uid 10001, and compose mounts
a secret with the host's ownership, so a key that is `0600` and yours is one
the container cannot open. 10001 is fixed in the `Dockerfile` and is part of the
interface. Bind-mounting a host directory in place of the named volume needs the
same treatment.
**4. Record the host key and start it.**
```sh
docker compose up -d
docker compose run --build --rm dejima-guard trust-host
docker compose run --build --rm dejima-guard accept # the baseline
docker compose up --build -d
docker compose exec dejima-guard dejima-guard test-alert
```
**`--build`, every time.** This builds from the `Dockerfile` rather than pulling
from a registry, and `up -d` reuses whatever image already exists, so after a
`git pull` it would start the old code and report success. Rebuilding is how an
update takes effect here.
**Go and check that alert arrived on your phone.** Nothing else exercises that
path: a clean cycle never publishes, so a wrong token or a wrong topic sits
undiscovered until the first real finding — and then takes it with it. The
guard suppresses its heartbeat when a send fails, so a broken channel
eventually shows up as a dead guard, but the first time you find that out
should be now and not then.
**A copy, not an override.** `docker compose up` reads `docker-compose.yml` and
`docker-compose.override.yml` together and merges them, so overriding the
example leaves its values in play rather than replacing them. Your copy is
gitignored, which is also where your host and your URLs belong.
The example will not start as shipped: its host is a placeholder the guard
refuses, so it cannot become a live config by being copied and forgotten.
The public half of that key goes in the devbox's `authorized_keys`, restricted
as `etc/config.example` shows.
**It never publishes a port, and it never should.** What makes a container an
acceptable home for a program holding an SSH key to the devbox is that nothing
can connect to it. The compose file drops every capability, mounts the root
filesystem read-only, and sets `no-new-privileges`.
path, and a broken one takes the first real finding with it.
**The volume is not a cache.** It holds the baseline, the last report and
`known_hosts`. Losing it is not a clean slate: a fresh baseline compares clean
against everything for the rest of its life, and a fresh `known_hosts` fails
host-key verification on every connection — which `BatchMode` turns into
"could not reach the devbox", at max priority, forever. A false alarm that
never stops is the kind that gets muted.
against everything for the rest of its life, and a fresh `known_hosts` reports
the devbox unreachable forever.
**Config comes from the environment first, then the file.** Compose is where
everything else about a deployment lives, so the environment is the ergonomic
place for a hostname. It is the wrong place for a token: `docker inspect` shows
it, and so does anything that can read `/proc`. Put secrets in the mounted
config file, which is what `DEJIMA_GUARD_CONF` points at.
**If you bind-mount a host directory instead of the named volume**, chown it to
uid `10001` first. The image creates that user and the named volume inherits
its ownership; a bind mount does not, and the guard cannot write its baseline
to a directory owned by root.
The image is Debian rather than Alpine deliberately: the diff that produces the
change list uses `--unchanged-line-format`, which is GNU diffutils and not
busybox. Alpine works once diffutils and coreutils are added back, at which
point the smaller base has been patched towards this one a package at a time.
**It never publishes a port, and never should.** What makes a container an
acceptable home for a program holding an SSH key to the devbox is that nothing
can connect to it. The compose file also drops every capability, mounts the root
filesystem read-only, and sets `no-new-privileges`.
### What goes in each setting
Every one can come from the config file or from the environment as
`DEJIMA_GUARD_` plus the key in upper case. The environment wins, which is
what makes a compose file workable — but a token belongs in the file, since
what makes a compose file workable, but a token belongs in the file, since
`docker inspect` shows the environment and so does anything reading `/proc`.
**The machine being watched.** Everything here describes the *devbox*, at the
@@ -186,19 +231,22 @@ other end of the connection, not the machine the guard runs on.
| Setting | What goes in it |
| --- | --- |
| `devbox_host` | The devbox's hostname or address, as this machine reaches it |
| `devbox_user` | The account **on the devbox** to log in as. Not root, and no default — the devbox runbook builds a machine with no root password, so the guard connects as an ordinary account and elevates for two commands |
| `devbox_user` | The account **on the devbox** to log in as. Not root, and no default: the devbox runbook builds a machine with no root password, so the guard connects as an ordinary account and elevates for two commands |
| `ssh_key` | The private key **on this machine**. Its own, not a general-purpose administrative one |
| `ssh_known_hosts` | Where the devbox's host key is remembered. Must persist — in a container, on the state volume |
| `ssh_known_hosts` | Where the devbox's host key is remembered. Must persist: in a container, on the state volume. Populated by `dejima-guard trust-host`, never automatically |
| `ssh_timeout` | Seconds to wait. The line between a slow devbox and a gone one |
| `critical_paths` | Paths whose change is its own max-priority alert, one per line. Defaults to the audit binary, the forced command, the sudoers rule, `sshd_config` and `authorized_keys` |
| `ignore_paths` | Paths whose change is reported but never alerted on, space-separated substrings. None by default. See [Changes not worth waking for](#changes-not-worth-waking-for) |
**Where alerts go.**
| Setting | What goes in it |
| --- | --- |
| `ntfy_url` | The topic to publish to. **Its own**, not the one dejima pushes to — that URL lives on the devbox, so root there could drown it or forge an all-clear |
| `ntfy_url` | The topic to publish to. **Its own**, not the one dejima pushes to: that URL lives on the devbox, so root there could drown it or forge an all-clear |
| `ntfy_token` | Write access to that topic and nothing else. The reason the config file is `0640` |
| `heartbeat_url` | GET after a cycle that completed clean. An Uptime Kuma push monitor |
| `heartbeat_url` | GET after a cycle that completed clean. An Uptime Kuma push monitor. **A credential**: the token is in the path, so it belongs in the config file rather than the environment |
| `alert_command` | An alternative to ntfy: gets the title and body on stdin |
| `guard_name` | This machine's name, for alerts to say where to run `dejima-guard report`. Optional; inside a container the hostname is a container ID, so it is never guessed |
`etc/config.example` carries the same reference with the reasoning attached.
@@ -207,17 +255,115 @@ other end of the connection, not the machine the guard runs on.
Both self-hosted, and if you run dejima's remote access you already have the
first.
**ntfy** carries the alerts. Give this its **own topic**, separate from the one
dejima pushes to, and give the devbox no credential for it — `ntfy_url` lives in
`~/.config/dejima/config` *on the devbox*, so root there can publish to that
topic, drown it, or forge an all-clear. Deny the devbox read on the alert topic
too: an intruder who can read the alerts about themselves learns what the
monitoring noticed and what it missed.
#### ntfy: its own topic, and a token that can only write to it
**Uptime Kuma** is the dead-man's switch. A push monitor expects a ping on a
cadence and alerts when one does not arrive; this pings only on a cycle that
completed and found nothing wrong. Without it, a watcher that dies looks exactly
like a watcher with nothing to report.
Give this its **own topic**, separate from the one dejima pushes to. `ntfy_url`
lives in `~/.config/dejima/config` *on the devbox*, so root there can publish to
dejima's topic, drown it, or forge an all-clear on it. A shared topic hands the
machine under suspicion a voice in the channel reporting on it.
On a public ntfy that is as far as you can go: anyone who learns the topic name
can post to it. Closing it properly needs a server you configure.
```yaml
# server.yml
auth-file: /var/lib/ntfy/user.db
auth-default-access: deny-all
```
> ⚠️ **This is a server-wide switch, and on its own it will break notifications
> that have nothing to do with dejima.** It closes *every* topic on that server.
> Worse, the apps subscribe to all of a server's topics over one connection, and
> ntfy authorises it by looping the list, returning 403 for the whole request
> on the first topic the account cannot read. Nothing says which one, so
> one missing grant reads as the server being broken. Grant everything back
> before you turn it on.
```sh
ntfy user add guard # role user, not admin
ntfy user add phone # you, subscribing
ntfy access guard dejima-alerts write-only
ntfy access phone "*" read-only # EVERY topic you subscribe to
ntfy access everyone dejima-notify read-write # dejima's topic, as it was
ntfy access everyone "home-*" read-write # anything else that published
ntfy token add guard # prints tk_..., for secrets/config
ntfy access # read it back
```
Four rules decide whether that is what you think it is:
- **`deny-all` is server-wide.** There is no per-topic default, so everything
publishing anonymously needs granting back by name. `dejima-notify-watch` has
no token to offer and does not need one: nothing about dejima changes, and the
devbox still ends up with no access to `dejima-alerts` at all.
- **Neither account may be an `admin`.** An admin bypasses the ACL entirely, so
an admin token is a key to the alert topic that no entry can take away.
- **`write-only` for the guard**, not `read-write`. It never reads the topic and
this machine can be taken too; read access would hand whoever took it every
alert about the devbox, and the gaps between them are as informative as the
contents. The devbox gets no read either, since an intruder reading the alerts
about themselves learns what the monitoring missed.
- **An `everyone` grant does cover a logged-in user**, on topics where that user
has no entry of their own. Logging in to read the alert topic does not cost
you the anonymous grant on dejima's.
The `"*"` for the reading account is doing real work. Name topics individually
if you prefer, but name *all* of them, everywhere that account subscribes: one
left out denies the rest with it.
**Then log the subscribers in** (web app: Settings → Users; Android: per
server). A denied subscribe fails the WebSocket upgrade, which the apps report
as `WebSocketNotSupportedException` or a 403 on the upgrade, so it reads as a
broken reverse proxy. Publishing keeps working throughout, because that path
carries a token, so a test alert arriving proves nothing about whether anyone
can read it. To tell the ACL from the proxy without WebSockets in the way, and
to find a topic that is denying the rest:
```sh
for t in dejima-alerts dejima-notify whatever-else; do
printf '%s ' "$t"
curl -s -o /dev/null -w '%{http_code}\n' -u phone:PASS \
"https://ntfy.example.invalid/$t/json?poll=1"
done
```
Anonymous should give 403 and the credentialled call 200. Anything else is the
proxy. A hand-rolled browser client needs `?auth=` instead of a header, since a
browser cannot set one on a WebSocket: its value is base64 of the string
`Basic ` plus base64 of `user:pass`.
**On a server you cannot reconfigure**, one topic cannot be closed properly.
Access falls back to `auth-default-access` whenever no entry matches, so denying
the alert topic means an entry for `everyone` *and* one per account, and the
next account added inherits publish rights to the channel reporting on the
devbox. Existing admins keep access regardless. What is left is an unguessable
topic name, which is not access control and is worth calling that; a second
server for the one topic, which carries a few hundred bytes a day; or
`alert_command`, which leaves ntfy entirely.
Either way, a stolen guard token can still publish: a forged all-clear, or noise
to bury a real alert. Nothing in ntfy prevents that, which is part of why the
heartbeat is a separate channel on a separate server.
#### Uptime Kuma: a Push monitor, and the interval matters
This is the dead-man's switch. dejima-guard pings only on a cycle that completed
and found nothing wrong, so silence is the signal, and without it, a guard that
died looks exactly like one with nothing to report.
**Add New Monitor → Monitor Type: `Push`.** Kuma then shows a **Push URL** ending
in a token. That whole URL is `heartbeat_url`.
| Field | Set it to | Why |
| --- | --- | --- |
| Monitor Type | `Push` | Kuma waits to be called. Every other type calls out, and there is nothing here to call |
| Heartbeat Interval | **Longer than dejima-guard's**: 1200s against the default 900s | Kuma counts from the last ping, so equal intervals put every ping right on the deadline. The gap is never exactly 900s either: the systemd timer adds up to 60s of deliberate jitter, and the cycle itself takes as long as two SSH round trips |
| Retries | `0` | A missed ping *is* the finding. Retries make Kuma wait for more of them before saying so, which delays the one alert that reports a guard that has stopped |
dejima-guard's own interval is `DEJIMA_GUARD_INTERVAL` (900s) in a container, or
`OnUnitActiveSec` in `systemd/dejima-guard.timer` (15min) on a host. Change
either and Kuma has to follow, or it starts alerting on a schedule nobody
changed.
Anything else works through `alert_command` and a `heartbeat_url` of the same
shape. Only the one path is documented, because a list of alternatives is a list
@@ -225,9 +371,9 @@ that goes stale.
## Why it pulls
The watcher reaches in; the devbox never reaches out. That falls out of the
firewall the devbox already has — inbound SSH from the admin subnet is
permitted, so **no new firewall rule is needed** — and it buys three things:
dejima-guard reaches in; the devbox never reaches out. That falls out of the
firewall the devbox already has: inbound SSH from the admin subnet is
permitted, so **no new firewall rule is needed**, and it buys three things:
**The baseline lives here.** Root on the devbox can lie about the current state.
It cannot alter what this machine recorded last week.
@@ -236,7 +382,7 @@ It cannot alter what this machine recorded last week.
quiet, or drown it by shouting.
**Silence becomes a signal.** This is the one that matters. A reporter running
*on* the devbox, switched off by whoever took the box, causes nothing to happen —
*on* the devbox, switched off by whoever took the box, causes nothing to happen:
forever, with nothing to see. A collection that stops arriving is itself the
alarm, and it is the only mechanism here that a compromised machine cannot
defeat by doing nothing.
@@ -249,7 +395,7 @@ minutes to notice the monitoring and scrub, and the resulting gap is evidence.
- **It does not prevent anything.** Everything it reports has already happened.
- **It does not detect an intruder who anticipated it.** The devbox-side audit
can be lied to by root on the devbox. What this catches is a payload that did
not think to look — most of them, since the adversary in this threat model did
not think to look: most of them, since the adversary in this threat model did
not choose your devbox.
- **It does not watch CI runners.** Their scheduled rollback bounds dwell time,
so the need is weaker. The design is not tier-specific and extending it would
@@ -259,27 +405,106 @@ minutes to notice the monitoring and scrub, and the resulting gap is evidence.
- **It cannot tell you its alerts are arriving.** Sent is not received. A
clean cycle never publishes, so a broken token or topic waits until the
first real finding. What it does do is suppress the heartbeat when a send
fails, so a broken channel surfaces as a dead guard rather than as silence —
fails, so a broken channel surfaces as a dead guard rather than as silence:
and `test-alert` exists so you can settle it deliberately rather than find
out.
- **It sends no detail.** Alert bodies carry counts and categories, never a
filename or a unit name. A compromised devbox controls those strings, and
interpolating one into a push hands it a channel to your phone. The detail is
in `dejima-guard report`, on this machine.
- **It sends no paths.** Alert bodies say what kind of change and under which
watched area ("1 file(s) modified in ~/.ssh/known_hosts"), worded by the
guard from a fixed list, never a filename or a unit name. A compromised devbox
controls those strings, and interpolating one into a push hands it a channel
to your phone. The paths are in `dejima-guard report`, on this machine.
## Use
```sh
dejima-guard check # what the timer runs
dejima-guard report # the last cycle's detail
dejima-guard accept # record the devbox's current state as the baseline
dejima-guard status # baseline, last check, any expected-changes window
dejima-guard report # the last cycle's detail: each path added, modified or removed
dejima-guard accept # record the devbox's current state as the baseline, then check
dejima-guard expect 30m # updating the devbox: hold alerts, then accept at the end
dejima-guard test-alert # publish a test alert, then go and look
dejima-guard trust-host # record the devbox's host key; run once, before check
dejima-guard-install-key # put the guard's key on a devbox, restricted
```
`accept` is a decision, which is why a check never makes it. After an `apt
upgrade` on the devbox, read the report, and if the changes are yours, accept.
`accept` is a decision, which is why a check never makes it. After an
unannounced change, read the report, and if the changes are yours, accept. The
check it runs afterwards gets the heartbeat going again.
### Updating the devbox without being paged
Say so first, on the guard:
```sh
dejima-guard expect 30m # or 2h, 90s; 24h at most
```
Until then, changes and the devbox being unreachable (a reboot) are held
rather than sent. When the window ends the guard accepts the devbox's state and
sends a low-priority note saying what it accepted; `report --finding` has the
paths. Finished early? `dejima-guard accept`. Called it off? `dejima-guard
expect --cancel`.
**Critical paths and containment still alert**, and a window never accepts a
critical path: those need an `accept` by hand. **It is opened on the guard and
only there.** A devbox that could announce its own changes could silence its
own guard.
### Changes not worth waking for
`ignore_paths` in the config, space-separated, each matched as a substring of
the path like `critical_paths`:
```
ignore_paths = /.ssh/known_hosts
```
`~/.ssh/known_hosts` is the usual candidate. It changes whenever the devbox
first connects to an SSH host, such as cloning from a new git server. Ignored
changes still show in `report`, under their own heading, and a critical path
cannot be ignored.
### In a container, every one of those needs a prefix
There is no `dejima-guard` on the host: it lives in the image, with the config
and the state volume the commands need. Running the bare name on a container
host either finds nothing or, if you also installed the host path, finds a
second deployment with its own baseline.
**While it is running**, which is the usual case:
```sh
docker compose exec dejima-guard dejima-guard report
docker compose exec dejima-guard dejima-guard accept
```
**While it is not** (during setup, or after `docker compose down`):
```sh
docker compose run --rm dejima-guard accept
```
`exec` runs inside the container that is already there. `run` starts another one
from the same image, with the same volume and secrets, and removes it after.
Either works; `exec` is cheaper and is what to reach for once the thing is up.
**The command is not repeated after `run`.** The image's entrypoint is already
the guard, so `run --rm dejima-guard accept` is the whole of it. Naming the
binary again passes it as an argument to itself.
**Or skip the prefix.** `bin/dejima-guard-docker` runs the guard in its
container by name, from any directory. Install it on the container host as
`dejima-guard` and every command above works as written:
```sh
sudo install -m 0755 bin/dejima-guard-docker /usr/local/bin/dejima-guard
dejima-guard status
```
It assumes the `container_name` the example compose file sets; set
`DEJIMA_GUARD_CONTAINER` if yours differs. Do not install it beside a host
install of the guard, which would be a second deployment with its own baseline.
## Tests
@@ -288,11 +513,16 @@ bats test/
```
They stub `ssh` and `curl` on `PATH` rather than replacing functions, because
what is under test includes whether this program calls them at all — and the
what is under test includes whether this program calls them at all, and the
worst failures here are silent: alerting on nothing until the topic is muted,
staying quiet when the box stops answering, and sending the heartbeat on a cycle
that failed.
## License
GPL-3.0-or-later.
GPL-3.0-or-later. See [LICENSE](LICENSE).
The dejima wordmark and logo are not code and are not covered by that licence.
They are trademarks; who holds them and what may be done with them is stated
once for the organisation, at
[dejima's ATTRIBUTION.md](https://gitbox.branderson.io/dejima/dejima/src/branch/main/ATTRIBUTION.md).
+125
View File
@@ -0,0 +1,125 @@
# Security policy
## What dejima-guard claims
It notices when a devbox changes, from a machine that is not the devbox, and it
tells you. It is a second opinion, not a boundary.
**It prevents nothing.** Everything it reports has already happened. The
controls that bound a compromise are
[dejima](https://gitbox.branderson.io/dejima/dejima)'s and
[dejima-devbox-hardener](https://gitbox.branderson.io/dejima/dejima-devbox-hardener)'s;
this only reports on the machine those two are protecting.
What it does claim is that its answer is hard to suppress from the devbox: the
baseline lives here rather than there, the alert leaves from here, and it pulls
rather than being pushed to, so a collection that stops arriving is itself the
alarm. Root on the devbox can lie about the current state. It cannot alter what
this machine recorded last week, and it cannot make this machine stay quiet
without that silence being the signal.
## What it holds, which is the new risk it introduces
Watching a machine means holding credentials to it, so this is a concentration
of privilege that did not exist before you ran it.
| It holds | Taking it gets | It does not get |
| --- | --- | --- |
| An SSH key to the devbox | Two read-only audit commands, through a forced command | A shell, a file, a tunnel, or `drift --accept` |
| An ntfy token | Publishing to the alert topic: a forged all-clear, or noise to bury a real one | Reading past alerts, if it is `write-only` as documented |
| The baseline | Knowing exactly what an intruder would have to preserve to stay unnoticed | Anything on the devbox |
The key is restricted at the devbox end rather than trusted here, which is the
half that matters: see
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access).
`dejima-guard-install-key` proves the restriction rather than assuming it.
## What actually bounds this
Two things characterise the exposure, and both matter more than any comparison
with the projects around it.
**It runs on whatever you run it on.** The guard holds a key to the watched box
and parses that box's output, and its exposure is bounded by the hardening of a
machine this project does not describe and asks nothing of. A NAS, a home
server, an existing VM: whatever it is, it is not hardened by anything here. The
container path answers this for itself, running unprivileged with every
capability dropped and the root filesystem read-only. The host path is a systemd
unit, and what that unit asks for is the whole of the answer: its own account,
an empty capability bounding set, and memory and task limits.
What the guard does to protect that host: it never runs as root, it caps how
much the watched machine may send it, it names the ssh settings a local
`ssh_config` could otherwise widen, and it refuses a config file that is
writable by group or others, because `alert_command` makes that file
executable-equivalent.
**The collector runs on the watched host.** `dejima-devbox-audit` executes on
the devbox, so root there can return whatever it likes, including last week's
state verbatim. This is the detection ceiling and no amount of care on this side
raises it. The heartbeat proves the guard ran, not that it saw the truth. What
the design does buy is that lying has to be deliberate and continuous: the
baseline is here, out of reach, so a payload that did not think to look is
caught, and one that stops the collection entirely is caught by its silence.
## Where it sits next to the other projects
Not a fourth enforcement layer. It enforces nothing, and it is the only one of
the four that adds a machine holding credentials rather than taking some away.
Three of dejima's controls have no equivalent here, and are worth knowing rather
than discovering:
- **No egress bound.** dejima's deny-by-default allowlist covers DNS as well as
IP. This has none: the container reaches whatever the host can. Its needs are
three destinations, so an allowlist is cheap, but it belongs to your host or
your container network. Doing it inside the container would mean granting
`NET_ADMIN` to something that currently drops every capability, which is a
worse trade than the gap.
- **The credential is in the container.** dejima keeps the model credential out
entirely, behind a proxy. Here the private key is mounted. An `SSH_AUTH_SOCK`
from the host would give the same use-without-possession and is not documented
yet.
- **No request logging.** dejima logs egress verdicts. This logs findings and
failures, not connections.
## What it deliberately does not do
- **It does not catch an intruder who anticipated it.** The devbox-side audit
can be lied to by root on the devbox. This catches a payload that did not
think to look, which is most of them.
- **It sends no paths off this machine.** Alert bodies carry counts and the
watched area a change falls under, printed from a fixed list in the guard,
never a filename or a unit name, because the devbox chooses those strings.
The detail stays in `dejima-guard report`.
- **An expected-changes window is opened on the guard, never by the devbox.**
One the devbox could open would let it silence its own guard. A window never
holds or accepts a change to a critical path, or a containment failure.
- **It treats what the devbox says as untrusted input**, including in that
report: control characters are replaced as the text is collected, since a
filename can carry an escape sequence and the report is read on the machine
holding the key to the box that named the file.
- **It cannot tell you its alerts are arriving.** Sent is not received.
`test-alert` exists so that is a thing you settle deliberately.
## This is not proven software
Early development. Nothing here has been through outside review, an external
audit, or adversarial testing by anyone other than the person who wrote it.
Every security property described above is a claim awaiting scrutiny rather than
one that has survived it. Do not put it anywhere that being wrong about it would
matter.
## Reporting a vulnerability
Report it privately rather than in a public issue, to the address on the
maintainer's forge profile. There is one maintainer and no promised response
time; you will get an acknowledgement when it is seen, and credit if you want
it.
The organisation-wide policy, including what the sister projects claim, is
[dejima's SECURITY.md](https://gitbox.branderson.io/dejima/dejima/src/branch/main/SECURITY.md).
## No warranty
GPL-3.0-or-later, sections 15 and 16. This is provided as-is.
+19
View File
@@ -0,0 +1,19 @@
# assets
The dejima wordmark, in the two versions the README needs: `-darkmode` for a
page that renders dark, `-lightmode` for one that renders light. The suffix
names the mode a file is for, not the colour of its ink, so `-darkmode` is the
pale one.
The README points at `-darkmode` unconditionally; the comment above the tag
says why it is not conditional. The GitHub mirror rewrites that tag to pick
between the two by the reader's theme.
**These are trademarks and are not covered by this repository's GPL licence.**
Who holds them, and what may be done with them, is stated once for the whole
organisation:
https://gitbox.branderson.io/dejima/dejima/src/branch/main/ATTRIBUTION.md
The link is absolute so that a fork resolves it to the canonical terms rather
than to its own copy.
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img"
aria-label="Developed in dejima">
<title>Developed in dejima</title>
<linearGradient id="g" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="79" height="20" fill="#282828"/>
<rect x="79" width="43" height="20" fill="#8ec07c"/>
<rect width="122" height="20" fill="url(#g)"/>
</g>
<g font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"
text-anchor="middle">
<text x="39.5" y="15" fill="#010101" fill-opacity=".3">Developed in</text>
<text x="39.5" y="14" fill="#ebdbb2">Developed in</text>
<text x="100.5" y="15" fill="#010101" fill-opacity=".3">dejima</text>
<text x="100.5" y="14" fill="#282828">dejima</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+823 -62
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Brad Anderson
#
# dejima-guard, run inside its container, from any directory.
#
# Install on the container host as `dejima-guard`, so the commands read the same
# there as everywhere else:
#
# sudo install -m 0755 bin/dejima-guard-docker /usr/local/bin/dejima-guard
#
# `docker exec` by container name rather than `docker compose exec`, which only
# works from the directory holding the compose file. The name is the
# container_name the example compose file sets.
container=${DEJIMA_GUARD_CONTAINER:-dejima-guard}
if [ -t 0 ] && [ -t 1 ]; then tty=-it; else tty=-i; fi
exec docker exec "$tty" "$container" dejima-guard "$@"
+42 -6
View File
@@ -64,7 +64,8 @@ case "$KEY" in
esac
# THE RESTRICTION, and it must stay identical to what dejima-devbox-hardener's
# docs/building-a-devbox.md section 7 documents. Two repositories, one line.
# docs/building-a-devbox.md documents under "Let the guard in". Two repositories,
# one line.
#
# The forced command ignores whatever was asked for and runs dejima-guard-shell,
# which whitelists the two commands the guard sends. The no-* options remove
@@ -125,7 +126,17 @@ fi
say ""
say "Checking what that key can do."
GKEY="${PUB%.pub}"
sshg() { ssh -i "$GKEY" -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" "$1" 2>&1; }
# IdentitiesOnly, or this checks the wrong key.
#
# `-i` adds a key to those offered rather than restricting to it, so with the
# operator's own key present - or an agent holding one - ssh authenticates with
# that, no forced command applies, and all four checks report the restriction
# missing when it is fine. Which is what happened the first time this ran
# against a real devbox.
sshg() {
ssh -i "$GKEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10 \
"$TARGET" "$1" 2>&1
}
fails=0
for c in "dejima-devbox-audit reach" "dejima-devbox-audit drift --state"; do
@@ -135,17 +146,42 @@ for c in "dejima-devbox-audit reach" "dejima-devbox-audit drift --state"; do
say " FAILED $c"
say " This is one the guard needs. Check that"
say " $SHELL_PATH exists on the devbox and that sudoers"
say " permits it - dejima-devbox-hardener section 7."
say " permits it - see \"Let the guard in\" in the"
say " dejima-devbox-hardener runbook."
fails=1
fi
done
# Captured, not piped. `sshg "$c" | grep -q refused` looks right and is not:
# under `set -o pipefail` the pipeline takes the rightmost NON-ZERO status, and
# a refusal is exit 64 relayed through ssh - so grep matched, the pipeline still
# came back 64, and a working restriction was reported as absent. The louder
# direction of that mistake, but a false alarm about the one control standing
# between a stolen key and this box is not a cheap one to raise.
#
# Both halves of the refusal are checked: the message, which is what the runbook
# tells an operator to look for, and a non-zero status. `cat /etc/shadow` on an
# unrestricted key fails too - with "Permission denied" and no "refused" - so
# neither alone separates a refusal from an ordinary failure.
for c in "cat /etc/shadow" "dejima-devbox-audit drift --accept"; do
if sshg "$c" | grep -q refused; then
out=""; rc=0
out=$(sshg "$c") || rc=$?
if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q refused; then
say " refused $c"
else
say " NOT REFUSED $c"
say " The forced command is not in force. Do not leave"
say " this key installed until it is."
# What came back, rather than only the conclusion drawn from it. This
# check reported a working restriction as missing for one release, and
# the evidence that it was wrong was in the output it had already
# captured and discarded.
say " the devbox said, exit $rc:"
printf '%s\n' "${out:-<nothing>}" | sed 's/^/ > /' >&2
say " The forced command is not in force for this key."
say " Check the line in ~/.ssh/authorized_keys on the"
say " devbox begins command=\"$SHELL_PATH\", and that"
say " there is not a SECOND, unrestricted entry for the"
say " same key above it - first match wins."
say " Do not leave this key installed until it is."
fails=1
fi
done
+15
View File
@@ -35,6 +35,21 @@ esac
# after however much of the sleep is left.
trap 'exit 0' TERM INT
# ARGUMENTS MEAN "RUN THIS ONCE", because this is the image's entrypoint.
#
# `docker compose run SERVICE CMD` replaces the command and keeps the
# entrypoint, so `run --rm dejima-guard dejima-guard trust-host` arrived here as
# two arguments this script ignored, and it started looping instead. The
# operator watching for a fingerprint got a check every fifteen minutes,
# forever, in a container they had to interrupt. That was the documented setup.
#
# So: no arguments is the timer, arguments are one command. It also shortens the
# form to `docker compose run --rm dejima-guard trust-host`, since the entrypoint
# is already the guard.
if [ "$#" -gt 0 ]; then
exec "$GUARD" "$@"
fi
echo "dejima-guard-loop: every ${INTERVAL}s" >&2
while :; do
"$GUARD" check || true
+20 -4
View File
@@ -13,9 +13,17 @@
# forgotten.
services:
dejima-guard:
# Built here, not pulled: there is no registry for this. Which makes
# `--build` part of every command that starts it - `docker compose up -d`
# alone reuses the image it already has, so after a `git pull` it would run
# the old code and look like it had worked.
build: .
# image: gitbox.example.invalid/dejima/dejima-guard:latest
container_name: dejima-guard
# bin/dejima-guard-docker finds the container by this name. And an init,
# because `dejima-guard expect` leaves a process waiting for the window's
# end, which needs reaping once it is done.
init: true
restart: unless-stopped
environment:
@@ -51,16 +59,24 @@ services:
# --- the dead-man's switch ------------------------------------------
#
# GET after a cycle that completed clean - an Uptime Kuma push monitor
# is the documented shape. Without it, a guard that dies looks exactly
# like a guard with nothing to report.
DEJIMA_GUARD_HEARTBEAT_URL: https://kuma.example.invalid/api/push/abcdef
# NOT HERE. heartbeat_url goes in the mounted config beside ntfy_token,
# because an Uptime Kuma push URL carries its token in the path: the URL
# is the credential rather than the destination. `docker inspect` shows
# everything in this block, and so does anything that can read /proc.
#
# What it grants is worth being plain about. Anyone holding that URL can
# tell Kuma this guard is healthy, which suppresses the one signal that
# reports a guard that has died - the single channel a compromised devbox
# cannot reach.
# --- this container --------------------------------------------------
#
# Seconds between collections. Sets the dead-man's switch granularity
# and how long an intruder has to notice the monitoring and scrub.
DEJIMA_GUARD_INTERVAL: "900"
# The name of the machine this container runs on, so an alert says
# where to go and run `dejima-guard report`. Optional.
# DEJIMA_GUARD_GUARD_NAME: your-docker-host
# Anything secret goes in the config file rather than here. Everything
# above is visible in `docker inspect` and to anything that can read
# /proc; a mounted secret is not. The guard reads the environment first
+57 -1
View File
@@ -49,8 +49,35 @@ ssh_key = /var/lib/dejima-guard/id_ed25519
# volume rather than a home directory that is new every run - without it,
# BatchMode turns the first connection into a failure and every collection
# reports the devbox unreachable.
#
# `dejima-guard trust-host` writes it, and is the only thing that does. Run it
# once before the first check. An empty file here is not a fresh start, it is a
# guard that cannot connect at all.
ssh_known_hosts = /var/lib/dejima-guard/known_hosts
# Paths whose change is a different kind of event from a config edit.
#
# The audit's own binary is the sharp one: if it changed, everything this guard
# has been told since is suspect, including the report saying so. It used to
# arrive as "1 file(s) changed", indistinguishable from a logrotate tweak among
# a hundred others. The forced command, the sudoers rule and authorized_keys are
# the same argument one layer out - they are what makes this access read-only,
# so a change to them changes what a stolen key is worth.
#
# One per line, matched as a substring of the path. The default covers the
# machinery this design rests on; add anything else you would want woken for.
# critical_paths = /usr/local/sbin/dejima-devbox-audit
# /etc/sudoers.d/dejima-guard
# Paths whose change is reported but not alerted on, space-separated, each
# matched as a substring of the path. None by default.
#
# ~/.ssh/known_hosts is the usual one: it changes whenever the devbox first
# connects to an SSH host, such as cloning from a new git server. Ignored
# changes still appear in `dejima-guard report`, and a critical path above is
# never ignored, whatever this says.
# ignore_paths = /.ssh/known_hosts
# Seconds to wait for the connection. A devbox that is slow is not a devbox
# that is gone, and this is the line between those two readings.
ssh_timeout = 10
@@ -77,6 +104,16 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
# A token with write access to that topic and nothing else. Optional on a
# server without access control, and the reason this file is 0640.
#
# WRITE-ONLY, not read-write. This program never reads the topic, and this
# machine could itself be taken - a token that can read would hand whoever took
# it every alert ever sent about the devbox, and the gaps between them.
#
# ntfy access guard <topic> write-only
#
# The README has the rest, including what to do on a server you cannot
# reconfigure: auth-default-access is server-wide, so closing one topic is not
# something a shared ntfy will let you do.
# ntfy_token = tk_...
# ===========================================================================
@@ -85,6 +122,12 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
# A URL to GET after a cycle that completed and found nothing wrong.
#
# A CREDENTIAL, not just a destination: an Uptime Kuma push URL carries its
# token in the path. Anyone holding it can tell Kuma this guard is healthy,
# which suppresses the one signal that reports a guard that has died. Keep it
# here rather than in a compose environment block, which `docker inspect`
# prints.
#
# An Uptime Kuma push monitor is the documented shape: it expects a ping on a
# cadence and alerts when one does not arrive. That is exactly what is wanted
# here, because this program is pinged only on a clean cycle - so silence from
@@ -95,7 +138,12 @@ ntfy_url = https://ntfy.example.invalid/devbox-alerts
# itself, which is why the line is here rather than left to be discovered.
#
# Anything speaking the same "GET this URL to say you are alive" shape works.
heartbeat_url = https://kuma.example.invalid/api/push/abcdef
#
# Copied whole, including the query Uptime Kuma appends. An example trimmed to
# the path is shorter and is not what you will paste, and the shorter one hid a
# bug: the URL check refused "&", so the real thing was rejected while this
# example passed.
heartbeat_url = https://kuma.example.invalid/api/push/abcdef?status=up&msg=OK&ping=
# ===========================================================================
# IF YOU ALERT THROUGH SOMETHING ELSE
@@ -105,3 +153,11 @@ heartbeat_url = https://kuma.example.invalid/api/push/abcdef
# Set it and ntfy_url is ignored.
#
# alert_command = /usr/local/bin/my-alerter
# ===========================================================================
# THIS MACHINE
# ===========================================================================
# This machine's name, so an alert can say where to run `dejima-guard report`.
# Optional. Never guessed: inside a container the hostname is a container ID.
# guard_name = nas
+33 -1
View File
@@ -8,13 +8,45 @@ Wants=network-online.target
Type=oneshot
ExecStart=@@BIN_DIR@@/dejima-guard check
# NOT ROOT, and this is the setting that matters most here.
#
# The container path has always run as its own uid, on the argument that running
# as root grants root to a program holding an SSH key to another machine, for no
# reason at all. That argument does not stop being true on a host, and this unit
# used to disagree with it: no User=, so a bug in the guard yielded root on a
# machine this project describes no hardening for, rather than an unprivileged
# account in a capability-dropped container.
#
# StateDirectory creates /var/lib/dejima-guard and chowns it to this user, so it
# replaces the ReadWritePaths line rather than joining it.
User=dejima-guard
Group=dejima-guard
StateDirectory=dejima-guard
# NoNewPrivileges does not drop capabilities for root, so on the old unit it was
# doing less than it looked like it was. Emptied outright: this opens two
# outbound connections and writes one directory.
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
PrivateDevices=yes
ProtectKernelModules=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
# It listens on nothing and needs almost nothing. Whatever this machine is, it
# is not this program's to put at risk.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/dejima-guard
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
# The watched host chooses how much it sends back. The collector caps that
# itself and treats an over-long answer as a finding; these are the backstop for
# everything the cap does not cover, on a machine that is running other things.
MemoryMax=256M
TasksMax=64
+1 -1
View File
@@ -8,7 +8,7 @@ Description=Watch the devbox on a cadence
# almost nothing; much longer and a compromise has room to tidy up.
OnBootSec=5min
OnUnitActiveSec=15min
# Without this a fleet of watchers all collect on the same second.
# Without this a fleet of dejima-guards all collect on the same second.
RandomizedDelaySec=60s
Persistent=true
+1214 -14
View File
File diff suppressed because it is too large Load Diff
+67 -4
View File
@@ -11,9 +11,17 @@
# key is live. So this program exists, and what it has to get right is that the
# line it writes carries the forced command, every time.
#
# `ssh` is stubbed with something that runs the remote script locally, so what
# is under test is the script this sends rather than whether this machine can
# reach a devbox.
# `ssh` is stubbed with a devbox: it runs the install script locally, and answers
# the four verification commands the way a box with the forced command in place
# answers them - two that work, two refused on stderr with exit 64.
#
# THE EXIT STATUS IS THE POINT. The stub this replaced did `shift; exec bash -c
# "$1"`, which for the verification calls handed bash the key's path rather than
# the command, so all four checks ran garbage and the phase was never tested at
# all. Two bugs shipped through it, both of which a stub that relayed a status
# would have caught: the missing IdentitiesOnly, and a `| grep -q refused`
# pipeline that under pipefail returned the refusal's own exit 64 and so read a
# working restriction as an absent one.
setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
@@ -21,7 +29,20 @@ setup() {
export HOME="$BATS_TEST_TMPDIR/home"
STUB="$BATS_TEST_TMPDIR/bin"
mkdir -p "$HOME" "$STUB"
printf '#!/bin/bash\nshift\nexec bash -c "$1"\n' > "$STUB/ssh"
cat > "$STUB/ssh" <<'STUBEOF'
#!/bin/bash
cmd="${!#}" # last argument, whatever options preceded it
case "$cmd" in
*authorized_keys*) # the install script, which this runs for real
exec bash -c "$cmd" ;;
"dejima-devbox-audit reach")
echo "PASS"; exit 0 ;;
"dejima-devbox-audit drift --state")
echo "FILE /etc/ssh/sshd_config a1b2"; exit 0 ;;
*) # dejima-guard-shell, verbatim
echo "dejima-guard-shell: refused: $cmd" >&2; exit 64 ;;
esac
STUBEOF
chmod +x "$STUB/ssh"
ssh-keygen -q -t ed25519 -N '' -f "$BATS_TEST_TMPDIR/k" -C guard
PUB="$BATS_TEST_TMPDIR/k.pub"
@@ -82,6 +103,48 @@ authk() { cat "$HOME/.ssh/authorized_keys" 2>/dev/null || true; }
[ ! -e "$HOME/.ssh/authorized_keys" ]
}
@test "a devbox with the restriction in force passes all four checks" {
# The end-to-end shape, against a stub that answers exactly as the real
# forced command does. This failed on a correctly restricted devbox: the
# refusal's exit 64 came back through the pipeline and outvoted grep's
# match, so both refusals were reported as the restriction being absent.
run_install
[ "$status" -eq 0 ]
[[ "$output" == *"permitted dejima-devbox-audit reach"* ]]
[[ "$output" == *"permitted dejima-devbox-audit drift --state"* ]]
[[ "$output" == *"refused cat /etc/shadow"* ]]
[[ "$output" == *"refused dejima-devbox-audit drift --accept"* ]]
[[ "$output" != *"NOT REFUSED"* ]]
}
@test "a devbox that runs what it should refuse fails the check" {
# The direction that matters. A stub with no forced command answers
# everything successfully, and this must not call that restricted.
printf '#!/bin/bash\ncmd="${!#}"\ncase "$cmd" in *authorized_keys*) exec bash -c "$cmd";; esac\nexit 0\n' > "$STUB/ssh"
chmod +x "$STUB/ssh"
run_install
[ "$status" -ne 0 ]
[[ "$output" == *"NOT REFUSED"* ]]
}
@test "a check that fails shows what came back, not only its conclusion" {
# This reported a working restriction as missing for one release, and the
# evidence it was wrong was in output it had captured and thrown away.
printf '#!/bin/bash\ncmd="${!#}"\ncase "$cmd" in *authorized_keys*) exec bash -c "$cmd";; esac\necho "root:x:0:0"\nexit 0\n' > "$STUB/ssh"
chmod +x "$STUB/ssh"
run_install
[[ "$output" == *"root:x:0:0"* ]]
}
@test "the verification offers only the guard's key" {
# Without IdentitiesOnly it checks whichever key ssh happens to use, which
# on the operator's own machine is usually their own - so all four checks
# report the restriction missing when it is fine. That is what happened the
# first time this ran for real, and a check that is wrong in the alarming
# direction gets ignored as fast as one wrong the other way.
grep -q 'IdentitiesOnly=yes' "$TOOL"
}
@test "the restriction matches what the devbox project documents" {
# Two repositories, one line. If dejima-devbox-hardener's section 7 and
# this disagree, the key installed here is not the key that project's