Skip to main content

carabiner

ci PyPI

A carabiner is the piece of gear that locks the system together and is rated to catch a fall. It is also the only piece you check before you need it.

Make any repository secure by default in one command, keep it that way, and prove the protections actually fire.

→ maximo000.github.io/carabiner

pip install carabiner-sec        # the command it installs is `carabiner`

carabiner scanning a repo, locking the existing findings as a baseline, then failing only on a new unpinned action

$ carabiner scan
  CRITICAL CI001  .github/workflows/pr.yml
           job 'hello' runs on pull_request_target and checks out the PR head
           -- untrusted code runs with your secrets
           fix: use `pull_request`, or split into an untrusted build job and a
                privileged job that never checks out the head

  2 new, 340 accepted (carabiner debt)   0.02s

Why another one

Every scanner already exists and is free — gitleaks, Trivy, Semgrep, OSV-Scanner. They are excellent and carabiner does not reimplement any of them. And the median repository runs none of them, for three specific reasons:

  1. Setup is per-tool, per-language, per-CI. A two-hour job you do once.
  2. The first run returns 400 findings and everyone gives up. The gate gets turned off, and the tool now has negative value — it looks like coverage.
  3. A configured control is not a working control. The hook is in .pre-commit-config.yaml but nobody ran pre-commit install.

The three things that aren't a wrapper

The ratchet. carabiner lock accepts every existing finding into a baseline. From then on CI fails only on what's new. You can adopt this in a ten-year-old repo on a Tuesday afternoon, and security only tightens from there. Accepted findings stay visible via carabiner debt — the debt is tracked, not deleted — and --expires 90 puts a deadline on it, because without one "accepted" quietly means "forever".

Findings are fingerprinted on (engine, rule, path, normalized snippet), never on line numbers. Adding an import at the top of a file must not resurrect 400 accepted findings; that's why baseline features elsewhere get abandoned.

The drill. carabiner drill doesn't read configuration — it attacks the repo. It plants a private key and checks the installed pre-commit hooks actually stop it; asks GitHub whether push protection is really on; and verifies the security workflow is a required check rather than one that runs, fails, and merges anyway.

$ carabiner drill
  HIGH     DRILL002  pre-commit hooks are configured but NOT installed -- the config looks right and nothing runs
  HIGH     DRILL019  'main' (protection rule) doesn't require a pull request -- changes can be pushed straight to it, past every review and check
  MEDIUM   DRILL017  Dependabot vulnerability alerts are disabled
  LOW      DRILL016  branch protection on 'main' does not apply to admins

(Real output: carabiner drilling its own repository.)

Drill What it proves
DRILL001-004 the pre-commit hooks exist, are installed, and actually block a planted private key
DRILL011 / 024 secret scanning and its push protection are really on
DRILL012 the default workflow token is read-only
DRILL023 workflows can't approve pull requests (which would let a bot satisfy a required review)
DRILL013 / 014 the default branch is protected -- by a classic rule or a ruleset -- and a status check is required
DRILL019 / 020 a pull request is required, with at least one approval
DRILL015 / 016 no force pushes, and no admin bypass
DRILL021 / 022 CODEOWNERS has no lines GitHub silently ignores, and code-owner review is actually required
DRILL017 / 018 Dependabot alerts are on, and SARIF uploads actually arrive

A drill that could not run never reports as passing — no token, no network, no pre-commit binary all produce "could NOT be verified", not a green check. Unverified is not secure. Drills are also never ratcheted: a control that stopped working is a regression today, not pre-existing debt to accept.

Most security tools check your configuration. carabiner checks your defenses by trying to get past them.

One normalized model. Every engine reports into one Finding. Deduplicated across engines, keeping the worse severity — two scanners reporting one CVE is one finding, and a developer shown the same problem twice trusts the tool less each time. Emitted as SARIF so findings land in the PR Security tab — which is exactly why snippet is scrubbed in Finding.__post_init__ rather than at each call site: a credential that reaches a finding reaches a code-scanning alert. Token-shaped runs are shortened to first4…last4, and a credential in a URL is removed outright. That second rule deliberately over-reaches, because a redactor is the one place in this tool where a false positive is cheaper than a false negative.

