diff --git a/.github/workflows/malware-guard.yml b/.github/workflows/malware-guard.yml index ba556a3..c84282b 100644 --- a/.github/workflows/malware-guard.yml +++ b/.github/workflows/malware-guard.yml @@ -1,33 +1,140 @@ -# Blocks the obfuscated-payload worm seen in Drips Wave repos (Sep-Oct 2026): -# code hidden after long runs of whitespace in config files, plus -# branch_structure.json / temp_*_push.bat entries added to .gitignore. name: Malware guard on: pull_request: push: - branches: [main] + workflow_dispatch: permissions: contents: read jobs: scan: + name: Malware guard runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - name: Check out source without persisted credentials + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false - - name: Scan for known payload signatures + - name: Inspect tracked Git objects without executing repository code + shell: bash run: | - set -u - fail=0 - check() { - hits=$(git grep -nIP "$1" -- "${@:2}" ':!.github/workflows/malware-guard.yml' ':!*lock*' ':!*.min.js' | cut -c1-200) - if [ -n "$hits" ]; then echo "::error::$1"; echo "$hits"; fail=1; fi - } - check '\S[ \t]{150,}\S|^[ \t]{150,}\S' '*.js' '*.cjs' '*.mjs' '*.ts' '*.jsx' '*.tsx' - check "global\.i\s*=\s*'|global\[['\"]!['\"]\]" '*.js' '*.cjs' '*.mjs' '*.ts' '*.jsx' '*.tsx' - check 'temp_auto_push|temp_interactive_push|branch_structure\.json' '.gitignore' '**/.gitignore' - check '"runOn"\s*:\s*"folderOpen"' '.vscode/*' - exit $fail + python3 -I - <<'PY' + """Dependency-free checks for the Drips/Wave malware delivery patterns.""" + import json + from pathlib import PurePosixPath + import re + import subprocess + import sys + + MARKERS = (b'rmcej%otb%', b'Cot%3T=sthP', b'Cot%3t=shtP', b'_$__1e42', b'_$_1e42') + SUSPICIOUS = {'fa-solid-500.woff2', 'fa-solid-600.eot', 'fa-solid.lik', + 'fa-solid-300.ttf', 'fa-solid-300.llf', 'branch_structure.json', + 'temp_auto_push.bat', 'temp_interactive_push.bat'} + EXECUTABLE = {'.js', '.cjs', '.mjs', '.ts', '.jsx', '.tsx', '.ps1', '.sh', '.bat'} + FONTS = {'.woff', '.woff2', '.ttf', '.eot', '.otf', '.lik', '.llf'} + + def check_file(name, data): + path = PurePosixPath(name.lower()) + hits = [] + if path.name in SUSPICIOUS or name.lower().endswith('dist/setup.js'): + hits.append('Known suspicious payload or propagation filename') + lowercase = data.lower() + if any(marker.lower() in lowercase for marker in MARKERS): + hits.append('Known obfuscated-loader marker') + text = data.decode('utf-8', 'replace') + if path.suffix in FONTS and re.search(r'\b(?:require\s*\(|global\s*[.\[]|child_process|function\s*\()', text): + hits.append('JavaScript stored in a font-like file') + if path.suffix in EXECUTABLE | FONTS: + for match in re.finditer(r'[ \t]{150,}', text): + if match.end() < len(text) and not text[match.end()].isspace(): + hits.append('Code hidden after unusually long whitespace') + break + if re.search(r"global\s{0,64}(?:\.\s{0,64}i\s{0,64}=|\[\s{0,64}['\"](?:!|_V)['\"]\s{0,64}\])", text): + hits.append('Known injected-global loader pattern') + if path.name == 'tasks.json' and path.parent.name == '.vscode': + if re.search(r'"runOn"\s*:\s*"folderOpen"', text): + hits.append('VS Code task executes automatically on folder open') + if path.name == 'settings.json' and path.parent.name == '.vscode': + if re.search(r'"task\.allowAutomaticTasks"\s*:\s*"on"', text): + hits.append('VS Code automatic tasks explicitly enabled') + if name.startswith('.github/workflows/'): + if 'DORIS_PAT' in text or ('pull_request_target' in text and re.search(r'gh\s+pr\s+(?:merge|review)', text)): + hits.append('Unsafe privileged PR approval or merge automation') + if path.name == 'package.json': + try: + scripts = json.loads(text).get('scripts', {}) + except (ValueError, AttributeError): + hits.append('Package manifest cannot be parsed for inspection') + scripts = {} + if not isinstance(scripts, dict): + hits.append('Package scripts must be an object') + scripts = {} + for script, command in scripts.items(): + if not isinstance(command, str): + hits.append('Package command must be a string') + continue + if re.search(r'@p+enapitools/openapi-generator-cli', command): + hits.append(f'Misspelled OpenAPI package namespace: {script}') + if re.search(r'(?:dist[/\\]setup\.js|fa-solid-[^\s]+|\b(?:curl|wget)\b[^\n]*\|\s*(?:ba)?sh\b)', command): + hits.append(f'Suspicious package entry point: {script}') + if script in {'start', 'dev', 'prestart', 'predev'} and re.search(r'\bapi\.js\b', command): + hits.append(f'API loader entry point requires security review: {script}') + return hits + + def scan_git(revision='HEAD'): + listing = subprocess.check_output(['git', 'ls-tree', '-r', '-z', revision]) + objects = [] + for record in listing.split(b'\0'): + if not record: + continue + info, raw_name = record.split(b'\t', 1) + mode, kind, oid = info.split() + if kind == b'blob': + name = raw_name.decode('utf-8', 'replace') + # This trusted inline scanner necessarily contains its own signatures. + if name != '.github/workflows/malware-guard.yml': + objects.append((name, oid)) + proc = subprocess.Popen(['git', 'cat-file', '--batch'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) + errors = [] + try: + for name, oid in objects: + proc.stdin.write(oid + b'\n') + proc.stdin.flush() + header = proc.stdout.readline().split() + if len(header) != 3 or header[1] != b'blob': + raise RuntimeError('Git blob could not be inspected') + size = int(header[2]) + data = proc.stdout.read(size) + if len(data) != size or proc.stdout.read(1) != b'\n': + raise RuntimeError('Incomplete Git blob read') + errors.extend(f'{name}: {hit}' for hit in check_file(name, data)) + finally: + proc.stdin.close() + proc.stdout.close() + code = proc.wait() + if code: + raise RuntimeError('Git blob reader failed') + for error in errors: + # Do not echo untrusted file content or GitHub Actions command syntax. + print(json.dumps(error, ensure_ascii=True)) + print(f'Inspected {len(objects)} tracked files; {len(errors)} malware-indicator findings.') + return 1 if errors else 0 + + def self_test(): + for marker in MARKERS: + assert check_file('config.cjs', marker), 'Marker detection failed' + assert check_file('.vscode/tasks.json', b'{"runOn":"folderOpen"}') + assert check_file('.vscode/settings.json', b'{"task.allowAutomaticTasks":"on"}') + assert check_file('public/custom.woff2', b"require('child_process')") + assert check_file('package.json', b'{"scripts":{"preinstall":"node dist/setup.js"}}') + assert check_file('config.js', b'module.exports={};' + b' '*200 + b'global.i="x"') + assert not check_file('package.json', b'{"scripts":{"dev":"next dev"}}') + assert not check_file('config.js', b'module.exports = {};\n') + + if __name__ == '__main__': + self_test() + sys.exit(scan_git()) + PY