From 0f9aa42c1ed879dcb980a9fb7375c216a226c206 Mon Sep 17 00:00:00 2001 From: mallison031 Date: Tue, 20 Oct 2026 11:38:27 +0100 Subject: [PATCH] ci: add supply-chain malware scan Scan every tracked file for supply-chain malware indicators (invisible-Unicode payloads, decode-and-eval loaders, install-hook droppers, credential harvesting, exfiltration endpoints, VS Code auto-run tasks, pull_request_target checkouts, committed native binaries) and check lockfiles with OSV-Scanner, failing on known-malicious (MAL-) packages. Runs on pull_request, pushes to main, weekly, and on demand, with a read-only token. --- .github/scripts/malware_scan.py | 188 +++++++++++++++++++++++++++++ .github/workflows/malware-scan.yml | 90 ++++++++++++++ 2 files changed, 278 insertions(+) create mode 100755 .github/scripts/malware_scan.py create mode 100644 .github/workflows/malware-scan.yml diff --git a/.github/scripts/malware_scan.py b/.github/scripts/malware_scan.py new file mode 100755 index 0000000..b3c6d99 --- /dev/null +++ b/.github/scripts/malware_scan.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Supply-chain malware scan for this repository. + +Scans every tracked text file for indicators used by recent open-source +supply-chain attacks (GlassWorm-style invisible-Unicode payloads, obfuscated +loaders, install-hook droppers, credential harvesting and exfiltration +endpoints, VS Code auto-run tasks, unsafe workflow triggers, committed native +binaries). + +HIGH findings fail the job; WARN findings are reported as annotations only. + +Suppress a reviewed false positive by putting `malware-scan: allow ` on +the same line, or by listing a path glob in .github/malware-scan-allow.txt +(one glob per line, `#` comments allowed). +""" +import fnmatch +import json +import os +import re +import subprocess +import sys + +ROOT = os.getcwd() +MAX_BYTES = 5_000_000 +SKIP_DIRS = ('node_modules/', 'target/', 'dist/', '.next/', 'vendor/') +LOCKFILES = ('package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock', 'bun.lockb') +CODE_EXT = ('.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.rs', '.py', '.sh', '.ps1', '.bat', + '.cmd', '.toml', '.yml', '.yaml', '.json') +BINARY_EXT = ('.exe', '.dll', '.so', '.dylib', '.node', '.bin', '.jar', '.scr', '.msi', '.deb', + '.rpm', '.apk', '.pyc', '.class') +SUPPRESS = 'malware-scan: allow' +# This scanner's own rule table matches its own patterns. +SELF = '.github/scripts/malware_scan.py' + +# Characters with no business in source: bidi overrides (Trojan Source), Unicode +# tag characters, the variation-selector supplement and supplementary private-use +# planes (GlassWorm hides its payload in these). +INVISIBLE_HIGH = re.compile('[‪-‮⁦-⁩\U000e0000-\U000e007f' + '\U000e0100-\U000e01ef\U000f0000-\U0010ffff]') +# Zero-width characters and BMP variation selectors are legitimate one at a time +# (emoji sequences), but a run of them is an encoded payload. +INVISIBLE_RUN = re.compile('[​-‍⁠︀-️]{3,}') + +LINE_RULES = [ + # (severity, id, regex, applies_to_docs) + ('HIGH', 'exfil-endpoint', re.compile( + r'discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{60,}|api\.telegram\.org/bot\d{6,}:[\w-]{30,}|webhook\.site/|' + r'\.pipedream\.net|requestbin\.|\binteract\.sh\b|\.oast\.(?:fun|me|pro|live|site|online)|' + r'burpcollaborator\.net|transfer\.sh/|pastebin\.com/raw|paste\.ee/r/|ngrok-free\.app', re.I), True), + ('HIGH', 'decode-and-eval', re.compile( + r'\b(?:eval|Function)\s*\(\s*(?:atob|Buffer\.from|unescape|decodeURIComponent|' + r'String\.fromCharCode)\s*\(|new\s+Function\s*\([^)]*(?:atob|Buffer\.from|fromCharCode)'), False), + ('HIGH', 'obfuscator-identifiers', re.compile(r'(?:_0x[0-9a-f]{4,}.*){3,}'), False), + ('HIGH', 'credential-harvest', re.compile( + r'\.ssh/id_(?:rsa|ed25519|ecdsa)|\.aws/credentials|\.config/solana/id\.json|wallet\.dat|' + r'Login Data|Local Storage/leveldb|\.npmrc[\'"]\s*\)|JSON\.stringify\(\s*process\.env\s*\)|' + r'keychain-db|exodus\.wallet|Electrum/wallets'), False), + ('HIGH', 'blockchain-c2', re.compile( + r'api\.trongrid\.io|tronscan\.org|bsc-dataseed|getSignaturesForAddress\s*\('), False), + ('HIGH', 'workflow-secret-dump', re.compile(r'toJSON\(\s*secrets\s*\)|secrets\.[A-Z0-9_]+[^\n]*\|\s*base64'), False), + ('WARN', 'pipe-to-shell', re.compile(r'(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b|iwr\s[^\n|]*\|\s*iex|Invoke-Expression'), False), + ('WARN', 'dynamic-eval', re.compile(r'(? MAX_BYTES or name in LOCKFILES: + continue + text = open(full, encoding='utf-8', errors='replace').read() + doc = is_doc(path) + + # File-level rules. + if name == 'package.json': + try: + scripts = json.loads(text).get('scripts', {}) or {} + except (ValueError, AttributeError): + # Unparseable manifests still get checked: npm may accept what + # json.loads rejects, and a broken file must not hide a hook. + scripts = dict(re.findall(r'"((?:pre|post)?(?:un)?install|prepare)"\s*:\s*"((?:[^"\\]|\\.)*)"', text)) + for hook in ('preinstall', 'install', 'postinstall', 'prepare', 'preuninstall', 'postuninstall'): + cmd = scripts.get(hook) + if not cmd: + continue + if re.search(r'curl|wget|https?://|node\s+-e|base64|powershell|\|\s*sh', cmd): + add('HIGH', 'install-hook-dropper', path, 0, f'"{hook}": {cmd}') + elif hook != 'prepare': + add('WARN', 'install-hook', path, 0, f'"{hook}": {cmd}') + if path.endswith('.vscode/tasks.json') and re.search(r'"runOn"\s*:\s*"folderOpen"', text): + add('HIGH', 'vscode-autorun-task', path, 0, 'task runs automatically when the folder is opened') + if path.startswith('.github/workflows/') and 'pull_request_target' in text and \ + re.search(r'github\.event\.pull_request\.head\.(?:sha|ref)|refs/pull/', text): + add('HIGH', 'pwn-request', path, 0, 'pull_request_target workflow checks out untrusted PR code') + if name == 'build.rs' and re.search(r'TcpStream|reqwest|ureq::|curl|wget|Command::new\(\s*"(?:sh|bash|powershell|cmd)"', text): + add('HIGH', 'build-script-network', path, 0, 'build.rs spawns a shell or opens a network connection') + + for i, line in enumerate(text.splitlines(), 1): + if SUPPRESS in line: + continue + m = INVISIBLE_HIGH.search(line) or INVISIBLE_RUN.search(line.lstrip('') if i == 1 else line) + if m: + cps = ' '.join(f'U+{ord(c):04X}' for c in m.group(0)[:6]) + add('HIGH', 'invisible-unicode', path, i, f'hidden characters {cps}') + for sev, rule, rx, docs_too in LINE_RULES: + if doc and not docs_too: + continue + if sev == 'WARN' and ((not doc and not path.endswith(CODE_EXT)) + or 'test_snapshots/' in path or '/fixtures/' in path): + continue + if rx.search(line): + add(sev, rule, path, i, line.strip()) + + high = [f for f in findings if f['severity'] == 'HIGH'] + warn = [f for f in findings if f['severity'] == 'WARN'] + base = os.environ.get('MALWARE_SCAN_BASE') + if base: + # On pull requests, only warn about files the PR touches; HIGH findings + # still cover the whole tree. + diff = subprocess.run(['git', 'diff', '--name-only', f'{base}...HEAD'], + capture_output=True, text=True) + if diff.returncode == 0: + touched = set(diff.stdout.split()) + warn = [f for f in warn if f['path'] in touched] + gh = os.environ.get('GITHUB_ACTIONS') == 'true' + for f in high + warn[:50]: + loc = f"{f['path']}:{f['line']}" if f['line'] else f['path'] + if gh: + level = 'error' if f['severity'] == 'HIGH' else 'warning' + line_part = f",line={f['line']}" if f['line'] else '' + msg = f"[{f['rule']}] {f['message']}".replace('%', '%25').replace('\r', '').replace('\n', ' ') + print(f"::{level} file={f['path']}{line_part}::{msg}") + else: + print(f"{f['severity']:4} {f['rule']:24} {loc} {f['message']}") + print(f'malware scan: {len(high)} high, {len(warn)} warning finding(s)') + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as fh: + fh.write(f'### Malware scan\n\n{len(high)} high, {len(warn)} warning finding(s).\n\n') + for f in high: + fh.write(f"- **{f['rule']}** `{f['path']}:{f['line']}` {f['message']}\n") + return 1 if high else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml new file mode 100644 index 0000000..ddfba63 --- /dev/null +++ b/.github/workflows/malware-scan.yml @@ -0,0 +1,90 @@ +name: Malware scan + +# Supply-chain malware gate. Two jobs: +# - indicators: .github/scripts/malware_scan.py scans every tracked file for +# invisible-Unicode payloads, decode-and-eval loaders, install-hook +# droppers, credential harvesting, exfiltration endpoints, VS Code auto-run +# tasks, pull_request_target checkouts and committed native binaries. +# - dependencies: OSV-Scanner checks every lockfile; the job fails on +# known-malicious packages (MAL- advisories) and reports other +# vulnerabilities as warnings. +# +# Runs on `pull_request` (never `pull_request_target`) with a read-only token, +# so a PR under scan cannot reach secrets or write to the repository. + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: malware-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + indicators: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Scan for malware indicators + env: + MALWARE_SCAN_BASE: ${{ github.event.pull_request.base.sha }} + run: python3 -I .github/scripts/malware_scan.py + + dependencies: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run OSV-Scanner + id: osv + continue-on-error: true + uses: google/osv-scanner-action/osv-scanner-action@b1d267aea5211fa648d9d442fa64b90dbdcbdd36 # v2.7.0 + with: + scan-args: |- + --format=json + --output=osv-results.json + --recursive + ./ + - name: Fail on known-malicious packages + run: | + python3 -I - <<'EOF' + import json, os, sys + path = 'osv-results.json' + if not os.path.exists(path) or os.path.getsize(path) == 0: + print('OSV-Scanner found no supported lockfiles; nothing to check.') + sys.exit(0) + data = json.load(open(path)) + malicious, vulnerable = [], [] + for result in data.get('results', []): + source = result.get('source', {}).get('path', '?') + for pkg in result.get('packages', []): + info = pkg.get('package', {}) + name = f"{info.get('ecosystem', '?')}/{info.get('name', '?')}@{info.get('version', '?')}" + for vuln in pkg.get('vulnerabilities', []): + ids = [vuln.get('id', '')] + vuln.get('aliases', []) + entry = (name, vuln.get('id', '?'), source) + (malicious if any(i.startswith('MAL-') for i in ids) else vulnerable).append(entry) + for name, vid, source in malicious: + print(f'::error file={source}::known-malicious package {name} ({vid})') + for name, vid, source in sorted(set(vulnerable))[:50]: + print(f'::warning file={source}::vulnerable dependency {name} ({vid})') + print(f'{len(malicious)} malicious, {len(vulnerable)} vulnerable dependency finding(s)') + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as fh: + fh.write(f'### Dependency scan\n\n{len(malicious)} known-malicious, {len(vulnerable)} vulnerable.\n') + for name, vid, source in malicious: + fh.write(f'- **{vid}** `{name}` in `{source}`\n') + sys.exit(1 if malicious else 0) + EOF