--json's shape is schema/finding.schema.json — versioned (schema_version, bumped only on a breaking change), so a downstream consumer isn't trusting an implicit contract. This is what invariant's security_scan check type demonstrates for a different pair of tools: read one project's evidence, assert on it from another.

Adopt it

carabiner init          # detect, configure, ratchet. Once per repo.
carabiner scan          # what is new. Pre-commit and CI.
carabiner scan --diff   # only what this commit touches. The pre-commit path.
carabiner scan --all    # every engine, whole history. CI cadence.
carabiner drill         # prove the controls fire. After init, and weekly.
carabiner scan --info   # also list informational findings (hidden by default).
carabiner debt          # what you carry, since when, and what is overdue.
carabiner lock --expires 90   # accept it, but only for 90 days.

init prints every file it will write before writing it, and --dry-run writes nothing. A security tool that silently rewrites your config has no business asking to be trusted.

Fix the biggest finding in one command

$ carabiner fix --dry-run
  .github/workflows/ci.yml:12
    - uses: actions/checkout@v7
    + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7
  ...
would pin 59 action reference(s)
$ carabiner fix

Unpinned actions (CI003) are about half of every finding across the repositories carabiner has been run on. carabiner fix resolves each tag or branch to the commit it points at today and pins it, keeping the tag as a # v7 comment -- which is what Dependabot reads to keep bumping a pinned action. Nothing that runs changes; it just stops being able to change without a diff.

Measured on six well-known repositories: django (59 references pinned), grafana (226), rich (12), httpx (4), pydantic and vite (already pinned). Every workflow's YAML was otherwise identical afterwards, CI003 went to zero, and the SHAs match what GitHub reports for each tag. Lines are rewritten in place, never re-serialised, so comments, quoting and CRLF line endings survive; your own repository's actions and local .//$/ actions are left alone; a reference that can't be resolved is listed and fails the command rather than being guessed. Set GITHUB_TOKEN for more than 60 lookups an hour.

It deliberately does not add permissions: or persist-credentials: false for you: either can break a job that pushes, comments or publishes, and only someone who knows that job can tell. Those stay findings, with the exact change to make.

In CI

permissions:
  contents: read
  security-events: write

steps:
  - uses: actions/checkout@v4
  - uses: MaXiMo000/carabiner@v0.4.1
  - uses: github/codeql-action/upload-sarif@v3
    with:
      sarif_file: carabiner.sarif

Findings land in the PR's Security tab, tracked across commits by the same stable fingerprint the ratchet uses — so reformatting a file does not report everything as new.

Add args: --all --summary carabiner.md and post that file as a PR comment to get one short line per PR — 2 new · 1 fixed · 340 accepted — instead of the whole backlog restated every time.

On GitLab CI

carabiner:
  script:
    - pip install carabiner-sec
    - carabiner scan --all --gitlab-sast gl-sast-report.json --fail-on low
  artifacts:
    reports:
      sast: gl-sast-report.json

--gitlab-sast writes a report GitLab's own merge-request Security widget reads natively — the same adoption surface --sarif gives on GitHub. Validated against GitLab's own published schema in the test suite, not just hand-checked field names, for the same reason the SARIF output is.

Anywhere else — GitLab CI, Jenkins, CircleCI

docker run --rm -v "$PWD:/repo:ro" ghcr.io/maximo000/carabiner:0.4.1 scan --all

The image bundles gitleaks and osv-scanner, runs as a non-root user, pins its base by digest, checksum-verifies every binary it downloads, and ships with a build-provenance attestation.

As a pre-commit hook

repos:
  - repo: https://github.com/MaXiMo000/carabiner
    rev: v0.4.1
    hooks:
      - id: carabiner

The fast path measured 0.41s–0.83s across ten well-known repositories (requests, flask, fastapi, express, axios, prettier, gin, ripgrep, bat) and 2.3s on a 302MB monorepo (next.js). Anything slower gets uninstalled from pre-commit inside a week, which is why deps runs only under --all: its OSV lookups cost 0.7–9.3s on those same repos and are the only thing that ever blew the budget.

Engines

