Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c61a28e86 | ||
|
|
ed2a843406 | ||
|
|
fe9db52f68 | ||
|
|
50f0f6980f | ||
|
|
674d77d788 | ||
|
|
aa4d7c9207 | ||
|
|
4b700a24cc | ||
|
|
051c096c0d | ||
|
|
f86a6b5c75 | ||
|
|
df590bf8c9 | ||
|
|
21ebd4709e | ||
|
|
aec0100ce6 |
@@ -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 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
@@ -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
@@ -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
@@ -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 ;;
|
||||
|
||||
Executable
+19
@@ -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 "$@"
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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" ]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user