05 — Data Modeling¶
This document describes the Quorum data model (quorum-sec-scan, v0.8.3 — revision 2026-07-04). Quorum is a CLI/Docker consensus security scanning tool: it orchestrates a pool of OSS scanners (trivy, grype, checkov, kics, dockle, kubescape, polaris, kube-score, terrascan, tfsec, regula, conftest), normalizes all output into a canonical in-memory domain model, resolves vulnerability aliases, correlates equivalent findings by a deterministic correlationKey, computes a confidence (consensus) score, and serializes the result to SARIF (primary), JSON or XML.
Because Quorum has no database (see §1), "data model" here means the domain model: a set of Go structs that are immutable throughout the pipeline, with three persistence projections (SARIF/JSON/XML) generated on every run. This document covers the three classic modeling levels — Conceptual, Logical and Physical — adapted to that context, with Mermaid diagrams, invariants and checklists.
Sources verified in code:
internal/model/model.go,internal/correlate/key.go,internal/correlate/correlate.go,internal/crosswalk/crosswalk.go,crosswalk/{aws,azure,gcp,k8s}.yaml,internal/consensus/consensus.go,internal/orchestrator/orchestrator.go,internal/report/{sarif,json,xml}.go,internal/severity/severity.go,internal/purl/purl.go,internal/adapter/. Advisory-layer types verified ininternal/model/model.go(see §8).
1. No RDBMS — rationale¶
Quorum does not use a relational database (PostgreSQL, MySQL, SQLite, etc.), nor NoSQL, nor any persistent domain datastore. This is an architectural decision, not a gap.
| Criterion | Quorum reality |
|---|---|
| Execution topology | Ephemeral CLI process (or Docker container), one run = one scan |
| Data lifecycle | All findings live in memory ([]model.Finding) during a single run and are serialized to file/stdout at the end |
| State between runs | Stateless by design. No history, users, sessions or multi-tenancy |
| Existing persistence | Only file caches: aliases (~/.cache/quorum/aliases.json, perm 0600, with schemaVersion) and the grype DB pre-cached in the :full image (with GRYPE_DB_VALIDATE_AGE=false, no expiry) — rebuildable caches, not a source of truth. The advisory layer adds an optional on-disk cache (--advice-cache, see §8), also rebuildable |
| Write concurrency | None — there are no concurrent writers to coordinate; parallelism is a read fan-out of scanners (goroutines) |
Why an RDBMS would be the wrong tool:
- No long-lived entities. A database exists to hold state that outlives processes and is queried/updated by multiple clients. Quorum produces a report and exits. The "state" is the SARIF/JSON/XML file.
- The natural integration is the CI/security ecosystem. The data's destination is GitHub Code Scanning (via SARIF), pipeline artifacts and exit-code gates — not dashboards querying a database. (Optionally,
--metricsemits a Prometheus textfile for telemetry — still an output artifact, not a datastore.) - Reproducibility. The
correlationKey/Fingerprintare deterministic and pure (a function of the finding's content). The domain's "primary key" is derived, not a database auto-increment. - Simple, auditable operation. With no database there are no migrations, connection pools, backups or data-at-rest attack surface. This aligns with the project's minimal supply chain principle.
Future proposal (clearly separate, NOT implemented): if a need for history/trends ever arises (e.g. "this CVE has reappeared for 3 releases"), the idiomatic path would be to ingest the already-emitted SARIF/JSON into an external store (e.g. GitHub Advanced Security itself, a data lake, or an append-only time-series SQLite) outside the Quorum binary. The core would remain stateless. This is an idea, not a roadmap commitment.
2. Conceptual Model¶
At the conceptual level, the Quorum domain describes what is represented, independent of Go or of any file format.
2.1 Domain entities¶
| Entity | Conceptual meaning |
|---|---|
| Target | The artifact being scanned: container image, repository/IaC or k8s manifests/cluster |
| Scanner (Adapter) | An OSS engine that inspects the Target and produces native findings |
| ScannerRun | The record of what happened with a scanner in one run (did it run? fail? unavailable?) — transparency: "0 vulns must never look like 'did not run'" |
| Finding | The canonical unit of a finding, normalized from any scanner into a single format |
| CorrelationKey / Fingerprint | The deterministic identity of a finding, used to group equivalent findings from different scanners |
| MergedFinding | The consensus result: a group of equivalent Findings, with aggregated severity and a confidence score |
| Report | The serialized projection (SARIF/JSON/XML) of the set of MergedFindings + ScannerRuns |
2.2 Conceptual diagram (ER)¶
erDiagram
TARGET ||--o{ SCANNER_RUN : "is scanned by"
SCANNER_RUN ||--o{ FINDING : "produces (if status=ran)"
FINDING }o--|| CORRELATION_KEY : "receives identity"
CORRELATION_KEY ||--|| MERGED_FINDING : "groups into"
MERGED_FINDING ||--o{ FINDING : "contains (Members)"
RESULT ||--o{ SCANNER_RUN : "aggregates"
RESULT ||--o{ MERGED_FINDING : "aggregates"
RESULT ||--|| REPORT : "serializes as SARIF/JSON/XML"
TARGET {
string Type "image|repo|k8s"
string Ref "image ref or path"
}
SCANNER_RUN {
string Name
string Version
string Status "ran|skipped|unavailable|error|timeout"
int Findings
int DurationMs
string Error
}
FINDING {
string Type "VULN|MISCONFIG|SECRET|K8S_POSTURE|IMG_HARDENING"
string Scanner
string VulnID
string PURL
string CanonicalControl
string Severity
string CorrelationKey
string Fingerprint
}
MERGED_FINDING {
string CorrelationKey PK
string Severity "aggregated (max)"
int DetectionCount
float Confidence "0..1"
string Fingerprint
}
2.3 Core principle: false split > false merge¶
The domain's golden rule is that erroneously splitting two distinct findings is preferable to erroneously merging different findings. This permeates the whole identity model (correlationKey), the crosswalk (which only groups rule-ids when equivalence is clear, see §4.5) and consensus: when in doubt, the correlationKey is more specific, and Unmapped findings never merge silently with others (see §4.4).
3. Logical Model¶
The logical level describes attributes, types, cardinalities, invariants and relationships — still without committing to Go syntax.
3.1 Class diagram¶
classDiagram
direction LR
class Result {
+Target Target
+ScannerRun[] Runs
+Finding[] Findings "raw, not serialized"
+MergedFinding[] Merged
+Time StartedAt
+Duration Duration
}
class Target {
+TargetType Type
+string Ref
}
class ScannerRun {
+string Name
+string Version
+string Status
+int Findings
+Duration Duration
+string Error
}
class Finding {
+FindingType Type
+string Scanner
+string ScannerVersion
+string VulnID
+string[] Aliases
+string PURL
+string RuleID
+string CanonicalControl
+string Category
+bool Unmapped
+Resource Resource
+Location Location
+Severity Severity
+float64 CVSS
+string CorrelationKey
+string Fingerprint
+string Title
+string Description
+bool Confirmed
+map Raw "not serialized"
}
class MergedFinding {
+string CorrelationKey
+FindingType Type
+string Title
+Severity Severity
+string[] DetectedBy
+int DetectionCount
+float64 Confidence
+bool Unmapped
+Finding[] Members
+string Fingerprint
+Remediation Remediation "advisory, opt-in"
+DocRef[] References "advisory, opt-in"
+Advice Advice "advisory, opt-in"
}
class Resource {
+string Kind
+string Name
+string Namespace
+string Address
}
class Location {
+string File
+int StartLine
+int EndLine
+string ImageLayer
}
class FindingType {
<<enumeration>>
VULN
MISCONFIG
SECRET
K8S_POSTURE
IMG_HARDENING
}
class Severity {
<<enumeration>>
CRITICAL
HIGH
MEDIUM
LOW
INFO
UNKNOWN
+Rank() int
}
Result "1" *-- "1" Target
Result "1" *-- "0..*" ScannerRun
Result "1" *-- "0..*" Finding
Result "1" *-- "0..*" MergedFinding
Finding "1" *-- "1" Resource
Finding "1" *-- "1" Location
Finding "1" --> "1" FindingType
Finding "1" --> "1" Severity
MergedFinding "1" o-- "1..*" Finding : Members
MergedFinding "1" --> "1" FindingType
MergedFinding "1" --> "1" Severity
MergedFinding "1" o-- "0..1" Remediation
MergedFinding "1" o-- "0..*" DocRef : References
MergedFinding "1" o-- "0..1" Advice
3.2 Enumerations¶
FindingType — classifies and selects the correlation strategy¶
The set of FindingType values remains unchanged since v0.2.3 (five values). What changed is the pool of scanners feeding each type: with 12 adapters, more engines produce MISCONFIG and K8S_POSTURE.
| Value | Meaning | Produced by (typical) |
|---|---|---|
VULN |
Software vulnerability (SCA) | trivy, grype |
MISCONFIG |
IaC misconfiguration | trivy, checkov, kics, terrascan, tfsec, regula, conftest (policy-as-code) |
SECRET |
Exposed secret | trivy (with redaction of the secret's Match) |
K8S_POSTURE |
Kubernetes security posture | kubescape, polaris, kube-score |
IMG_HARDENING |
Container image hardening | dockle |
FindingType is the discriminator that selects the BuildKey algorithm (see §4). Note that conftest is policy-as-code: it evaluates your Rego (from ./policy) and emits MISCONFIG; its identity falls into the MISCONFIG branch of BuildKey like any other IaC.
Severity — single normalized scale¶
| Value | Rank() |
Normalization origin |
|---|---|---|
CRITICAL |
5 | CVSS ≥ 9.0; labels "CRITICAL"/"CRIT" |
HIGH |
4 | CVSS ≥ 7.0; "HIGH"/"ERROR"/"DANGER"; Dockle "FATAL" |
MEDIUM |
3 | CVSS ≥ 4.0; "MEDIUM"/"MODERATE"/"WARNING"; Dockle "WARN" |
LOW |
2 | CVSS > 0; "LOW"/"MINOR"; Dockle "INFO" |
INFO |
1 | "INFO"/"INFORMATIONAL"/"NEGLIGIBLE"; Dockle "PASS/SKIP/IGNORE" |
UNKNOWN |
0 | CVSS = 0; "UNKNOWN"/"NONE"/empty; unrecognized label |
Normalization lives in internal/severity/severity.go: FromCVSS, FromLabel, FromDockle, plus utilities Max, AtLeast (used by --fail-on/--min-severity) and Parse. Rank() makes the scale orderable for aggregation and report sorting.
3.3 Finding attributes (central entity)¶
| Attribute | Logical type | Required | Notes |
|---|---|---|---|
Type |
FindingType | Yes | Correlation discriminator |
Scanner |
string | Yes | Name of the source adapter |
ScannerVersion |
string | No | Binary version |
VulnID |
string | Conditional (VULN) | CVE/GHSA; canonical after alias resolution |
Aliases |
string[] | No | Other ids reported by the scanner |
PURL |
string | Conditional (VULN) | pkg:type/ns/name@version |
RuleID |
string | Conditional (MISCONFIG/etc.) | Native rule id; crosswalk input (scanner\|ruleID) |
CanonicalControl |
string | No | Crosswalk result (AVD/CWE/CIS) — or already native (trivy/tfsec emit AVD) |
Category |
string | No | Semantic category (crosswalk fallback) |
Unmapped |
bool | No | true when the crosswalk did not resolve a control |
Resource |
Resource | No | Target IaC/k8s object |
Location |
Location | No | File/line or image layer |
Severity |
Severity | Yes | Normalized scale |
CVSS |
float64 | No | 0 = absent |
CorrelationKey |
string | Computed | Assigned by the correlator, never by the adapter |
Fingerprint |
string | Computed | sha256(CorrelationKey) |
Title |
string | Yes | Human-readable title |
Description |
string | No | |
Confirmed |
bool | No | Confirmed by an authoritative source (NVD/OSV) |
Raw |
map[string]any | No | Original payload; not serialized (tag json:"-") |
3.4 Relationships and cardinalities¶
| Relationship | Cardinality | Rule |
|---|---|---|
| Result → ScannerRun | 1 : 0..N | One run per selected adapter (even if skipped/unavailable) |
| ScannerRun → Finding | 1 : 0..N | Only status=ran produces findings; the rest produce 0 |
| Finding → CorrelationKey | N : 1 | Many findings may share the same key |
| CorrelationKey → MergedFinding | 1 : 1 | Each distinct key becomes exactly one MergedFinding |
| MergedFinding → Finding (Members) | 1 : 1..N | Always ≥ 1 member (a group is never empty) |
| Finding → Resource / Location | 1 : 1 | Embedded value objects (always present, possibly empty) |
| MergedFinding → Remediation / Advice | 1 : 0..1 | Advisory, opt-in; nil unless --advice enrichment ran (see §8) |
| MergedFinding → DocRef (References) | 1 : 0..N | Advisory, opt-in; empty unless --advice enrichment ran |
3.5 Invariants and logical constraints¶
These are the constraints that replace an RDBMS's constraints. There is no engine enforcing them on a table — they are guaranteed by construction in code and/or verified by contract/unit tests (test coverage required in CI).
- INV-1 (computed identity). Adapters never fill
CorrelationKey/Fingerprint. Those fields are assigned centrally inCorrelator.Enrich(or in the orchestrator fallback). Origin: comment inmodel.goandorchestrator.go. - INV-2 (Fingerprint derives from the key). Always
Fingerprint == sha256hex(CorrelationKey). Pure function, no salt. Origin:correlate.Fingerprint. - INV-3 (aggregated severity = maximum).
MergedFinding.Severity == max(Members[*].Severity)byRank(). Origin:consensus.aggregateSeverity. - INV-4 (count by distinct scanner).
DetectionCount == |distinct(lower(Members[*].Scanner))|, not the number of findings — two findings from the same scanner count as 1. Origin:consensus.distinctScanners/DetectionCount. - INV-5 (
DetectedByordered and unique). List of distinct scanners, lowercased and alphabetically sorted. Origin:distinctScanners(usessort.Strings). - INV-6 (bounded confidence).
0 ≤ Confidence ≤ 1(viaclamp01). Origin:consensus.confidence. - INV-7 (
Unmappedpropagation).MergedFinding.Unmapped == OR(Members[*].Unmapped). Origin:consensus.anyUnmapped. - INV-8 (non-merge of unmapped). An
Unmappedfinding is keyed byUNMAPPED:<lower(scanner)>:<UPPER(ruleID)>, ensuring it never merges silently with another of a different control. Origin:correlate.controlKey. - INV-9 (non-empty group). Every
MergedFindinghaslen(Members) ≥ 1;Type,Fingerprintinherit fromMembers[0]. Origin:consensus.Merge. - INV-10 (resolved
Title).MergedFinding.Titleis the first non-empty title among the members; fallback to theCorrelationKey. Origin:consensus.bestTitle. - INV-11 (deterministic report ordering). MergedFindings are ordered by
(Severity.Rank desc, Confidence desc, DetectionCount desc, CorrelationKey asc). The finalCorrelationKeytie-breaker guarantees total stability. Origin:consensus.Merge(sort.SliceStable). - INV-12 (scanner transparency). Every selected adapter yields a
ScannerRunwithStatus ∈ {ran, skipped, unavailable, error, timeout}, even with no findings. Origin:orchestrator.runOne. - INV-13 (advisory is presentation-only).
Remediation/References/Adviceare populated after consensus and never feedCorrelationKey/Fingerprint/Confidence/aggregated severity or the--fail-ongate. Without--advicethey stay nil/empty and the output is byte-identical. Origin: field comments inmodel.go; packageinternal/advisor/internal/enrich.
Invariant validation checklist (for review/PR)¶
- [ ] No adapter assigns
CorrelationKeyorFingerprint(INV-1) - [ ]
Fingerprintmatchessha256hex(CorrelationKey)(INV-2) - [ ] Merge
Severityis the maximum of the members (INV-3) - [ ]
DetectionCount= distinct scanners, not findings (INV-4) - [ ]
Confidencestays within [0,1] (INV-6) - [ ]
Unmappedfindings never co-grouped with different controls (INV-8) - [ ] Report ordering is fully deterministic (INV-11)
- [ ] Every selected scanner appears in
Runswith a valid status (INV-12) - [ ] Advisory fields never alter identity, confidence or the gate; absent without
--advice(INV-13)
4. correlationKey and Fingerprint (identity)¶
The domain's "primary key" is the correlationKey, a pure and deterministic function of the finding's normalized content, defined in internal/correlate/key.go. There is no universal key: each FindingType has its own strategy, reflecting the fact that "equivalence" means different things for a CVE and for a Terraform misconfiguration.
4.1 Identity pipeline¶
flowchart LR
A["Normalized Finding<br/>(from adapter)"] --> B{Type?}
B -->|VULN| C["resolveVuln:<br/>VulnID -> canonical (alias/OSV)"]
B -->|MISCONFIG / K8S / IMGH| D["resolveControl:<br/>crosswalk -> CanonicalControl<br/>or Unmapped=true"]
C --> E["BuildKey(f)"]
D --> E
E --> F["CorrelationKey"]
F --> G["Fingerprint = sha256(CorrelationKey)"]
G --> H["consensus.Merge:<br/>group by CorrelationKey"]
Enrichment and keying happen in Correlator.Enrich; grouping is the responsibility of the consensus package. The correlate package "owns identity", consensus "owns grouping". In resolveControl, findings that already arrive with CanonicalControl filled (trivy and tfsec emit AVD ids natively) skip the crosswalk — auto-correlation with trivy is immediate.
4.2 BuildKey formulas by type¶
| Type | correlationKey formula |
Stability target |
|---|---|---|
VULN |
VULN\|<UPPER(VulnID)>\|<purl.NameVersion(PURL)> |
Same CVE + same name@version correlates across scanners, ignoring ecosystem prefix/qualifiers |
MISCONFIG |
MISCONFIG\|<fileKey>\|<resourceType>\|<controlKey> |
fileKey = lowercased basename; resourceType derived from the address (e.g. aws_s3_bucket) |
K8S_POSTURE |
K8S\|<objectRef>\|<controlKey> |
objectRef = ns/kind/name. The container is NOT part of the key (see note below) |
IMG_HARDENING |
IMGH\|<controlKey> |
Hardening is global to the image |
SECRET |
SECRET\|<normPath>\|<lineKey>\|<lower(RuleID)> |
Normalized path + line + rule |
| (default) | OTHER\|<Scanner>\|<Title> |
Conservative fallback |
Change since v0.2.3 — K8S key without container. The
K8S_POSTUREformula no longer includes theAddress(container). The comment inkey.goexplains why: the engines report at different granularities — kubescape at the workload level, polaris per container. Including the container in the key would block consensus across engines. Trade-off consciously accepted: two containers failing the same control in the same workload merge into a single finding — appropriate for posture (false split > false merge).
4.3 Component normalization¶
| Helper | What it does | Why |
|---|---|---|
purl.NameVersion |
Strips pkg:, ?/# qualifiers; lowercases |
Same package correlates despite ecosystem variations |
fileKey |
path.Base + lowercase |
Scanners report different roots/relativity; only the basename is stable |
normPath |
Normalizes separators, path.Clean, strips ./,/, lowercases |
Stable path for SECRET |
resourceType |
First segment of the address containing _ (e.g. aws_s3_bucket); fallback to Kind |
Engines disagree between the Terraform address and the literal resource name |
objectRef |
lower(namespace/kind/name), empty namespace → default |
Stable k8s object identity (workload level, no container) |
lineKey |
StartLine (or 0 if absent) |
Discriminates secrets on the same rule/file |
4.4 controlKey and Unmapped handling¶
controlKey prefers CanonicalControl (uppercased). When no control is resolved, it falls back to UNMAPPED:<lower(scanner)>:<UPPER(RuleID)>. This materializes the false split > false merge principle: a finding with no canonical mapping keeps its own identity and never collides with another of a distinct control.
Documented trade-off (KNOWN ISSUE in code): for
MISCONFIG, two distinct resources of the same type with the same control in the same file can over-merge. This was consciously accepted as preferable to never correlating across engines.
4.5 Crosswalk: canonical hubs and versioned format¶
The CanonicalControl that feeds controlKey comes from the crosswalk (internal/crosswalk/crosswalk.go): a scanner + ruleID → canonical control mapping loaded from the YAML files in ./crosswalk. All mappings were DERIVED from real output of the scanners (run via the :full image against examples/), pairing ids only where the engines flag the same concept on the same resource.
Canonical hubs by domain:
| File | Hub (canonical id) | Coverage | Paired engines |
|---|---|---|---|
crosswalk/aws.yaml |
AVD (AVD-AWS-####) |
S3, IAM, EBS, Security Group, RDS, KMS, CloudTrail, VPC flow logs | trivy (native), checkov, kics, terrascan, regula |
crosswalk/azure.yaml |
AVD (AVD-AZU-####) |
Storage account (HTTPS/TLS), Key Vault | trivy (native), checkov, kics, terrascan, regula |
crosswalk/gcp.yaml |
AVD (AVD-GCP-####) |
GCS bucket, firewall (RDP/SSH), Cloud SQL | trivy (native), checkov, kics, terrascan, regula |
crosswalk/k8s.yaml |
Kubescape (C-####) |
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 | kubescape (native), polaris, kube-score |
Design notes faithful to the code:
- Trivy and tfsec are the IaC hub. Trivy speaks AVD natively; tfsec also emits AVD ids and therefore auto-correlates with trivy without needing a crosswalk entry (
resolveControlreturns early whenCanonicalControlis already filled). - RBAC stays single-engine. In
crosswalk/k8s.yaml, kubescape RBAC controls (C-0035/C-0185/C-0272/…) are not paired: they depend on an active cluster context and did not fire over static manifests, so they stayUnmappedrather than forcing a false merge. Documented in the YAML itself. - kube-score grouped checks (
container-resources= cpu+memory,pod-probes= liveness+readiness) are intentionally left unmapped — mapping a grouped check to a single canonical control would cause over-merge.
Versioned format (schemaVersion)¶
The loader (crosswalk.Load) accepts two forms of file, trying the versioned one first:
- Versioned document (preferred): a map with
schemaVersion: 1+controls: [...]. Used byazure.yaml,gcp.yamlandk8s.yaml. The constantcrosswalk.SchemaVersion = 1defines the current version and allows the format to evolve. - Plain list (legacy, still supported): the YAML is a top-level list of controls (no
schemaVersion). Used today byaws.yaml.
Each Control has canonicalControl, category, cwe (optional), title and ids: { scanner: [ruleID, ...] }. The internal index is byRule["<lower(scanner)>|<ruleID>"] → Resolution. A missing crosswalk directory is not an error (Quorum runs without a custom crosswalk; all findings degrade to Unmapped).
5. Consensus and MergedFinding¶
consensus.Merge (internal/consensus/consensus.go) groups Findings by CorrelationKey and produces scored MergedFindings. Raw detection count is not confidence — engine diversity, severity and authoritative confirmation also weigh in (DESIGN §9).
5.1 Confidence formula¶
| Component | Calculation | Range |
|---|---|---|
count |
log(1+N) / log(5), N = distinct scanners (diminishing returns) |
~0..1 |
diversity |
distinct engine families: 1→0.33, 2→0.66, 3+→1.0 | 0..1 |
severity |
CRITICAL=1.0, HIGH=0.8, MEDIUM=0.5, LOW=0.3, INFO/UNKNOWN=0.1 | 0.1..1 |
authoritative |
1.0 if any member is Confirmed, or is a CVE with CVSS>0; otherwise 0 |
0 or 1 |
Engine families (scannerCategory), updated for the 12 scanners: trivy/grype → sca; checkov/kics/terrascan/tfsec/regula → iac; conftest → policy; kubescape/polaris/kube-score → k8s; dockle → hardening. Unknown scanners become other:<name>, counting as their own family (diversity does not inflate artificially). Diversity rewards consensus across distinct families: two engines of the same family (e.g. checkov + kics, both iac) add less than engines of different families.
5.2 MergedFinding derivation diagram¶
flowchart TD
G["Group: Finding[] with same CorrelationKey"] --> S["distinctScanners -> DetectedBy, DetectionCount"]
G --> A["aggregateSeverity -> Severity (max)"]
G --> T["bestTitle -> Title"]
G --> U["anyUnmapped -> Unmapped"]
S --> C["confidence(members, scanners, agg) -> Confidence"]
A --> C
G --> M["MergedFinding"]
S --> M
A --> M
T --> M
U --> M
C --> M
Advisory note: when
--adviceis enabled, an enrichment step runs afterMergeand may attachRemediation/References/Adviceto eachMergedFinding. That step reads the merged finding but never mutates its identity, severity or confidence (INV-13). See §8.
6. Physical Model¶
At the physical level, Quorum's "storage" is (a) the layout of Go structs in memory and (b) the three serialized projections. There is no SQL schema.
6.1 Physical in memory — Go structs¶
The canonical definitions are in internal/model/model.go. Relevant physical points:
Finding.Raw map[string]anyhas tagjson:"-"→ never serialized; it exists only for debugging/adapters.Result.Findingshas tagjson:"-"→ the raw findings do not go into the default report; the report exposesMerged(renamed tofindingsin JSON). Origin:orchestrator.Result.- Fields with
omitemptydisappear when empty (e.g.aliases,purl,cvss,description). The advisory fieldsremediation,references,adviceare allomitempty→ absent from output unless--advicepopulated them. SeverityandFindingTypearestringtypedefs — they serialize as literal strings ("CRITICAL","VULN").
6.2 Physical serialized — three projections¶
| Format | Reporter | Role | Shape |
|---|---|---|---|
| SARIF 2.1.0 | sarif.go |
Primary — GitHub Code Scanning | runs[].results[] with partialFingerprints["quorum/v1"] |
| JSON | json.go |
Generic integration / detail | {tool, version, target, scanners, summary, findings} (findings = dump of []MergedFinding) |
| XML | xml.go |
Legacy/JUnit-like pipelines | <quorumReport> mirroring the JSON |
Output-path hardening:
--outputappliesfilepath.Cleanand writes with permission0600; atargetstarting with-is refused (protection against argument injection); OSV ids are validated and passed throughurl.PathEscape. This does not change the data model, but it affects how the projections are written.
6.2.1 Canonical → SARIF mapping¶
| Canonical field | SARIF destination |
|---|---|
MergedFinding.Fingerprint |
result.partialFingerprints["quorum/v1"] |
Severity |
result.level (CRITICAL/HIGH→error, MEDIUM→warning, rest→note) |
Title |
result.message.text + rule.shortDescription |
Type |
rule.name + rule.properties.type |
VulnID / CanonicalControl / RuleID |
result.ruleId (in that order of preference; fallback CorrelationKey) |
Location.File/StartLine/EndLine |
result.locations[].physicalLocation (deduplicated per file) |
DetectedBy, DetectionCount, Confidence (rounded to 2 places), Severity, CorrelationKey, Unmapped |
result.properties |
Result.Target.Ref + scanner summary |
run.properties |
Version note:
report.Version(the fingerprint namespace and SARIF driver version) is pinned to"0.1.0"in code (sarif.goline 13), distinct from the product version (v0.8.3). See Gaps.
6.2.2 JSON structure¶
{
"tool": "quorum",
"version": "0.1.0",
"target": { "type": "repo", "ref": "./" },
"scanners": [
{ "name": "trivy", "version": "0.55.0", "status": "ran",
"findings": 12, "durationMs": 1842 }
],
"summary": {
"totalFindings": 7, // = len(Merged)
"durationMs": 5300,
"bySeverity": { "CRITICAL": 1, "HIGH": 3 },
"multiDetected": 2 // findings with detectionCount > 1
},
"findings": [ /* []MergedFinding */ ]
}
Each element of findings is a MergedFinding. Under --advice it may carry the optional remediation, references and advice objects (all omitempty); without --advice those keys are absent and the JSON is byte-identical to a plain run.
6.2.3 XML structure¶
<quorumReport tool="quorum" version="..."> with <target>, <scanners><scanner .../></scanners> and <findings><finding>...</finding></findings>. Each <finding> carries type, severity, detectionCount, confidence, unmapped, fingerprint as attributes, and correlationKey, title, detectedBy>scanner, locations>location as elements.
6.3 Indexes, Triggers, Views, Procedures, Sequences — N/A¶
| Construct | Status | Rationale |
|---|---|---|
| Indexes | N/A | There is no database to index. The logical "indexes" are built in memory per run and discarded at the end: the map[string][]Finding by CorrelationKey in consensus.Merge and the crosswalk's byRule map[string]Resolution (amortized O(1) access) |
| Triggers | N/A | No database and no persistent mutations. The analogous "reaction to an event" is the pure functional pipeline scan → normalize → alias → correlate → score → report |
| Views | N/A | The three serializations (SARIF/JSON/XML) are the read "views" of the same canonical set, generated at runtime by the reporters |
| Stored Procedures / Functions | N/A | All logic is Go code (e.g. BuildKey, confidence). There is no SQL/PL nor database RPC. The controlled exception is conftest's policy-as-code, which runs the user's Rego (./policy) — but it produces Findings, it does not persist state |
| Sequences / Auto-increment | N/A | Identity is derived from content (Fingerprint = sha256(CorrelationKey)), not generated by a sequence |
| Constraints (FK/UNIQUE/CHECK) | N/A in the DBMS; equivalents in code | FKs/uniques are guaranteed by construction: grouping by CorrelationKey (logical UNIQUE), Members ⊆ Findings (logical FK). The equivalent CHECKs are the invariants in §3.5 |
| Migrations | N/A | No persistent schema. Model changes are struct changes + release versioning; external compatibility is the responsibility of the SARIF/JSON/XML contract. The only schema-versioned artifact is the crosswalk (schemaVersion: 1), which evolves by tolerant reading (accepts both legacy and versioned forms), not by migration |
Future proposal (NOT implemented): should the external ingest described in §1 be adopted, indexes on
(fingerprint)and(vulnId)and materialized trend views would make sense — in the external store, never in the binary.
7. Data dictionary (summary)¶
| Struct | Package | Role | Serialized in |
|---|---|---|---|
Finding |
model |
Normalized canonical unit | JSON (inside members), SARIF (source of the fields) |
MergedFinding |
model |
Scored post-consensus group | SARIF/JSON/XML (report unit) |
Resource |
model |
Target IaC/k8s object | embedded |
Location |
model |
File/line/layer | SARIF locations, XML locations |
Severity |
model |
Normalized scale (enum) | string |
FindingType |
model |
Correlation discriminator (enum) | string |
Remediation |
model |
Deterministic Phase-0 remediation template (advisory) | JSON remediation (opt-in, omitempty) |
DocRef |
model |
External OWASP reference (advisory) | JSON references (opt-in, omitempty) |
Advice |
model |
Opt-in local-LLM recommendation, always labeled (advisory) | JSON advice (opt-in, omitempty) |
Fix |
model |
Verified AI-proposed patch, embedded in Advice (advisory) |
JSON advice.fix (opt-in, omitempty) |
Control / Resolution |
crosswalk |
scanner+ruleID → canonical control mapping |
not serialized (input config, crosswalk/*.yaml) |
ScannerRun |
orchestrator |
Execution transparency | JSON/XML scanners, SARIF run.properties |
Result |
orchestrator |
Complete scan output | base of all reporters |
Target |
adapter |
Scanned artifact | target in all formats |
8. Advisory layer in the model (opt-in)¶
Since v0.8.3, MergedFinding carries an optional advisory layer, enabled by the --advice flag. These fields are presentation-only: they are populated after consensus and never touch CorrelationKey, Fingerprint, Confidence, aggregated severity or the --fail-on gate. Without --advice, they stay nil/empty and the report is byte-identical to a plain run (INV-13). The deterministic core still has no AI; the AI parts are strictly opt-in and off by default.
8.1 New fields on MergedFinding¶
| Field | Type | Populated by | Meaning |
|---|---|---|---|
Remediation |
*Remediation |
Phase 0 (deterministic, no model) | Curated remediation template matched by canonicalControl/ruleId/category/type |
References |
[]DocRef |
Phase 0 / Phase 2 (deterministic) | OWASP references; Phase 2 retrieves them from a digest-pinned OWASP corpus |
Advice |
*Advice |
Phase 1 (opt-in local LLM) | Natural-language recommendation from an on-host OpenAI-compatible endpoint; always labeled |
8.2 Advisory value objects¶
classDiagram
class Remediation {
+string Title
+string Summary
+string Snippet
+string Source "template — deterministic, never guessed"
}
class DocRef {
+string Title
+string URL
}
class Advice {
+string Recommendation
+Fix Fix
+string Provider "local | remote"
+string Model
+string Label "AI-generated, advisory only"
}
class Fix {
+string File
+string Content "proposed full replacement"
+bool Verified "always true when attached"
}
Advice "1" o-- "0..1" Fix
model.Remediation— a suggested fix. In Phase 0 it is a curated, deterministic template keyed by the finding's canonical control (Source = "template", never guessed).Snippetis illustrative, not an exact diff. Data lives inknowledge/*.yaml(aws/azure/gcp/k8s/image/categories); packageinternal/enrich.model.DocRef— an external reference (e.g. an OWASP cheat sheet) grounding the remediation in an authoritative, citable source. Phase 2 (RAG-as-artifact, packageinternal/rag) retrieves these deterministically from the versioned, digest-pinned OWASP corpus (knowledge/owasp/corpus.yaml): lexical retrieval by default (no model), semantic (embeddings) when the corpus is embedded viaquorum advise-index—scanauto-picks semantic when the corpus has vectors.model.Advice— the AI-generated advisory layer (Phase 1, opt-in via--advice-provider=local; Phase 3 via=remote).Providerislocalorremote,Modelis the id the endpoint reported, andLabelis always"AI-generated, advisory only". Reproducible via temperature=0 + an on-disk cache keyed by fingerprint+provider+model. Graceful degradation: if the model is unreachable the report ships without AI advice and the scan never fails. Packageinternal/advisor.model.Fix— an AI-proposed replacement for the offending file, attached only when--fix=suggestis set and it passed the verify-the-fix loop: the patch is applied to a temp copy, re-scanned with the same scanner, and kept only if the finding is gone and the file still parses.Verifiedis alwaystruewhen attached (otherwise the fix is dropped). Quorum never auto-applies. Remote provider refuses--fix(it would upload source) and is blocked by--offline; only the normalized finding is ever sent to a remote provider — never source code.
8.3 Serialization¶
All three fields carry omitempty, so they are absent from JSON/XML unless enrichment populated them. New metrics (only under --advice) reflect the same data: quorum_advice_enriched{kind=remediation|references|recommendation}, quorum_advice_provider{provider}, and quorum_advice_fix{stage=proposed|verified} (the verify-the-fix rate). The knowledge pack + crosswalk get a SLSA build-provenance attestation each release (verify with gh attestation verify knowledge/owasp/corpus.yaml). See 13-ia.md for the honest framing and 21-proposta-ia.md for the full Phase 0–3 design.
Assumptions¶
- Code version analyzed: repository state on the
mainbranch at the time of writing (product labeled v0.8.3, revision 2026-07-04). Citations reflect the files read directly (key.go,correlate.go,crosswalk.go,consensus.go,model.go, thecrosswalk/*.yaml); no behavior was inferred without reading. - Canonical model unchanged at the core. The domain
structs andFindingType(5 values) remain identical to v0.2.3; what evolved is the scanner pool (6 → 12), the crosswalk (now with versioned AVD/kubescape hubs), the K8S key (no container), and — new in v0.8.3 — the opt-in advisory fields onMergedFinding(Remediation/References/Advice), which are presentation-only and off by default. report.Version = "0.1.0"was reported faithfully as it is in code. It is assumed to be the version of the output contract/fingerprint namespace, not the product version — see Gaps.- DESIGN.md is referenced by sections (§3, §6, §8, §9) as per code comments; the detailed content of that document was not re-read here, only the anchors cited in the source.
- The JSON/XML examples are illustrative of the shape defined by the struct tags; values are fictitious.
- "Database" was interpreted as a persistent domain datastore; the file caches (aliases
0600withschemaVersion, the non-expiring grype DB, the advisory--advice-cache) are treated as rebuildable caches, not as a data model.
Gaps¶
report.Versionis hardcoded to"0.1.0"and diverges from the product version (v0.8.3); the code does not make clear whether this is intentional (contract versioning) or lag. Documented as an observation.- Mixed crosswalk format.
aws.yamlstill uses the legacy form (top-level list, noschemaVersion), whileazure/gcp/k8s.yamluse the versioned form (schemaVersion: 1+controls). The loader accepts both; migratingaws.yamlto the versioned form is cosmetic and has not been done. - Unlike v0.2.3,
polarisandkube-scoreare no longer placeholders: they are now real adapters (internal/adapter/polaris.go,kubescore.go) and entries incrosswalk/k8s.yaml. The earlier gap ("polaris without an adapter") is resolved. - The exact numeric content of some DESIGN.md sections (e.g. the full correlation matrix) was not transcribed; only the behavior present in code was documented.
Open Questions¶
- Should the
report.Versionstring come to reflect the product version (via GoReleaser ldflags), or is it deliberately an independent contract versioning? - Should
crosswalk/aws.yamlbe migrated to the versioned format (schemaVersion: 1+controls) for consistency with the others, or does the legacy form remain supported indefinitely? - The kubescape RBAC controls stay
Unmappedfor lack of a second engine over static manifests. If a scan with an active cluster context makes polaris and kubescape agree, shouldcrosswalk/k8s.yamlgain those pairs? - The
[]MergedFindingdump in the JSONfindingsfield includesMembers(each a fullFinding); is this a stable, supported output contract or an internal detail subject to change?