Requisitos Funcionais¶
Este documento especifica os Requisitos Funcionais (RF) do Quorum (quorum-sec-scan), versão v0.2.3. O Quorum é uma ferramenta CLI/Docker de consensus security scanning: orquestra um pool de scanners open-source (trivy, grype, checkov, kics, dockle, kubescape), normaliza todos os achados para um modelo canônico (model.Finding), resolve aliases de vulnerabilidade, correlaciona achados equivalentes por uma chave determinística (correlationKey), pontua a confiança do consenso (confidence) e emite um relatório unificado (SARIF/JSON/XML), com gating por exit code para CI/CD.
Cada RF abaixo é derivado diretamente do código-fonte (cmd/quorum/*.go e internal/*) e descrito com ID, Nome, Descrição, Fluxo, Entradas, Saídas, Regras de negócio, Prioridade e Dependências. Os RFs cobrem a superfície real do produto as-is; capacidades que não existem (frontend web, banco relacional, API REST, IA/LLM, autenticação) estão marcadas como N/A na seção Itens fora de escopo (N/A).
Princípio de projeto que permeia todos os RFs: "false split > false merge" e "0 findings is not proof of safety". O sistema prefere isolar um achado a fundi-lo incorretamente, e nunca trata "0 vulnerabilidades" como prova de segurança — o status por scanner é sempre reportado.
Índice de Requisitos¶
| ID | Nome | Prioridade | Componente principal |
|---|---|---|---|
| RF-001 | Comando scan de um target |
Obrigatório | cmd/quorum/scan.go |
| RF-002 | Inferência e seleção do tipo de target | Obrigatório | cmd/quorum/scan.go |
| RF-003 | Listar scanners registrados (list-scanners) |
Obrigatório | cmd/quorum/root.go |
| RF-004 | Seleção de scanners | Obrigatório | orchestrator, adapter |
| RF-005 | Execução paralela (fan-out) com timeout por scanner | Obrigatório | orchestrator |
| RF-006 | Probe de versão e disponibilidade do scanner | Obrigatório | orchestrator, adapter |
| RF-007 | Status por scanner (transparência de execução) | Obrigatório | orchestrator, report |
| RF-008 | Normalização canônica de achados | Obrigatório | adapter, model, severity |
| RF-009 | Resolução de aliases de vulnerabilidade | Obrigatório | alias, cache |
| RF-010 | Modo offline (OSV desligado) | Obrigatório | cmd/quorum/scan.go, alias |
| RF-011 | Cache de aliases | Recomendado | cache |
| RF-012 | Crosswalk rule → controle canônico | Obrigatório | crosswalk, correlate |
| RF-013 | Fallback automático do crosswalk bundled | Recomendado | cmd/quorum/scan.go |
| RF-014 | Correlação por correlationKey e fingerprint |
Obrigatório | correlate |
| RF-015 | Scoring de consenso e agregação | Obrigatório | consensus |
| RF-016 | Emissão de relatório SARIF/JSON/XML | Obrigatório | report |
| RF-017 | Baseline de supressão (.quorumignore) |
Obrigatório | filter |
| RF-018 | Filtro de severidade mínima (--min-severity) |
Obrigatório | filter, severity |
| RF-019 | Gate de falha (--fail-on) e exit codes |
Obrigatório | cmd/quorum/scan.go, severity |
| RF-020 | Saída em arquivo ou stdout | Obrigatório | cmd/quorum/scan.go |
| RF-021 | Resumo de console e logs de progresso | Recomendado | cmd/quorum/scan.go |
| RF-022 | Versão da ferramenta | Recomendado | cmd/quorum/root.go |
Visão geral do pipeline¶
O pipeline executado pelo comando scan é linear e determinístico após a fase de fan-out:
flowchart TD
A["scan target (flags)"] --> B["RF-002: resolver tipo de target<br/>image | repo | k8s"]
B --> C["RF-004: selecionar adapters<br/>(todos ou --scanners)"]
C --> D["RF-005: fan-out paralelo<br/>(goroutines + timeout)"]
D --> E["RF-006: probe de versão<br/>(60s, distingue OOM/timeout/ausente)"]
E --> F["RF-008: normalizar<br/>cada output → model.Finding"]
F --> G["RF-009/RF-012: enrich<br/>(aliases VULN + crosswalk MISCONFIG/K8S)"]
G --> H["RF-014: correlationKey + fingerprint"]
H --> I["RF-015: consenso<br/>(merge + confidence + severidade agregada)"]
I --> J["RF-017/RF-018: filtros<br/>(baseline + min-severity)"]
J --> K["RF-016/RF-020: relatório<br/>SARIF | JSON | XML → arquivo/stdout"]
K --> L["RF-007/RF-021: status por scanner + resumo"]
L --> M["RF-019: gate fail-on → exit code"]
Referência de pacotes: cmd/quorum (CLI cobra) → internal/orchestrator → internal/adapter (scanners) → internal/{alias,cache,crosswalk} (enrich) → internal/correlate → internal/consensus → internal/filter → internal/report.
RF-001 — Comando scan de um target¶
| Campo | Conteúdo |
|---|---|
| ID | RF-001 |
| Nome | Comando scan de um target |
| Descrição | O usuário deve poder escanear um único target (imagem de container, repositório/diretório IaC ou manifests k8s) executando o pool de scanners e produzindo um relatório de consenso. É o comando central da ferramenta. |
| Prioridade | Obrigatório |
Fluxo
sequenceDiagram
actor U as Usuário/CI
participant C as quorum scan
participant O as orchestrator
participant R as report
U->>C: quorum scan <target> [flags]
C->>C: valida flags (--fail-on, --min-severity, --format, --baseline)
C->>O: orchestrator.Run(ctx, target, options)
O-->>C: Result (Runs, Findings, Merged)
C->>C: filter.Apply (baseline + min-severity)
C->>R: report.Write (SARIF/JSON/XML)
R-->>U: relatório (arquivo ou stdout)
C->>U: resumo no stderr + exit code
Entradas
| Entrada | Origem | Obrigatório |
|---|---|---|
<target> (1 argumento posicional) |
cobra.ExactArgs(1) |
Sim |
Flags --type, --scanners, --format/-f, --output/-o, --fail-on, --min-severity, --baseline, --crosswalk, --cache, --timeout, --offline, --quiet/-q |
CLI | Não (têm default) |
Saídas: relatório no formato escolhido (stdout ou arquivo), resumo de execução no stderr, exit code (0/1/2).
Regras de negócio
- cobra.ExactArgs(1): exatamente um target é exigido; 0 ou 2+ argumentos → erro de uso (exit 2).
- Flags inválidas (ex.: --fail-on fora de critical|high|medium|low, --format desconhecido) abortam antes de qualquer scanner rodar.
- O comando nunca falha por causa de cache/aliases/crosswalk corrompidos — esses degradam graciosamente (ver RF-009, RF-011, RF-012).
Dependências: RF-002, RF-004, RF-005, RF-008, RF-014, RF-015, RF-016, RF-017, RF-018, RF-019.
RF-002 — Inferência e seleção do tipo de target¶
| Campo | Conteúdo |
|---|---|
| ID | RF-002 |
| Nome | Inferência e seleção do tipo de target |
| Descrição | O tipo de target (image, repo, k8s) pode ser informado via --type ou inferido automaticamente. O tipo seleciona quais adapters são aplicáveis e como cada scanner é invocado. |
| Prioridade | Obrigatório |
Fluxo / Regras de mapeamento (resolveTargetType em scan.go)
Valor de --type |
Resultado |
|---|---|
image |
TargetImage |
repo / fs / dir |
TargetRepo |
k8s / kubernetes / manifests |
TargetK8s |
| (vazio) + caminho existente em disco | TargetRepo (inferido) |
| (vazio) + caminho inexistente | TargetImage (inferido — assume ref de imagem) |
| qualquer outro valor | erro invalid --type (exit 2) |
Entradas: --type (string, case-insensitive), <target> (usado em os.Stat para inferência).
Saídas: adapter.TargetType resolvido, propagado para adapter.Target{Type, Ref}.
Regras de negócio
- A inferência usa os.Stat(ref): existir no disco → repo; caso contrário → image.
- O tipo é case-insensitive (strings.ToLower).
- O tipo determina o Supports(target) de cada adapter (RF-004) e os argumentos de CLI de cada scanner (RF-008).
Dependências: RF-001. Consumido por RF-004 e RF-008.
RF-003 — Listar scanners registrados (list-scanners)¶
| Campo | Conteúdo |
|---|---|
| ID | RF-003 |
| Nome | Listar scanners registrados (list-scanners) |
| Descrição | O usuário deve poder listar todos os adapters de scanner registrados e os tipos de finding que cada um cobre (capabilities). |
| Prioridade | Obrigatório |
Fluxo (newListScannersCmd em root.go): obtém adapter.Names(), ordena, e para cada nome imprime nome + lista de Capability.Type.
Entradas: nenhuma (sem argumentos nem flags próprias).
Saídas (stdout, uma linha por scanner, formato %-12s %v), exemplo conceitual:
| Scanner | Tipos cobertos (Capabilities) |
|---|---|
checkov |
[MISCONFIG] |
dockle |
[IMG_HARDENING] |
grype |
[VULN] |
kics |
[MISCONFIG] |
kubescape |
[K8S_POSTURE] |
trivy |
[VULN MISCONFIG SECRET] |
Regras de negócio
- A lista vem do registry populado em tempo de compilação pelos init() de cada adapter (adapter.Register).
- Saída ordenada alfabeticamente por nome (sort.Strings).
- Não verifica se o binário do scanner está instalado — lista capacidades declaradas, não disponibilidade em runtime (isso é função do RF-006 durante o scan).
Dependências: registro de adapters (adapter.Register, adapter.Names, adapter.Get, Capabilities).
RF-004 — Seleção de scanners¶
| Campo | Conteúdo |
|---|---|
| ID | RF-004 |
| Nome | Seleção de scanners |
| Descrição | Por padrão todos os adapters que suportam o target são executados; o usuário pode restringir a um subconjunto via --scanners (lista separada por vírgula). Nomes desconhecidos são avisados, não falham o scan. |
| Prioridade | Obrigatório |
Fluxo (splitScanners em scan.go + selectAdapters em orchestrator.go)
flowchart TD
A["--scanners vazio?"] -->|Sim| B["adapter.All() ordenado por nome"]
A -->|Não| C["para cada nome (lowercase, trim)"]
C --> D{"adapter.Get(name) existe?"}
D -->|Sim| E["adiciona à seleção"]
D -->|Não| F["acrescenta a 'unknown' → warning"]
B --> G["filtra por Supports(target) no runOne"]
E --> G
Entradas: --scanners (string CSV, ex.: trivy,grype), target.
Saídas: conjunto de adapters a executar; lista de nomes desconhecidos (para warning).
Regras de negócio
- --scanners vazio ⇒ todos os adapters registrados (adapter.All()), ordenados por nome.
- Nomes são normalizados para lowercase e trimmed; entradas vazias são descartadas.
- Nome inexistente ⇒ é colocado em unknown e emite warning unknown scanner %q ignored (known: ...) — não aborta o scan.
- Mesmo selecionado, um scanner que não suporta o tipo de target é marcado como skipped (ver RF-006).
Dependências: RF-002 (tipo de target), registro de adapters. Consumido por RF-005.
RF-005 — Execução paralela (fan-out) com timeout por scanner¶
| Campo | Conteúdo |
|---|---|
| ID | RF-005 |
| Nome | Execução paralela (fan-out) com timeout por scanner |
| Descrição | Os scanners selecionados são executados concorrentemente (uma goroutine por scanner), cada um com um timeout individual configurável. Os resultados são coletados e agregados ao final. |
| Prioridade | Obrigatório |
Fluxo (orchestrator.Run + runOne): para cada adapter selecionado é disparada uma goroutine; um sync.WaitGroup aguarda todas; um sync.Mutex protege a coleta de Runs e findings. Após wg.Wait(), os runs são ordenados por nome.
Entradas: adapters selecionados (RF-004), --timeout (mapeado para Options.PerScannerTime, default 5m), context.Background().
Saídas: []ScannerRun (status/duração/erro por scanner) + []model.Finding agregados.
Regras de negócio
- Cada scanner roda com context.WithTimeout(ctx, PerScannerTime) quando PerScannerTime > 0. Estouro do prazo ⇒ status timeout (RF-007).
- PerScannerTime == 0 ⇒ sem timeout adicional (só o do contexto raiz).
- A coleta concorrente é protegida por mutex; a ordem final de Runs é determinística (ordenada por nome) independentemente da ordem de término.
- runCmd trata exit code não-zero com saída em stdout como sucesso (vários scanners retornam não-zero ao encontrar achados); exit não-zero sem stdout vira erro com stderr anexado.
Dependências: RF-004, RF-006. Alimenta RF-008.
RF-006 — Probe de versão e disponibilidade do scanner¶
| Campo | Conteúdo |
|---|---|
| ID | RF-006 |
| Nome | Probe de versão e disponibilidade do scanner |
| Descrição | Antes de rodar cada scanner, o orquestrador executa um probe de versão com timeout dedicado (60s) para detectar binário ausente, lentidão/resource starvation e kill por OOM, classificando o resultado em status distintos. |
| Prioridade | Obrigatório |
Fluxo (runOne): Supports(target) → se não suporta, skipped; senão Version(verCtx) com context.WithTimeout(ctx, ProbeTime). O resultado do erro é classificado.
Entradas: adapter, target, Options.ProbeTime (default defaultProbeTime = 60s).
Saídas: versão do scanner (em sucesso) ou ScannerRun.Status = "unavailable" com mensagem de diagnóstico específica.
Regras de negócio — classificação do probe
| Condição | Status | Diagnóstico |
|---|---|---|
!Supports(target) |
skipped |
"does not support target" |
verCtx.Err() == DeadlineExceeded |
unavailable |
"version probe exceeded 60s — tool too slow / resource-starved" |
erro contém signal: killed |
unavailable |
"version probe killed — likely OOM; raise memory limit" |
| outro erro (binário ausente) | unavailable |
erro bruto ("not installed/available") |
- O probe é generoso (60s) por design: tools pesadas (ex.: checkov/Python) têm cold start lento, especialmente quando todos os scanners sobem em paralelo em runner com pouca memória. Um budget apertado marcaria um tool funcional como
unavailable. ProbeTime <= 0⇒ usadefaultProbeTime(60s).- Um scanner
unavailable/skippednão contribui com findings, mas aparece no relatório (RF-007).
Dependências: RF-002, RF-004. Consumido por RF-005, RF-007.
RF-007 — Status por scanner (transparência de execução)¶
| Campo | Conteúdo |
|---|---|
| ID | RF-007 |
| Nome | Status por scanner (transparência de execução) |
| Descrição | Cada execução de scanner é registrada com nome, versão, status, contagem de findings, duração e erro. Esse status é exibido no resumo e embutido no relatório, para que "0 findings" nunca seja confundido com "scan não rodou". |
| Prioridade | Obrigatório |
Estados possíveis (ScannerRun.Status):
| Status | Significado |
|---|---|
ran |
executou com sucesso; Findings reflete a contagem |
skipped |
não suporta o tipo de target |
unavailable |
binário ausente, lento (timeout de probe) ou killed (OOM) |
error |
falhou na execução (não foi timeout) |
timeout |
execução excedeu --timeout |
Entradas: resultados de cada runOne.
Saídas: Result.Runs []ScannerRun; resumo no stderr (RF-021); bloco scanners no SARIF (properties.scanners com name/status/version) e equivalente em JSON/XML.
Regras de negócio
- Princípio "0 findings is not proof of safety": o resumo sempre imprime a nota e os status por scanner; SARIF inclui scannerSummary em properties.
- Mensagens de erro são truncadas a 60 chars no resumo de console (a versão completa fica no relatório JSON/SARIF).
Dependências: RF-005, RF-006. Consumido por RF-016, RF-021.
RF-008 — Normalização canônica de achados¶
| Campo | Conteúdo |
|---|---|
| ID | RF-008 |
| Nome | Normalização canônica de achados |
| Descrição | Cada adapter invoca seu scanner nativo e traduz a saída para model.Finding, com tipo, severidade normalizada, identidade (CVE/PURL/RuleID/Resource/Location) e metadados. Nenhuma etapa posterior opera sobre JSON bruto de scanner. |
| Prioridade | Obrigatório |
Tipos canônicos (model.FindingType): VULN, MISCONFIG, SECRET, K8S_POSTURE, IMG_HARDENING.
Normalização de severidade (internal/severity): converge para CRITICAL > HIGH > MEDIUM > LOW > INFO > UNKNOWN.
| Função | Mapeamento |
|---|---|
FromCVSS |
≥9.0 CRIT · ≥7.0 HIGH · ≥4.0 MED · >0 LOW · 0 UNKNOWN |
FromLabel |
enums de Trivy/Grype/Checkov/KICS/Kubescape (ex.: ERROR/DANGER→HIGH, WARNING→MED, NEGLIGIBLE→INFO) |
FromDockle |
FATAL→HIGH · WARN→MED · INFO→LOW · PASS/SKIP/IGNORE→INFO |
Matriz de cobertura por adapter (de Supports/Capabilities/Run):
| Scanner | Targets suportados (Supports) |
Tipos produzidos (Capabilities) |
|---|---|---|
| trivy | image, repo, k8s | VULN, MISCONFIG, SECRET |
| grype | image, repo | VULN |
| checkov | repo, k8s | MISCONFIG |
| kics | repo, k8s | MISCONFIG |
| dockle | image | IMG_HARDENING |
| kubescape | k8s, repo | K8S_POSTURE |
Entradas: adapter.Target, contexto com timeout.
Saídas: []model.Finding com campos populados por tipo (VULN: VulnID/PURL/CVSS; MISCONFIG: RuleID/CanonicalControl/Resource/Location; etc.).
Regras de negócio
- Adapters nunca calculam CorrelationKey/Fingerprint — isso é centralizado no correlator (RF-014).
- Trivy emite ids AVD diretamente; normalizeAVD reconstrói o prefixo AVD- quando versões novas o omitem (ex.: AWS-0086 → AVD-AWS-0086).
- Confirmed é setado quando o achado tem fonte autoritativa (ex.: Trivy com DataSource), influenciando o consenso (RF-015).
- Cada adapter possui contract test contra fixtures em internal/adapter/testdata.
Dependências: RF-002, RF-005. Alimenta RF-009, RF-012, RF-014.
RF-009 — Resolução de aliases de vulnerabilidade¶
| Campo | Conteúdo |
|---|---|
| ID | RF-009 |
| Nome | Resolução de aliases de vulnerabilidade |
| Descrição | Para findings do tipo VULN, o identificador é resolvido para uma forma canônica (CVE preferido) usando uma cadeia em camadas: aliases locais do scanner → cache local → OSV.dev. Garante que GHSA-xxxx (Grype) e CVE-yyyy (Trivy) para o mesmo bug correlacionem em vez de dividir. |
| Prioridade | Obrigatório |
Fluxo da cadeia (chainResolver.Canonical)
flowchart TD
A["id + aliases do scanner"] --> B{"CVE já presente?"}
B -->|Sim| Z["retorna CVE (upper)"]
B -->|Não| C{"cache local tem id?"}
C -->|Sim| Y["retorna valor cacheado"]
C -->|Não| D{"OSV habilitado (online)?"}
D -->|Sim| E["OSV.Aliases(id) → preferCVE"]
D -->|Não| F["preferCVE(aliases locais)"]
E --> G["grava no cache + retorna"]
F --> G
Entradas: VulnID, Aliases, contexto; cliente OSV (quando online), *cache.Store.
Saídas: VulnID canônico (CVE > GHSA > primeiro não-vazio).
Regras de negócio
- Preferência: CVE > GHSA > primeiro id não-vazio (preferCVE).
- A cadeia nunca retorna erro — em qualquer falha (rede/HTTP), degrada para o melhor id disponível (DESIGN §7).
- OSV (api.osv.dev/v1/vulns/{id}) usa cliente com timeout 8s, MaxRetries=2 (backoff exponencial 200ms base); só 429/5xx/erro de rede são retryable.
- Resultados resolvidos são gravados no cache (RF-011) para idempotência entre re-scans de CI.
- Só VULN passa por alias; outros tipos usam crosswalk (RF-012).
Dependências: RF-008, RF-010 (offline), RF-011 (cache). Alimenta RF-014.
RF-010 — Modo offline (OSV desligado)¶
| Campo | Conteúdo |
|---|---|
| ID | RF-010 |
| Nome | Modo offline (OSV desligado) |
| Descrição | A flag --offline desabilita toda consulta de rede à OSV.dev; a resolução de aliases passa a usar apenas aliases locais do scanner e o cache. |
| Prioridade | Obrigatório |
Fluxo: em runScan, if !f.offline { osv = alias.NewOSVClient() }; passando nil ao alias.New, a Layer 3 (OSV) é pulada.
Entradas: --offline (bool, default false).
Saídas: resolver que opera só com Layers 1 e 2.
Regras de negócio
- --offline ⇒ nenhuma chamada HTTP é feita; aliases dependem só do que o scanner reportou + cache existente.
- O estado offline é logado: ... offline=%v.
- Recomendado em ambientes air-gapped e para builds reprodutíveis.
Dependências: RF-009.
RF-011 — Cache de aliases¶
| Campo | Conteúdo |
|---|---|
| ID | RF-011 |
| Nome | Cache de aliases |
| Descrição | Um armazenamento chave/valor persistente em arquivo JSON acelera e dá idempotência à resolução de aliases entre re-scans, sem dependência de banco de dados. |
| Prioridade | Recomendado |
Entradas: --cache (path; default os.UserCacheDir()/quorum/aliases.json, fallback .quorum-cache.json).
Saídas: arquivo JSON {id: canonical} atualizado a cada Put.
Regras de negócio
- Arquivo ausente/ilegível ⇒ cache vazio, nunca erro (otimização, não fonte de falha).
- Escrita atômica via *.tmp + os.Rename; falhas de flush são silenciosamente ignoradas.
- Seguro para uso concorrente intra-processo (sync.RWMutex).
- Não há TTL/expiração: entradas persistem (premissa — ver Premissas).
Dependências: RF-009.
RF-012 — Crosswalk rule → controle canônico¶
| Campo | Conteúdo |
|---|---|
| ID | RF-012 |
| Nome | Crosswalk rule → controle canônico |
| Descrição | Para findings MISCONFIG/K8S_POSTURE/IMG_HARDENING, o rule id nativo de cada scanner é mapeado para um controle canônico compartilhado (AVD, com fallback de categoria semântica), via arquivos YAML, para que misconfigs equivalentes de engines diferentes correlacionem. |
| Prioridade | Obrigatório |
Fluxo (crosswalk.Load + Correlator.resolveControl): carrega todos os *.yaml/*.yml de um diretório, indexa "scanner|ruleID" → Resolution{Control, Category, CWE, Title}. No enrich, se o finding já tem CanonicalControl (ex.: Trivy AVD), mantém; senão resolve pelo RuleID.
Entradas: --crosswalk (diretório; default ./crosswalk), Scanner, RuleID.
Saídas: CanonicalControl (+ Category/Title) preenchidos, ou Unmapped=true.
Regras de negócio
- Diretório ausente não é erro (os.IsNotExist) — a ferramenta roda sem crosswalk custom, e os findings ficam Unmapped.
- "Never guess a match" (DESIGN §6): sem mapeamento, o finding é isolado e marcado Unmapped=true; nunca é fundido por adivinhação.
- Mapeamento é case-insensitive no nome do scanner; a chave é lower(scanner)|trim(ruleID).
- Finding já canônico (CanonicalControl != "") não é re-resolvido.
Dependências: RF-008, RF-013 (fallback de diretório). Alimenta RF-014.
RF-013 — Fallback automático do crosswalk bundled¶
| Campo | Conteúdo |
|---|---|
| ID | RF-013 |
| Nome | Fallback automático do crosswalk bundled |
| Descrição | Quando --crosswalk não é informado explicitamente e o ./crosswalk padrão não existe, o sistema usa o crosswalk embarcado na imagem Docker em /opt/quorum/crosswalk, evitando carregar 0 regras silenciosamente em docker run. |
| Prioridade | Recomendado |
Fluxo (resolveCrosswalkDir)
| Condição | Diretório usado |
|---|---|
--crosswalk passado explicitamente (Changed) |
valor literal informado |
default ./crosswalk existe |
./crosswalk |
default ausente e /opt/quorum/crosswalk existe |
/opt/quorum/crosswalk (bundled) |
| nenhum existe | retorna o default (carrega 0 regras) |
Entradas: --crosswalk, flag Changed do cobra, existência dos diretórios (os.Stat).
Saídas: diretório de crosswalk efetivo (logado: crosswalk=%d rules (%s)).
Regras de negócio
- O fallback só ocorre quando o usuário não mudou --crosswalk (respeita escolha explícita verbatim).
- Resolve o problema de docker run … scan . a partir de um workdir arbitrário onde ./crosswalk não existe.
Dependências: RF-012.
RF-014 — Correlação por correlationKey e fingerprint¶
| Campo | Conteúdo |
|---|---|
| ID | RF-014 |
| Nome | Correlação por correlationKey e fingerprint |
| Descrição | Cada finding recebe uma correlationKey determinística específica por tipo e um Fingerprint = sha256(correlationKey). A chave é a identidade usada para agrupar findings equivalentes no consenso e para supressão/portabilidade no SARIF. |
| Prioridade | Obrigatório |
Estratégias de chave por tipo (BuildKey):
| Tipo | Estrutura da correlationKey |
|---|---|
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) |
| outros | OTHER\|scanner\|title |
Entradas: model.Finding enriquecido (após alias/crosswalk).
Saídas: f.CorrelationKey, f.Fingerprint.
Regras de negócio
- controlKey prefere CanonicalControl; quando Unmapped, usa UNMAPPED:scanner:RuleID para nunca fundir um achado não-mapeado com outro diferente (false split > false merge).
- MISCONFIG chaveia por basename do arquivo + tipo de recurso + controle (engines reportam paths/relatividade diferentes). Trade-off documentado: dois recursos distintos do mesmo tipo, mesmo controle e mesmo arquivo podem over-merge — aceitável vs. nunca correlacionar.
- A chave é uma função pura e determinística dos dados normalizados.
- O Fingerprint é exposto no SARIF como partialFingerprints["quorum/v1"] (RF-016) e aceito pelo baseline (RF-017).
- Sem correlator, o orquestrador ainda aplica BuildKey/Fingerprint para permitir o agrupamento.
Dependências: RF-009, RF-012. Alimenta RF-015, RF-016, RF-017.
RF-015 — Scoring de consenso e agregação¶
| Campo | Conteúdo |
|---|---|
| ID | RF-015 |
| Nome | Scoring de consenso e agregação |
| Descrição | Findings com a mesma correlationKey são agrupados em MergedFinding, com severidade agregada (máximo), lista de scanners que detectaram, detectionCount e um confidence (0..1) que pondera contagem, diversidade de engines, severidade e confirmação autoritativa. |
| Prioridade | Obrigatório |
Fórmula de confiança (consensus.confidence, DESIGN §9):
| Fator | Cálculo |
|---|---|
count (0.35) |
ln(1+nScanners)/ln(5) — retornos decrescentes |
diversity (0.25) |
famílias distintas de engine: 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 se algum membro Confirmed ou CVE com CVSS>0; senão 0 |
Famílias de engine (scannerCategory): trivy/grype=sca, checkov/kics=iac, kubescape/polaris=k8s, dockle=hardening.
Entradas: []model.Finding com CorrelationKey.
Saídas: []model.MergedFinding (Title, Severity máx, DetectedBy, DetectionCount, Confidence, Unmapped, Members, Fingerprint), ordenado por (severidade ↓, confidence ↓, detectionCount ↓, key ↑).
Regras de negócio
- DetectionCount = número de scanners distintos (não de findings brutos).
- Diversidade de engines pesa mais que repetição da mesma família: 2 engines diferentes valem mais que 2 do mesmo grupo.
- Unmapped propaga se qualquer membro for unmapped.
- Severidade agregada é o máximo entre os membros (severity.Max).
- Ordenação estável e determinística para relatórios reproduzíveis.
Dependências: RF-014. Alimenta RF-016, RF-017, RF-018, RF-019.
RF-016 — Emissão de relatório SARIF/JSON/XML¶
| Campo | Conteúdo |
|---|---|
| ID | RF-016 |
| Nome | Emissão de relatório SARIF/JSON/XML |
| Descrição | O resultado consolidado é serializado em um de três formatos: SARIF 2.1.0 (primário), JSON ou XML. O SARIF carrega fingerprints portáveis, propriedades de consenso e o resumo de scanners. |
| Prioridade | Obrigatório |
Fluxo (report.ParseFormat + report.Write): valida --format e despacha para writeSARIF/writeJSON/writeXML.
Entradas: --format/-f (sarif|json|xml, default sarif), *orchestrator.Result.
Saídas — SARIF (primário):
| Elemento SARIF | Origem |
|---|---|
runs[].tool.driver |
nome quorum, version (build-time), rules por finding |
results[].ruleId |
CVE (VULN) / CanonicalControl / RuleID / correlationKey |
results[].level |
CRIT/HIGH→error, MED→warning, demais→note |
results[].partialFingerprints["quorum/v1"] |
Fingerprint (sha256) |
results[].properties |
detectedBy, detectionCount, confidence, severity, correlationKey, unmapped |
runs[].properties.scanners |
resumo name/status/version (RF-007) |
Regras de negócio
- --format inválido ⇒ erro unknown format (exit 2), antes de rodar scanners.
- SARIF usa schema 2.1.0; SetEscapeHTML(false) para preservar caracteres em URIs.
- Regras SARIF são deduplicadas por ruleId e ordenadas.
- JSON inclui detalhe bruto canônico (Result.Findings); XML é a forma alternativa estruturada.
Dependências: RF-015, RF-007. Consumido por RF-020.
RF-017 — Baseline de supressão (.quorumignore)¶
| Campo | Conteúdo |
|---|---|
| ID | RF-017 |
| Nome | Baseline de supressão (.quorumignore) |
| Descrição | Um arquivo de baseline lista findings conhecidos/aceitos a suprimir, por Fingerprint ou correlationKey (um por linha). Findings suprimidos são removidos do relatório e do gating, mas a supressão é sempre logada. |
| Prioridade | Obrigatório |
Fluxo (filter.LoadBaseline + Baseline.Has + filter.Apply): carrega ids (lowercase), ignora linhas em branco e comentários # (inclusive trailing # nota); um MergedFinding casa se seu fingerprint OU correlationKey está no conjunto.
Entradas: --baseline (path; default .quorumignore), MergedFinding.
Saídas: lista filtrada (Result.Kept) + contador SuppressedBaseline.
Regras de negócio
- Arquivo ausente: se --baseline não foi mudado, baseline vazio (silencioso); se foi mudado explicitamente e não existe ⇒ erro baseline file not found (exit 2).
- Casa por Fingerprint ou correlationKey — o usuário pode copiar qualquer um do relatório.
- Comparação case-insensitive.
- Supressões são logadas (filtered: %d suppressed by baseline ...) — "a suppressed finding is still a finding" (DESIGN §14).
Dependências: RF-014, RF-015.
RF-018 — Filtro de severidade mínima (--min-severity)¶
| Campo | Conteúdo |
|---|---|
| ID | RF-018 |
| Nome | Filtro de severidade mínima (--min-severity) |
| Descrição | Findings abaixo da severidade mínima informada são removidos do relatório e do gating, reduzindo ruído em CI. |
| Prioridade | Obrigatório |
Fluxo (severity.Parse + filter.Apply): findings com severity.AtLeast(m.Severity, minSeverity) == false são descartados (quando minSeverity != UNKNOWN).
Entradas: --min-severity (critical|high|medium|low).
Saídas: lista filtrada + contador SuppressedSeverity.
Regras de negócio
- Valor inválido ⇒ erro invalid --min-severity (exit 2).
- Ausente ⇒ SevUnknown ⇒ filtro desabilitado (mantém tudo).
- Aplicado após o consenso e antes do gating (RF-019), então afeta o exit code.
- Contagem de descartes é logada junto com a supressão por baseline.
Dependências: RF-015, internal/severity.
RF-019 — Gate de falha (--fail-on) e exit codes¶
| Campo | Conteúdo |
|---|---|
| ID | RF-019 |
| Nome | Gate de falha (--fail-on) e exit codes |
| Descrição | Quando --fail-on é informado, o processo termina com exit code 1 se algum finding (após filtros) atingir ou exceder a severidade limite. Os exit codes são o contrato de integração com CI/CD. |
| Prioridade | Obrigatório |
Contrato de exit codes
| Exit | Significado |
|---|---|
0 |
OK — sucesso, ou nenhum finding atingiu --fail-on |
1 |
Gate disparou — algum finding ≥ --fail-on |
2 |
Erro de uso/runtime (flag inválida, baseline ausente explícito, erro de orquestração) |
Fluxo: após emitir relatório e resumo, calcula worstSeverity(res); se severity.AtLeast(worst, failThreshold) ⇒ os.Exit(1).
Entradas: --fail-on (critical|high|medium|low).
Saídas: exit code; log gate: found %s finding >= --fail-on %s → exit 1.
Regras de negócio
- Valor inválido ⇒ erro invalid --fail-on (exit 2).
- O gate considera apenas findings que sobreviveram a baseline (RF-017) e min-severity (RF-018).
- Sem --fail-on, o scan nunca retorna 1 por causa de findings (exit 0), só 2 em erro.
- Exit 2 é produzido pelo main.go quando Execute() retorna erro.
Dependências: RF-015, RF-017, RF-018.
RF-020 — Saída em arquivo ou stdout¶
| Campo | Conteúdo |
|---|---|
| ID | RF-020 |
| Nome | Saída em arquivo ou stdout |
| Descrição | O relatório é escrito em stdout por padrão ou em um arquivo via --output/-o; diretórios intermediários do caminho são criados automaticamente. |
| Prioridade | Obrigatório |
Fluxo (emit): renderiza para buffer; se --output vazio ⇒ cmd.OutOrStdout(); senão os.MkdirAll(dir) + os.WriteFile (0644).
Entradas: --output/-o (path; default stdout).
Saídas: relatório no destino escolhido.
Regras de negócio
- Diretório do arquivo é criado com os.MkdirAll(..., 0o755) se não existir.
- stdout permite piping; o resumo e os logs vão para stderr (RF-021), nunca poluindo a saída do relatório.
Dependências: RF-016.
RF-021 — Resumo de console e logs de progresso¶
| Campo | Conteúdo |
|---|---|
| ID | RF-021 |
| Nome | Resumo de console e logs de progresso |
| Descrição | Durante e após o scan, o Quorum imprime no stderr logs de progresso ([quorum] ...) e um resumo final com status por scanner, contagens por severidade, findings multi-detectados, tempo decorrido e a nota de segurança. --quiet/-q suprime essa saída. |
| Prioridade | Recomendado |
Entradas: --quiet/-q (bool), *orchestrator.Result.
Saídas (stderr): linhas [quorum] ... de progresso e bloco ── quorum summary ── com:
- status/versão/findings por scanner;
- total após consenso e quantos multi-detectados (DetectionCount > 1);
- contagens CRIT/HIGH/MED/LOW/INFO;
- tempo decorrido;
- nota: "0 findings is not proof of safety — see scanner statuses above."
Regras de negócio
- --quiet suprime tanto os logs de progresso quanto o resumo (mas não o relatório de RF-020 nem o exit code).
- Todos os logs vão para stderr, mantendo stdout limpo para o relatório.
Dependências: RF-007, RF-015.
RF-022 — Versão da ferramenta¶
| Campo | Conteúdo |
|---|---|
| ID | RF-022 |
| Nome | Versão da ferramenta |
| Descrição | O Quorum reporta sua versão (flag --version do cobra), sobrescrita em build-time via -ldflags "-X main.version=...", também estampada no driver SARIF. |
| Prioridade | Recomendado |
Entradas: --version (cobra), variável version (build-time).
Saídas: string de versão no stdout; Version propagado para report.Version (driver SARIF e namespace quorum/v1).
Regras de negócio
- Default em código é 0.1.0; releases sobrescrevem via GoReleaser/ldflags (a release atual é v0.2.3).
Dependências: nenhuma.
Rastreabilidade: RF → componente de código¶
| RF | Arquivos-chave |
|---|---|
| RF-001, RF-020 | cmd/quorum/scan.go (runScan, emit) |
| RF-002 | cmd/quorum/scan.go (resolveTargetType) |
| RF-003, RF-022 | cmd/quorum/root.go, cmd/quorum/main.go |
| RF-004, RF-005, RF-006, RF-007 | internal/orchestrator/orchestrator.go |
| RF-008 | internal/adapter/*.go, internal/model/model.go, internal/severity/severity.go, internal/purl |
| RF-009, RF-010 | internal/alias/resolver.go, internal/alias/osv.go |
| RF-011 | internal/cache/store.go |
| RF-012, RF-013 | internal/crosswalk/crosswalk.go, internal/correlate/correlate.go, cmd/quorum/scan.go |
| RF-014 | internal/correlate/key.go, internal/correlate/correlate.go |
| RF-015 | internal/consensus/consensus.go |
| RF-016 | internal/report/{report,sarif,json,xml}.go |
| RF-017, RF-018 | internal/filter/filter.go |
| RF-019 | cmd/quorum/scan.go (worstSeverity, gate), cmd/quorum/main.go |
| RF-021 | cmd/quorum/scan.go (printSummary, logf) |
Itens fora de escopo (N/A)¶
O template enterprise prevê capacidades que não existem no Quorum por decisão arquitetural ("CLI/Docker only"). Estas são declaradas N/A com justificativa:
| Capacidade | Status | Justificativa técnica |
|---|---|---|
| Frontend web / painel | N/A | O produto é panel-less e CI/CD-first (root.go: "No panel, no daemon"). A saída é relatório (SARIF/JSON/XML) + exit code. |
| Banco de dados relacional | N/A | Persistência limita-se ao cache JSON de aliases (internal/cache), explicitamente "without pulling in a CGO database". |
| API REST / HTTP server | N/A | Não há servidor; a única chamada de rede de saída é a OSV.dev para aliases (RF-009). |
| Autenticação / contas de usuário | N/A | Sem multiusuário; a ferramenta roda no contexto do processo CI/desenvolvedor. |
| IA / LLM | N/A | A correlação/consenso é determinística (chaves + fórmula), sem modelos de ML. |
| Orquestração de cloud/K8s em runtime | N/A | k8s é um tipo de target (manifests/cluster a escanear), não um runtime do próprio Quorum. |
Propostas futuras (claramente separadas do as-is): TTL/expiração no cache de aliases; exportador de baseline (gerar
.quorumignorea partir de um relatório); formato de saída adicional (ex.: SARIF + sumário Markdown). Nada disso está implementado em v0.2.3.
Cross-links¶
- Modelo de dados, matriz de correlação (§6), matemática do consenso (§9) e status de scanner (§14): ver
DESIGN.mdna raiz do repositório. - Documentos relacionados desta suíte: 01-visao-geral · 03-requisitos-nao-funcionais · 04-arquitetura (criar conforme o índice da documentação).
Premissas¶
- Versão de referência: o documento descreve o comportamento as-is da v0.2.3; a constante
versionemroot.goainda é0.1.0por ser sobrescrita em build-time (RF-022), o que assumimos ser intencional. - Capabilities vs. disponibilidade:
list-scanners(RF-003) reporta capacidades declaradas nos adapters, não disponibilidade real do binário — a disponibilidade só é verificada durante oscan(RF-006). polarisno mapa de consenso:scannerCategoryincluipolaris(famíliak8s), mas não há adapterpolarisregistrado no código atual; assumimos que é uma entrada preparatória/futura e não a listamos como scanner ativo (RF-008/RF-015).- Cobertura de targets por adapter: a matriz de RF-008 reflete os métodos
Supports/Capabilitieslidos diretamente do código; eventuais divergências entreSupports(o que roda) eCapabilities(o quelist-scannersmostra) foram preservadas como estão (ex.: kubescapeSupportsrepo+k8s mas declara capability só de k8s). - Cache sem expiração: o
internal/cachenão implementa TTL; assume-se que o usuário gerencia/limpa o arquivo manualmente quando necessário (listado como proposta futura). - Nomes de documentos cruzados (
01-...,03-...,04-...) seguem a convenção numérica da suíte de docs; os arquivos podem ainda não existir no repositório. - OSV.dev como única dependência de rede: assume-se que nenhuma outra etapa do pipeline faz I/O de rede;
--offline(RF-010) é suficiente para operação air-gapped. --timeoutmapeia para timeout por scanner (PerScannerTime), não para o scan inteiro; oProbeTime(60s) é separado e não é exposto como flag de CLI na v0.2.3.