Metadata-Version: 2.4
Name: aspora-threatlenscli
Version: 0.4.0
Summary: Image, code and open-source dependency scanning in your CI, reported to a ThreatLens server
Keywords: ci,container-scanning,gitleaks,sast,sca,security,semgrep,trivy
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# threatlenscli — ThreatLens CLI

Snyk-style scanning in **your** CI. The scanners run on the runner; only the
results go to your ThreatLens server. The server never builds your code, never pulls
your private images and never needs a Docker socket.

| Command | Scans | Engines (on this machine) | Snyk equivalent |
|---|---|---|---|
| `threatlenscli oss [PATH]` | open-source dependencies, incl. transitive | `trivy fs` (lockfiles) ∪ `trivy rootfs` (installed artefacts) ∪ optional build-tool SBOM | `snyk test` / `snyk monitor` |
| `threatlenscli code [PATH]` | SAST, code quality, secrets, IaC misconfig | Semgrep CE, Gitleaks (else Trivy secret), Trivy misconfig | `snyk code test` + `snyk iac test` |
| `threatlenscli image <REF>` (`scan`, `container`) | container image: OS + library vulns, secrets, misconfig | Trivy (same flags as the server) | `snyk container test` |

It is a single Python file with zero pip dependencies (Python 3.8+).

## Install

From PyPI (the package is `aspora-threatlenscli`; the command is `threatlenscli`):

```bash
pipx install aspora-threatlenscli            # or: pip install aspora-threatlenscli
pipx install aspora-threatlenscli==0.4.0     # in CI: pin the version
```

Or the single file straight from the repository:

```bash
sudo install -m 755 cli/threatlenscli /usr/local/bin/threatlenscli      # or: ./cli/threatlenscli install
threatlenscli --version                                         # threatlenscli 0.2.0
threatlenscli doctor                                            # checks everything below
```

In CI, install threatlenscli from the commit you reviewed and verify it (SUP-1/SUP-5):

```bash
curl -fsSLo threatlenscli https://raw.githubusercontent.com/<org>/<repo>/<40-char-sha>/cli/threatlenscli
echo "<sha256 from: git show <sha>:cli/threatlenscli | sha256sum>  threatlenscli" | sha256sum -c -
```

Requirements on the runner:

- **Trivy >= 0.53** on `PATH`. Install a pinned release and verify its checksum.
  threatlenscli refuses the credential-stealing releases **0.69.4, 0.69.5 and 0.69.6**
  (GHSA-69fq-xp46-6x23). If `doctor` ever reports one, remove it and rotate every
  secret that runner can reach. Add more entries with
  `THREATLENS_SCANNER_VERSION_DENYLIST=trivy:x.y.z,...`.
- For `code`: **semgrep**, installed hash-locked from the server's
  `backend/semgrep.lock` of the reviewed commit (`python3.14 -m venv /opt/semgrep &&
  /opt/semgrep/bin/pip install --require-hashes --only-binary=:all: -r backend/semgrep.lock`;
  the lock is resolved for CPython 3.14 on Linux, so another interpreter fails
  loudly instead of installing unhashed files) and, optionally, **gitleaks** (v8
  with the `dir` command). Without semgrep the code scan has no SAST and no
  quality rules. threatlenscli says so on stderr, and `--require-sast` turns that into
  exit 2. semgrep and gitleaks are checked against the same denylist
  (`THREATLENS_SCANNER_VERSION_DENYLIST=semgrep:x.y.z,gitleaks:x.y.z`); an unparseable
  version exits 2 (fail closed).
- For `image`: the image must be reachable by Trivy (local Docker daemon or a
  registry). Registry credentials go in `TRIVY_USERNAME` / `TRIVY_PASSWORD`, never on argv.

GitHub Actions: pin every third-party action (including any Trivy action) by
its full commit SHA. The trivy-action and setup-trivy tags were compromised in
the same incident.

## Authenticate

An admin creates a key in **Settings → API Keys**. Give CI keys the narrowest scope:

| Command | Scope | Legacy scope (still accepted, audited as deprecated) |
|---|---|---|
| `threatlenscli oss` | `ingest:oss` | `code:write` |
| `threatlenscli code` | `ingest:code` | `code:write` |
| `threatlenscli image` | `ingest:image` | `images:write` |

An `ingest:*` key can upload results and read back its own runs. It cannot read
other findings, add images or trigger server scans. Keys expire after 90 days
by default.

