Skip to content

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 --metrics and --log-format; per-scanner argument passthrough via QUORUM_<SCANNER>_ARGS; DoS caps for scanner output and target size; validation of target against argument injection; and I/O hardening (report with 0600 + 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 touches correlationKey, fingerprint, confidence, aggregated severity, or the --fail-on gate; without --advice the 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 + the advise-index subcommand, 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/orchestratorinternal/adapter (12 scanners) → internal/{alias,cache,crosswalk} (enrich) → internal/correlateinternal/consensusinternal/filterinternal/{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-formattext|json, --advice-providernone|local|remote, --fixoff|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 --scannersall 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 ⇒ uses defaultProbeTime (60s).
  • An unavailable/skipped scanner 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-0086AVD-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/trivy already 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):

confidence = clamp01( 0.35·count + 0.25·diversity + 0.25·severity + 0.15·authoritative )
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 --formatunknown 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 (writeMetricsFilereport.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 .quorumignore from 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.



Assumptions

  1. Reference version: the document describes the as-is behavior of v0.8.3 (revision 2026-07-04); the version constant in root.go is still 0.1.0 because it is overridden at build time (FR-022), which we assume is intentional (GoReleaser/ldflags inject v0.8.3).
  2. Capabilities vs. availability: list-scanners (FR-003) reports the capabilities declared by the 12 adapters, not real binary availability — availability is only checked during the scan (FR-006).
  3. polaris/kube-score now registered: in v0.2.3, polaris existed only in scannerCategory with no adapter. In v0.8.3, polaris, kube-score, and kubescape are registered adapters that produce K8S_POSTURE and are correlated by the crosswalk's k8s hub (crosswalk/k8s.yaml).
  4. Per-adapter target coverage: the FR-008 matrix reflects the Supports/Capabilities methods read directly from the code; divergences between Supports (what runs) and Capabilities (what list-scanners shows) were preserved — e.g. kubescape and polaris have Supports repo+k8s, but declare capability only for K8S_POSTURE for k8s.
  5. conftest is opt-in: without Rego policies (default ./policy or QUORUM_CONFTEST_ARGS="--policy ..."), conftest errors and is reported as error (FR-007/FR-008) — expected policy-as-code behavior, not a defect.
  6. 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.
  7. Cache without expiration: internal/cache implements no TTL (versioned by schemaVersion); 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 with GRYPE_DB_VALIDATE_AGE=false (does not expire) in the image supply chain — out of scope for this FR doc.
  8. Cross-document names (01-..., 03-..., 04-...) follow the doc suite's numeric convention, currently published on GitHub Pages.
  9. 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 — local stays on-host, remote is egress-gated and blocked by --offline.
  10. --timeout maps 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.
  11. Caps with high defaults: QUORUM_MAX_OUTPUT_BYTES (512 MiB) and QUORUM_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.
  12. QUORUM_<SCANNER>_ARGS is trusted: the passthrough (FR-025) assumes the same trust level as CLI flags (the operator controls the container/CLI environment).
  13. Advisory layer is opt-in and non-blocking: --advice and 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 change correlationKey/fingerprint/confidence/aggregated severity/the --fail-on gate. Without --advice, output is byte-identical.