Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b1adb22
feat(vex): discover VEX documents as OCI artifacts
mvanhorn Jun 7, 2026
460d4c2
chore(vex): run go mod tidy
DmitriyLewen Jun 24, 2026
429bf89
fix(vex): do not derive transport from repository_url scheme
DmitriyLewen Jun 24, 2026
41eb630
refactor(vex): reuse pkg/attestation for VEX attestation decoding
DmitriyLewen Jun 24, 2026
48c42f3
fix(vex): iterate legacy .att layers instead of requiring a single layer
DmitriyLewen Jun 24, 2026
58c7b1e
refactor(vex): simplify registry error-code helpers
DmitriyLewen Jun 24, 2026
e88d674
docs(vex): add TODO to pass RegistryOptions for registry auth
DmitriyLewen Jun 24, 2026
30a359c
refactor(vex): reuse media-type constants from pkg/oci
DmitriyLewen Jun 24, 2026
3ddc7f8
test(vex): consolidate OCI VEX discovery tests into a table
DmitriyLewen Jun 24, 2026
d7235c2
docs(vex): document isOpenVEXPredicateType matching
DmitriyLewen Jun 24, 2026
b315ab9
Merge branch 'main' of github.com:aquasecurity/trivy into fix/10750-v…
DmitriyLewen Jun 24, 2026
a715af0
fix(vex): reject legacy .att with too many layers
DmitriyLewen Jun 24, 2026
fe41eb8
test(vex): cover registry error helpers and resolveDigest/legacy edge…
DmitriyLewen Jun 24, 2026
57e7904
test(vex): satisfy linters in oci_test.go
DmitriyLewen Jun 25, 2026
cae73b5
refactor(vex): return early on a nil package URL
DmitriyLewen Jun 29, 2026
71594f8
refactor(vex): pass context.Context through OCI VEX retrieval
DmitriyLewen Jun 29, 2026
e6c2804
refactor(vex): use a set for supported VEX artifact types
DmitriyLewen Jun 29, 2026
b2af079
fix(vex): limit attestation layer size to prevent decompression bombs
DmitriyLewen Jun 29, 2026
26c3e5c
refactor(vex): extract OCI VEX discovery into pkg/vex/oci with a publ…
DmitriyLewen Jun 30, 2026
1513021
fix(vex): skip non-OpenVEX OCI referrers instead of failing the scan
DmitriyLewen Jun 30, 2026
5c54651
test(vex): cover digest-version resolution and versioned predicate type
DmitriyLewen Jun 30, 2026
e85db19
test: extend registrytest with basic-auth server and image/attestatio…
DmitriyLewen Jul 1, 2026
9fa0261
refactor(vex): rename isReferrersUnsupported to isReferrersUnavailable
DmitriyLewen Jul 1, 2026
8c247b2
test(vex): make OCI VEX tests black-box and reuse registrytest
DmitriyLewen Jul 1, 2026
5c35030
test(vex): cover the OCI registry path in TestFilter
DmitriyLewen Jul 1, 2026
b35ea62
docs(vex): document supported OCI VEX layouts
DmitriyLewen Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion go.mod
Comment thread
DmitriyLewen marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ require (
github.com/open-policy-agent/opa v1.17.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/openvex/discovery v0.1.1-0.20240802171711-7c54efc57553
github.com/openvex/go-vex v0.2.7
github.com/owenrumney/go-sarif/v2 v2.3.3
github.com/package-url/packageurl-go v0.1.6
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -914,8 +914,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/openvex/discovery v0.1.1-0.20240802171711-7c54efc57553 h1:c4u0GIH0w2Q57Pm2Oldrq6EiHFnLCCnRs98A+ggj/YQ=
github.com/openvex/discovery v0.1.1-0.20240802171711-7c54efc57553/go.mod h1:z4b//Qi7p7zcM/c41ogeTy+/nqfMbbeYnfZ+EMCTCD0=
github.com/openvex/go-vex v0.2.7 h1:/pN3bqvS4QOc6WkkL0hbKzJuAtsUD9vmvk9IZkzD3Zc=
github.com/openvex/go-vex v0.2.7/go.mod h1:ZyQC3NXl9jjS53JOpBG3LAUXySkW8IlJ/GIhsnf5D54=
github.com/owenrumney/go-sarif v1.1.1/go.mod h1:dNDiPlF04ESR/6fHlPyq7gHKmrM0sHUvAGjsoh8ZH0U=
Expand Down
311 changes: 303 additions & 8 deletions pkg/vex/oci.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
package vex

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/url"
"slices"
"strings"

"github.com/openvex/discovery/pkg/discovery"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/hashicorp/go-multierror"
"github.com/in-toto/in-toto-golang/in_toto"
openvex "github.com/openvex/go-vex/pkg/vex"
"github.com/package-url/packageurl-go"
"github.com/secure-systems-lab/go-securesystemslib/dsse"
"golang.org/x/xerrors"

ftypes "github.com/aquasecurity/trivy/pkg/fanal/types"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/purl"
"github.com/aquasecurity/trivy/pkg/remote"
"github.com/aquasecurity/trivy/pkg/types"
)

