workflow-linter.yml
1 # SPDX-License-Identifier: AGPL-3.0-or-later 2 # workflow-linter.yml - Validates GitHub workflows against RSR security standards 3 # This workflow can be copied to other repos for consistent enforcement 4 name: Workflow Security Linter 5 6 on: 7 push: 8 paths: 9 - '.github/workflows/**' 10 pull_request: 11 paths: 12 - '.github/workflows/**' 13 workflow_dispatch: 14 15 permissions: read-all 16 17 jobs: 18 lint-workflows: 19 runs-on: ubuntu-latest 20 permissions: 21 contents: read 22 23 steps: 24 - name: Checkout 25 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 26 27 - name: Check SPDX Headers 28 run: | 29 echo "=== Checking SPDX License Headers ===" 30 failed=0 31 for file in .github/workflows/*.yml .github/workflows/*.yaml; do 32 [ -f "$file" ] || continue 33 if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then 34 echo "ERROR: $file missing SPDX header" 35 failed=1 36 fi 37 done 38 if [ $failed -eq 1 ]; then 39 echo "Add '# SPDX-License-Identifier: AGPL-3.0-or-later' as first line" 40 exit 1 41 fi 42 echo "All workflows have SPDX headers" 43 44 - name: Check Permissions Declaration 45 run: | 46 echo "=== Checking Permissions ===" 47 failed=0 48 for file in .github/workflows/*.yml .github/workflows/*.yaml; do 49 [ -f "$file" ] || continue 50 if ! grep -q "^permissions:" "$file"; then 51 echo "ERROR: $file missing top-level 'permissions:' declaration" 52 failed=1 53 fi 54 done 55 if [ $failed -eq 1 ]; then 56 echo "Add 'permissions: read-all' at workflow level" 57 exit 1 58 fi 59 echo "All workflows have permissions declared" 60 61 - name: Check SHA-Pinned Actions 62 run: | 63 echo "=== Checking Action Pinning ===" 64 # Find any uses: lines that don't have @SHA format 65 # Pattern: uses: owner/repo@<40-char-hex> 66 unpinned=$(grep -rn "uses:" .github/workflows/ | \ 67 grep -v "@[a-f0-9]\{40\}" | \ 68 grep -v "uses: \./\|uses: docker://\|uses: actions/github-script" || true) 69 70 if [ -n "$unpinned" ]; then 71 echo "ERROR: Found unpinned actions:" 72 echo "$unpinned" 73 echo "" 74 echo "Replace version tags with SHA pins, e.g.:" 75 echo " uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4.1.1" 76 exit 1 77 fi 78 echo "All actions are SHA-pinned" 79 80 - name: Check for Duplicate Workflows 81 run: | 82 echo "=== Checking for Duplicates ===" 83 # Known duplicate patterns 84 if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then 85 echo "ERROR: Duplicate CodeQL workflows found" 86 echo "Delete codeql-analysis.yml (keep codeql.yml)" 87 exit 1 88 fi 89 if [ -f .github/workflows/rust.yml ] && [ -f .github/workflows/rust-ci.yml ]; then 90 echo "WARNING: Potential duplicate Rust workflows" 91 echo "Consider consolidating rust.yml and rust-ci.yml" 92 fi 93 echo "No critical duplicates found" 94 95 - name: Check CodeQL Language Matrix 96 run: | 97 echo "=== Checking CodeQL Configuration ===" 98 if [ ! -f .github/workflows/codeql.yml ]; then 99 echo "No CodeQL workflow found (optional)" 100 exit 0 101 fi 102 103 # Detect repo languages 104 has_js=$(find . -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" -path "*/src/*" -o -path "*/lib/*" 2>/dev/null | head -1) 105 has_py=$(find . -name "*.py" -path "*/src/*" -o -path "*/lib/*" 2>/dev/null | head -1) 106 has_go=$(find . -name "*.go" -path "*/src/*" -o -path "*/cmd/*" -o -path "*/pkg/*" 2>/dev/null | head -1) 107 has_rs=$(find . -name "*.rs" -path "*/src/*" 2>/dev/null | head -1) 108 has_java=$(find . -name "*.java" -path "*/src/*" 2>/dev/null | head -1) 109 has_rb=$(find . -name "*.rb" -path "*/lib/*" -o -path "*/app/*" 2>/dev/null | head -1) 110 111 echo "Detected languages:" 112 [ -n "$has_js" ] && echo " - javascript-typescript" 113 [ -n "$has_py" ] && echo " - python" 114 [ -n "$has_go" ] && echo " - go" 115 [ -n "$has_rs" ] && echo " - rust (note: CodeQL rust is limited)" 116 [ -n "$has_java" ] && echo " - java-kotlin" 117 [ -n "$has_rb" ] && echo " - ruby" 118 119 # Check for over-reach 120 if grep -q "language:.*'go'" .github/workflows/codeql.yml && [ -z "$has_go" ]; then 121 echo "WARNING: CodeQL configured for Go but no Go files found" 122 fi 123 if grep -q "language:.*'python'" .github/workflows/codeql.yml && [ -z "$has_py" ]; then 124 echo "WARNING: CodeQL configured for Python but no Python files found" 125 fi 126 if grep -q "language:.*'java'" .github/workflows/codeql.yml && [ -z "$has_java" ]; then 127 echo "WARNING: CodeQL configured for Java but no Java files found" 128 fi 129 if grep -q "language:.*'ruby'" .github/workflows/codeql.yml && [ -z "$has_rb" ]; then 130 echo "WARNING: CodeQL configured for Ruby but no Ruby files found" 131 fi 132 133 echo "CodeQL check complete" 134 135 - name: Check Secrets Guards 136 run: | 137 echo "=== Checking Secrets Usage ===" 138 # Look for secrets without conditional guards in mirror workflows 139 if [ -f .github/workflows/mirror.yml ]; then 140 if grep -q "secrets\." .github/workflows/mirror.yml; then 141 if ! grep -q "if:.*vars\." .github/workflows/mirror.yml; then 142 echo "WARNING: mirror.yml uses secrets without vars guard" 143 echo "Add 'if: vars.FEATURE_ENABLED == true' to jobs" 144 fi 145 fi 146 fi 147 echo "Secrets check complete" 148 149 - name: Summary 150 run: | 151 echo "" 152 echo "=== Workflow Linter Summary ===" 153 echo "All critical checks passed." 154 echo "" 155 echo "For more info, see: robot-repo-bot/ERROR-CATALOG.scm"