Referee is B.A.L.L.E.R.'s package security layer. It checks every package a command installs — the requested package and every resolved dependency — against public vulnerability data, and inspects each downloaded archive before its binary is linked.
It is enabled by default. --no-referee turns it off for one command;
enabled = false under [referee] in baller.conf turns it off permanently.
- The two phases
- Advisory identities
- Verdicts and the risk index
- Phase A — the advisory gate
- Phase B — the artifact scan
baller referee— the command group- Export formats
- Configuration
- Declaring an advisory identity
- Caching
- JSON output
- Errors
- Code map
- Scope and limits
| Phase | Runs | Scope | Can block? |
|---|---|---|---|
| A — advisory gate | Before the install loop, on the whole resolved plan | Every package, root and dependencies | Yes — aborts before anything is written |
| B — artifact scan | After download and extraction, before the binary is linked | Sources that download archives: GitHub, Baller registry, Chocolatey | Yes — aborts and purges the download |
System and Cargo packages produce no artifact B.A.L.L.E.R. handles — apt and
cargo install fetch and place their own content — so only Phase A applies to
them.
Phase A runs before the pre_install hook, so a blocked package never executes
a hook of its own.
Where each phase sits per command:
| Command | Phase A | Phase B |
|---|---|---|
draft |
On the full resolved plan, before the install loop | After extraction, before create_symlink |
update |
On the new version and any dependencies it pulls in | After extraction, before the old extract directory is pruned |
substitute |
On the replacement and its dependencies, before the old package is ejected | After extraction, before create_symlink |
build (manifest) |
On the one manifest package, after its download URL (and so its version) is resolved and before the pre_install hook |
After extraction, before create_symlink; a block purges the download |
build (Cargo project) |
On the crate's crates.io identity, before cargo build --release spends any compile time |
On the single compiled binary, before it is linked; a block leaves the binary in place |
build performs no dependency resolution. A manifest's dependencies are
recorded, not installed, and a Cargo project's are resolved by cargo itself, so
build's Phase A gates exactly one package rather than a plan. On the manifest
path the registry decides the version that is actually installed — a manifest
without download_url has it resolved, and the version overwritten, before
anything is downloaded — so the gate runs after that resolution, not on the
version the manifest declared. build --dry-run previews the gate like
draft --dry-run does: it prints the block it would hit and exits successfully.
That means a dry run of a manifest with no download_url now makes a registry
call and an advisory call; it used to touch no network at all.
On the Cargo-project path, Phase B scans only the binary about to be linked,
not target/release. That tree's deps/, incremental/, .fingerprint/ and
build/ subtrees routinely hold tens of thousands of files — enough to exhaust
the scanner's 20,000-file budget and leave coverage unknown, which is absence of
data passed off as safety — and build/<pkg>/out/ holds scripts generated by
dependency build scripts that are never installed. The single-file scan applies
the same rules, the same Unix permission check and the same VirusTotal lookup to
the exact artifact. A block does not delete the binary: it is the user's own
build output, not a download, so it is left where cargo put it and nothing is
linked.
eject is not gated: it removes a package and installs nothing, so there is
nothing for either phase to look at.
Because Phase A gates the whole plan up front, a block writes nothing at all — no symlink, no roster row, no cached archive — regardless of where in the plan the offending package sits.
Because update scans before pruning, a rejected upgrade leaves the previously
installed version linked and runnable.
A package is expanded into every identity public advisory data may know it by. All of them are queried in one batched request, and the highest risk found under any of them becomes the package's risk.
| Source | Identity | Scope |
|---|---|---|
Cargo { crate_name } |
(crates.io, crate_name) |
Primary |
GitHub { owner, repo } |
(GitHub, owner/repo) |
Primary |
Chocolatey |
(NuGet, name) |
Primary |
Chocolatey whose project_url points at github.com/owner/repo |
(GitHub, owner/repo) |
Derived |
System { apt } |
(Debian, name) |
Fallback |
System { dnf } |
(Fedora, name) |
Fallback |
System { pacman } |
none — reported unknown |
— |
BallerRegistry |
none, unless declared | — |
Any source with an [advisory] section |
the declared (ecosystem, name) |
Declared |
Any source whose metadata carries vulnerabilities |
the registry's own feed | Primary, no network call |
IdentityScope records where an identity came from, and appears in
--json output as scope:
| Scope | Source of the identity |
|---|---|
primary |
The package source's own ecosystem |
derived |
Read out of package metadata, such as a Chocolatey project_url |
declared |
Stated by the package itself, through [advisory] |
fallback |
A best-effort distro mapping |
Identities naming the same (ecosystem, name) are collapsed, keeping the
strongest scope (declared > primary > derived > fallback).
The derived Chocolatey identity exists because NuGet advisories are filed
against nuget.org ids: a Chocolatey wrapper for a tool like 7-Zip usually has
no NuGet record, while the tool's own project does. Referee reads the
project_url that already arrives with the Chocolatey metadata
(src/http/chocolatey.rs), turns it into an owner/repo pair with
parse_github_url (src/core/manifest.rs), and queries both identities in the
same batch.
An ecosystem is never inferred from a package name. A package that maps to no
identity is reported unknown.
Each identity produces one of four verdicts, and a package's overall verdict is the worst of its identities':
| Verdict | Meaning |
|---|---|
clean |
Queried, and no advisory matched this version |
vulnerable |
An advisory matched this version |
unknown |
Nothing to judge by — see below |
unverified |
The advisory lookup failed and Referee failed open |
Aggregation rules, in order:
- Any
vulnerableidentity makes the packagevulnerable, at the highest risk found. - Otherwise, any
unverifiedidentity makes the packageunverified. - Otherwise, if every identity is
unknown, the package isunknown. - Otherwise the package is
clean— at least one identity answered.
unknown is produced in three places, not only when a package has no
identity:
- the package maps to no advisory identity at all (
Referee::checkinsrc/security/mod.rs); - every verdict for the package is itself
unknown(PackageReport::statusinsrc/security/verdict.rs); - a cached verdict string this build does not recognise is read back
(
CachedVerdict::into_verdict).
unverified is narrower: the lookup itself failed and fail_policy = fail-open let the install continue (src/security/mod.rs). Both are covered by
the same Verdict::is_unchecked predicate. They are reported, never
presented as safety, and never blocked however strict the thresholds; both
appear in a closing summary line naming what was not verified.
Severity arrives from OSV as a CVSS vector string. Referee computes the base score — CVSS v3.0/v3.1 and v2 are implemented from their published formulas, and a bare number is taken as already scored — then halves it onto a 0–5 Referee Risk Index:
risk_index = highest_matched_cvss / 2.0
Scores are read in this order, first hit wins:
- the advisory's own
severitylist; - the
severityon a matchingaffectedentry; database_specific.severity, mappedCRITICAL→ 9.0,HIGH→ 7.5,MODERATE/MEDIUM→ 5.0,LOW→ 3.0.
CVSS v4.0 vectors are not computed from the vector; such an advisory falls through to step 3.
| Band | Default condition | Behaviour |
|---|---|---|
pass |
risk < warn_at (2.5, i.e. CVSS < 5.0) |
Install silently |
warn |
warn_at <= risk < block_at (CVSS 5.0–7.9) |
Print the advisory, install anyway |
block |
risk >= block_at (4.0, i.e. CVSS >= 8.0) |
Abort the whole plan |
A matched advisory with no published severity bands as warn: it cannot be
scored, so it is never passed as safe and never blocks. Only a vulnerable
package is banded — an unknown or unverified package is reported, never
blocked, however strict the thresholds.
An advisory's affected entries are matched locally as well as by the service:
- An explicit
versionslist is matched first, by exact string and then by normalised comparison, so a record listing1.21-76matches an installed1.21. rangesare normalised into intervals from theirintroduced/fixed/last_affected/limitevents.fixedexcludes its own version,last_affectedincludes it, anintroducedwith no terminator runs to infinity, and a terminator with nointroducedstarts from zero. Events are sorted before they are walked, so out-of-order event lists behave the same as sorted ones.SEMVERandECOSYSTEMrange bounds are parsed withparse_version_flexible(src/core/dep_solver.rs), which normalises Debian epochs, revisions, Fedora tags and zero-padded segments —2:8.1.0875-5ubuntu2becomes8.1.875.GITranges carry commits rather than versions and are skipped.- The installed version is parsed with
parse_advisory_version(src/security/ranges.rs), which keeps a semver pre-release instead of stripping it.1.2.3-rc1precedes1.2.3, so withintroduced: 0andfixed: 1.2.3a1.2.3-rc1install is inside the range and is reportedvulnerable— it used to collapse to1.2.3and reportclean. Distro ecosystems (Debian, Ubuntu, Fedora, Alpine, Red Hat, AlmaLinux, Rocky Linux, SUSE, openSUSE) are exempt and keep the lossy parser: a Debian1.2.3-7parses as semver with a7pre-release, but it is the seventh packaging of 1.2.3, and reading it as a release candidate would block patched packages. - The explicit
versionslist stays on the lossy comparison on purpose. A precise one could only remove matches — a record listing1.2.3would stop matching an installed1.2.3-rc1— and the publisher's own list must never lose a hit. - Ecosystems compare on the part before
:, so OSV'sDebian:11matchesDebian. - If none of a record's
affectedentries name the identity being checked, the service's own filtering is kept rather than the record being discarded.
A blocked plan:
$ baller draft badtool
Drafting badtool...
[Error]: referee blocked 1 package(s); nothing was installed
badtool v2.0.0 — risk index 4.90 is at or above the block threshold of 4.00
• GHSA-crit (CVE-2026-0001) CVSS 9.8 — badtool is affected
run with --no-referee to install anyway, or raise referee.block_at
A blocked dependency stops the whole plan, and nothing is downloaded:
$ baller draft root # root -> middle -> leaf; middle is critical
[Error]: referee blocked 1 package(s); nothing was installed
middle v0.4.0 — risk index 4.90 is at or above the block threshold of 4.00
$ baller roster
Empty No packages installed. Use 'baller draft <name>' to install.
A warning prints and continues:
$ baller draft warntool
Drafting warntool...
Referee warntool v3.0.0: risk index 3.05 of 5.00
• GHSA-med (CVE-2026-0002) CVSS 6.1 — warntool is affected
Done warntool v3.0.0 drafted!
--dry-run reports a block instead of raising it, and exits 0:
$ baller draft badtool --dry-run
Dry run badtool v2.0.0 from baller
• badtool v2.0.0 (root, install)
Download: https://example.test/badtool.tar.gz
✗ badtool v2.0.0 would be blocked — risk index 4.90 is at or above the block threshold of 4.00
• GHSA-crit (CVE-2026-0001) CVSS 9.8 — badtool is affected
Note nothing was installed
A rejected upgrade leaves the working version in place:
$ baller update upd
Updating upd: 1.0.0 -> 2.0.0
[Error]: referee blocked the downloaded archive for 'upd' v2.0.0 — the download was discarded
• BLOCK: [suspicious-payload] hook.sh — /dev/tcp/10.0.0.9/4444
$ baller roster
• upd v1.0.0
$ upd
hello from upd
One POST {osv_base_url}/v1/querybatch per 100 identities, plus one
GET /v1/vulns/{id} per distinct advisory that matched and needs its
severity, summary and ranges. A clean plan costs exactly one request; an
advisory affecting five packages in one plan is fetched once. Identities
answered from the cache cost nothing.
A detail fetch that fails does not discard the finding: the advisory is still
reported, with no score, which bands as warn.
Withdrawn advisories are ignored.
fail_policy = fail-open (the default) reports the affected packages as
unverified and continues:
$ baller draft tool
Referee 1 package(s) were not verified: tool (unverified)
Done tool v1.0.0 drafted!
fail_policy = fail-closed refuses instead:
[Error]: referee could not verify this install: network error: HTTP 500 …
(fail_policy = fail-closed, so nothing was installed)
An unverified verdict is never cached.
Every regular file in the extracted tree is read: text files directly, binaries
through a strings-style pass, so an embedded address or command is still
found. Matches are reported at most once per rule per file.
Findings are ordered worst-first after the optional VirusTotal lookup has
added its own, so a VirusTotal block leads the list rather than trailing every
scanner warning — in the install-time error and in referee audit alike. The
blocked-install error prefixes each finding with its severity in capitals
(• BLOCK: [suspicious-payload] install.sh — …, • WARN: …); the Markdown,
JSON and SARIF exports are unchanged.
The rule table is compiled once per scanner. If a rule's expression ever fails
to compile, only that rule is lost: the rest are rebuilt into a smaller set,
match indices are translated back to the table so no finding is attributed to
the wrong rule, and each lost rule is named in a warn-level log line on every
command that builds a scanner. A unit test fails CI if any rule in the shipped
table does not compile.
| Rule | Matches | Severity |
|---|---|---|
suspicious-payload |
base64 -d | sh, echo <blob> | base64, [Convert]::FromBase64String with iex, certutil -urlcache, iex(New-Object Net.WebClient), DownloadString(...) | iex, powershell -enc, -ExecutionPolicy bypass with -WindowStyle hidden |
block |
suspicious-payload |
/dev/tcp/host/port, nc -e /bin/sh |
block |
suspicious-payload |
reg add …\CurrentVersion\Run |
block |
suspicious-payload |
chmod +x /tmp/…, %TEMP%\… followed by Start-Process |
block |
suspicious-payload |
appending to $HOME/.bashrc, .zshrc, .profile, .bash_profile |
block |
suspicious-payload |
curl … | sh, iwr … | iex, schtasks /create, New-ScheduledTask*, crontab -, /etc/cron.d/, systemctl enable |
warn |
exfil-attempt |
A credential env-var name (AWS_SECRET_ACCESS_KEY, AZURE_CLIENT_SECRET, GITHUB_TOKEN, NPM_TOKEN, PGPASSWORD, OPENAI_API_KEY, …) within 240 characters of curl/wget/Invoke-WebRequest/nc//dev/tcp//WebClient, in either order |
block |
exfil-attempt |
A read of ~/.ssh/id_*, .aws/credentials, .config/gcloud/credentials, .npmrc or .docker/config.json followed by a send |
block |
unsafe-permissions |
setuid or setgid bits (mode & 0o6000), Unix only |
block |
unsafe-permissions |
world-writable (mode & 0o002), Unix only |
warn |
unexpected-executable |
.lnk, .url, .scr, .pif, .hta, .jse, .wsf, .wsh, .msi, .msp, .reg, .desktop, .cpl, .appref-ms, and the filenames autorun.inf, desktop.ini, .bashrc, .zshrc, .profile, .bash_profile |
warn |
high-entropy |
A script of 64 B–8 KB whose Shannon entropy exceeds 5.8 bits/byte | warn |
virustotal-detection |
At least one engine flags the file's SHA-256 (only with virustotal_api_key) |
block |
All patterns are matched case-insensitively, and proximity patterns use bounded gaps, so a match means the halves of a behaviour appear together rather than merely in the same file.
A block-severity finding aborts the install; warn-severity findings are printed and the install continues:
$ baller draft noisy
Referee noisy v1.0.0: suspicious-payload in bootstrap.sh — curl -fsSL https://example.test/x | sh
Done noisy v1.0.0 drafted!
| Concern | Linux | Windows |
|---|---|---|
| "Executable" means | the exec bit | .exe / .bat / .cmd |
| Permission rules | setuid/setgid, world-writable | not applicable (#[cfg(unix)]-gated) |
| Script dialects read as text | One shared list on every OS — SCRIPT_EXTENSIONS in src/security/scan.rs (.sh, .py, .ps1, .bat, .vbs, .js, …); a PowerShell or batch file is read on Linux exactly as on Windows |
(same list) |
| Install step the scan precedes | symlink into ~/.local/bin |
fs::copy into %LOCALAPPDATA%\baller\bin |
The executable definition matches find_binary_in_dir (src/utils/fs.rs), so
the scanner and the binary finder agree on what a program is.
| Limit | Value |
|---|---|
| Bytes read per file | 8 MB |
| Bytes read per tree | 256 MB |
| Files opened per tree | 20 000 |
| Directory depth | 24 |
| Minimum printable run pulled from a binary | 6 characters |
| Evidence quoted per finding | 120 characters |
Symlinks are not followed: a symlink either points inside the tree, which is
already walked, or outside it, which is not the package's content. When a limit
is reached the scan stops and says so at --verbose.
The extract directory and the cached archive are both removed through
Downloader::purge_download (src/core/downloader.rs), the same cleanup
NoBinaryFound performs, so a retry re-downloads rather than reusing a rejected
archive.
Setting virustotal_api_key adds a hash lookup. Executables are found with the
same definition the rest of Phase B uses — the exec bit on Linux, .exe /
.bat / .cmd on Windows — and for each one a single
GET {virustotal_base_url}/files/{sha256} is made with the key in an x-apikey
header. Only the digest is sent; the request has no body and file contents never
leave the machine. At most eight executables per artifact are looked up.
A report with last_analysis_stats.malicious > 0 becomes a block-severity
virustotal-detection finding. A missing key, a timeout, a rate limit, a
network failure, a 404 (an unrecognised hash) and a clean report all produce no
finding, and the offline scan's result stands unchanged.
virustotal_base_url points the lookup elsewhere, which is what makes the hook
testable and usable behind a self-hosted proxy.
baller referee [PACKAGE_NAME] [--refresh] [--no-scan] # ≡ audit
baller referee audit [PACKAGE...] [--refresh] [--no-scan]
[--fail-on block|warn] [--format json|markdown|sarif] [--out FILE]
baller referee check [PACKAGE...] [--refresh] [--fail-on block|warn]
baller referee scan [PACKAGE...] [--fail-on block|warn]
baller referee cache [--status | --clear | --prune <DAYS> [--include-vulnerable]]
baller referee config
baller referee sbom [--out FILE] [--format cyclonedx-json]
Every subcommand is read-only with respect to the roster: nothing is ejected,
updated, linked or unlinked. cache writes only to the verdict cache. audit,
check and scan refuse to run with Referee disabled; cache, config and
sbom do not consult it and work either way.
A package name is resolved against the roster in three forms — the exact name,
the part after the last /, and the part after the last : — so ripgrep,
BurntSushi/ripgrep and cargo:ripgrep all find the same row. audit,
check and scan accept several names; a name given twice is audited once.
The bare baller referee [PACKAGE_NAME] runs audit with unchanged behavior.
It rebuilds each installed package from its roster row — including the advisory
identity it was installed under — checks it against advisory data, and re-scans
its extracted tree where one is still on disk.
$ baller referee --refresh
Refereeing 2 package(s) — warn at 2.50, block at 4.00 on the 0-5 risk scale
PACKAGE VERSION STATUS RISK ADVISORIES
alpha 1.0.0 vulnerable 4.90 GHSA-late
• GHSA-late (CVE-2026-4242) CVSS 9.8 — a serious flaw in alpha
beta 2.0.0 clean — —
! warn [suspicious-payload] bootstrap.sh — curl -fsSL https://x.test | sh
Summary 2 package(s): 1 over the block threshold, 0 warned, 0 not verified
Note the audit changes nothing — eject or update a flagged package yourself
| Flag | Effect |
|---|---|
--refresh |
Clears the verdict cache and re-queries the advisory service |
--no-scan |
Checks advisory data only; skips the artifact re-scan |
--fail-on block|warn |
Exit code for CI (see below) |
--format json|markdown|sarif |
Renders the report in another format (see Export formats) |
--out FILE |
Writes the formatted report to FILE; requires --format |
A package whose extract directory has been swept reports artifact not on disk — nothing to re-scan, which is distinct from a clean scan.
The same path audit --no-scan takes, as its own verb: advisory data is
checked (honoring --refresh and --fail-on) and no extracted tree is walked.
Re-scans each installed package's extracted tree without any advisory lookup,
and reports per package clean, N finding(s), not on disk (the extract
directory was swept) or not scanned (no install path recorded). It accepts
--fail-on block|warn (see below). --json
prints {"command": "referee", "subcommand": "scan", "packages": [...]} with
the same scan object audit --json uses.
--fail-on (on audit, check and scan) turns the report into a CI gate
without changing what the command does. A package trips it through either
phase, judged the way an install would judge it: advisories by the gate's own
banding, re-scan findings by their severity.
| Flag | Exit 1 when |
|---|---|
| (none) | never — the command always exits 0 when it runs |
--fail-on block |
an advisory is at or above block_at, or a re-scan found a block-severity finding |
--fail-on warn |
an advisory is at or above warn_at, or a re-scan found any finding (everything block catches included) |
check never re-scans, so only advisories count there; scan consults no
advisory data, so only findings count; audit --no-scan behaves like check.
The report (table, --json document or --format output) is printed first;
the failure is a RefereeAuditFailed error on stderr naming each package and
the phase(s) that tripped it, e.g. alpha v1.0.0 (advisory), epsilon v5.0.0 (scan). unverified and unknown packages never trip --fail-on: they have
no band. Nor does a package whose artifact is not on disk — there was nothing
to scan.
| Flag | Effect |
|---|---|
--status (default) |
Row count per ecosystem, the newest checked_at (UTC), and how many clean verdicts cache_ttl_days has aged out |
--clear |
Empties the cache — what --refresh does before an audit |
--prune <DAYS> |
Deletes clean verdicts computed more than DAYS days ago; vulnerable ones are kept, and how many were kept is reported |
--include-vulnerable |
With --prune, also deletes vulnerable verdicts past the cutoff, and reports how many went |
--status, --clear and --prune are mutually exclusive;
--include-vulnerable requires --prune. Pruning keeps vulnerable rows by
default for the same reason the TTL never ages them out: deleting one turns a
known vulnerability back into "no data", which an audit then reports as
unknown or unverified rather than vulnerable. The JSON document adds
removed_vulnerable and kept_vulnerable to the existing command,
subcommand, action, days and removed keys. Verdict staleness has two complementary
answers: cache_ttl_days (opt-in, see Configuration) decides
which rows are no longer acted on — a clean verdict older than the TTL is
re-queried on the next install — while --prune deletes rows. With the TTL
unset, cached verdicts never age out and --prune is the only answer.
--status --json carries cache_ttl_days and stale, the number of clean
verdicts the next install will re-query (0 when the TTL is unset).
Prints the [referee] settings actually in effect: enabled (which accounts
for --no-referee), warn_at, block_at, fail_policy, osv_base_url,
cache_ttl_days (off / null when unset), virustotal_base_url (<default> / null when unset) and
virustotal_api_key: set|unset. The key's value is never printed.
--json prints {"command": "referee", "subcommand": "config", "config": {...}}.
Emits a CycloneDX 1.5 JSON document built from the roster alone — nothing is fetched:
- one
components[]entry per installed package:type: application,bom-ref(name@version),name,version,description, apurlfor Cargo (pkg:cargo/…) and GitHub (pkg:github/…) packages,hashes(SHA-256, only when the roster holds a 64-hex digest),externalReferences(distribution=download_url,vcs= repository) andpropertiesballer:source/baller:user_installed; - one
dependencies[]entry per component, withdependsOnbuilt from thepackage_dependenciestable. An edge to a package baller did not install (a system library, a virtual package) has no component and is left out.
SPDX output and license data are not produced: the roster stores no license.
audit --format renders the same audit three ways. With no --out the
rendered document replaces the table on stdout; with --out FILE it is written
to the file and stdout keeps the normal table (or --json document).
json— theballer referee --jsondocument described in JSON output.markdown— a# Referee auditdocument with the thresholds and fail policy, one table row per package (package, version, status, band, risk, advisories), a## Detailssection per package with advisories, unverified identities and scan findings, and the closing summary.sarif— a SARIF 2.1.0 log with one run whosetool.driverisballer-referee.tool.driver.rulesholds one rule per advisory id (helpUripointing atosv.dev), one per scan rule (scan/<rule>), andreferee/unverified.resultsholds one result per matched advisory, per scan finding, and per unverified/unknown package; each carriesruleId,ruleIndex, a packagelogicalLocation(plus the file'sphysicalLocationfor a scan finding) andpackage/version/sourceproperties.
| Source | SARIF level |
|---|---|
Advisory banded block on its own CVSS |
error |
Advisory banded warn (including unscored) |
warning |
Advisory banded pass |
note |
Scan finding block / warn |
error / warning |
| Unverified or unknown package | note |
[referee]
enabled = true
warn_at = 2.5
block_at = 4.0
fail_policy = fail-open
osv_base_url = https://api.osv.dev
# cache_ttl_days = 7
# virustotal_api_key = <your key>
# virustotal_base_url = https://www.virustotal.com/api/v3| Key | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | true |
Master switch for both phases. referee_enabled is an accepted alias |
warn_at |
float 0–5 | 2.5 |
Risk index at or above which a package is reported |
block_at |
float 0–5 | 4.0 |
Risk index at or above which the plan is aborted |
fail_policy |
fail-open / fail-closed |
fail-open |
What an unreachable advisory service means. open and closed are accepted, and _ is read as - |
osv_base_url |
string | https://api.osv.dev |
Advisory API base URL, for self-hosting and tests |
cache_ttl_days |
whole number ≥ 0 | unset | How old a cached clean verdict may be before it is re-queried. Unset keeps cached verdicts indefinitely; 0 re-queries every clean verdict on every install (what CI wants). A cached vulnerable verdict is never re-queried |
virustotal_api_key |
string | unset | Enables the hash-only VirusTotal lookup |
virustotal_base_url |
string | https://www.virustotal.com/api/v3 |
VirusTotal API base URL, for tests and self-hosted proxies |
0 <= warn_at < block_at <= 5 is validated when the config is parsed:
$ baller roster
[Error]: referee warn_at (4.5) must be below block_at (4)
--no-referee overrides enabled = true for one command. With Referee
disabled, baller referee audit, check and scan — including the bare
baller referee form, which is audit — refuse to run; cache, config and
sbom work either way, because none of them asks anything of the advisory
service:
$ baller --no-referee referee
[Error]: referee is disabled — remove --no-referee, or set 'enabled = true' under [referee] in baller.conf
A package can state where its known-issue surface lives. In a baller build
manifest:
name = "ripgrep"
version = "14.1.1"
[advisory]
ecosystem = "crates.io" # OSV ecosystem: crates.io, npm, NuGet, PyPI, GitHub, …
name = "ripgrep" # optional; defaults to the package name
aliases = ["CVE-2026-1234"] # advisory ids this package is tracked underThe same fields work in a JSON manifest and in registry-served metadata:
{ "name": "ripgrep", "version": "14.1.1",
"advisory": { "ecosystem": "crates.io", "aliases": ["CVE-2026-1234"] } }ecosystem+namebecome aDeclaredidentity, queried in the same batch as any primary one. A section withoutecosystemnames nothing queryable and is ignored.- Each entry in
aliasesis fetched by id and range-checked against the installed version, so an alias reports its issue on the versions it actually affects. Aliases are reported under the identitydeclared:<package name>. - The declaration is stored on the roster (the
advisorycolumn ofinstalled_packages), soballer refereere-checks the package under the same identity the install used.
A registry may instead ship OSV-shaped records with the metadata:
{
"name": "tool",
"version": "2.0.0",
"vulnerabilities": [
{
"id": "BALLER-2026-0001",
"summary": "the registry knows about this one",
"severity": [{ "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }],
"affected": [{ "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }] }] }]
}
]
}These are evaluated locally under the identity BallerRegistry:<name> and cost
no network request. Records that cannot be decoded are skipped individually and
logged at --verbose.
Verdicts live in the existing SQLite database:
CREATE TABLE referee_cache (
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
verdict TEXT NOT NULL, -- clean | vulnerable
risk REAL,
advisories TEXT NOT NULL, -- JSON array of matched advisories
checked_at TEXT NOT NULL,
PRIMARY KEY (ecosystem, name, version)
);- A row is only ever reused for the exact
(ecosystem, name, version)it was computed from. - Only
cleanandvulnerableare stored.unknownandunverifiedare not cached. - A cache hit makes no network request;
--verboselogs the hit and the date the verdict was computed. checked_atdrives freshness whencache_ttl_daysis set: acleanrow older than the TTL is not used, and the identity is re-queried. If that re-query cannot reach the advisory service, the package isunverified(underfail-open) — the stalecleannever stands in for an answer.- A
vulnerablerow is exempt from the TTL and is used whatever its age: re-querying it could only confirm the block it already causes. Because such a block may rest on old data, it says so — the block reason names thechecked_atof the cached verdict, anddraft --dry-runprints its age. - Every replayed verdict is identifiable: JSON identities carry
cachedandchecked_at(packages carryall_cachedandoldest_checked_at), the Markdown export has aCheckedcolumn, and SARIF results carrycachedandcheckedAtinproperties. - A row whose
verdictthis build does not recognise is read asunknownand re-queried. baller referee --refreshandballer referee cache --clearempty the table;baller referee cache --prune <DAYS>drops rows older than DAYS days exceptvulnerableones, which go only with--include-vulnerable; andballer referee cacheshows row counts per ecosystem.
Cache rows survive baller sweep, which clears downloads rather than database
state.
--json embeds the report in each command's single JSON document:
{
"command": "draft",
"package": "badtool",
"version": "2.0.0",
"referee": {
"enabled": true,
"warn_at": 2.5,
"block_at": 4.0,
"packages": [
{
"name": "badtool",
"version": "2.0.0",
"source": "baller",
"status": "vulnerable",
"band": "block",
"risk": 4.9,
"advisories": [
{ "id": "GHSA-crit", "aliases": ["CVE-2026-0001"], "cvss": 9.8,
"summary": "badtool is affected" }
],
"identities": [
{ "ecosystem": "crates.io", "name": "badtool", "scope": "declared",
"status": "vulnerable", "risk": 4.9, "advisories": [] }
]
}
]
}
}enabled is false when Referee was skipped. risk and cvss are null when
nothing was scored, and are rounded to two and one decimal places respectively.
Each package's band is computed against the warn_at / block_at in effect —
the same values the document reports at its top level.
advisories is flattened across identities, worst first; identities keeps the
per-identity breakdown.
baller referee --json uses the same package shape, adds fail_policy at the
top level, and gives each package a scan object:
{ "scan": { "scanned": true, "findings": [
{ "path": "bootstrap.sh", "rule": "suspicious-payload", "severity": "warn",
"evidence": "curl -fsSL https://x.test | sh" } ] } }{"scanned": false} means --no-scan or a source with no artifact;
{"scanned": false, "reason": "install path is gone"} means the extract
directory is no longer on disk.
| Variant | Raised when | State left behind |
|---|---|---|
RefereeBlocked |
A package crosses block_at in Phase A |
Nothing written: no download, no symlink, no roster row |
RefereeScanBlocked |
Phase B finds a block-severity issue | Extract directory and cached archive both purged; on build's Cargo-project path the compiled binary is left in place and nothing is linked |
RefereeUnavailable |
The advisory service is unreachable under fail-closed |
Nothing written |
RefereeAuditFailed |
baller referee audit/check/scan --fail-on found an advisory or re-scan finding at or above the level |
Nothing written — the report is printed first; only the exit code changes |
RefereeBlocked lists every package over the threshold with its advisories and
reason, and names the escape hatches. All four exit non-zero, like any other
failed command. See error-handling.md.
Referee lives in src/security/:
| File | Contents |
|---|---|
mod.rs |
Referee (the service on AppContext), Referee::gate, Referee::audit, Referee::screen_artifact, GateOutcome, FailPolicy |
export.rs |
ScanOutcome, render_markdown, render_sarif — the audit's Markdown and SARIF 2.1.0 writers |
identity.rs |
AdvisoryIdentity, IdentityScope, advisory_identities(&Package) |
osv.rs |
OsvClient (query_batch, vuln), and the wire types Vulnerability, Severity, Affected, Range, Event |
ranges.rs |
affects, entry_affects, entry_is_about — affected-version interval matching |
scoring.rs |
cvss_score, best_cvss, qualitative_cvss, risk_index, classify, Band, RefereeThresholds |
verdict.rs |
Verdict, MatchedAdvisory, AdvisoryVerdict, PackageReport |
scan.rs |
ArtifactScanner (scan_tree, scan_file_at, uncompiled_rules), ScanRule, ScanSeverity, ScanFinding, has_blocking, sort_findings, executable_candidates, shannon_entropy |
virustotal.rs |
VirusTotalClient::screen — the hash-only lookup |
Entry points elsewhere:
| Location | What it does |
|---|---|
src/context.rs |
Builds Referee from config.referee and GlobalFlags::no_referee |
src/config/config.rs |
RefereeConfig, the [referee] keys, threshold validation |
src/commands/draft.rs |
Phase A before the install loop; screen_artifact() wraps Phase B and purges on a block; screen_binary() wraps the single-binary scan and never purges; report_blocked() prints a dry run's blocked packages |
src/commands/build.rs |
Phase A on the one manifest package after URL resolution, or on a Cargo project before compiling; Phase B via screen_artifact() (manifest) or screen_binary() (Cargo project) |
src/commands/update.rs |
Phase A on the upgrade plan; Phase B before the old extract directory is pruned |
src/commands/substitute.rs |
Phase A before the old package is ejected; Phase B before linking |
src/commands/referee/ |
The baller referee group: mod.rs (dispatcher, roster resolution, re-scan, table, fail_on_error), audit.rs, check.rs, scan.rs, cache.rs, config.rs, sbom.rs (CycloneDX writer), export.rs (stdout / --out delivery) |
src/cli/parse.rs |
RefereeArgs / RefereeSub — the clap group and the bare-form back-compat |
src/core/db.rs |
referee_cache_get / _get_fresh / _put / _clear / _count / _stats / _stale_count / _prune (and its RefereeCachePrune outcome), the advisory roster column, InstalledPackage::to_package |
src/core/package.rs |
AdvisoryDeclaration, Package::advisory_identities, Package::declared_aliases |
src/error/error.rs |
RefereeBlocked, RefereeScanBlocked, RefereeUnavailable, RefereeAuditFailed, BlockedPackage |
src/http/mod.rs |
HttpClient::post_json, HttpClient::get_json_optional_with_headers |
Unit tests sit beside each module. src/security/integration.rs runs the whole
service against a mock advisory server built on TcpListener: banding,
blocking, fail-open and fail-closed, cache hits and misses, batch chunking and
result alignment, hydration, withdrawn and out-of-range advisories, declared
aliases, registry-native records, both Phase B outcomes, and the --fail-on
exit-code banding.
src/benches/workflow.rs carries referee_phase_a_cached (a 25-package cached
gate) and referee_phase_b_scan (a small extracted tree).
Referee reports known vulnerabilities and a small set of artifact signals. It is
not an antivirus, not a guarantee against a novel supply-chain attack, and not
a license scanner. baller referee sbom inventories what is installed, but
carries no license data and produces CycloneDX only (no SPDX).
Known gaps:
GitHubecosystem coverage. The repo-scoped(GitHub, owner/repo)identity behaves identically on Linux and Windows, but public advisory coverage under a bare repo identity is thinner than under the language ecosystems. Acleanverdict there means "no record". A package that also publishes to a language ecosystem should declare it with[advisory].- Distro identities are best-effort. Debian and Fedora versions are
normalised heuristically, and distro source package names differ from binary
ones — hence
Fallbackscope.pacmanmaps to no OSV ecosystem and reportsunknown. - Chocolatey ids with no NuGet record and no GitHub
project_urlmap to nothing and reportunknown. - CVSS v4.0 vectors are not computed from the vector; such advisories fall back to a qualitative rating, and warn when there is none.
- The Baller registry serves no advisory data yet
(#10), so registry-sourced
packages are
unknownunless they carry[advisory]orvulnerabilities. baller buildgates one package. It does no dependency resolution, so a manifest's recordeddependenciesare not checked until they are drafted.