Pular para conteúdo

AUR-549 — SBOM CycloneDX 1.6 gerado com Trivy

Status

Implementado em internal/sbom (gerador, validador, pipeline, configuração) e na wiring cmd/aurumcode/aur549.go (subcomando aurumcode sbom, aditivo a review/fix). Prova de comportamento: cmd/aurumcode/aur549_test.go:: TestAUR549*; aceite: tests/acceptance/AUR-549.sh.

Problema

A adoção corporativa (RFC do dono, fora deste repositório público) pede um SBOM OWASP CycloneDX gerado com Trivy para o repositório e, quando houver, para uma imagem de container — nunca um SBOM vazio ou de outro formato/versão aceito silenciosamente. Esta é a base que o AUR-550 (envio ao Dependency-Track) consome; este card cobre só a geração e a validação.

v3: spec_version é um MÍNIMO, não um valor exato

A v2 (33253a0) validava specVersion por igualdade exata contra o configurado ("1.6"). Na revisão, o Trivy fixado por digest em .board/bootstrap/locks/scanners.yml (vuln_scanner_image, 0.73.0) emite CycloneDX 1.7, e não existe flag do Trivy para pedir uma versão de especificação mais antiga (Trivy CHANGELOG da 0.71.0, PR #10715) — ou seja, a igualdade exata reprovava TODO SBOM real que esse binário gerasse, não só uma entrada malformada.

A correção (internal/sbom/validator.go, specVersionAtLeast): aceitar quando a saída tem a MESMA major do configurado e minor ≥ ao configurado. Uma major diferente ("2.0" contra um configurado "1.6") continua recusada mesmo sendo numericamente "maior" — mudança de major é incompatibilidade, não avanço. Ambos os lados (a saída do Trivy e o spec_version configurado) são exigidos no formato estrito major.minor; internal/config.SBOMGeneratorConfig.Validate já recusa um spec_version fora desse formato na CARGA da configuração, antes de qualquer chamada ao Trivy — uma falha de "o que isso quer dizer" nunca vira uma falha de "o Trivy devolveu algo estranho" mais tarde.

Downstream: o OWASP Dependency-Track só ingere CycloneDX 1.7 a partir da versão 5.1.0 do servidor (ou do backport 4.14.4) — o AUR-550 (ou quem configurar o servidor) precisa dessa versão mínima.

Decisão de arquitetura (v2): quality_gates dentro de internal/config

A v1 deste card não listava internal/config em seus paths e por isso lia sua própria seção de um arquivo separado, .aurumcode/quality_gates.yml. A v2 do card corrigiu esse defeito: internal/config passou a constar nos paths, e a diretriz do dono é uma só configuração simples — um .aurumcode/config.yml (mais Markdown) — nunca um segundo arquivo yml.

quality_gates agora é um campo de internal/config.Config (QualityGates QualityGatesConfig, qualitygates.go), lido do MESMO .aurumcode/config.yml que review/rules/ignore/gate/exceptions já usam. QualityGatesConfig é a seção COMPARTILHADA por três cards de adoção corporativa — Sast *SastConfig (AUR-548), SsorDtrack *SsorDtrackConfig (este card, campo SBOMGenerator, mais o AUR-550) e SupplyChain *SupplyChainConfig (reservado) — cada um um PONTEIRO: nil significa "nunca declarado", distinto de "declarado, mesmo vazio". Isso é o que permite a ApplyCentralPolicy (central.go) decidir a precedência POR SEÇÃO: diferente de Gate/Exceptions (que uma política sempre decide por completo, declarados ou não), aqui uma política que só declara sast não arrasta ssor_dtrack com ela — o repositório continua livre para configurar ssor_dtrack.sbom_generator por conta própria, porque a política nunca opinou sobre essa chave especificamente. Só quando a política declara ssor_dtrack (mesmo vazio) é que ela vale sozinha, com a seção do repositório descartada e um aviso (internal/config/central_test.go/qualitygates_test.go prova os três casos: política muda, política não fala do assunto, política fala de outra seção).

Como Config agora TEM o campo quality_gates, o decode estrito de LoadCentralPolicy (yaml.Decoder.KnownFields(true)) reconhece a chave normalmente — nenhuma política que declare quality_gates quebra mais a decodificação, ao contrário da v1.

aurumcode sbom lê Gate E quality_gates.ssor_dtrack.sbom_generator numa ÚNICA resolução efetiva (loadEffectiveConfig, cmd/aurumcode/aur549.go: config.Load e, com --politica, ValidatePolicyOutsideReviewedTree + LoadCentralPolicy + ApplyCentralPolicy) — a mesma sequência que runReview/runPRReview já usam, agora também compartilhando a mesma leitura de arquivo.

Comportamento

aurumcode sbom [--repo dir] [--imagem referencia] [--politica dir] [--trivy-bin caminho]:

  1. Resolve o Config efetivo (loadEffectiveConfig) e, dentro dele, QualityGates.SsorDtrack.SBOMGenerator. Sem seção declarada (nem no repositório, nem na política quando há uma): não faz nada, sai 0 (Documentation and compatibility do card).
  2. Valida a seção (SBOMGeneratorConfig.Validate, internal/config/ qualitygates.go): tool só aceita trivy, format só aceita cyclonedx; qualquer outro valor é erro de configuração (exit 2), antes de qualquer chamada externa.
  3. Resolve output_file contra o repositório, recusando qualquer caminho que escape dele — absoluto, .., OU um link simbólico que resolva para fora da raiz (sbom.ResolveOutputPath, que roda EvalSymlinks sobre o prefixo já existente do caminho).
  4. Roda trivy fs --format cyclonedx --output <tmp> <repo> (sbom.TrivyGenerator.GenerateFilesystem) para um arquivo temporário no mesmo diretório do destino, valida esse arquivo (sbom.Validate: JSON, bomFormat=CycloneDX, specVersion igual ao configurado) e só então o renomeia para output_file (sbom.GenerateAndValidate) — AC-001. Uma falha em qualquer etapa (trivy ausente/erro, saída vazia, saída que não valida) nunca deixa um arquivo no caminho final — AC-002, AC-003.
  5. Com --imagem/--image, repete o mesmo pipeline com trivy image --format cyclonedx --output <tmp> <imagem>, escrevendo em um segundo arquivo derivado deterministicamente (sbom.ImageOutputPath: <nome>-image<ext>) — AC-004.
  6. Qualquer falha de geração/validação é reportada e roteada pelo MESMO gate AUR-519 que a revisão usa (evaluateGate, cmd/aurumcode/policygate.go), com o motivo próprio sbom_generation_failure (gateReasonSBOMFailure): gate.inconclusive: block, ou nenhum gate declarado (este comando é novo — não há comportamento legado a preservar, e o Outcome do card exige "nunca SBOM vazio aceito") → exitQualityNotReviewed; gate.inconclusive: warn → publica o motivo em stderr e sai 0, nunca bloqueando.

Testes

cmd/aurumcode/aur549_test.go usa um executável trivy falso (um script sh) num diretório temporário colocado em PATH — nunca a rede, nunca o binário real:

  • TestAUR549TrivyGeneratesValidatedSBOM (AC-001): trivy falso que grava CycloneDX 1.6 válido produz o arquivo configurado, com o argv (fs, --format cyclonedx, --output, o repositório) registrado e verificado.
  • TestAUR549NonCycloneDXOutputRejected (AC-002): quatro subtestes — specVersion errado, bomFormat errado, saída que não é JSON, saída vazia — todos recusados, com o arquivo final confirmado ausente em cada caso.
  • TestAUR549MissingTrivyIsInconclusive (AC-003): trivy ausente do PATH sob nenhum gate/block (falha fechado) e sob warn (publica o motivo, sai 0).
  • TestAUR549ImageProducesSeparateFile (AC-004): com --imagem, duas invocações de trivy (fs e image) produzem dois arquivos distintos.

tests/acceptance/AUR-549.sh roda os quatro testes acima dentro de uma árvore semeada (go.mod go.sum cmd internal pkg) sem rede, e aplica AC-002-MUT-001: substitui a única linha de decisão em internal/sbom/validator.go (formatAndVersionOK, a comparação de bomFormat/specVersion) por return true e confirma que o subteste badversion de AC-002 vira RED — provando que o teste realmente depende da validação, e não passaria mesmo aceitando qualquer saída.

Trivy como processo externo

internal/sbom.TrivyGenerator nunca embute um binário: resolve trivy via PATH, ou via --trivy-bin apontando a outro executável (usado pelos testes para apontar ao script falso, e, em CI, para o wrapper abaixo).

.github/workflows/review.yml ganhou a etapa "Generate SBOM (Trivy, AUR-549)", que roda SEMPRE (revisão desta rodada: um grep de texto que só decidia a mesma coisa que aurumcode sbom já decide, com o risco real de discordar dela, foi removido) — o custo de rodar sem nada configurado é só extrair o binário e gerar o wrapper; o docker run do Trivy em si só acontece quando aurumcode sbom decide gerar. A etapa extrai o binário aurumcode já compilado na imagem aurumcode-review (docker create/ docker cp — nenhum segundo build), gera (via heredoc com delimitador citado, sem interpolação, mais um sed para injetar o valor pinado) um wrapper trivy-docker.sh que reproduz o argv que TrivyGenerator já constrói (fs --format <f> --output <p> <raiz> / image --format <f> --output <p> <imagem>) dentro de docker run contra vuln_scanner_image (hoje docker.io/aquasec/trivy@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c, de .board/bootstrap/locks/scanners.yml), e então chama aurumcode sbom --repo . --trivy-bin trivy-docker.sh DIRETO NO RUNNER (nunca de dentro de outro container): rodar docker run de dentro de um container através de um socket do Docker montado criaria um container IRMÃO que precisa de caminhos do HOST para seus próprios -v, e nada dentro daquele container sabe genericamente o caminho do host atrás do seu próprio /github/workspace — por isso esta etapa evita se aninhar desde o início, chamando docker run apenas do processo do job no runner.

Revisão desta rodada, segunda correção: o wrapper NÃO monta mais .aurumcode-target (a árvore checked-out) como gravável dentro do container do Trivy. A saída do Trivy dentro do container sempre aponta para um diretório descartável criado em $RUNNER_TEMP (mktemp -d), montado leitura-e-escrita; o próprio wrapper, já de volta no runner (nunca dentro do container), move (mv) o arquivo pronto para o caminho que aurumcode sbom pediu, só depois que o docker run termina, e limpa o diretório descartável (trap ... EXIT). A montagem da árvore escaneada (trivy fs, modo fs) continua só leitura, inalterada.

A sintaxe YAML+heredoc foi verificada localmente: python3 -c "import yaml..." confirma o parse do review.yml e extrai a string do run: inalterada; bash -n confirma a sintaxe do script extraído e do wrapper gerado; uma simulação com um docker falso no PATH confirma que o wrapper reconstrói exatamente os dois docker run esperados (fs e image) com os mounts corretos. NÃO foi validado contra uma execução real do GitHub Actions (sem acesso a CI neste ambiente) — um reviewer deveria considerar isso antes do primeiro merge que dependa desta etapa.

action.yml (a action standalone, using: docker) NÃO roda aurumcode sbom: seu próprio container não tem como traduzir seu /github/workspace para o caminho equivalente no HOST, que um docker run aninhado através do socket do Docker precisaria. Documentado como comentário no próprio action.yml.

Lacunas conhecidas (reportadas, não resolvidas por este card)

  • A etapa de CI em review.yml não foi exercitada contra uma execução real do GitHub Actions (ver seção acima) — validada por inspeção, YAML/bash sintático e uma simulação local com docker/aurumcode falsos.
  • action.yml continua sem gerar SBOM (limitação estrutural documentada, não um lapso) — um card futuro teria que resolver a tradução de caminho host/container para viabilizar isso de dentro de uma action standalone.
  • Dockerfile não foi alterado: a geração de SBOM em CI não depende de nada instalado na imagem do produto, só do docker do próprio runner.