Node.js 腳本如何在執行求職編程測試前掃描程式碼庫
Read the take-home test before you run it
文章提供 Node.js 腳本 `audit-take-home.mjs`,讓用戶在安裝依賴或執行求職編程測試前,靜態檢查程式碼庫中的自動執行設定與可疑程式碼特徵。
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 installruns the project'spreinstall,install,postinstallandpreparescripts 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 auser/reposhorthand inpackage.jsonpulls a dependency straight from a Git repository, outside the npm registry. - Then there's the code you're asked to run. Tests,
npm startand 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_modulesand.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_processfor 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-scriptswhen 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
來源:DEV Community · dev.to