@@ -2,6 +2,7 @@ name: Malware Signature Scan
22
33on :
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