From 43feb0893af01cbe5d7076f1f5474d887dd7e7aa Mon Sep 17 00:00:00 2001 From: Satendra Rawat Date: Mon, 28 Sep 2026 13:08:35 +0530 Subject: [PATCH 1/4] feat(malware-scan): alert a Google Chat space when the CI scan finds malware The reusable workflow takes an optional MALWARE_ALERT_WEBHOOK secret. On a failed scan it posts repo, branch, actor, the first 10 HIGH rule/path pairs and the run link (no code snippets) with plain curl, threaded per repo. A separate message is sent when the scanner cannot run. Skipped when the secret is absent. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/malware-scan.yml | 60 +++++++++++++++++++++++++++++- malware-scan/README.md | 21 +++++++++++ malware-scan/workflow.yml | 4 ++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index c3b98d1..d942ca0 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,65 @@ 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 }} + 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" ;; *) 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" --arg key "$GITHUB_REPOSITORY" \ + '{text: $text, thread: {threadKey: $key}}' > payload.json + curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json; charset=UTF-8' \ + --data @payload.json "$WEBHOOK&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + echo "Alert sent." diff --git a/malware-scan/README.md b/malware-scan/README.md index 5009d08..f8f8297 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -61,6 +61,27 @@ 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. Save it as an **organisation** Actions secret named `MALWARE_ALERT_WEBHOOK`, available to all + repositories. A client's GitHub organisation needs its own copy. +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, +actor, the first 10 `RULE path:line` findings and a link to the run. Alerts for the same repository +land in one thread. 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 }} From 654bbb44fde765d67b6ce03df52ff786d396b7e0 Mon Sep 17 00:00:00 2001 From: Satendra Rawat Date: Mon, 28 Sep 2026 13:22:59 +0530 Subject: [PATCH 2/4] docs(malware-scan): set up the alert webhook as a repository secret Org secrets aren't readable by private repos on GitHub Free. Co-Authored-By: Claude Opus 5.5 --- malware-scan/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/malware-scan/README.md b/malware-scan/README.md index f8f8297..9d00b58 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -67,8 +67,11 @@ A failed scan only notifies the person who pushed, and in this attack that is th account. To alert the whole team: 1. In the Google Chat space: **Apps & integrations β†’ Webhooks β†’ Add webhook**, then copy the URL. -2. Save it as an **organisation** Actions secret named `MALWARE_ALERT_WEBHOOK`, available to all - repositories. A client's GitHub organisation needs its own copy. +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. From 7f22cf25b1b73013e1c8f801d367fd8dba41fdba Mon Sep 17 00:00:00 2001 From: Satendra Rawat Date: Mon, 28 Sep 2026 13:38:38 +0530 Subject: [PATCH 3/4] feat(malware-scan): say whether a push was a force-push in the alert Co-Authored-By: Claude Opus 5.5 --- .github/workflows/malware-scan.yml | 8 +++++++- malware-scan/README.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index d942ca0..7968a30 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -82,10 +82,16 @@ jobs: 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" ;; *) event="$EVENT" ;; esac + 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) diff --git a/malware-scan/README.md b/malware-scan/README.md index 9d00b58..355346c 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -75,7 +75,7 @@ account. To alert the whole team: 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, +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. Alerts for the same repository land in one thread. 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 From 27ec0984d54b23999f9fc1d43198e44c9819a5ac Mon Sep 17 00:00:00 2001 From: Satendra Rawat Date: Mon, 28 Sep 2026 13:45:34 +0530 Subject: [PATCH 4/4] feat(malware-scan): post each alert as its own message, not threaded Co-Authored-By: Claude Opus 5.5 --- .github/workflows/malware-scan.yml | 5 ++--- malware-scan/README.md | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index 7968a30..a1846ea 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -111,8 +111,7 @@ jobs: The gate is not protecting this repo until it passes again. <$run_url|Open the run>" fi - jq -n --arg text "$text" --arg key "$GITHUB_REPOSITORY" \ - '{text: $text, thread: {threadKey: $key}}' > payload.json + 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&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + --data @payload.json "$WEBHOOK" echo "Alert sent." diff --git a/malware-scan/README.md b/malware-scan/README.md index 355346c..cb7a596 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -76,8 +76,8 @@ account. To alert the whole team: [`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. Alerts for the same repository -land in one thread. Code snippets are never sent, because they are attacker-controlled. If the +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.