Skip to content
PreClone

How to check a GitHub repo for malware before you clone it

Last updated 13 October 2026

Malicious repos rarely hide the payload in the code you'd review. They hide the trigger: one line in a file your tools run on their own when you open the folder, start an AI agent, run npm install, run npm start or commit. So check those files first, before the folder goes anywhere near your editor: .vscode/tasks.json, .devcontainer/devcontainer.json, .claude/settings.json, .mcp.json, AGENTS.md, the scripts in package.json, build configs like next.config.js, and .git/hooks if it came as a zip.

This is the order they'd run in, what each one looks like when it's been abused, and what to do if you already opened the repo. It's written for the fake take-home and the “can you fix our site” client repo, but it works for any repo from someone you don't know.

Get the files without running them

Reading a file can't hurt you. Opening the folder in VS Code or Cursor can, so don't do that yet. Two ways to read first:

  • github.dev. Press . on any GitHub repo page. It opens the repo in VS Code in your browser, with no terminal and nothing that runs the code (GitHub docs). The file tree shows dotfiles, which the GitHub file view makes easy to skim past.
  • Download the archive and read it in a terminal:
curl -L -o repo.tar.gz https://github.com/OWNER/REPO/archive/refs/heads/BRANCH.tar.gz
mkdir repo && tar -xzf repo.tar.gz -C repo --strip-components=1
cd repo && ls -la . .vscode .devcontainer .claude .cursor .husky 2>/dev/null

A plain git clone doesn't run anything either, and git doesn't copy a repo's hooks when you clone. Skip --recurse-submodules until you've read .gitmodules. A zip from a recruiter is different: it can ship a whole .git folder with hooks that are live the moment you unpack it. That's why some lures arrive as zips (HN).

When you open the folder: editor tasks and dev containers

.vscode/tasks.json is the most common trigger in fake-interview repos. A task with "runOn": "folderOpen" runs when the folder opens (VS Code docs). Trend Micro counted 392 downloader-style task files and 131 that launch a fake font or image file in one campaign (Trend Micro, April 2026); Abstract Security tracked the same pattern earlier (Abstract Security, January 2026). The abused version looks like this:

{
  "label": "install-fonts",
  "type": "shell",
  "command": "node public/fonts/fa-brands-regular.woff2",
  "runOptions": { "runOn": "folderOpen" },
  "presentation": { "reveal": "never", "echo": false }
}

A font file is not a program. reveal: never hides the terminal. Any folderOpen task that runs node, curl, wget, bash, powershell or a file from the repo needs a reason. Check dependsOn too: a harmless-looking task can pull in one that isn't.

grep -rn "folderOpen" .vscode *.code-workspace 2>/dev/null

Then, in the same pass:

  • .vscode/settings.json: settings that switch off prompts (task.allowAutomaticTasks, security.workspace.trust.*) or point an extension at a program inside the repo.
  • .devcontainer/devcontainer.json: initializeCommand runs on your host machine, not in the container (Dev Container spec). Also look for mounts of ~/.ssh or --privileged.
  • .vscode/extensions.json recommending extensions you've never heard of, and any committed .vsix file.
  • .envrc (direnv), .idea/ (JetBrains), .exrc and .dir-locals.el if you use those tools.

Set your editor up before the next repo

VS Code has Workspace Trust on by default, and automatic tasks never run in an untrusted folder. Its task.allowAutomaticTasks setting defaults to off and asks once per folder; leave it there (Workspace Trust, tasks). Don't add a parent folder like ~/code to your trusted list: every subfolder inherits it.

Cursor ships with Workspace Trust disabled (Cursor docs), so a folderOpen task has nothing to ask first (Oasis Security). Turn it on in your user settings:

"security.workspace.trust.enabled": true,
"task.allowAutomaticTasks": "off"

Trust is still a yes or no prompt that doesn't tell you what yes will run. Pick Restricted Mode, read the files above, then decide.

When an AI agent starts: hooks, MCP servers and hidden instructions

