Skip to content

07 - Persistence and Artifacts

Version: v0.8.3 · Revision: 2026-07-04

This document describes everything Quorum (quorum-sec-scan, v0.8.3) persists to disk and reads from disk during a run: formats, location, lifecycle, versioning and the update/migration strategy of each artifact. Quorum is a CLI/Docker consensus security scanning tool that orchestrates 12 scanners (SCA, misconfig/IaC, K8s posture and policy-as-code); there is no relational database, application server or long-lived shared state. The product's "persistence" is deliberately minimal and made up of files only: a local alias cache (now schema-versioned), the suppression baseline, the crosswalk YAML files (aws/azure/gcp/k8s, versioned), the Grype vulnerability database embedded in the image, the output report files, an optional Prometheus metrics file, and — new in the advisory layer — an opt-in AI advice cache plus the bundled advisory knowledge pack (remediation templates + a digest-pinned OWASP corpus).

Related design principle: the cache and auxiliary artifacts can never be a source of scan failure. Where an artifact is missing, unreadable or corrupted, Quorum degrades gracefully to safe behavior (see internal/cache/store.go, internal/crosswalk/crosswalk.go, internal/filter/filter.go). The advisory layer follows the same rule: it is opt-in via --advice, presentation-only, and without it the output is byte-identical.

Related documents: Architecture · Data Model · Supply Chain · AI.


1. Relational database — N/A

Status: N/A (not applicable).

Quorum does not use a relational database (PostgreSQL, MySQL, SQLite with SQL, etc.) and this is an architectural decision, not a gap.

Technical rationale:

  • Stateless / batch execution model. Quorum is invoked as a short-lived CLI process (quorum scan <target>), runs a pool of scanners in parallel, correlates the results in memory and emits a report. When the process ends there is no state to keep between runs — except the alias cache, which is purely an optimization (see §2), and the optional AI advice cache (§3.6), which is likewise a reproducibility/speed optimization.
  • No multiuser, no cross-process concurrency. There are no user accounts, sessions, REST API or persistent service. There is no need for transactions, multi-writer concurrency control or relational queries.
  • Portability and zero-dependency. The binary is built with CGO_ENABLED=0 (see Dockerfile/Dockerfile.full). Introducing SQLite (CGO) or a database server would break the "single static binary" premise and the :slim distribution (pure orchestrator, amd64+arm64).
  • The only state that would justify a KV store is already served by a JSON file. The alias cache is a small string→string map; a relational database would be over-engineering. Literal comment in internal/cache/store.go: "It gives the alias resolver idempotency and speed across CI re-scans without pulling in a CGO database."

Future proposal (clearly separated — NOT implemented)

If, in some evolution, the product were to offer a "server" mode (scan history, dashboards, cross-repo deduplication), then structured storage would be pertinent. Even in that scenario, the recommendation would be to start with a KV/embedded store (e.g. BoltDB/Pebble) or an append-only datastore, and not necessarily an RDBMS. This is out of scope for v0.8.3.


2. Overview of persisted artifacts

flowchart LR
    subgraph Read["Inputs read from disk"]
        BL[".quorumignore<br/>baseline"]
        CW["crosswalk/*.yaml<br/>aws · azure · gcp · k8s<br/>(./crosswalk or<br/>/opt/quorum/crosswalk)"]
        KN["knowledge pack<br/>templates + OWASP corpus<br/>(/opt/quorum/knowledge)<br/>(only under --advice)"]
        POL["./policy/*.rego<br/>(conftest, optional)"]
        GDB["grype DB<br/>/opt/grype/db"]
        CACHE_R["~/.cache/quorum/<br/>aliases.json (read)"]
        ADV_R["~/.cache/quorum/<br/>advice.json (read, --advice)"]
    end

    Q["quorum scan &lt;target&gt;<br/>(CLI process)"]

    subgraph Write["Outputs written to disk"]
        REP["report<br/>SARIF / JSON / XML<br/>(-o or stdout, 0600)"]
        MET["Prometheus metrics<br/>(--metrics, 0644)"]
        CACHE_W["~/.cache/quorum/<br/>aliases.json (write, 0600)"]
        ADV_W["~/.cache/quorum/<br/>advice.json (write, --advice, 0600)"]
    end

    BL --> Q
    CW --> Q
    KN --> Q
    POL --> Q
    GDB --> Q
    CACHE_R --> Q
    ADV_R --> Q
    Q --> REP
    Q --> MET
    Q --> CACHE_W
    Q --> ADV_W

Summary table of all artifacts:

