Skip to content
Merged
Show file tree
Hide file tree
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
188 changes: 188 additions & 0 deletions .github/scripts/malware_scan.py
Original file line number Diff line number Diff line change
@@ -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 <reason>` 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'(?<![\w.])eval\s*\(|new\s+Function\s*\('), False),
('WARN', 'long-encoded-blob', re.compile(r'["\'`][A-Za-z0-9+/]{400,}={0,2}["\'`]|(?:\\x[0-9a-fA-F]{2}){60,}'), False),
('WARN', 'child-process', re.compile(r'require\(\s*["\']child_process["\']\)|from\s+["\'](?:node:)?child_process["\']'), False),
]


def tracked_files():
out = subprocess.run(['git', 'ls-files', '-z'], capture_output=True, check=True).stdout
return [p for p in out.decode().split('\0') if p]


def load_allow():
path = os.path.join(ROOT, '.github', 'malware-scan-allow.txt')
if not os.path.exists(path):
return []
globs = []
for line in open(path, encoding='utf-8'):
line = line.split('#', 1)[0].strip()
if line:
globs.append(line)
return globs


def is_doc(path):
return path.endswith(('.md', '.mdx', '.txt', '.rst', '.adoc'))


def main():
allow = load_allow()
findings = []

def add(sev, rule, path, line, msg):
findings.append({'severity': sev, 'rule': rule, 'path': path, 'line': line, 'message': msg[:200]})

for path in tracked_files():
if any(path.startswith(d) or f'/{d}' in path for d in SKIP_DIRS):
continue
if path == SELF or any(fnmatch.fnmatch(path, g) for g in allow):
continue
full = os.path.join(ROOT, path)
if not os.path.isfile(full) or os.path.islink(full):
continue
size = os.path.getsize(full)
name = os.path.basename(path)
with open(full, 'rb') as fh:
head = fh.read(4096)
if path.lower().endswith(BINARY_EXT) or head[:4] == b'\x7fELF' or head[:2] == b'MZ' \
or head[:4] in (b'\xcf\xfa\xed\xfe', b'\xca\xfe\xba\xbe'):
add('HIGH', 'native-binary', path, 0, 'committed native executable/library')
continue
if b'\0' in head or size > 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())
90 changes: 90 additions & 0 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
@@ -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
Loading