On November 3, Binance published its first incident report on the October 29 breach, in which more than $6.3 billion was stolen from its wallets. According to The Block's summary, one step in the chain will be familiar to anyone who has looked for a job in tech. In September, attackers posing as company executives ran a fake recruitment process and sent an employee a coding assignment. He downloaded it, and it infected his work computer. Weeks later that computer was the starting point for the wallet attack.

A take-home test asks you to clone a stranger's repository, install its dependencies and run it, which is also what an attacker needs you to do. This post looks at where a repo can run code on your machine before you have read it, and walks through a small Node.js script that checks for those spots without executing anything.

Where a repo can run code on your machine

  • In a project folder, npm install runs the project's preinstall, install, postinstall and prepare scripts by itself, and dependencies can run their own install scripts too (npm docs).
  • A VS Code task with "runOptions": { "runOn": "folderOpen" } starts when the folder is opened, once automatic tasks are allowed (VS Code tasks). Workspace Trust keeps tasks from running in Restricted Mode (Workspace Trust).
  • A github: URL or a user/repo shorthand in package.json pulls a dependency straight from a Git repository, outside the npm registry.
  • Then there's the code you're asked to run. Tests, npm start and helper scripts run with your permissions like anything else.

The script

audit-take-home.mjs reads files and nothing else. It looks for install hooks, editor tasks that run on open, non-registry dependencies, native binaries, and a few source patterns that deserve a closer look: code built from strings, child processes, base64 decoding, raw IP addresses, paths to SSH keys or wallet files, and very long lines that often mean minified or obfuscated code.

#!/usr/bin/env node // Static red-flag scan for a take-home coding test, before you install or run anything. // It only reads files. It never runs npm, node, python or any script from the repo. // usage: node audit-take-home.mjs <path-to-repo> import { readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, extname, join, relative, sep } from 'node:path'; const root = process.argv[2]; if (!root) { console.error('usage: node audit-take-home.mjs <path-to-repo>'); process.exit(2); } const SKIP_DIRS = new Set(['.git', 'node_modules']); const CODE_EXT = new Set(['.js', '.cjs', '.mjs', '.ts', '.jsx', '.tsx', '.py', '.sh', '.ps1', '.bat', '.cmd']); // scripts npm runs by itself during `npm install` in a project folder const INSTALL_HOOKS = ['preinstall', 'install', 'postinstall', 'prepublish', 'preprepare', 'prepare', 'postprepare']; const CODE_PATTERNS = [ ['builds code from strings', /\beval\s*\(|new\s+Function\s*\(/], ['starts other processes', /child_process|\bexecSync\s*\(|\bspawn\s*\(|subprocess\.|os\.system\s*\(/], ['decodes base64', /\batob\s*\(|Buffer\.from\([^)]*['"]base64['"]|b64decode\s*\(/], ['talks to a raw IP address', /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}/], ['touches keys, wallets or browser data', /\.ssh[\/]|id_rsa|id_ed25519|Login Data|Local State|wallet\.dat|keychain|\.npmrc|Exodus|MetaMask/i], ]; const outsideRegistry = (v) => /^(git\+|git:|github:|gitlab:|bitbucket:|https?:|file:)/.test(v) || /^[\w.-]+\/[\w.-]+(#.*)?$/.test(v); const findings = []; const flag = (file, what, detail) => findings.push({ file: (relative(root, file) || basename(file)).split(sep).join('/'), what, detail }); function checkPackageJson(file) { let pkg; try { pkg = JSON.parse(readFileSync(file, 'utf8')); } catch { return flag(file, 'package.json does not parse', ''); } for (const hook of INSTALL_HOOKS) if (pkg.scripts?.[hook]) flag(file, `npm install runs "${hook}"`, pkg.scripts[hook]); const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies }; for (const [name, spec] of Object.entries(deps)) if (outsideRegistry(String(spec))) flag(file, 'dependency from outside the npm registry', `${name}: ${spec}`); } function checkVsCodeTasks(file) { if (/"runOn"\s*:\s*"folderOpen"/.test(readFileSync(file, 'utf8'))) flag(file, 'VS Code task runs when the folder is opened', 'runOptions.runOn: "folderOpen"'); } function checkSource(file) { const src = readFileSync(file, 'utf8'); for (const [what, re] of CODE_PATTERNS) { const m = src.match(re); if (m) flag(file, what, m[0].slice(0, 60)); } const longest = src.split('\n').reduce((n, line) => Math.max(n, line.length), 0); if (longest > 2000) flag(file, 'very long line, often minified or obfuscated', `${longest} characters`); } function walk(dir) { for (const name of readdirSync(dir)) { if (SKIP_DIRS.has(name)) continue; const path = join(dir, name); const st = statSync(path); if (st.isDirectory()) { walk(path); continue; } if (name === 'package.json') checkPackageJson(path); if (name === 'tasks.json' && basename(dir) === '.vscode') checkVsCodeTasks(path); if (/\.(node|exe|dll|so|dylib)$/i.test(name)) flag(path, 'ships a native binary', `${st.size} bytes`); if (CODE_EXT.has(extname(name).toLowerCase()) && st.size < 2_000_000) checkSource(path); } } walk(root); if (!findings.length) { console.log('No red flags found. That is not proof the code is safe: still run it in a throwaway machine.'); process.exit(0); } const byFile = Map.groupBy ? Map.groupBy(findings, (f) => f.file) : findings.reduce((m, f) => m.set(f.file, [...(m.get(f.file) ?? []), f]), new Map()); console.log(`${findings.length} red flag(s) in ${byFile.size} file(s):\n`); for (const [file, list] of byFile) { console.log(file); for (const f of list) console.log(` - ${f.what}${f.detail ? `: ${f.detail}` : ''}`); } console.log('\nDo not run npm install, the tests or the editor tasks on a machine that holds credentials.'); process.exit(1); 

