A recruiter writes that they liked your GitHub. The role fits, the pay is 20 to 40 percent above market, the interview goes well, and then comes the take-home: clone the repo, run npm install, finish the TODO list in 48 hours.

That last step is the whole attack.

Why npm install is the dangerous part

When you install a package, npm can run scripts that the package author wrote: preinstall, install and postinstall. They are there for legitimate reasons, like compiling native code. They also run with your user's permissions, on your machine, before you have read a single line of the project.

In the fake recruiter pattern we document, the sequence is short:

  1. npm install triggers a postinstall hook.
  2. A payload hidden as an encoded string is decoded at runtime.
  3. The script looks through browser profiles and config folders, including wallet extensions and key files.
  4. What it finds is sent to a server the attacker controls.

You never see an error. The project may even build and run, so the test feels normal. The numbers vary by case, and the full write-ups are in our fake test project investigation and the deepfake interview one.

The tells are in the process, not the code

  • A deadline that leaves no time to read ("submit within 48 hours").
  • A "broken" camera, or a face that looks slightly off, on the video call.
  • A request to turn off your antivirus because it "flags our custom compiler". That is not a technical requirement.
  • A repo that is not under the company's own organization.

None of these prove anything alone. Together they are a reason to slow down.

Look first: a small audit script

This script does not run the project. It only reads files and prints what would execute on install, and what looks like hidden code. Save it as audit.js (it is also in a small MIT licensed repo with a self test: github.com/cryptostrapon/npm-install-audit):

#!/usr/bin/env node // npm-install-audit: look before you run. // Usage: node audit.js ./some-repo // Read only, no network, no dependencies. A clean result is not proof of safety. const fs = require("fs"); const path = require("path"); const target = process.argv[2] || "."; let root; try { root = path.resolve(target); if (!fs.statSync(root).isDirectory()) throw new Error("not a directory"); } catch (e) { console.error("Cannot read directory: " + target); process.exit(2); } const HOOKS = ["preinstall", "install", "postinstall", "prepare"]; const SKIP_DIRS = new Set([".git", "node_modules"]); const RISKY = [ [/child_process|execSync|execFileSync|spawnSync|\bspawn\(/, "runs shell commands"], [/\beval\(|new Function\(/, "evaluates code at runtime"], [/\batob\(|Buffer\.from\([^)]*base64/, "decodes base64 at runtime"], [/(curl|wget)\s[^\n]*\|\s*(sh|bash)/, "pipes a download into a shell"], [/Login Data|keystore|wallet\.dat|\.ssh[\/\\]|id_rsa|\.aws[\/\\]credentials|Local Storage[\/\\]leveldb/i, "touches key, wallet or browser data paths"], ]; let findings = 0; const flag = (file, msg) => { findings++; console.log("! " + path.relative(root, file) + ": " + msg); }; function checkPackage(file) { let pkg; try { pkg = JSON.parse(fs.readFileSync(file, "utf8")); } catch { flag(file, "package.json could not be parsed, read it by hand"); return; } for (const h of HOOKS) { if (pkg.scripts && pkg.scripts[h]) flag(file, h + " hook runs: " + pkg.scripts[h]); } } function checkSource(file) { let text; try { text = fs.readFileSync(file, "utf8"); } catch { return; } for (const [re, why] of RISKY) if (re.test(text)) flag(file, why); if (!/\.min\.js$/.test(file)) { const blob = text.match(/[A-Za-z0-9+\/=]{300,}/); if (blob) flag(file, "long encoded string (" + blob[0].length + " chars)"); } } function walk(dir, depth) { if (depth > 8) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (SKIP_DIRS.has(e.name)) continue; const p = path.join(dir, e.name); if (e.isSymbolicLink()) continue; if (e.isDirectory()) { walk(p, depth + 1); continue; } if (!e.isFile()) continue; if (e.name === "package.json") { checkPackage(p); continue; } if (/\.(js|cjs|mjs|sh)$/.test(e.name)) { let size = 0; try { size = fs.statSync(p).size; } catch { continue; } if (size < 2e6) checkSource(p); } } } walk(root, 0); if (findings) { console.log("\n" + findings + " finding(s). Read them before you run npm install."); process.exit(1); } console.log("No obvious install hooks or risky patterns found. Still read the code."); 

Run it on the cloned folder:

git clone <repo> task && node audit.js ./task 

On a repo with a postinstall that decodes a base64 payload and reads ~/.ssh/id_rsa, it prints:

! package.json: postinstall hook runs: node ./scripts/setup.js ! scripts/setup.js: runs shell commands ! scripts/setup.js: decodes base64 at runtime ! scripts/setup.js: touches key, wallet or browser data paths ! scripts/setup.js: long encoded string (380 chars) 5 finding(s). Read them before you run npm install. 

This is a first filter, not a verdict. A clean result does not make a repo safe, and a hit is not always malware, since real projects use these hooks too. If it flags something you do not understand, that is your answer.

A safer habit

  • Install with npm install --ignore-scripts first, so hooks do not run, and read before you enable anything.
  • Open unknown projects in a throwaway VM or container, never on the machine that holds your wallets and browser sessions.
  • If the company is real, check the job on their own careers page and write to a recruiter there.
  • Keep wallet keys and signed-in browser profiles off the machine you use for take-home tasks.

Check the recruiter message too

The code is only half of it. Most of the tells from the process section above show up in the message that arrives before the repo.

We built a free AI scam detector (our project, disclosed here because it is ours) that takes a pasted message, a link, a wallet address or a screenshot. Paste the recruiter's message or the screenshot of the chat and it lists the red flags it finds. It reads text for patterns. It cannot confirm that a real person is behind the account, so treat it like the script above, a first filter that tells you when to slow down.

Question for the comments: has a recruiter ever sent you a take-home that made you stop? What was the tell?