Some checks failed
Security / Security check (push) Failing after 1s
The old 'F∆I Platform' product name and platform@flemming.ai contact survived in docs/NOTICE/descriptions/help text; the product is Ch∆In and the contact is chain@flemming.ai. Generic 'cross-platform/platform-native' left untouched. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
292 lines
10 KiB
Bash
Executable file
292 lines
10 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Ch∆In — staged-content security check.
|
|
# Runs from .githooks/pre-commit, .githooks/commit-msg, and Forgejo CI.
|
|
#
|
|
# Modes:
|
|
# content — scan staged files (filename + diff) for secrets,
|
|
# confidential terms, banned phrases, forbidden files
|
|
# message FILE — scan a commit-message file for DCO sign-off,
|
|
# Conventional Commit subject, no Claude trailer
|
|
# ci [BASE] — scan all changes between BASE..HEAD
|
|
# (default BASE: origin/main)
|
|
#
|
|
# Exits non-zero on any violation; prints all violations before exiting
|
|
# so the operator sees the full picture in one pass.
|
|
|
|
set -uo pipefail
|
|
cd "$(git rev-parse --show-toplevel)"
|
|
|
|
MODE="${1:-content}"
|
|
FAILED=0
|
|
|
|
if [ -t 1 ]; then
|
|
RED=$'\033[31m'; YEL=$'\033[33m'; GRN=$'\033[32m'; RST=$'\033[0m'
|
|
else
|
|
RED=''; YEL=''; GRN=''; RST=''
|
|
fi
|
|
|
|
fail() { printf "%s✗ %s%s\n" "$RED" "$*" "$RST" >&2; FAILED=1; }
|
|
pass() { printf "%s✓ %s%s\n" "$GRN" "$*" "$RST"; }
|
|
|
|
# ─── Patterns ────────────────────────────────────────────────────────────
|
|
# Secrets — case-sensitive. Patterns are deliberately narrow to avoid
|
|
# false-positives on plain SHA-1s (40 hex), commit refs, etc. Each entry
|
|
# requires either a known prefix or a context keyword.
|
|
SECRET_PATTERNS=(
|
|
'ghp_[A-Za-z0-9]{36}' # GitHub PAT
|
|
'gho_[A-Za-z0-9]{36}' # GitHub OAuth
|
|
'ghs_[A-Za-z0-9]{36}' # GitHub server-to-server
|
|
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
|
'sk-[A-Za-z0-9]{48,}' # OpenAI API key
|
|
'AKIA[0-9A-Z]{16}' # AWS access key id
|
|
'(token|Bearer)[ =]"?[a-f0-9]{40}\b' # Forgejo / Gitea PAT in
|
|
# header / URL / env
|
|
'FORGEJO_TOKEN[= ]"?[a-f0-9]{40}' # Forgejo PAT in env-var
|
|
'-----BEGIN [A-Z ]*PRIVATE KEY-----' # Private keys (RSA, EC, …)
|
|
)
|
|
|
|
# Filenames that should never be committed.
|
|
FORBIDDEN_FILE_PATTERNS=(
|
|
'(^|/)\.env$'
|
|
'(^|/)\.env\.local$'
|
|
'(^|/)credentials\.(json|yaml|yml|toml)$'
|
|
'\.(pem|p12|pfx|key)$'
|
|
'(^|/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$'
|
|
)
|
|
|
|
# Filenames matching FORBIDDEN_FILE_PATTERNS but explicitly allowed.
|
|
FILE_ALLOWLIST=(
|
|
'\.env\.example$'
|
|
'\.env\.template$'
|
|
'\.env\.sample$'
|
|
'tests?/.*\.(pem|key)$' # Test fixtures
|
|
'fixtures?/.*\.(pem|key)$'
|
|
)
|
|
|
|
# Confidential terms are loaded at runtime from a file outside
|
|
# the repo (~/.fai-security/banned-terms.txt by default; override
|
|
# with $FAI_BANNED_TERMS_FILE). The list itself is private — the
|
|
# whole point of the gate is to keep certain strings out of any
|
|
# committed artefact, so the list of strings to gate on must not
|
|
# live in a committed artefact either.
|
|
#
|
|
# File format: one regex per line. Lines starting with `#` and
|
|
# blank lines are ignored. Each line is matched as a
|
|
# case-insensitive grep -E pattern with `-i`; use `\b…\b` for
|
|
# word boundaries.
|
|
#
|
|
# Bootstrap on a fresh machine:
|
|
# mkdir -p ~/.fai-security
|
|
# chmod 700 ~/.fai-security
|
|
# printf '\\b%s\\b\\n' TERM_A TERM_B > ~/.fai-security/banned-terms.txt
|
|
# chmod 600 ~/.fai-security/banned-terms.txt
|
|
#
|
|
# When the file is missing the gate logs a warning but does NOT
|
|
# fail — running on a fresh checkout where the operator hasn't
|
|
# yet bootstrapped the list shouldn't block them. Real CI runs
|
|
# explicitly verify the file is present and refuse otherwise
|
|
# (see scan_ci_mode below).
|
|
CONFIDENTIAL_TERMS=()
|
|
_load_confidential_terms() {
|
|
local file="${FAI_BANNED_TERMS_FILE:-$HOME/.fai-security/banned-terms.txt}"
|
|
if [ ! -f "$file" ]; then
|
|
return 1
|
|
fi
|
|
while IFS= read -r line; do
|
|
# Strip trailing comments + leading/trailing whitespace.
|
|
line="${line%%#*}"
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
[ -z "$line" ] && continue
|
|
CONFIDENTIAL_TERMS+=("$line")
|
|
done < "$file"
|
|
return 0
|
|
}
|
|
if ! _load_confidential_terms; then
|
|
printf "%s⚠ confidentiality list not found at ${FAI_BANNED_TERMS_FILE:-~/.fai-security/banned-terms.txt}; gate will skip the confidential-terms scan%s\n" \
|
|
"${YEL:-}" "${RST:-}" >&2
|
|
fi
|
|
|
|
# Memory: feedback_no_marketing_speak.md.
|
|
BANNED_PHRASES=(
|
|
'\bviral\b'
|
|
'\bkiller (feature|app)\b'
|
|
'\bjust works\b'
|
|
'\bpowerful\b'
|
|
'\bgame[- ]changer\b'
|
|
'\brevolutionary\b'
|
|
'\bworld[- ]class\b'
|
|
'\bblazingly fast\b'
|
|
'\blightning[- ]fast\b'
|
|
)
|
|
|
|
# ─── Helpers ─────────────────────────────────────────────────────────────
|
|
scan_secrets() {
|
|
# Case-sensitive — secret prefixes have known casing.
|
|
local input="$1"
|
|
for pat in "${SECRET_PATTERNS[@]}"; do
|
|
local hits
|
|
hits=$(printf "%s" "$input" | grep -E -- "$pat" 2>/dev/null || true)
|
|
if [ -n "$hits" ]; then
|
|
fail "secret pattern matched: $pat"
|
|
printf "%s\n" "$hits" | head -3 | sed 's/^/ /' >&2
|
|
fi
|
|
done
|
|
}
|
|
|
|
scan_terms_ci() {
|
|
# Case-insensitive — banned terms / confidentiality.
|
|
local label="$1"; local input="$2"; shift 2
|
|
for pat in "$@"; do
|
|
local hits
|
|
hits=$(printf "%s" "$input" | grep -i -E -- "$pat" 2>/dev/null || true)
|
|
if [ -n "$hits" ]; then
|
|
fail "$label: $pat"
|
|
printf "%s\n" "$hits" | head -3 | sed 's/^/ /' >&2
|
|
fi
|
|
done
|
|
}
|
|
|
|
is_allowlisted_file() {
|
|
local f="$1"
|
|
for ap in "${FILE_ALLOWLIST[@]}"; do
|
|
if printf "%s" "$f" | grep -E -- "$ap" >/dev/null; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
scan_filenames() {
|
|
local files="$1"
|
|
[ -z "$files" ] && return
|
|
while IFS= read -r f; do
|
|
[ -z "$f" ] && continue
|
|
is_allowlisted_file "$f" && continue
|
|
for fp in "${FORBIDDEN_FILE_PATTERNS[@]}"; do
|
|
if printf "%s" "$f" | grep -E -- "$fp" >/dev/null; then
|
|
fail "forbidden file in stage: $f (matches $fp)"
|
|
fi
|
|
done
|
|
done <<< "$files"
|
|
}
|
|
|
|
scan_diff() {
|
|
local diff="$1"
|
|
[ -z "$diff" ] && return
|
|
scan_secrets "$diff"
|
|
# `${ARR[@]+"${ARR[@]}"}` is the safe-under-set-u expansion
|
|
# for arrays that may be empty (CONFIDENTIAL_TERMS is empty
|
|
# when the operator hasn't bootstrapped ~/.fai-security/).
|
|
scan_terms_ci "confidential term" "$diff" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
|
scan_terms_ci "banned phrase" "$diff" "${BANNED_PHRASES[@]}"
|
|
}
|
|
|
|
# ─── Modes ───────────────────────────────────────────────────────────────
|
|
# The security script itself stores the patterns as regex strings, so its
|
|
# own diff would self-flag on every commit. Exclude the security tooling
|
|
# directories from the content scan. Repos with additional meta-files that
|
|
# legitimately mention banned terms (a banned-words gate in code, a policy
|
|
# doc, an LLM prompt template that filters such terms) extend this list
|
|
# via a `.security-allow` file at the repo root: one pathspec per line,
|
|
# blank lines and `#` comments ignored.
|
|
EXCLUDES=(":(exclude)tools/security/" ":(exclude).githooks/")
|
|
|
|
if [ -f ".security-allow" ]; then
|
|
while IFS= read -r line; do
|
|
line="${line%%#*}"
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
[ -z "$line" ] && continue
|
|
EXCLUDES+=(":(exclude)$line")
|
|
done < ".security-allow"
|
|
fi
|
|
|
|
scan_content_mode() {
|
|
local files diff
|
|
files=$(git diff --cached --name-only --diff-filter=ACMR)
|
|
diff=$(git diff --cached --no-color -U0 -- . "${EXCLUDES[@]}" \
|
|
| grep -E '^\+[^+]' || true)
|
|
scan_filenames "$files"
|
|
scan_diff "$diff"
|
|
}
|
|
|
|
scan_message_mode() {
|
|
local msgfile="${1:-}"
|
|
if [ -z "$msgfile" ] || [ ! -f "$msgfile" ]; then
|
|
fail "commit-message file not found: $msgfile"
|
|
return
|
|
fi
|
|
|
|
# Strip git's commentary lines.
|
|
local msg subject
|
|
msg=$(grep -v '^#' "$msgfile")
|
|
subject=$(printf "%s" "$msg" | head -1)
|
|
|
|
# Skip merge / fixup / squash / revert — git generates these and the
|
|
# subject can't always be reshaped to Conventional Commits.
|
|
case "$subject" in
|
|
"Merge "*|"fixup! "*|"squash! "*|"Revert "*) return ;;
|
|
esac
|
|
|
|
if ! printf "%s" "$subject" \
|
|
| grep -E '^(feat|fix|docs|refactor|test|chore|perf|build|ci|style)(\([a-z0-9_,-]+\))?!?: .+' \
|
|
>/dev/null; then
|
|
fail "subject is not Conventional Commits: $subject"
|
|
fail " expected: type(scope): message (type: feat|fix|docs|refactor|test|chore|perf|build|ci|style)"
|
|
fi
|
|
|
|
if ! printf "%s" "$msg" | grep -E '^Signed-off-by: .+ <.+@.+>$' >/dev/null; then
|
|
fail "missing DCO sign-off line (use git commit -s)"
|
|
fi
|
|
|
|
if printf "%s" "$msg" | grep -i 'co-authored-by:.*claude' >/dev/null; then
|
|
fail "Co-Authored-By: Claude trailer is forbidden (memory: feedback_coding_style.md)"
|
|
fi
|
|
|
|
scan_terms_ci "confidential term in commit message" "$msg" ${CONFIDENTIAL_TERMS[@]+"${CONFIDENTIAL_TERMS[@]}"}
|
|
scan_terms_ci "banned phrase in commit message" "$msg" "${BANNED_PHRASES[@]}"
|
|
}
|
|
|
|
scan_ci_mode() {
|
|
local base="${1:-origin/main}"
|
|
if ! git rev-parse --quiet --verify "$base" >/dev/null 2>&1; then
|
|
base=$(git rev-list --max-parents=0 HEAD | tail -1)
|
|
fi
|
|
# In CI the confidentiality list is non-optional: a missing
|
|
# file means an unbootstrapped runner is reviewing changes,
|
|
# and we'd be silently letting confidential terms through.
|
|
# The deploy step that wires up the runner is responsible for
|
|
# placing the file at $FAI_BANNED_TERMS_FILE.
|
|
if [ "${#CONFIDENTIAL_TERMS[@]}" -eq 0 ]; then
|
|
fail "CI mode requires \$FAI_BANNED_TERMS_FILE (or ~/.fai-security/banned-terms.txt) to exist"
|
|
fail " bootstrap the runner with a private confidentiality list before re-running"
|
|
return
|
|
fi
|
|
local files diff
|
|
files=$(git diff --name-only --diff-filter=ACMR "$base"..HEAD)
|
|
diff=$(git diff --no-color -U0 "$base"..HEAD -- . "${EXCLUDES[@]}" \
|
|
| grep -E '^\+[^+]' || true)
|
|
scan_filenames "$files"
|
|
scan_diff "$diff"
|
|
}
|
|
|
|
case "$MODE" in
|
|
content) scan_content_mode ;;
|
|
message) scan_message_mode "${2:-}" ;;
|
|
ci) scan_ci_mode "${2:-}" ;;
|
|
*)
|
|
echo "usage: $0 {content | message FILE | ci [BASE_REF]}" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
if [ "$FAILED" -ne 0 ]; then
|
|
printf "\n%sSecurity check failed.%s\n" "$RED" "$RST" >&2
|
|
printf "Edit the staged files / commit message and re-stage.\n" >&2
|
|
printf "%sIf a match is a verified false positive, --no-verify bypasses the hook.\n" "$YEL" >&2
|
|
printf "Never bypass for real secrets — rotate them first.%s\n" "$RST" >&2
|
|
exit 1
|
|
fi
|
|
|
|
pass "Security check passed."
|