**Allowed identities.** A key can be bound to the projects it may write, using
glob patterns over normalised identities: `github.com/acme/*` or
`github.com/acme/payments` (`*` matches one path segment, `**` any number).
With an empty list, the key may only write projects it created itself. An
upload for any other project gets 403, and the refusal is audited. Set these
bindings for every shared CI key, and again after rotating a key.

```bash
threatlenscli login        # asks for your ThreatLens URL, opens the browser: sign in, approve, done
```

`login` opens the ThreatLens sign-in page in your browser (or prints the link, for
SSH sessions). Sign in, check that the code matches your terminal, and approve; the
terminal then says `Authenticated as you@company.com` and every command works. The
CLI receives its own upload-only key (90 days, revocable; `threatlenscli logout`
revokes it). Admins' CLIs may upload for any repository; a member's CLI only for
projects and branches it creates itself. To paste a key instead:
`threatlenscli login --with-api-key`.

No server is built in. In CI set both environment variables; they always win over
`~/.threatlenscli/config.json`. On a CI runner an env key is never paired with the
server a stale config on a shared runner names:

```bash
export THREATLENS_SERVER=${{ vars.THREATLENS_SERVER }}     # e.g. https://threatlens.example.com
export THREATLENS_API_KEY=${{ secrets.THREATLENS_API_KEY }}
```


Transport rules:
- TLS certificates are always verified. Use `SSL_CERT_FILE` for a private CA.
- Plain `http://` works only for loopback. For another host, outside CI only,
  pass `login --insecure` or set `THREATLENS_ALLOW_INSECURE=1`. It is never allowed in CI.
- Redirects to another host or port, or from https to http, are refused, so the
  key is never forwarded. A key saved for server A is never sent to a different
  `THREATLENS_SERVER`.
- Reads (GET) retry 429, 502, 503, 504 and network errors with backoff
  (`Retry-After` honoured). The upload (POST) is retried only when the server
  explicitly did not process it (429, or 503 `ingest_busy`/`scan_in_progress`)
  or the request never left the runner (connection refused, DNS, send timeout).
  A lost response (read timeout, reset, 502/504) is NOT retried: the server may
  have stored the results, so the CLI exits 2 and says to check the UI.
  `--upload-timeout` (default 300 s) sets how long to wait for the response.
  Large uploads are gzip-compressed.

## Exit codes (all scan commands)

| Code | Meaning |
|---|---|
| `0` | scan completed, nothing at or above the gate |
| `1` | scan completed, gating issues found |
| `2` | error: scanner failure or timeout, network/TLS, HTTP 4xx/5xx, upload refused, bad flag (`--fail-on patchable`, unknown `--fail-on` value), `--require-sast` without semgrep, denylisted or too-old Trivy |
| `3` | nothing scannable. `oss`: no package resolved by any pass (the unresolved manifests are listed). `code`: no file to scan. Never for images |

## Gating (CI policy)

```
--severity-threshold low|medium|high|critical   issues at or ABOVE this severity gate (default low;
                                                 'unknown' gates as medium)
--fail-on all          fail only if a gating issue has a fix
--fail-on upgradable   fail only if upgrading fixes it (unknown upgradability + a fix counts as upgradable)
--fail-on none         report, never fail
                       (`threatlenscli code` ignores all/upgradable with a warning: secrets and SAST
                       findings have no upgrade fix, so those values would disable the gate)
--include-dev          include dev dependencies (npm, yarn, gradle) — off by default, like `snyk test`.
                       Without it, dev dependencies are removed from every pass, including the copies
                       `npm ci` installed in node_modules (matched by purl, else name+version) and
                       binaries shipped inside a dev-only package (esbuild's Go binary), so they
                       neither gate nor upload; the count is printed and recorded in meta/coverage
--exclude-base-image-vulns   image only: ignore OS vulns that come from the base image (needs --file)
--require-fresh-db     exit 2 unless the Trivy DB was built within 48 h and is not past its NextUpdate
                       (useful with --offline runners that restore a cached DB)
```

Code-quality and licence findings are reported (JSON, SARIF, the UI) but never fail
the build; `threatlenscli code --gate-quality` lets them gate too.