Artifact Default path Format Direction Written by Lifecycle
Alias cache ~/.cache/quorum/aliases.json (os.UserCacheDir()) Versioned JSON ({schemaVersion, data}) read + write Quorum (internal/cache) persistent across scans; regenerable
AI advice cache ~/.cache/quorum/advice.json (os.UserCacheDir()) Versioned JSON ({schemaVersion, data}) read + write (only under --advice) Quorum (internal/advisor via internal/cache) persistent across scans; regenerable; off by default
Baseline .quorumignore (cwd) text, 1 entry/line read user versioned in the user's repo
Crosswalk ./crosswalk → fallback /opt/quorum/crosswalk Versioned YAML (schemaVersion + controls) or legacy list read maintainers / user bundled in the image; versioned
Knowledge pack /opt/quorum/knowledge (bundled) YAML templates + digest-pinned OWASP corpus read (only under --advice) maintainers bundled in the image; versioned per release
Policy Rego ./policy/*.rego Rego (OPA) read user versioned in the user's repo (via conftest)
Grype DB /opt/grype/db (GRYPE_DB_CACHE_DIR) Grype/Syft DB (managed by Grype) read image build (Grype) frozen at build; updatable
Output report -o <file> or stdout SARIF / JSON / XML write Quorum (internal/report) ephemeral (CI artifact)
Metrics --metrics <file> Prometheus text-format write Quorum (internal/report) ephemeral (scrape/textfile collector)

The release supply chain artifacts (SPDX SBOM, SLSA attestations, cosign signatures) are not produced by a scan run; they are generated in the build pipeline and covered in §10 and in Infrastructure/Supply Chain. Since v0.8.3 the bundled knowledge pack + crosswalk also receive their own SLSA attestation (§10).


3. Alias cache — ~/.cache/quorum/aliases.json

3.1 Purpose

The alias resolver (internal/alias) normalizes any vulnerability identifier to a canonical form (preferring CVE), so that Grype's GHSA-xxxx and Trivy's CVE-yyyy for the same bug correlate instead of splitting. The resolution chain (chainResolver.Canonical) is:

flowchart TD
    A["input id"] --> B{"scanner-local<br/>aliases contain a CVE?"}
    B -- yes --> R1["return CVE (no cache/network touched)"]
    B -- no --> C{"hit in local cache<br/>aliases.json?"}
    C -- yes --> R2["return cached value"]
    C -- no --> D{"--offline?"}
    D -- yes --> R3["preferCVE(local aliases) → write to cache"]
    D -- no --> E["query OSV.dev<br/>(/v1/vulns/&lt;id&gt;)"]
    E -- success --> F["preferCVE(OSV aliases) → write to cache"]
    E -- network failure --> R3

The cache exists to provide idempotency and speed on CI re-scans without pulling in a CGO database (comment in internal/cache/store.go).

3.2 Format

Plain JSON, now a versioned envelope (diskFormat in store.go) with two fields: schemaVersion (integer, currently 1) and data (the string → string map of input id → canonical form). It is indented with 2 spaces (json.MarshalIndent(diskFormat{...}, "", " ")). Example:

{
  "schemaVersion": 1,
  "data": {
    "GHSA-xxxx-yyyy-zzzz": "CVE-2024-12345",
    "GHSA-aaaa-bbbb-cccc": "CVE-2023-99999"
  }
}

Inside data, the key is the input id (not normalized, as it came from the scanner). The value is the result of preferCVE(...) at the moment of resolution (see internal/alias/resolver.go, line r.local.Put(id, canon)).

3.3 Location

  • Default: defaultCachePath() in cmd/quorum/scan.go returns filepath.Join(os.UserCacheDir(), "quorum", "aliases.json").
  • Linux: ~/.cache/quorum/aliases.json
  • macOS: ~/Library/Caches/quorum/aliases.json
  • Windows: %LocalAppData%\quorum\aliases.json
  • Fallback: if os.UserCacheDir() fails, it uses .quorum-cache.json in the current directory.
  • Override: flag --cache <file>. Passing --cache "" (empty string) puts the store in memory-only modecache.Open("") neither reads nor writes to disk (see store.go and store_test.go).

3.4 Lifecycle and write semantics

  • Open (cache.Open): reads the file if it exists; a missing or unreadable file results in an empty cache, not an error"a bad cache never breaks a scan". Moreover, the file is only adopted when it parses successfully AND schemaVersion matches the current version AND data is non-nil; a legacy/incompatible/corrupted file degrades to an empty cache (one-time rebuild, no migration, no reading of corrupted data).
  • Write (cache.Put): happens on every new resolution that reaches layer 3 of the chain. The write is atomic: it serializes a snapshot inside the {schemaVersion, data} envelope, writes to aliases.json.tmp and does os.Rename(tmp, path). Flush failures are deliberately swallowed — the cache is an optimization, never a source of failure.
  • Directory creation: os.MkdirAll(filepath.Dir(path), 0o755) is done lazily on the first Put.
  • Permissions: file 0o600 (per-user data; no reason to be world-readable — this applies to both the .tmp and the final file after rename), directory 0o755.
  • Concurrency: safe for concurrent use within a process (sync.RWMutex). There is no inter-process locking — two simultaneous quorum scan runs sharing the same cache can overwrite each other; because writes go through an atomic rename the file is never corrupted, but the last write wins (loss of a few entries, no correctness impact since each entry is regenerable).

3.5 Versioning and migration

Aspect Current situation (v0.8.3)
Versioned schema Yes. The envelope carries schemaVersion (const schemaVersion = 1 in store.go).
Schema validation Conditional adoption: json.Unmarshal OK and schemaVersion equal to the current one and data != nil; otherwise empty cache.
Migration Automatic by discard. A file with a different version is treated as missing and rebuilt once — no migration script, no mis-parsed read.
TTL / expiration None. Entries live indefinitely.
Invalidation Manual (delete the file) or automatic when schemaVersion is bumped.

Update/regeneration strategy (actionable):

  • [ ] To force re-resolution via OSV, delete ~/.cache/quorum/aliases.json (or point --cache at a new path).
  • [ ] In CI, cache this file between runs to speed up and reduce calls to OSV.dev (e.g. actions/cache with a stable key).
  • [ ] Use --offline to never query OSV; the cache + scanner-local aliases become the only sources.
  • [ ] If an alias mapping is wrong (rare), deleting the file is the "migration".
  • [ ] When migrating between Quorum versions with a different schemaVersion, no action is required: the old cache is discarded and rebuilt transparently.

Note on schema robustness: with the embedded schemaVersion, a future incompatible format change is now handled explicitly (bump the constant → controlled discard of the old cache), closing the gap that existed in v0.2.3. See Assumptions.

3.6 AI advice cache — ~/.cache/quorum/advice.json

New in the advisory layer and only touched when --advice is set (without it, this file is never read or written and output is byte-identical). The AI advisory layer (internal/advisor) attaches a natural-language recommendation — and, under --fix=suggest, a verify-the-fix patch — to each merged finding. Those attachments are cached on disk so repeated CI runs reuse a finding's recommendation instead of re-querying the model (reproducibility + speed).

  • Backing store: the same internal/cache store as the alias cache — hence the same versioned envelope ({schemaVersion, data}, schemaVersion = 1), the same atomic-rename write, the same 0o600 permissions and the same "a bad cache never breaks a scan" degradation. It is a separate file so it never collides with the alias map.
  • Location: defaultAdviceCachePath() in cmd/quorum/advisor.go returns filepath.Join(os.UserCacheDir(), "quorum", "advice.json") (fallback .quorum-advice.json if os.UserCacheDir() fails). Override with --advice-cache <file>; an empty value puts it in memory-only mode.
  • Key: "advice:" + sha256(promptVersion | provider | model | fingerprint) (see cacheKey in internal/advisor/advisor.go). Salting by provider (local/remote) and model ensures a local and a remote answer for the same finding never collide; salting by promptVersion invalidates stale advice when the prompt changes. This is what makes advice reproducible together with temperature=0.
  • Value: the JSON-serialized model.Advice (recommendation text, provider, model, the "AI-generated, advisory only" label, and — under --fix — a verified Fix).
  • Guarantee: presentation-only. The advice cache never influences correlationKey, fingerprint, confidence, aggregated severity or the --fail-on gate — it only stores what is attached to the report. If the model is unreachable, the run ships without AI advice and the scan never fails.

Because the advice cache stores model output keyed by finding fingerprint, treat it like the report: it may reflect finding detail. It inherits the 0o600 owner-only permission for that reason.


4. Baseline — .quorumignore

4.1 Purpose

The baseline is the list of known/accepted findings that must be suppressed from the report and from gating (--fail-on). Without it, --fail-on would be "unusable noise" in CI (comment in internal/filter/filter.go). A suppression is always logged (scan.go prints filtered: N suppressed by baseline ...) — "a suppressed finding is still a finding".

4.2 Format

Text file, one entry per line, where each entry is a Fingerprint OR a CorrelationKey copied from a previous report. Parsing rules (filter.LoadBaseline):

  • Blank lines are ignored.
  • Lines starting with # are comments.
  • End-of-line comments are allowed: entry # note.
  • Comparison is case-insensitive (entries and findings are normalized with strings.ToLower).

Example:

# .quorumignore — findings accepted for this repository

# by fingerprint (sha256 of the correlationKey)
3b1f...c0de   # CVE-2024-12345 in lib X — accepted until upgrade in Q3

# by correlationKey (more readable, broader)
VULN|CVE-2023-0001|pkg:npm/left-pad@1.0.0

An entry by CorrelationKey suppresses all findings that share that key; an entry by Fingerprint is more specific. See Data Model for the definition of both.

4.3 Location and lifecycle

  • Default: .quorumignore in the current directory (flag --baseline).
  • Lifecycle: it is a user artifact, versioned in the target repository (like .gitignore). Quorum only reads it; it never writes it.
  • Absence:
  • If the user did not pass --baseline explicitly and the file does not exist → empty baseline, scan proceeds (see scan.go: present == false and flag unchanged).
  • If the user did pass --baseline explicitly and the file does not exist → fatal error (baseline file not found), exit code 2. This avoids "suppression silently turned off" via a wrong path.

4.4 Versioning and migration

Aspect Current situation
Versioned schema Not applicable — it is a list of opaque tokens.
Migration None needed. Entries that do not match any finding simply have no effect.
Maintenance User responsibility (review/clean up stale entries).

Usage checklist (actionable):

  • [ ] Generate a report, copy the fingerprint/partialFingerprints["quorum/v1"] or correlationKey of the accepted finding.
  • [ ] Add it to .quorumignore with a comment justifying it and/or a review deadline.
  • [ ] Commit .quorumignore in the target repository.
  • [ ] Review periodically: since suppressions are logged, audit the CI stderr.

5. Crosswalk — ./crosswalk/opt/quorum/crosswalk

5.1 Purpose

The crosswalk maps each scanner's own rule ids to a shared canonical control (AVD hub for IaC/cloud, Kubescape C-#### hub for K8s posture, with a semantic category fallback), so that equivalent misconfigs coming from different engines correlate (internal/crosswalk/crosswalk.go, DESIGN §8). It is what enables consensus beyond SCA in v0.8.3. Conservative principle: "false split > false merge" — only clearly equivalent controls are grouped, and the mappings are derived from real scanner output and cross-checked semantically.

Coverage per file (four files versioned in the repo):

File Hub Mapped engines Scope (examples)
crosswalk/aws.yaml AVD-AWS trivy · checkov · kics · terrascan · regula S3, IAM, EBS, Security Groups, RDS, KMS, CloudTrail, VPC flow logs
crosswalk/azure.yaml AVD-AZU trivy · checkov · kics · terrascan · regula Storage Account, Key Vault
crosswalk/gcp.yaml AVD-GCP trivy · checkov · kics · terrascan · regula GCS bucket, firewall, Cloud SQL
crosswalk/k8s.yaml Kubescape C-#### kubescape · polaris · kube-score privilege-escalation, privileged, non-root, cpu/mem limits, probes, read-only-fs, linux-hardening, automount-SA, network-policy, host-network, host-PID/IPC, capabilities, secrets

Relevant correlation notes:

  • tfsec emits AVD ids natively, so its findings auto-correlate with Trivy without needing a dedicated crosswalk entry (comment in Dockerfile.full).
  • RBAC stays single-engine (Kubescape's RBAC requires real cluster context and is not paired with another engine — documented as a decision, not a gap).
  • Grouped kube-score checks (e.g. container-resources = cpu+memory; pod-probes = liveness+readiness) are left intentionally unmapped so as not to over-merge a composite check into a single canonical control (header of k8s.yaml).

5.2 Format

YAML, accepted in two forms (crosswalk.Load):

  1. Versioned document (preferred, v0.8.3): a top-level map with schemaVersion (const SchemaVersion = 1) and controls (a list of Control). This is the format used by azure.yaml, gcp.yaml and k8s.yaml.
  2. Plain list (legacy, still supported): the entire file is a top-level YAML sequence of Control (no version header). This is the format still used by aws.yaml.

Load tries the versioned form first (adopts it when schemaVersion != 0 or controls != nil) and, if it doesn't match, falls back to the plain list. Structure of Control (struct in crosswalk.go):

schemaVersion: 1
controls:
  - canonicalControl: AVD-AZU-0059      # canonical control (hub)
    category: encryption                # semantic category (fallback)
    cwe: CWE-319                         # optional
    title: "Storage account without secure transfer (HTTPS)"
    ids:                                 # scanner -> [ruleIDs]
      trivy:     [AVD-AZU-0059]
      checkov:   [CKV_AZURE_3]
      kics:      ["12944ec4-1fa0-47be-8b17-42a034f937c2"]
      terrascan: [AC_AZURE_0373]

Internal indexing: on load, each (scanner, ruleID) pair becomes the key "scanner|ruleid" (lowercased, trimmed) pointing to a Resolution{Control, Category, CWE, Title} (see key() and add() in crosswalk.go). Full real examples in crosswalk/aws.yaml and crosswalk/k8s.yaml.

5.3 Location and directory resolution

The directory choice is made by resolveCrosswalkDir (cmd/quorum/scan.go):

flowchart TD
    A["--crosswalk"] --> B{"flag passed<br/>explicitly?"}
    B -- yes --> R1["use the literal value"]
    B -- no --> C{"./crosswalk exists<br/>(is a directory)?"}
    C -- yes --> R2["use ./crosswalk"]
    C -- no --> D{"/opt/quorum/crosswalk<br/>exists?"}
    D -- yes --> R3["fallback: /opt/quorum/crosswalk<br/>(crosswalk bundled in the image)"]
    D -- no --> R4["use ./crosswalk (will load 0 rules)"]

The fallback exists so that docker run … scan . from an arbitrary workdir still gets the embedded mappings, instead of silently loading 0 rules. The :slim and :full images copy crosswalk to /opt/quorum/crosswalk (COPY crosswalk /opt/quorum/crosswalk in both Dockerfiles).

5.4 Loading and fault tolerance

crosswalk.Load(dir):

  • Reads all *.yaml/*.yml files (case-insensitive extension) in the directory and merges all controls (regardless of whether they are in the versioned form or the legacy list).
  • Subdirectories are ignored.
  • A missing directory is NOT an error (os.IsNotExist → empty crosswalk): the tool runs without a custom crosswalk, and each lookup that misses keeps the finding isolated and marked as unmapped (DESIGN §6, "never guess a match").
  • A file read error or invalid YAML IS a fatal error (Load returns the error; scan.go wraps it as loading crosswalk: ..., exit 2). Unlike a missing directory, a present-but-broken file fails fast.

The number of loaded rules is logged: crosswalk=%d rules (%s) on stderr.

5.5 Versioning, "table" and migration

The crosswalk is the product's mapping table (the closest analog to "schema/migrations" that Quorum has). Versioning:

Aspect Current situation (v0.8.3)
Content versioning Coupled to the Quorum version: the YAMLs live in the repo (crosswalk/) and are embedded in the image per version (v0.8.3).
Per-file version field Yes (new). Const SchemaVersion = 1; the versioned form (schemaVersion + controls) is supported, with the legacy list still accepted for compatibility.
Schema migration Versioned documents allow evolving the format without breaking legacy files; changes to the Control struct's fields still require rewriting code + YAMLs (there is no automatic data migration).
User extension Pointing --crosswalk <dir> at a directory of your own, or adding files to the bundled directory, replaces the default (there is no merge between default and custom beyond Load merging files from the same directory).
Mapping provenance Derived from real scanner output and cross-checked semantically (headers of aws.yaml/azure.yaml/gcp.yaml/k8s.yaml).

Maintenance/update checklist (actionable):

  • [ ] When bumping a scanner version, re-run against fixtures and review whether rule ids changed (KICS uses query UUIDs; they can change between versions).
  • [ ] Add new controls as items under controls:, keeping the canonicalControl on the AVD hub (cloud/IaC) or Kubescape C-#### (K8s) when one exists.
  • [ ] Prefer the versioned form (schemaVersion: 1 + controls:) for new files; the plain list remains valid only for compatibility.
  • [ ] For local customization: copy the bundled directory, edit it, and pass --crosswalk <your-dir>.
  • [ ] Validate with quorum scan ... and check the crosswalk=N rules counter on stderr.

6. Knowledge pack — /opt/quorum/knowledge (advisory layer)

6.1 Purpose

The knowledge pack is the bundled data behind the opt-in advisory layer (--advice). It is read-only input, consulted only when advice is requested, and it powers the two deterministic advisory phases that need no model at all:

  • Phase 0 (deterministic templates + references, internal/enrich): curated remediation templates and OWASP references, matched by canonicalControl/ruleId/category/type. It attaches model.Remediation and model.DocRef to a finding with no model in the loop.
  • Phase 2 (RAG-as-artifact, internal/rag): retrieval from a versioned, digest-pinned OWASP corpus that grounds recommendations. Retrieval is lexical by default (no model); it becomes semantic (embeddings) once the corpus is embedded via quorum advise-index, and scan auto-picks semantic when the corpus ships vectors.

Neither phase touches correlationKey/fingerprint/confidence/aggregated severity or the --fail-on gate — like the whole advisory layer, they are presentation-only.

6.2 Layout and format

All YAML, versioned in the repo under knowledge/ and bundled into both images at /opt/quorum/knowledge:

File Phase Content
knowledge/aws.yaml · azure.yaml · gcp.yaml · k8s.yaml · image.yaml 0 Curated remediation templates + OWASP references, keyed by control/rule/type per cloud/K8s/image domain.
knowledge/categories.yaml 0 Broad fallbacks by semantic category and by finding type — attaches an authoritative OWASP reference (no snippet) when no control-specific entry matched. schemaVersion: 1, entries:.
knowledge/owasp/corpus.yaml 2 The RAG corpus: short OWASP passages (title + url + keywords + text) used to ground advice. schemaVersion: 1, a digest: pin, and a list of chunks.

The corpus is digest-pinned: corpus.yaml carries digest: "sha256:…" and the loader recomputes a content hash over the chunks and refuses to load on mismatch (tamper/truncation guard). Embeddings are intentionally absent by default — retrieval falls back to the deterministic lexical retriever — and can be added later without changing the pinned content domain.

6.3 quorum advise-index — embedding the corpus

The advise-index subcommand (cmd/quorum/advise_index.go) reads the OWASP corpus, embeds every chunk via a local OpenAI-compatible embeddings endpoint (e.g. Ollama), and writes it back with per-chunk embedding: vectors. Embeddings are excluded from the content digest, so the pin is preserved. Once vectors are present, scan --advice --advice-provider local automatically switches from lexical to semantic retrieval.

  • Flags: --corpus (default knowledge/owasp/corpus.yaml), --out (default: overwrite --corpus in place), --advice-endpoint (default http://localhost:11434/v1), --advice-embed-model (default nomic-embed-text).
  • It is a one-time offline maintenance step, not part of a scan.

6.4 Location, lifecycle and fault tolerance

  • Location: bundled at /opt/quorum/knowledge in both the Dockerfile (:slim) and Dockerfile.full (:full) via COPY knowledge /opt/quorum/knowledge. Outside a container, the packaged path under the repo (knowledge/) is used.
  • Lifecycle: read-only, versioned per release together with the binary/image; a scan never writes it (only advise-index rewrites the corpus, and only when explicitly run).
  • Fault tolerance: the pack is only consulted under --advice. A missing template file simply means no deterministic remediation for that finding (the finding still ships). A corpus digest mismatch makes the RAG loader refuse the corpus, so grounding falls back to whatever remains available — never a scan failure.

6.5 Versioning and supply chain

The knowledge pack is coupled to the Quorum version. Since v0.8.3, the pack + crosswalk get a SLSA build-provenance attestation each release (the knowledge job in release.yml computes knowledge.sha256 over every pack file and attests it). A consumer can verify a bundled file independently, e.g.:

gh attestation verify knowledge/owasp/corpus.yaml --repo Martinez1991/quorum-sec-scan

See §10 and Infrastructure/Supply Chain for the full release provenance picture, and AI for the honest framing of the advisory layer.


7. Grype DB — /opt/grype/db

7.1 Purpose

The Grype vulnerability database is required for the Grype scanner to work. In the :full image it is pre-cached at build time so that the first scan works offline and never hits the error "failed to load vulnerability db: database does not exist" (comment in Dockerfile.full).

7.2 Format and location

  • Format: Grype/Syft database, managed internally by Grype itself (Quorum neither reads nor writes this DB directly — it only invokes the grype binary). It is opaque to Quorum.
  • Location: /opt/grype/db, set via ENV GRYPE_DB_CACHE_DIR=/opt/grype/db in Dockerfile.full.
  • Populated by: RUN grype db update && grype db status during the image build.
  • Available only in the :full image (which embeds the scanners). The :slim image includes neither scanners nor DB — it expects scanners on the runner's PATH.

7.3 Lifecycle, versioning and update

Aspect Current situation (v0.8.3)
State at build DB frozen at the moment of building the :full image.
Runtime auto-update Off by default (ENV GRYPE_DB_AUTO_UPDATE=false).
Age validation Off (ENV GRYPE_DB_VALIDATE_AGE=false) — see below.
On-the-fly update Set GRYPE_DB_AUTO_UPDATE=true at runtime (requires network) for Grype to update the DB itself.
Recommended update Rebuild/re-pull the :full image to get a fresher DB.
Versioning Coupled to the image tag (GRYPE_VERSION pinned by digest in Dockerfile.full, currently v0.114.0) and to the build date.
DB schema migration Managed by Grype, not by Quorum.

Why GRYPE_DB_VALIDATE_AGE=false is mandatory here: by default Grype refuses a DB older than 5 days (db.max-allowed-built-age) and would start to fail EVERY scan as soon as the image was a few days old. Since the DB is baked at build time (and the image can be used for weeks in air-gapped environments), Quorum disables age validation by design — it accepts a potentially stale DB and documents the rebuild cadence; freshness is a supply chain choice, not a crash (comment in Dockerfile.full).

flowchart LR
    BUILD["docker build :full<br/>grype db update"] --> FROZEN["frozen DB<br/>/opt/grype/db<br/>(build date)"]
    FROZEN --> RUN{"runtime"}
    RUN -- "AUTO_UPDATE=false + VALIDATE_AGE=false (default)" --> USE["uses build DB<br/>(offline OK, never expires)"]
    RUN -- "GRYPE_DB_AUTO_UPDATE=true (needs network)" --> UPD["grype updates DB on-the-fly"]
    NEWIMG["rebuild / re-pull the :full image"] -.recommended refresh.-> FROZEN

Operational implication: the DB ages with the image, but does not expire (there is no longer a hard-fail by age). An out-of-date DB may miss recent CVEs — this reinforces the product's warning "0 findings is not proof of safety". Keep the :full image up to date in long-running pipelines.

Checklist (actionable):

  • [ ] For air-gapped environments: rely on the build DB and re-pull the image on a regular cadence (e.g. weekly).
  • [ ] For networked environments: consider GRYPE_DB_AUTO_UPDATE=true for maximum freshness.
  • [ ] Check the DB age with grype db status inside the container.

8. Output reports — SARIF / JSON / XML

8.1 Purpose and format

Final scan output, serialized by internal/report (report.Write, Format selector in report.go):

  • SARIF (primary, --format sarif, default): includes partialFingerprints["quorum/v1"] and the Fingerprint = sha256(correlationKey) for integration with code-scanning platforms.
  • JSON (--format json): canonical model serialized.
  • XML (--format xml).

Invalid format → usage error (exit 2). See report.ParseFormat (want sarif|json|xml).

Secret redaction: findings from scanners that carry matched secret snippets (e.g. Trivy's Match) are redacted before entering the report, so the output artifact does not leak the detected secret itself. See Data Model.

AI advice in the report: when --advice is set, each finding may carry an Advice block (recommendation, provider, model, and the "AI-generated, advisory only" label) plus deterministic Remediation/References fields. These are presentation-only additions; without --advice the report is byte-identical to the deterministic core.

8.2 Location and lifecycle

emit() in cmd/quorum/scan.go:

  • Without -o/--output: writes to stdout (cmd.OutOrStdout()).
  • With -o <file>: the path is normalized with filepath.Clean (collapses ./ and ../ segments), creates the parent directory if needed (os.MkdirAll(dir, 0o755)) and writes the file with permission 0o600 (owner-only — the report may carry sensitive finding detail and should not be world-readable by default), overwriting if it exists.
  • Lifecycle: ephemeral. It is a CI/output artifact; Quorum keeps no history and never reopens reports. Persistence/retention is the pipeline's responsibility (e.g. uploading SARIF to GitHub Code Scanning, a build artifact).

8.3 Versioning

  • The SARIF schema follows the SARIF standard; the product's own fingerprint namespace is explicitly versioned as quorum/v1 in partialFingerprints.
  • There is no "migration" — each run produces a fresh, complete report.

9. Metrics — --metrics <file> (Prometheus)

9.1 Purpose and format

--metrics <file> writes metrics in Prometheus text format (report.WriteMetrics, internal/report/metrics.go), suitable for a node_exporter textfile collector or a Pushgateway — exportable telemetry for a CLI that has no long-lived process to be scraped. Emitted series include, among others:

  • quorum_scan_duration_seconds (gauge) — total wall-clock time of the scan.
  • quorum_scanner_up{scanner,status} (gauge) — 1 if the scanner ran, 0 otherwise (skipped/unavailable/error/timeout).

When --advice is enabled, the advisory layer adds its own series (still non-sensitive counts):

  • quorum_advice_enriched{kind} — deterministic enrichments attached, by kind = remediation | references | recommendation.
  • quorum_advice_provider{provider} — which provider produced advice (none | local | remote).
  • quorum_advice_fix{stage} — proposed vs. verified fixes, by stage = proposed | verified (the verified/proposed ratio is the verify-the-fix rate).

These are non-sensitive counts, meant to be collected.

9.2 Location and lifecycle

writeMetricsFile() in cmd/quorum/scan.go:

  • The path is normalized with filepath.Clean, the parent directory is created (os.MkdirAll(dir, 0o755)) and the file is written with permission 0o644 — unlike the report (0600), because metrics are non-sensitive counts meant for scraping.
  • Lifecycle: ephemeral, overwritten on each run; retention/collection is the responsibility of the pipeline or the collector.

Complementarily, --log-format text|json controls the format of progress logs on stderr (produces no file). I/O-relevant anti-DoS caps: QUORUM_MAX_TARGET_BYTES (default 20 GiB, target size limit) and QUORUM_MAX_OUTPUT_BYTES (default 512 MiB, per-scanner output ceiling). Per-scanner passthrough via QUORUM_<SCANNER>_ARGS (e.g. QUORUM_CHECKOV_ARGS=--bc-api-key ...).


10. SBOMs and release attestations — supply chain artifacts

Status: generated in the build pipeline, not in a scan run.

Unlike the artifacts in the previous sections (read/written by quorum scan), these are produced by the release (.github/workflows/release.yml, .goreleaser.yaml, Dockerfile.full) and live in GHCR / GitHub Releases, not on the runtime disk:

  • SPDX SBOM per binary/archive: GoReleaser generates an SPDX SBOM per archive via syft (the sboms: block in .goreleaser.yaml), so each release publishes a machine-readable bill of materials of the binaries.
  • Attested SPDX SBOM for the image: in addition to BuildKit's sbom=true, the :full/:slim image receives an SBOM attestation via actions/attest-sbom.
  • SLSA provenance: build-provenance attestation (actions/attest-build-provenance) for the image and per binary.
  • Knowledge pack + crosswalk provenance (new in v0.8.3): the knowledge job attests every bundled pack file listed in knowledge.sha256 (remediation templates + the digest-pinned OWASP corpus + crosswalk), verifiable with gh attestation verify knowledge/owasp/corpus.yaml.
  • cosign keyless signature: signature over the checksums file (covers all artifacts by hash) and keyless verification with retry in the pipeline.
  • THIRD_PARTY_NOTICES.md: third-party notices published alongside.

Lifecycle: immutable per release, versioned by the semver tag; the moving v0 tag is advanced automatically on each release (tag-major.yml). Full details in Infrastructure/Supply Chain.


11. Fault-tolerance summary per artifact

Artifact Missing Unreadable / corrupted Strategy
Alias cache empty cache, scan proceeds empty cache, scan proceeds (also if schemaVersion differs) always degrades; never fails
AI advice cache empty cache, scan proceeds (only under --advice) empty cache, scan proceeds (same store as alias cache) opt-in; presentation-only; never fails
Baseline (.quorumignore) empty if default; error if passed explicitly read error propagated (except not-exist) explicit failure only when the user asked for the file
Crosswalk (dir) empty crosswalk (0 rules), findings stay unmapped fatal error if a file is present-but-invalid absence tolerated; broken content fails fast
Knowledge pack no deterministic remediation for that finding (only under --advice) corpus digest mismatch → RAG loader refuses corpus, grounding degrades opt-in; never a scan failure
Grype DB Grype error (but :full always has it) managed by Grype; never expires (VALIDATE_AGE=false) pre-cached at build
Output report N/A (it is a write) N/A filepath.Clean; creates parent dir; overwrites (0600)
Metrics N/A (it is a write) N/A filepath.Clean; creates parent dir; overwrites (0644)

Assumptions

  • The description reflects the codebase at v0.8.3 (branch main), as read from internal/cache/store.go, internal/alias/resolver.go, internal/alias/osv.go, internal/crosswalk/crosswalk.go, internal/filter/filter.go, internal/advisor/advisor.go, cmd/quorum/advisor.go, cmd/quorum/advise_index.go, cmd/quorum/scan.go, internal/report/report.go, internal/report/metrics.go, Dockerfile, Dockerfile.full, .goreleaser.yaml, .github/workflows/release.yml, the four crosswalk/{aws,azure,gcp,k8s}.yaml files and the knowledge/ pack (categories.yaml, owasp/corpus.yaml, and the cloud/K8s/image templates).
  • Per-OS cache paths derive from the contract of Go's stdlib os.UserCacheDir(); the per-OS examples (Linux/macOS/Windows) follow that function's documentation, not a hardcoded string in the code (the code only composes <UserCacheDir>/quorum/aliases.json and <UserCacheDir>/quorum/advice.json).
  • The AI advice cache shares the internal/cache store, so its envelope, atomic-write and degradation properties are the same as the alias cache; it is a separate file (advice.json) and is only exercised under --advice.
  • The internal Grype DB format is treated as opaque by Quorum; claims about it are based on the Grype contract and the GRYPE_DB_CACHE_DIR/GRYPE_DB_AUTO_UPDATE/GRYPE_DB_VALIDATE_AGE variables set in Dockerfile.full, not on reading the DB binary.
  • "Ephemeral report" and "ephemeral metrics" assume typical CI usage; Quorum imposes no retention — any persistence is external to the product.
  • The SARIF details (partialFingerprints["quorum/v1"], Fingerprint = sha256(correlationKey)) are based on the product specification and the routing in internal/report; the exact content of sarif.go is not cited line by line here (see Data Model).
  • The supply chain artifacts (SBOM/SLSA/cosign) are described from .goreleaser.yaml, Dockerfile.full and the release workflows; the operational detail lives in Infrastructure.

Known gaps

  • Cache without TTL/automatic invalidation: entries (in both the alias and advice caches) live indefinitely; only manual removal of the file (or a schemaVersion bump) refreshes them.
  • No inter-process locking on the caches: concurrent runs sharing the same --cache/--advice-cache can lose entries (no corruption, thanks to the atomic rename).
  • Custom crosswalk replaces, does not merge: pointing --crosswalk <dir> at another directory swaps the default entirely; there is no merge between the bundled directory and the custom one.
  • Crosswalk data migration still manual: schemaVersion enables format evolution and coexistence with the legacy list, but changes to the Control struct's fields still require rewriting code + YAMLs.
  • Grype DB age: frozen at the :full image build and without age expiration (VALIDATE_AGE=false); without a rebuild, it can silently miss recent CVEs.

Open questions

  • Is it worth introducing a configurable TTL or a quorum cache clear command for the alias and advice caches, now that the schema is already versioned?
  • What is the officially recommended cadence for rebuilding/re-pulling the :full image to keep the Grype DB fresh in long-running pipelines, given that age validation is disabled?
  • Should a custom crosswalk merge with the bundled one (instead of replacing it) when --crosswalk points at another directory?
  • Does it make sense to migrate aws.yaml (still in the legacy list form) to the versioned form (schemaVersion + controls), standardizing the four files?