chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Security check for potential unencrypted file writes to disk
|
||||
# This script helps prevent accidentally bypassing encryption at rest
|
||||
|
||||
set -e
|
||||
|
||||
echo "Checking for potential unencrypted file writes to disk..."
|
||||
echo "========================================="
|
||||
|
||||
# Patterns that might indicate writing sensitive data to disk
|
||||
# Note: Using basic grep patterns without lookaheads
|
||||
SUSPICIOUS_PATTERNS=(
|
||||
# Python patterns
|
||||
"\.write\("
|
||||
"\.save\("
|
||||
"\.dump\("
|
||||
"open\(.*['\"]w['\"].*\)"
|
||||
"open\(.*['\"]wb['\"].*\)"
|
||||
"with.*open\(.*['\"]w['\"]"
|
||||
"with.*open\(.*['\"]wb['\"]"
|
||||
"\.to_csv\("
|
||||
"\.to_json\("
|
||||
"\.to_excel\("
|
||||
"\.to_pickle\("
|
||||
"tempfile\.NamedTemporaryFile.*delete=False"
|
||||
"Path.*\.write_text\("
|
||||
"Path.*\.write_bytes\("
|
||||
"shutil\.copy"
|
||||
"shutil\.move"
|
||||
"\.export_to_file\("
|
||||
"\.save_to_file\("
|
||||
"\.write_pdf\("
|
||||
"\.savefig\("
|
||||
# JavaScript patterns
|
||||
"fs\.writeFile"
|
||||
"fs\.writeFileSync"
|
||||
"fs\.createWriteStream"
|
||||
"fs\.appendFile"
|
||||
)
|
||||
|
||||
# Directories to exclude from checks
|
||||
EXCLUDE_DIRS=(
|
||||
"tests"
|
||||
"test"
|
||||
"__pycache__"
|
||||
".git"
|
||||
"node_modules"
|
||||
".venv"
|
||||
"venv"
|
||||
"migrations"
|
||||
"static"
|
||||
"vendor"
|
||||
"dist"
|
||||
"build"
|
||||
".next"
|
||||
"coverage"
|
||||
"examples"
|
||||
"scripts"
|
||||
".github"
|
||||
"cookiecutter-docker"
|
||||
)
|
||||
|
||||
# Files to exclude
|
||||
EXCLUDE_FILES=(
|
||||
"*_test.py"
|
||||
"test_*.py"
|
||||
"*.test.js"
|
||||
"*.spec.js"
|
||||
"*.test.ts"
|
||||
"*.spec.ts"
|
||||
"setup.py"
|
||||
"webpack.config.js"
|
||||
"**/migrations/*.py"
|
||||
"*.min.js"
|
||||
"*.bundle.js"
|
||||
"*-min.js"
|
||||
"*.min.css"
|
||||
)
|
||||
|
||||
# Safe keywords that indicate encrypted or safe operations
|
||||
# These patterns indicate that file writes have been security-verified
|
||||
SAFE_KEYWORDS=(
|
||||
"write_file_verified"
|
||||
"write_json_verified"
|
||||
)
|
||||
|
||||
# Known safe usage patterns (logs, configs, etc.)
|
||||
SAFE_USAGE_PATTERNS=(
|
||||
"security/file_write_verifier.py"
|
||||
"import tempfile"
|
||||
"tempfile\.mkdtemp"
|
||||
"tmp_path"
|
||||
"tmp_file"
|
||||
)
|
||||
|
||||
# Build exclude arguments for grep
|
||||
EXCLUDE_ARGS=""
|
||||
for dir in "${EXCLUDE_DIRS[@]}"; do
|
||||
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude-dir=$dir"
|
||||
done
|
||||
|
||||
for file in "${EXCLUDE_FILES[@]}"; do
|
||||
EXCLUDE_ARGS="$EXCLUDE_ARGS --exclude=$file"
|
||||
done
|
||||
|
||||
# Track if we found any issues
|
||||
FOUND_ISSUES=0
|
||||
ALL_MATCHES=""
|
||||
|
||||
echo "Scanning codebase for suspicious patterns..."
|
||||
|
||||
# Search only in src/ directory to avoid .venv and other non-source directories
|
||||
SEARCH_PATHS="src/"
|
||||
|
||||
# Single pass to collect all matches
|
||||
for pattern in "${SUSPICIOUS_PATTERNS[@]}"; do
|
||||
# Use grep with binary files excluded and max line length to avoid issues with minified files
|
||||
# shellcheck disable=SC2086 # Word splitting is intentional for EXCLUDE_ARGS
|
||||
matches=$(grep -rn -I $EXCLUDE_ARGS -- "$pattern" $SEARCH_PATHS --include="*.py" --include="*.js" --include="*.ts" 2>/dev/null | head -1000 || true)
|
||||
if [ -n "$matches" ]; then
|
||||
ALL_MATCHES="$ALL_MATCHES$matches\n"
|
||||
fi
|
||||
done
|
||||
|
||||
# Also check for specific problematic patterns in one pass
|
||||
# shellcheck disable=SC2086 # Word splitting is intentional for EXCLUDE_ARGS
|
||||
temp_matches=$(grep -rn -I $EXCLUDE_ARGS -E "tmp_path|tempfile|/tmp/" $SEARCH_PATHS --include="*.py" 2>/dev/null | head -500 || true)
|
||||
if [ -n "$temp_matches" ]; then
|
||||
ALL_MATCHES="$ALL_MATCHES$temp_matches\n"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086 # Word splitting is intentional for EXCLUDE_ARGS
|
||||
db_matches=$(grep -rn -I $EXCLUDE_ARGS -E "report_content.*open|report_content.*write|markdown_content.*open|markdown_content.*write" $SEARCH_PATHS --include="*.py" 2>/dev/null | head -500 || true)
|
||||
if [ -n "$db_matches" ]; then
|
||||
ALL_MATCHES="$ALL_MATCHES$db_matches\n"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086 # Word splitting is intentional for EXCLUDE_ARGS
|
||||
export_matches=$(grep -rn -I $EXCLUDE_ARGS -E "export.*Path|export.*path\.open|export.*\.write" $SEARCH_PATHS --include="*.py" 2>/dev/null | head -500 || true)
|
||||
if [ -n "$export_matches" ]; then
|
||||
ALL_MATCHES="$ALL_MATCHES$export_matches\n"
|
||||
fi
|
||||
|
||||
# Now filter all matches at once
|
||||
if [ -n "$ALL_MATCHES" ]; then
|
||||
echo "Filtering results for false positives..."
|
||||
|
||||
# Remove duplicates and sort (use tr to handle potential null bytes)
|
||||
ALL_MATCHES=$(echo -e "$ALL_MATCHES" | tr -d '\0' | sort -u)
|
||||
|
||||
filtered_matches=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
|
||||
# Check if line contains safe keywords
|
||||
skip_line=0
|
||||
for safe_pattern in "${SAFE_KEYWORDS[@]}"; do
|
||||
if echo "$line" | grep -qE -- "$safe_pattern"; then
|
||||
skip_line=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if line contains safe usage patterns
|
||||
if [ "$skip_line" -eq 0 ]; then
|
||||
for usage_pattern in "${SAFE_USAGE_PATTERNS[@]}"; do
|
||||
if echo "$line" | grep -qE -- "$usage_pattern"; then
|
||||
skip_line=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Additional filters for test/mock files that might not be caught by path exclusion
|
||||
if [ "$skip_line" -eq 0 ]; then
|
||||
if echo "$line" | grep -qE "test|mock|stub" && ! echo "$line" | grep -q "#"; then
|
||||
skip_line=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Allowlist of files that legitimately write to disk without encryption.
|
||||
# These must NOT touch user data or secrets — only:
|
||||
# - web/app_factory.py — Flask/framework config writes
|
||||
# - document_loaders/bytes_loader.py — in-memory → tmp for parsers
|
||||
# - journal_quality/downloader.py — public OpenAlex/DOAJ/predatory/
|
||||
# JabRef/ROR snapshots downloaded to the user data dir (bibliographic
|
||||
# metadata only — journal names, ISSNs, h-indices; no PII/secrets)
|
||||
# - journal_quality/data_sources/*.py — same family, per-source adapters
|
||||
# that write the intermediate JSON manifests under the user data dir
|
||||
# If you add an entry here, document WHY the file's writes are safe
|
||||
# (public data, not user-specific, not encrypted at rest by design).
|
||||
if [ "$skip_line" -eq 0 ]; then
|
||||
if echo "$line" | grep -qE "web/app_factory\.py|document_loaders/bytes_loader\.py|journal_quality/downloader\.py|journal_quality/data_sources/.+\.py"; then
|
||||
skip_line=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Filter safe temp files with proper cleanup
|
||||
if [ "$skip_line" -eq 0 ]; then
|
||||
if echo "$line" | grep -q "database/encrypted_db.py"; then
|
||||
skip_line=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$skip_line" -eq 0 ]; then
|
||||
filtered_matches="$filtered_matches$line\n"
|
||||
FOUND_ISSUES=1
|
||||
fi
|
||||
done <<< "$ALL_MATCHES"
|
||||
|
||||
if [ -n "$filtered_matches" ] && [ "$FOUND_ISSUES" -eq 1 ]; then
|
||||
echo "⚠️ Found potential unencrypted file writes:"
|
||||
echo "========================================="
|
||||
echo -e "$filtered_matches"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "========================================="
|
||||
|
||||
if [ $FOUND_ISSUES -eq 1 ]; then
|
||||
echo "❌ Security check failed: Found potential unencrypted file writes"
|
||||
echo ""
|
||||
echo "Please review the above findings and ensure:"
|
||||
echo "1. Sensitive data is not written to disk unencrypted"
|
||||
echo "2. Temporary files are properly cleaned up"
|
||||
echo "3. Use in-memory operations where possible"
|
||||
echo "4. If file writes are necessary, ensure they're encrypted or add '# Safe: <reason>' comment"
|
||||
echo ""
|
||||
echo "For exports, use the in-memory pattern like in export_report_to_memory()"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Security check passed: No suspicious unencrypted file writes detected"
|
||||
fi
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Combine multiple per-model AI code reviews into a single sticky PR comment.
|
||||
#
|
||||
# This is the pure-assembly half of the AI Code Reviewer workflow
|
||||
# (.github/workflows/ai-code-reviewer.yml), split out so the non-trivial parts —
|
||||
# header/footer stripping, "Reviewer N" section assembly, label union, and the
|
||||
# pass/fail aggregation — can be unit-tested without any network or GitHub API
|
||||
# access. The workflow runs the models (network) and writes each reviewer's raw
|
||||
# stdout to resp_<i>.json and its exit code to code_<i>; this script turns those
|
||||
# into the comment body, label set, decision, and success count.
|
||||
#
|
||||
# Usage:
|
||||
# combine-ai-reviews.sh <work_dir> <model_1> [<model_2> ...]
|
||||
#
|
||||
# Reads, for i in 0..N-1 (N = number of model args):
|
||||
# <work_dir>/resp_<i>.json - raw stdout from ai-reviewer.sh for reviewer i
|
||||
# <work_dir>/code_<i> - that reviewer's integer exit code
|
||||
# <work_dir>/err_<i>.log - that reviewer's stderr (only shown in DEBUG)
|
||||
# The model names are used only for the per-reviewer log-group labels; the
|
||||
# posted comment stays anonymized ("Reviewer 1", "Reviewer 2", ...).
|
||||
#
|
||||
# Environment:
|
||||
# HEAD_SHA - head commit sha for the "Last reviewed at commit" line (opt)
|
||||
# DEBUG_MODE - "true" to echo raw responses + stderr to this script's stderr
|
||||
#
|
||||
# Writes (into <work_dir>):
|
||||
# comment_body.md - the combined sticky-comment body
|
||||
# labels.txt - deduped labels, one per line (may be empty)
|
||||
# decision.txt - "pass" or "fail"
|
||||
# success_count.txt - number of reviewers that produced a usable review
|
||||
#
|
||||
# Logs ::group:: sections and debug output to stderr (kept off stdout so the
|
||||
# output files are the only contract). Exit status is 0 even when every
|
||||
# reviewer failed — the caller inspects success_count.txt and decides whether
|
||||
# to fail the workflow. A non-zero exit means a usage error only.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORK_DIR="${1:-}"
|
||||
if [ -z "$WORK_DIR" ] || [ ! -d "$WORK_DIR" ]; then
|
||||
echo "usage: $0 <work_dir> <model> [<model> ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
MODELS=("$@")
|
||||
if [ "${#MODELS[@]}" -eq 0 ]; then
|
||||
echo "error: at least one model name is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DEBUG_MODE="${DEBUG_MODE:-false}"
|
||||
HEAD_SHA="${HEAD_SHA:-}"
|
||||
|
||||
COMBINED_REVIEW=""
|
||||
ALL_LABELS=""
|
||||
ANY_FAIL="false"
|
||||
SUCCESS_COUNT=0
|
||||
|
||||
# Assemble the combined comment in model order. Reviewers are anonymized as
|
||||
# "Reviewer N" — the model -> number mapping appears only in the (debug) logs
|
||||
# below, so readers judge the feedback, not the model.
|
||||
for i in "${!MODELS[@]}"; do
|
||||
REVIEWER_NUM=$((i + 1))
|
||||
EXIT_CODE="$(cat "$WORK_DIR/code_$i" 2>/dev/null || echo 1)"
|
||||
# A missing/empty/garbage exit-code file counts as a failed reviewer rather
|
||||
# than tripping the numeric comparison below (which would print "integer
|
||||
# expression expected" and then fall through to wrongly treating the reviewer
|
||||
# as a success).
|
||||
[[ "$EXIT_CODE" =~ ^[0-9]+$ ]] || EXIT_CODE=1
|
||||
AI_RESPONSE="$(cat "$WORK_DIR/resp_$i.json" 2>/dev/null || echo "")"
|
||||
echo "::group::Reviewer #$REVIEWER_NUM (${MODELS[i]})" >&2
|
||||
|
||||
if [ "$DEBUG_MODE" = "true" ]; then
|
||||
{
|
||||
echo "=== RAW AI RESPONSE (reviewer #$REVIEWER_NUM, exit $EXIT_CODE) ==="
|
||||
echo "$AI_RESPONSE"
|
||||
echo "--- stderr ---"
|
||||
cat "$WORK_DIR/err_$i.log" 2>/dev/null || true
|
||||
echo "=== END RAW AI RESPONSE ==="
|
||||
} >&2
|
||||
fi
|
||||
|
||||
# One model failing (hard error, empty, or non-JSON) must not sink the others:
|
||||
# record a short failure note and keep going. The caller fails the workflow
|
||||
# only if *every* reviewer failed (success_count == 0). A reviewer that errors
|
||||
# out never sets a verdict, so it can never flip the aggregate decision to
|
||||
# "fail" — only a model that returns a valid "fail" verdict does. Sensitive raw
|
||||
# output is never written to the comment.
|
||||
# Require a JSON *object*: a bare `jq .` accepts any valid JSON, including a
|
||||
# top-level string/array/number that a refusing or misbehaving model might
|
||||
# emit ("I refuse to review this"). That would pass the gate and then crash
|
||||
# the `.review` access below under `set -e`, sinking *every* reviewer and
|
||||
# writing no output at all. `type == "object"` rejects non-objects so they
|
||||
# degrade to a per-reviewer failure note, preserving the others.
|
||||
if [ "$EXIT_CODE" -ne 0 ] || [ -z "$AI_RESPONSE" ] || ! echo "$AI_RESPONSE" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
||||
echo "⚠️ Reviewer #$REVIEWER_NUM did not return a usable review (exit $EXIT_CODE)" >&2
|
||||
REVIEW_BODY="_This reviewer could not complete its review (see workflow logs for details)._"
|
||||
LABELS=""
|
||||
else
|
||||
REVIEW_RAW=$(echo "$AI_RESPONSE" | jq -r '.review // "No review provided"')
|
||||
# Trim whitespace with tr (the verdict is a single token). The previous
|
||||
# `| xargs` aborted the entire script on an unbalanced quote in the value,
|
||||
# which—inside this per-reviewer loop—would sink every reviewer.
|
||||
DECISION=$(echo "$AI_RESPONSE" | jq -r '.fail_pass_workflow // "uncertain"' | tr -d '[:space:]')
|
||||
LABELS=$(echo "$AI_RESPONSE" | jq -r '.labels_added[]? // empty')
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
[ "$DECISION" = "fail" ] && ANY_FAIL="true"
|
||||
|
||||
# Each review opens with a "## AI Code Review" H2 and closes with the
|
||||
# singular Friendly AI Reviewer footer. Under a "### 👤 Reviewer N"
|
||||
# subheading the H2 is redundant and the footer would repeat once per model,
|
||||
# so strip both here; a single (plural) footer is added to the combined
|
||||
# comment below. The footer match keys on the stable "Review by [Friendly AI
|
||||
# Reviewer]" prefix and the \s*\z tail absorbs trailing whitespace; if the
|
||||
# upstream markers ever change and aren't found, the text is left intact
|
||||
# (nothing is dropped).
|
||||
REVIEW_BODY=$(printf '%s' "$REVIEW_RAW" \
|
||||
| perl -0pe 's/\A\s*##\s*AI Code Review\s*\n+//' \
|
||||
| perl -0pe 's/\n*---\s*\n\*Review by \[Friendly AI Reviewer\][^\n]*\n?\s*\z//s')
|
||||
fi
|
||||
|
||||
# Lead each section with a blank line so the "### Reviewer N" heading always
|
||||
# renders (command substitution strips trailing newlines, so the separator
|
||||
# must precede the heading, not follow the previous body).
|
||||
COMBINED_REVIEW=$(printf '%s\n\n### 👤 Reviewer %s\n\n%s' "$COMBINED_REVIEW" "$REVIEWER_NUM" "$REVIEW_BODY")
|
||||
[ -n "$LABELS" ] && ALL_LABELS=$(printf '%s\n%s' "$ALL_LABELS" "$LABELS")
|
||||
echo "::endgroup::" >&2
|
||||
done
|
||||
|
||||
# Aggregate labels (union across reviewers) and the pass/fail decision (request
|
||||
# changes if ANY reviewer did).
|
||||
LABELS=$(printf '%s\n' "$ALL_LABELS" | sed '/^[[:space:]]*$/d' | sort -u)
|
||||
if [ "$ANY_FAIL" = "true" ]; then
|
||||
DECISION="fail"
|
||||
else
|
||||
DECISION="pass"
|
||||
fi
|
||||
|
||||
REVIEWER_WORD="reviewers"
|
||||
[ "${#MODELS[@]}" -eq 1 ] && REVIEWER_WORD="reviewer"
|
||||
STICKY_MARKER="<!-- ai-code-review:sticky -->"
|
||||
FOOTER="*Reviews by [Friendly AI Reviewer](https://github.com/LearningCircuit/Friendly-AI-Reviewer) - made with ❤️*"
|
||||
# COMBINED_REVIEW already starts with "\n\n", so it follows the title directly
|
||||
# (no extra newline) to yield exactly one blank line before "### Reviewer 1".
|
||||
# The backticks in `%s` are intentional literal Markdown (inline-code the SHA),
|
||||
# not a command substitution — single quotes keep them literal on purpose.
|
||||
# shellcheck disable=SC2016
|
||||
COMMENT_BODY=$(printf '%s\n\n## 🤖 AI Code Review (%s %s)%s\n\n---\n%s\n\n_Last reviewed at commit `%s`_' \
|
||||
"$STICKY_MARKER" "${#MODELS[@]}" "$REVIEWER_WORD" "$COMBINED_REVIEW" "$FOOTER" "${HEAD_SHA:0:7}")
|
||||
|
||||
printf '%s' "$COMMENT_BODY" > "$WORK_DIR/comment_body.md"
|
||||
printf '%s' "$LABELS" > "$WORK_DIR/labels.txt"
|
||||
printf '%s' "$DECISION" > "$WORK_DIR/decision.txt"
|
||||
printf '%s' "$SUCCESS_COUNT" > "$WORK_DIR/success_count.txt"
|
||||
Executable
+594
@@ -0,0 +1,594 @@
|
||||
#!/bin/bash
|
||||
|
||||
# File Whitelist Security Check Script
|
||||
# Enhanced security checks with comprehensive file type detection
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Load allowed file patterns from shared whitelist (single source of truth)
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
|
||||
WHITELIST_FILE="$REPO_ROOT/.file-whitelist.txt"
|
||||
|
||||
if [ ! -f "$WHITELIST_FILE" ]; then
|
||||
echo "❌ Missing .file-whitelist.txt — cannot run whitelist check."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ALLOWED_PATTERNS=()
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
ALLOWED_PATTERNS+=("$line")
|
||||
done < "$WHITELIST_FILE"
|
||||
|
||||
# Load per-check ignore lists (exact paths to skip for specific checks)
|
||||
IGNORE_ENV_FILES=()
|
||||
IGNORE_ENV_FILE="$REPO_ROOT/.github/security/ignore-env-files.txt"
|
||||
if [ -f "$IGNORE_ENV_FILE" ]; then
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
IGNORE_ENV_FILES+=("$line")
|
||||
done < "$IGNORE_ENV_FILE"
|
||||
fi
|
||||
|
||||
IGNORE_SUSPICIOUS_FILETYPES=()
|
||||
IGNORE_SUSPICIOUS_FILE="$REPO_ROOT/.github/security/ignore-suspicious-filetypes.txt"
|
||||
if [ -f "$IGNORE_SUSPICIOUS_FILE" ]; then
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
IGNORE_SUSPICIOUS_FILETYPES+=("$line")
|
||||
done < "$IGNORE_SUSPICIOUS_FILE"
|
||||
fi
|
||||
|
||||
# Get list of files to check
|
||||
if [ "${CHECK_ALL_FILES:-}" = "true" ]; then
|
||||
echo "🔍 Checking ALL tracked files (release gate mode)..."
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
TOTAL_FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l)
|
||||
echo "📋 Found $TOTAL_FILE_COUNT tracked files to check"
|
||||
elif [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
# For PRs: check all files that would be added/modified in the entire PR
|
||||
echo "🔍 Checking files in PR from $GITHUB_BASE_REF to HEAD..."
|
||||
|
||||
CHANGED_FILES=$(git diff --name-only --diff-filter=AM origin/"$GITHUB_BASE_REF"..HEAD)
|
||||
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l)
|
||||
echo "📋 Found $FILE_COUNT changed files with git diff"
|
||||
|
||||
# Also get newly added files across all commits in the PR
|
||||
# Use a more robust approach that handles edge cases
|
||||
ALL_NEW_FILES=$(git log --name-only --pretty=format: --diff-filter=A origin/"$GITHUB_BASE_REF"..HEAD 2>/dev/null | grep -v '^$' | sort | uniq || echo "")
|
||||
NEW_FILE_COUNT=$(echo "$ALL_NEW_FILES" | wc -w)
|
||||
echo "📋 Found $NEW_FILE_COUNT newly added files with git log"
|
||||
|
||||
# Combine both lists and remove duplicates - handle empty ALL_NEW_FILES
|
||||
if [ -n "$ALL_NEW_FILES" ]; then
|
||||
CHANGED_FILES=$(echo -e "$CHANGED_FILES\n$ALL_NEW_FILES" | sort | uniq | grep -v '^$')
|
||||
fi
|
||||
TOTAL_FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l)
|
||||
echo "📋 Total unique files to check: $TOTAL_FILE_COUNT"
|
||||
else
|
||||
# For direct pushes: check files in the current commit
|
||||
echo "🔍 Checking files in latest commit..."
|
||||
CHANGED_FILES=$(git diff --name-only --diff-filter=AM HEAD~1..HEAD)
|
||||
TOTAL_FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l)
|
||||
echo "📋 Found $TOTAL_FILE_COUNT files in direct push"
|
||||
fi
|
||||
|
||||
echo "🔍 Running comprehensive security checks..."
|
||||
echo ""
|
||||
|
||||
FILES_CHECKED=0
|
||||
WHITELIST_VIOLATIONS=()
|
||||
LARGE_FILES=()
|
||||
BINARY_FILES=()
|
||||
SUSPICIOUS_FILES=()
|
||||
RESEARCH_DATA_VIOLATIONS=()
|
||||
FLASK_SECRET_VIOLATIONS=()
|
||||
ENV_FILE_VIOLATIONS=()
|
||||
HIGH_ENTROPY_VIOLATIONS=()
|
||||
HARDCODED_PATH_VIOLATIONS=()
|
||||
HARDCODED_IP_VIOLATIONS=()
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS=()
|
||||
|
||||
# Use improved file processing that handles spaces and special characters
|
||||
while IFS= read -r file; do
|
||||
[ -z "$file" ] && continue
|
||||
|
||||
# Skip deleted files
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
FILES_CHECKED=$((FILES_CHECKED + 1))
|
||||
if [ $((FILES_CHECKED % 10)) -eq 0 ]; then
|
||||
printf "."
|
||||
fi
|
||||
|
||||
# 1. Whitelist check
|
||||
ALLOWED=false
|
||||
for pattern in "${ALLOWED_PATTERNS[@]}"; do
|
||||
if echo "$file" | grep -qE "$pattern"; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALLOWED" = "false" ]; then
|
||||
WHITELIST_VIOLATIONS+=("$file")
|
||||
fi
|
||||
|
||||
# 2. Large file check (>1MB)
|
||||
if [ -f "$file" ]; then
|
||||
FILE_SIZE=$(stat -c%s "$file" 2>/dev/null || echo 0)
|
||||
if [ "$FILE_SIZE" -gt 1048576 ]; then
|
||||
LARGE_FILES+=("$file ($(echo "$FILE_SIZE" | awk '{printf "%.1fMB", $1/1024/1024}'))")
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Binary file check
|
||||
if file "$file" | grep -q "binary"; then
|
||||
BINARY_FILES+=("$file")
|
||||
fi
|
||||
|
||||
# 4. Secret pattern check - REMOVED: gitleaks workflow handles this more accurately
|
||||
|
||||
# 5. Suspicious filename patterns - whitelist approach
|
||||
SAFE_FILENAME_PATTERNS=(
|
||||
".*token_counter.*\.py$"
|
||||
".*migrate.*token.*\.py$"
|
||||
".*enhanced.*token.*\.md$"
|
||||
"docs/.*token.*\.md$"
|
||||
"tests/.*\.py$"
|
||||
"docs/decisions/.*\.md$"
|
||||
".*session_passwords\.py$"
|
||||
".*change_password\.html$"
|
||||
"tests/ui_tests/.*password.*\.js$"
|
||||
".*password_validator\.py$"
|
||||
".*password_utils\.py$"
|
||||
)
|
||||
|
||||
# Check if filename looks suspicious
|
||||
if echo "$file" | grep -iE "(secret|password|token|\.key$|\.pem$|\.p12$|\.pfx$|\.env$)" >/dev/null; then
|
||||
# Check if filename matches whitelist patterns
|
||||
FILENAME_WHITELISTED=false
|
||||
for pattern in "${SAFE_FILENAME_PATTERNS[@]}"; do
|
||||
if echo "$file" | grep -qE "$pattern"; then
|
||||
FILENAME_WHITELISTED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FILENAME_WHITELISTED" = "false" ]; then
|
||||
SUSPICIOUS_FILES+=("$file")
|
||||
fi
|
||||
fi
|
||||
|
||||
# 6. LDR-specific security checks
|
||||
# Check for research data leakage
|
||||
if [ -f "$file" ] && [ -r "$file" ]; then
|
||||
# Check for hardcoded research queries in non-test files
|
||||
if ! echo "$file" | grep -qE "(test|mock|example)"; then
|
||||
if grep -E "(research_id|session_id|query_id).*=.*[\"'][0-9a-f]{8,}[\"']" "$file" >/dev/null 2>&1; then
|
||||
RESEARCH_DATA_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for Flask secret keys
|
||||
if grep -E "SECRET_KEY.*=.*[\"'][^\"']{16,}[\"']" "$file" >/dev/null 2>&1; then
|
||||
if ! grep -iE "(os\.environ|getenv|config\[|example|placeholder)" "$file" >/dev/null 2>&1; then
|
||||
FLASK_SECRET_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for environment files
|
||||
if echo "$file" | grep -E "\.(env|env\.[a-zA-Z]+)$" >/dev/null; then
|
||||
ENV_IGNORED=false
|
||||
for epath in "${IGNORE_ENV_FILES[@]+${IGNORE_ENV_FILES[@]}}"; do
|
||||
[ "$file" = "$epath" ] && ENV_IGNORED=true && break
|
||||
done
|
||||
if [ "$ENV_IGNORED" = "false" ]; then
|
||||
ENV_FILE_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for high-entropy strings (potential keys/secrets)
|
||||
if [ -f "$file" ] && [ -r "$file" ]; then
|
||||
# Skip HTML files and other safe file types for entropy checks
|
||||
if ! echo "$file" | grep -qE "\.(html|css|js|json|yml|yaml|md)$"; then
|
||||
# Skip news_strategy.py which contains example categories in prompts
|
||||
if ! echo "$file" | grep -qE "news_strategy\.py$"; then
|
||||
# Look for base64-like strings or hex strings that are suspiciously long
|
||||
if grep -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$file" >/dev/null 2>&1; then
|
||||
# Exclude common false positives
|
||||
if ! grep -iE "(sha256|md5|hash|test|example|fixture|integrity)" "$file" >/dev/null 2>&1; then
|
||||
HIGH_ENTROPY_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for hardcoded paths (Unix/Windows)
|
||||
if ! echo "$file" | grep -qE "(test|mock|example|\.md$|docker|Docker|\.yml$|\.yaml$|config/paths\.py$|security/path_validator\.py$)"; then
|
||||
# Look for absolute paths and user home directories
|
||||
if grep -E "(/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+|C:\\\\Users\\\\[a-zA-Z0-9_-]+|/opt/|/var/|/etc/|/usr/local/)" "$file" >/dev/null 2>&1; then
|
||||
# Exclude common false positives and Docker volume mounts
|
||||
if ! grep -iE "(example|sample|placeholder|TODO|FIXME|/usr/local/bin|/etc/hosts|documentation|/etc/searxng|volumes?:|docker)" "$file" >/dev/null 2>&1; then
|
||||
HARDCODED_PATH_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for hardcoded IP addresses
|
||||
if ! echo "$file" | grep -qE "(test|mock|example|\.md$)"; then
|
||||
# Look for IPv4 addresses (excluding common safe ones)
|
||||
if grep -E "\b([0-9]{1,3}\.){3}[0-9]{1,3}\b" "$file" >/dev/null 2>&1; then
|
||||
# Exclude localhost, documentation IPs, and common examples
|
||||
if ! grep -E "\b(127\.0\.0\.1|0\.0\.0\.0|localhost|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|255\.255\.255\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)" "$file" >/dev/null 2>&1; then
|
||||
# Additional check to exclude obvious non-IPs (version numbers, etc)
|
||||
if grep -E "\b([0-9]{1,3}\.){3}[0-9]{1,3}\b" "$file" | grep -vE "(version|v[0-9]+\.|release|tag)" >/dev/null 2>&1; then
|
||||
HARDCODED_IP_VIOLATIONS+=("$file")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 7. Suspicious file type check - detect potentially dangerous file types
|
||||
if [ -f "$file" ]; then
|
||||
# Check if file is in the suspicious-filetypes ignore list
|
||||
FILETYPE_IGNORED=false
|
||||
for fpath in "${IGNORE_SUSPICIOUS_FILETYPES[@]+${IGNORE_SUSPICIOUS_FILETYPES[@]}}"; do
|
||||
[ "$file" = "$fpath" ] && FILETYPE_IGNORED=true && break
|
||||
done
|
||||
|
||||
if [ "$FILETYPE_IGNORED" = "false" ]; then
|
||||
# Check for suspicious file extensions
|
||||
if echo "$file" | grep -iE "\.(exe|dll|so|dylib|bin|deb|rpm|msi|dmg|pkg|app)$" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (executable/binary)")
|
||||
elif echo "$file" | grep -iE "\.(zip|tar|gz|rar|7z|tar\.gz|tar\.bz2|tgz)$" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (compressed archive)")
|
||||
elif echo "$file" | grep -iE "\.(log|tmp|temp|cache|bak|backup|swp|swo|DS_Store|thumbs\.db|desktop\.ini|~|\.orig|\.rej|\.patch)$" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (temporary/cache)")
|
||||
elif echo "$file" | grep -iE "\.(png|jpg|jpeg|gif|bmp|tiff|svg|ico|webp)$" >/dev/null; then
|
||||
# Images are suspicious unless in specific directories
|
||||
if ! echo "$file" | grep -qE "(^docs/images/|^src/local_deep_research/web/static/favicon\.png$|^installers/.*\.ico$)"; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (image file)")
|
||||
fi
|
||||
elif echo "$file" | grep -iE "\.(mp3|mp4|wav|avi|mov|mkv|flv|wmv|webm|m4a|ogg)$" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (media file)")
|
||||
elif echo "$file" | grep -iE "\.(csv|xlsx|xls|doc|docx|pdf|ppt|pptx)$" >/dev/null; then
|
||||
# Documents are suspicious unless in docs directory
|
||||
if ! echo "$file" | grep -qE "docs/"; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (document file)")
|
||||
fi
|
||||
elif echo "$file" | grep -iE "\.(db|sqlite|sqlite3)$" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (database file)")
|
||||
elif echo "$file" | grep -iE "node_modules/|__pycache__/|\.pyc$|\.pyo$|\.egg-info/|dist/|build/|\.cache/" >/dev/null; then
|
||||
SUSPICIOUS_FILETYPE_VIOLATIONS+=("$file (build artifact/cache)")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
echo ""
|
||||
echo "✓ Checked $FILES_CHECKED files"
|
||||
echo ""
|
||||
|
||||
# Report all violations with detailed explanations
|
||||
echo "📊 Security scan completed. Analyzing results..."
|
||||
echo "📋 Summary of findings:"
|
||||
echo " - File type violations: ${#WHITELIST_VIOLATIONS[@]}"
|
||||
echo " - Large files: ${#LARGE_FILES[@]}"
|
||||
echo " - Binary files: ${#BINARY_FILES[@]}"
|
||||
echo " - Suspicious filenames: ${#SUSPICIOUS_FILES[@]}"
|
||||
echo " - Research data leaks: ${#RESEARCH_DATA_VIOLATIONS[@]}"
|
||||
echo " - Hardcoded Flask secrets: ${#FLASK_SECRET_VIOLATIONS[@]}"
|
||||
echo " - Environment files: ${#ENV_FILE_VIOLATIONS[@]}"
|
||||
echo " - High-entropy strings: ${#HIGH_ENTROPY_VIOLATIONS[@]}"
|
||||
echo " - Hardcoded paths: ${#HARDCODED_PATH_VIOLATIONS[@]}"
|
||||
echo " - Hardcoded IPs: ${#HARDCODED_IP_VIOLATIONS[@]}"
|
||||
echo " - Suspicious file types: ${#SUSPICIOUS_FILETYPE_VIOLATIONS[@]}"
|
||||
|
||||
TOTAL_VIOLATIONS=0
|
||||
|
||||
if [ ${#WHITELIST_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ WHITELIST VIOLATIONS - File types not allowed in repository:"
|
||||
echo " These files don't match any pattern in .file-whitelist.txt."
|
||||
echo " Binary files (images, audio, etc.) bloat the repo and should NOT be committed."
|
||||
echo " Only a small set of explicitly listed binary files is allowed — store others externally."
|
||||
echo " If this is a legitimate text/config file, add it to .file-whitelist.txt (requires maintainer approval)."
|
||||
echo ""
|
||||
for violation in "${WHITELIST_VIOLATIONS[@]}"; do
|
||||
echo " 🚫 $violation"
|
||||
|
||||
# Show file type and extension
|
||||
FILE_EXT="${violation##*.}"
|
||||
if [ -f "$violation" ]; then
|
||||
FILE_TYPE=$(file -b "$violation" 2>/dev/null || echo "unknown")
|
||||
echo " → File extension: .$FILE_EXT"
|
||||
echo " → File type: $FILE_TYPE"
|
||||
echo " → First few lines:"
|
||||
head -3 "$violation" 2>/dev/null | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
echo " → Issue: File extension/type not in .file-whitelist.txt"
|
||||
echo " → Fix: For text/config files, add pattern to .file-whitelist.txt"
|
||||
echo " → Note: Binary files should NOT be added to the repo — store them externally"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#WHITELIST_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#LARGE_FILES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ LARGE FILES (>1MB) - Files too big for repository:"
|
||||
echo " Large files should typically be stored externally or compressed."
|
||||
echo ""
|
||||
for violation in "${LARGE_FILES[@]}"; do
|
||||
echo " 📏 $violation"
|
||||
echo " → Issue: File size exceeds 1MB limit"
|
||||
echo " → Fix: Use Git LFS, external storage, or compress the file"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#LARGE_FILES[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#BINARY_FILES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ BINARY FILES DETECTED - Review these carefully:"
|
||||
echo " Binary files may contain sensitive data and can't be easily reviewed."
|
||||
echo ""
|
||||
for violation in "${BINARY_FILES[@]}"; do
|
||||
echo " 🔒 $violation"
|
||||
echo " → Issue: Binary file detected (contents not reviewable)"
|
||||
echo " → Action: Verify this file doesn't contain sensitive data"
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
|
||||
if [ ${#SUSPICIOUS_FILES[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ SUSPICIOUS FILENAMES - Files with security-sensitive names:"
|
||||
echo " These filenames contain words that often indicate sensitive files."
|
||||
echo ""
|
||||
for violation in "${SUSPICIOUS_FILES[@]}"; do
|
||||
echo " 🚨 $violation"
|
||||
|
||||
# Show which keyword triggered the detection
|
||||
if echo "$violation" | grep -qi "secret"; then
|
||||
echo " → Triggered by: 'secret' in filename"
|
||||
elif echo "$violation" | grep -qi "password"; then
|
||||
echo " → Triggered by: 'password' in filename"
|
||||
elif echo "$violation" | grep -qi "token"; then
|
||||
echo " → Triggered by: 'token' in filename"
|
||||
elif echo "$violation" | grep -qi "api"; then
|
||||
echo " → Triggered by: 'api' in filename"
|
||||
elif echo "$violation" | grep -qi "key"; then
|
||||
echo " → Triggered by: 'key' in filename"
|
||||
fi
|
||||
|
||||
# Show file content preview if it exists
|
||||
if [ -f "$violation" ]; then
|
||||
FILE_TYPE=$(file -b "$violation" 2>/dev/null || echo "unknown")
|
||||
FILE_SIZE=$(stat -c%s "$violation" 2>/dev/null || echo "unknown")
|
||||
echo " → File info: $FILE_TYPE (${FILE_SIZE} bytes)"
|
||||
echo " → Content preview:"
|
||||
head -3 "$violation" 2>/dev/null | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
|
||||
echo " → Issue: Filename contains suspicious keywords (secret/password/token/key)"
|
||||
echo " → Fix: Rename file or add to SAFE_FILENAME_PATTERNS whitelist"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#SUSPICIOUS_FILES[@]}))
|
||||
fi
|
||||
|
||||
# LDR-specific violation reports
|
||||
if [ ${#RESEARCH_DATA_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ RESEARCH DATA LEAKAGE - Hardcoded research session data found:"
|
||||
echo " Research IDs and session data should never be hardcoded in production code."
|
||||
echo ""
|
||||
for violation in "${RESEARCH_DATA_VIOLATIONS[@]}"; do
|
||||
echo " 📊 $violation"
|
||||
|
||||
# Show the specific lines with research data
|
||||
echo " → Found hardcoded research data:"
|
||||
grep -n -E "(research_id|session_id|query_id).*=.*[\"'][0-9a-f]{8,}[\"']" "$violation" 2>/dev/null | head -3 | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
|
||||
echo " → Issue: Hardcoded research/session IDs in non-test file"
|
||||
echo " → Fix: Use environment variables or configuration files"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#RESEARCH_DATA_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#FLASK_SECRET_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ FLASK SECRET KEY - Hardcoded Flask secret keys found:"
|
||||
echo " Flask secret keys must never be hardcoded for security reasons."
|
||||
echo ""
|
||||
for violation in "${FLASK_SECRET_VIOLATIONS[@]}"; do
|
||||
echo " 🔐 $violation"
|
||||
|
||||
# Show the specific lines with secret keys
|
||||
echo " → Found hardcoded Flask secret key:"
|
||||
grep -n -E "SECRET_KEY.*=.*[\"'][^\"']{16,}[\"']" "$violation" 2>/dev/null | head -3 | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
|
||||
echo " → Issue: Hardcoded Flask SECRET_KEY"
|
||||
echo " → Fix: Use os.environ or load from secure config file"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#FLASK_SECRET_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#ENV_FILE_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ ENVIRONMENT FILES - .env files detected:"
|
||||
echo " Environment files contain sensitive configuration and should never be committed."
|
||||
echo ""
|
||||
for violation in "${ENV_FILE_VIOLATIONS[@]}"; do
|
||||
echo " 🌍 $violation"
|
||||
echo " → Issue: Environment file in repository"
|
||||
echo " → Fix: Add to .gitignore and use .env.example instead"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#ENV_FILE_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#HIGH_ENTROPY_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ HIGH ENTROPY STRINGS - Potential secrets or keys detected:"
|
||||
echo " Long random strings may be API keys, tokens, or other secrets."
|
||||
echo ""
|
||||
for violation in "${HIGH_ENTROPY_VIOLATIONS[@]}"; do
|
||||
echo " 🎲 $violation"
|
||||
|
||||
# Show sample of high entropy strings
|
||||
echo " → Found high-entropy strings:"
|
||||
grep -n -E "[a-zA-Z0-9+/]{40,}={0,2}|[a-f0-9]{40,}" "$violation" 2>/dev/null | head -3 | while read -r line; do
|
||||
# Truncate long lines for readability
|
||||
echo " ${line:0:120}..."
|
||||
done
|
||||
|
||||
echo " → Issue: High-entropy strings that could be secrets"
|
||||
echo " → Fix: Review and move to environment variables if sensitive"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#HIGH_ENTROPY_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#HARDCODED_PATH_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ HARDCODED PATHS - System-specific paths detected:"
|
||||
echo " Absolute paths can expose system structure and break portability."
|
||||
echo ""
|
||||
for violation in "${HARDCODED_PATH_VIOLATIONS[@]}"; do
|
||||
echo " 📁 $violation"
|
||||
|
||||
# Show the specific hardcoded paths
|
||||
echo " → Found hardcoded paths:"
|
||||
grep -n -E "(/home/[a-zA-Z0-9_-]+|/Users/[a-zA-Z0-9_-]+|C:\\\\Users\\\\[a-zA-Z0-9_-]+|/opt/|/var/|/etc/|/usr/local/)" "$violation" 2>/dev/null | head -5 | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
|
||||
echo " → Issue: Hardcoded absolute paths reduce portability"
|
||||
echo " → Fix: Use relative paths, environment variables, or config files"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#HARDCODED_PATH_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#HARDCODED_IP_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ HARDCODED IP ADDRESSES - External IP addresses detected:"
|
||||
echo " Hardcoded IPs can expose infrastructure and cause connectivity issues."
|
||||
echo ""
|
||||
for violation in "${HARDCODED_IP_VIOLATIONS[@]}"; do
|
||||
echo " 🌐 $violation"
|
||||
|
||||
# Show the specific IP addresses
|
||||
echo " → Found hardcoded IP addresses:"
|
||||
grep -n -E "\b([0-9]{1,3}\.){3}[0-9]{1,3}\b" "$violation" 2>/dev/null | grep -v -E "(127\.0\.0\.1|0\.0\.0\.0|localhost|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|255\.255\.255\.|192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)" | head -5 | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
|
||||
echo " → Issue: Hardcoded IP addresses (non-private/localhost)"
|
||||
echo " → Fix: Use DNS names, environment variables, or config files"
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#HARDCODED_IP_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
if [ ${#SUSPICIOUS_FILETYPE_VIOLATIONS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "❌ SUSPICIOUS FILE TYPES - Potentially dangerous file types detected:"
|
||||
echo " These file types are commonly used for malware, data leaks, or bloat the repository."
|
||||
echo ""
|
||||
for violation in "${SUSPICIOUS_FILETYPE_VIOLATIONS[@]}"; do
|
||||
echo " 🚨 $violation"
|
||||
|
||||
FILE_PATH="${violation%% (*}"
|
||||
FILE_CATEGORY="${violation##*\\(}"
|
||||
FILE_CATEGORY="${FILE_CATEGORY%\\)}"
|
||||
|
||||
# Provide specific guidance based on file category
|
||||
case "$FILE_CATEGORY" in
|
||||
"executable/binary")
|
||||
echo " → Issue: Executable/binary files can contain malware"
|
||||
echo " → Fix: Remove executable files, use package managers instead"
|
||||
;;
|
||||
"compressed archive")
|
||||
echo " → Issue: Compressed archives hide their contents from review"
|
||||
echo " → Fix: Extract contents and commit individual files instead"
|
||||
;;
|
||||
"temporary/cache")
|
||||
echo " → Issue: Temporary/cache files should not be committed"
|
||||
echo " → Fix: Add to .gitignore and remove from repository"
|
||||
;;
|
||||
"image file")
|
||||
echo " → Issue: Binary image files bloat the repo and should NOT be committed"
|
||||
echo " → Fix: Store images externally. Only a few explicitly listed images in docs/images/ are allowed"
|
||||
;;
|
||||
"media file")
|
||||
echo " → Issue: Media files are large and rarely needed in code repos"
|
||||
echo " → Fix: Use external hosting or remove if unnecessary"
|
||||
;;
|
||||
"document file")
|
||||
echo " → Issue: Office documents should be in docs/ directory if needed"
|
||||
echo " → Fix: Move to docs/ directory or convert to markdown"
|
||||
;;
|
||||
"database file")
|
||||
echo " → Issue: Database files contain data that shouldn't be in source control"
|
||||
echo " → Fix: Add to .gitignore and use migrations/seeds instead"
|
||||
;;
|
||||
"build artifact/cache")
|
||||
echo " → Issue: Build artifacts and cache files bloat the repository"
|
||||
echo " → Fix: Add to .gitignore and remove from repository"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Show file info if available
|
||||
if [ -f "$FILE_PATH" ]; then
|
||||
FILE_SIZE=$(stat -c%s "$FILE_PATH" 2>/dev/null || echo "unknown")
|
||||
if [ "$FILE_SIZE" != "unknown" ]; then
|
||||
READABLE_SIZE=$(echo "$FILE_SIZE" | awk '{if($1>=1048576) printf "%.1fMB", $1/1048576; else if($1>=1024) printf "%.1fKB", $1/1024; else printf "%dB", $1}')
|
||||
echo " → File size: $READABLE_SIZE"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
TOTAL_VIOLATIONS=$((TOTAL_VIOLATIONS + ${#SUSPICIOUS_FILETYPE_VIOLATIONS[@]}))
|
||||
fi
|
||||
|
||||
# Final result
|
||||
if [ $TOTAL_VIOLATIONS -eq 0 ]; then
|
||||
echo ""
|
||||
echo "✅ All security checks passed!"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo "💡 To fix these issues:"
|
||||
echo " - For text/config files: add pattern to .file-whitelist.txt (requires maintainer approval)"
|
||||
echo " - For binary files (images, audio, video, archives): do NOT add to the repo"
|
||||
echo " Binary files permanently bloat git history. Store them externally instead."
|
||||
echo " Only a small set of explicitly listed binary files is permitted."
|
||||
echo " - Use environment variables for secrets"
|
||||
echo " - Never hardcode research data or session IDs"
|
||||
echo " - Use .env.example files instead of .env"
|
||||
echo " - Replace absolute paths with relative paths or configs"
|
||||
echo " - Use DNS names instead of hardcoded IP addresses"
|
||||
echo ""
|
||||
echo "⚠️ SECURITY REMINDER: This is a public repository!"
|
||||
echo " Never commit sensitive data, API keys, or personal information."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Filter ``npm audit --json`` output against an allowlist of advisories.
|
||||
|
||||
Reads ``npm audit --json`` on stdin. Exits non-zero (1) if any
|
||||
moderate-or-higher vulnerability is NOT fully explained by allowlisted
|
||||
advisories, otherwise 0.
|
||||
|
||||
npm reports a parent package as vulnerable when it (transitively) depends on
|
||||
a vulnerable child; the parent's ``via`` then contains a *string* reference to
|
||||
the child rather than an advisory object. We resolve those ``via`` chains so a
|
||||
package that is vulnerable *only* because of an allowlisted advisory is itself
|
||||
treated as handled — otherwise allowlisting one leaf (e.g. js-yaml) would still
|
||||
leave its dozen parents (lhci/*, jest/*) failing the gate.
|
||||
|
||||
The allowlist is supplied via the ``AUDIT_ALLOWLIST`` env var as a
|
||||
space-separated list of GHSA IDs. Keep it tight and justified in the workflow
|
||||
that sets it — only advisories with **no available fix** in **dev/test-only**
|
||||
tooling belong here.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ALLOW = set(os.environ.get("AUDIT_ALLOWLIST", "").split())
|
||||
SEVERITIES = {"moderate", "high", "critical"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
print(
|
||||
"::error::empty npm audit output (audit did not run?)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(
|
||||
f"::error::could not parse npm audit JSON: {exc}", file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
# A genuine npm audit report always carries these keys (an empty/clean
|
||||
# audit still has "vulnerabilities": {}). Their absence means the audit
|
||||
# did NOT actually run — e.g. a registry/network error returns valid JSON
|
||||
# like {"message": ..., "error": ...} and npm exits non-zero, which the
|
||||
# caller's `|| true` swallows. Fail safe (gate red) rather than pass a
|
||||
# green gate on an audit that never happened.
|
||||
if "vulnerabilities" not in data or "auditReportVersion" not in data:
|
||||
print(
|
||||
"::error::npm audit did not return a valid report (audit failed "
|
||||
f"to run?): {data.get('message', 'unknown error')}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
vulns = data.get("vulnerabilities") or {}
|
||||
if not isinstance(vulns, dict):
|
||||
print(
|
||||
"::error::npm audit 'vulnerabilities' is not an object — "
|
||||
"treating as a failed audit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
def reachable_ghsas(name, seen):
|
||||
"""All advisory GHSAs reachable from a package's via-chain."""
|
||||
if name in seen:
|
||||
return set()
|
||||
seen.add(name)
|
||||
found = set()
|
||||
for via in (vulns.get(name) or {}).get("via", []):
|
||||
if isinstance(via, dict) and "GHSA-" in (via.get("url") or ""):
|
||||
# Take only the GHSA token, not any trailing path/query the
|
||||
# URL might carry (e.g. .../GHSA-xxxx/foo?bar).
|
||||
tail = via["url"].split("GHSA-", 1)[1]
|
||||
found.add("GHSA-" + tail.split("/")[0].split("?")[0])
|
||||
elif isinstance(via, str):
|
||||
found |= reachable_ghsas(via, seen)
|
||||
return found
|
||||
|
||||
unhandled = 0
|
||||
for name, info in vulns.items():
|
||||
if info.get("severity") not in SEVERITIES:
|
||||
continue
|
||||
ghsas = reachable_ghsas(name, set())
|
||||
if ghsas and ghsas <= ALLOW:
|
||||
print(f" allowlisted: {name} ({info['severity']}) {sorted(ghsas)}")
|
||||
else:
|
||||
unhandled += 1
|
||||
detail = sorted(ghsas - ALLOW) if ghsas else "no-GHSA"
|
||||
print(f" UNHANDLED: {name} ({info['severity']}) {detail}")
|
||||
|
||||
suffix = "y" if unhandled == 1 else "ies"
|
||||
print(f" -> {unhandled} non-allowlisted moderate+ vulnerabilit{suffix}")
|
||||
return 1 if unhandled else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# Validates that all docker-compose image references use SHA256 digests
|
||||
# Prevents supply chain attacks by ensuring immutable image references
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration - images that are allowed without SHA digests
|
||||
ALLOWED_EXCEPTIONS=(
|
||||
"localdeepresearch/local-deep-research:latest" # Own image, built by CI
|
||||
)
|
||||
|
||||
# Check if an image reference is in the exceptions list
|
||||
is_exception() {
|
||||
local image="$1"
|
||||
for exception in "${ALLOWED_EXCEPTIONS[@]}"; do
|
||||
if [[ "$image" == "$exception" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Validate a single docker-compose file
|
||||
validate_compose_file() {
|
||||
local file="$1"
|
||||
local violations=0
|
||||
local line_num=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Check if this line contains an image reference
|
||||
if [[ "$line" =~ ^[[:space:]]*image:[[:space:]]*(.+)$ ]]; then
|
||||
local image="${BASH_REMATCH[1]}"
|
||||
image=$(echo "$image" | tr -d '"' | xargs) # Remove quotes and whitespace
|
||||
|
||||
# Skip if it's an exception
|
||||
if is_exception "$image"; then
|
||||
echo -e "${YELLOW} Line $line_num: $image (exception)${NC}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if image has SHA digest
|
||||
if [[ ! "$image" =~ @sha256: ]]; then
|
||||
echo -e "${RED} ❌ Line $line_num: Missing SHA digest${NC}"
|
||||
echo -e "${RED} Image: $image${NC}"
|
||||
violations=$((violations + 1))
|
||||
else
|
||||
echo -e "${GREEN} ✓ Line $line_num: $image${NC}"
|
||||
fi
|
||||
fi
|
||||
done < "$file"
|
||||
|
||||
return $violations
|
||||
}
|
||||
|
||||
# Main validation logic
|
||||
main() {
|
||||
local total_violations=0
|
||||
local files_checked=0
|
||||
|
||||
echo "🔍 Validating docker-compose image pinning..."
|
||||
echo ""
|
||||
|
||||
# Find all docker-compose files
|
||||
while IFS= read -r compose_file; do
|
||||
# Skip cookiecutter templates and examples (documentation only)
|
||||
if [[ "$compose_file" =~ cookiecutter-docker/ ]] || [[ "$compose_file" =~ examples/ ]]; then
|
||||
echo -e "${YELLOW}⏭ Skipping: $compose_file (template/example)${NC}"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "📄 Checking: $compose_file"
|
||||
if validate_compose_file "$compose_file"; then
|
||||
: # No violations
|
||||
else
|
||||
violations=$?
|
||||
total_violations=$((total_violations + violations))
|
||||
fi
|
||||
files_checked=$((files_checked + 1))
|
||||
echo ""
|
||||
done < <(find . -name "docker-compose*.yml" -o -name "docker-compose*.yaml")
|
||||
|
||||
# Summary
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📊 Summary:"
|
||||
echo " Files checked: $files_checked"
|
||||
echo " Violations: $total_violations"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [ $total_violations -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${RED}❌ Found $total_violations unpinned images in docker-compose files${NC}"
|
||||
echo ""
|
||||
echo "Images must use SHA256 digests for security and reproducibility."
|
||||
echo ""
|
||||
echo "To fix:"
|
||||
echo " 1. Pull the image: docker pull <image:tag>"
|
||||
echo " 2. Get digest: docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
|
||||
echo " 3. Update file: image: <image:tag>@sha256:..."
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo " # Bad"
|
||||
echo " image: ollama/ollama:latest"
|
||||
echo ""
|
||||
echo " # Good"
|
||||
echo " image: ollama/ollama:latest@sha256:8850b8b33936b9fb..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ All docker-compose images properly pinned${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validates that all GitHub Actions workflow service containers and container images
|
||||
use SHA256 digests for supply chain security.
|
||||
|
||||
Prevents tag tampering attacks by ensuring immutable image references.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("❌ Error: PyYAML is required. Install with: pip install pyyaml")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ANSI color codes
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m" # No Color
|
||||
|
||||
|
||||
def has_sha_digest(image_ref: str) -> bool:
|
||||
"""Check if image reference includes SHA256 digest."""
|
||||
return "@sha256:" in image_ref
|
||||
|
||||
|
||||
def validate_workflow(workflow_path: Path) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
Validate a workflow file for unpinned images.
|
||||
|
||||
Returns:
|
||||
List of (job_name, violation_type, image) tuples
|
||||
"""
|
||||
violations = []
|
||||
|
||||
try:
|
||||
with open(workflow_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if not isinstance(data, dict) or "jobs" not in data:
|
||||
return violations
|
||||
|
||||
# Check each job
|
||||
for job_name, job_def in data["jobs"].items():
|
||||
if not isinstance(job_def, dict):
|
||||
continue
|
||||
|
||||
# Check container: field
|
||||
if "container" in job_def:
|
||||
container = job_def["container"]
|
||||
|
||||
# Container can be a string or dict with 'image' key
|
||||
if isinstance(container, str):
|
||||
if not has_sha_digest(container):
|
||||
violations.append((job_name, "container", container))
|
||||
elif isinstance(container, dict) and "image" in container:
|
||||
image = container["image"]
|
||||
if not has_sha_digest(image):
|
||||
violations.append((job_name, "container", image))
|
||||
|
||||
# Check services: field
|
||||
if "services" in job_def and isinstance(job_def["services"], dict):
|
||||
for service_name, service_def in job_def["services"].items():
|
||||
if isinstance(service_def, dict) and "image" in service_def:
|
||||
image = service_def["image"]
|
||||
if not has_sha_digest(image):
|
||||
violations.append(
|
||||
(job_name, f"service '{service_name}'", image)
|
||||
)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
print(f"{Colors.RED}❌ YAML parse error in {workflow_path}:{Colors.NC}")
|
||||
print(f" {e}")
|
||||
# Return a violation to fail the check
|
||||
violations.append(("parse_error", "error", str(e)))
|
||||
except Exception as e:
|
||||
print(
|
||||
f"{Colors.RED}❌ Error processing {workflow_path}: {e}{Colors.NC}"
|
||||
)
|
||||
violations.append(("error", "error", str(e)))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Main validation logic."""
|
||||
workflows_dir = Path(".github/workflows")
|
||||
|
||||
if not workflows_dir.exists():
|
||||
print(
|
||||
f"{Colors.RED}❌ .github/workflows directory not found{Colors.NC}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print("🔍 Validating GitHub Actions workflow images...")
|
||||
print()
|
||||
|
||||
total_violations = 0
|
||||
files_checked = 0
|
||||
|
||||
# Process all workflow files
|
||||
for workflow_file in sorted(workflows_dir.glob("*.yml")) + sorted(
|
||||
workflows_dir.glob("*.yaml")
|
||||
):
|
||||
violations = validate_workflow(workflow_file)
|
||||
|
||||
if violations:
|
||||
print(f"{Colors.RED}📄 {workflow_file.name}:{Colors.NC}")
|
||||
for job_name, violation_type, image in violations:
|
||||
print(
|
||||
f"{Colors.RED} ❌ Job '{job_name}' {violation_type}: {image}{Colors.NC}"
|
||||
)
|
||||
print()
|
||||
total_violations += len(violations)
|
||||
else:
|
||||
print(f"{Colors.GREEN} ✓ {workflow_file.name}{Colors.NC}")
|
||||
|
||||
files_checked += 1
|
||||
|
||||
# Summary
|
||||
print("━" * 50)
|
||||
print("📊 Summary:")
|
||||
print(f" Files checked: {files_checked}")
|
||||
print(f" Violations: {total_violations}")
|
||||
print("━" * 50)
|
||||
|
||||
if total_violations > 0:
|
||||
print()
|
||||
print(
|
||||
f"{Colors.RED}❌ Found {total_violations} unpinned images in workflow files{Colors.NC}"
|
||||
)
|
||||
print()
|
||||
print("Service container images must use SHA256 digests for security.")
|
||||
print()
|
||||
print("To fix:")
|
||||
print(" 1. Pull the image: docker pull <image:tag>")
|
||||
print(
|
||||
" 2. Get digest: docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
|
||||
)
|
||||
print(" 3. Update workflow:")
|
||||
print()
|
||||
print("Example:")
|
||||
print(" services:")
|
||||
print(" redis:")
|
||||
print(f"{Colors.RED} image: redis:alpine # Bad{Colors.NC}")
|
||||
print(
|
||||
f"{Colors.GREEN} image: redis:alpine@sha256:... # Good{Colors.NC}"
|
||||
)
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(f"{Colors.GREEN}✅ All workflow images properly pinned{Colors.NC}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user