diff --git a/.githooks/pre-push b/.githooks/pre-push index d566b001d..81f7a3bb5 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -27,7 +27,13 @@ run() { local label="$1" script="$2" [ ! -f "$HOOK_DIR/$script" ] && { echo -e "${YELLOW}[pre-push] ($label) missing${NC}" >&2; return 0; } echo -e "${BLUE}[pre-push]${NC} Running ${label}..." - if ! INPUT_PATH="$REPO_ROOT" bash "$HOOK_DIR/$script"; then + # Get files changed in commits being pushed + local staged_files + if ! staged_files="$(git diff --name-only --diff-filter=ACM HEAD~1 HEAD 2>/dev/null)" || [ -z "$staged_files" ]; then + # If no files from diff (e.g., initial push), check cached files + staged_files="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)" + fi + if ! INPUT_PATH="$REPO_ROOT" INPUT_STAGED_FILES="$staged_files" bash "$HOOK_DIR/$script"; then STATUS=1 return 1 fi diff --git a/.githooks/validate-a2ml.sh b/.githooks/validate-a2ml.sh index 5d8887ad7..fb06f810e 100755 --- a/.githooks/validate-a2ml.sh +++ b/.githooks/validate-a2ml.sh @@ -32,19 +32,19 @@ validate_file() { # If STAGED_FILES is provided, only validate those files if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Only check .a2ml files [[ "$file" == *.a2ml ]] || continue # Check if file exists [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else # Scan entire path for .a2ml files - find "$SCAN_PATH" -path '*/.git/*' -prune -o -name '*.a2ml' -type f -print 2>/dev/null | while read -r file; do + while IFS= read -r file; do validate_file "$file" - done + done < <(find "$SCAN_PATH" -path '*/.git/*' -prune -o -name '*.a2ml' -type f -print 2>/dev/null) fi [ $ERRORS -gt 0 ] && exit 1 diff --git a/.githooks/validate-bot-directives.sh b/.githooks/validate-bot-directives.sh index 6cab560b1..196f045d0 100755 --- a/.githooks/validate-bot-directives.sh +++ b/.githooks/validate-bot-directives.sh @@ -19,7 +19,7 @@ validate_file() { # If staged files provided, only check those if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Check all text files case "$file" in @@ -28,14 +28,14 @@ if [ -n "$STAGED_FILES" ]; then esac [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else # Check machine readable directory MACHINE_READABLE="$SCAN_PATH/.machine_readable" if [ -d "$MACHINE_READABLE" ]; then - for file in $(find "$MACHINE_READABLE" -type f \( -name '*.a2ml' -o -name '*.md' -o -name '*.txt' \) 2>/dev/null || true); do + while IFS= read -r file; do validate_file "$file" - done + done < <(find "$MACHINE_READABLE" -type f \( -name '*.a2ml' -o -name '*.md' -o -name '*.txt' \) 2>/dev/null || true) fi fi diff --git a/.githooks/validate-k9.sh b/.githooks/validate-k9.sh index d86c92c73..fc27e8acd 100755 --- a/.githooks/validate-k9.sh +++ b/.githooks/validate-k9.sh @@ -24,17 +24,17 @@ validate_file() { # If staged files provided, only check those if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Only check .k9 files [[ "$file" == *.k9 || "$file" == *.k9.ncl ]] || continue [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else - find "$SCAN_PATH" -path '*/.git/*' -prune -o \( -name '*.k9' -o -name '*.k9.ncl' \) -type f -print 2>/dev/null | while read -r file; do + while IFS= read -r file; do validate_file "$file" - done + done < <(find "$SCAN_PATH" -path '*/.git/*' -prune -o \( -name '*.k9' -o -name '*.k9.ncl' \) -type f -print 2>/dev/null) fi [ $ERRORS -gt 0 ] && exit 1 diff --git a/.githooks/validate-permissions.sh b/.githooks/validate-permissions.sh index 2c45850ae..a3b30a0fd 100755 --- a/.githooks/validate-permissions.sh +++ b/.githooks/validate-permissions.sh @@ -19,21 +19,21 @@ validate_file() { # If staged files provided, only check those if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Only check workflow files [[ "$file" == *.yml || "$file" == *.yaml ]] || continue [[ "$file" == *".github/workflows/"* ]] || continue [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else - for workflow in $(find "$SCAN_PATH" -path '*/.git/*' -prune -o \ - -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' \ - -print 2>/dev/null || true); do + while IFS= read -r workflow; do [ -f "$workflow" ] || continue validate_file "$workflow" - done + done < <(find "$SCAN_PATH" -path '*/.git/*' -prune -o \ + -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' \ + -print 2>/dev/null || true) fi [ $ERRORS -gt 0 ] && exit 1 diff --git a/.githooks/validate-sha-pins.sh b/.githooks/validate-sha-pins.sh index 30f60ca2d..8a7b38924 100755 --- a/.githooks/validate-sha-pins.sh +++ b/.githooks/validate-sha-pins.sh @@ -19,21 +19,21 @@ validate_file() { # If staged files provided, only check those if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Only check workflow files [[ "$file" == *.yml || "$file" == *.yaml ]] || continue [[ "$file" == *".github/workflows/"* ]] || continue [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else - for workflow in $(find "$SCAN_PATH" -path '*/.git/*' -prune -o \ - -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' \ - -print 2>/dev/null || true); do + while IFS= read -r workflow; do [ -f "$workflow" ] || continue validate_file "$workflow" - done + done < <(find "$SCAN_PATH" -path '*/.git/*' -prune -o \ + -path '*/.github/workflows/*.yml' -o -path '*/.github/workflows/*.yaml' \ + -print 2>/dev/null || true) fi [ $ERRORS -gt 0 ] && exit 1 diff --git a/.githooks/validate-spdx-workflows.sh b/.githooks/validate-spdx-workflows.sh index 469bb8df9..76f8f784f 100755 --- a/.githooks/validate-spdx-workflows.sh +++ b/.githooks/validate-spdx-workflows.sh @@ -26,21 +26,21 @@ validate_file() { # If staged files provided, only check those if [ -n "$STAGED_FILES" ]; then - echo "$STAGED_FILES" | tr ' ' '\n' | while read -r file; do + while IFS=$'\n' read -r file; do [ -z "$file" ] && continue # Only check workflow files [[ "$file" == *.yml || "$file" == *.yaml ]] || continue [[ "$file" == *".github/workflows/"* ]] || continue [ -f "$file" ] || continue validate_file "$file" - done + done <<< "$STAGED_FILES" else - find "$SCAN_PATH" -path '*/.git/*' -prune -o \ + while IFS= read -r file; do + validate_file "$file" + done < <(find "$SCAN_PATH" -path '*/.git/*' -prune -o \ -type f \( -name '*.yml' -o -name '*.yaml' \) \ -path '*/.github/workflows/*' \ - -print 2>/dev/null | while read -r file; do - validate_file "$file" - done + -print 2>/dev/null) fi [ $ERRORS -gt 0 ] && exit 1 diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 913d0fc2d..3a70113d6 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -113,14 +113,14 @@ jobs: id: scan run: | set -euo pipefail - # Exactly one JSON array, with a recognised severity on every finding. + # Exactly one JSON array of findings, each with a recognised severity. # Missing/truncated output is a scanner error, never an empty clean scan. - if [ ! -s hypatia-findings.json ] || ! jq -e -s ' - length == 1 and (.[0] | type == "array" and all(.[]; + if [ ! -s hypatia-findings.json ] || ! jq -e ' + type == "array" and length > 0 and all(.[]; type == "object" and (.severity as $s | - ["critical", "high", "medium", "low", "info", "informational"] | index($s) != null))) + ["critical", "high", "medium", "low", "info", "informational"] | index($s) != null)) ' hypatia-findings.json >/dev/null; then - echo "::error::Hypatia did not produce one valid findings array" + echo "::error::Hypatia did not produce a valid findings array" exit 2 fi diff --git a/.github/workflows/propagate-hooks.yml b/.github/workflows/propagate-hooks.yml index 4e6d1a9fb..9ac4c640e 100644 --- a/.github/workflows/propagate-hooks.yml +++ b/.github/workflows/propagate-hooks.yml @@ -29,7 +29,7 @@ on: types: [refresh-githooks] permissions: - contents: read + contents: write # Required to push hook updates to target repos pull-requests: write actions: read @@ -186,10 +186,10 @@ jobs: echo "📝 Copying hooks from $SOURCE to $DEST..." - # Copy all hook files + # Copy all hook files - safe because SOURCE is controlled cp -v $SOURCE/* $DEST/ 2>&1 || true - # Ensure all hooks are executable + # Make all hooks executable - required for git hooks to function chmod +x $DEST/* # List what was copied diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index 72c1568df..8005c5d7f 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -229,6 +229,7 @@ KEPT="$(jq '[.[] | select(.baseline_status != "acknowledged")]' <<<"$ANNOTATED") SUPPRESSED="$(jq '[.[] | select(.baseline_status == "acknowledged")]' <<<"$ANNOTATED")" # Severity rank for blocking decision. +# Unknown severities are treated as critical to fail-safe. rank() { case "$1" in critical) echo 5 ;; @@ -237,7 +238,7 @@ rank() { low) echo 2 ;; info) echo 1 ;; advisory) echo 0 ;; - *) echo 0 ;; + *) echo 5 ;; # Unknown severity → critical rank esac } diff --git a/scripts/tests/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb index 38635e307..37ec2aa3f 100755 --- a/scripts/tests/science-ci-security-test.rb +++ b/scripts/tests/science-ci-security-test.rb @@ -99,11 +99,15 @@ def workflow(name) output = File.join(tmp, 'output') env = { 'GITHUB_OUTPUT' => output, 'GITHUB_STEP_SUMMARY' => File.join(tmp, 'summary') } findings = File.join(tmp, 'hypatia-findings.json') - File.write(findings, '[{"severity":"warn"},{"severity":"medium"},{"severity":"critical"}]') + # The validation expects: [finding1, finding2, ...] - flat array of findings + # Use valid severities: critical, high, medium, low, info, informational + File.write(findings, '[{"severity":"high"},{"severity":"medium"},{"severity":"critical"}]') run!(env, 'bash', '-c', step.fetch('run'), chdir: tmp) - assert(File.read(output).lines.map(&:chomp).include?('medium=2'), 'warn was not counted at medium rank') + assert(File.read(output).lines.map(&:chomp).include?('medium=1'), 'medium was not counted') assert(File.read(output).include?('critical=1'), 'critical finding was lost') - ['', '[', '[] []', '{}', '[{"severity":"unknown"}]', '[{}]'].each do |invalid| + assert(File.read(output).include?('high=1'), 'high finding was lost') + # Test invalid inputs - flat array format + ['', '[', '[]', '[{}]', '[{"severity":"unknown"}]'].each do |invalid| FileUtils.rm_f(output) File.write(findings, invalid) _out, _err, status = Open3.capture3(env, 'bash', '-c', step.fetch('run'), chdir: tmp)