If you're going to let Claude Code, Cursor or Copilot's agent into the repo, its project files are part of what runs.

  • .claude/settings.json: hooks (a SessionStart hook runs a command when a session begins), enableAllProjectMcpServers, pre-approved commands like Bash(curl:*), and an env block that sets ANTHROPIC_BASE_URL.
  • .mcp.json and .cursor/mcp.json: each MCP server is a command that starts a process on your machine.
  • AGENTS.md, CLAUDE.md, .cursor/rules/*.mdc, .github/copilot-instructions.md: instructions like “run the setup script first”, and text you can't see.

Check Point showed project files in a cloned repo running commands and sending the user's API key out, in some cases before the trust dialog could be read (CVE-2025-59536 and CVE-2026-21852, both fixed; Check Point). Keep your agent updated, and still read the files. To find invisible characters (zero-width, bidirectional controls, Unicode tag characters):

rg -n '[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{2064}\x{FEFF}\x{E0000}-\x{E007F}]' .

When you install: package.json scripts and package manager config

npm install runs the preinstall, install, postinstall and prepare scripts in package.json, and the same scripts in every dependency. Read them:

jq .scripts package.json
  • A script that pipes a download into a shell, runs node on something in public/ or assets/, or calls a file with an odd name.
  • Dependencies with names one letter off a real package (jsonwebtokn for jsonwebtoken), dependencies that point at a git URL or a tarball URL, and lockfile entries that resolve to a host other than registry.npmjs.org.
  • .npmrc, .yarnrc.yml and .pnpmfile.cjs: these can change the registry or run repo code during install. For Python, setup.py runs on install.

npm install --ignore-scripts stops install scripts, yours and your dependencies'. It doesn't stop npm start, npm test or npm run, which still run their script (npm docs).

When you run it: the files npm start loads

Many lures have clean install scripts and put the payload in code that runs on start. Follow it by hand: the start or dev script, then the file it runs, then every local file that file requires. In one of the example repos on this site the loader sits at the end of server/server.js → server/routes/auth.js → server/config/db.js.

Build tool configs run too. Trend Micro found obfuscated JavaScript appended to postcss.config.mjs, tailwind.config.js, eslint.config.mjs and next.config.mjs, pushed far to the right with whitespace so it doesn't show in an editor or a diff (Trend Micro). Look for very long lines and the usual building blocks:

# lines over 500 characters in source and config files
find . -name node_modules -prune -o -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.ts' \) \
  -exec awk 'length > 500 { print FILENAME ":" FNR }' {} +

# code that builds and runs code, or reaches for your files
grep -rnE "eval\(|new Function|atob\(|child_process|\.ssh|Login Data|global\['(!|_V)'\]" \
  --include='*.js' --include='*.mjs' --include='*.cjs' --include='*.ts' --exclude-dir=node_modules .

global['!'] and global['_V'] are markers Trend Micro published for that campaign. A hit isn't proof and no hit isn't a pass, but a tailwind.config.js that calls eval doesn't need a second opinion.

Also read any Makefile, justfile or setup.sh the README tells you to run, and anything that runs before your tests (jest.config.js setup files, conftest.py).

When you commit: git hooks

Take-homes often ask you to make a branch, commit and push. Hooks in .git/hooks run on those commands (githooks). A cloned repo has no hooks of its own; a zip can.

  • In a zip, list .git/hooks. Anything without .sample on the end is live.
  • Read .git/config before you run any git command in the folder. core.hooksPath moves the hooks somewhere else, and core.fsmonitor names a command git runs during ordinary commands like git status (git-config).
  • In any repo, .husky/, lefthook.yml and .pre-commit-config.yaml install hooks when you run the install step. Usually routine; read what they run.

If a zip's .git looks wrong, delete the folder and git init a fresh one. You lose the history, not the take-home.

A faster first pass

I built a free checker that does this reading for you: paste a link on preclone.dev and it reads the repo without running it and lists the commands set to run on your machine, grouped by when they run, with the file and line. No login for public repos, and zips are read inside your browser tab. The fake take-home example shows the tasks.json, postinstall and npm start chain above in one report.

Its limits are the limits of reading files. It can't see what a server sends later when a script downloads something at run time, the machine code inside binaries, your dependencies' own source, or tricks nobody has written a rule for yet. That's why it never calls a repo safe. The full list is on what it checks. Other free scanners exist too, such as ScanRepo and Frisk; use whichever you'll actually use.

A check is the look before you decide. If you're going to run a stranger's code, run it in a disposable VM with nothing on it you'd miss.

If you already opened it or ran npm install

Assume it ran. The payloads in these campaigns are stealers and backdoors (Trend Micro names BeaverTail, InvisibleFerret and OtterCookie, among others): they go after saved passwords, tokens, keys and wallets, and leave a way back in. Work in this order:

  1. Disconnect the machine from the network and stop using it for anything that touches a password or token.
  2. From a different device, change passwords saved in your browser and revoke and reissue tokens: GitHub, npm (~/.npmrc), cloud keys (~/.aws, ~/.config/gcloud), SSH keys (~/.ssh), API keys in .env files, and any AI tool keys. Move crypto to a new wallet created on a clean device.
  3. Check for persistence: ~/Library/LaunchAgents and /Library/LaunchDaemons on a Mac, crontab -l, systemd user units in ~/.config/systemd/user, your shell rc files (~/.zshrc, ~/.bashrc, ~/.profile), VS Code and Cursor extensions you didn't install, and Startup items and scheduled tasks on Windows.
  4. Consider a full reinstall. Persistence can be hard to find, and a clean OS is the only state you can be sure of.
  5. Tell your employer's security team if it was a work machine, and report the recruiter's profile and the repo to the platform.

A repo scanner can't help after the fact. It's for the minute before you open the folder.

Questions

Is git clone itself dangerous?
Usually not. A plain git clone doesn't run the repo's code, and git doesn't copy a repo's hooks when you clone it. The danger starts when you open the folder in an editor, install dependencies, start the app or point an AI agent at it. Keep git up to date and skip --recurse-submodules on a repo you haven't read. A zip is different: it can carry a .git folder with live hooks and config.
Does VS Code Workspace Trust protect me?
It's the right control and you should keep it on. In Restricted Mode VS Code blocks tasks, debugging, the terminal, agents and extensions that haven't opted in. But it's one yes or no prompt that doesn't say what trusting will run, and it doesn't cover npm install, npm start or git hooks you run yourself. Cursor ships with it turned off.
Is npm install --ignore-scripts enough?
It stops install scripts, including your dependencies' install scripts. It doesn't stop npm start, npm test or npm run, which still run their script, and it doesn't stop configs your tools load, editor tasks or agent hooks.
Can I ask Claude Code or Cursor to check the repo for me?
Not inside the repo. The agent reads the repo's instruction files (AGENTS.md, CLAUDE.md, .cursor/rules), which can contain text you can't see, and project settings can start hooks and MCP servers when a session begins. Read those files yourself first, outside the agent.
Should I just use a VM?
For anything you're going to run, yes: a disposable VM or cloud box with no saved passwords, SSH keys, cloud credentials or wallets. A dev container is weaker than it looks, because initializeCommand runs on your host before the container exists.