Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
24 changes: 24 additions & 0 deletions malware-scan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<RECIPES_SHA>` 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.
Expand Down
4 changes: 4 additions & 0 deletions malware-scan/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ jobs:
with:
recipes_ref: <RECIPES_SHA>
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 }}
Loading