Malware Signature Scan #9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Malware Signature Scan | |
| on: | |
| push: | |
| pull_request: | |
| schedule: | |
| - cron: '0 6 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| jobs: | |
| scan: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Scan for malware signatures (A8-1662 / PolinRider incident) | |
| id: scan | |
| shell: bash | |
| run: | | |
| set +e | |
| FOUND=0 | |
| 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' ) | |
| 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 | |
| } | |
| rule "1. known campaign markers" \ | |
| "A8-1662|A8-\*#new|global\['r'\] *= *require|global\.i *= *['\"]A8" | |
| # 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 | |
| # 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 | |
| - name: Fail the check if a signature was found | |
| if: steps.scan.outputs.found == '1' | |
| run: | | |
| echo "::error::Malware signature detected in this repository. See the scan step output above. Do not trust removal commit messages -- read file content directly." | |
| exit 1 | |
| - name: Open an alert issue | |
| if: failure() && steps.scan.outputs.found == '1' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| 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 malicious code signature (campaign A8-1662 / obfuscated payload / disguised binary) at commit ${context.sha}.`, | |
| '', | |
| `Run: ${runUrl}`, | |
| '', | |
| '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 existing = await github.paginate(github.rest.issues.listForRepo, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| per_page: 100 | |
| }); | |
| 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}`); | |
| } |