const (
sigstoreBundleMediaType = "application/vnd.dev.sigstore.bundle.v0.3+json"
dsseEnvelopeMediaType = "application/vnd.dsse.envelope.v1+json"
)

var supportedVEXArtifactTypes = []string{
Comment thread
nikpivkin marked this conversation as resolved.
Outdated
sigstoreBundleMediaType,
dsseEnvelopeMediaType,
}

type OCI struct{}

func NewOCI(report *types.Report) (*OpenVEX, error) {
Expand All @@ -33,22 +58,292 @@ func NewOCI(report *types.Report) (*OpenVEX, error) {
}

func RetrieveVEXAttestation(p *purl.PackageURL) (*OpenVEX, error) {
return retrieveVEXAttestation(context.Background(), p, ftypes.RegistryOptions{})
Comment thread
DmitriyLewen marked this conversation as resolved.
Outdated
Comment thread
nikpivkin marked this conversation as resolved.
Outdated
}

func retrieveVEXAttestation(ctx context.Context, p *purl.PackageURL, registryOptions ftypes.RegistryOptions) (*OpenVEX, error) {
var purlString string
if p != nil {
Comment thread
nikpivkin marked this conversation as resolved.
Outdated
purlString = p.String()
}
logger := log.WithPrefix("vex").With(log.String("type", "oci"),
log.String("purl", p.String()))
log.String("purl", purlString))

// Probe the OCI artifact and retrieve VEX documents
vexDocuments, err := discovery.NewAgent().ProbePurl(p.String())
digest, registryOptions, err := resolveDigest(ctx, p, registryOptions)
if err != nil {
return nil, xerrors.Errorf("failed to probe the package URL: %w", err)
return nil, xerrors.Errorf("failed to resolve OCI digest: %w", err)
}
if len(vexDocuments) == 0 {

vexDoc, err := retrieveReferrerVEX(ctx, digest, registryOptions)
if err != nil {
return nil, xerrors.Errorf("failed to retrieve VEX attestation from OCI referrers: %w", err)
}
if vexDoc == nil {
vexDoc, err = retrieveLegacyVEX(ctx, digest, registryOptions)
if err != nil {
return nil, xerrors.Errorf("failed to retrieve VEX attestation from legacy tag: %w", err)
}
}
if vexDoc == nil {
logger.Info("No VEX attestations found")
return nil, nil
}

logger.Debug("VEX attestation found, taking the first one")
logger.Debug("VEX attestation found")
return &OpenVEX{
vex: *vexDocuments[0],
vex: *vexDoc,
source: fmt.Sprintf("VEX attestation in OCI registry (%s)", p.String()),
}, nil
}

func resolveDigest(ctx context.Context, p *purl.PackageURL, registryOptions ftypes.RegistryOptions) (name.Digest, ftypes.RegistryOptions, error) {
ociPURL := p.Unwrap()
if ociPURL == nil {
return name.Digest{}, registryOptions, xerrors.New("package URL is nil")
}
if ociPURL.Type != packageurl.TypeOCI {
return name.Digest{}, registryOptions, xerrors.Errorf("unsupported package URL type: %s", ociPURL.Type)
}

repoURL := ociPURL.Qualifiers.Map()["repository_url"]
if repoURL == "" {
return name.Digest{}, registryOptions, xerrors.New("repository_url qualifier is missing")
}

var insecure bool
repoURL, insecure = normalizeRepositoryURL(repoURL)
if insecure {
registryOptions.Insecure = true
}
Comment thread
DmitriyLewen marked this conversation as resolved.
Outdated

nameOpts := nameOptions(registryOptions)
ref, err := name.ParseReference(repoURL, nameOpts...)
if err != nil {
return name.Digest{}, registryOptions, xerrors.Errorf("repository URL parse error: %w", err)
}

if ociPURL.Version != "" {
return ref.Context().Digest(ociPURL.Version), registryOptions, nil
}

if digest, ok := ref.(name.Digest); ok {
return digest, registryOptions, nil
}

desc, err := remote.Get(ctx, ref, registryOptions)
if err != nil {
return name.Digest{}, registryOptions, xerrors.Errorf("image get error: %w", err)
}
return ref.Context().Digest(desc.Digest.String()), registryOptions, nil
}

func normalizeRepositoryURL(repoURL string) (string, bool) {
u, err := url.Parse(repoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return repoURL, false
}

normalized := u.Host + u.EscapedPath()
return normalized, u.Scheme == "http"
}

func nameOptions(registryOptions ftypes.RegistryOptions) []name.Option {
var opts []name.Option
if registryOptions.Insecure {
opts = append(opts, name.Insecure)
}
return opts
}

func retrieveReferrerVEX(ctx context.Context, digest name.Digest, registryOptions ftypes.RegistryOptions) (*openvex.VEX, error) {
index, err := remote.Referrers(ctx, digest, registryOptions)
if err != nil {
if isReferrersUnsupported(err) {
log.WithPrefix("vex").Debug("OCI referrers are not available", log.Err(err))
return nil, nil
}
return nil, xerrors.Errorf("unable to fetch referrers: %w", err)
}

manifest, err := index.IndexManifest()
if err != nil {
return nil, xerrors.Errorf("unable to get referrers manifest: %w", err)
}
if manifest == nil {
return nil, nil
}

for _, desc := range manifest.Manifests {
if !slices.Contains(supportedVEXArtifactTypes, desc.ArtifactType) {
continue
}

ref := digest.Context().Digest(desc.Digest.String())
blob, err := fetchAttestationBlob(ctx, ref, registryOptions)
if err != nil {
return nil, xerrors.Errorf("referrer blob error (%s): %w", desc.Digest.String(), err)
}

vexDoc, err := decodeOpenVEXAttestation(blob, desc.ArtifactType)
if err != nil {
return nil, xerrors.Errorf("referrer decode error (%s): %w", desc.Digest.String(), err)
}
return vexDoc, nil
}

return nil, nil
}

func retrieveLegacyVEX(ctx context.Context, digest name.Digest, registryOptions ftypes.RegistryOptions) (*openvex.VEX, error) {
tag := strings.ReplaceAll(digest.DigestStr(), ":", "-") + ".att"
ref := digest.Context().Tag(tag)

blob, err := fetchAttestationBlob(ctx, ref, registryOptions)
Comment thread
DmitriyLewen marked this conversation as resolved.
Outdated
if err != nil {
if isNotFound(err) {
return nil, nil
}
return nil, err
}

return decodeOpenVEXAttestation(blob, dsseEnvelopeMediaType)
}

func fetchAttestationBlob(ctx context.Context, ref name.Reference, registryOptions ftypes.RegistryOptions) ([]byte, error) {
desc, err := remote.Get(ctx, ref, registryOptions)
if err != nil {
return nil, err
}

img, err := desc.Image()
if err != nil {
return nil, xerrors.Errorf("image error: %w", err)
}

layers, err := img.Layers()
if err != nil {
return nil, xerrors.Errorf("layers error: %w", err)
}
if len(layers) != 1 {
return nil, xerrors.Errorf("OCI artifact must be a single layer")
}

rc, err := layers[0].Uncompressed()
if err != nil {
return nil, xerrors.Errorf("failed to fetch the layer: %w", err)
}
defer rc.Close()

blob, err := io.ReadAll(rc)
Comment thread
nikpivkin marked this conversation as resolved.
Outdated
if err != nil {
return nil, xerrors.Errorf("read layer error: %w", err)
}
return blob, nil
}

type sigstoreBundle struct {
Comment thread
DmitriyLewen marked this conversation as resolved.
Outdated
MediaType string `json:"mediaType"`
DSSEEnvelope json.RawMessage `json:"dsseEnvelope"`
}

func decodeOpenVEXAttestation(blob []byte, artifactType string) (*openvex.VEX, error) {
if artifactType == sigstoreBundleMediaType {
var bundle sigstoreBundle
if err := json.NewDecoder(bytes.NewReader(blob)).Decode(&bundle); err != nil {
return nil, xerrors.Errorf("failed to decode Sigstore bundle: %w", err)
}
if bundle.MediaType != "" && bundle.MediaType != sigstoreBundleMediaType {
return nil, xerrors.Errorf("unexpected Sigstore bundle media type: %s", bundle.MediaType)
}
if len(bundle.DSSEEnvelope) == 0 {
return nil, xerrors.New("Sigstore bundle is missing dsseEnvelope")
}
blob = bundle.DSSEEnvelope
}

return decodeDSSEOpenVEX(blob)
}

type openVEXStatement struct {
in_toto.StatementHeader
Predicate openvex.VEX `json:"predicate"`
}

func decodeDSSEOpenVEX(blob []byte) (*openvex.VEX, error) {
var envelope dsse.Envelope
if err := json.NewDecoder(bytes.NewReader(blob)).Decode(&envelope); err != nil {
return nil, xerrors.Errorf("failed to decode as a DSSE envelope: %w", err)
}
if envelope.PayloadType != in_toto.PayloadType {
return nil, xerrors.Errorf("invalid attestation payload type: %s", envelope.PayloadType)
}

decoded, err := base64.StdEncoding.DecodeString(envelope.Payload)
if err != nil {
return nil, xerrors.Errorf("failed to decode attestation payload: %w", err)
}

var statement openVEXStatement
if err = json.NewDecoder(bytes.NewReader(decoded)).Decode(&statement); err != nil {
return nil, xerrors.Errorf("failed to decode attestation payload as in-toto statement: %w", err)
}
if !isOpenVEXPredicateType(statement.PredicateType) {
return nil, xerrors.Errorf("unsupported predicate type: %s", statement.PredicateType)
}

return &statement.Predicate, nil
}

func isOpenVEXPredicateType(predicateType string) bool {
return predicateType == openvex.TypeURI || strings.HasPrefix(predicateType, openvex.TypeURI+"/")
}

func isReferrersUnsupported(err error) bool {
if isNotFound(err) {
return true
}

var terr *transport.Error
if !errorsAs(err, &terr) {
return false
}
for _, diagnostic := range terr.Errors {
if diagnostic.Code == transport.UnsupportedErrorCode {
return true
}
}
return false
}

func isNotFound(err error) bool {
var terr *transport.Error
if !errorsAs(err, &terr) {
return false
}
if terr.StatusCode == 404 {
return true
}
for _, diagnostic := range terr.Errors {
if diagnostic.Code == transport.ManifestUnknownErrorCode || diagnostic.Code == transport.NameUnknownErrorCode {
return true
}
}
return false
}

func errorsAs(err error, target any) bool {
if xerrors.As(err, target) {
return true
}

var multiErr *multierror.Error
if !xerrors.As(err, &multiErr) {
return false
}
for _, e := range multiErr.Errors {
if xerrors.As(e, target) {
return true
}
}
return false
}
Loading