#!/usr/bin/env bash # Read-only Ferryman attachment safety scan. Never reads token contents. set -euo pipefail WORKSPACE= while (($#)); do case "$1" in --workspace) WORKSPACE=${2:?}; shift 2 ;; -h|--help) echo "Usage: scan-project-safety.sh --workspace /path/to/project" exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done : "${WORKSPACE:?--workspace is required}" WORKSPACE=$(cd "$WORKSPACE" && pwd -P) ATTACHMENT="$WORKSPACE/.ferryman" COMMUNICATIONS="$ATTACHMENT/ferryman" FAILURES=0 result() { local level=$1 check=$2 detail=$3 printf '%-4s %-22s %s\n' "$level" "$check" "$detail" [[ "$level" != FAIL ]] || FAILURES=$((FAILURES + 1)) } result PASS workspace "$WORKSPACE" if [[ -d "$WORKSPACE/.git" ]]; then main_remote=$(git -C "$WORKSPACE" config --get remote.origin.url || true) result PASS main_remote "${main_remote:-"(none)"}" # Probe a child path so a directory-only rule such as /.ferryman/ is # recognized even before the attachment directory exists. if git -C "$WORKSPACE" check-ignore --no-index -q -- .ferryman/probe; then result PASS main_ignore "/.ferryman/ is ignored" else result FAIL main_ignore "/.ferryman/ is not ignored" fi else result WARN main_git "workspace is not a Git checkout" fi if [[ -d "$ATTACHMENT" ]]; then result PASS attachment "$ATTACHMENT" reparse=$(find "$ATTACHMENT" -path "$COMMUNICATIONS/.git" -prune -o -type l -print) if [[ -z "$reparse" ]]; then result PASS symlinks "no symlinks under .ferryman" else result FAIL symlinks "$reparse" fi else result WARN attachment "no .ferryman attachment exists" fi if [[ -f "$ATTACHMENT/token" ]]; then result PASS outer_token "exists; contents were not read" else result WARN outer_token "absent; hub registration may be deferred" fi if [[ -d "$COMMUNICATIONS/.git" ]]; then inner_remote=$(git -C "$COMMUNICATIONS" config --get remote.origin.url || true) scan_suffix="${FERRYMAN_CHANNEL_GIT_SUFFIX:--ferryman}" if [[ -z "$inner_remote" ]]; then # No upstream at all is the Syncthing-only channel, not a failure. result PASS inner_remote "(none; Syncthing-only)" elif [[ -z "${FERRYMAN_CHANNEL_GIT_OWNER:-}" ]]; then result FAIL inner_remote "$inner_remote (set FERRYMAN_CHANNEL_GIT_OWNER to pin the expected owner)" elif [[ "$inner_remote" =~ ^https://github\.com/"$FERRYMAN_CHANNEL_GIT_OWNER"/[A-Za-z0-9._-]+"$scan_suffix"(\.git)?$ ]]; then result PASS inner_remote "$inner_remote" else result FAIL inner_remote "$inner_remote" fi inner_status=$(git -C "$COMMUNICATIONS" status --porcelain --untracked-files=all) if [[ -z "$inner_status" ]]; then result PASS inner_status "portable repository is clean" else result WARN inner_status "$inner_status" fi else result WARN inner_git "portable communications Git checkout is absent" fi for forbidden in token runtime; do if [[ -e "$COMMUNICATIONS/$forbidden" ]]; then result FAIL "inner_$forbidden" "forbidden portable path exists" else result PASS "inner_$forbidden" "absent" fi done if [[ -d "$COMMUNICATIONS" ]]; then suspicious=$(find "$COMMUNICATIONS" -path "$COMMUNICATIONS/.git" -prune -o -type f \ \( -iname '.env' -o -iname '.env.*' -o -iname '*token*' -o -iname '*secret*' \ -o -iname '*credential*' -o -iname '*password*' -o -iname '*.db' \ -o -iname '*.sqlite' -o -iname '*.sqlite3' -o -iname '*.lock' \) -print) if [[ -z "$suspicious" ]]; then result PASS portable_names "no suspicious portable filenames" else result FAIL portable_names "$suspicious" fi fi STANDARD="$COMMUNICATIONS/STANDARD.toml" if [[ -f "$STANDARD" ]]; then revision=$(sed -nE 's/^revision[[:space:]]*=[[:space:]]*([0-9]+)[[:space:]]*$/\1/p' "$STANDARD" | head -n1) revision=${revision:-0} if ((revision == 2)); then result PASS standard_revision "revision 2" elif ((revision > 2)); then result FAIL standard_revision "project revision $revision is newer than this checkout" else result WARN standard_revision "revision $revision needs update to 2" fi else result WARN standard_revision "STANDARD.toml is missing" fi ((FAILURES == 0)) || exit 2