Releases: MaXiMo000/carabiner
Release list
carabiner v0.2.1
pip install carabiner-secFixed — --diff silently scanned nothing on Windows
Two bugs, one cause: OS-native path strings compared against forward-slash literals.
git reports .github/workflows/x.yml. pathlib on Windows produces .github\workflows\x.yml. The --diff filter compared the two, never matched, and so scanned no files at all — then reported a clean repository. A scan that reports "clean" because it examined nothing is exactly the failure this tool exists to complain about, and it shipped inside the tool.
The same cause routed every .circleci/config.yml to the Azure Pipelines parser, because the dispatch compared a native path string to the literal .circleci/config.yml.
Fixed at the boundary rather than at each comparison: Finding normalises its path to POSIX on construction, and every engine emits as_posix(). One missed comparison is a silent pass on one operating system, with no way to notice from the other.
Found by the Windows CI matrix, which was added two releases ago after Windows had been listed as "untested" for some time. It has now caught two real bugs — this one and a UnicodeDecodeError crash — in three days.
58 tests, 220 checks, Linux and Windows across Python 3.10 and 3.13.
carabiner v0.2.0
pip install carabiner-secTwo new engines, a pre-commit path that is actually fast, and two drills that check controls people assume are on.
--diff — only what the commit touches
The first implementation filtered a full scan afterwards, which saves nothing: the work has already happened, and it measured slower than not using the flag. The engines now avoid the work instead, and secrets uses gitleaks' own --staged mode.
| repository | full tree | --diff |
|---|---|---|
| next.js | 3.92s | 1.80s |
| grafana | 4.25s | 1.97s |
| home-assistant | 6.21s | 1.97s |
kubernetes — native, no wrapper
K8S001 |
hostNetwork / hostPID / hostIPC — the container shares the node's namespaces |
K8S002 |
a privileged container, which is root on the node in all but name |
K8S003 |
allowPrivilegeEscalation, letting a setuid binary regain what the securityContext took away |
K8S004 |
neither runAsNonRoot nor a non-zero runAsUser, so nothing prevents root |
K8S005 |
a literal credential in env — committed in a manifest means committed in your history |
K8S005's first two findings were both wrong. airflow's SECRET_NAME holds the name of a Kubernetes Secret and etcd's INITIAL_CLUSTER_TOKEN is a cluster identifier — both matched on the word alone. It now skips reference-shaped keys (…_NAME, …_PATH, …_ID) and requires the value to carry real entropy: a cluster name scores near 3.2, a credential above 4.
ci — Jenkins, CircleCI and Azure Pipelines
The last host gap. The same vulnerability in three dialects: text somebody else controls reaching a shell without ever becoming data.
JEN001Groovy interpolates${params.X}inside double quotes before the shell ever runs, so the value arrives as code. Single quotes do not, which is why the quote style is the finding.JEN002a literal credential assigned in a pipelineCIR001an orb pinned to@volatile, which always resolves to the newest publishCIR002a pipeline parameter substituted into a run stepAZP001a branch name or commit message substituted into a script step
Deduplicated by resolved path: macOS and Windows are case-insensitive, so Jenkinsfile and jenkinsfile matched as two files and every finding was reported twice.
Jenkinsfiles are Groovy, so that engine is regex-level and shallower than the parsed GitHub and GitLab ones — stated plainly rather than implied.
Two more drills
DRILL017— are Dependabot alerts actually enabled? Adependabot.ymlin the repository does not switch them on.DRILL018— has any SARIF ever arrived? A rejected upload does not fail the job that produced it, so a green pipeline and an empty Security tab look identical from the outside.
57 tests, 218 checks, on Linux and Windows across Python 3.10 and 3.13, plus a Docker build, a wheel install into a clean venv, and carabiner scanning itself — every commit.
carabiner v0.1.14
Make any repo secure by default, keep it that way, and prove the controls fire.
- uses: MaXiMo000/carabiner@v0.1.14pip install carabiner-sec # installs the `carabiner` command
docker run --rm -v "$PWD:/repo:ro" ghcr.io/maximo000/carabiner:0.1.14 scan --allThe Marketplace display name is now Carabiner Repo Security — GitHub requires an action name to be unique across every action, user and organisation. The repository, the carabiner command and the carabiner-sec package are unchanged.
Calibrated against 60 public repositories
Tuned by scanning 60 real projects — tokio, grafana, vault, rails, next.js, home-assistant, airflow — across 13 languages, then re-checking every finding with an oracle written independently of the engines.
Default output is a median of 9 findings per repository; roughly 800 informational ones across the corpus are counted but not listed until --info. Hidden never means disappeared — the count always shows.
A version tag on an action is informational. A moving branch in someone else's repository is not. Classifying refs by shape rather than by a branch-name list also caught stable, nightly and cargo-hack. One unpinned reference is one finding, however many steps use it: tokio reaches dtolnay/rust-toolchain@stable 34 times in one workflow — one decision, not 34 lines.
tokio: 172 findings → 10. Corpus criticals: 31 → 2, and both survivors are real — a committed private key in grafana, and an unguarded pull_request_target + PR-head checkout in rollup.
A native Dockerfile engine
Trivy scans a built image — daemon, build, minutes. A Dockerfile is text, and the worst mistakes are visible before anything is built.
DOCK001 |
the final stage never drops root |
DOCK002 |
untagged or :latest base image |
DOCK003 |
a credential baked into ARG/ENV, readable with docker history |
DOCK004 |
a remote script piped straight into a shell at build time |
DOCK005 |
TLS verification disabled during the build |
54 real issues across 19 of the 60 repositories — after spot-checking killed 33 of the rule's first 36 findings. FROM builder is a stage reference, scratch is a keyword, ${BASE_IMAGE} is unknowable, and airflow embeds whole Python programs in BuildKit heredocs that were being parsed as instructions.
Findings that named an action or image were unreadable
The redactor treated /, . and - as token characters, so dtolnay/rust-toolchain@stable printed as dtol...hain@stable. A credential is a long unbroken run with no separators to split on — splitting on them keeps identifiers legible while still masking ghp_… and AKIA….
What it does
A ratchet. carabiner init accepts existing findings into a baseline, so CI goes green today and only new problems fail it. --expires 90 puts a deadline on accepted debt.
A drill. carabiner drill doesn't read config — it plants a private key and checks your hooks actually block it, then asks GitHub whether push protection is really on. A check that could not run never reports as passing.
One normalized model → SARIF into the PR's Security tab, deduplicated across engines, most-severe-wins.
52 tests, 193 checks, on Linux and Windows across Python 3.10 and 3.13, plus a Docker build, a wheel install into a clean venv, and carabiner scanning itself — every commit.
carabiner v0.1.13
Make any repo secure by default, keep it that way, and prove the controls fire.
- uses: MaXiMo000/carabiner@v0.1.13pip install carabiner-sec # installs the `carabiner` command
docker run --rm -v "$PWD:/repo:ro" ghcr.io/maximo000/carabiner:0.1.13 scan --allCalibrated against 60 public repositories
Tuned by scanning 60 real projects — tokio, grafana, vault, rails, next.js, home-assistant, airflow and more — across 13 languages, then re-checking every finding with an oracle written independently of the engines.
The default output is now a median of 9 findings per repository. Roughly 800 informational ones across the corpus are counted but not listed until you pass --info. Hidden never means disappeared: the count always shows.
A version tag on an action is informational. A moving branch in someone else's repository is not — that is what can change tonight without a diff on your side. Classifying refs by shape rather than by a list of branch names also caught stable, nightly and cargo-hack. And one unpinned reference is one finding, however many steps use it: tokio reaches dtolnay/rust-toolchain@stable 34 times in a single workflow, which is one decision, not 34 lines.
tokio went from 172 findings to 10. Corpus criticals went from 31 to 2 — and both survivors are real: a committed private key in grafana, and an unguarded pull_request_target + PR-head checkout in rollup.
New: a native Dockerfile engine
Trivy scans a built image — daemon, build, minutes. A Dockerfile is text, and the mistakes that matter most are visible before anything is built.
DOCK001 |
the final stage never drops root |
DOCK002 |
untagged or :latest base image |
DOCK003 |
a credential baked into ARG/ENV, readable with docker history |
DOCK004 |
a remote script piped straight into a shell at build time |
DOCK005 |
TLS verification disabled during the build |
54 real issues across 19 of the 60 repositories — but only after spot-checking killed 33 of the rule's first 36 findings. FROM builder is a stage reference, scratch is a keyword, ${BASE_IMAGE} is unknowable, and airflow embeds entire Python programs in BuildKit heredocs that were being parsed as instructions.
Fixed: findings that named an action or image were unreadable
The redactor treated /, . and - as token characters, so dtolnay/rust-toolchain@stable printed as dtol...hain@stable. A credential is a long unbroken run with no separators to split on — splitting on them keeps identifiers legible while still masking ghp_… and AKIA… tokens.
What it does
A ratchet. carabiner init accepts your existing findings into a baseline, so CI goes green today and only new problems fail it. --expires 90 puts a deadline on accepted debt.
A drill. carabiner drill doesn't read config — it plants a private key and checks your hooks actually block it, then asks GitHub whether push protection is really on. A check that could not run never reports as passing.
One normalized model → SARIF into the PR's Security tab, deduplicated across engines, most-severe-wins.
52 tests, 193 checks, on Linux and Windows across Python 3.10 and 3.13, plus a Docker build, a wheel install into a clean venv, and carabiner scanning itself — every commit.
carabiner v0.1.11
pip install carabiner-secConsolidates v0.1.6–v0.1.11. This run was driven by scanning 60 public repositories (4.0 GB across Python, JS/TS, Go, Rust, Java, Kotlin, Ruby, PHP, C, C++, C#, Elixir, Swift and shell) and then re-checking every finding with an oracle written independently of the engines.
Precision
Critical findings across the corpus went from 31 to 2 — and both survivors are real: a committed -----BEGIN PRIVATE KEY----- in grafana, and an unguarded pull_request_target + PR-head checkout in rollup.
Five false-positive classes were found and fixed. Four were the same mistake wearing different clothes — judging a file by its name instead of reading it:
| What | Evidence |
|---|---|
.env flagged by filename |
grafana commits 11 .env files holding mysql_version=8.0.32 |
.env.example treated as real |
strapi ships JWT_SECRET=tobemodified — that is documentation |
"AUTH" matched inside "AUTHOR" |
LINEAR_CMS_STATUS_WAITING_ON_AUTHOR=<uuid> read as a credential |
test-fixtures unmatched |
vault's 14 test keys were critical because the directory name is hyphenated |
.npmrc assumed to hold a token |
every one in the corpus contained ignore-scripts=true |
CI002 was separately wrong two times in three: pull_request.base.ref is the PR's target branch, and a GitHub username cannot carry a shell metacharacter. It now names the fields an outsider can actually put text into.
A guarded pull_request_target (discourse gates on the author's login, which is not spoofable) now reports high and says the guard exists, rather than crying open-door over a closed one.
Correctness
- Windows crash fixed — text I/O used the locale encoding, so one em dash in a workflow file killed the scan with
UnicodeDecodeError. Windows is now in the test matrix. - Dependencies in subprojects were silently skipped.
backend/andfrontend/lockfiles were invisible; the scan looked clean rather than unchecked. --offlineis now real and enforced by a test that blocks socket creation.- Packaging — the distribution is
carabiner-sec; the command is stillcarabiner.
Performance
deps moved behind --all: its OSV lookups cost 0.7–9.3s while every other engine stayed under 1.7s. Worst fast path across the corpus went 10.2s → 2.4s, median 0.47s.
48 tests, 161 checks, on Linux and Windows across Python 3.10 and 3.13, plus a Docker build, a wheel install into a clean venv, and carabiner scanning itself — every commit.
carabiner v0.1.5
Dependencies in subprojects were silently skipped.
Found on the first repository carabiner was pointed at that I had not written myself: backend/ and frontend/ each carried a package-lock.json, the deps engine looked only at the top level, found nothing, and therefore said nothing. The scan looked like a repo with no dependency problems rather than one where dependencies were never checked.
The root cause was carabiner's own gate — osv-scanner is invoked with -r and recurses perfectly well. Now a depth-bounded walk that prunes node_modules and friends: 0.017s on that repo against a 2s budget, so the performance concern that justified "top level only" was unfounded.
On that repository the fix surfaced 186 advisories that had been invisible, including a CRITICAL and 90 HIGH.
carabiner v0.1.4
pip install carabiner-secFixed — a crash on Windows
Path.read_text() uses the locale encoding on Windows, not UTF-8. One em dash, smart quote or emoji anywhere in a scanned file took the whole scan down with UnicodeDecodeError — a crash for most Windows users, not an edge case.
Files read from your repo now use UTF-8 with errors="replace", because a stray byte shouldn't end a scan. Files carabiner writes use strict UTF-8. Pinned by an AST-based lint over every text read and write in the codebase.
This was found by adding Windows to the CI matrix, which had been listed as untested for several releases. It paid for itself on the first run.
Added
- Windows is now tested — Linux and Windows, Python 3.10 and 3.13.
--offlineis real. It previously only reached the drills whiledepsstill called out through osv-scanner, andSECURITY.mdclaimed otherwise. Engines now declare whether they need the network and are skipped entirely, enforced by a test that blocks socket creation and asserts a full scan still completes.- PyPI project metadata — the page was blank. README, classifiers, keywords, and PEP 639 licence metadata.
Also
The README had silently gone stale — most edits to it had no-opped, leaving it claiming "Phase 0, native engines only" long after five engines, the drill, SARIF, Docker and PyPI shipped. Rewritten, and now covered by tests asserting the docs pin the shipped version, recommend no mutable ref (@main is exactly what CI003 reports), and mention every registered engine.
Full diff: v0.1.2...v0.1.4
carabiner v0.1.2
First release published to PyPI.
pip install carabiner-sec # installs the `carabiner` commandThe PyPI name carabiner was already taken by an unrelated package, so the distribution is carabiner-sec. The command you type is unchanged.
Fixed since v0.1.0 — adding the landing page gave the repo a second top-level directory, and setuptools correctly refused to guess which one was the package. pip install . had worked locally right up until site/ existed. Packages are now declared explicitly, and CI builds the distribution and installs the wheel into a clean venv on every commit — a break that only appears at release is one that appears in front of users.
Published via PyPI trusted publishing over OIDC. No API token exists in this repository.
carabiner v0.1.0
First release. Four engines, the ratchet, the drill, and three ways to install it.
What it does
- The ratchet —
carabiner initaccepts every existing finding into a baseline, so CI goes green immediately and only new findings fail it. Adoptable in a ten-year-old repo without a cleanup sprint. Findings are fingerprinted on content, never line numbers, so reformatting a file doesn't resurrect the backlog.--expires 90puts a deadline on accepted debt. - The drill —
carabiner drilldoesn't read config. It plants a private key and runs your installed pre-commit hooks against it, then asks GitHub whether push protection is on and whether your default branch actually requires a status check. A drill that couldn't run never reports as passing. - One normalized model — SARIF into the PR Security tab, deduplicated across engines, most-severe-wins.
Engines
| Engine | Needs |
|---|---|
ci — GitHub Actions: pull_request_target + PR-head checkout, github.event script injection, unpinned actions, token blast radius, self-hosted runners |
nothing |
ci — GitLab CI: merge-request-title script injection, unpinned remote include:, mutable image tags |
nothing |
repo — .gitignore gaps, committed key material, credentials in git remotes |
nothing |
secrets |
gitleaks |
deps — PyPI, npm, Go, Maven, crates.io… ids normalized to CVE |
osv-scanner |
Install
- uses: MaXiMo000/carabiner@v0.1.0docker run --rm -v "$PWD:/repo:ro" ghcr.io/maximo000/carabiner:v0.1.0 scan --allThe image bundles both scanners, pins its base by digest, checksum-verifies every binary it downloads, runs as a non-root user, and ships with a build-provenance attestation. linux/amd64 only for now.
Known limits — Jenkins, CircleCI and Bitbucket get every engine except ci. Windows is untested. Only top-level dependency manifests are read.