Functional Requirements¶
This document specifies the Functional Requirements (FR) of Quorum (quorum-sec-scan), version v0.8.3 (revision 2026-07-04). Quorum is a CLI/Docker consensus security scanning tool: it orchestrates a pool of 12 open-source scanners (trivy, grype, checkov, kics, dockle, kubescape, polaris, kube-score, terrascan, tfsec, regula, conftest), normalizes every finding to a canonical model (model.Finding), resolves vulnerability aliases, correlates equivalent findings by a deterministic key (correlationKey), scores consensus confidence (confidence), and emits a unified report (SARIF/JSON/XML) with exit-code gating for CI/CD.
Each FR below is derived directly from the source code (cmd/quorum/*.go and internal/*) and described with ID, Name, Description, Flow, Inputs, Outputs, Business rules, Priority, and Dependencies. The FRs cover the real product surface as-is; capabilities that do not exist (web frontend, relational database, REST API, built-in authentication) are marked N/A in the Out-of-scope items (N/A) section.
Design principle that permeates every FR: "false split > false merge" and "0 findings is not proof of safety". The system prefers to isolate a finding rather than merge it incorrectly, and never treats "0 vulnerabilities" as proof of safety — the per-scanner status is always reported.
What changed since v0.2.3 (as-is v0.8.3): the pool grew from 6 to 12 scanners; consensus became active also for MISCONFIG/K8S_POSTURE/IaC (not only SCA), via a crosswalk derived from real output (
crosswalk/{aws,azure,gcp,k8s}.yaml); new flags--metricsand--log-format; per-scanner argument passthrough viaQUORUM_<SCANNER>_ARGS; DoS caps for scanner output and target size; validation oftargetagainst argument injection; and I/O hardening (report with0600+filepath.Clean).What is new since v0.7.4 (as-is v0.8.3): an opt-in ADVISORY LAYER (
--advice). It is presentation-only — it never touchescorrelationKey,fingerprint,confidence, aggregated severity, or the--fail-ongate; without--advicethe output is byte-identical. The deterministic core still has no AI. The layer has four phases: Phase 0 (deterministic remediation templates + OWASP references from a knowledge pack, RF-028), Phase 2 (deterministic OWASP RAG references from a digest-pinned corpus + theadvise-indexsubcommand, RF-029), Phase 1 (opt-in local LLM recommendation, RF-030), and the suggested-fix mode with verify-the-fix re-scan (RF-031). Remote provider (Phase 3) is egress-gated. New CLI flags:--advice,--advice-provider,--advice-endpoint,--advice-model,--advice-embed-model,--advice-cache,--advice-max,--advice-allow-egress,--knowledge,--fix.
Requirements Index¶
| ID | Name | Priority | Main component |
|---|---|---|---|
| FR-001 | scan command for a target |
Mandatory | cmd/quorum/scan.go |
| FR-002 | Target type inference and selection | Mandatory | cmd/quorum/scan.go |
| FR-003 | List registered scanners (list-scanners) |
Mandatory | cmd/quorum/root.go |
| FR-004 | Scanner selection | Mandatory | orchestrator, adapter |
| FR-005 | Parallel execution (fan-out) with per-scanner timeout | Mandatory | orchestrator |
| FR-006 | Scanner version probe and availability | Mandatory | orchestrator, adapter |
| FR-007 | Per-scanner status (execution transparency) | Mandatory | orchestrator, report |
| FR-008 | Canonical normalization of findings | Mandatory | adapter, model, severity |
| FR-009 | Vulnerability alias resolution | Mandatory | alias, cache |
| FR-010 | Offline mode (OSV disabled) | Mandatory | cmd/quorum/scan.go, alias |
| FR-011 | Alias cache | Recommended | cache |
| FR-012 | Crosswalk rule → canonical control | Mandatory | crosswalk, correlate |
| FR-013 | Automatic bundled crosswalk fallback | Recommended | cmd/quorum/scan.go |
| FR-014 | Correlation by correlationKey and fingerprint |
Mandatory | correlate |
| FR-015 | Consensus scoring and aggregation | Mandatory | consensus |
| FR-016 | SARIF/JSON/XML report emission | Mandatory | report |
| FR-017 | Suppression baseline (.quorumignore) |
Mandatory | filter |
| FR-018 | Minimum severity filter (--min-severity) |
Mandatory | filter, severity |
| FR-019 | Failure gate (--fail-on) and exit codes |
Mandatory | cmd/quorum/scan.go, severity |
| FR-020 | Output to file or stdout | Mandatory | cmd/quorum/scan.go |
| FR-021 | Console summary and progress logs | Recommended | cmd/quorum/scan.go |
| FR-022 | Tool version | Recommended | cmd/quorum/root.go |
| FR-023 | Prometheus metrics export (--metrics) |
Recommended | cmd/quorum/scan.go, report |
| FR-024 | Progress log format (--log-format) |
Recommended | cmd/quorum/scan.go |
| FR-025 | Per-scanner argument passthrough (QUORUM_<SCANNER>_ARGS) |
Recommended | internal/adapter |
| FR-026 | DoS caps: output and target size | Mandatory | internal/adapter, cmd/quorum/scan.go |
| FR-027 | Target validation (argument injection) | Mandatory | cmd/quorum/scan.go |
| FR-028 | Advisory enrichment (--advice, knowledge pack) |
Recommended | internal/enrich, cmd/quorum/scan.go |
| FR-029 | OWASP RAG references and advise-index |
Recommended | internal/rag, cmd/quorum/advise_index.go |
| FR-030 | AI recommendations (--advice-provider) |
Optional | internal/advisor |
| FR-031 | Suggested fix (--fix) with verify-the-fix |
Optional | internal/advisor |
Pipeline overview¶
The pipeline run by the scan command is linear and deterministic after the fan-out phase. The advisory layer (FR-028…FR-031) is an opt-in presentation stage bolted on after consensus and filters, before the report is written; it never feeds back into the gate.
flowchart TD
A["scan target (flags)"] --> A0["FR-027: validate target ref<br/>(rejects '-' prefix)"]
A0 --> B["FR-002: resolve target type<br/>image | repo | k8s"]
B --> B0["FR-026: target size cap<br/>(QUORUM_MAX_TARGET_BYTES)"]
B0 --> C["FR-004: select adapters<br/>(all or --scanners)"]
C --> D["FR-005: parallel fan-out<br/>(goroutines + timeout)"]
D --> E["FR-006: version probe<br/>(60s, distinguishes OOM/timeout/missing)"]
E --> F["FR-008/FR-025/FR-026: normalize<br/>output → model.Finding (extraArgs, output cap)"]
F --> G["FR-009/FR-012: enrich<br/>(VULN aliases + crosswalk MISCONFIG/K8S/IaC)"]
G --> H["FR-014: correlationKey + fingerprint"]
H --> I["FR-015: consensus<br/>(merge + confidence + aggregated severity)"]
I --> J["FR-017/FR-018: filters<br/>(baseline + min-severity)"]
J --> J2["FR-028/029/030/031: advisory layer (--advice, opt-in)<br/>remediation + OWASP refs + AI recommendation + fix"]
J2 --> K["FR-016/FR-020: report<br/>SARIF | JSON | XML → file/stdout"]
K --> L["FR-007/FR-021/FR-024: per-scanner status + summary (text/json)"]
L --> L2["FR-023: Prometheus metrics (--metrics)"]
L2 --> M["FR-019: fail-on gate → exit code"]
Package reference: cmd/quorum (cobra CLI) → internal/orchestrator → internal/adapter (12 scanners) → internal/{alias,cache,crosswalk} (enrich) → internal/correlate → internal/consensus → internal/filter → internal/{enrich,rag,advisor} (opt-in advisory) → internal/report.
RF-001 — scan command for a target¶
| Field | Content |
|---|---|
| ID | FR-001 |
| Name | scan command for a target |
| Description | The user must be able to scan a single target (container image, IaC repository/directory, or k8s manifests) by running the scanner pool and producing a consensus report. It is the tool's central command. |
| Priority | Mandatory |
Flow
sequenceDiagram
actor U as User/CI
participant C as quorum scan
participant O as orchestrator
participant R as report
U->>C: quorum scan <target> [flags]
C->>C: validate target ref, --log-format, --fail-on, --min-severity, --format, --baseline, --advice-provider, --fix
C->>C: target size cap (FR-026)
C->>O: orchestrator.Run(ctx, target, options)
O-->>C: Result (Runs, Findings, Merged)
C->>C: filter.Apply (baseline + min-severity)
C->>C: advisory layer (--advice, opt-in) → remediation/refs/AI (FR-028…FR-031)
C->>R: report.Write (SARIF/JSON/XML)
R-->>U: report (file or stdout)
C->>C: --metrics (optional) → Prometheus file
C->>U: summary on stderr + exit code
Inputs
| Input | Source | Required |
|---|---|---|
<target> (1 positional argument) |
cobra.ExactArgs(1) |
Yes |
Flags --type, --scanners, --format/-f, --output/-o, --fail-on, --min-severity, --baseline, --crosswalk, --knowledge, --cache, --metrics, --log-format, --timeout, --offline, --quiet/-q |
CLI | No (have defaults) |
Advisory flags --advice, --advice-provider, --advice-endpoint, --advice-model, --advice-embed-model, --advice-cache, --advice-max, --advice-allow-egress, --fix |
CLI | No (off by default) |
Environment variables QUORUM_<SCANNER>_ARGS, QUORUM_MAX_OUTPUT_BYTES, QUORUM_MAX_TARGET_BYTES, QUORUM_ADVICE_API_KEY |
Environment | No |
Outputs: report in the chosen format (stdout or file), execution summary on stderr, optional metrics file, exit code (0/1/2).
Business rules
- cobra.ExactArgs(1): exactly one target is required; 0 or 2+ arguments → usage error (exit 2).
- The target is validated against argument injection before any other step (FR-027).
- Invalid flags (e.g. --fail-on outside critical|high|medium|low, unknown --format, --log-format ≠ text|json, --advice-provider ≠ none|local|remote, --fix ≠ off|suggest) abort before any scanner runs.
- The command never fails because of a corrupt cache/aliases/crosswalk — those degrade gracefully (see FR-009, FR-011, FR-012). Likewise, the advisory layer degrades gracefully and never fails the scan (FR-028…FR-031).
Dependencies: FR-002, FR-004, FR-005, FR-008, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-026, FR-027; optionally FR-028…FR-031.
RF-002 — Target type inference and selection¶
| Field | Content |
|---|---|
| ID | FR-002 |
| Name | Target type inference and selection |
| Description | The target type (image, repo, k8s) can be provided via --type or inferred automatically. The type selects which adapters are applicable and how each scanner is invoked. |
| Priority | Mandatory |
Flow / Mapping rules (resolveTargetType in scan.go)
--type value |
Result |
|---|---|
image |
TargetImage |
repo / fs / dir |
TargetRepo |
k8s / kubernetes / manifests |
TargetK8s |
| (empty) + path exists on disk | TargetRepo (inferred) |
| (empty) + path does not exist | TargetImage (inferred — assumes an image ref) |
| any other value | error invalid --type (exit 2) |
Inputs: --type (string, case-insensitive), <target> (used in os.Stat for inference).
Outputs: resolved adapter.TargetType, propagated to adapter.Target{Type, Ref}.
Business rules
- Inference uses os.Stat(ref): exists on disk → repo; otherwise → image.
- The type is case-insensitive (strings.ToLower).
- The type determines each adapter's Supports(target) (FR-004) and each scanner's CLI arguments (FR-008).
Dependencies: FR-001, FR-027 (ref validation). Consumed by FR-004, FR-008, and FR-026 (the size cap applies only to repo/k8s).
RF-003 — List registered scanners (list-scanners)¶
| Field | Content |
|---|---|
| ID | FR-003 |
| Name | List registered scanners (list-scanners) |
| Description | The user must be able to list every registered scanner adapter and the finding types each one covers (capabilities). |
| Priority | Mandatory |
Flow (newListScannersCmd in root.go): gets adapter.Names(), sorts them, and for each name prints name + list of Capability.Type.
Inputs: none (no arguments or flags of its own).
Outputs (stdout, one line per scanner, format %-12s %v, alphabetically sorted), the 12 scanners registered in v0.8.3:
| Scanner | Covered types (Capabilities) |
|---|---|
checkov |
[MISCONFIG] |
conftest |
[MISCONFIG] |
dockle |
[IMG_HARDENING] |
grype |
[VULN] |
kics |
[MISCONFIG] |
kube-score |
[K8S_POSTURE] |
kubescape |
[K8S_POSTURE] |
polaris |
[K8S_POSTURE] |
regula |
[MISCONFIG] |
terrascan |
[MISCONFIG] |
tfsec |
[MISCONFIG] |
trivy |
[VULN MISCONFIG SECRET] |
Business rules
- The list comes from the registry populated at compile time by each adapter's init() (adapter.Register).
- Output is sorted alphabetically by name (sort.Strings); note that kube-score precedes kubescape (the hyphen sorts before s).
- It does not check whether the scanner binary is installed — it lists declared capabilities, not runtime availability (that is FR-006's job during the scan).
Dependencies: adapter registry (adapter.Register, adapter.Names, adapter.Get, Capabilities).
RF-004 — Scanner selection¶
| Field | Content |
|---|---|
| ID | FR-004 |
| Name | Scanner selection |
| Description | By default all adapters that support the target are run; the user can restrict to a subset via --scanners (comma-separated list). Unknown names are warned, they do not fail the scan. |
| Priority | Mandatory |
Flow (splitScanners in scan.go + selectAdapters in orchestrator.go)
flowchart TD
A["--scanners empty?"] -->|Yes| B["adapter.All() sorted by name"]
A -->|No| C["for each name (lowercase, trim)"]
C --> D{"adapter.Get(name) exists?"}
D -->|Yes| E["add to selection"]
D -->|No| F["append to 'unknown' → warning"]
B --> G["filter by Supports(target) in runOne"]
E --> G
Inputs: --scanners (CSV string, e.g. trivy,grype,tfsec), target.
Outputs: set of adapters to run; list of unknown names (for warning).
Business rules
- Empty --scanners ⇒ all 12 registered adapters (adapter.All()), sorted by name.
- Names are normalized to lowercase and trimmed; empty entries are discarded.
- A nonexistent name ⇒ goes into unknown and emits warning unknown scanner %q ignored (known: ...) — it does not abort the scan.
- Even when selected, a scanner that does not support the target type is marked skipped (see FR-006).
Dependencies: FR-002 (target type), adapter registry. Consumed by FR-005.
RF-005 — Parallel execution (fan-out) with per-scanner timeout¶
| Field | Content |
|---|---|
| ID | FR-005 |
| Name | Parallel execution (fan-out) with per-scanner timeout |
| Description | The selected scanners run concurrently (one goroutine per scanner), each with a configurable individual timeout. Results are collected and aggregated at the end. |
| Priority | Mandatory |
Flow (orchestrator.Run + runOne): for each selected adapter a goroutine is spawned; a sync.WaitGroup waits for all of them; a sync.Mutex protects the collection of Runs and findings. After wg.Wait(), the runs are sorted by name.
Inputs: selected adapters (FR-004), --timeout (mapped to Options.PerScannerTime, default 5m), context.Background().
Outputs: []ScannerRun (status/duration/error per scanner) + aggregated []model.Finding.
Business rules
- Each scanner runs with context.WithTimeout(ctx, PerScannerTime) when PerScannerTime > 0. Deadline overrun ⇒ status timeout (FR-007).
- PerScannerTime == 0 ⇒ no extra timeout (only the root context's).
- Concurrent collection is mutex-protected; the final order of Runs is deterministic (sorted by name) regardless of finish order.
- runCmd treats a non-zero exit code with stdout output as success (several scanners return non-zero when they find issues); non-zero exit without stdout becomes an error with stderr attached. The buffered output is bounded by a cap (FR-026).
Dependencies: FR-004, FR-006. Feeds FR-008.
RF-006 — Scanner version probe and availability¶
| Field | Content |
|---|---|
| ID | FR-006 |
| Name | Scanner version probe and availability |
| Description | Before running each scanner, the orchestrator runs a version probe with a dedicated timeout (60s) to detect a missing binary, slowness/resource starvation, and OOM kill, classifying the result into distinct statuses. |
| Priority | Mandatory |
Flow (runOne): Supports(target) → if not supported, skipped; otherwise Version(verCtx) with context.WithTimeout(ctx, ProbeTime). The error result is classified.
Inputs: adapter, target, Options.ProbeTime (default defaultProbeTime = 60s).
Outputs: scanner version (on success) or ScannerRun.Status = "unavailable" with a specific diagnostic message.
Business rules — probe classification
| Condition | Status | Diagnostic |
|---|---|---|
!Supports(target) |
skipped |
"does not support target" |
verCtx.Err() == DeadlineExceeded |
unavailable |
"version probe exceeded 60s — tool too slow / resource-starved" |
error contains signal: killed |
unavailable |
"version probe killed — likely OOM; raise memory limit" |
| other error (missing binary) | unavailable |
raw error ("not installed/available") |
- The probe is generous (60s) by design: heavy tools (e.g. checkov/Python) have a slow cold start, especially when all 12 scanners spin up in parallel on a low-memory runner. A tight budget would mark a working tool as
unavailable. ProbeTime <= 0⇒ usesdefaultProbeTime(60s).- An
unavailable/skippedscanner does not contribute findings, but it appears in the report (FR-007).
Dependencies: FR-002, FR-004. Consumed by FR-005, FR-007.
RF-007 — Per-scanner status (execution transparency)¶
| Field | Content |
|---|---|
| ID | FR-007 |
| Name | Per-scanner status (execution transparency) |
| Description | Every scanner execution is recorded with name, version, status, finding count, duration, and error. This status is shown in the summary and embedded in the report, so that "0 findings" is never mistaken for "the scan did not run". |
| Priority | Mandatory |
Possible states (ScannerRun.Status):
| Status | Meaning |
|---|---|
ran |
ran successfully; Findings reflects the count |
skipped |
does not support the target type |
unavailable |
missing binary, slow (probe timeout), or killed (OOM) |
error |
failed during execution (not a timeout) — includes conftest with no Rego policies (see FR-008) and an output-cap overrun (FR-026) |
timeout |
execution exceeded --timeout |
Inputs: results of each runOne.
Outputs: Result.Runs []ScannerRun; summary on stderr (FR-021); scanners block in SARIF (properties.scanners with name/status/version) and equivalent in JSON/XML; metrics quorum_scanner_up/quorum_scanner_findings/quorum_scanner_duration_seconds (FR-023).
Business rules
- "0 findings is not proof of safety" principle: the summary always prints the note and the per-scanner statuses; SARIF includes scannerSummary in properties.
- Error messages are truncated to 60 chars in the console summary (the full version stays in the JSON/SARIF report).
Dependencies: FR-005, FR-006. Consumed by FR-016, FR-021, FR-023.
RF-008 — Canonical normalization of findings¶
| Field | Content |
|---|---|
| ID | FR-008 |
| Name | Canonical normalization of findings |
| Description | Each adapter invokes its native scanner and translates the output to model.Finding, with type, normalized severity, identity (CVE/PURL/RuleID/Resource/Location), and metadata. No later step operates on raw scanner JSON. |
| Priority | Mandatory |
Canonical types (model.FindingType): VULN, MISCONFIG, SECRET, K8S_POSTURE, IMG_HARDENING.
Severity normalization (internal/severity + local adapter mappers): converges to CRITICAL > HIGH > MEDIUM > LOW > INFO > UNKNOWN.
| Function | Mapping |
|---|---|
FromCVSS |
≥9.0 CRIT · ≥7.0 HIGH · ≥4.0 MED · >0 LOW · 0 UNKNOWN |
FromLabel |
Trivy/Grype/Checkov/KICS/Terrascan enums (e.g. ERROR/DANGER→HIGH, WARNING→MED, NEGLIGIBLE→INFO) |
FromDockle |
FATAL→HIGH · WARN→MED · INFO→LOW · PASS/SKIP/IGNORE→INFO |
ksSeverity (kubescape) |
derived from scoreFactor 0..10: ≥9 CRIT · ≥7 HIGH · ≥4 MED · >0 LOW · else MED |
polarisSeverity |
danger/error→HIGH · warning→MED · else LOW |
kubeScoreSeverity |
grade 1..10 (1 worst): ≤3 HIGH · ≤7 MED · else LOW |
regulaSeverity |
critical/high/medium/low/informational; fallback FromLabel |
Per-adapter coverage matrix (from Supports/Capabilities/Run) — the 12 scanners:
| Scanner | Supported targets (Supports) |
Produced types (Capabilities) |
Family (consensus) |
|---|---|---|---|
| trivy | image, repo, k8s | VULN, MISCONFIG, SECRET | sca |
| grype | image, repo | VULN | sca |
| checkov | repo, k8s | MISCONFIG | iac |
| kics | repo, k8s | MISCONFIG | iac |
| terrascan | repo, k8s | MISCONFIG | iac |
| tfsec | repo, k8s | MISCONFIG | iac |
| regula | repo, k8s | MISCONFIG | iac |
| conftest | repo, k8s | MISCONFIG (policy-as-code) | policy |
| dockle | image | IMG_HARDENING | hardening |
| kubescape | k8s, repo | K8S_POSTURE | k8s |
| polaris | k8s, repo | K8S_POSTURE | k8s |
| kube-score | k8s | K8S_POSTURE | k8s |
Inputs: adapter.Target, context with timeout, QUORUM_<SCANNER>_ARGS (FR-025).
Outputs: []model.Finding with fields populated per type (VULN: VulnID/PURL/CVSS; MISCONFIG: RuleID/CanonicalControl/Resource/Location; etc.). When the advisory layer is enabled, MergedFinding also carries Remediation, References, Advice (types model.Remediation/DocRef/Advice/Fix) — see FR-028…FR-031; these are presentation-only and never affect identity.
Business rules
- Adapters never compute CorrelationKey/Fingerprint — that is centralized in the correlator (FR-014).
- Trivy emits AVD ids directly; normalizeAVD reconstructs the AVD- prefix when newer versions omit it (e.g. AWS-0086 → AVD-AWS-0086).
- tfsec extracts the AVD id from its links (avdInLink) and fills CanonicalControl — so it auto-correlates with Trivy without depending on the crosswalk.
- dockle uses its own CIS-DI code as CanonicalControl (already canonical) and only emits actionable gaps (discards PASS/SKIP/IGNORE/INFO).
- conftest has no built-in rules: it evaluates your own Rego (default ./policy, or QUORUM_CONFTEST_ARGS="--policy <dir>"). Without policies it errors and the scanner is reported as error — expected behavior, policy-as-code is opt-in. RuleID is fixed as policy (Rego has no stable id), keeping each finding isolated; failures→HIGH, warnings→MED.
- regula/polaris/kubescape/kube-score emit only failure results (FAIL/success:false/status failed/grade < 10).
- Confirmed is set when the finding has an authoritative source (e.g. Trivy with DataSource, Grype with a CVE in relatedVulnerabilities), influencing consensus (FR-015).
- Trivy secrets are redacted before storage: only a snippet of the Match survives, with long tokens masked (redactSecretText) — the raw secret value is never persisted.
- Each adapter has a contract test against fixtures in internal/adapter/testdata.
Dependencies: FR-002, FR-005, FR-025. Feeds FR-009, FR-012, FR-014.
RF-009 — Vulnerability alias resolution¶
| Field | Content |
|---|---|
| ID | FR-009 |
| Name | Vulnerability alias resolution |
| Description | For VULN-type findings, the identifier is resolved to a canonical form (CVE preferred) using a layered chain: scanner-local aliases → local cache → OSV.dev. It ensures that GHSA-xxxx (Grype) and CVE-yyyy (Trivy) for the same bug correlate instead of splitting. |
| Priority | Mandatory |
Chain flow (chainResolver.Canonical)
flowchart TD
A["id + scanner aliases"] --> B{"CVE already present?"}
B -->|Yes| Z["return CVE (upper)"]
B -->|No| C{"local cache has id?"}
C -->|Yes| Y["return cached value"]
C -->|No| D{"OSV enabled (online)?"}
D -->|Yes| E["OSV.Aliases(id) → preferCVE"]
D -->|No| F["preferCVE(local aliases)"]
E --> G["write to cache + return"]
F --> G
Inputs: VulnID, Aliases, context; OSV client (when online), *cache.Store.
Outputs: canonical VulnID (CVE > GHSA > first non-empty).
Business rules
- Preference: CVE > GHSA > first non-empty id (preferCVE).
- The chain never returns an error — on any failure (network/HTTP), it degrades to the best available id (DESIGN §7).
- OSV (api.osv.dev/v1/vulns/{id}) uses a client with an 8s timeout, MaxRetries=2 (exponential backoff, 200ms base); only 429/5xx/network errors are retryable. The id is validated and passed through url.PathEscape before composing the URL.
- Resolved results are written to the cache (FR-011) for idempotency across CI re-scans.
- Only VULN goes through aliasing; other types use the crosswalk (FR-012).
Dependencies: FR-008, FR-010 (offline), FR-011 (cache). Feeds FR-014.
RF-010 — Offline mode (OSV disabled)¶
| Field | Content |
|---|---|
| ID | FR-010 |
| Name | Offline mode (OSV disabled) |
| Description | The --offline flag disables all network queries to OSV.dev; alias resolution then uses only scanner-local aliases and the cache. |
| Priority | Mandatory |
Flow: in runScan, if !f.offline { osv = alias.NewOSVClient() }; passing nil to alias.New skips Layer 3 (OSV).
Inputs: --offline (bool, default false).
Outputs: resolver that operates with Layers 1 and 2 only.
Business rules
- --offline ⇒ no HTTP call is made; aliases depend only on what the scanner reported + existing cache.
- The offline state is logged: ... offline=%v.
- Recommended in air-gapped environments and for reproducible builds. Note: --offline also blocks --advice-provider=remote (FR-030).
Dependencies: FR-009.
RF-011 — Alias cache¶
| Field | Content |
|---|---|
| ID | FR-011 |
| Name | Alias cache |
| Description | A persistent key/value store in a JSON file speeds up and gives idempotency to alias resolution across re-scans, without a database dependency. |
| Priority | Recommended |
Inputs: --cache (path; default os.UserCacheDir()/quorum/aliases.json, fallback .quorum-cache.json).
Outputs: JSON file {schemaVersion, id: canonical} updated on each Put, written with permission 0600.
Business rules
- A missing/unreadable file ⇒ empty cache, never an error (an optimization, not a source of failure).
- The cache carries a schemaVersion: entries from an incompatible schema are discarded (the cache restarts clean) instead of mixing formats.
- Atomic write via *.tmp + os.Rename, with permission 0600 (the file may contain vulnerability ids); flush failures are silently ignored.
- Safe for concurrent intra-process use (sync.RWMutex).
- No TTL/expiration: entries persist (assumption — see Assumptions).
Dependencies: FR-009.
RF-012 — Crosswalk rule → canonical control¶
| Field | Content |
|---|---|
| ID | FR-012 |
| Name | Crosswalk rule → canonical control |
| Description | For MISCONFIG/K8S_POSTURE/IMG_HARDENING findings, each scanner's native rule id is mapped to a shared canonical control (AVD, with a semantic-category fallback), via YAML files, so that equivalent misconfigs from different engines correlate. |
| Priority | Mandatory |
Flow (crosswalk.Load + Correlator.resolveControl): loads every *.yaml/*.yml in a directory, indexes "scanner|ruleID" → Resolution{Control, Category, CWE, Title}. During enrich, if the finding already has CanonicalControl (e.g. Trivy/tfsec AVD, dockle CIS-DI), it keeps it; otherwise it resolves by RuleID.
Bundled crosswalk (./crosswalk, derived from real scanner output, under the false split > false merge principle):
| File | Canonical hub | Coverage |
|---|---|---|
crosswalk/aws.yaml |
AVD (AWS) | S3, IAM, EBS, Security Groups, RDS, KMS, CloudTrail, VPC flow logs |
crosswalk/azure.yaml |
AVD (Azure) | Storage, Key Vault |
crosswalk/gcp.yaml |
AVD (GCP) | bucket, firewall, SQL |
crosswalk/k8s.yaml |
C-#### (kubescape) | 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 |
- The AWS/Azure/GCP hubs correlate checkov × kics × terrascan × tfsec × regula × trivy; the k8s hub correlates kubescape × polaris × kube-score.
tfsec/trivyalready emit native AVD and therefore auto-correlate without a crosswalk entry. - RBAC stays single-engine: kubescape's RBAC analysis requires cluster context, so it is not correlated with other engines (documented, not a crosswalk gap).
Inputs: --crosswalk (directory; default ./crosswalk), Scanner, RuleID.
Outputs: CanonicalControl (+ Category/Title) filled, or Unmapped=true.
Business rules
- A missing directory is not an error (os.IsNotExist) — the tool runs without a custom crosswalk, and findings stay Unmapped.
- "Never guess a match" (DESIGN §6): without a mapping, the finding is isolated and marked Unmapped=true; it is never merged by guessing.
- Mapping is case-insensitive on the scanner name; the key is lower(scanner)|trim(ruleID).
- An already-canonical finding (CanonicalControl != "") is not re-resolved.
Dependencies: FR-008, FR-013 (directory fallback). Feeds FR-014.
RF-013 — Automatic bundled crosswalk fallback¶
| Field | Content |
|---|---|
| ID | FR-013 |
| Name | Automatic bundled crosswalk fallback |
| Description | When --crosswalk is not passed explicitly and the default ./crosswalk does not exist, the system uses the crosswalk bundled in the Docker image at /opt/quorum/crosswalk, avoiding silently loading 0 rules in docker run. |
| Priority | Recommended |
Flow (resolveCrosswalkDir)
| Condition | Directory used |
|---|---|
--crosswalk passed explicitly (Changed) |
the literal value provided |
default ./crosswalk exists |
./crosswalk |
default absent and /opt/quorum/crosswalk exists |
/opt/quorum/crosswalk (bundled) |
| none exists | returns the default (loads 0 rules) |
Inputs: --crosswalk, cobra's Changed flag, directory existence (os.Stat).
Outputs: effective crosswalk directory (logged: crosswalk=%d rules (%s)).
Business rules
- The fallback happens only when the user has not changed --crosswalk (respects an explicit choice verbatim).
- Solves the problem of docker run … scan . from an arbitrary workdir where ./crosswalk does not exist.
Dependencies: FR-012.
RF-014 — Correlation by correlationKey and fingerprint¶
| Field | Content |
|---|---|
| ID | FR-014 |
| Name | Correlation by correlationKey and fingerprint |
| Description | Each finding receives a deterministic type-specific correlationKey and a Fingerprint = sha256(correlationKey). The key is the identity used to group equivalent findings in consensus and for suppression/portability in SARIF. |
| Priority | Mandatory |
Per-type key strategies (BuildKey):
| Type | correlationKey structure |
|---|---|
VULN |
VULN\|UPPER(VulnID)\|nameVersion(PURL) |
MISCONFIG |
MISCONFIG\|fileBasename\|resourceType\|controlKey |
K8S_POSTURE |
K8S\|ns/kind/name\|container\|controlKey |
IMG_HARDENING |
IMGH\|controlKey |
SECRET |
SECRET\|normPath\|startLine\|lower(RuleID) |
| others | OTHER\|scanner\|title |
Inputs: enriched model.Finding (after alias/crosswalk).
Outputs: f.CorrelationKey, f.Fingerprint.
Business rules
- controlKey prefers CanonicalControl; when Unmapped, it uses UNMAPPED:scanner:RuleID to never merge an unmapped finding with a different one (false split > false merge).
- MISCONFIG keys by file basename + resource type + control (engines report different paths/relativity). Documented trade-off: two distinct resources of the same type, same control, and same file may over-merge — acceptable vs. never correlating.
- The key is a pure and deterministic function of the normalized data.
- The Fingerprint is exposed in SARIF as partialFingerprints["quorum/v1"] (FR-016) and accepted by the baseline (FR-017).
- Without a correlator, the orchestrator still applies BuildKey/Fingerprint to enable grouping.
Dependencies: FR-009, FR-012. Feeds FR-015, FR-016, FR-017.
RF-015 — Consensus scoring and aggregation¶
| Field | Content |
|---|---|
| ID | FR-015 |
| Name | Consensus scoring and aggregation |
| Description | Findings with the same correlationKey are grouped into a MergedFinding, with aggregated severity (maximum), the list of scanners that detected it, detectionCount, and a confidence (0..1) that weighs count, engine diversity, severity, and authoritative confirmation. |
| Priority | Mandatory |
Confidence formula (consensus.confidence, DESIGN §9):
| Factor | Calculation |
|---|---|
count (0.35) |
ln(1+nScanners)/ln(5) — diminishing returns |
diversity (0.25) |
distinct engine families: 1→0.33, 2→0.66, 3+→1.0 |
severity (0.25) |
CRIT 1.0 · HIGH 0.8 · MED 0.5 · LOW 0.3 · INFO 0.1 |
authoritative (0.15) |
1.0 if any member is Confirmed or a CVE with CVSS>0; else 0 |
Engine families (scannerCategory): sca = trivy/grype · iac = checkov/kics/terrascan/tfsec/regula · policy = conftest · k8s = kubescape/polaris/kube-score · hardening = dockle. A scanner outside the map gets family other:<name> (isolated).
Inputs: []model.Finding with CorrelationKey.
Outputs: []model.MergedFinding (Title, max Severity, DetectedBy, DetectionCount, Confidence, Unmapped, Members, Fingerprint), sorted by (severity ↓, confidence ↓, detectionCount ↓, key ↑). The advisory layer may later attach Remediation/References/Advice to a MergedFinding (FR-028…FR-031) without changing any of these consensus fields.
Business rules
- DetectionCount = number of distinct scanners (not raw findings).
- Engine diversity weighs more than repetition of the same family: 2 engines from different families are worth more than 2 from the same group (e.g. checkov + terrascan, both iac, count as 1 family).
- Unmapped propagates if any member is unmapped.
- Aggregated severity is the maximum among members (severity.Max).
- Stable and deterministic ordering for reproducible reports.
Dependencies: FR-014. Feeds FR-016, FR-017, FR-018, FR-019, FR-023.
RF-016 — SARIF/JSON/XML report emission¶
| Field | Content |
|---|---|
| ID | FR-016 |
| Name | SARIF/JSON/XML report emission |
| Description | The consolidated result is serialized into one of three formats: SARIF 2.1.0 (primary), JSON, or XML. SARIF carries portable fingerprints, consensus properties, and the scanner summary. |
| Priority | Mandatory |
Flow (report.ParseFormat + report.Write): validates --format and dispatches to writeSARIF/writeJSON/writeXML.
Inputs: --format/-f (sarif|json|xml, default sarif), *orchestrator.Result.
Outputs — SARIF (primary):
| SARIF element | Source |
|---|---|
runs[].tool.driver |
name quorum, version (build-time), rules per finding |
results[].ruleId |
CVE (VULN) / CanonicalControl / RuleID / correlationKey |
results[].level |
CRIT/HIGH→error, MED→warning, others→note |
results[].partialFingerprints["quorum/v1"] |
Fingerprint (sha256) |
results[].properties |
detectedBy, detectionCount, confidence, severity, correlationKey, unmapped |
runs[].properties.scanners |
name/status/version summary (FR-007) |
Business rules
- Invalid --format ⇒ unknown format error (exit 2), before any scanner runs.
- SARIF uses schema 2.1.0; SetEscapeHTML(false) to preserve characters in URIs.
- SARIF rules are deduplicated by ruleId and sorted.
- JSON includes the raw canonical detail (Result.Findings); XML is the alternative structured form.
- When --advice is on, the report additionally renders the advisory attachments (remediation, OWASP references, AI recommendation), each labeled "AI-generated, advisory only" where applicable (FR-028…FR-031).
Dependencies: FR-015, FR-007. Consumed by FR-020.
RF-017 — Suppression baseline (.quorumignore)¶
| Field | Content |
|---|---|
| ID | FR-017 |
| Name | Suppression baseline (.quorumignore) |
| Description | A baseline file lists known/accepted findings to suppress, by Fingerprint or correlationKey (one per line). Suppressed findings are removed from the report and gating, but suppression is always logged. |
| Priority | Mandatory |
Flow (filter.LoadBaseline + Baseline.Has + filter.Apply): loads ids (lowercase), ignores blank lines and # comments (including trailing # note); a MergedFinding matches if its fingerprint OR correlationKey is in the set.
Inputs: --baseline (path; default .quorumignore), MergedFinding.
Outputs: filtered list (Result.Kept) + SuppressedBaseline counter.
Business rules
- Missing file: if --baseline was not changed, empty baseline (silent); if it was changed explicitly and does not exist ⇒ error baseline file not found (exit 2).
- Matches by Fingerprint or correlationKey — the user can copy either from the report.
- Case-insensitive comparison.
- Suppressions are logged (filtered: %d suppressed by baseline ...) — "a suppressed finding is still a finding" (DESIGN §14).
Dependencies: FR-014, FR-015.
RF-018 — Minimum severity filter (--min-severity)¶
| Field | Content |
|---|---|
| ID | FR-018 |
| Name | Minimum severity filter (--min-severity) |
| Description | Findings below the provided minimum severity are removed from the report and gating, reducing noise in CI. |
| Priority | Mandatory |
Flow (severity.Parse + filter.Apply): findings with severity.AtLeast(m.Severity, minSeverity) == false are dropped (when minSeverity != UNKNOWN).
Inputs: --min-severity (critical|high|medium|low).
Outputs: filtered list + SuppressedSeverity counter.
Business rules
- Invalid value ⇒ invalid --min-severity error (exit 2).
- Absent ⇒ SevUnknown ⇒ filter disabled (keeps everything).
- Applied after consensus and before gating (FR-019), so it affects the exit code.
- The drop count is logged together with baseline suppression.
Dependencies: FR-015, internal/severity.
RF-019 — Failure gate (--fail-on) and exit codes¶
| Field | Content |
|---|---|
| ID | FR-019 |
| Name | Failure gate (--fail-on) and exit codes |
| Description | When --fail-on is provided, the process exits with code 1 if any finding (after filters) reaches or exceeds the threshold severity. The exit codes are the integration contract with CI/CD. |
| Priority | Mandatory |
Exit code contract
| Exit | Meaning |
|---|---|
0 |
OK — success, or no finding reached --fail-on |
1 |
Gate fired — some finding ≥ --fail-on |
2 |
Usage/runtime error (invalid flag, explicit baseline missing, invalid target, size cap exceeded, orchestration error) |
Flow: after emitting the report, summary, and metrics, it computes worstSeverity(res); if severity.AtLeast(worst, failThreshold) ⇒ os.Exit(1).
Inputs: --fail-on (critical|high|medium|low).
Outputs: exit code; log gate: found %s finding >= --fail-on %s → exit 1.
Business rules
- Invalid value ⇒ invalid --fail-on error (exit 2).
- The gate considers only findings that survived the baseline (FR-017) and min-severity (FR-018).
- Without --fail-on, the scan never returns 1 because of findings (exit 0), only 2 on error.
- The advisory layer never affects the gate: --advice/--fix are presentation-only and cannot change worstSeverity or the exit code (FR-028…FR-031).
- Exit 2 is produced by main.go when Execute() returns an error.
Dependencies: FR-015, FR-017, FR-018.
RF-020 — Output to file or stdout¶
| Field | Content |
|---|---|
| ID | FR-020 |
| Name | Output to file or stdout |
| Description | The report is written to stdout by default or to a file via --output/-o; the path is normalized and intermediate directories are created automatically. |
| Priority | Mandatory |
Flow (emit): renders to a buffer; if --output is empty ⇒ cmd.OutOrStdout(); otherwise filepath.Clean(output) + os.MkdirAll(dir, 0o755) + os.WriteFile with permission 0600.
Inputs: --output/-o (path; default stdout).
Outputs: report at the chosen destination.
Business rules
- The path is normalized with filepath.Clean (collapses ./ and ../ segments).
- The file's directory is created with os.MkdirAll(..., 0o755) if it does not exist.
- The report is written with permission 0600 (owner-only): it may carry sensitive finding detail, so it should not be world-readable by default.
- stdout allows piping; the summary and logs go to stderr (FR-021), never polluting the report output.
Dependencies: FR-016.
RF-021 — Console summary and progress logs¶
| Field | Content |
|---|---|
| ID | FR-021 |
| Name | Console summary and progress logs |
| Description | During and after the scan, Quorum prints progress logs and a final summary on stderr with per-scanner status, severity counts, multi-detected findings, elapsed time, and the safety note. --quiet/-q suppresses this output. |
| Priority | Recommended |
Inputs: --quiet/-q (bool), --log-format (FR-024), *orchestrator.Result.
Outputs (stderr): progress lines (text format [quorum] ... or json, per FR-024) and a ── quorum summary ── block with:
- status/version/findings per scanner;
- total after consensus and how many are multi-detected (DetectionCount > 1);
- CRIT/HIGH/MED/LOW/INFO counts;
- elapsed time;
- note: "0 findings is not proof of safety — see scanner statuses above."
Business rules
- --quiet suppresses both the progress logs and the summary (but not the FR-020 report nor the exit code).
- All logs go to stderr, keeping stdout clean for the report.
- The ── quorum summary ── block is always tabular text (does not change with --log-format); only the logf progress lines honor text|json.
Dependencies: FR-007, FR-015, FR-024.
RF-022 — Tool version¶
| Field | Content |
|---|---|
| ID | FR-022 |
| Name | Tool version |
| Description | Quorum reports its version (cobra's --version flag), overridden at build time via -ldflags "-X main.version=...", also stamped in the SARIF driver. |
| Priority | Recommended |
Inputs: --version (cobra), version variable (build-time).
Outputs: version string on stdout; Version propagated to report.Version (SARIF driver and quorum/v1 namespace).
Business rules
- The in-code default is 0.1.0; releases override it via GoReleaser/ldflags (the current release is v0.8.3).
Dependencies: none.
RF-023 — Prometheus metrics export (--metrics)¶
| Field | Content |
|---|---|
| ID | FR-023 |
| Name | Prometheus metrics export (--metrics) |
| Description | When --metrics <file> is provided, Quorum writes metrics in Prometheus textfile format (for the Node Exporter textfile collector or push gateway), enabling observability of scan runs in CI. |
| Priority | Recommended |
Flow (writeMetricsFile → report.WriteMetrics): after emitting the report and summary, if --metrics is not empty, the path is filepath.Cleaned, the directory is created (0o755), and the file is written with permission 0644 (metrics are non-sensitive counts, meant for scraping). A write failure ⇒ writing metrics error (exit 2).
Inputs: --metrics (path; default empty = disabled), *orchestrator.Result, optional advisory metrics.
Outputs (Prometheus text file):
| Metric | Type | Description |
|---|---|---|
quorum_scan_duration_seconds |
gauge | total (wall-clock) scan time |
quorum_scanner_up{scanner,status} |
gauge | 1 if status == ran, else 0 (skipped/unavailable/error/timeout) |
quorum_scanner_findings{scanner} |
gauge | raw findings per scanner (pre-consensus) |
quorum_scanner_duration_seconds{scanner} |
gauge | per-scanner duration |
quorum_findings_after_consensus |
gauge | findings remaining after the consensus merge |
quorum_advice_enriched{kind} |
gauge | advisory attachments, kind=remediation\|references\|recommendation (only under --advice) |
quorum_advice_provider{provider} |
gauge | 1 for the active AI provider (only when --advice-provider is local/remote) |
quorum_advice_fix{stage} |
gauge | suggested fixes by verify-the-fix stage, stage=proposed\|verified (only under --fix=suggest) |
Business rules
- Empty --metrics ⇒ no file is written (opt-in feature).
- Metrics are written after the report and summary, but before the gate (FR-019), so they exist even when the process ends with exit 1.
- Permission 0644 (non-sensitive), in contrast to the 0600 report (FR-020).
- The quorum_advice_* series are emitted only when --advice is enabled; without it the metrics file is byte-identical to a non-advisory run.
Dependencies: FR-007, FR-015, FR-028…FR-031. Component: internal/report/metrics.go.
RF-024 — Progress log format (--log-format)¶
| Field | Content |
|---|---|
| ID | FR-024 |
| Name | Progress log format (--log-format) |
| Description | The progress lines on stderr can be emitted in human-readable text ([quorum] ...) or structured JSON ({ts, level, msg}), for ingestion by log collectors in CI pipelines. |
| Priority | Recommended |
Flow (the logf closure in runScan): if --quiet, emits nothing; if --log-format json, serializes {ts (RFC3339 UTC), level:"info", msg} and prints one JSON line; otherwise prints [quorum] <msg>.
Inputs: --log-format (string; default text; accepts text|json).
Outputs: progress lines on stderr in the chosen format.
Business rules
- Invalid value (≠ text/json) ⇒ invalid --log-format error (exit 2), validated right after resolving the target type.
- Applies only to the logf progress lines; the ── quorum summary ── block (FR-021) stays tabular.
- --quiet takes precedence: no log is emitted, regardless of --log-format.
Dependencies: FR-021.
RF-025 — Per-scanner argument passthrough (QUORUM_<SCANNER>_ARGS)¶
| Field | Content |
|---|---|
| ID | FR-025 |
| Name | Per-scanner argument passthrough |
| Description | The operator can inject extra CLI arguments into a specific scanner via the QUORUM_<NAME>_ARGS environment variable, without modifying the adapter — to broaden coverage (extra frameworks/checks) or pass platform credentials. |
| Priority | Recommended |
Flow (extraArgs + splitArgs in adapter.go): each adapter, when building its command line, does args = append(args, extraArgs("<name>")...) before target.Ref. extraArgs reads QUORUM_<UPPER(NAME)>_ARGS, and splitArgs splits the value shell-style (on spaces, honoring single and double quotes; no escaping or variable expansion).
Inputs: environment variables QUORUM_TRIVY_ARGS, QUORUM_CHECKOV_ARGS, QUORUM_CONFTEST_ARGS, etc. (one per registered scanner).
Outputs: extra arguments concatenated to the native scanner invocation.
Business rules
- The value is operator-controlled (whoever runs the container/CLI) — the same trust level as the flags themselves.
- Usage examples: QUORUM_CHECKOV_ARGS="--bc-api-key <key>" unlocks Prisma Cloud/Bridgecrew policies; QUORUM_CONFTEST_ARGS="--policy <dir>" points conftest at its Rego; QUORUM_TRIVY_ARGS broadens frameworks/scanners.
- Splitting honors quotes: --policy "/path with space" becomes a single argument.
- Absence/empty value ⇒ no extra arguments (nil).
Dependencies: FR-008. Component: internal/adapter/adapter.go (extraArgs, splitArgs).
RF-026 — DoS caps: output and target size¶
| Field | Content |
|---|---|
| ID | FR-026 |
| Name | DoS caps: output and target size |
| Description | Two caps protect the process against memory/resource exhaustion: the volume of buffered stdout per scanner and the on-disk size of a filesystem target. Both have high defaults and are adjustable/disableable via environment variable. |
| Priority | Mandatory |
Scanner output cap (capWriter + maxOutputBytes in adapter.go)
| Aspect | Value |
|---|---|
| Default | 512 MiB (defaultMaxOutputBytes) |
| Override | QUORUM_MAX_OUTPUT_BYTES (bytes; must be > 0) |
| Behavior | capWriter buffers up to the limit and discards the rest without blocking the child process's pipe; at the end, if there was an overrun, runCmd returns an error output exceeded N bytes — aborting to avoid OOM and the scanner becomes error (FR-007) |
Target size cap (checkTargetSize + defaultMaxTargetBytes in scan.go)
| Aspect | Value |
|---|---|
| Default | 20 GiB (defaultMaxTargetBytes) |
| Override | QUORUM_MAX_TARGET_BYTES (bytes; 0 disables) |
| Scope | only repo/k8s (local) targets; image is skipped (no local tree) |
| Behavior | filepath.WalkDir sums the size of regular files and stops as soon as the cap is crossed (normal repos pay only a light stat pass); an overrun ⇒ error target %q exceeds the N-byte size cap (exit 2) |
Inputs: QUORUM_MAX_OUTPUT_BYTES, QUORUM_MAX_TARGET_BYTES, target.
Outputs: clean abort (error) when a cap is exceeded.
Business rules
- Invalid QUORUM_MAX_TARGET_BYTES value (non-integer) ⇒ invalid QUORUM_MAX_TARGET_BYTES error.
- QUORUM_MAX_TARGET_BYTES <= 0 ⇒ cap disabled.
- Invalid/≤0 QUORUM_MAX_OUTPUT_BYTES value ⇒ ignored (keeps the 512 MiB default).
- Unreadable entries during the walk are skipped (they do not abort the scan).
- The target cap runs before fan-out; the output cap runs during each runCmd.
Dependencies: FR-002 (target type), FR-005 (execution). Components: internal/adapter/adapter.go, cmd/quorum/scan.go.
RF-027 — Target validation (argument injection)¶
| Field | Content |
|---|---|
| ID | FR-027 |
| Name | Target validation (argument injection) |
| Description | The positional target is validated before being passed to any scanner: a value starting with - is rejected, because a downstream scanner could interpret it as a CLI flag (argument injection). |
| Priority | Mandatory |
Flow (validateTargetRef in scan.go, called at the start of runScan): if strings.HasPrefix(ref, "-"), returns an error; otherwise proceeds.
Inputs: <target> (positional argument).
Outputs: error invalid target %q: must not start with '-' (exit 2), suggesting ./-name for a literal path; or proceeds.
Business rules
- No real image reference nor legitimate path starts with -; such a path must be passed as ./-name.
- The validation is the first step of runScan, before type inference, caps, and fan-out.
Dependencies: FR-001. Precedes FR-002.
RF-028 — Advisory enrichment (--advice, knowledge pack)¶
| Field | Content |
|---|---|
| ID | FR-028 |
| Name | Advisory enrichment (--advice, knowledge pack) — Phase 0 |
| Description | With --advice, Quorum attaches deterministic remediation templates and OWASP references (Phase 0, no model) to merged findings, matched by canonicalControl/ruleId/category/type from a curated knowledge pack. It is presentation-only and never touches identity, confidence, aggregated severity, or the gate. |
| Priority | Recommended |
Flow (internal/enrich + runScan): loads the knowledge pack from --knowledge (default ./knowledge, with a bundled fallback analogous to the crosswalk so docker run … scan . --advice from any workdir still finds the templates), then for each MergedFinding looks up a matching template and fills Remediation/References. Logged: advice: knowledge=%d entries (%s) → %d findings enriched.
Knowledge pack (knowledge/*.yaml): curated remediation templates + OWASP references organized by domain (aws, azure, gcp, k8s, image, categories).
Inputs: --advice (bool, default false), --knowledge (directory; default ./knowledge), the []MergedFinding from consensus.
Outputs: MergedFinding.Remediation (model.Remediation) and MergedFinding.References ([]model.DocRef) populated for matched findings; rendered into the report (FR-016) and counted in quorum_advice_enriched{kind=remediation|references} (FR-023).
Business rules
- Presentation-only: never modifies correlationKey, fingerprint, confidence, aggregated severity, or the --fail-on gate. Without --advice the output is byte-identical.
- Fully deterministic — no model involved in Phase 0; matching is a pure lookup by canonical control/rule/category/type.
- Graceful degradation: a missing/unreadable knowledge pack yields no attachments and never fails the scan.
Dependencies: FR-015. Feeds FR-016, FR-023. Component: internal/enrich/enrich.go.
RF-029 — OWASP RAG references and advise-index¶
| Field | Content |
|---|---|
| ID | FR-029 |
| Name | OWASP RAG references and advise-index — Phase 2 |
| Description | With --advice, Quorum also retrieves OWASP references from a versioned, digest-pinned corpus (RAG-as-artifact, deterministic). Retrieval is lexical by default (no model); it becomes semantic (embeddings) once the corpus is embedded via the advise-index subcommand. scan auto-selects semantic retrieval when the corpus ships vectors. |
| Priority | Recommended |
Flow (internal/rag + runScan): loads knowledge/owasp/corpus.yaml; if it HasEmbeddings() and --advice-provider=local, uses a semantic embedder (--advice-embed-model) against the local endpoint; otherwise falls back to lexical retrieval. Logged: advice(rag): corpus=%d chunks (…) → %d findings gained OWASP references.
advise-index subcommand (cmd/quorum/advise_index.go): reads the OWASP corpus, embeds every chunk via a local OpenAI-compatible embeddings endpoint, and writes it back with per-chunk vectors. Embeddings are excluded from the content digest, so the pin is preserved. Once embeddings are present, scan --advice --advice-provider local uses semantic retrieval automatically.
| Flag | Default | Meaning |
|---|---|---|
--corpus |
knowledge/owasp/corpus.yaml |
corpus file to embed |
--out |
(empty) | output file (default: overwrite --corpus in place) |
--advice-endpoint |
http://localhost:11434/v1 |
OpenAI-compatible embeddings base URL |
--advice-embed-model |
nomic-embed-text |
embedding model id |
Inputs: --advice, --advice-embed-model, the OWASP corpus; for advise-index, --corpus/--out/--advice-endpoint/--advice-embed-model.
Outputs: OWASP References attached to matched findings; advise-index writes an embedded, still-pinned corpus.
Business rules
- Deterministic and presentation-only, like FR-028: it never affects identity/confidence/gating.
- The corpus is digest-pinned; advise-index preserves the pin because vectors are excluded from the digest.
- Default lexical retrieval requires no model; semantic retrieval is used only when the corpus already carries embeddings.
- Supply chain: the knowledge pack + crosswalk receive a SLSA build-provenance attestation each release (release.yml knowledge job); verify with gh attestation verify knowledge/owasp/corpus.yaml.
Dependencies: FR-028. Feeds FR-016, FR-023. Component: internal/rag/*.go, cmd/quorum/advise_index.go.
RF-030 — AI recommendations (--advice-provider)¶
| Field | Content |
|---|---|
| ID | FR-030 |
| Name | AI recommendations (--advice-provider) — Phases 1 & 3 |
| Description | Optionally, --advice-provider=local queries an on-host OpenAI-compatible endpoint (e.g. Ollama) for a natural-language recommendation per finding; --advice-provider=remote calls an external API (Phase 3), which is egress-gated. Every AI attachment is labeled "AI-generated, advisory only" and never affects the gate. |
| Priority | Optional |
Flow (internal/advisor + runScan): when --advice and provider ∈ {local, remote}, builds a client (NewLocalClient/NewRemoteClient) and requests a recommendation for each merged finding (capped by --advice-max). Results fill MergedFinding.Advice. Reproducible via temperature=0 + an on-disk cache (--advice-cache) keyed by fingerprint+provider+model.
| Flag | Default | Meaning |
|---|---|---|
--advice-provider |
none |
none\|local\|remote |
--advice-endpoint |
http://localhost:11434/v1 |
OpenAI-compatible base URL |
--advice-model |
qwen2.5-coder:7b |
model id for local |
--advice-embed-model |
nomic-embed-text |
embedding model for semantic OWASP retrieval |
--advice-cache |
(user cache dir) | cache file for AI advice |
--advice-max |
50 |
max findings sent to the provider per run (0 = no cap) |
--advice-allow-egress |
false |
consent to send findings off-host: required for remote |
Inputs: --advice, --advice-provider, --advice-endpoint, --advice-model, --advice-cache, --advice-max, --advice-allow-egress, QUORUM_ADVICE_API_KEY (remote).
Outputs: MergedFinding.Advice (model.Advice) populated; quorum_advice_provider{provider} metric (FR-023).
Business rules
- Off by default and presentation-only: never touches correlationKey/fingerprint/confidence/aggregated severity/the gate.
- Graceful degradation: if the model is unreachable, the report ships without AI advice and the scan never fails.
- Remote provider (Phase 3) sends only the normalized finding (titles, paths, controls) — never source code. It is:
- BLOCKED by --offline (a remote model would send findings off-host);
- gated on explicit consent via --advice-allow-egress;
- requires QUORUM_ADVICE_API_KEY;
- refuses --fix (that would upload source — use local).
- Reproducibility: temperature=0 + fingerprint+provider+model cache key make re-runs deterministic.
Dependencies: FR-028, FR-029; FR-010 (offline blocks remote). Component: internal/advisor/*.go.
RF-031 — Suggested fix (--fix) with verify-the-fix¶
| Field | Content |
|---|---|
| ID | FR-031 |
| Name | Suggested fix (--fix) with verify-the-fix |
| Description | With --advice-provider=local and --fix=suggest, Quorum proposes a patch for a finding that must pass a verify-the-fix re-scan: the patch is applied to a temporary copy, re-scanned with the same scanner, and kept only if the finding is gone and the file still parses. It never auto-applies. |
| Priority | Optional |
Flow (internal/advisor verify path): for each candidate finding the local model proposes a patch; the patch is applied to a temp copy, the same scanner is re-run, and the suggestion is retained only if the target finding disappears and the file parses. Logged as fixes: %d verified / %d proposed.
Inputs: --fix (off|suggest, default off), a local provider (FR-030).
Outputs: verified patch attached to the finding (model.Fix); metrics quorum_advice_fix{stage=proposed|verified} where verified/proposed is the verify-the-fix rate (FR-023).
Business rules
- Never auto-applies: the patch is a suggestion attached to the report; the operator decides.
- Verify-the-fix gate: a proposed patch is only surfaced as verified if the re-scan confirms the finding is gone and the file still parses.
- --fix requires local: it is refused with --advice-provider=remote (would upload file source).
- --fix=off (default) ⇒ no patches proposed.
Dependencies: FR-030. Feeds FR-023. Component: internal/advisor/verify.go.
Traceability: FR → code component¶
| FR | Key files |
|---|---|
| FR-001, FR-020 | cmd/quorum/scan.go (runScan, emit) |
| FR-002 | cmd/quorum/scan.go (resolveTargetType) |
| FR-003, FR-022 | cmd/quorum/root.go, cmd/quorum/main.go |
| FR-004, FR-005, FR-006, FR-007 | internal/orchestrator/orchestrator.go |
| FR-008 | internal/adapter/*.go (12 adapters), internal/model/model.go, internal/severity/severity.go, internal/purl |
| FR-009, FR-010 | internal/alias/resolver.go, internal/alias/osv.go |
| FR-011 | internal/cache/store.go |
| FR-012, FR-013 | internal/crosswalk/crosswalk.go, internal/correlate/correlate.go, crosswalk/*.yaml, cmd/quorum/scan.go |
| FR-014 | internal/correlate/key.go, internal/correlate/correlate.go |
| FR-015 | internal/consensus/consensus.go |
| FR-016 | internal/report/{report,sarif,json,xml}.go |
| FR-017, FR-018 | internal/filter/filter.go |
| FR-019 | cmd/quorum/scan.go (worstSeverity, gate), cmd/quorum/main.go |
| FR-021 | cmd/quorum/scan.go (printSummary, logf) |
| FR-023 | cmd/quorum/scan.go (writeMetricsFile), internal/report/metrics.go |
| FR-024 | cmd/quorum/scan.go (logf, --log-format validation) |
| FR-025 | internal/adapter/adapter.go (extraArgs, splitArgs) |
| FR-026 | internal/adapter/adapter.go (capWriter, maxOutputBytes), cmd/quorum/scan.go (checkTargetSize) |
| FR-027 | cmd/quorum/scan.go (validateTargetRef) |
| FR-028 | internal/enrich/enrich.go, knowledge/*.yaml, cmd/quorum/scan.go |
| FR-029 | internal/rag/*.go, knowledge/owasp/corpus.yaml, cmd/quorum/advise_index.go |
| FR-030 | internal/advisor/{advisor,client}.go, cmd/quorum/scan.go |
| FR-031 | internal/advisor/verify.go |
Out-of-scope items (N/A)¶
The enterprise template envisions capabilities that do not exist in Quorum by architectural decision ("CLI/Docker only"). These are declared N/A with justification:
| Capability | Status | Technical justification |
|---|---|---|
| Web frontend / dashboard | N/A | The product is panel-less and CI/CD-first (root.go: "No panel, no daemon"). The output is a report (SARIF/JSON/XML) + exit code + Prometheus metrics (FR-023). |
| Relational database | N/A | Persistence is limited to the alias JSON cache (internal/cache) and the on-disk advice cache — explicitly "without pulling in a CGO database". |
| REST API / HTTP server | N/A | There is no server; outbound network calls are OSV.dev for aliases (FR-009) and, only when opted in, the advisory endpoint (FR-030). --metrics writes a local file, it does not expose an endpoint. |
| Authentication / user accounts | N/A | No multi-user; the tool runs in the CI/developer process context. QUORUM_ADVICE_API_KEY (FR-030) is an outbound provider credential, not user auth. |
| AI / LLM in the deterministic core | N/A | Correlation/consensus is deterministic (keys + formula), with no ML models. An opt-in advisory layer (--advice, FR-028…FR-031) adds AI recommendations, but it is off by default, presentation-only, and never affects correlation/confidence/gating. |
| Runtime cloud/K8s orchestration | N/A | k8s is a target type (manifests/cluster to scan), not a runtime of Quorum itself. |
Future proposals (clearly separated from as-is): TTL/expiration in the alias cache; a baseline exporter (generate
.quorumignorefrom a report); an additional output format (e.g. SARIF + Markdown summary); multi-engine RBAC correlation (today single-engine, FR-012). None of this is implemented in v0.8.3. The advisory layer (FR-028…FR-031), by contrast, is implemented and shipping in v0.8.3.
Cross-links¶
- Data model, correlation matrix (§6), consensus math (§9), and scanner status (§14): see
DESIGN.mdat the repository root. - Related documents in this suite: 01-visao-geral · 03-requisitos-nao-funcionais · 04-arquitetura · 06-interfaces-cli-e-formatos · 13-ia · 21-proposta-ia.
- Published documentation (MkDocs Material) on GitHub Pages; third-party notices in
THIRD_PARTY_NOTICES.md.
Assumptions¶
- Reference version: the document describes the as-is behavior of v0.8.3 (revision 2026-07-04); the
versionconstant inroot.gois still0.1.0because it is overridden at build time (FR-022), which we assume is intentional (GoReleaser/ldflags injectv0.8.3). - Capabilities vs. availability:
list-scanners(FR-003) reports the capabilities declared by the 12 adapters, not real binary availability — availability is only checked during thescan(FR-006). polaris/kube-scorenow registered: in v0.2.3,polarisexisted only inscannerCategorywith no adapter. In v0.8.3,polaris,kube-score, andkubescapeare registered adapters that produceK8S_POSTUREand are correlated by the crosswalk's k8s hub (crosswalk/k8s.yaml).- Per-adapter target coverage: the FR-008 matrix reflects the
Supports/Capabilitiesmethods read directly from the code; divergences betweenSupports(what runs) andCapabilities(whatlist-scannersshows) were preserved — e.g.kubescapeandpolarishaveSupportsrepo+k8s, but declare capability only forK8S_POSTUREfor k8s. conftestis opt-in: without Rego policies (default./policyorQUORUM_CONFTEST_ARGS="--policy ..."), conftest errors and is reported aserror(FR-007/FR-008) — expected policy-as-code behavior, not a defect.- RBAC single-engine: RBAC correlation is not multi-engine because kubescape's RBAC analysis requires cluster context (FR-012); it is a documented design decision, not a crosswalk gap.
- Cache without expiration:
internal/cacheimplements no TTL (versioned byschemaVersion); it is assumed the user manages/clears the file manually when needed (listed as a future proposal). Related note: the grype DB is pre-cached withGRYPE_DB_VALIDATE_AGE=false(does not expire) in the image supply chain — out of scope for this FR doc. - Cross-document names (
01-...,03-...,04-...) follow the doc suite's numeric convention, currently published on GitHub Pages. - OSV.dev as the only always-on network dependency: it is assumed no other pipeline step does network I/O by default;
--offline(FR-010) is enough for air-gapped operation. The advisory layer's AI providers (FR-030) are additional, opt-in outbound calls —localstays on-host,remoteis egress-gated and blocked by--offline. --timeoutmaps to a per-scanner timeout (PerScannerTime), not the whole scan;ProbeTime(60s) is separate and not exposed as a CLI flag in v0.8.3.- Caps with high defaults:
QUORUM_MAX_OUTPUT_BYTES(512 MiB) andQUORUM_MAX_TARGET_BYTES(20 GiB) are DoS guards (FR-026) well above any real use; it is assumed operators only adjust them in extreme cases. QUORUM_<SCANNER>_ARGSis trusted: the passthrough (FR-025) assumes the same trust level as CLI flags (the operator controls the container/CLI environment).- Advisory layer is opt-in and non-blocking:
--adviceand its sub-flags (FR-028…FR-031) are off by default; when enabled they only add presentation, degrade gracefully if a model/knowledge source is unavailable, and can never changecorrelationKey/fingerprint/confidence/aggregated severity/the--fail-ongate. Without--advice, output is byte-identical.