Engine Checks Needs
ci — GitHub Actions CI001 pull_request_target + PR-head checkout · CI002 script injection from github.event into run: · CI003 unpinned actions · CI004/5 token blast radius · CI006 secrets in reach of checked-out contributor code · CI007 self-hosted runners · CI008 persist-credentials left on · CI009 secrets: inherit across repos · CI010 cache poisoning · CI011 ships artefacts on a tag without creating a Release nothing
ci — GitLab CI GL001 script injection from a merge-request title or branch name · GL002 unpinned remote include: · GL003 mutable image and service tags nothing
repo REPO001 .gitignore gaps · REPO002 committed key material · REPO003 no disclosure policy · REPO004 credentials in git remotes nothing
ci — Jenkins / CircleCI / Azure JEN001 Groovy interpolation into sh · JEN002 literal credential in a pipeline · CIR001 @volatile orb · CIR002 pipeline parameter into a run step · AZP001 branch name into a script nothing
kubernetes K8S001 hostNetwork/hostPID/hostIPC · K8S002 privileged container · K8S003 privilege escalation · K8S004 nothing preventing root · K8S005 a literal credential in env nothing
docker DOCK001 final stage never drops root · DOCK002 untagged or :latest base · DOCK003 credential baked into ARG/ENV · DOCK004 remote script piped into a shell · DOCK005 TLS verification disabled at build time · DOCK006 copies the whole build context with no .dockerignore, so .git and every secret ever committed to it ship inside the image nothing
secrets working tree every commit; history behind --all and one severity higher, because deleting the file is not remediation gitleaks
deps lockfile advisories across PyPI, npm, Go, Maven, crates.io and more; ids normalised to CVE so two scanners cannot report one problem twice osv-scanner

Severity is calibrated against real repositories. Across 60 public projects the default output is a median of 9 findings per repo; another ~800 informational ones are counted but not listed until you ask with --info. A version tag on an action is informational; a moving branch in someone else's repository is not. A private key under tests/ is reported lower than one in config/.

Re-run since, on a different sample. 30 popular, unrelated public repos — Python, JS/TS, Go, Rust, Ruby, Java — shallow-cloned fresh and scanned with --all, gitleaks and osv-scanner installed. 28 of 30 produced at least one new finding; expressjs/express and spf13/cobra scanned clean. Median scan time was 10.5s, osv-scanner's live lookup included. The single largest number — 3,339, on facebook/react — came from a sprawling, decade-old monorepo with dozens of workflow files and an equally old lockfile: real surface area, not a scanner malfunction.

The dominant rule by far was SECRET-*, on all but a handful of the 30 — almost none of it a live credential. psf/requests ships four real TLS private keys under tests/certs/, used by its own mTLS test suite; the tests/ demotion above puts each at medium in the working tree, high only once history is in scope, exactly as designed. That is the pattern across the sample: a raw secrets scanner flags something in nearly every mature codebase, and almost all of it is a fixture, an example JWT in a doc, or a placeholder — precisely the noise the ratchet exists to keep out of a team's way, not a one-off blind spot in this particular tool. The signal worth a maintainer's time was elsewhere: unpinned actions, a missing top-level permissions: block, and dependency advisories on old transitive pins, all real and all still there after the fixtures are filtered out.

A repository referencing its own action is not reported at all. Moving that tag needs push access to the repository being scanned — the same access that would let someone rewrite the workflow outright — so no boundary is crossed and there is nothing to pin against. It is also the universal shape for an action repository: the only honest way to test the tag your users consume is to consume it. CI003 resolves the repository from $GITHUB_REPOSITORY, falling back to the origin remote. The exemption is that repository, never the action: the same owner/action@v1 referenced from anywhere else is a third party and still reported.

A missing scanner degrades to an install hint, never a crash. And a scanner that fails produces a finding saying the check did not happen — a tool that errors is not a repo that is clean. That rule covers a native engine raising too, not just a wrapped one exiting non-zero: one engine's bug is reported and every other engine still runs, rather than the whole scan going down with it.

Accuracy, measured

