diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index c3b98d1..a1846ea 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -18,6 +18,10 @@ on: required: false type: boolean default: false + secrets: + MALWARE_ALERT_WEBHOOK: + description: 'Google Chat incoming-webhook URL. When set, the team is alerted if the scan finds malware or cannot run.' + required: false permissions: contents: read @@ -44,13 +48,70 @@ jobs: persist-credentials: false # Deliberately NO npm install / composer install / build step. + # Output is saved (and the exit code recorded) for the alert step. - name: Scan tracked files + id: tracked working-directory: project - run: python3 ../scanner/malware-scan/scan_malicious.py + run: | + set +e + python3 ../scanner/malware-scan/scan_malicious.py 2>&1 | tee ../scan-output.txt + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + exit "$code" - name: Scan all branches + id: branches if: inputs.scan_all_branches working-directory: project run: | + set +e git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*' - python3 ../scanner/malware-scan/scan_malicious.py --all-branches + python3 ../scanner/malware-scan/scan_malicious.py --all-branches 2>&1 | tee ../scan-output.txt + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + exit "$code" + + # Posts to Google Chat with curl, not a third-party action, so nothing else sees + # the webhook. Only rule IDs and paths are sent: code snippets are attacker-controlled. + - name: Alert Google Chat + if: failure() + continue-on-error: true + env: + WEBHOOK: ${{ secrets.MALWARE_ALERT_WEBHOOK }} + CODE: ${{ steps.branches.outputs.code || steps.tracked.outputs.code }} + EVENT: ${{ github.event_name }} + REF: ${{ github.head_ref || github.ref_name }} + ACTOR: ${{ github.actor }} + FORCED: ${{ github.event.forced }} + run: | + [ -n "$WEBHOOK" ] || { echo "MALWARE_ALERT_WEBHOOK not set; skipping alert."; exit 0; } + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + case "$EVENT" in + schedule) event="weekly sweep" ;; + pull_request) event="pull request" ;; + push) if [ "$FORCED" = "true" ]; then event="⚠️ FORCE-PUSH"; else event="normal push"; fi ;; + *) event="$EVENT" ;; + esac + if [ "$CODE" = "1" ]; then + high=$(grep -c '^\[HIGH' scan-output.txt || true) + list=$(grep '^\[HIGH' scan-output.txt | sed -E 's/^\[HIGH *\] +//; s/ +/ /' | tr -d '<>' | cut -c1-150 | head -10) + more=""; [ "$high" -gt 10 ] && more=$'\n'"…and $((high - 10)) more" + text="🚨 *Malware scan: possible malware found* + *Repo:* $GITHUB_REPOSITORY + *Branch:* $REF ($event by $ACTOR, commit ${GITHUB_SHA:0:7}) + *HIGH findings:* $high + $list$more + + Do NOT run npm install/dev/build on this code, and do not pull this branch. + <$run_url|Open the scan run>" + else + text="⚠️ *Malware scan could not run* + *Repo:* $GITHUB_REPOSITORY + *Branch:* $REF ($event by $ACTOR) + The gate is not protecting this repo until it passes again. + <$run_url|Open the run>" + fi + jq -n --arg text "$text" '{text: $text}' > payload.json + curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json; charset=UTF-8' \ + --data @payload.json "$WEBHOOK" + echo "Alert sent." diff --git a/malware-scan/README.md b/malware-scan/README.md index 5009d08..cb7a596 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -61,6 +61,30 @@ hooks. The workflow runs on every push and PR, and sweeps the tip of every branch weekly (Mondays 03:00 UTC) and on manual dispatch. +### Alerts (Google Chat) + +A failed scan only notifies the person who pushed, and in this attack that is the compromised +account. To alert the whole team: + +1. In the Google Chat space: **Apps & integrations β†’ Webhooks β†’ Add webhook**, then copy the URL. +2. In each project that should alert, save it as a **repository** Actions secret named + `MALWARE_ALERT_WEBHOOK` (Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret). + Each repository can point at its own space. On GitHub Free, private repositories can't read + organisation secrets. On a paid plan (or for public repositories), an organisation secret with + the same name works as a default, and a repository secret overrides it. +3. Make sure the project's caller workflow passes the secret (the `secrets:` block in + [`workflow.yml`](workflow.yml)) and pins a `` that includes alerts. + +When the scan finds HIGH findings, the space gets one message with the repository, branch, event (a push is labelled normal or FORCE-PUSH), +actor, the first 10 `RULE path:line` findings and a link to the run. Each alert is its own message +(not threaded). Code snippets are never sent, because they are attacker-controlled. If the +scanner itself fails to run, a shorter "could not run" message is sent. Without the secret, the +step is skipped (for example, on PRs from forks). The message is sent with plain `curl`, so no +third-party action ever sees the webhook. + +The webhook URL is a credential: anyone who has it can post to the space. To rotate it, delete the +webhook in the space, add a new one and update the secret. + **Why a reusable workflow instead of copying the scanner:** a copy inside the project can be edited by the same push that adds the malware. Here the scanner is checked out from this repo, into a separate folder, at a pinned commit. diff --git a/malware-scan/workflow.yml b/malware-scan/workflow.yml index 360b889..d3ec5e5 100644 --- a/malware-scan/workflow.yml +++ b/malware-scan/workflow.yml @@ -18,3 +18,7 @@ jobs: with: recipes_ref: scan_all_branches: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + # Optional: alerts the team's Google Chat space when malware is found (see README). + # Pass only this secret; don't use `secrets: inherit`. + secrets: + MALWARE_ALERT_WEBHOOK: ${{ secrets.MALWARE_ALERT_WEBHOOK }}