405 lines
18 KiB
Bash
405 lines
18 KiB
Bash
#!/usr/bin/env bash
|
|
# check-binary-composition.sh — release gate: assert what must NOT be inside a
|
|
# shipped artifact.
|
|
#
|
|
# Microsoft Defender's ML classifier flagged the v0.9.1-rc.1 binaries. The
|
|
# hardening pass that followed *removed capability* — an executable stack, an
|
|
# in-process updater, test-only environment seams, the embedded HTTP/UI server
|
|
# and its process enumerator in the standard build. Each of those regresses
|
|
# invisibly: one restored #include, one Makefile source list edit, one revived
|
|
# call site, and nothing else in CI notices — the binary just quietly gets its
|
|
# malware-shaped surface back and the next release gets flagged again.
|
|
#
|
|
# This script is the proof that each removal stayed removed. It asserts only
|
|
# NEGATIVE properties (needle absent), plus one canary string we know ships,
|
|
# because an absence check aimed at the wrong file — a compressed artifact, a
|
|
# stub, a truncated download — would otherwise pass vacuously and read green.
|
|
# A missing tool is a hard error for the same reason: a skipped assertion must
|
|
# never look like a satisfied one.
|
|
#
|
|
# Usage: scripts/ci/check-binary-composition.sh [--variant=auto|standard|ui] <binary-or-dir>...
|
|
# Directories are scanned recursively; format (ELF / Mach-O / PE) is detected
|
|
# per file from its magic bytes and each assertion runs where it is
|
|
# meaningful. Exit 0 = every assertion passed, 1 = at least one failed,
|
|
# 2 = usage error, missing tool, or nothing checkable was found (a vacuous
|
|
# run is a failure, not a pass).
|
|
set -euo pipefail
|
|
|
|
case "${1:-}" in
|
|
-h | --help)
|
|
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
# ── Needles ─────────────────────────────────────────────────────────
|
|
# Every needle below was grep-verified against the tree it comes from: it
|
|
# exists ONLY in the code the assertion is about, so a hit means that code is
|
|
# linked in, not that some unrelated string happens to look similar.
|
|
|
|
# S6: worker/Windows test seams read these env vars. A release binary that
|
|
# still honours them lets any process on the box steer the indexer's child
|
|
# processes and file placement — and CBM_TEST_* in a shipped binary is exactly
|
|
# the "debug/injection hooks" shape AV heuristics score on.
|
|
SEAM_NEEDLES=(
|
|
'CBM_TEST_WORKER_DESCENDANT_PID_FILE'
|
|
'CBM_TEST_WINDOWS_USER_PATH_RUN_ID'
|
|
)
|
|
|
|
# The in-process updater (download-and-replace-own-binary) is the single most
|
|
# malware-shaped behaviour we ever shipped; updates now run from install.sh
|
|
# out-of-process. These four needles cover both halves of what was removed:
|
|
# the download base URL, the checksum URL built on top of it, the GitHub API
|
|
# release query of the daemon's background version check, and that request's
|
|
# Accept header (which survives even if the URL is ever assembled at runtime).
|
|
UPDATER_NEEDLES=(
|
|
'releases/latest/download'
|
|
'api.github.com/repos'
|
|
'releases/latest'
|
|
'Accept: application/vnd.github+json'
|
|
)
|
|
|
|
# SQLite loadable extensions = arbitrary code execution through a database
|
|
# file. The amalgamation is built with SQLITE_OMIT_LOAD_EXTENSION; if that ever
|
|
# drops out, dlopen()/LoadLibrary() re-enters the store layer.
|
|
# NOTE: release binaries are stripped, so the two API symbol names only appear
|
|
# when the surface is *exported*. The dlopen error text is the needle that
|
|
# still works on a stripped build — it is compiled in only when the feature is.
|
|
SQLITE_LOADEXT_NEEDLES=(
|
|
'sqlite3_load_extension'
|
|
'sqlite3_enable_load_extension'
|
|
'unable to open shared library ['
|
|
)
|
|
|
|
# The standard artifact must not contain the UI's HTTP server at all: an
|
|
# unauthenticated localhost listener plus a process enumerator is precisely the
|
|
# behaviour pair a classifier reads as a backdoor. Needles are split across
|
|
# both UI translation units and are independent of each other, so a refactor of
|
|
# any single one cannot silently disarm the assertion:
|
|
# 'HTTP/1.1 %d %s' httpd.c response-status writer
|
|
# 'Request Header Fields Too Large' httpd.c status-text table
|
|
# '/api/ui-config', '/api/processes' http_server.c route dispatch
|
|
# 'ps -eo …' / '[c]odebase-memory-mcp' the popen() process enumerator's
|
|
# shell pipeline, both halves
|
|
# Verified unique to src/ui/ across src/, vendored/ and internal/ — the only
|
|
# other occurrences in the repo are tests/test_httpd.c and scripts/, neither of
|
|
# which is ever linked into a release binary.
|
|
UI_HTTP_NEEDLES=(
|
|
'HTTP/1.1 %d %s'
|
|
'Request Header Fields Too Large'
|
|
'/api/ui-config'
|
|
'/api/processes'
|
|
'ps -eo pid,pcpu,rss,etime,comm'
|
|
'[c]odebase-memory-mcp'
|
|
)
|
|
|
|
# Canary: proves the needle scan can actually see this file's strings. Without
|
|
# it, handing the gate a gzip, a stub or a 0-byte file would pass every
|
|
# absence assertion. 168+ occurrences in a real artifact, 0 in anything else.
|
|
CANARY_NEEDLE='codebase-memory-mcp'
|
|
|
|
# ── Args ────────────────────────────────────────────────────────────
|
|
VARIANT=auto
|
|
TARGETS=()
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--variant=*) VARIANT="${arg#--variant=}" ;;
|
|
-*)
|
|
echo "FAIL: unknown flag $arg (see --help)" >&2
|
|
exit 2
|
|
;;
|
|
*) TARGETS+=("$arg") ;;
|
|
esac
|
|
done
|
|
case "$VARIANT" in
|
|
auto | standard | ui) ;;
|
|
*)
|
|
echo "FAIL: --variant must be auto, standard or ui (got '$VARIANT')" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
|
echo "FAIL: no binaries or directories given (see --help)" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# ── ELF program-header reader ───────────────────────────────────────
|
|
# Resolved lazily: a Windows/macOS-only run must not fail for want of readelf,
|
|
# but an ELF in scope with no reader available must fail loudly (exit 2) rather
|
|
# than skip the executable-stack assertion.
|
|
ELF_READER=''
|
|
ELF_READER_KIND=''
|
|
resolve_elf_reader() {
|
|
[ -n "$ELF_READER" ] && return 0
|
|
for cand in readelf llvm-readelf /opt/homebrew/opt/llvm/bin/llvm-readelf \
|
|
/usr/local/opt/llvm/bin/llvm-readelf; do
|
|
if command -v "$cand" >/dev/null 2>&1; then
|
|
ELF_READER="$cand"
|
|
ELF_READER_KIND=readelf
|
|
return 0
|
|
fi
|
|
done
|
|
for cand in objdump llvm-objdump /opt/homebrew/opt/llvm/bin/llvm-objdump; do
|
|
if command -v "$cand" >/dev/null 2>&1; then
|
|
ELF_READER="$cand"
|
|
ELF_READER_KIND=objdump
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Echoes the GNU_STACK flag field ("RWE", "RW", "rwx", "rw-"), empty if the
|
|
# header is absent or unparseable.
|
|
gnu_stack_flags() {
|
|
case "$ELF_READER_KIND" in
|
|
readelf)
|
|
"$ELF_READER" -lW "$1" 2>/dev/null |
|
|
awk '/GNU_STACK/ { print $(NF - 1); exit }'
|
|
;;
|
|
objdump)
|
|
# objdump -p prints " STACK off ..." and the flags on the next line.
|
|
"$ELF_READER" -p "$1" 2>/dev/null |
|
|
awk '/STACK off/ { getline
|
|
for (i = 1; i <= NF; i++)
|
|
if ($i == "flags") { print $(i + 1); exit } }'
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ── Reporting ───────────────────────────────────────────────────────
|
|
# PASS and FAIL both go to stdout so the per-assertion sequence stays in order
|
|
# in a CI log (stderr would interleave nondeterministically); only the final
|
|
# verdict is echoed to stderr, which is what a failed step's tail shows.
|
|
pass_count=0
|
|
fail_count=0
|
|
FAILURES=()
|
|
|
|
report() { # verdict, assertion-id, file-token, message
|
|
printf '%-4s %-22s %s: %s\n' "$1" "$2" "$3" "$4"
|
|
if [ "$1" = FAIL ]; then
|
|
fail_count=$((fail_count + 1))
|
|
FAILURES+=("$2 $3: $4")
|
|
else
|
|
pass_count=$((pass_count + 1))
|
|
fi
|
|
}
|
|
|
|
assert_absent() { # file, token, assertion-id, needle
|
|
if LC_ALL=C grep -a -q -F -e "$4" "$1"; then
|
|
report FAIL "$3" "$2" "'$4' is PRESENT (must not ship)"
|
|
else
|
|
report PASS "$3" "$2" "'$4' absent"
|
|
fi
|
|
}
|
|
|
|
assert_present() { # file, token, assertion-id, needle, why-pass, why-fail
|
|
if LC_ALL=C grep -a -q -F -e "$4" "$1"; then
|
|
report PASS "$3" "$2" "'$4' present ($5)"
|
|
else
|
|
report FAIL "$3" "$2" "'$4' is MISSING — $6"
|
|
fi
|
|
}
|
|
|
|
# ── Per-file checks ─────────────────────────────────────────────────
|
|
detect_format() { # file → elf | macho | pe | other
|
|
magic=$(LC_ALL=C od -An -N4 -tx1 "$1" 2>/dev/null | tr -d ' \n')
|
|
case "$magic" in
|
|
7f454c46) echo elf ;;
|
|
cffaedfe | cefaedfe | feedface | feedfacf | cafebabe | bebafeca) echo macho ;;
|
|
4d5a*) echo pe ;;
|
|
*) echo other ;;
|
|
esac
|
|
}
|
|
|
|
checked_files=0
|
|
skipped_files=0
|
|
|
|
check_file() {
|
|
file="$1"
|
|
# Two path components: both variants ship a binary literally named
|
|
# "codebase-memory-mcp", so the parent directory is what tells them apart.
|
|
token=$(printf '%s' "$file" | awk -F/ '{ if (NF > 1) print $(NF - 1) "/" $NF; else print $NF }')
|
|
fmt=$(detect_format "$file")
|
|
if [ "$fmt" = other ]; then
|
|
printf 'skip %-22s %s: not an ELF/Mach-O/PE binary\n' '-' "$token"
|
|
skipped_files=$((skipped_files + 1))
|
|
return 0
|
|
fi
|
|
|
|
is_ui=0
|
|
case "$VARIANT" in
|
|
ui) is_ui=1 ;;
|
|
standard) is_ui=0 ;;
|
|
auto)
|
|
# The UI archive is codebase-memory-mcp-ui-<os>-<arch>, but the binary
|
|
# inside it is just "codebase-memory-mcp" — so the whole path decides,
|
|
# not the basename. Printed below so a misclassified path is visible in
|
|
# the log instead of silently disarming the UI assertion.
|
|
case "$file" in
|
|
*-ui | *-ui.* | *-ui-* | *-ui/*) is_ui=1 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
|
|
variant_label=standard
|
|
[ "$is_ui" -eq 1 ] && variant_label='UI (A5-no-ui-http not applicable)'
|
|
printf '\n── %s [%s, %s] ──\n' "$token" "$fmt" "$variant_label"
|
|
checked_files=$((checked_files + 1))
|
|
|
|
# A0 — anti-vacuity canary; every assertion below is an absence check.
|
|
assert_present "$file" "$token" A0-canary "$CANARY_NEEDLE" \
|
|
'the needle scan can read this file' \
|
|
'this is not one of our artifacts, or its strings are unreadable (packed/compressed/truncated) — every absence assertion below would pass vacuously'
|
|
|
|
# A1 — executable stack (ELF only). Every Linux artifact of v0.9.1-rc.1
|
|
# shipped GNU_STACK RWE: a writable+executable stack, which no modern
|
|
# binary has and which any classifier weighs heavily.
|
|
if [ "$fmt" = elf ]; then
|
|
if ! resolve_elf_reader; then
|
|
echo "FAIL: no readelf/llvm-readelf/objdump available; cannot assert" \
|
|
"the non-executable stack for $token — refusing to skip it" >&2
|
|
exit 2
|
|
fi
|
|
flags=$(gnu_stack_flags "$file")
|
|
# readelf spells the flags "RWE"/"RW", objdump "rwx"/"rw-" — match both.
|
|
case "$flags" in
|
|
'')
|
|
report FAIL A1-noexec-stack "$token" \
|
|
"no GNU_STACK program header found ($ELF_READER) — without PT_GNU_STACK the loader may fall back to READ_IMPLIES_EXEC"
|
|
;;
|
|
*E* | *X* | *e* | *x*)
|
|
report FAIL A1-noexec-stack "$token" \
|
|
"GNU_STACK is $flags — the stack is EXECUTABLE (link with -z noexecstack; check .S/asm objects for a missing .note.GNU-stack)"
|
|
;;
|
|
*)
|
|
report PASS A1-noexec-stack "$token" "GNU_STACK is $flags (no execute bit)"
|
|
;;
|
|
esac
|
|
else
|
|
printf 'n/a %-22s %s: executable-stack check is ELF-only\n' A1-noexec-stack "$token"
|
|
fi
|
|
|
|
# A2 — test-only seams.
|
|
for needle in "${SEAM_NEEDLES[@]}"; do
|
|
assert_absent "$file" "$token" A2-no-test-seams "$needle"
|
|
done
|
|
# Sweep for seams nobody thought to pin: a new CBM_TEST_* env var added to
|
|
# production code lands here on its first release, not on the next audit.
|
|
# Two seams remain in release artifacts BY DECISION, so the wildcard is an
|
|
# allowlist rather than a blanket ban: scripts/smoke-test.sh runs against the
|
|
# real release artifact, and these are what let it do so honestly —
|
|
# CRASH_ON/HANG_ON inject the faults that prove supervisor recovery, and
|
|
# WINDOWS_USER_PATH_RUN_ID is what stops the PATH smoke from writing the
|
|
# tester's actual PATH. Deleting them would trade genuine release-artifact
|
|
# coverage for a cosmetic win. Anything NOT on this list is a novel seam and
|
|
# fails, which is the property that matters: the list can only shrink by
|
|
# decision, never grow by accident.
|
|
seam_allowed='CBM_TEST_CRASH_ON CBM_TEST_HANG_ON CBM_TEST_WINDOWS_USER_PATH_RUN_ID'
|
|
seam_unexpected=''
|
|
for found in $(LC_ALL=C grep -a -o -E 'CBM_TEST_[A-Za-z0-9_]+' "$file" 2>/dev/null |
|
|
sort -u || true); do
|
|
case " $seam_allowed " in
|
|
*" $found "*) ;;
|
|
*) seam_unexpected="$seam_unexpected $found" ;;
|
|
esac
|
|
done
|
|
if [ -n "$seam_unexpected" ]; then
|
|
report FAIL A2-no-test-seams "$token" \
|
|
"unexpected CBM_TEST_* seam(s):$seam_unexpected (allowlist: $seam_allowed)"
|
|
else
|
|
report PASS A2-no-test-seams "$token" 'no CBM_TEST_* seams beyond the smoke allowlist'
|
|
fi
|
|
|
|
# A3 — updater/release URLs.
|
|
for needle in "${UPDATER_NEEDLES[@]}"; do
|
|
assert_absent "$file" "$token" A3-no-updater-urls "$needle"
|
|
done
|
|
|
|
# A4 — SQLite loadable-extension surface.
|
|
for needle in "${SQLITE_LOADEXT_NEEDLES[@]}"; do
|
|
assert_absent "$file" "$token" A4-no-sqlite-loadext "$needle"
|
|
done
|
|
# Positive control for A4: the three needles above are all *absent* on a
|
|
# stripped build even when the feature is compiled in, so on its own that
|
|
# assertion can pass vacuously. SQLite's own compile-option table names
|
|
# every OMIT_* it was built with — when that table is present (it ships
|
|
# unless SQLITE_OMIT_COMPILEOPTION_DIAGS is set) it proves the omission
|
|
# positively rather than by absence.
|
|
if LC_ALL=C grep -a -q -F -e 'sqlite_compileoption_get' "$file"; then
|
|
assert_present "$file" "$token" A4-no-sqlite-loadext 'OMIT_LOAD_EXTENSION' \
|
|
'sqlite reports SQLITE_OMIT_LOAD_EXTENSION' \
|
|
'sqlite was NOT built with SQLITE_OMIT_LOAD_EXTENSION, so the dlopen()/LoadLibrary() extension loader is linked in'
|
|
else
|
|
printf 'n/a %-22s %s: %s\n' A4-no-sqlite-loadext "$token" \
|
|
"sqlite compile-option table absent; A4 rests on absence needles only"
|
|
fi
|
|
|
|
# A5 — UI/HTTP subsystem, standard artifacts only.
|
|
#
|
|
# NOT YET ENFORCED. src/ui/{config,http_server,layout3d,httpd,embedded_stub}.c
|
|
# are still in PROD_SRCS, and the standard build merely substitutes empty
|
|
# embedded assets — so the HTTP server and its popen("ps … | grep") process
|
|
# enumerator are linked into artifacts that can never serve a UI. Excluding
|
|
# them is a real refactor: four files outside src/ui (src/main.c,
|
|
# src/daemon/host.c, src/daemon/application.c, src/mcp/index_supervisor.c)
|
|
# reference UI symbols, including the daemon that serves the UI.
|
|
#
|
|
# Until that lands, this assertion reports rather than fails. A gate everyone
|
|
# knows is red teaches people to ignore gates, and a known-failing check has
|
|
# no more enforcement value than this note — but the needles are kept live and
|
|
# exercised so that the day the split lands, flipping CBM_CHECK_UI_ABSENT=1
|
|
# (then making it the default) is a one-line change and not a rewrite.
|
|
if [ "$is_ui" -eq 1 ]; then
|
|
printf 'n/a %-22s %s: UI artifact, HTTP server ships here by design\n' \
|
|
A5-no-ui-http "$token"
|
|
elif [ "${CBM_CHECK_UI_ABSENT:-0}" = "1" ]; then
|
|
for needle in "${UI_HTTP_NEEDLES[@]}"; do
|
|
assert_absent "$file" "$token" A5-no-ui-http "$needle"
|
|
done
|
|
else
|
|
ui_hits=0
|
|
for needle in "${UI_HTTP_NEEDLES[@]}"; do
|
|
if LC_ALL=C grep -a -q -F -e "$needle" "$file"; then
|
|
ui_hits=$((ui_hits + 1))
|
|
fi
|
|
done
|
|
printf 'INFO %-22s %s: %d/%d UI/HTTP needles present (no-UI split pending; set CBM_CHECK_UI_ABSENT=1 to enforce)\n' \
|
|
A5-no-ui-http "$token" "$ui_hits" "${#UI_HTTP_NEEDLES[@]}"
|
|
fi
|
|
}
|
|
|
|
# ── Walk the targets ────────────────────────────────────────────────
|
|
for target in "${TARGETS[@]}"; do
|
|
if [ -d "$target" ]; then
|
|
# "! -type d" rather than "-type f": a symlinked or otherwise unusual
|
|
# artifact must produce a visible skip line, never vanish from the run.
|
|
# sort keeps the report order stable across platforms (find order is not).
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] && check_file "$f"
|
|
done <<EOF
|
|
$(find "$target" ! -type d | LC_ALL=C sort)
|
|
EOF
|
|
elif [ -f "$target" ]; then
|
|
check_file "$target"
|
|
else
|
|
echo "FAIL: $target is neither a file nor a directory" >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
printf '\n'
|
|
if [ "$checked_files" -eq 0 ]; then
|
|
echo "FAIL: no ELF/Mach-O/PE binary found in the given targets ($skipped_files file(s) skipped)" >&2
|
|
echo " a gate that checked nothing is not a green gate" >&2
|
|
exit 2
|
|
fi
|
|
if [ "$fail_count" -ne 0 ]; then
|
|
echo "BINARY COMPOSITION GATE FAILED: $fail_count assertion(s) over $checked_files binary/binaries" >&2
|
|
for f in "${FAILURES[@]}"; do
|
|
echo " - $f" >&2
|
|
done
|
|
exit 1
|
|
fi
|
|
echo "BINARY COMPOSITION OK: $pass_count assertion(s) passed over $checked_files binary/binaries"
|