The gate uses the **server's triage**: findings marked false positive, accepted
risk or not affected (and not past their `suppress_until`) do not fail the build.
Nothing else carries over. A finding this scan reports is present, so a
default-branch row that the pipeline closed (`resolved`, `fixed`, `stale`,
`not_applicable`) still gates: a PR that brings back a vulnerability `main`
already fixed fails. Only an advisory withdrawn upstream (`rejected`) is exempt.
With `--no-upload` the gate runs on local results only, without triage; threatlenscli
notes this on stderr.

`--fail-on critical,high` (the old severity list) is still accepted. It now means
`--severity-threshold high`: at or above, so a critical fails a "high" gate.

## Outputs

| Flag | Output |
|---|---|
| `--json` | exactly one JSON document on stdout (`schema: vss-cli-result/1`); all progress goes to stderr |
| `--json-file-output PATH` | the same document, written to a file |
| `--sarif-file-output PATH` | SARIF 2.1.0 for GitHub code scanning: one run per engine with distinct categories (`vss/oss/`, `vss/image/`, `vss/code/semgrep/`, `vss/code/gitleaks/`, `vss/code/trivy/`), text only, repo-relative paths, redacted |
| `--sbom-file-output PATH` | CycloneDX from `trivy convert` (inventory only, no vulnerabilities). Trivy 0.74 writes specVersion 1.7. For `oss` it covers every pass (lockfiles, installed artefacts, `--sbom`) after dev dependencies are excluded (unless `--include-dev`) |

JSON and SARIF are redacted: secrets never appear, even for public repositories
whose CI artefacts are public. No runner path leaves the machine: the report
`ArtifactName` and the SBOM `metadata.component.name` carry the project identity,
and absolute paths (target, `$VIRTUAL_ENV`, temp dir, `--artifact`, `--sbom`)
are replaced by placeholders in uploads and output files.

Trivy DB (C14): unless `--offline`, every scan first runs `--download-db-only`,
then `--download-java-db-only` (oss/image), then — for image and code — a
misconfig checks-bundle prefetch (a `trivy config` of an empty private dir).
Sources are pinned to `mirror.gcr.io` / `ghcr.io` unless the runner sets its own
`TRIVY_DB_REPOSITORY` / `TRIVY_JAVA_DB_REPOSITORY` / `TRIVY_CHECKS_BUNDLE_REPOSITORY`.
Every scan then runs with `--skip-db-update --skip-java-db-update
--skip-check-update`. A failed prefetch is a warning (the embedded checks are
used) and is recorded as `meta.check_bundle_prefetch` / `check_bundle_stale`.

## `threatlenscli oss` — run it after the build

```bash
npm ci            # or: pip install -r requirements.txt into a venv / mvn package / gradle build
threatlenscli oss . --severity-threshold high --sarif-file-output oss.sarif
```

- Pass 1, `trivy fs`: reads lockfiles with the dependency graph (relationship,
  `introduced_through`, dev flag, licences). It runs in `precise` mode, so an
  unpinned manifest yields no package rather than a made-up version.
- Pass 2, `trivy rootfs`: reads what is installed (`node_modules`,
  `site-packages`, a venv (`$VIRTUAL_ENV` is added automatically), JAR/WAR
  files, Go and Rust binaries). `--artifact PATH` adds built outputs;
  `--lockfile-only` skips this pass.
- JVM builds keep dependencies in `~/.m2` / `~/.gradle`, outside the project:
  - `--sbom target/bom.json` ingests a cyclonedx-maven-plugin or
    cyclonedx-gradle-plugin SBOM (re-matched by `trivy sbom`, which takes no
    `--skip-check-update`: it has no misconfig checks). The path is checked
    (exists, <= 128 MiB, CycloneDX or SPDX JSON) before any Trivy pass runs.
  - Manifests without a lockfile are resolved automatically **on a CI runner**,
    like `snyk test` (elsewhere pass `--resolve`; `--no-resolve` turns it off):
    1. a workspace lockfile in a parent folder (npm/pnpm/yarn, uv/poetry/pdm, Cargo);
    2. the ecosystem's own tool, writing into a private dir, never the repo:
       `npm install --package-lock-only --ignore-scripts`, `pip install --dry-run
       --report`, `composer update --no-install --no-scripts --no-plugins`,
       `bundle lock`, `cargo generate-lockfile`, `dotnet restore --use-lock-file`,
       `mvn dependency:copy-dependencies`, a Gradle init-script task. The nearest
       `gradlew`/`mvnw` up to the git root is used (multi-module builds);
    3. JVM only: the JARs built in a parent build root (Dockerfile in
       `services/api`, `./gradlew assemble` at the repo root).
    A missing tool or a failed run is a warning, never fatal. Build tools
    execute your build configuration; the server never does.
