name: Security Vulnerability Scan on: push: branches: [ main, staging, develop ] pull_request: branches: [ main, staging, develop ] schedule: # Daily security scan at 2 AM UTC - cron: '0 2 * * *' workflow_dispatch: jobs: check-changes: name: Check for Dependency Changes runs-on: ubuntu-latest outputs: dependencies_changed: ${{ steps.filter.outputs.dependencies_changed }} steps: - name: Download CI helpers shell: bash env: CI_TOKEN: ${{ secrets.CI_TOKEN }} run: | set -euo pipefail REF="${{ github.sha }}" if [ -z "$REF" ]; then REF="${{ github.ref_name }}" fi if [ -z "$REF" ]; then REF="${{ github.head_ref }}" fi if [ -z "$REF" ]; then REF="main" fi URL="https://git.michaelschiemer.de/${{ github.repository }}/raw/${REF}/scripts/ci/clone_repo.sh" mkdir -p /tmp/ci-tools if [ -n "$CI_TOKEN" ]; then curl -sfL -u "$CI_TOKEN:x-oauth-basic" "$URL" -o /tmp/ci-tools/clone_repo.sh else curl -sfL "$URL" -o /tmp/ci-tools/clone_repo.sh fi chmod +x /tmp/ci-tools/clone_repo.sh - name: Analyse changed files id: filter shell: bash run: | set -euo pipefail REF_NAME="${{ github.ref_name }}" if [ -z "$REF_NAME" ]; then REF_NAME="${{ github.head_ref }}" fi if [ -z "$REF_NAME" ]; then REF_NAME="main" fi REPO="${{ github.repository }}" WORKDIR="/workspace/repo" export CI_REPOSITORY="$REPO" export CI_TOKEN="${{ secrets.CI_TOKEN }}" export CI_REF_NAME="$REF_NAME" export CI_DEFAULT_BRANCH="main" export CI_TARGET_DIR="$WORKDIR" export CI_FETCH_DEPTH="2" /tmp/ci-tools/clone_repo.sh cd "$WORKDIR" # For scheduled or manual runs, always run the scan if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "dependencies_changed=true" >> "$GITHUB_OUTPUT" echo "ℹ️ Scheduled/manual run - will scan dependencies" exit 0 fi CHANGED_FILES="" EVENT_BEFORE="${{ github.event.before }}" if [ "${{ github.event_name }}" = "push" ] && [ -n "$EVENT_BEFORE" ]; then if git rev-parse "$EVENT_BEFORE" >/dev/null 2>&1; then CHANGED_FILES="$(git diff --name-only "$EVENT_BEFORE" HEAD || true)" else git fetch origin "$EVENT_BEFORE" --depth 1 || true if git rev-parse "$EVENT_BEFORE" >/dev/null 2>&1; then CHANGED_FILES="$(git diff --name-only "$EVENT_BEFORE" HEAD || true)" fi fi fi if [ -z "$CHANGED_FILES" ]; then if git rev-parse HEAD^ >/dev/null 2>&1; then CHANGED_FILES="$(git diff --name-only HEAD^ HEAD || true)" else git fetch origin "$REF_NAME" --depth 50 || true if git rev-parse HEAD^ >/dev/null 2>&1; then CHANGED_FILES="$(git diff --name-only HEAD^ HEAD || true)" fi fi fi DEPENDENCIES_CHANGED=false if [ -n "$CHANGED_FILES" ]; then while IFS= read -r FILE; do [ -z "$FILE" ] && continue if echo "$FILE" | grep -Eq "^(composer\.json|composer\.lock)$"; then DEPENDENCIES_CHANGED=true break fi done <<< "$CHANGED_FILES" fi echo "dependencies_changed=$DEPENDENCIES_CHANGED" >> "$GITHUB_OUTPUT" if [ "$DEPENDENCIES_CHANGED" = "true" ]; then echo "ℹ️ Dependencies changed - security scan will run" else echo "ℹ️ No dependency changes detected - skipping security scan" fi security-audit: needs: check-changes if: needs.check-changes.outputs.dependencies_changed == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' name: Composer Security Audit runs-on: php-ci # Uses pre-built PHP 8.5 CI image with Composer pre-installed steps: - name: Checkout code run: | # For pull_request events, use the head ref (source branch) if [ "${{ github.event_name }}" = "pull_request" ]; then REF_NAME="${{ github.head_ref || github.event.pull_request.head.ref }}" elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then REF_NAME="${{ inputs.branch || github.ref_name }}" else REF_NAME="${{ github.ref_name }}" fi # Fallback to main if REF_NAME is still empty if [ -z "$REF_NAME" ] || [ "$REF_NAME" = "" ]; then REF_NAME="main" fi REPO="${{ github.repository }}" echo "📋 Cloning branch: $REF_NAME" echo "📦 Repository: $REPO" # Use CI token if available, otherwise try public access if [ -n "${{ secrets.CI_TOKEN }}" ]; then git clone --depth 1 --branch "$REF_NAME" \ "https://${{ secrets.CI_TOKEN }}@git.michaelschiemer.de/${REPO}.git" \ /workspace/repo else # Try public HTTPS (works if repository is public) git clone --depth 1 --branch "$REF_NAME" \ "https://git.michaelschiemer.de/${REPO}.git" \ /workspace/repo || \ # Fallback: Try to use Gitea's internal runner access git clone --depth 1 \ "https://git.michaelschiemer.de/${REPO}.git" \ /workspace/repo fi cd /workspace/repo - name: Get Composer cache directory id: composer-cache shell: bash run: | echo "dir=$(composer global config cache-dir 2>/dev/null | cut -d' ' -f3 || echo "$HOME/.composer/cache")" >> $GITHUB_OUTPUT - name: Cache Composer dependencies uses: actions/cache@v4 with: path: | ${{ steps.composer-cache.outputs.dir }} vendor/ key: ${{ runner.os }}-composer-security-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer-security- - name: Validate composer.json and composer.lock run: | cd /workspace/repo # Validate composer.json (less strict - lock file might be updated during install) composer validate --no-check-lock || echo "⚠️ composer.lock might need update, but continuing..." # Try to update lock file if needed composer update --lock --no-interaction || echo "⚠️ Could not update lock file, but continuing..." - name: Install dependencies run: | cd /workspace/repo # TEMPORARY WORKAROUND: Ignore PHP 8.5 platform requirement until dependencies officially support PHP 8.5 # TODO: Remove --ignore-platform-req=php when dependencies are updated (estimated: 1 month) composer install --prefer-dist --no-progress --no-dev --ignore-platform-req=php - name: Run Composer Security Audit id: security-audit run: | cd /workspace/repo composer audit --format=json > audit-result.json || true cat audit-result.json - name: Parse audit results id: parse-audit run: | cd /workspace/repo if [ -f audit-result.json ]; then # jq is pre-installed in php-ci image jq --version ADVISORIES=$(jq -r '.advisories | length' audit-result.json 2>/dev/null || echo "0") ABANDONED=$(jq -r '.abandoned | length' audit-result.json 2>/dev/null || echo "0") echo "advisories_count=$ADVISORIES" >> $GITHUB_OUTPUT echo "abandoned_count=$ABANDONED" >> $GITHUB_OUTPUT echo "## Security Audit Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Vulnerabilities Found:** $ADVISORIES" >> $GITHUB_STEP_SUMMARY echo "- **Abandoned Packages:** $ABANDONED" >> $GITHUB_STEP_SUMMARY if [ "$ADVISORIES" -gt 0 ]; then echo "has_vulnerabilities=true" >> $GITHUB_OUTPUT echo "- **Status:** ❌ Security vulnerabilities detected - review required" >> $GITHUB_STEP_SUMMARY # Display vulnerability details echo "" >> $GITHUB_STEP_SUMMARY echo "### Vulnerability Details" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY jq '.advisories' audit-result.json >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY exit 1 else echo "has_vulnerabilities=false" >> $GITHUB_OUTPUT echo "- **Status:** ✅ No security vulnerabilities detected" >> $GITHUB_STEP_SUMMARY fi if [ "$ABANDONED" -gt 0 ]; then echo "" >> $GITHUB_STEP_SUMMARY echo "### Abandoned Packages" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY jq -r '.abandoned | to_entries[] | "- **\(.key)**: \(.value // "No replacement suggested")"' audit-result.json >> $GITHUB_STEP_SUMMARY fi fi - name: Save audit results if: always() run: | cd /workspace/repo if [ -f audit-result.json ]; then mkdir -p /tmp/artifacts cp audit-result.json /tmp/artifacts/security-audit-results-${{ github.run_number }}.json || true echo "✅ Audit results saved" fi - name: Create Gitea issue on vulnerability (scheduled runs only) if: failure() && github.event_name == 'schedule' run: | # Prepare issue body ISSUE_TITLE="🚨 Security Vulnerabilities Detected in Dependencies" ISSUE_BODY="## Security Audit Report\n\n" ISSUE_BODY="${ISSUE_BODY}**Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")\n" ISSUE_BODY="${ISSUE_BODY}**Workflow Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n" if [ -f audit-result.json ]; then # Add vulnerability details ISSUE_BODY="${ISSUE_BODY}### Vulnerabilities Found\n\n" ISSUE_BODY="${ISSUE_BODY}\`\`\`json\n" ISSUE_BODY="${ISSUE_BODY}$(cat audit-result.json)\n" ISSUE_BODY="${ISSUE_BODY}\`\`\`\n\n" fi ISSUE_BODY="${ISSUE_BODY}### Action Required\n\n" ISSUE_BODY="${ISSUE_BODY}1. Review the vulnerability details above\n" ISSUE_BODY="${ISSUE_BODY}2. Update affected packages: \`composer update \`\n" ISSUE_BODY="${ISSUE_BODY}3. Run tests: \`make test\`\n" ISSUE_BODY="${ISSUE_BODY}4. Verify with: \`make security-check\`\n" # Create issue using Gitea API # Note: Requires CI_TOKEN secret to be configured if [ -n "${{ secrets.CI_TOKEN }}" ]; then curl -X POST \ -H "Authorization: token ${{ secrets.CI_TOKEN }}" \ -H "Content-Type: application/json" \ -d "{\"title\":\"${ISSUE_TITLE}\",\"body\":\"${ISSUE_BODY}\",\"labels\":[\"security\",\"dependencies\",\"automated\"]}" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/issues" else echo "⚠️ CI_TOKEN not configured - skipping issue creation" echo "Please add CI_TOKEN as repository secret for automated issue creation" fi - name: Notify on failure if: failure() run: | echo "::error::Security vulnerabilities detected! Check the audit results for details." echo "Run 'make security-check' locally to review vulnerabilities."