mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
The four rejection checks were independent greps over the whole log: two npubs, a context and a decision. Together they proved only that node-a mentioned each npub somewhere, rejected somebody on an inbound handshake, and rejected somebody by denylist. Nothing tied a rejection to a named peer, and node-a lists both denied peers as auto_connect peers, so it logs their npubs on the outbound connect path whether or not any rejection ever happened. The actual message was never matched. Replace them with a helper that requires every given string on ONE line, and assert per denied peer: the real message, that peer's npub, and the denylist decision together. Strings match in any order by chaining fixed-string greps over the surviving lines, so the assertion does not depend on how the log formatter orders a message and its fields. A grep over empty input yields the empty string rather than anything a caller could mistake for a match, so an unreadable container times out and fails instead of passing. Two limits are written at the call sites rather than left implied. The decision conjunct discriminates nothing, since the two allowing variants return early and denylist is the only value that can reach that warning. And node-a authorizes before dialing, so it emits a fully formed rejection line for each denied peer on the outbound path; these assertions are satisfiable without the inbound check running at all, and it is the peer-count assertions that would catch that. Verified against the running harness: all three find a real matching line. The defect shape they replace was exercised offline first, with the three strings spread across three lines, where the old checks passed and the new ones fail.
249 lines
11 KiB
Bash
Executable File
249 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
# Integration test for the ACL allowlist harness.
|
|
#
|
|
# Usage: ./test.sh [--skip-build] [--keep-up]
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
|
|
GENERATE_CONFIGS="$SCRIPT_DIR/generate-configs.sh"
|
|
|
|
SKIP_BUILD=false
|
|
KEEP_UP=false
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--skip-build) SKIP_BUILD=true; shift ;;
|
|
--keep-up) KEEP_UP=true; shift ;;
|
|
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
cleanup() {
|
|
if [ "$KEEP_UP" = false ]; then
|
|
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT
|
|
|
|
log() {
|
|
echo "=== $*"
|
|
}
|
|
|
|
peer_npubs() {
|
|
local container="$1"
|
|
docker exec "$container" fipsctl show peers \
|
|
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(" ".join(sorted(p["npub"] for p in data.get("peers", []) if p.get("connectivity") == "connected")))'
|
|
}
|
|
|
|
acl_field() {
|
|
local container="$1"
|
|
local field="$2"
|
|
docker exec "$container" fipsctl acl show \
|
|
| python3 -c 'import json,sys; data=json.load(sys.stdin); field=sys.argv[1]; value=data.get(field); print(" ".join(sorted(value)) if isinstance(value, list) else ("" if value is None else value))' "$field"
|
|
}
|
|
|
|
assert_peer_set() {
|
|
local container="$1"
|
|
local expected="$2"
|
|
local actual
|
|
actual="$(peer_npubs "$container")"
|
|
if [ "$actual" != "$expected" ]; then
|
|
echo "FAIL: $container peers mismatch" >&2
|
|
echo " expected: $expected" >&2
|
|
echo " actual: $actual" >&2
|
|
exit 1
|
|
fi
|
|
echo "PASS: $container peers match expected set"
|
|
}
|
|
|
|
assert_acl_field() {
|
|
local container="$1"
|
|
local field="$2"
|
|
local expected="$3"
|
|
local actual
|
|
actual="$(acl_field "$container" "$field")"
|
|
if [ "$actual" != "$expected" ]; then
|
|
echo "FAIL: $container ACL field $field mismatch" >&2
|
|
echo " expected: $expected" >&2
|
|
echo " actual: $actual" >&2
|
|
exit 1
|
|
fi
|
|
echo "PASS: $container ACL field $field matches expected value"
|
|
}
|
|
|
|
# Connected-peer count for a container, or the empty string if it did not
|
|
# answer.
|
|
#
|
|
# Empty is deliberately distinct from a real 0, and here the distinction is
|
|
# the whole point: the ACL denial checks below expect exactly 0, so an
|
|
# `|| echo 0` fallback lets an unreachable container satisfy them on the first
|
|
# iteration and the security property is never observed. Same shape as
|
|
# admission-cap-test.sh's read_peer_count.
|
|
read_connected_peers() {
|
|
local container="$1"
|
|
docker exec "$container" fipsctl show peers 2>/dev/null \
|
|
| python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null \
|
|
|| true
|
|
}
|
|
|
|
wait_for_peers_exact() {
|
|
local container="$1"
|
|
local expected_count="$2"
|
|
local timeout="${3:-30}"
|
|
|
|
local count="" answered=false
|
|
for _ in $(seq 1 "$timeout"); do
|
|
count=$(read_connected_peers "$container")
|
|
if [ -n "$count" ]; then
|
|
answered=true
|
|
if [ "$count" -eq "$expected_count" ]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
if [ "$answered" = false ]; then
|
|
echo "FAIL: $container never answered a peer query in ${timeout}s, so a count of $expected_count was never actually observed" >&2
|
|
else
|
|
echo "FAIL: $container did not reach $expected_count connected peers in ${timeout}s (last answer: $count)" >&2
|
|
fi
|
|
docker exec "$container" fipsctl show peers >&2 || true
|
|
exit 1
|
|
}
|
|
|
|
# Assert that ONE log line in $container contains every one of the given
|
|
# fixed strings.
|
|
#
|
|
# Single-line matching is the point. Independent whole-log greps for an
|
|
# npub and for `decision=denylist match` are jointly satisfied by "this
|
|
# npub appears somewhere" plus "somebody was rejected by denylist", which
|
|
# is not the property this suite exists to prove. Node-a lists the denied
|
|
# peers as auto_connect peers, so it logs their npubs on the outbound
|
|
# connect path whether or not a rejection ever happened; the npub has to
|
|
# be on the rejection line itself to mean anything.
|
|
#
|
|
# Strings match in any order, by chaining fixed-string greps over the
|
|
# surviving lines, so the assertion does not depend on the order the
|
|
# tracing formatter emits a message and its fields in. A grep over empty
|
|
# input yields the empty string rather than a value that could satisfy
|
|
# the caller, so a container that cannot be read times out and fails
|
|
# rather than passing.
|
|
#
|
|
# Polls rather than reading once: under the XX handshake the
|
|
# cross-connection tie-breaker decides which side reaches its ACL check
|
|
# first, so an inbound-handshake rejection may not emit until a later
|
|
# retry. Same wait-with-timeout shape as wait_for_peers_exact above.
|
|
#
|
|
# Deliberately NOT registered in check-log-strings.py's SHELL_HELPERS,
|
|
# for two reasons, and note that registering it would in fact capture
|
|
# nothing: that extractor reads only a helper's FIRST argument and only
|
|
# when it is a quoted literal free of `$` (check-log-strings.py:116),
|
|
# whereas the first argument here is the unquoted container name
|
|
# carrying ${FIPS_CI_NAME_SUFFIX}. Verified by running the extractor
|
|
# with this helper added: zero hits. The same is true of the
|
|
# `assert_log_contains` this replaced, so nothing left the check's scope.
|
|
#
|
|
# It should stay out of scope regardless: that check exists for strings
|
|
# whose disappearance from src/ would let an assertion silently pass,
|
|
# and every string here is a positive requirement, so a missing one
|
|
# exhausts the poll and exits 1, which is loud.
|
|
assert_log_line_contains_all() {
|
|
local container="$1"
|
|
local timeout="$2"
|
|
shift 2
|
|
|
|
local logs surviving pattern
|
|
for _ in $(seq 1 "$timeout"); do
|
|
logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)"
|
|
surviving="$logs"
|
|
for pattern in "$@"; do
|
|
surviving="$(printf '%s' "$surviving" | grep -F -- "$pattern" || true)"
|
|
done
|
|
if [ -n "$surviving" ]; then
|
|
echo "PASS: $container has a log line matching all of: $*"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
echo "FAIL: no single log line in $container contains all of: $* (waited ${timeout}s)" >&2
|
|
exit 1
|
|
}
|
|
|
|
if [ "$SKIP_BUILD" = false ]; then
|
|
log "Building Linux test binaries"
|
|
"$TESTING_DIR/scripts/build.sh" --no-docker
|
|
fi
|
|
|
|
log "Generating ACL allowlist fixtures"
|
|
"$GENERATE_CONFIGS"
|
|
|
|
log "Starting ACL allowlist harness"
|
|
docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
|
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
|
|
|
log "Waiting for expected peer convergence"
|
|
wait_for_peers_exact fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 3 40
|
|
wait_for_peers_exact fips-acl-container-b${FIPS_CI_NAME_SUFFIX:-} 1 40
|
|
wait_for_peers_exact fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} 0 5
|
|
wait_for_peers_exact fips-acl-container-d${FIPS_CI_NAME_SUFFIX:-} 0 5
|
|
wait_for_peers_exact fips-acl-container-e${FIPS_CI_NAME_SUFFIX:-} 1 40
|
|
wait_for_peers_exact fips-acl-container-f${FIPS_CI_NAME_SUFFIX:-} 1 40
|
|
|
|
log "Verifying peer sets"
|
|
assert_peer_set fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
|
assert_peer_set fips-acl-container-b${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
|
assert_peer_set fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} ""
|
|
assert_peer_set fips-acl-container-d${FIPS_CI_NAME_SUFFIX:-} ""
|
|
assert_peer_set fips-acl-container-e${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
|
assert_peer_set fips-acl-container-f${FIPS_CI_NAME_SUFFIX:-} "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
|
|
|
|
log "Checking alias-based ACL resolution"
|
|
assert_acl_field fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} allow_file_entries "node-a node-b node-e node-f"
|
|
assert_acl_field fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} allow_entries "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
|
assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_file_entries "node-a node-b node-c node-d node-e node-f"
|
|
assert_acl_field fips-acl-container-c${FIPS_CI_NAME_SUFFIX:-} allow_entries "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c"
|
|
|
|
log "Checking ACL rejection logs"
|
|
# One assertion per denied peer, each requiring the real message, that
|
|
# peer's npub and the denylist decision on the SAME line. The npub being
|
|
# on the rejection line is what carries the weight here: node-a rejected
|
|
# THIS peer. The `decision=` conjunct adds no discrimination, since
|
|
# PeerAclDecision::allowed() returns early for AllowList and DefaultAllow
|
|
# (src/node/acl.rs:44-46), so DenyList is the only value that can reach
|
|
# that warn! and every rejection line carries it. It is kept because the
|
|
# remediation prescribes it and it documents the expected decision.
|
|
#
|
|
# Residual, recorded rather than hidden: node-a has auto_connect stanzas
|
|
# for both denied peers and authorizes before dialing, so it emits a
|
|
# fully-formed rejection line for each on the outbound_connect path.
|
|
# These two assertions are therefore satisfiable without the inbound ACL
|
|
# check running at all. The suite still catches that, because the denied
|
|
# peer would then connect and the peer-count assertions above would time
|
|
# out; but these two lines alone do not prove the inbound path.
|
|
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
|
"Rejected peer by ACL" \
|
|
"npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" \
|
|
"decision=denylist match"
|
|
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
|
"Rejected peer by ACL" \
|
|
"npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" \
|
|
"decision=denylist match"
|
|
# The outsider-initiated path specifically, asserted separately and not
|
|
# per-npub. Node-a carries static stanzas for both denied peers, so each
|
|
# can be rejected on the outbound_connect path as well and which context
|
|
# a given npub lands in is not deterministic (see README). Requiring an
|
|
# inbound rejection of a *named* peer would red on scheduling rather than
|
|
# on a regression; requiring that one exists at all does not.
|
|
assert_log_line_contains_all fips-acl-container-a${FIPS_CI_NAME_SUFFIX:-} 15 \
|
|
"Rejected peer by ACL" \
|
|
"context=inbound_handshake" \
|
|
"decision=denylist match"
|
|
|
|
log "ACL allowlist integration test passed"
|