- Detected manifests that did not resolve (`build.gradle` without
  `gradle.lockfile`, unpinned `requirements.txt`, `.csproj` without
  `packages.lock.json`, `requirements.lock`, ...) are listed with a fix. When no
  package resolves at all, the exit code is 3, never a false "no
  vulnerabilities".
- Identity is the normalised git remote (`github.com/acme/api`, credentials
  stripped) plus the sub-path in a monorepo. Without a remote, pass
  `--project-name`. CLI projects are separate from GitHub-imported ones; the UI
  links them by remote.
- Only the **default branch** is monitored: its upload becomes the project's
  inventory, findings are reconciled, the daily re-check watches it and Slack
  alerts fire. Uploads from other branches (PRs, feature branches) are stored
  as separate branch snapshots. They return triage-aware gate results and never
  touch default-branch findings.
- The default branch comes from `refs/remotes/origin/HEAD`, `CI_DEFAULT_BRANCH`
  (GitLab), `BUILDKITE_PIPELINE_DEFAULT_BRANCH`, or on GitHub Actions (whose
  shallow checkout has no origin/HEAD) `repository.default_branch` in
  `$GITHUB_EVENT_PATH`. Once the server has stored a project's default branch it
  keeps it: a different claim from a client is logged, audited and ignored.
  With no default known at all, only `main`/`master` are monitored, and that
  guess is not stored.
- A pull/merge-request build is **never** monitored, whatever its head branch
  is called (a fork PR from its own `main`). threatlenscli detects it from
  `GITHUB_HEAD_REF`/`GITHUB_EVENT_NAME`, `CI_MERGE_REQUEST_IID`,
  `BITBUCKET_PR_ID`, `BUILDKITE_PULL_REQUEST`, `CIRCLE_PULL_REQUEST`,
  `SYSTEM_PULLREQUEST_PULLREQUESTID` or `CHANGE_ID`.
