Skip to content
Merged
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
98 changes: 77 additions & 21 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Malware Signature Scan

on:
push:
pull_request:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
Expand All @@ -16,34 +17,86 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Scan for known malware signatures (A8-1662 / PolinRider incident)
- name: Scan for malware signatures (A8-1662 / PolinRider incident)
id: scan
shell: bash
run: |
set +e
FOUND=0

echo "== campaign marker / require shim =="
if grep -rnE "A8-1662|A8-\*#new|global\['r'\]=require" --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
SRC=( --include='*.js' --include='*.mjs' --include='*.cjs'
--include='*.ts' --include='*.mts' --include='*.cts'
--include='*.tsx' --include='*.jsx' --include='*.vue' --include='*.svelte'
--include='*.json' --include='*.html' --include='*.yml' --include='*.yaml' --include='*.sh' )
SKIP=( --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=build
--exclude-dir=.git --exclude-dir=vendor --exclude-dir=coverage
--exclude='*.min.js' --exclude='*.min.css' --exclude='*.map'
--exclude='package-lock.json' --exclude='yarn.lock' --exclude='pnpm-lock.yaml'
--exclude='malware-scan.yml' )

echo "== obfuscator shape =="
if grep -rlE "_0x[0-9a-f]{4,}\(0x[0-9a-f]+\)" --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
rule() {
local label="$1" re="$2" out
out=$(grep -rnE "$re" "${SRC[@]}" "${SKIP[@]}" . 2>/dev/null)
echo "== $label =="
if [ -n "$out" ]; then
echo "$out" | cut -c1-200 | head -50
FOUND=1
fi
}

echo "== whitespace-padding disguise trick =="
if grep -rnE '\);? {200,}\S' --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
rule "1. known campaign markers" \
"A8-1662|A8-\*#new|global\['r'\] *= *require|global\.i *= *['\"]A8"

echo "== vscode auto-run triggers =="
if [ -f .vscode/tasks.json ] && grep -qE "folderOpen" .vscode/tasks.json; then FOUND=1; fi
if [ -f .vscode/settings.json ] && grep -qE "allowAutomaticTasks" .vscode/settings.json; then FOUND=1; fi
# Rule 2: obfuscator string-array dispatch. Two guards against false positives:
# the offset is 2-4 hex digits (a 6/8-digit literal is a colour, e.g.
# THREE.Color(0x1a0a14)), and the call must repeat many times in one file --
# real string-array dispatch appears dozens of times, a colour constant once.
echo "== 2. obfuscator string-array dispatch (repeated) =="
out=$(grep -roE "[A-Za-z_\$][A-Za-z0-9_\$]{2,}\(0x[0-9a-f]{2,4}\)" \
"${SRC[@]}" "${SKIP[@]}" . 2>/dev/null \
| awk -F: '{c[$1]++} END{for(f in c) if(c[f]>=8) printf "%s: %d dispatch-style calls\n", f, c[f]}')
if [ -n "$out" ]; then echo "$out" | head -50; FOUND=1; fi

echo "== disguised binaries (font/image files that are actually text/JS) =="
for f in $(git ls-files | grep -iE '\.(woff2?|ttf|otf|png|jpe?g|ico)$'); do
if file "$f" | grep -qi 'text\|javascript'; then
echo "DISGUISED: $f"
FOUND=1
fi
# Rule 3: code pushed off-screen behind padding. Require what follows the
# padding to look executable (identifier then call or assignment) -- generated
# HTML such as Plotly's pads before markup like "<div id=", which is harmless.
rule "3. whitespace-padding disguise" \
" {120,}[A-Za-z_\$][A-Za-z0-9_\$]*[[:space:]]*[(=]"

# Rules 4a/4b replace the old ".{400,}" length rule. Length alone flags inline
# SVG, data: URIs, minified vendor bundles and JSON data -- all normal. What
# actually indicates a hidden payload is *encoding*, so look for that instead.
rule "4a. long run of hex/unicode escapes (encoded string)" \
"(\\\\x[0-9a-fA-F]{2}){8,}|(\\\\u[0-9a-fA-F]{4}){8,}"

# A base64 blob on its own is not suspicious -- Plotly stores chart data that
# way, and so do inline images and fonts. It matters only when something
# decodes and runs it, so require a decode/exec primitive around the blob.
rule "4b. base64 blob fed to a decode/exec primitive" \
"(atob|eval|Function)[[:space:]]*\([[:space:]]*['\"][A-Za-z0-9+/]{100,}|Buffer\.from\([[:space:]]*['\"][A-Za-z0-9+/]{100,}|(eval|Function)[[:space:]]*\([[:space:]]*(atob|decodeURIComponent|unescape)[[:space:]]*\("

echo "== 5. dangerous primitives in build/config files =="
CFG=$(git ls-files 2>/dev/null | grep -E '(^|/)([A-Za-z0-9._-]*\.config\.(js|cjs|mjs|ts)|\.eslintrc[^/]*)$')
if [ -n "$CFG" ]; then
out=$(echo "$CFG" | tr '\n' '\0' | xargs -0 -r grep -nE \
'\beval\(|new Function\(|child_process|createRequire|\batob\(|Buffer\.from\([^)]*base64' 2>/dev/null)
if [ -n "$out" ]; then echo "$out" | cut -c1-200; FOUND=1; fi
fi

echo "== 6. vscode auto-run triggers =="
if [ -f .vscode/tasks.json ] && grep -qE "folderOpen" .vscode/tasks.json; then
echo "VSCODE: tasks.json runs on folderOpen"; FOUND=1
fi
if [ -f .vscode/settings.json ] && grep -qE "allowAutomaticTasks" .vscode/settings.json; then
echo "VSCODE: allowAutomaticTasks set"; FOUND=1
fi

echo "== 7. disguised binaries (font/image files that are actually text/JS) =="
for f in $(git ls-files 2>/dev/null | grep -iE '\.(woff2?|ttf|otf|png|jpe?g|ico|gif|webp)$'); do
if file "$f" | grep -qiE 'text|javascript'; then echo "DISGUISED: $f"; FOUND=1; fi
done

echo "RESULT FOUND=$FOUND"
echo "found=$FOUND" >> "$GITHUB_OUTPUT"
exit 0

Expand All @@ -61,23 +114,26 @@ jobs:
const title = `Malware signature detected — ${context.sha.substring(0,7)}`;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
`Automated scan found a known malicious code signature (campaign A8-1662 / obfuscated payload / disguised binary) at commit ${context.sha}.`,
`Automated scan found a malicious code signature (campaign A8-1662 / obfuscated payload / disguised binary) at commit ${context.sha}.`,
'',
`Run: ${runUrl}`,
'',
'Do not trust commit messages claiming removal -- a prior incident involved paired commits where one removed the payload and a second, seconds later, silently re-added it under a misleading title. Read file content directly at the current ref before concluding anything is clean.'
'Do not trust commit messages claiming removal -- in the original incident the commit titled "Remove malicious code injected into eslint.config.js" did not remove the payload, it swapped in a larger variant. Read file content directly at the current ref before concluding anything is clean.'
].join('\n');
const { data: issues } = await github.rest.issues.listForRepo({
const existing = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open'
state: 'open',
per_page: 100
});
const dup = issues.find(i => i.title.startsWith('Malware signature detected'));
const dup = existing.find(i => !i.pull_request && i.title.startsWith('Malware signature detected'));
if (!dup) {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body
});
} else {
core.info(`Alert issue already open: #${dup.number}`);
}
Loading