Skip to main content

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):

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:

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):

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.

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:

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

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.
    • --resolve runs mvn dependency:copy-dependencies or a Gradle init-script task on the runner and scans the resolved JARs. This executes 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

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

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

# 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 }
# 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:

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. 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.

Metadata

Release files for aspora-vsscli 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for aspora-vsscli 0.3.1
File Size Uploaded
aspora_vsscli-0.3.1.tar.gz 79.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for aspora-vsscli 0.3.1
File Interpreter ABI Platform
aspora_vsscli-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 160.3 kB

Release files / aspora_vsscli-0.3.1.tar.gz

Download URL aspora_vsscli-0.3.1.tar.gz
Size 79.9 kB
Tags Source
SHA-256 checksum
How to use checksums
9e1ffde9782498c49c4b4a7b94a411f9c830638b0f7daf5e3bef0fcc8f17420e
BLAKE2b-256 checksum
How to use checksums
93819f7fd4b6af9d3d919d0a99025cb5bdcb34b9ed8244167052515215df792a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / aspora_vsscli-0.3.1-py3-none-any.whl

Download URL aspora_vsscli-0.3.1-py3-none-any.whl
Size 80.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f517379b86ae70eeae4180806afd2eb8271ac9446f4de77b284964abd5aabcef
BLAKE2b-256 checksum
How to use checksums
4bf92a9acf6b9635885d987494da20b4a4e7109906e9eea5363b4b2b10e3c9dc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

0.3.2

2 release files

This release

0.3.1 This release

2 release files

0.3.0

2 release files

0.2.0

2 release 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