- The server re-derives everything from the raw Trivy reports (it never trusts
  the client's merge) and unions them with a live OSV query of every versioned
  package, so a finding carries `engines: trivy`, `osv` or both. An OSV outage
  never resolves OSV-found findings.
- An upload resolves findings that disappeared only when it is provably
  complete: fresh Trivy DB (not `--offline`), all requested passes present, no
  package-count collapse, and no manifest that resolved last run and is
  unresolved now (a monorepo sub-project whose install failed). Otherwise
  nothing is resolved and the run's coverage says why.

## `threatlenscli code`

```bash
threatlenscli code . --require-sast --severity-threshold high --sarif-file-output code.sarif
```

- **Semgrep** packs: the security base (`p/default`, `p/owasp-top-ten`,
  `p/cwe-top-25`, `p/secrets`, `p/security-audit`), language and framework
  packs from detected files, IaC/CI packs, and quality packs (`p/r2c-bug-scan`,
  `p/r2c-best-practices`, `r/<lang>.lang.correctness`, ...). The server's list
  (`GET /api/code/config?languages=…&frameworks=…&iac=…`, sent the local
  detection) wins when it is available; `coverage.semgrep_pack_source` says which
  list ran. The local fallback (`--no-upload`, or the server unreachable) uses
  tables that mirror the server's default `select_configs` exactly (a parity
  test compares them), including `p/ci` for CI configs (`.github/workflows`,
  `.gitlab-ci.yml`, `Jenkinsfile`, ...); a server-side `CODE_SEMGREP_CONFIGS`
  override is not applied locally (`coverage.semgrep_pack_note`). Semgrep always
  runs with `--metrics=off`, never `--config auto`, `--disable-nosem` and
  `--verbose` (only then does Semgrep report `paths.skipped`; its log goes to a
  file in the private dir). Files over `--semgrep-max-target-bytes` (default
  1000000, the server's) or that time out / fail to parse are counted in
  `coverage.semgrep` (`skipped_paths`, `skipped_by_reason`), warned about, and
  uploaded as repo-relative `paths.skipped` so the server never resolves a
  finding in a file Semgrep did not read. Extra configs:
  `--semgrep-config p/jwt` or a local rules file.
- **Gitleaks** (when installed) runs without `--redact` into a 0600 report
  inside a private temp dir. threatlenscli digests each secret (sha256), then drops
  the raw value and masks Match/Line before anything is uploaded; the report is
  deleted. gitleaks always honours a repo `.gitleaksignore`, and threatlenscli reports
  when one exists. Without gitleaks, Trivy's secret scanner is used, and the
  digest is recovered from the file, not from Trivy's masked match. Gitleaks
  skips files over `--gitleaks-max-target-mb` (default 50, the server's
  `GITLEAKS_MAX_TARGET_MB`) without a word, so threatlenscli lists them itself:
  `coverage.bounds.GITLEAKS_MAX_TARGET_MB` (`value`, `hit`, `skipped_sample`),
  `coverage.gitleaks_skipped.paths` and a warning.
- **Trivy** misconfig: Dockerfile, Kubernetes, Helm, CloudFormation, Azure ARM,
  Terraform plan JSON and Ansible (`--misconfig-scanners
  azure-arm,cloudformation,dockerfile,helm,kubernetes,terraformplan-json,ansible`),
  with `**/<dir>` skip globs. **Trivy's Terraform scanner never runs in threatlenscli**:
  it resolves `module` sources from the scanned tree, including loopback
  addresses no proxy can block, so a fork PR's `.tf` could make the runner
  connect where it chooses. When `.tf`, `.tf.json`, `.tofu` or `.tofu.json` files
  are present, threatlenscli warns and records `coverage.terraform_not_scanned`; only
  Semgrep `p/terraform` rules cover them. Full Terraform IaC coverage needs the
  server code scan (which scans a guarded copy of the tree).
  Trivy runs with **no network**: a dead loopback proxy (`HTTPS_PROXY`/`HTTP_PROXY`/
  `ALL_PROXY` in both cases, empty `NO_PROXY`/`no_proxy`), `GIT_ALLOW_PROTOCOL=vss-none`
  and a private `TMPDIR`.
- Semgrep's secret digest is sha256 of the credential literal (one quoted
  literal, or the value of a single `NAME=VALUE` assignment), the same rule as
  the server and Gitleaks/Trivy, so one secret found by several engines is one finding.
- `--tracked-only` reports only files git tracks (`git ls-files -z`, so
  non-ASCII names match). Committed `.env` and `.github/**` files are kept. If
  `git ls-files` fails or times out, `--tracked-only` exits 2 instead of
  filtering against an unknown list.
- Secret findings from Semgrep are masked before upload, in `--json` and in
  SARIF: the matched span, each of its lines, quoted literals inside it and
  every metavariable value (what rule messages interpolate) are masked on the
  full source line before it is clipped. If a literal would still be visible,
  the snippet is dropped and the message becomes the rule title. The mask is the
  server's: a known token prefix plus the last 2 characters for tokens of 16+
  characters (`ghp_****…i2`), `****…xy` for 24+ characters, and nothing at all
  for shorter secrets (`********`).
- Local findings (`--no-upload` gate, `--json`, SARIF) use the server's
  identities: one secret found by several engines is ONE finding (merged on file
  + secret digest, `engines` lists them); misconfig identity is rule + resource +
  a hash of the cause lines + an occurrence index, never a line number, so SARIF
  `partialFingerprints` survive edits above the finding. A secret's local
  fingerprint is keyed with a per-run random key and SARIF omits
  `partialFingerprints` for secrets, so a published artifact carries nothing
  derived from a credential. SARIF tags quality results `correctness`/
  `maintainability`/`performance` and licence results `license` (no `security`
  tag or `security-severity`).
- `--offline`: Semgrep registry packs (`p/...`, `r/...`) are downloads from
  semgrep.dev, so they are skipped. Only `--semgrep-config <local rules>` run;
  with none, Semgrep is skipped with a warning (`--require-sast` then exits 2).
  No checks-bundle prefetch runs (cached or embedded misconfig checks,
  recorded as `check_bundle_stale`).

## `threatlenscli image`

```bash
threatlenscli image my-app:1.4.2 --file Dockerfile --platform linux/amd64 --severity-threshold critical
```

This uses the server's exact Trivy argv, flag for flag and in order
(`GET /api/images/scan-profile` shows it): `--scanners vuln,secret,misconfig`,
`--image-config-scanners misconfig,secret`, `--pkg-types os,library`,
`--list-all-pkgs`, `--skip-check-update`, `--no-progress`,
`--misconfig-scanners dockerfile,kubernetes,helm,cloudformation,azure-arm,ansible`
(never Terraform: its module resolution would make the runner fetch
attacker-chosen URLs), `--detection-priority comprehensive`,
`--max-image-size 16384MiB` and `--image-src docker,remote -- <ref>`. The only
differences are no `--cache-dir` and `--offline-scan` with `--offline`. A Trivy
too old for `--max-image-size` drops it with a warning (`meta.flags_dropped`).
The scan runs with `GIT_ALLOW_PROTOCOL=vss-none` and a private `TMPDIR`
(registry egress stays: the image must be pulled). `--file` uploads the
Dockerfile's FROM lines for base-image attribution and advice.

## CI recipes

```yaml
# GitHub Actions — PRs gate, main monitors (same command; the branch decides)
- run: npm ci
- run: threatlenscli oss . --severity-threshold high --sarif-file-output oss.sarif
  env: { THREATLENS_SERVER: ${{ vars.THREATLENS_SERVER }}, THREATLENS_API_KEY: ${{ secrets.THREATLENS_OSS_KEY }} }
- run: threatlenscli code . --require-sast --severity-threshold high --sarif-file-output code.sarif
  env: { THREATLENS_SERVER: ${{ vars.THREATLENS_SERVER }}, THREATLENS_API_KEY: ${{ secrets.THREATLENS_CODE_KEY }} }
- uses: github/codeql-action/upload-sarif@<full-commit-sha>
  if: always()
  with: { sarif_file: oss.sarif, category: threatlens-oss }
```

```yaml
# GitLab CI
threatlens:
  script:
    - threatlenscli oss . --severity-threshold high --json-file-output threatlens-oss.json
  artifacts: { paths: [threatlens-oss.json], when: always }
```

Every upload carries provenance in `meta`: the Trivy, Semgrep and Gitleaks
versions, the Trivy binary sha256, the Trivy DB `UpdatedAt`, sanitised argv
(the target and temp dirs become `<target>`/`<tmp>`, any other absolute path keeps
only its basename, credential flag values are masked), git remote, branch and commit, and the scanner
environment variables that were ignored (for example a `TRIVY_SEVERITY` set in
CI).

## Other commands

```
threatlenscli login [--server URL]    browser sign-in (--with-api-key: paste a key); 0600 config
threatlenscli logout                  remove saved config
threatlenscli config                  show config (key masked; env overrides flagged)
threatlenscli doctor                  scanners, denylist, DB age, server reachability, key scopes + identities
threatlenscli list [--type repos]     images (default) or repositories on the server
threatlenscli install [--dest DIR]    symlink this script onto PATH
```

## Measuring parity with Snyk

The JSON document written by `--json-file-output` (`schema: vss-cli-result/1`)
is the ThreatLens input of the parity harness in `backend/tests/parity/`. Run the scan
with `--no-upload` on the same machine and day as the Snyk run, for example:

```bash
threatlenscli image nginx:1.25@sha256:<digest> --no-upload --json-file-output threatlens-image.json
snyk container test nginx:1.25@sha256:<digest> --json-file-output=snyk-container.json
python3 backend/tests/parity/parity_harness.py compare --snyk-container snyk-container.json --vss threatlens-image.json
```

The corpus, the commands for every target, the match rules and the pass bar
(recall >= 80 % per product, precision reported) are in
[`docs/parity-benchmark.md`](../docs/parity-benchmark.md). No parity figure is
claimed until it has been measured that way.

## Honest limitations

- Semgrep CE is intraprocedural: there is no cross-function or cross-file taint
  (Snyk Code has it). Pro-only rules are unavailable.
- Registry rules are under the Semgrep Rules License and are fetched live;
  threatlenscli bundles none.
- `trivy rootfs` finds installed packages but has no dependency graph, so those
  findings have `relationship: unknown`. Rust binaries are found only when built
  with cargo-auditable.
- `--fail-on upgradable` knows upgradability exactly only for direct
  dependencies. For transitive ones, "has a fix" is treated as upgradable
  (fails closed).
- The Trivy DB is the newest build available when the run starts, which can
  lag upstream by up to about 24 h.
- A code scan does not scan git history for secrets.
