Cordon
A language-agnostic software supply-chain security scanner. It reads source, dependency manifests, lockfiles, build scripts, CI configuration, Dockerfiles and infrastructure-as-code, and reports malicious packages, install-time behaviour, leaked credentials and dependency risk.
No runtime dependencies. No network access unless you ask for it. It never executes the code it scans.
pipx install cordon-scanner
cordon-scanner scan .
What it looks like
A package with a postinstall script that posts the environment to a webhook
and pipes a download into a shell, and a workflow that sends a publish token to
a remote host:
Real output, rendered from a captured run rather than drawn. The image is an
SVG rather than a GIF for a reason that matters here: it is text, so it can be
read in a diff before it is trusted, and a tool that reports committed binaries
should not ship one to advertise itself. Regenerate it with
python scripts/render_demo.py after piping a scan into it.
The image is served from an absolute URL so it renders on PyPI as well as on GitHub, where a repository-relative path resolves to nothing.
- What it looks like
- Install
- Run it
- Configure it
- Environments — local, pre-commit, CI, monorepo, air-gapped, enterprise
- Tuning what fires
- Adopting on an existing codebase
- Exit codes
- Why this exists
Install
| Method | Command | Use when |
|---|---|---|
| pipx | pipx install cordon-scanner |
Local development. Isolated, on PATH. |
| pip | pip install cordon-scanner |
Inside a virtualenv you already manage. |
| GitHub Action | uses: Threx-code/cordon@<sha> |
GitHub Actions. See Environments. |
Python 3.11, 3.12 and 3.13 on Linux, macOS and Windows.
The Action is pinned by commit SHA rather than by a @v0 tag: a tag is mutable,
and pinning the scanner by something its author can move defeats the point of
running it. There is no published container image yet -- that is Phase 6, along
with signed artefacts and provenance.
Verify the install:
cordon --version
cordon-scanner rules list # what will run
Run it
cordon-scanner scan . # a directory
cordon-scanner scan ./package.tgz # an archive, read in memory
cordon-scanner scan . --severity high --fail-on high # gate a pipeline
Choosing what gets scanned
cordon-scanner scan . --exclude 'vendor/**' --exclude 'dist/**'
cordon-scanner scan . --include 'src/**'
cordon-scanner scan . --tracked # only files git tracks
cordon-scanner scan . --git-diff origin/main # only what changed
cordon-scanner scan . --staged # the git index, not the working tree
--staged reads blobs from the git index rather than from disk. That matters
for a pre-commit hook: a hook reading the working tree is defeated by staging a
poisoned file and restoring the clean one, so the poisoned blob is what gets
committed and the clean one is what gets scanned.
Narrowing applies only to file analysis. Dependency and manifest checks always run against the whole tree, because a malicious transitive dependency appears in no diff.
Output
cordon-scanner scan . -f text # default, for a terminal
cordon-scanner scan . -f json # machine-readable
cordon-scanner scan . -f sarif:cordon.sarif # code scanning platforms
cordon-scanner scan . -f junit:results.xml # CI test reporters
cordon-scanner scan . -f markdown # PR comments, job summaries
cordon-scanner scan . -f github # inline annotations on a diff
cordon-scanner scan . -f github -f sarif:cordon.sarif -f json:result.json
--format is repeatable, and FMT:PATH writes that format to that file. Only
formats without a path go to stdout.
Controlling what appears in the report
cordon-scanner scan . --evidence masked # default: values are masked
cordon-scanner scan . --evidence hash_only # no snippets at all
Use hash_only when the report goes somewhere widely readable — a pull-request
comment, a SARIF upload to a third-party platform. Secret findings are hash-only
regardless of this setting.
Other commands
cordon-scanner inventory . # what is this repository, and the evidence
cordon-scanner rules list # every rule that can fire
cordon-scanner rules show RULE.ID # one rule in full
cordon-scanner rules test # run every rule's own samples
cordon-scanner config validate # check a configuration file
cordon-scanner config explain # effective settings and where each came from
cordon-scanner guard install # install fail-closed git hooks
cordon-scanner guard verify # check the hooks are intact
cordon-scanner baseline create # record today's findings as known
cordon-scanner baseline compare # fail only on what is new
cordon-scanner config explain is the one to reach for when a setting is not doing what
you expect: it prints the effective value of everything and names the layer that
supplied it.
Configure it
Cordon runs correctly with no configuration. Add a file when you need to change something.
Place any of cordon.yaml, cordon.yml, .cordon.yaml or .cordon.yml at the
root of the scanned tree. It is discovered automatically; --config PATH points
at one elsewhere.
version: 1
scan:
severity_threshold: low # info | low | medium | high | critical
confidence_threshold: low # low | medium | high | confirmed
exclude:
- "vendor/**"
- "**/*.min.js"
include: [] # empty means everything not excluded
detectors: # every detector is on unless named here
secrets: true
capability: true
minified: # treated as generated, not hand-written
- "dist/**"
offline: true # no network access. The default.
allow_plugins: false # third-party detectors. Off by default.
profile: balanced # fast | balanced | thorough
limits:
max_file_bytes: 10485760
total_timeout: 900
policy:
fail_on: [high, {category: malicious}]
fail_on_incomplete: false
min_confidence_to_fail: medium
evidence: masked # none | masked | hash_only
rules:
packs: [cordon-builtin]
extra: [] # additional YAML rule packs
disabled: [] # rule ids that must not fire
suppressions:
- rule: SUSPECT.DECODE_EXEC.001
path: "src/loader.js"
justification: "Reviewed by the platform team, tracked in TICKET-42."
expires: 2026-11-07 # required, and at most a year out
approved_by: "platform-team"
Unknown keys are refused, with a suggestion. A misspelled sevrity_threshold
that was silently ignored would leave you believing a threshold is in force when
the default is.
Configuration layers
Four layers, applied in this order, each able to make a setting stricter:
- Built-in defaults — safe with no configuration at all.
- Organisation policy —
--policyor$CORDON_POLICY. A ceiling, not a set of defaults. - Repository configuration — the file above.
- Command line — highest precedence, still checked against the ceiling.
A configuration file discovered inside the scanned tree is treated as
untrusted input, because it is part of what you are scanning. It cannot raise a
resource limit, and it cannot add a rule pack. Both are reported rather than
silently ignored. A file named with --config is operator input and keeps those
powers.
Suppressions
Every suppression names one rule and one path, carries a justification, and expires. Wildcards in either field are refused, and the maximum lifetime is one year. These are the tool's own requirements and apply whether or not an organisation policy is configured.
A suppressed finding stays in the report, marked, with its justification attached. An auditor's first question is what the tool was told to ignore.
Limits
| Limit | Default | What it bounds |
|---|---|---|
max_file_bytes |
10 MiB | Largest file read in full. Beyond it, a prefix is scanned and the scan is marked incomplete. |
max_line_bytes |
1 MiB | Longest line considered. |
max_total_bytes |
5 GiB | Total bytes traversed. |
max_files |
200,000 | Files walked. |
max_findings |
50,000 | Findings retained. |
max_dependencies |
100,000 | Nodes in the dependency graph. |
per_file_timeout |
5 s | Detector time on one file. |
total_timeout |
900 s | Whole scan. |
max_archive_ratio |
200 | Compression ratio before an archive is refused. |
max_archive_entries |
50,000 | Members extracted. |
max_archive_depth |
3 | Nested archive levels. |
max_uncompressed_bytes |
2 GiB | Expanded archive size. |
max_path_depth |
64 | Directory nesting. |
max_path_bytes |
4096 | Path length. |
max_memory_bytes |
1 GiB | File content held at once. |
max_workers |
0 (automatic) | Worker processes. |
mmap_threshold |
1 MiB | Unused; retained for compatibility. |
Reaching any limit produces a finding and marks the scan incomplete. A scan that stopped early and a scan that found nothing never look the same.
Environments
Local development
cordon-scanner scan .
The incremental cache makes repeat scans fast. It lives in
$XDG_CACHE_HOME/cordon or ~/.cache/cordon, and --cache-dir moves it.
Entries are authenticated, so an entry written by anything else is ignored.
Pre-commit hooks
Two options. Cordon's own, which fails closed:
cordon-scanner guard install # writes shims into .git/hooks
cordon-scanner guard verify # check they are still intact
The shims live in .git/hooks, which git does not track, so no commit, branch
switch, merge or git clean removes them. If cordon cannot run, the commit is
refused rather than allowed.
Or the pre-commit framework:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Threx-code/cordon
rev: v0.1.0
hooks:
- id: cordon
Either way the hook scans the git index, not the working tree.
GitHub Actions
- uses: Threx-code/cordon@<sha> # pin by commit, not by tag
with:
target: .
severity: medium
fail-on: high
sarif: true
Every input is passed through the environment rather than interpolated into a
shell command. The action refuses to run under pull_request_target, which
grants a writable token and repository secrets to a job that may check out
untrusted code.
Or call the CLI directly:
- run: pipx install cordon-scanner
- run: cordon-scanner scan . -f github -f sarif:cordon.sarif --fail-on high
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: cordon.sarif
if: always() matters: uploading only on success hides findings exactly when
there are some.
GitLab, Jenkins, Azure Pipelines
Templates are in ci/. All of them reduce to the same two lines:
pip install cordon-scanner
cordon-scanner scan . --fail-on high -f junit:cordon-junit.xml
Large repositories and monorepos
cordon-scanner scan . --tracked --jobs 8 --exclude 'third_party/**'
cordon-scanner scan . --git-diff origin/main # pull requests
--tracked skips build output and anything git ignores. On a pull request,
--git-diff narrows file analysis to what changed while still checking the whole
dependency graph.
If scans are slow, exclude generated trees rather than lowering a limit. Exclusions are visible in the report; a lowered limit is a coverage loss that looks like tuning, and is reported as one.
Air-gapped and offline
Cordon is offline by default and has no runtime dependencies. Nothing needs to be reachable.
The advisory database ships inside the wheel, so known-malicious package versions are matched without a network call. To use your own -- an OSV export, or an internal list -- pass it in:
cordon-scanner scan . --advisories ./advisories.json
The format is a JSON list, so an export script needs no library:
[
{"ecosystem": "npm", "name": "event-stream", "versions": ["3.3.6"],
"malicious": true, "summary": "...", "reference": "https://...", "id": "GHSA-..."}
]
A supplied database replaces the bundled one rather than adding to it: if you are stating what your organisation acts on, silently merging a shipped list into it would produce findings you did not choose.
pip download cordon-scanner -d ./wheels # on a connected machine
pip install --no-index --find-links ./wheels cordon-scanner
cordon-scanner scan . --offline
Rule packs ship inside the wheel. Nothing is fetched at scan time.
Enterprise, with an organisation policy
A policy file is a ceiling. Distribute it however you distribute configuration,
and point at it with --policy or $CORDON_POLICY.
# org-policy.yaml
version: 1
name: "acme-baseline"
issuer: "security@acme.example"
issued: 2026-01-01
enforce:
min_severity_threshold: low # repositories may not report less
min_confidence_threshold: low
detectors_required: [capability, manifest, secrets]
allow_limit_increase: false
allow_plugins: false
allow_extra_rule_packs: false
allow_network: false
max_total_timeout: 600
policy:
fail_on: [high, {category: malicious}]
fail_on_incomplete: true
suppressions:
max_duration_days: 90
require_justification: true
require_approver: true
forbid_path_only: true
forbid_categories: [malicious]
A repository whose configuration conflicts with the ceiling is refused, with the conflict named — not silently clamped, which would leave the repository owner believing a setting is in force when it is not. Command-line flags are checked against the same ceiling.
Tuning what fires
Too much noise, in order of preference:
- Raise the reporting threshold.
--severity mediumhides low-value findings. It cannot hide anything that fails the build; a report that omits the reason for a non-zero exit is worse than a noisy one. - Exclude generated trees.
exclude: ["dist/**", "vendor/**"]. - Disable a rule.
rules.disabled: [SUSPECT.OBFUSCATION.LONGLINE.001]. Reported, so the reduction is visible. - Suppress a specific finding. One rule, one path, a justification and an expiry.
- Disable a detector.
scan.detectors: {obfuscation: false}. The bluntest instrument; reported as a coverage loss.
Every one of these is recorded in the output. A check that was turned off and a check that found nothing must not look the same.
To see what a rule actually does before deciding:
cordon-scanner rules show SUSPECT.DECODE_EXEC.001
Adopting on an existing codebase
Turning a scanner on in a mature repository usually produces a backlog nobody can act on that day. Record it and gate on what is new:
cordon-scanner baseline create . # writes cordon-baseline.json
git add cordon-baseline.json && git commit -m "Record cordon-scanner baseline"
cordon-scanner scan . --baseline cordon-baseline.json # existing debt is marked
cordon-scanner baseline compare . # fails only on new findings
Baselined findings stay in the report, marked, so the debt is visible rather than deleted. Malicious findings are never baselined: "we have not fixed this yet" is not a coherent position about evidence of intent to harm.
Review the file before committing it. Every entry is something the repository is choosing not to fix yet.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Clean. The scan completed and nothing met the failure policy. |
| 1 | Findings. The scan completed and something met the failure policy. |
| 2 | Scanner error. Cordon itself failed — report it. |
| 3 | Configuration error. Fix the invocation or the config file. |
| 4 | Incomplete, and --fail-on-incomplete was set. |
if cordon-scanner scan . is correct with no flags, and any non-zero code fails safe.
The distinctions matter because a pipeline that cannot tell "the scanner broke"
from "your code is bad" gets configured to ignore both.
Using it as a library
from cordon_scanner import Scanner
scanner = Scanner.for_target("./repository")
result = scanner.scan("./repository")
for finding in result.findings:
print(finding.rule_id, finding.severity, finding.location)
Scanner.for_target assembles the configuration through the same resolver the
CLI uses, so the organisation ceiling applies. Constructing Scanner(config)
directly is supported for a config you built deliberately; it does not clamp,
because it cannot know where the config came from.
Everything returned is immutable, and output is deterministic: identical inputs produce identical findings in a stable order.
Why this exists
Most scanning answers "does the code I wrote contain a bug?" — well served by SAST tools, linters and CVE databases.
Cordon answers "is the code I did not write trying to attack me?"
A typical application is a few thousand lines of first-party code on top of a few hundred thousand lines of third-party code, pulled from a public registry, resolved transitively, and executed on the developer's machine at install time — before any test runs, any review happens, or any container boundary exists.
The attack pattern repeats with small variations. Someone gains publish rights
to a package, through a phished maintainer account, an expired domain, an
abandoned package handed to a volunteer, or a typosquatted name nobody watched.
They publish a version functionally identical to the last one, plus a lifecycle
script — postinstall, prepare, a build.rs, a setup.py command class, a
Gradle task. That script runs as the developer, with the developer's SSH keys,
cloud credentials and publish tokens. It reads what it can reach and sends it
somewhere, and increasingly uses what it stole to publish poisoned versions of
every package that developer maintains.
The whole exchange happens between typing an install command and getting a prompt back. There is no review step, no CI gate, no sandbox.
This is the documented shape of event-stream, ua-parser-js, coa, rc,
node-ipc, the torchtriton dependency-confusion incident, the xz-utils
backdoor, and the self-replicating npm worms of 2025.
Three properties follow from that, and they shape everything else:
- The scan target is untrusted input, including its configuration file. A repository cannot use its own config to blind the scan without the output saying so.
- Nothing from the target is ever executed. Lockfiles are parsed, never resolved. No package manager is invoked.
- Reduced coverage is always reported. A limit reached, a detector disabled, a file excluded, a rule turned off — each produces a finding. A scan that examined nothing must never look like a scan that found nothing.
What it does not claim
Cordon is a static analyser. It reads code and configuration; it does not run them, and static analysis cannot decide what a program will do at runtime. So the guarantee is deliberately narrower than "it catches attacks", and worth stating precisely:
No evasion is silent. A technique used to hide behaviour is either resolved to the real behaviour, or produces a signal of its own.
Renaming an import, binding a function to a local name, splitting a token or a primitive across a concatenation, computing a name at runtime, encoding a payload in several layers, writing a shell command inside another language — all of these are resolved, and each has a test that proves it. What cannot be resolved becomes its own finding: a target assembled at runtime is reported as dynamic dispatch, because reaching for a name that cannot be read is itself informative.
What remains is behaviour that exists only when the code runs — a target decoded from a network response, logic gated on a value that is fetched. No static tool observes that, and this one does not pretend to. Observing it requires running the code in isolation, which is a separate opt-in component precisely because "never executes the code it scans" is a promise worth keeping in the default tool.
Two further limits are worth knowing. Detection is strongest where a language
pack defines the capability primitives, and a language with no pack inherits no
behavioural rules — the coverage matrix in docs/05-COVERAGE-MATRIX.md says
which is which. And checks that need a registry to answer them (whether a
version was withdrawn, whether a hash matches what is published) require
--online, and are absent by default.
Documentation
| Document | Contents |
|---|---|
| docs/01-ARCHITECTURE.md | Components, detection engine, rule format, extension points |
| docs/02-THREAT-MODEL.md | Attacker profiles, trust boundaries, the constraints they imply |
| docs/03-INTERFACES.md | CLI, configuration and SDK reference; SARIF mapping |
| docs/04-OPERATIONS.md | Deployment, rule authoring, performance, release process |
| docs/05-COVERAGE-MATRIX.md | Every rule that ships, by threat domain and attack category |
| docs/06-SANDBOX.md | The opt-in component that runs a package in isolation, and what it observes |
Status
Alpha, and the classifier says so. The detection engine, rule packs, eleven
ecosystems, reporters, policy layer, baselines, git-aware scanning and the
advisory layer are implemented and tested; docs/03-INTERFACES.md separates the
commands that ship from those that are designed.
What alpha means here in practice: the interfaces may still change, the bundled
advisory set covers documented incidents rather than a full feed, and the benign
corpus behind the confidence: high measurement is small. None of those is
hidden -- cordon-scanner rules list shows what will run, rules diff shows what
changed, and every reduction in coverage is reported as a finding.
Licence
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cordon_scanner-0.1.0.tar.gz.
File metadata
- Download URL: cordon_scanner-0.1.0.tar.gz
- Upload date:
- Size: 690.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c838c4dedf3fa46541966aedf642489329d90daa47d2342e10a05bb08d106edf
|
|
| MD5 |
4491ad33e0e46a63670e90c96eccc7c9
|
|
| BLAKE2b-256 |
a63678a7524074bcdd428eecbe973b627672be8fabd5095b992fc6ec9d16e6bc
|
Provenance
The following attestation bundles were made for cordon_scanner-0.1.0.tar.gz:
Publisher:
release.yml on Threx-code/cordon
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cordon_scanner-0.1.0.tar.gz -
Subject digest:
c838c4dedf3fa46541966aedf642489329d90daa47d2342e10a05bb08d106edf - Sigstore transparency entry: 2772933865
- Sigstore integration time:
-
Permalink:
Threx-code/cordon@34f646241a91f3bd3731a495037664919eb4322e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Threx-code
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@34f646241a91f3bd3731a495037664919eb4322e -
Trigger Event:
push
-
Statement type:
File details
Details for the file cordon_scanner-0.1.0-py3-none-any.whl.
File metadata
- Download URL: cordon_scanner-0.1.0-py3-none-any.whl
- Upload date:
- Size: 462.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3288c11773403d82e5cbe2dc8aa223bb15d312bc03f2b6cd64da3d77feec039d
|
|
| MD5 |
85fbb1665b8be8e502ccb8204bfefdf9
|
|
| BLAKE2b-256 |
92b61dd8ffb21371b782463157baa53791d91c262f4588b14404f4634b8d459c
|
Provenance
The following attestation bundles were made for cordon_scanner-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Threx-code/cordon
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cordon_scanner-0.1.0-py3-none-any.whl -
Subject digest:
3288c11773403d82e5cbe2dc8aa223bb15d312bc03f2b6cd64da3d77feec039d - Sigstore transparency entry: 2772933905
- Sigstore integration time:
-
Permalink:
Threx-code/cordon@34f646241a91f3bd3731a495037664919eb4322e -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Threx-code
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@34f646241a91f3bd3731a495037664919eb4322e -
Trigger Event:
push
-
Statement type: