Metadata-Version: 2.4
Name: aspora-vsscli
Version: 0.3.2
Summary: Image, code and open-source dependency scanning in your CI, reported to a Vendor Security Scanner 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

# vsscli — Vendor Security Scanner CLI

Snyk-style scanning in **your** CI. The scanners run on the runner; only the
results go to the VSS 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 |
|---|---|---|---|
| `vsscli oss [PATH]` | open-source dependencies, incl. transitive | `trivy fs` (lockfiles) ∪ `trivy rootfs` (installed artefacts) ∪ optional build-tool SBOM | `snyk test` / `snyk monitor` |
| `vsscli code [PATH]` | SAST, code quality, secrets, IaC misconfig | Semgrep CE, Gitleaks (else Trivy secret), Trivy misconfig | `snyk code test` + `snyk iac test` |
| `vsscli 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-vsscli`; the command is `vsscli` — the PyPI
project named `vsscli` is someone else's, never install that one):

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

Or the single file straight from the repository:

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

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

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

Requirements on the runner:

- **Trivy >= 0.53** on `PATH`. Install a pinned release and verify its checksum.
  vsscli 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
  `VSS_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. vsscli says so on stderr, and `--require-sast` turns that into
  exit 2. semgrep and gitleaks are checked against the same denylist
  (`VSS_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) |
|---|---|---|
| `vsscli oss` | `ingest:oss` | `code:write` |
| `vsscli code` | `ingest:code` | `code:write` |
| `vsscli 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
vsscli login                                          # https://threatlens.internal.genorim.xyz; prompts for the key, never pass it on argv
```

In CI use environment variables. They always win over `~/.vsscli/config.json`.
Only the key is needed: the server defaults to
`https://threatlens.internal.genorim.xyz` (set `VSS_SERVER` for another one).
On a CI runner an env key never goes to the server a stale config on a shared
runner names:

```bash
export VSS_API_KEY=${{ secrets.VSS_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 `VSS_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
  `VSS_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
                       (`vsscli 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)
```

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; vsscli
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`.

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

```bash
npm ci            # or: pip install -r requirements.txt into a venv / mvn package / gradle build
vsscli 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`). vsscli 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.

## `vsscli code`

```bash
vsscli 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. vsscli 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 vsscli 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 vsscli 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 vsscli**:
  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, vsscli 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`).

## `vsscli image`

```bash
vsscli 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: vsscli oss . --severity-threshold high --sarif-file-output oss.sarif
  env: { VSS_SERVER: ${{ vars.VSS_SERVER }}, VSS_API_KEY: ${{ secrets.VSS_OSS_KEY }} }
- run: vsscli code . --require-sast --severity-threshold high --sarif-file-output code.sarif
  env: { VSS_SERVER: ${{ vars.VSS_SERVER }}, VSS_API_KEY: ${{ secrets.VSS_CODE_KEY }} }
- uses: github/codeql-action/upload-sarif@<full-commit-sha>
  if: always()
  with: { sarif_file: oss.sarif, category: vss-oss }
```

```yaml
# GitLab CI
vss:
  script:
    - vsscli oss . --severity-threshold high --json-file-output vss-oss.json
  artifacts: { paths: [vss-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

```
vsscli login [--server URL]    save server + key (prompted; 0600 config; default server built in)
vsscli logout                  remove saved config
vsscli config                  show config (key masked; env overrides flagged)
vsscli doctor                  scanners, denylist, DB age, server reachability, key scopes + identities
vsscli list [--type repos]     images (default) or repositories on the server
vsscli 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 VSS 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
vsscli image nginx:1.25@sha256:<digest> --no-upload --json-file-output vss-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 vss-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;
  vsscli 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.