A few notes on the choices:

  • It skips node_modules and .git. A take-home repo shouldn't ship either, and if it does, that is worth a look on its own.
  • The patterns are deliberately loose. A real project may use child_process for good reasons, so each hit is a pointer to a file to read, and a hit doesn't mean the file is malicious.
  • It exits with code 1 when it finds anything, so you can put it in a script or a pre-commit hook for reviewers.

Running it on a sample

The sample repo next to the script has a postinstall hook, a dependency pulled from GitHub, a VS Code task set to run on folder open, and an install script that decodes a base64 string and passes it to a shell command. The script itself is harmless and only prints "hello", but it has the same shape as the real thing.

node audit-take-home.mjs sample-take-home 
5 red flag(s) in 3 file(s): .vscode/tasks.json - VS Code task runs when the folder is opened: runOptions.runOn: "folderOpen" package.json - npm install runs "postinstall": node scripts/setup.js - dependency from outside the npm registry: helpers: github:example/helpers scripts/setup.js - starts other processes: child_process - decodes base64: Buffer.from('aGVsbG8=', 'base64' Do not run npm install, the tests or the editor tasks on a machine that holds credentials. 

Pointed at the src folder alone, which only holds the exercise code, it prints No red flags found. and exits with 0.

What it doesn't catch

Static checks are a first look. Obfuscated code can avoid every pattern here, a dependency's own install script only exists after you download it, and a malicious test file looks like any other test file. A clean result means nothing obvious turned up. Run the code somewhere disposable anyway:

  • a virtual machine, a container or a cloud development environment with no SSH keys, cloud credentials, browser sessions or wallets;
  • npm install --ignore-scripts when you need dependencies without running their hooks;
  • VS Code in Restricted Mode until you've read the repository.

In the Binance case the infected machine was a work computer, and that gave the attackers a path to colleagues and, eventually, to a container deployment permission. A throwaway environment would have stopped them at the first step.

Sources