The CI engine run over the workflows of 40 popular repositories -- pytest, black, ruff, uv, click, numpy, pandas, scikit-learn, transformers, langchain, react, vue, svelte, next.js, vscode, TypeScript, deno, bun, node, express, tokio, axum, ripgrep, bat, rust, go, fzf, gin, prometheus, traefik, kubernetes, terraform, compose, rails, laravel, mastodon, discourse, spring-boot, elasticsearch, airflow -- and every finding that fails a build by default (high and above) read by hand, along with every script-injection finding:

before after
high/critical findings 5 2
of which correct 2 2
script injection (CI002) 4 2
of which correct 1 2

What the review fixed:

  • huggingface/transformers, three HIGH CI009: callers on workflow_run of a schedule-only workflow were treated as untrusted. A workflow_run is now only as untrusted as the workflows it follows -- resolved by name in the same repository; one that can't be found stays untrusted.
  • denoland/deno, three CI002: a type: choice input interpolated into run:. GitHub rejects a dispatch value outside the options, and boolean/number/environment inputs can't carry shell either. The one real sink beside it -- the free-text commitHash -- is still reported, and now named.

The two highs left are discourse's check-pr-body.yml: pull_request_target checking out the PR head with credentials persisted. Real, and already reported one level down from critical because a user.login guard limits it to Dependabot. The remaining findings are mostly CI005/CI004 (write-all or default token permissions, 211) and CI003 (unpinned actions; 230 of them informational version tags from GitHub itself) -- the kind carabiner fix and a permissions: block clear.

Known limits, stated plainly

  • The ci engine covers GitHub Actions and GitLab CI. Jenkins, CircleCI and Bitbucket get the other engines and nothing from that one.
  • The published Docker image is linux/amd64 only.
  • The fast path scans the whole working tree, not just changed files, so a very large monorepo can exceed the 2s target.
  • The secrets engine's working-tree scan skips node_modules, .venv, __pycache__, dist, build, vendor, target and .git — generated or vendored content that has no business being read as source, and that gitleaks' non-git scan mode has no .gitignore of its own to tell it to skip. Measured, not theoretical: a compiled .pyc embedded a string this project's own drill.py deliberately split across a concatenation to keep out of a scanner's sight in the .py source, because CPython folds that concatenation back into one literal at compile time. If one of those directories is ever actually committed to a repo, --all's history scan still finds what's in it — a real secret checked into node_modules is a problem regardless of what carabiner shows on a pre-commit run.

Tested on Linux and Windows, Python 3.10 and 3.13. --offline is enforced by a test that blocks socket creation and asserts a full scan still completes — the claim is checked, not documented.

What it will never do

No SaaS. No dashboard. No account. No telemetry. No AI. No auto-rewriting your security config. And it never reimplements a scanner that already exists — the value is the ratchet, the drill, and the normalized model.

Dependencies: PyYAML and the standard library. That is the whole list, on purpose — every dependency is a package a security auditor now implicitly vouches for.

License

MIT. See SECURITY.md to report a vulnerability.

Metadata

Release files for carabiner-sec 0.4.1

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

Source distribution (sdist)

Source distribution for carabiner-sec 0.4.1
File Size Uploaded
carabiner_sec-0.4.1.tar.gz 94.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for carabiner-sec 0.4.1
File Interpreter ABI Platform
carabiner_sec-0.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 165.3 kB

Release files / carabiner_sec-0.4.1.tar.gz

Download URL carabiner_sec-0.4.1.tar.gz
Size 94.3 kB
Tags Source
SHA-256 checksum
How to use checksums
d0567253b255861d2956334b118ed292a87cef94357abe54c6601532323d01c8
BLAKE2b-256 checksum
How to use checksums
4a46891e177485530b93ec606b7db32d5f222702b80d54817bb8f5977971a075
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 Oct 15, 2026.

Transparency log

Release files / carabiner_sec-0.4.1-py3-none-any.whl

Download URL carabiner_sec-0.4.1-py3-none-any.whl
Size 71.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f4ba7615872be2bebe5fdd307836049bf5a97153cf97f270dcc27bfa7e5ad007
BLAKE2b-256 checksum
How to use checksums
aed14cdda55cae1b3476a49c0e346e7e6887614b8e3431e6864e2f20fd71d58b
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 Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.1 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

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