Work / Case study

Security CLI · v0.5.0

NovaCheck

Finds package names that do not exist — in manifests, imports, and install commands — before you install them.

NovaCheck

The code compiled. The tests were green. The package in package.json did not exist on npm. Worse: the name was already in AGENTS.md as an install command, waiting for someone to run it. That combination is no longer rare, and it is a bad way to find out that a model has been guessing.

What the loop hides

I build with Cursor, Copilot, Claude, ChatGPT, and agents. The rhythm is easy to like: describe a feature, accept the diff, move on. A lot of that code runs. That is the part that makes the rest hard to see.

The failures I kept hitting were not a new species of bug. They were old bugs, shipped at the speed of autocomplete. A dependency name that looked right and had never been published — slopsquatting, if you want the ugly word. The name does not start in package.json. It starts in prose: a README, a setup guide, an AGENTS.md, a skill file. A human or an agent copies npx something-plausible, and only later does the name reach a manifest. A shell command built from user input because the snippet “just works.” SQL glued together with +. An API key left in .env.example for convenience. Math.random() used to mint a token. A postinstall script copied from a gist. Hunks merged under a review that never happened.

Traditional scanners are not useless here. They are aimed at a different job. SAST is noisy if you let it be. SCA wants a lockfile and a known CVE. Secret scanners catch some tokens and miss others. None of them, by default, care that the author of the hunk might be a model, or that the install command in the docs might be a hallucination rather than a compromise of a real package.

Four constraints, in order

I wanted a pass I would actually run before a push. That meant four rules, written down so they could be argued with.

The scan stays on the machine, or on the CI runner. The repository is not uploaded to a third party. Network access, when it exists, is for package-registry metadata — names, not source — and --offline turns it off.

Findings have to be few enough to fix. A detector that pages you for style is a detector you will ignore. I dropped version-blind CVE lookup for that reason. It looked complete. It was not precise.

Every issue should come with a fix prompt you can paste back into the same tool that wrote the code. Shame is not a workflow. And “looks fine locally” is not a gate: there has to be a policy file, SARIF for GitHub Code Scanning, and an Action that fails the job.

What it looks for

NovaCheck is tuned for AI-shaped risk, not for a complete inventory of theoretical CVEs. ghost-deps is the reason to run it. It asks npm and PyPI whether a name was ever published, and whether it sits next to a popular name — in manifests, in imports, and, since 0.5.0, in the install commands written in documentation and agent instruction files (*.md, *.mdx, *.txt, *.yml, AGENTS.md, SKILL.md, *.mdc, *.rules). npm i, npx, pnpm add, bunx, pip install, uv add, poetry add, pipx install: if a model wrote the command, the scan reads the package it would install. Precision is the rest of that work. Git, file, link, workspace, catalog, and URL specifiers are not registry packages, so they are not looked up. npm: aliases resolve to the package actually installed. Python import names are mapped to their distribution (import yaml is PyYAML, not a ghost named yaml). Shared namespace roots such as google and azure are not attributed to a single distribution. Placeholders in docs are ignored.

secrets is secretlint plus entropy. env-leak looks for .env files that are unignored or already tracked by Git — including on Windows, which 0.4.0 had to fix. supply-chain watches dangerous lifecycle scripts and git+http:// dependencies. dangerous-sinks is the usual list that models love because it is short: shell and SQL injection, CORS *, TLS verification off, eval, XSS sinks, pickle, unsafe YAML. insecure-crypto is for weak primitives and predictable tokens in a security context. Equally severe findings are ordered with ghost-deps first.

Provenance is split on purpose. ai-presence is informational. A Generated by Cursor comment, an AI_DISCLOSURE.md, an SPDX trailer: those are good. They do not pull the Trust Score down. ai-unreviewed is the one that costs you points — Agent Trace or SPDX ranges that were never marked as read. I do not try to guess that a function “looks like AI.” Style heuristics drift. Explicit markers do not.

Parsing is tree-sitter for JavaScript, TypeScript, and Python. That is slower than a regex festival and less embarrassing when the file is real.

The score is a policy, not a trophy

The Trust Score starts at 100. Critical findings subtract 25, high 12, medium 5, low 2, info nothing. The number is only useful if you can fail a build on it. The default threshold is 85. Status in the UI follows .novacheck.ymlminimumScore, failOn, ignore paths and finding IDs — not a hard-coded vanity cutoff. When a flag and a file disagree, the flag wins.

npx novacheck . is the whole pitch. --ghosts is the fast gate: only the names that do not resolve. --only and --skip narrow the rest; the GitHub Action has the same inputs. --changed origin/main scans the diff. --sarif writes Code Scanning. --html is on by default, at .novacheck/report.html. --fail-on-incomplete is how CI should run: if a file or a registry package could not be analysed, the job dies instead of pretending the tree is clean. Local scans warn. That distinction landed in 0.4.0, with registry timeouts, bounded retries, and concurrent lookups. The terminal report now opens with a Ghost packages block: the name, the location, and the install command behind it.

The GitHub Action at v0.5.0 executes the same published npm package as npx. Release tags are pinned to commits. Fork pull requests that lack security-events: write skip the SARIF upload rather than fail the job for a permissions problem. A changed scan is a diff gate. It is not a certificate for the rest of the repository. A full scan on main still has to exist. The repository ships examples/ai-slop, a deliberately unsafe tree that scores 0/100, and scripts/ghost-hunt.ts for batch research over public repositories. The protocol is in docs/RESEARCH.md.

Limits

NovaCheck will not replace a human review, a pentest, or a lockfile audit that understands your actual graph. Ghost detection covers npm and PyPI today. Ghost-dependency checks need the network unless you pass --offline, in which case they cannot confirm a name. The HTML report is a review surface, not a compliance artefact. The in-browser scan on this page is a teaching demo: it is not the CLI, and it never uploads your code.

The published tool is TypeScript on Node 20+, MIT, novacheck on npm. Development uses Bun. The first public release was 0.3.0 on 5 August 2026; 0.4.0 followed two days later; 0.5.0 on 17 August 2026. If you find a hole, do not open a public issue. SECURITY.md is the channel.

The in-browser scan on this page is a teaching demo. It is not the published CLI and never uploads your code.

Interactive demo

Pattern scan, in the browser.

Paste a snippet. Detectors run only in this tab—nothing is uploaded. The published CLI is npx novacheck ., or npx novacheck . --ghosts for the names that do not resolve.

Trust score

0/100BLOCKED
  • criticalShell injection riskUntrusted input appears to reach a shell execution sink.
  • criticalSQL concatenationSQL is built with string concatenation instead of bound parameters.
  • highXSS sinkHTML is assigned from a value that may be attacker-controlled.
  • criticalHardcoded credential patternA secret-like token appears in source. Rotate it if this was real.
  • criticalGhost package: phantom-migrateThis name is from the published examples/ai-slop fixture. The real CLI asks npm and PyPI; this demo does not.