Skip to content

Commit 3edd8a5

Browse files
authored
security: harden malware scan coverage and fix two scanner bugs (#3)
Broadens the scan beyond .js/.ts to .tsx/.jsx/.json/.html/.yml/.sh, makes the obfuscator rule identifier-agnostic, adds behavioural long-line and dangerous-primitive-in-config rules, and scans pull requests. Also fixes two scanner bugs: excludes were expanded before includes (GNU grep then re-admits the excluded file), and rules piped grep into cut, taking the exit status from cut so they tripped on every run. Co-authored-by: Laura Norwood <204180622+Lanthanum89@users.noreply.github.com>
1 parent dc450ef commit 3edd8a5

1 file changed

Lines changed: 77 additions & 21 deletions

File tree

.github/workflows/malware-scan.yml

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: Malware Signature Scan
22

33
on:
44
push:
5+
pull_request:
56
schedule:
67
- cron: '0 6 * * *'
78
workflow_dispatch:
@@ -16,34 +17,86 @@ jobs:
1617
steps:
1718
- uses: actions/checkout@v4
1819

19-
- name: Scan for known malware signatures (A8-1662 / PolinRider incident)
20+
- name: Scan for malware signatures (A8-1662 / PolinRider incident)
2021
id: scan
2122
shell: bash
2223
run: |
2324
set +e
2425
FOUND=0
2526
26-
echo "== campaign marker / require shim =="
27-
if grep -rnE "A8-1662|A8-\*#new|global\['r'\]=require" --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
27+
SRC=( --include='*.js' --include='*.mjs' --include='*.cjs'
28+
--include='*.ts' --include='*.mts' --include='*.cts'
29+
--include='*.tsx' --include='*.jsx' --include='*.vue' --include='*.svelte'
30+
--include='*.json' --include='*.html' --include='*.yml' --include='*.yaml' --include='*.sh' )
31+
SKIP=( --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=build
32+
--exclude-dir=.git --exclude-dir=vendor --exclude-dir=coverage
33+
--exclude='*.min.js' --exclude='*.min.css' --exclude='*.map'
34+
--exclude='package-lock.json' --exclude='yarn.lock' --exclude='pnpm-lock.yaml'
35+
--exclude='malware-scan.yml' )
2836
29-
echo "== obfuscator shape =="
30-
if grep -rlE "_0x[0-9a-f]{4,}\(0x[0-9a-f]+\)" --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
37+
rule() {
38+
local label="$1" re="$2" out
39+
out=$(grep -rnE "$re" "${SRC[@]}" "${SKIP[@]}" . 2>/dev/null)
40+
echo "== $label =="
41+
if [ -n "$out" ]; then
42+
echo "$out" | cut -c1-200 | head -50
43+
FOUND=1
44+
fi
45+
}
3146
32-
echo "== whitespace-padding disguise trick =="
33-
if grep -rnE '\);? {200,}\S' --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' . ; then FOUND=1; fi
47+
rule "1. known campaign markers" \
48+
"A8-1662|A8-\*#new|global\['r'\] *= *require|global\.i *= *['\"]A8"
3449
35-
echo "== vscode auto-run triggers =="
36-
if [ -f .vscode/tasks.json ] && grep -qE "folderOpen" .vscode/tasks.json; then FOUND=1; fi
37-
if [ -f .vscode/settings.json ] && grep -qE "allowAutomaticTasks" .vscode/settings.json; then FOUND=1; fi
50+
# Rule 2: obfuscator string-array dispatch. Two guards against false positives:
51+
# the offset is 2-4 hex digits (a 6/8-digit literal is a colour, e.g.
52+
# THREE.Color(0x1a0a14)), and the call must repeat many times in one file --
53+
# real string-array dispatch appears dozens of times, a colour constant once.
54+
echo "== 2. obfuscator string-array dispatch (repeated) =="
55+
out=$(grep -roE "[A-Za-z_\$][A-Za-z0-9_\$]{2,}\(0x[0-9a-f]{2,4}\)" \
56+
"${SRC[@]}" "${SKIP[@]}" . 2>/dev/null \
57+
| awk -F: '{c[$1]++} END{for(f in c) if(c[f]>=8) printf "%s: %d dispatch-style calls\n", f, c[f]}')
58+
if [ -n "$out" ]; then echo "$out" | head -50; FOUND=1; fi
3859
39-
echo "== disguised binaries (font/image files that are actually text/JS) =="
40-
for f in $(git ls-files | grep -iE '\.(woff2?|ttf|otf|png|jpe?g|ico)$'); do
41-
if file "$f" | grep -qi 'text\|javascript'; then
42-
echo "DISGUISED: $f"
43-
FOUND=1
44-
fi
60+
# Rule 3: code pushed off-screen behind padding. Require what follows the
61+
# padding to look executable (identifier then call or assignment) -- generated
62+
# HTML such as Plotly's pads before markup like "<div id=", which is harmless.
63+
rule "3. whitespace-padding disguise" \
64+
" {120,}[A-Za-z_\$][A-Za-z0-9_\$]*[[:space:]]*[(=]"
65+
66+
# Rules 4a/4b replace the old ".{400,}" length rule. Length alone flags inline
67+
# SVG, data: URIs, minified vendor bundles and JSON data -- all normal. What
68+
# actually indicates a hidden payload is *encoding*, so look for that instead.
69+
rule "4a. long run of hex/unicode escapes (encoded string)" \
70+
"(\\\\x[0-9a-fA-F]{2}){8,}|(\\\\u[0-9a-fA-F]{4}){8,}"
71+
72+
# A base64 blob on its own is not suspicious -- Plotly stores chart data that
73+
# way, and so do inline images and fonts. It matters only when something
74+
# decodes and runs it, so require a decode/exec primitive around the blob.
75+
rule "4b. base64 blob fed to a decode/exec primitive" \
76+
"(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:]]*\("
77+
78+
echo "== 5. dangerous primitives in build/config files =="
79+
CFG=$(git ls-files 2>/dev/null | grep -E '(^|/)([A-Za-z0-9._-]*\.config\.(js|cjs|mjs|ts)|\.eslintrc[^/]*)$')
80+
if [ -n "$CFG" ]; then
81+
out=$(echo "$CFG" | tr '\n' '\0' | xargs -0 -r grep -nE \
82+
'\beval\(|new Function\(|child_process|createRequire|\batob\(|Buffer\.from\([^)]*base64' 2>/dev/null)
83+
if [ -n "$out" ]; then echo "$out" | cut -c1-200; FOUND=1; fi
84+
fi
85+
86+
echo "== 6. vscode auto-run triggers =="
87+
if [ -f .vscode/tasks.json ] && grep -qE "folderOpen" .vscode/tasks.json; then
88+
echo "VSCODE: tasks.json runs on folderOpen"; FOUND=1
89+
fi
90+
if [ -f .vscode/settings.json ] && grep -qE "allowAutomaticTasks" .vscode/settings.json; then
91+
echo "VSCODE: allowAutomaticTasks set"; FOUND=1
92+
fi
93+
94+
echo "== 7. disguised binaries (font/image files that are actually text/JS) =="
95+
for f in $(git ls-files 2>/dev/null | grep -iE '\.(woff2?|ttf|otf|png|jpe?g|ico|gif|webp)$'); do
96+
if file "$f" | grep -qiE 'text|javascript'; then echo "DISGUISED: $f"; FOUND=1; fi
4597
done
4698
99+
echo "RESULT FOUND=$FOUND"
47100
echo "found=$FOUND" >> "$GITHUB_OUTPUT"
48101
exit 0
49102
@@ -61,23 +114,26 @@ jobs:
61114
const title = `Malware signature detected — ${context.sha.substring(0,7)}`;
62115
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
63116
const body = [
64-
`Automated scan found a known malicious code signature (campaign A8-1662 / obfuscated payload / disguised binary) at commit ${context.sha}.`,
117+
`Automated scan found a malicious code signature (campaign A8-1662 / obfuscated payload / disguised binary) at commit ${context.sha}.`,
65118
'',
66119
`Run: ${runUrl}`,
67120
'',
68-
'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.'
121+
'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.'
69122
].join('\n');
70-
const { data: issues } = await github.rest.issues.listForRepo({
123+
const existing = await github.paginate(github.rest.issues.listForRepo, {
71124
owner: context.repo.owner,
72125
repo: context.repo.repo,
73-
state: 'open'
126+
state: 'open',
127+
per_page: 100
74128
});
75-
const dup = issues.find(i => i.title.startsWith('Malware signature detected'));
129+
const dup = existing.find(i => !i.pull_request && i.title.startsWith('Malware signature detected'));
76130
if (!dup) {
77131
await github.rest.issues.create({
78132
owner: context.repo.owner,
79133
repo: context.repo.repo,
80134
title,
81135
body
82136
});
137+
} else {
138+
core.info(`Alert issue already open: #${dup.number}`);
83139
}

0 commit comments

Comments
 (0)