Skip to main content

Cordon

CI PyPI Python Runtime dependencies License Coverage matrix

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:

Terminal output: seven critical findings in a compromised npm package

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.


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:

  1. Built-in defaults — safe with no configuration at all.
  2. Organisation policy--policy or $CORDON_POLICY. A ceiling, not a set of defaults.
  3. Repository configuration — the file above.
  4. 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:

  1. Raise the reporting threshold. --severity medium hides 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.
  2. Exclude generated trees. exclude: ["dist/**", "vendor/**"].
  3. Disable a rule. rules.disabled: [SUSPECT.OBFUSCATION.LONGLINE.001]. Reported, so the reduction is visible.
  4. Suppress a specific finding. One rule, one path, a justification and an expiry.
  5. 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

Apache-2.0. See LICENSE and NOTICE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cordon_scanner-0.1.0.tar.gz (690.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cordon_scanner-0.1.0-py3-none-any.whl (462.3 kB view details)

Uploaded Python 3

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

Hashes for cordon_scanner-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c838c4dedf3fa46541966aedf642489329d90daa47d2342e10a05bb08d106edf
MD5 4491ad33e0e46a63670e90c96eccc7c9
BLAKE2b-256 a63678a7524074bcdd428eecbe973b627672be8fabd5095b992fc6ec9d16e6bc

See more details on using hashes here.

Provenance

The following attestation bundles were made for cordon_scanner-0.1.0.tar.gz:

Publisher: release.yml on Threx-code/cordon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for cordon_scanner-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3288c11773403d82e5cbe2dc8aa223bb15d312bc03f2b6cd64da3d77feec039d
MD5 85fbb1665b8be8e502ccb8204bfefdf9
BLAKE2b-256 92b61dd8ffb21371b782463157baa53791d91c262f4588b14404f4634b8d459c

See more details on using hashes here.

Provenance

The following attestation bundles were made for cordon_scanner-0.1.0-py3-none-any.whl:

Publisher: release.yml on Threx-code/cordon

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 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