Compare 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
9 changed files with 866 additions and 64 deletions
+94 -14
View File
@@ -4,10 +4,10 @@
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. A mirror on a
light-themed host swaps the filename below for dejima-wordmark-lightmode.png,
which is committed alongside for exactly that. One line, on the mirror,
where someone can see whether it worked.
-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
@@ -194,8 +194,8 @@ same treatment.
**4. Record the host key and start it.**
```sh
docker compose run --build --rm dejima-guard dejima-guard trust-host
docker compose run --build --rm dejima-guard dejima-guard accept # the baseline
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
```
@@ -236,6 +236,7 @@ other end of the connection, not the machine the guard runs on.
| `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.**
@@ -245,6 +246,7 @@ other end of the connection, not the machine the guard runs on.
| `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. **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.
@@ -406,25 +408,103 @@ minutes to notice the monitoring and scrub, and the resulting gap is evidence.
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
+7 -3
View File
@@ -88,9 +88,13 @@ than discovering:
- **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 detail off this machine.** Alert bodies carry counts and
categories, never a filename or a unit name, because the devbox chooses those
strings. The detail stays in `dejima-guard report`.
- **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
+3 -3
View File
@@ -5,9 +5,9 @@ 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. A mirror on a light-themed
host changes that one line; the comment above the tag says why it is not
conditional.
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
+343 -32
View File
@@ -361,21 +361,106 @@ heartbeat() {
BASE="$STATE_DIR/baseline"
LAST="$STATE_DIR/last-report"
# The most recent cycle that FOUND something, kept beside the most recent cycle.
#
# last-report is overwritten every cycle, so a clean one erases the evidence of
# the alert before it. On anything that alternates - and a flapping line
# alternates by definition - an operator who runs `report` after an alert lands
# is most likely to catch a clean cycle and see "no changes against the
# baseline", which reads as the alert having been imaginary.
FINDING="$STATE_DIR/last-finding"
# The end of an expected-changes window, in epoch seconds. See cmd_expect.
WINDOW="$STATE_DIR/expect-until"
# Categories rather than paths, because the body of an alert must carry nothing
# the watched machine chose. A count of changed unit files is safe to send; the
# name of one is not.
summarise() { # summarise DIFF_FILE
local f=$1 n_file n_unit n_port n_link
n_file=$(grep -c '^[+-] FILE ' "$f" || true)
n_unit=$(grep -c '^[+-] UNIT ' "$f" || true)
n_port=$(grep -c '^[+-] PORT ' "$f" || true)
n_link=$(grep -c '^[+-] LINK ' "$f" || true)
[ "$n_file" -gt 0 ] && printf '%s file(s)\n' "$n_file"
[ "$n_link" -gt 0 ] && printf '%s symlink(s)\n' "$n_link"
[ "$n_unit" -gt 0 ] && printf '%s systemd unit(s)\n' "$n_unit"
[ "$n_port" -gt 0 ] && printf '%s listening port(s)\n' "$n_port"
return 0
# Sorted here rather than trusted to arrive sorted, so a baseline this program
# rewrote compares line for line with what the devbox sends next.
statediff() { # statediff OLD NEW
diff --unchanged-line-format= --old-line-format='- %L' --new-line-format='+ %L' \
<(LC_ALL=C sort "$1") <(LC_ALL=C sort "$2") || true
}
# One line per thing that changed: STATUS<TAB>KIND<TAB>KEY.
#
# An edited file is two diff lines, its old hash removed and its new one added,
# which read as a deletion and a creation. Paired by what the line is about, an
# edit says "modified".
changes() { # changes DIFF_FILE
awk '
{
sign = substr($0, 1, 1); kind = $2; key = $0
sub(/^[+-] [A-Z]+ /, "", key)
if (kind == "FILE") sub(/^[^ ]+ +/, "", key)
else if (kind == "LINK") sub(/ -> .*/, "", key)
id = kind SUBSEP key
if (!(id in seen)) order[++n] = id
seen[id] = seen[id] sign
}
END {
for (i = 1; i <= n; i++) {
split(order[i], p, SUBSEP); s = seen[order[i]]
st = (s ~ /-/ && s ~ /\+/) ? "modified" : (s ~ /\+/ ? "added" : "removed")
printf "%s\t%s\t%s\n", st, p[1], p[2]
}
}' "$1"
}
changes_table() { # changes_table DIFF_FILE
changes "$1" | awk -F '\t' '{ printf " %-9s %-5s %s\n", $1, $2, $3 }'
}
# What changed, in words this program chose, for the body of an alert.
#
# The devbox names every path, so no path goes in a push. What goes instead is
# the watched area it falls under, printed from the fixed list below and never
# from the path itself: a home directory or filename an intruder chose cannot
# ride along. A count of changed unit files is safe to send; the name of one is
# not.
describe() { # describe DIFF_FILE
changes "$1" | awk -F '\t' '
function area(p) {
if (p ~ /^\/home\/[^\/]+\/\.ssh\/known_hosts/) return "~/.ssh/known_hosts"
if (p ~ /^\/home\/[^\/]+\/\.ssh\//) return "~/.ssh"
if (p ~ /^\/home\/[^\/]+\/\.config\/systemd\/user\//) return "~/.config/systemd/user"
if (p ~ /^\/home\/[^\/]+\/\.local\/bin\//) return "~/.local/bin"
if (p ~ /^\/home\/[^\/]+\/\.[^\/]+$/) return "shell startup files"
if (p ~ /^\/root\/\.ssh\//) return "/root/.ssh"
if (p ~ /^\/var\/spool\/cron\//) return "crontabs"
if (p ~ /^\/etc\/apt\//) return "/etc/apt"
if (p ~ /^\/etc\/systemd\//) return "/etc/systemd"
if (p ~ /^\/etc\/ssh\//) return "/etc/ssh"
if (p ~ /^\/etc\//) return "/etc"
if (p ~ /^\/usr\/local\/bin\//) return "/usr/local/bin"
if (p ~ /^\/usr\/local\/sbin\//) return "/usr/local/sbin"
return "other paths"
}
{
if ($2 == "UNIT") g = "unit(s) " ($1 == "added" ? "enabled" : "disabled")
else if ($2 == "PORT") g = "listening port(s) " ($1 == "added" ? "opened" : "closed")
else g = ($2 == "LINK" ? "symlink(s) " : "file(s) ") $1 " in " area($3)
if (!(g in c)) order[++n] = g
c[g]++
}
END {
for (i = 1; i <= n && i <= 8; i++) printf "%s %s\n", c[order[i]], order[i]
if (n > 8) printf "%s more kind(s) of change\n", n - 8
}' | paste -sd ';' - | sed 's/;/, /g'
}
# Said beside a change to known_hosts, which is the one most often harmless and
# least obviously so.
known_hosts_note() { # known_hosts_note DESCRIPTION
case "$1" in
*"~/.ssh/known_hosts"*)
printf ' known_hosts changes when the devbox first connects to an SSH host it has not seen, such as cloning from a new git server.' ;;
esac
}
# Where to read the rest. guard_name comes from this machine's config, so it is
# as safe to send as the devbox's own configured name.
details_hint() {
local g
g=$(cfg guard_name)
printf ' Details: %s report%s.' "$PROG" "${g:+, on $g}"
}
# Paths whose change is a different kind of event from a config edit.
@@ -405,6 +490,44 @@ critical_hits() { # critical_hits DIFF_FILE -> matching lines
done <<< "$(cfg critical_paths "$CRITICAL_DEFAULT")"
}
# Moves the lines an operator has said are not worth waking for out of the
# diff. They stay in the report, under their own heading.
#
# A critical path is never ignored. Ignoring the file that keeps the guard's
# access read-only would be a way to stop watching the guard's own footing.
ignore_split() { # ignore_split DIFF_FILE IGNORED_FILE
local pats=$2.pats crit=$2.crit
cfg ignore_paths | tr -s '[:space:]' '\n' | grep . > "$pats" || true
critical_hits "$1" > "$crit"
grep -F -f "$pats" "$1" | grep -vxF -f "$crit" > "$2" || true
grep -vxF -f "$2" "$1" > "$1.keep" || true
mv "$1.keep" "$1"
rm -f "$pats" "$crit"
}
# The baseline an expected-changes window ends with: everything collected now,
# except lines under a critical path, which keep their old value so the check
# still alerts on them. Those are accepted by hand or not at all.
accept_window() { # accept_window NOW_FILE
local pats=$1.crit
cfg critical_paths "$CRITICAL_DEFAULT" | grep . > "$pats" || true
{ grep -vF -f "$pats" "$1"; grep -F -f "$pats" "$BASE"; } \
| LC_ALL=C sort > "$BASE.new" || true
rm -f "$pats"
grep -q . "$BASE.new" || { rm -f "$BASE.new"; return 1; }
mv "$BASE.new" "$BASE"
}
window_end() {
local e
[ -r "$WINDOW" ] || return 0
e=$(head -1 "$WINDOW")
case "$e" in ''|*[!0-9]*) return 0 ;; esac
printf '%s\n' "$e"
}
clock() { date -d "@$1" +'%H:%M %Z'; }
# A listener or an enabled unit that was not there before is not a config edit.
#
# It is the shape of a thing arranging to run again, or to be reached. Both were
@@ -479,11 +602,18 @@ require_config() {
}
cmd_check() {
local rc=0 now reach_rc reach_out changed=0 body=""
local rc=0 now reach_rc reach_out changed=0 body="" held=0 wend window=none
require_config
install -d -m 0750 "$STATE_DIR"
now=$(mktemp) || die "no temp space"
# An expected-changes window is open until its end, and closing once it has
# passed. The check that finds it closing is the one that accepts.
wend=$(window_end)
if [ -n "$wend" ]; then
if [ "$(date +%s)" -lt "$wend" ]; then window=open; else window=closing; fi
fi
# 1. Can it be reached at all?
#
# Deliberately the first question. A devbox that does not answer is the
@@ -491,7 +621,7 @@ cmd_check() {
local ssh_err
ssh_err=$(mktemp) || die "no temp space"
# shellcheck disable=SC2064
trap "rm -f '$now' '$now.diff' '$ssh_err'" EXIT
trap "rm -f '$now' '$now.raw' '$now.diff' '$now.ignored' '$now.old' '$now.accepted' '$ssh_err'" EXIT
# ssh's own words are kept, not sent to /dev/null. They were, and the alert
# offered three guesses instead - so the one failure that stops this program
@@ -514,6 +644,20 @@ cmd_check() {
if [ "$crc" -ne 0 ]; then
sanitise < "$ssh_err" > "$ssh_err.clean" && mv "$ssh_err.clean" "$ssh_err"
# A reboot is part of most updates, so inside a window this is held
# rather than sent. No heartbeat either way: nothing was checked.
if [ "$window" = open ]; then
say "HELD: could not reach $(target), inside the expected-changes window"
say " until $(clock "$wend"). ssh said:"
sed 's/^/ /' "$ssh_err" >&2
return 1
fi
# A window ends at its end, reachable or not. Accepting whatever the box
# says when it next answers, hours later, is not what was expected.
if [ "$window" = closing ]; then
rm -f "$WINDOW"
say "The expected-changes window has ended with nothing to accept."
fi
alert max "$(target_short): unreachable" \
"dejima-guard could not collect from $(target). It may be down, off the network, or refusing this key." || true
say "FAIL: could not reach $(target). ssh said:"
@@ -575,13 +719,27 @@ cmd_check() {
return 1
fi
diff --unchanged-line-format= --old-line-format='- %L' --new-line-format='+ %L' \
"$BASE" "$now" > "$now.diff" || true
statediff "$BASE" "$now" > "$now.diff"
local accepted=""
if [ "$window" = closing ]; then
cp "$BASE" "$now.old"
accept_window "$now" || say "Nothing usable to accept as the window closed."
rm -f "$WINDOW"
statediff "$now.old" "$BASE" > "$now.accepted"
statediff "$BASE" "$now" > "$now.diff"
accepted=$(describe "$now.accepted")
alert low "$(target_short): expected changes accepted" \
"The expected-changes window on $(target) has closed. Accepted: ${accepted:-nothing had changed}.$(known_hosts_note "$accepted")" || true
fi
ignore_split "$now.diff" "$now.ignored"
if [ -s "$now.diff" ]; then
changed=1
body=$(summarise "$now.diff" | paste -sd', ' -)
body=$(describe "$now.diff")
fi
[ "$window" = open ] && [ "$changed" = 1 ] && held=1
# 4. One alert per cycle, batching every finding.
#
@@ -598,19 +756,21 @@ cmd_check() {
# No path names in the body. The devbox chose those strings, and the
# count plus the category is enough to say go and read the report.
alert max "$(target_short): watched path changed" \
"$crit_n line(s) changed under paths this guard treats as its own footing on $(target), including possibly the audit it collects with. Treat what it has reported since as unverified." || true
"$crit_n line(s) changed under paths this guard treats as its own footing on $(target), including possibly the audit it collects with. Treat what it has reported since as unverified.$(details_hint)" || true
rc=1
elif [ "$changed" = 1 ] && [ "$reach_rc" -ne 0 ]; then
alert max "$(target_short): changes and containment" \
"On $(target): $body changed, and the containment check did not pass." || true
"On $(target): $body, and the containment check did not pass.$(details_hint)" || true
rc=1
elif [ "$held" = 1 ]; then
say "HELD: changes on $(target), inside the expected-changes window until $(clock "$wend")."
elif [ "$newp" -gt 0 ]; then
alert max "$(target_short): new listener or unit" \
"On $(target): $body changed, and $newp of them is something newly arranged to run or to be reached." || true
"On $(target): $body. $newp of them is something newly arranged to run or to be reached.$(details_hint)" || true
rc=1
elif [ "$changed" = 1 ]; then
alert high "$(target_short): unexplained changes" \
"On $(target): $body changed since the last accepted state." || true
"On $(target), since the last accepted state: $body.$(known_hosts_note "$body")$(details_hint)" || true
rc=1
elif [ "$reach_rc" -ne 0 ]; then
alert high "$(target_short): containment" \
@@ -618,16 +778,33 @@ cmd_check() {
rc=1
fi
local result=clean
[ "$rc" -eq 0 ] || result=alerted
[ "$held" = 0 ] || result="held, expected-changes window open until $(clock "$wend")"
{
printf '%s\n' "--- collected $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
printf 'result: %s\n' "$result"
printf 'reach exit: %s\n%s\n\n' "$reach_rc" "$reach_out"
if [ -s "$now.accepted" ]; then
printf 'accepted as the expected-changes window closed:\n'
changes_table "$now.accepted"; printf '\n'
fi
if [ "$changed" = 1 ]; then
printf 'changes against the baseline:\n'; cat "$now.diff"
printf 'changes against the baseline:\n'; changes_table "$now.diff"
printf '\nas collected:\n'; cat "$now.diff"
else
printf 'no changes against the baseline\n'
fi
if [ -s "$now.ignored" ]; then
printf '\nignored (ignore_paths):\n'; changes_table "$now.ignored"
fi
} > "$LAST"
# Kept only when there was something to keep, so it is not overwritten by
# the next quiet cycle. This is the copy an alert is telling you to read,
# and a closing window's is the record of what it let through.
if [ "$rc" -ne 0 ] || [ -s "$now.accepted" ]; then cp "$LAST" "$FINDING"; fi
if [ "$ALERT_FAILED" = 1 ]; then
# No heartbeat, deliberately.
#
@@ -643,7 +820,7 @@ cmd_check() {
fi
if [ "$rc" -eq 0 ]; then
say "CLEAN: no changes, containment passed."
[ "$held" = 1 ] || say "CLEAN: no changes, containment passed."
# Only on a cycle that completed and found nothing wrong. Pinging in a
# failure path would tell the dead-man's switch that all is well at the
# exact moment it is not.
@@ -673,9 +850,87 @@ cmd_accept() {
[ "$crc" -eq 0 ] || die "could not collect from the devbox"
grep -q . "$raw" || die "the devbox returned nothing - not accepting that as a baseline"
sanitise < "$raw" > "$BASE"
rm -f "$raw" "$err" "$WINDOW"
trap - EXIT
say "Baseline accepted: $(grep -c . "$BASE") entries in $BASE"
say " Take it on a box you believe is clean. One taken on a compromised"
say " machine records the compromise as normal and reports clean forever."
# A check straight after, so the heartbeat resumes now rather than at the
# next cycle, and the report reflects what was just accepted.
say ""
cmd_check
}
# Says changes are coming, so an update does not page anyone.
#
# Opened HERE, on the guard, and never from the devbox. A devbox that could say
# "expect changes" is a devbox that could silence its own guard.
#
# Until the window ends, changes and the devbox being unreachable are held
# rather than sent. Critical paths and containment still alert, since a change
# there is worth a push even during maintenance, and a window never accepts a
# critical path. At the end the rest of the devbox's state is accepted, by a
# check started now to run then rather than by whichever cycle of the timer
# comes next. If that check is lost, to a container restart say, the first
# check after the end does the same.
cmd_expect() { # cmd_expect [DURATION|--cancel]
local d=${1:-30m} n secs wend
[ -f "$BASE" ] || die "no baseline yet - run $PROG accept first"
if [ "$d" = --cancel ]; then
rm -f "$WINDOW"
say "Expected-changes window closed, nothing accepted. Changes will alert."
return 0
fi
n=${d%[smh]}
case "$n" in ''|*[!0-9]*) die "expect takes a duration like 30m, 2h or 90s - got '$d'" ;; esac
case "$d" in
*h) secs=$((n * 3600)) ;;
*s) secs=$n ;;
*) secs=$((n * 60)) ;;
esac
[ "$secs" -le 86400 ] || die "refusing a window over 24h: that is not watching"
wend=$(( $(date +%s) + secs ))
printf '%s\n' "$wend" > "$WINDOW"
# fd 9 is the lock, closed so the waiting check does not hold it while it
# sleeps.
setsid -f sh -c 'sleep "$1"; exec "$2" check' sh "$secs" "$0" \
</dev/null >/dev/null 2>&1 9>&-
say "Expecting changes on $(target) until $(clock "$wend")."
say " Changes and the devbox being unreachable are held until then. Critical"
say " paths and containment still alert. At $(clock "$wend") the state is accepted"
say " and a low-priority note says what changed."
say ""
say " Done early: $PROG accept"
say " Call it off: $PROG expect --cancel"
}
cmd_status() {
local wend
if [ -f "$BASE" ]; then
printf 'baseline: accepted %s, %s entries\n' \
"$(date -u -r "$BASE" +'%Y-%m-%dT%H:%M:%SZ')" "$(grep -c . "$BASE")"
else
printf 'baseline: none - run %s accept\n' "$PROG"
fi
if [ -r "$LAST" ]; then
printf 'last check: %s, %s\n' "$(sed -n 's/^--- collected //p' "$LAST" | head -1)" \
"$(sed -n 's/^result: //p' "$LAST" | head -1)"
else
printf 'last check: none\n'
fi
wend=$(window_end)
if [ -n "$wend" ]; then
printf 'window: expecting changes until %s\n' "$(clock "$wend")"
else
printf 'window: none\n'
fi
if [ -r "$FINDING" ]; then
printf 'last finding: %s (%s report --finding)\n' \
"$(sed -n 's/^--- collected //p' "$FINDING" | head -1)" "$PROG"
fi
}
# The alert path is the one thing here nothing else exercises.
@@ -704,7 +959,46 @@ cmd_test_alert() {
return 1
}
cmd_report() { [ -r "$LAST" ] && cat "$LAST" || say "no report yet - run $PROG check"; }
cmd_report() { # cmd_report [--finding]
# AN ARGUMENT THIS COPY DOES NOT KNOW IS AN ERROR, not something to ignore.
#
# The version before --finding existed dropped every argument at the
# dispatch, so `report --finding` printed the last report and looked like
# the flag had worked. An operator comparing the two commands saw identical
# output and reasonably concluded the feature did nothing, when what it did
# was not exist yet in the copy they were running.
#
# A container makes that easy to be in: `git pull` changes the checkout and
# not the image. So an unknown argument says which copy this is.
case "${1:-}" in
""|--finding) ;;
*) die "unknown argument for report: $1
This copy knows --finding and nothing else. If you expected a flag that is not
listed, the program answering you is older than the one you read about:
docker compose up --build -d # a container
sudo ./install.sh # a host install" ;;
esac
if [ "${1:-}" = "--finding" ]; then
[ -r "$FINDING" ] || { say "no cycle has found anything yet"; return 0; }
cat "$FINDING"
return 0
fi
[ -r "$LAST" ] || { say "no report yet - run $PROG check"; return 0; }
cat "$LAST"
# Said when the last cycle was clean and an earlier one was not, because
# that is exactly when an operator is looking and finding nothing. Compared
# by content: a finding identical to the last report IS the last report.
if [ -r "$FINDING" ] && ! cmp -s "$FINDING" "$LAST"; then
say ""
say "That was a clean cycle. The most recent one that found something is"
say "still here, from $(sed -n 's/^--- collected //p' "$FINDING" | head -1):"
say " $PROG report --finding"
fi
}
# Records the devbox's host key, because nothing else here ever did.
#
@@ -823,13 +1117,19 @@ cmd_trust_host() {
usage() {
cat >&2 <<USAGE
usage: $PROG [check|accept|report|test-alert|trust-host]
usage: $PROG [check|status|report [--finding]|accept|expect [30m]|test-alert|trust-host]
check collect from the devbox, diff against the baseline held here,
alert on any change or on containment failing, and send the
heartbeat only if the cycle completed clean and could alert
accept record the devbox's current state as the baseline
report the last cycle's detail, which is deliberately not in the alert
status the baseline, the last check, and any expected-changes window
accept record the devbox's current state as the baseline, then check
expect hold alerts for changes and for the devbox being unreachable,
then accept: 30m by default, or 2h, 90s. --cancel ends it
without accepting
report the last cycle's detail, which is deliberately not in the alert.
--finding shows the most recent cycle that found something, which
a later clean cycle would otherwise have overwritten
test-alert publish a test alert, then go and check it arrived. Nothing
else here exercises that path until it is needed
trust-host record the devbox's SSH host key, which every other command
@@ -838,10 +1138,21 @@ usage: $PROG [check|accept|report|test-alert|trust-host]
USAGE
}
# One run at a time on the state directory. The loop, an operator's accept and
# the check a window left waiting would otherwise each read and write the
# baseline under the others.
lock() {
install -d -m 0750 "$STATE_DIR"
exec 9>"$STATE_DIR/lock"
flock -w 300 9 || die "another $PROG run has held the lock for five minutes"
}
case "${1:-check}" in
check) cmd_check ;;
accept) cmd_accept ;;
report) cmd_report ;;
check) lock; cmd_check ;;
accept) lock; cmd_accept ;;
expect) shift; lock; cmd_expect "$@" ;;
status) cmd_status ;;
report) shift; cmd_report "$@" ;;
test-alert) cmd_test_alert ;;
trust-host) shift; cmd_trust_host "$@" ;;
-h|--help) usage ;;
+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 "$@"
+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
+7
View File
@@ -20,6 +20,10 @@ services:
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:
@@ -70,6 +74,9 @@ services:
# 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
+17
View File
@@ -69,6 +69,15 @@ ssh_known_hosts = /var/lib/dejima-guard/known_hosts
# 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
@@ -144,3 +153,11 @@ heartbeat_url = https://kuma.example.invalid/api/push/abcdef?status=up&msg=OK&pi
# 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
+361 -12
View File
@@ -837,18 +837,30 @@ SSHEOF
}
@test "every compose command that starts the guard rebuilds it" {
# There is no registry: the image is built from the Dockerfile. `docker
# compose up -d` reuses whatever image 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.
# There is no registry: the image is built from the Dockerfile in the
# checkout. `docker compose up -d` reuses whatever image 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.
#
# Scoped to the setup, which is the part that follows a pull. A one-shot
# `run` afterwards is working against an image that is already current, and
# rebuilding on every `accept` would be a slow answer to a question nobody
# asked.
setup_block=$(sed -n '/^### Or as a container/,/^### What goes in each setting/p' "$REPO/README.md")
while IFS= read -r line; do
case "$line" in
*" up "*|*" run "*) ;;
*) continue ;; # exec and down need no image built
*) continue ;;
esac
[[ "$line" == *--build* ]] \
|| { echo "compose command without --build: $line"; false; }
done < <(grep -oE 'docker compose [a-z]+ [^`]*' "$REPO/README.md")
|| { echo "setup compose command without --build: $line"; false; }
done < <(printf '%s\n' "$setup_block" | grep -oE 'docker compose [a-z]+ [^`]*')
# Anywhere at all, an `up` starts the long-running thing and must be current.
while IFS= read -r line; do
[[ "$line" == *--build* ]] \
|| { echo "compose up without --build: $line"; false; }
done < <(grep -oE 'docker compose up [^`]*' "$REPO/README.md")
}
# --- branding -----------------------------------------------------------------
@@ -867,8 +879,8 @@ SSHEOF
| sed 's/^src="//; s/"$//; s/^](//; s/)$//')
[ "$found" -ge 2 ] || { echo "expected at least the wordmark and the badge"; false; }
# Both wordmark modes ship, so a mirror on a light-themed host changes one
# line rather than needing a file it does not have.
# Both wordmark modes ship, because the GitHub mirror picks between them
# by the reader's theme.
[ -f "$REPO/assets/dejima-wordmark-darkmode.png" ]
[ -f "$REPO/assets/dejima-wordmark-lightmode.png" ]
}
@@ -876,9 +888,9 @@ SSHEOF
@test "the marks are attributed, since the licence here does not cover them" {
# They are trademarks, and GPL-3.0 says nothing about them. The link is
# absolute so a fork resolves it to the canonical terms rather than to its
# own copy of them.
grep -q 'dejima/src/branch/main/ATTRIBUTION.md' "$REPO/README.md"
grep -q 'dejima/src/branch/main/ATTRIBUTION.md' "$REPO/assets/README.md"
# own copy of them. Either form: the GitHub mirror rewrites Gitea's.
grep -qE 'dejima/(src/branch|blob)/main/ATTRIBUTION.md' "$REPO/README.md"
grep -qE 'dejima/(src/branch|blob)/main/ATTRIBUTION.md' "$REPO/assets/README.md"
}
# --- what the alerting setup documents ---------------------------------------
@@ -1247,3 +1259,340 @@ PORT tcp 0.0.0.0:4444" 0
run_watch check
[[ "$(curl_log)" == *"watched path changed"* ]]
}
# --- running one command in a container ---------------------------------------
@test "the entrypoint runs a single command when given arguments" {
# `docker compose run SERVICE CMD` replaces the command and keeps the
# entrypoint. So `run --rm dejima-guard trust-host` arrives at the loop as
# an argument, and a loop that ignored its arguments started checking every
# fifteen minutes instead - in a container the operator had to interrupt,
# while waiting for a fingerprint that was never going to print.
# Timed out, because the bug being guarded against is an infinite loop:
# without the timeout a regression hangs the suite rather than failing it.
run timeout 5 env DEJIMA_GUARD_BIN=/bin/echo "$REPO/bin/dejima-guard-loop" trust-host
[ "$status" -eq 0 ]
[ "$output" = "trust-host" ]
[[ "$output" != *"every"* ]]
}
@test "the entrypoint still loops when given nothing" {
# The timer is the default, and this is the half a container deployment
# depends on.
run timeout 2 env DEJIMA_GUARD_INTERVAL=60 DEJIMA_GUARD_BIN=/bin/true \
"$REPO/bin/dejima-guard-loop"
[[ "$output" == *"every 60s"* ]]
}
@test "the setup does not name the binary twice after run" {
# The entrypoint is already the guard, so repeating the name passes it to
# itself as an argument.
! grep -q 'compose run .*dejima-guard dejima-guard' "$REPO/README.md"
}
@test "the container form of every command is documented" {
# There is no dejima-guard on a container host. The bare names in Use are
# the host path, and running them on a compose deployment finds nothing, or
# finds a second deployment with its own baseline.
grep -q 'docker compose exec dejima-guard dejima-guard' "$REPO/README.md"
grep -q 'docker compose run --rm dejima-guard' "$REPO/README.md"
grep -qi 'no .dejima-guard. on the host' "$REPO/README.md"
}
# --- the evidence outliving the cycle after it --------------------------------
#
# last-report is overwritten every cycle, so a clean one erases the alert before
# it. On anything that alternates - and a flapping line alternates by definition
# - an operator running `report` after an alert lands is most likely to catch a
# clean cycle and read "no changes against the baseline", which says the alert
# was imaginary.
@test "a clean cycle does not erase the last finding" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE bbb /etc/one" 0 # a change: this cycle alerts
run_watch check
stub_ssh "FILE bbb /etc/one" 0 # accepted as the new normal? no:
run_watch accept # re-accept so the next is clean
run_watch check
run_watch report --finding
[[ "$output" == *"/etc/one"* ]]
}
@test "report says a finding is still there when the last cycle was clean" {
# This is exactly when an operator is looking and finding nothing.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE bbb /etc/one" 0
run_watch check
run_watch accept
run_watch check
run_watch report
[[ "$output" == *"no changes against the baseline"* ]]
[[ "$output" == *"report --finding"* ]]
}
@test "report does not point at a finding that is the report" {
# After an alerting cycle the two are the same file, and telling somebody to
# go and read what they are already reading is noise.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE bbb /etc/one" 0
run_watch check
run_watch report
[[ "$output" != *"report --finding"* ]]
}
@test "--finding with nothing found says so rather than printing the last cycle" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch check
run_watch report --finding
[[ "$output" == *"no cycle has found anything yet"* ]]
}
@test "an argument report does not know is an error, not a no-op" {
# The version before --finding dropped every argument at the dispatch, so
# `report --finding` printed the last report and looked like the flag had
# worked. Identical output from two different commands reads as the feature
# doing nothing, when what it did was not exist in the copy being run.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch check
run_watch report --findings # a plausible typo
[ "$status" -ne 0 ]
[[ "$output" == *"unknown argument"* ]]
}
@test "the unknown-argument message says how to get a newer copy" {
# A container makes this easy to be in: git pull changes the checkout and
# not the image.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch check
run_watch report --whatever
[[ "$output" == *"--build"* ]]
[[ "$output" == *"install.sh"* ]]
}
@test "report with no argument is still fine" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch check
run_watch report
[ "$status" -eq 0 ]
}
# --- what changed, in words the guard chose ------------------------------------
@test "the alert says where a change is and what kind, never the path" {
stub_ssh "FILE aaa /home/intruder-chosen/.ssh/known_hosts" 0
run_watch accept
curl_reset
stub_ssh "FILE bbb /home/intruder-chosen/.ssh/known_hosts
FILE ccc /etc/apt/sources.list.d/new.list" 0
run_watch check
[[ "$(curl_log)" == *"1 file(s) modified in ~/.ssh/known_hosts"* ]]
[[ "$(curl_log)" == *"1 file(s) added in /etc/apt"* ]]
[[ "$(curl_log)" == *"first connects to an SSH host"* ]]
[[ "$(curl_log)" != *intruder-chosen* ]]
[[ "$(curl_log)" != *new.list* ]]
}
@test "the report pairs an edit into one modified line" {
stub_ssh "FILE aaa /etc/one
FILE ddd /etc/gone" 0
run_watch accept
stub_ssh "FILE bbb /etc/one
FILE ccc /etc/two" 0
run_watch check
run_watch report
[[ "$output" == *"modified FILE /etc/one"* ]]
[[ "$output" == *"added FILE /etc/two"* ]]
[[ "$output" == *"removed FILE /etc/gone"* ]]
}
@test "the alert names the guard and the command to run, when configured" {
export DEJIMA_GUARD_GUARD_NAME=nas
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE bbb /etc/one" 0
run_watch check
[[ "$(curl_log)" == *"Details: dejima-guard report, on nas."* ]]
}
# --- ignore_paths ----------------------------------------------------------------
@test "an ignored path does not alert, and the cycle is clean" {
export DEJIMA_GUARD_IGNORE_PATHS='/.ssh/known_hosts'
stub_ssh "FILE aaa /home/u/.ssh/known_hosts" 0
run_watch accept
curl_reset
stub_ssh "FILE bbb /home/u/.ssh/known_hosts" 0
run_watch check
[ "$status" -eq 0 ]
[ "$(alerts)" = 0 ]
[ "$(heartbeats)" = 1 ]
run_watch report
[[ "$output" == *"ignored (ignore_paths):"* ]]
[[ "$output" == *"/home/u/.ssh/known_hosts"* ]]
}
@test "ignore_paths cannot hide a critical path" {
export DEJIMA_GUARD_IGNORE_PATHS='/.ssh/'
stub_ssh "FILE aaa /home/u/.ssh/authorized_keys" 0
run_watch accept
stub_ssh "FILE bbb /home/u/.ssh/authorized_keys" 0
run_watch check
[ "$status" -ne 0 ]
[[ "$(curl_log)" == *"watched path changed"* ]]
}
# --- expected-changes windows -------------------------------------------------
open_window() { printf '%s\n' "$(( $(date +%s) + $1 ))" > "$DEJIMA_GUARD_STATE/expect-until"; }
@test "inside a window a change is held, and the heartbeat still goes" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
curl_reset
open_window 600
stub_ssh "FILE bbb /etc/one" 0
run_watch check
[ "$status" -eq 0 ]
[ "$(alerts)" = 0 ]
[ "$(heartbeats)" = 1 ]
run_watch report
[[ "$output" == *"result: held"* ]]
}
@test "inside a window an unreachable devbox is held, without a heartbeat" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
curl_reset
open_window 600
stub_ssh "" 0 255
run_watch check
[ "$status" -ne 0 ]
[ "$(alerts)" = 0 ]
[ "$(heartbeats)" = 0 ]
}
@test "inside a window a critical path and containment still alert" {
stub_ssh "FILE aaa /etc/ssh/sshd_config" 0
run_watch accept
curl_reset
open_window 600
stub_ssh "FILE bbb /etc/ssh/sshd_config" 0
run_watch check
[[ "$(curl_log)" == *"watched path changed"* ]]
curl_reset
stub_ssh "FILE aaa /etc/ssh/sshd_config" 1
run_watch check
[[ "$(curl_log)" == *"containment"* ]]
}
@test "a window that has ended accepts, except critical paths" {
stub_ssh "FILE aaa /etc/one
FILE aaa /etc/ssh/sshd_config" 0
run_watch accept
curl_reset
open_window -1
stub_ssh "FILE bbb /etc/one
FILE bbb /etc/ssh/sshd_config" 0
run_watch check
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
grep -q 'FILE bbb /etc/one' "$DEJIMA_GUARD_STATE/baseline"
grep -q 'FILE aaa /etc/ssh/sshd_config' "$DEJIMA_GUARD_STATE/baseline"
[[ "$(curl_log)" == *"expected changes accepted"* ]]
[[ "$(curl_log)" == *"Priority: low"* ]]
[[ "$(curl_log)" == *"watched path changed"* ]]
run_watch report --finding
[[ "$output" == *"accepted as the expected-changes window closed"* ]]
}
@test "a window that ends with the devbox unreachable accepts nothing" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
curl_reset
open_window -1
stub_ssh "" 0 255
run_watch check
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
[[ "$(curl_log)" == *"unreachable"* ]]
stub_ssh "FILE bbb /etc/one" 0
run_watch check
[[ "$(curl_log)" == *"unexplained changes"* ]]
}
@test "expect accepts at the end of the window, not at the next cycle" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch expect 1s
[ "$status" -eq 0 ]
stub_ssh "FILE bbb /etc/one" 0
for _ in 1 2 3 4 5 6 7 8 9 10; do
grep -q 'FILE bbb' "$DEJIMA_GUARD_STATE/baseline" && break
sleep 1
done
grep -q 'FILE bbb /etc/one' "$DEJIMA_GUARD_STATE/baseline"
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
}
@test "expect refuses a nonsense duration, an endless one, and no baseline" {
run_watch expect 30m
[ "$status" -ne 0 ]
[[ "$output" == *"no baseline"* ]]
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
run_watch expect soon
[ "$status" -ne 0 ]
run_watch expect 25h
[ "$status" -ne 0 ]
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
}
@test "accept closes a window and checks, so the heartbeat resumes" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
open_window 600
curl_reset
run_watch accept
[ "$status" -eq 0 ]
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
[ "$(heartbeats)" = 1 ]
}
@test "expect --cancel closes a window without accepting" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
open_window 600
run_watch expect --cancel
[ ! -f "$DEJIMA_GUARD_STATE/expect-until" ]
stub_ssh "FILE bbb /etc/one" 0
run_watch check
[[ "$(curl_log)" == *"unexplained changes"* ]]
}
@test "status shows the baseline, the last check and the window" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
open_window 600
run_watch status
[[ "$output" == *"1 entries"* ]]
[[ "$output" == *"last check:"*"clean"* ]]
[[ "$output" == *"expecting changes until"* ]]
}
# --- the container host's wrapper --------------------------------------------
@test "the docker wrapper runs the guard in its container, from anywhere" {
cat > "$STUB/docker" <<EOF
#!/bin/sh
printf '%s\n' "\$*" > "$BATS_TEST_TMPDIR/docker.args"
EOF
chmod +x "$STUB/docker"
cd "$BATS_TEST_TMPDIR"
PATH="$STUB:$PATH" run "$REPO/bin/dejima-guard-docker" accept < /dev/null
[ "$(cat "$BATS_TEST_TMPDIR/docker.args")" = "exec -i dejima-guard dejima-guard accept" ]
}