Backend¶
The "backend" of Quorum (v0.8.3) is the set of Go packages that make up the CLI/Docker.
There is no application server, no long-running process (daemon), and no HTTP API: the backend
is a library of internal/* packages orchestrated by a command-line binary (cmd/quorum). Each
run of quorum scan is an ephemeral process that fans out to 12 external scanners (trivy,
grype, checkov, kics, dockle, kubescape, polaris, kube-score, terrascan, tfsec, regula, conftest),
normalizes everything into the canonical model model.Finding, resolves vulnerability aliases,
correlates equivalent findings, computes a consensus score, and emits a SARIF/JSON/XML report
(optionally Prometheus metrics too). The design principle that permeates the whole codebase is
"false split > false merge" (prefer never merging two distinct findings over incorrectly
merging two different findings) and "0 findings is not proof of safety" (absence of results is
never the same as proven safety).
Since v0.8.3 there is also an opt-in advisory layer (--advice): a strictly presentational
set of packages (internal/enrich, internal/rag, internal/advisor) that attach remediation
templates, OWASP references, and — optionally — an AI-generated recommendation. It is off by
default and never touches correlationKey / fingerprint / confidence / aggregated
severity / the --fail-on gate; without --advice the output is byte-identical. The deterministic
core still has no AI.
This document maps the enterprise "Backend" template (Layers, Services, Repositories, Controllers, Middlewares, Workers, Jobs, Cache, Queue/Messaging) onto the reality of the code. When the template asks for a concept typical of web/distributed applications that does not exist in this product, the item is marked N/A with a technical justification.
Related documents: Architecture · Data model · CLI / Commands · AI framing · this file is the canonical reference for the Go code.
Revision: 2026-07-04 · reference version: v0.8.3.
1. Layered overview¶
The backend is strictly layered, with dependencies pointing always inward (from the CLI binary
toward the domain). No domain package imports cmd/quorum, and the model package imports no one —
it is the stable core that everyone shares.
flowchart TD
subgraph CLI["Entry Layer (Controllers) — cmd/quorum"]
MAIN[main.go]
ROOT[root.go<br/>cobra root + list-scanners + advise-index]
SCAN[scan.go<br/>scan command + DoS guards]
end
subgraph APP["Application / Orchestration Layer"]
ORCH[orchestrator<br/>fan-out + per-scanner status]
end
subgraph SVC["Domain Services Layer"]
CORR[correlate<br/>identity + key]
CONS[consensus<br/>grouping + score]
ALIAS[alias<br/>CVE/GHSA resolution]
CW[crosswalk<br/>rule -> canonical control]
FILT[filter<br/>baseline + min-severity]
REP[report<br/>SARIF/JSON/XML + metrics]
end
subgraph ADV["Advisory Layer (opt-in, presentation-only) — --advice"]
ENR[enrich<br/>Phase 0: remediation templates + OWASP refs]
RAG[rag<br/>Phase 2: digest-pinned OWASP corpus]
ADVI[advisor<br/>Phase 1/3: local/remote LLM + verify-the-fix]
end
subgraph GW["Gateways Layer (Repositories)"]
AD[adapter<br/>wrappers for the 12 scanner CLIs]
CACHE[cache<br/>file-based JSON KV]
OSV[alias/osv<br/>OSV.dev HTTP client]
end
subgraph CORE["Core / pure utilities"]
MODEL[model<br/>Finding / MergedFinding]
SEV[severity<br/>normalization table]
PURL[purl<br/>package identity]
end
EXT[(OSS scanners<br/>trivy/grype/checkov/kics/<br/>dockle/kubescape/polaris/<br/>kube-score/terrascan/tfsec/<br/>regula/conftest)]
NET[(OSV.dev API)]
AIEP[(OpenAI-compatible<br/>endpoint: Ollama/remote)]
FS[(Filesystem:<br/>cache, crosswalk YAML,<br/>knowledge pack, OWASP corpus,<br/>policy Rego, .quorumignore,<br/>output, metrics)]
MAIN --> ROOT --> SCAN
SCAN --> ORCH
SCAN --> CORR & FILT & REP & CW & ALIAS & CACHE
SCAN -.opt-in.-> ENR & RAG & ADVI
ORCH --> AD
ORCH --> CORR
ORCH --> CONS
CORR --> ALIAS & CW
ALIAS --> CACHE & OSV
AD --> EXT
OSV --> NET
ADVI --> AIEP
RAG --> AIEP
CACHE --> FS
CW --> FS
ENR & RAG --> FS
REP --> FS
SCAN -.shared model.-> MODEL
CORR & CONS & FILT & REP & AD --> MODEL
ENR & RAG & ADVI --> MODEL
AD & CONS --> SEV
AD & CORR --> PURL
Layers table¶
| Layer | Package(s) | Responsibility | Depends on |
|---|---|---|---|
| Entry (Controllers) | cmd/quorum |
Flag parsing (cobra), target validation/inference, DoS guards (target size, argument injection), dependency wiring, exit codes | the whole application/services layer |
| Application / Orchestration | internal/orchestrator |
Select adapters, parallel fan-out, timeout/probe, collect findings, trigger enrich+consensus | adapter, correlate, consensus, model |
| Domain services | correlate, consensus, alias, crosswalk, filter, report |
Business logic: identity, score, alias/control resolution, suppression, serialization (report + metrics) | model, severity, purl, cache, osv |
| Advisory (opt-in) | enrich, rag, advisor |
Presentation-only: remediation templates + OWASP references + optional LLM recommendation/fix. Never affects gating | model, cache; optional OpenAI-compatible endpoint |
| Gateways (Repositories) | adapter, cache, alias/osv |
Access to external resources: CLI processes, cache file, OSV HTTP | model, severity, purl |
| Core / utilities | model, severity, purl |
Canonical types and pure, side-effect-free functions | nothing (model is a leaf) |
Repo golden rule (
internal/model/model.go): "Nothing in the pipeline operates on a scanner's raw JSON — every adapter normalizes into Finding, and every later stage speaks Finding and MergedFinding only." TheRaw map[string]anyis kept but markedjson:"-"(not serialized by default).
2. Execution pipeline (from command to report)¶
The full path of a scan run, with the packages responsible for each stage:
sequenceDiagram
participant U as User/CI
participant C as cmd/quorum (scan.go)
participant O as orchestrator
participant A as adapters (goroutines)
participant E as correlate (Correlator)
participant K as consensus
participant F as filter
participant V as advisory (enrich/rag/advisor)
participant R as report
U->>C: quorum scan <target> [flags]
C->>C: validateTargetRef / resolveTargetType / checkTargetSize
C->>C: Parse(fail-on,min-sev) / validate log-format / validate advice flags
C->>C: LoadBaseline / crosswalk.Load / cache.Open
C->>C: correlate.New(alias.New(cache,osv), crosswalk)
C->>O: orchestrator.Run(ctx, target, Options)
par parallel fan-out
O->>A: probe Version() (timeout 60s)
A-->>O: ran|skipped|unavailable|error|timeout
O->>A: Run() (timeout = --timeout, capped output)
A-->>O: []model.Finding
end
O->>E: Enrich(ctx, allFindings)
E->>E: resolveVuln (alias) / resolveControl (crosswalk)
E->>E: BuildKey + Fingerprint (sha256)
O->>K: consensus.Merge(allFindings)
K-->>O: []MergedFinding (score + ordering)
O-->>C: Result{Runs, Findings, Merged}
C->>F: filter.Apply(min-severity, baseline)
opt --advice (presentation-only, after gating inputs are fixed)
C->>V: enrich.Enrich / rag.AttachReferences / advisor.Enrich
V-->>C: Remediation / References / Advice attached in place
end
C->>R: report.Write(SARIF|JSON|XML) [+ WriteMetrics]
C->>U: report + summary + exit code (0/1/2)
Stages in prose:
- Controller (
scan.go) validates flags, rejects malicious targets (leading-), applies the target-size cap, infers the target type, loads baseline/crosswalk/cache and injects the dependencies into theCorrelator. It also validates the advisory flags (--advice-provider,--fix, egress consent) before doing any work. - Orchestrator selects the adapters that support the target, runs each in a goroutine, does the
version probe and the run with a timeout (and a stdout cap), and collects the canonical
Findings. - Correlate enriches (resolves aliases for VULN, controls for MISCONFIG/K8S_POSTURE/
IMG_HARDENING) and stamps
CorrelationKey+Fingerprint. - Consensus groups by
CorrelationKeyand computesConfidence/DetectionCount. - Filter applies
--min-severityand the.quorumignorebaseline. - Advisory (only if
--advice) runs after consensus and gating inputs are fixed: it attaches remediation templates, OWASP references, and optionally an AI recommendation/fix — strictly presentational, never mutating the key/fingerprint/confidence/severity/gate. - Report serializes the report and, if
--metricswas passed, writes the Prometheus metrics (including the advisory series when--adviceis on); the controller decides the exit code via--fail-on.
3. Services (domain logic)¶
The template's "services" correspond to the domain packages. None of them holds long-lived mutable global state; they are functions/objects instantiated per run.
3.1 internal/orchestrator — orchestration service¶
Key file: orchestrator.go. Exposes Run(ctx, target, Options) (*Result, error).
Optionscarries:Scanners []string,PerScannerTime(per-scanner timeout),ProbeTime(version-probe timeout, default60sviadefaultProbeTime),Correlator, andLogf.ResultaggregatesRuns []ScannerRun,Findings []model.Finding(raw, for JSON detail),Merged []model.MergedFinding,Duration.ScannerRun.Statustakesran | skipped | unavailable | error | timeout— status transparency is deliberate: "0 vulns must never look like scan didn't run".runOnedistinguishes probe failure causes: timeout (DeadlineExceeded), killed by signal/OOM (killedSignaldetects"signal: killed"), and missing binary, emitting actionable error messages (e.g. "raise the container's memory limit").- When
Correlator == nil, the orchestrator still computesCorrelationKey/Fingerprintviacorrelate.BuildKey/Fingerprintso consensus grouping works even without enrichment.
3.2 internal/correlate — identity service¶
Files: correlate.go (enrichment) and key.go (deterministic key).
Correlator{Alias, Crosswalk}— both dependencies may benil(degrades to id-as-is / unmapped).Enrichiterates over the findings:TypeVuln→resolveVuln(alias);TypeMisconfig/TypeK8sPosture/TypeImgHardening→resolveControl(crosswalk). Then appliesBuildKey+Fingerprint.BuildKeyis pure and deterministic, with a per-type key (there is no universal key):
| Type | CorrelationKey shape |
|---|---|
VULN |
VULN\|<uppercase VULNID>\|<name@version from PURL> |
MISCONFIG |
MISCONFIG\|<file basename>\|<resource type>\|<control> |
K8S_POSTURE |
K8S\|<ns/kind/name>\|<control> |
IMG_HARDENING |
IMGH\|<control> |
SECRET |
SECRET\|<normalized path>\|<line>\|<ruleId> |
| others | OTHER\|<scanner>\|<title> |
- Cross-engine granularity note: the
MISCONFIGkey uses basename + resource type + control (engines disagree on path and resource identity); theK8S_POSTUREkey uses object (ns/kind/name) + control, deliberately without the container, because kubescape reports at the workload level and polaris per container — including the container would block cross-engine consensus (documented inkey.go). Fingerprint(key) = sha256(key)in hex — it is thepartialFingerprints["quorum/v1"]in SARIF.controlKeyprefers the resolved canonical control; when unmapped, it usesUNMAPPED:<scanner>:<ruleId>to never silently merge distinct findings.
3.3 internal/consensus — score service¶
File: consensus.go. Exposes Merge([]Finding) []MergedFinding.
- Groups by
CorrelationKey, preserving first-appearance order. - For each group it computes:
DetectedBy(distinct scanners),Severity(aggregated max),DetectionCount,Unmapped(any member),Confidence. - Confidence formula (DESIGN §9), weights: count
0.35, diversity0.25, severity0.25, authoritative0.15. - count:
log(1+n)/log(5)— diminishing returns. - diversity: distinct engine families (
scannerCategory: sca, iac, k8s, hardening, policy); 1 family ≈ 0.33, 2 ≈ 0.66, 3+ = 1.0 — two different engines are worth more than two identical ones. - authoritative: 1.0 if
Confirmedor if it is a CVE with CVSS > 0. - Sorts by
(severity, confidence, detectionCount, correlationKey)descending for stable, useful output.
Current scannerCategory map (12 scanners → 5 families):
| Family | Scanners |
|---|---|
sca |
trivy, grype |
iac |
checkov, kics, terrascan, tfsec, regula |
k8s |
kubescape, polaris, kube-score |
hardening |
dockle |
policy |
conftest |
3.4 internal/alias — identifier-resolution service¶
Files: resolver.go (chain) and osv.go (OSV.dev client).
chainResolverresolves a vuln id to the canonical form, preferring CVE, in 3 layers:- aliases already present in the finding (
preferCVE), - local cache (
cache.Store), - OSV.dev arbitration (
osvSource). - Never returns an error: degrades gracefully to the best available id on a network failure.
--offlinepassesosv = nil, disabling layer 3 (uses only local aliases + cache).OSVClienthas retry with exponential backoff, an HTTP timeout, and classifies failures as retryable (network, 429, 5xx) or not.
3.5 internal/crosswalk — control-mapping service¶
File: crosswalk.go. Loads *.yaml/*.yml from a directory and indexes
"scanner|ruleID" → Resolution{Control, Category, CWE, Title}.
- A missing directory is not an error (runs without a custom crosswalk).
Resolvereturnsok=falsewhen there is no mapping — the caller keeps the finding isolated and marks itUnmapped("never guess a match", DESIGN §6).- The default directory is
./crosswalkwith automatic fallback to/opt/quorum/crosswalk(the Docker image bundle), viaresolveCrosswalkDirinscan.go. - Consensus enabled beyond SCA: the mappings are derived from real scanner output (applying "false split > false merge"). The bundle includes:
crosswalk/aws.yaml,azure.yaml,gcp.yaml— AVD hub; covers S3/IAM/EBS/SG/RDS/KMS/ CloudTrail/VPC-flow-logs (AWS), Storage/Key Vault (Azure), bucket/firewall/SQL (GCP).crosswalk/k8s.yaml— kubescape C-#### hub; correlates kubescape × polaris × kube-score on controls such as privilege-escalation, privileged, non-root, cpu/mem limits, probes, read-only-fs, linux-hardening, service-account automount, network-policy, host-network, host-PID/IPC, capabilities, and secrets.- tfsec auto-correlates with trivy without a crosswalk: it emits native AVD (extracts
AVD-<PROV>-<n>from the link and stores it inCanonicalControl). Seetfsec.go. - RBAC stays single-engine (kubescape's RBAC requires cluster context; documented as a limitation, not merged with other engines).
3.6 internal/filter — post-processing/gating service¶
File: filter.go. Applies the minimum-severity cut and the baseline before reporting/gating.
Baselinematches byFingerprintORCorrelationKey(the user can copy either from the report); the.quorumignorefile (default),#comments, blank lines ignored.LoadBaselinedistinguishes "missing" (ok=false) from "present but empty" — the controller requires existence only when--baselinewas passed explicitly.ApplyreturnsResult{Kept, SuppressedBaseline, SuppressedSeverity}— it always logs suppressions (a suppressed finding is still a finding, DESIGN §14).
3.7 internal/report — serialization service¶
Files: report.go (format dispatch), sarif.go, json.go, xml.go, metrics.go.
Write(w, res, format)dispatches to SARIF (primary), JSON, or XML.ParseFormatvalidates--format.- SARIF carries
partialFingerprints["quorum/v1"]derived fromFingerprint. WriteMetrics(w, res, adv)(metrics.go) emits the result in Prometheus text format, suited to node_exporter's textfile collector or a Pushgateway — exportable telemetry for a CLI with no long-running process to scrape. Series emitted:quorum_scan_duration_seconds,quorum_scanner_up{scanner,status},quorum_scanner_findings{scanner},quorum_scanner_duration_seconds{scanner},quorum_findings_after_consensus,quorum_findings_total{severity}, andquorum_multi_detected. When--adviceis on, it also emits the advisory series (see §3.8 and §5.1). Triggered by the--metrics <file>flag (see §5.1).
3.8 Advisory services (internal/enrich, internal/rag, internal/advisor) — opt-in¶
The advisory layer is presentation-only and off by default. It runs after consensus and the
gating inputs are fixed, attaching fields to MergedFinding in place without ever touching
CorrelationKey / Fingerprint / Confidence / aggregated severity / the --fail-on gate.
Without --advice, the report is byte-identical. The deterministic core has no AI; the AI parts
(Phases 1 and 3) are strictly opt-in and off by default. Every AI attachment is labeled
"AI-generated, advisory only". See AI framing and the AI proposal.
3.8.1 internal/enrich — Phase 0 (deterministic, no model)¶
File: enrich.go. Load(dir) reads the curated knowledge pack (knowledge/*.yaml:
aws/azure/gcp/k8s/image/categories) into a KB; KB.Enrich(merged) attaches a
model.Remediation (curated fix template) plus model.DocRef OWASP references, matched by
canonicalControl / ruleId / category / type. No model, fully deterministic. KB.Len()
reports how many entries were loaded. The default directory is ./knowledge with fallback to the
image-bundled /opt/quorum/knowledge (via resolveKnowledgeDir), configurable with --knowledge.
3.8.2 internal/rag — Phase 2 (RAG-as-artifact, deterministic)¶
Files: rag.go, lexical.go, embed.go. Retrieval from a versioned, digest-pinned OWASP
corpus (knowledge/owasp/corpus.yaml). Load(dir) reads the corpus and verifies its
ContentDigest; a tampered corpus fails the pin and is refused. Retrieval is lexical by
default (no model) via NewLexical; semantic (embeddings, NewSemantic) only when the corpus
ships vectors and a local endpoint is configured. scan auto-picks semantic when
corpus.HasEmbeddings() and --advice-provider=local. AttachReferences(merged, retriever, k)
attaches the top-k OWASP passages as model.DocRefs; GroundingText produces the grounding
string that feeds the Phase 1 prompt. Embeddings are excluded from the content digest, so the
pin is preserved after quorum advise-index.
3.8.3 internal/advisor — Phase 1 (local LLM) and Phase 3 (remote provider), opt-in¶
Files: advisor.go, client.go, prompt.go, verify.go. Advisor.Enrich(ctx, merged) queries an
OpenAI-compatible endpoint for a natural-language recommendation, attaching a model.Advice and
returning Stats{Advised, FixProposed, FixVerified}. It never returns a scan-failing error: on
the first connectivity failure it logs once and stops (graceful degradation — the report ships
without AI advice and the scan never fails).
- Phase 1, local (
NewLocalClient,--advice-provider=local): queries an on-host endpoint (e.g. Ollama). Reproducible viatemperature=0plus an on-disk cache keyed by fingerprint+provider+model. --fix=suggest: proposes a patch that must pass a verify-the-fix re-scan (verify.go): apply to a temp copy, re-scan with the same scanner (via theRescannerinterface, backed by the adapter registry incmd/quorum/advisor.go), and keep the patch only if the finding is gone and the file still parses. It never auto-applies. Fixes are scoped to repo/k8s targets (not image targets).- Phase 3, remote (
NewRemoteClient,--advice-provider=remote): calls an external API (auth viaQUORUM_ADVICE_API_KEY). Data leaves the host, so it is gated on explicit consent (--advice-allow-egress), blocked by--offline, and refuses--fix(which would upload source). Only the normalized finding is sent — never source code.
4. Repositories (external-resource access gateways)¶
Quorum has no relational-database repository. The "repository" role (an abstraction over access to a persistent or I/O external resource) is played by three gateways:
| Gateway | Package | External resource | Pattern |
|---|---|---|---|
| Scanner adapters | internal/adapter |
CLI processes (12 scanners) | Interface + Registry + exec.CommandContext |
| Alias cache | internal/cache |
On-disk JSON file | KV store with atomic flush |
| OSV client | internal/alias/osv.go |
OSV.dev HTTP API | HTTP client with retry/backoff |
The alias cache is also reused (with a distinct default path) by the advisory layer as the AI-advice cache — see §3.8.3 and §9.
4.1 Adapters as scanner gateways¶
The adapter package is the gateway to the "outside world" of the scanners. Each adapter implements
the Adapter interface:
type Adapter interface {
Name() string
Version(ctx context.Context) (string, error)
Supports(target Target) bool
Capabilities() []Capability
Run(ctx context.Context, target Target) ([]model.Finding, error)
}
- Registry: each adapter calls
Register(&xxx{})in itsinit();Get/All/Namesexpose the registry. A duplicate namepanics (a programming error). - Process execution:
runCmdruns the binary, folds stderr into the error, and treats non-zero exit with non-empty stdout as success (several scanners exit != 0 precisely because they found problems).toolVersiondoes the version probe and detects a missing binary. - Normalization: each adapter has its own parser (e.g.
trivy.parse) that translates the native JSON into[]model.Finding, usingseverity.FromLabel/FromCVSS/FromDockleandpurl.Build. Adapters never computeCorrelationKey— that is centralized in the correlator. - Contract tests: each adapter has a contract test against fixtures in
internal/adapter/testdata(seeadapter_test.go,realdata_test.go).
Registered adapters (12): trivy, grype (sca family); checkov, kics, terrascan, tfsec,
regula (iac / MISCONFIG); kubescape, polaris, kube-score (k8s / K8S_POSTURE);
dockle (hardening / IMG_HARDENING); conftest (policy — policy-as-code).
Per-adapter notes relevant to the backend:
conftesthas no built-in rules: it evaluates your own Rego (in./policyby default, or viaQUORUM_CONFTEST_ARGS="--policy <dir>"). With no policies it errors and the scanner is reported aserror— expected, since policy-as-code is opt-in.tfsecderives the AVD from the link and stores it inCanonicalControl, correlating with trivy without a crosswalk.kubescapewrites the report to a temp file (--output) and treats non-zero exit with a valid report as success.
Package shared helpers (adapter.go)¶
| Helper | Role |
|---|---|
maxOutputBytes() / capWriter |
DoS cap on scanner stdout. runCmd buffers into capWriter (default 512 MiB, defaultMaxOutputBytes, override via QUORUM_MAX_OUTPUT_BYTES); if exceeded, it aborts with an error instead of OOM. capWriter still returns len(p) so the child process's pipe never blocks, marking over and detecting truncation after the run. |
extraArgs(name) / splitArgs(s) |
Per-scanner argument passthrough via env QUORUM_<NAME>_ARGS (e.g. QUORUM_CHECKOV_ARGS="--bc-api-key <key>" unlocks Prisma Cloud/Bridgecrew policies; QUORUM_CONFTEST_ARGS="--policy <dir>"). splitArgs does a shell-like split (respects single/double quotes, no variable expansion). The value is operator-controlled — same trust level as the flags. Applied by all adapters. |
redactSecretText(s) |
Secret redaction: masks long tokens ([A-Za-z0-9+/=_-]{12,}), preserving the first 4 chars + …REDACTED…. Used by trivy to carry a SECRET's context (line) without leaking the value ("matched (redacted): " + redactSecretText(s.Match)). |
runCmd / toolVersion |
Process execution and version probe (described above). |
4.2 Cache as a repository¶
internal/cache/store.go is a map[string]string KV persisted to a JSON file — a deliberate choice
to give alias resolution idempotency/speed without pulling in a CGO database.
Open(path)loads; a missing/corrupt file becomes an empty cache (never breaks a scan).Putwrites with an atomic rename (.tmp→ destination, perm0600) and creates parent directories on demand; flush errors are swallowed on purpose (the cache is an optimization, not a source of failure). The file carries aschemaVersionfor format invalidation.- Safe for concurrent use (
sync.RWMutex) — relevant because the adapters run in parallel and share the resolver. - Default path:
~/.cache/quorum/aliases.json(viaos.UserCacheDir), configurable with--cache. - The same store type backs the AI-advice cache (
--advice-cache, default~/.cache/quorum/advice.json), keyed by fingerprint+provider+model for reproducible CI runs.
4.3 OSV client as a remote repository¶
internal/alias/osv.go is the HTTP gateway to OSV.dev (GET /v1/vulns/<id>), the "arbiter of last
resort" in the alias chain. The id is validated and passes through url.PathEscape before the
request. Details in §3.4.
5. Controllers (cobra commands)¶
The entry layer (cmd/quorum) is Quorum's "controller": it translates arguments/flags into service
calls, wires dependencies, and defines exit codes.
| File | Role |
|---|---|
main.go |
Entry point; runs the root, prints the error and exits with exit 2 on usage/runtime failure |
root.go |
Defines the root quorum command (cobra) and the list-scanners + advise-index subcommands |
scan.go |
Defines the scan command, validates/guards the target, wires all dependencies, and runs the pipeline |
advisor.go |
Wires the advisory layer for scan: the adapterRescanner (verify-the-fix re-scan via the adapter registry) and the AI-advice cache path |
advise_index.go |
Defines the advise-index command (embeds the OWASP corpus, preserving the digest pin) |
5.1 Commands¶
scan <target>(ExactArgs(1)): the main command. Core flags:--type(image|repo|k8s, inferred if omitted),--scanners,--format/-f(sarif|json|xml),--output/-o,--fail-on,--min-severity,--baseline(.quorumignore),--crosswalk(./crosswalk+ fallback/opt/quorum/crosswalk),--cache,--metrics <file>(writes Prometheus textfile metrics),--log-format(text|json, progress log format on stderr),--timeout(default5m),--offline,--quiet/-q.
Advisory flags (all opt-in; without --advice the output is byte-identical):
| Flag | Default | Meaning |
|---|---|---|
--advice |
false |
Attach deterministic remediation templates + OWASP references (advisory; does not affect gating) |
--advice-provider |
none |
AI recommendation provider: none|local|remote |
--advice-endpoint |
http://localhost:11434/v1 |
OpenAI-compatible base URL (local by default; set the remote URL for remote) |
--advice-model |
qwen2.5-coder:7b |
Model id for --advice-provider=local |
--advice-embed-model |
nomic-embed-text |
Embedding model for semantic OWASP retrieval (only when the corpus ships embeddings) |
--advice-cache |
~/.cache/quorum/advice.json |
AI-advice cache file (keyed by fingerprint+model) |
--advice-max |
50 |
Max findings sent to the AI provider per run (0 = no cap) |
--advice-allow-egress |
false |
Consent to send findings off-host; required for --advice-provider=remote |
--fix |
off |
AI suggested-fix mode: off|suggest (suggest emits a verify-the-fix patch; never auto-applies) |
--knowledge |
./knowledge |
Directory of advisory knowledge-pack files (remediation templates + OWASP refs) |
Advisory-flag validation (in scan.go): --advice-provider must be none|local|remote and
--fix must be off|suggest; --advice-provider=remote is blocked by --offline, requires
--advice-allow-egress and QUORUM_ADVICE_API_KEY, and refuses --fix (it would upload
source).
advise-index(NoArgs): embeds the OWASP RAG corpus, turning the default lexical retrieval into semantic. It reads the corpus, embeds every chunk via a local OpenAI-compatible embeddings endpoint (e.g. Ollama), and writes it back with per-chunk vectors. Embeddings are excluded from the content digest, so the pin is preserved. Flags:--corpus,--out,--advice-endpoint,--advice-embed-model.list-scanners: lists the registered adapters and their capabilities (Capabilities().Type).
Beyond the flags, behavior is tunable via environment variables (operator trust level):
QUORUM_<NAME>_ARGS (per-scanner passthrough, §4.1), QUORUM_MAX_OUTPUT_BYTES (stdout cap, default
512 MiB), QUORUM_MAX_TARGET_BYTES (on-disk target-size cap, default 20 GiB; 0 disables), and
QUORUM_ADVICE_API_KEY (auth for --advice-provider=remote).
5.2 Target inference and validation¶
validateTargetRef: refuses a target starting with-(avoids argument injection into a downstream scanner; steer./-nameto a literal path).checkTargetSize: forrepo/k8stargets, does a walk that stops as soon as the cap (QUORUM_MAX_TARGET_BYTES, default 20 GiB) is crossed;imagetargets are skipped.resolveTargetType: if--typeis omitted, an existing on-disk path →repo; otherwise →image. Accepts aliases (fs/dir→ repo;kubernetes/manifests→ k8s).
5.3 Exit codes (the gating contract)¶
| Exit | Meaning | Origin in code |
|---|---|---|
0 |
OK — no finding reached --fail-on |
normal runScan return |
1 |
Gate tripped — a finding ≥ --fail-on |
os.Exit(1) in runScan |
2 |
Usage/runtime error | os.Exit(2) in main.go |
The advisory layer never changes the exit code: a scan with
--adviceyields the exact same gate result as without it.
5.4 Progress output, summary, and artifacts¶
- The controller injects a
Logfthat writes to stderr (silenceable via--quiet) in two formats:text(prefix[quorum]) orjson(line{ts,level,msg}), per--log-format. Under--adviceit also logs the advisory stages (advice: knowledge=…,advice(rag): corpus=…,advice(ai): provider=… fixes: N verified / M proposed). - It prints a human summary (per-scanner status, counts by severity, multi-detected, time) — always ending with "0 findings is not proof of safety".
--outputis normalized withfilepath.Cleanand written with perm0600(it may carry sensitive finding detail). The--metricsfile is written with0644(non-sensitive counts, meant to be scraped).
6. Middlewares — N/A (with equivalents)¶
N/A. There is no HTTP middleware chain nor interception framework, because there is no HTTP server. The roles middlewares would fill in a web application are covered by CLI/Go-runtime mechanisms:
| Typical middleware role | Quorum equivalent |
|---|---|
| Authentication/authorization | N/A — no accounts/users (local CI tool). The one credential, QUORUM_ADVICE_API_KEY, is an outbound API key for the opt-in remote provider, not inbound auth |
| Request logging | Logf injected by the controller (stderr, text/json via --log-format) |
| Timeout/cancellation | context.Context propagated; context.WithTimeout per scanner and on the probe |
| Central error handling | runCmd/toolVersion fold stderr; main.go centralizes exit 2 |
| Panic recovery | No global recover; panics are programming errors (e.g. duplicate registration) |
| Rate limiting | Partial — backoff/retry in the OSV client (osv.go) and in the advisor HTTP client |
| Abuse/DoS protection | validateTargetRef (argument injection), checkTargetSize and capWriter (size caps); egress consent gate for the remote advisor |
7. Workers (fan-out concurrency)¶
Quorum's "workers" are ephemeral goroutines created by the orchestrator for the parallel fan-out of scanners. There is no persistent worker pool nor work queue.
flowchart LR
R[orchestrator.Run] -->|wg.Add per adapter| G1[goroutine: trivy]
R --> G2[goroutine: grype]
R --> G3[goroutine: checkov]
R --> Gn[goroutine: ... up to 12]
G1 -->|mu.Lock| AGG[(all slice + Runs)]
G2 --> AGG
G3 --> AGG
Gn --> AGG
AGG -->|wg.Wait| ENR[Enrich -> Merge]
Mechanics (in orchestrator.go):
- One goroutine per selected adapter; a
sync.WaitGroupsynchronizes completion. - Results are aggregated under a
sync.Mutex(res.Runsand theallslice). - Each worker (
runOne) does:Supports→ version probe with a timeout (ProbeTime, 60s default) →Runwith a timeout (PerScannerTime=--timeout) → classify status. - The
context.Contextflows from the controller; each worker derives sub-contexts withWithTimeoutand cancels at the end (cancelVer()/defer cancel). - There is no explicit parallelism limit (concurrency = number of supported adapters, today ≤ 12).
Concurrency-guarantee checklist:
- [x] Shared access protected by a mutex (
res.Runs,all). - [x] Alias cache safe for concurrency (
cache.StorewithRWMutex). - [x] Per-scanner and per-probe timeouts isolated via
context. - [x] Results deterministically ordered after
wg.Wait(sort.SliceonRuns). - [x] Per-scanner stdout cap (
capWriter) to avoid OOM under pathological output. - [ ] Configurable parallelism limit (does not exist — see Assumptions / future proposal).
The advisory layer runs after the fan-out (single-threaded, in the controller), so it adds no concurrency to this section. The AI provider is queried sequentially per finding, capped by
--advice-max.
8. Jobs / scheduling — N/A¶
N/A. There is no scheduler, internal cron, background job, nor recurring task inside the process. Quorum is one-shot: one process per invocation, terminating when it emits the report.
Scheduling, when desired, is external to the backend: GitHub Actions schedules/triggers the CLI
(via the action.yml composite or the :full/:slim image). Warm-ups like the grype database cache
happen at build time of the Docker image, not as a runtime job. Likewise, quorum advise-index
(embedding the OWASP corpus) is a one-off pre-processing command, not a scheduled job.
Future proposal (clearly separated): a
--watchmode or integration with external schedulers could be added without changing the core, but does not exist today.
9. Cache¶
There are two cache layers, both backed by the same internal/cache store (§4.2): the alias
cache and the opt-in AI-advice cache.
| Aspect | Alias cache | AI-advice cache (--advice only) |
|---|---|---|
| Backend | JSON file (map[string]string + schemaVersion) |
same store type |
| Key / value | vuln id → canonical id (CVE preferred) | fingerprint+provider+model → recommendation |
| Default location | ~/.cache/quorum/aliases.json |
~/.cache/quorum/advice.json |
| Flag | --cache <path> |
--advice-cache <path> |
| Write | Atomic (.tmp + os.Rename), perm 0600, errors swallowed |
same |
| Concurrency | sync.RWMutex |
same |
| Invalidation | No TTL; schemaVersion invalidates on format change |
keyed by model, so a model change is a new key |
| Failure tolerance | Missing/corrupt file → empty cache, scan proceeds | same; also degrades to a live query |
There is no scan-result cache, no OSV HTTP cache beyond that KV, and no distributed in-memory cache.
The grype database pre-cache is an artifact of the :full image (build time, with
GRYPE_DB_VALIDATE_AGE=false so it does not expire), not a cache layer managed by the Go backend.
10. Queue / Messaging — N/A¶
N/A. There is no broker (Kafka/RabbitMQ/SQS), no internal message queue, and no asynchronous inter-process communication. The technical justification:
- Quorum is a single, stateless, ephemeral process; all internal coordination uses Go's in-process
primitives (goroutines +
WaitGroup+Mutex+context). - The execution model is synchronous fan-out/fan-in within a single invocation — there is no decoupled producer/consumer and no need for message durability.
- The "messaging" between stages is simply passing
[]model.Findingslices in memory down the pipeline (orchestrator → correlate → consensus → filter → [advisory] → report).
There is no messaging proposal: introducing it would contradict the principle of being a lightweight,
daemonless CLI ("No panel, no daemon" — root.go).
11. Responsibility of each internal/* package¶
Quick-reference table (one line per package), faithful to the code read:
| Package | Template role | Single responsibility | Files | Depends on |
|---|---|---|---|---|
model |
Core | Canonical types Finding/MergedFinding (incl. advisory fields Remediation/References/Advice), Severity (with Rank), Resource, Location, Remediation/DocRef/Advice/Fix |
model.go |
— (leaf) |
severity |
Utility | Severity normalization (from label, CVSS, Dockle), Max, AtLeast, Parse |
severity.go |
model |
purl |
Utility | Build/extract package identity (name@version) for the VULN key |
purl.go |
— |
adapter |
Repository/Gateway | Adapter interface + registry + runCmd/toolVersion + helpers (capWriter/maxOutputBytes, extraArgs/splitArgs, redactSecretText); one file per scanner with a parser |
adapter.go, trivy.go, grype.go, checkov.go, kics.go, dockle.go, kubescape.go, polaris.go, kubescore.go, terrascan.go, tfsec.go, regula.go, conftest.go |
model, severity, purl |
cache |
Repository (cache) | Persistent JSON KV with atomic flush (0600), schemaVersion, thread-safe |
store.go |
— |
alias |
Service + gateway | Id resolution chain (aliases→cache→OSV); OSV HTTP client (validated id + PathEscape) |
resolver.go, osv.go |
cache |
crosswalk |
Service | Maps scanner\|ruleID → canonical control (YAML derived from real output) |
crosswalk.go |
yaml.v3 |
correlate |
Service | Enrichment + deterministic CorrelationKey/Fingerprint |
correlate.go, key.go |
alias, crosswalk, model, purl |
consensus |
Service | Groups by key and computes Confidence/DetectionCount; sorts; scannerCategory (5 families) |
consensus.go |
model, severity |
filter |
Service | Baseline (.quorumignore) + --min-severity cut |
filter.go |
model, severity |
report |
Service | Serializes Result into SARIF/JSON/XML + Prometheus metrics (incl. advisory series) |
report.go, sarif.go, json.go, xml.go, metrics.go |
orchestrator |
orchestrator |
Application | Parallel fan-out, probe/timeout, per-scanner status, orchestrates the pipeline | orchestrator.go |
adapter, correlate, consensus, model |
enrich |
Advisory (opt-in) | Phase 0: loads the knowledge pack and attaches curated remediation templates + OWASP refs (deterministic, no model) | enrich.go |
model, yaml.v3 |
rag |
Advisory (opt-in) | Phase 2: lexical/semantic retrieval from the digest-pinned OWASP corpus; AttachReferences/GroundingText; embedder |
rag.go, lexical.go, embed.go |
model |
advisor |
Advisory (opt-in) | Phase 1/3: local/remote LLM recommendation + verify-the-fix patch; graceful degradation; labeled attachments | advisor.go, client.go, prompt.go, verify.go |
model, cache, adapter (via Rescanner) |
evals |
Advisory (testing) | Offline harness measuring deterministic remediation coverage, OWASP reference relevance, and the verify-the-fix rate (runs in CI, no heavy model) | evals.go |
enrich, rag, model |
And the command packages:
| Package | Role | Responsibility |
|---|---|---|
cmd/quorum |
Controller | main.go (entry/exit), root.go (cobra root + list-scanners + advise-index), scan.go (scan command + wiring + DoS guards + exit codes), advisor.go (advisory wiring + verify-the-fix rescanner), advise_index.go (embed the OWASP corpus) |
12. Dependency graph (acyclic)¶
flowchart BT
model
severity --> model
purl
cache
crosswalk
alias --> cache
correlate --> alias
correlate --> crosswalk
correlate --> model
correlate --> purl
consensus --> model
consensus --> severity
filter --> model
filter --> severity
adapter --> model
adapter --> severity
adapter --> purl
orchestrator --> adapter
orchestrator --> correlate
orchestrator --> consensus
orchestrator --> model
report --> orchestrator
report --> model
enrich --> model
rag --> model
advisor --> model
advisor --> cache
evals --> enrich
evals --> rag
cmdquorum["cmd/quorum"] --> orchestrator
cmdquorum --> correlate
cmdquorum --> alias
cmdquorum --> cache
cmdquorum --> crosswalk
cmdquorum --> filter
cmdquorum --> report
cmdquorum --> severity
cmdquorum --> model
cmdquorum --> adapter
cmdquorum --> enrich
cmdquorum --> rag
cmdquorum --> advisor
Graph characteristics: acyclic, model is a leaf, and the whole domain is testable without a
network (the OSV client is injected via the osvSource interface, stubbable in tests; the advisor's
Client and Rescanner are likewise interfaces). The advisory packages sit at the same "leaf-ish"
level — they depend only on model (plus cache/adapter via interfaces), never the other way
around.
13. Extension checklist (actionable)¶
Add a new scanner:
- [ ] Create
internal/adapter/<scanner>.goimplementing theAdapterinterface. - [ ] Call
Register(&<scanner>{})ininit(). - [ ] Implement
Version,Supports,Capabilities,Run+ a parser tomodel.Finding. - [ ] Use
runCmd(output cap) andextraArgs("<scanner>")for operator passthrough. - [ ] Map the engine family in
consensus.scannerCategory(sca/iac/k8s/hardening/policy). - [ ] Add fixtures in
internal/adapter/testdataand the contract test. - [ ] (If IaC/k8s) add YAML crosswalk rules derived from real output for the scanner's
ruleIDs (or emit a nativeCanonicalControl, as tfsec does with AVD).
Add a new report format:
- [ ] Add a
Formatinreport/report.goand thecaseinWrite/ParseFormat. - [ ] Create
report/<format>.gowith thewrite<Format>(w, res)function. - [ ] Update the
--formatflag help inscan.go.
Extend the advisory layer:
- [ ] Phase 0: add curated entries to the knowledge pack (
knowledge/*.yaml), matched bycanonicalControl/ruleId/category/type;enrich.Loadpicks them up. - [ ] Phase 2: add passages to
knowledge/owasp/corpus.yaml; re-runquorum advise-indexto refresh the semantic index (the content digest is preserved). Add a case tointernal/evals. - [ ] Keep every AI attachment labeled "AI-generated, advisory only" and ensure it never mutates key/fingerprint/confidence/severity/gate.
Assumptions¶
- Reference version: the document describes the code of the current tree (v0.8.3). The
versionvariable inroot.gois"dev"as the build default (overridden by-ldflags "-X main.version=..."in GoReleaser); the "real" release version comes from the ldflag, not from the code. - "Backend" = Go packages: I interpreted "backend" as the set
cmd/quorum+internal/*, since there is no server/long-running service. Web-oriented template items (HTTP middlewares, jobs/scheduler, queue/messaging) were treated as N/A with a justification, per the writing rules. - Engine families: the
scannerCategorytable inconsensus.gocovers the 12 adapters currently registered, grouped into 5 families (sca, iac, k8s, hardening, policy). - Parallelism limit: I assumed the fan-out concurrency equals the number of supported adapters (≤ 12 today), since there is no configurable limiter in the code.
- SARIF/JSON/XML serialization details: I read
report.go,sarif.go,metrics.goand the use ofpartialFingerprints["quorum/v1"]; the field-by-field detail of the SARIF schema is left to Data model to avoid duplication. - grype pre-cache: I assumed the grype database in the
:fullimage is populated at build time (Dockerfile.full, withGRYPE_DB_VALIDATE_AGE=false), and therefore is not a cache layer managed by the Go backend. - conftest's
./policydirectory: theconftestadapter evaluates operator Rego in./policy(or viaQUORUM_CONFTEST_ARGS="--policy <dir>"). With no policies, the scanner is reported aserrorby design (policy-as-code is opt-in); no versioned Rego ships in the repo by default. - Advisory layer is presentation-only: I verified in
scan.gothat--adviceruns after consensus and gating inputs are fixed and attaches fields toMergedFindingin place; it never touchesCorrelationKey/Fingerprint/Confidence/severity/--fail-on. The deterministic core has no AI; Phases 1 (local) and 3 (remote) are strictly opt-in, off by default, with the remote provider gated on explicit egress consent, blocked by--offline, and refusing--fix.