diff --git a/attestation/oci/oci.go b/attestation/oci/oci.go index 47f6ddf6..e9e5aa65 100644 --- a/attestation/oci/oci.go +++ b/attestation/oci/oci.go @@ -15,19 +15,16 @@ package oci import ( - "archive/tar" - "bytes" - "compress/gzip" "crypto" "encoding/json" "fmt" - "io" - "net/http" "os" + "path/filepath" "strings" "github.com/in-toto/go-witness/attestation" "github.com/in-toto/go-witness/cryptoutil" + docker "github.com/in-toto/go-witness/internal/docker" "github.com/in-toto/go-witness/log" "github.com/invopop/jsonschema" ) @@ -37,7 +34,7 @@ const ( Type = "https://witness.dev/attestations/oci/v0.1" RunType = attestation.PostProductRunType - mimeTypes = "application/x-tar" + sha256MimeType = "text/sha256+text" ) // This is a hacky way to create a compile time error in case the attestor @@ -66,14 +63,14 @@ func init() { } type Attestor struct { - TarDigest cryptoutil.DigestSet `json:"tardigest"` - Manifest []Manifest `json:"manifest"` - ImageTags []string `json:"imagetags"` - LayerDiffIDs []cryptoutil.DigestSet `json:"diffids"` - ImageID cryptoutil.DigestSet `json:"imageid"` - ManifestRaw []byte `json:"manifestraw"` - ManifestDigest cryptoutil.DigestSet `json:"manifestdigest"` - tarFilePath string `json:"-"` + Materials []Material `json:"materials"` + ImageReferences []string `json:"imagereferences"` + ImageDigest cryptoutil.DigestSet `json:"imagedigest"` +} + +type Material struct { + URI string `json:"uri"` + Digest cryptoutil.DigestSet `json:"digest"` } type Manifest struct { @@ -82,46 +79,6 @@ type Manifest struct { Layers []string `json:"Layers"` } -func (m *Manifest) getImageID(ctx *attestation.AttestationContext, tarFilePath string) (cryptoutil.DigestSet, error) { - tarFile, err := os.Open(tarFilePath) - if err != nil { - return nil, err - } - defer tarFile.Close() - - tarReader := tar.NewReader(tarFile) - for { - h, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - if h.FileInfo().IsDir() { - continue - } - - if h.Name == m.Config { - - b := make([]byte, h.Size) - _, err := tarReader.Read(b) - if err != nil && err != io.EOF { - return nil, err - } - - imageID, err := cryptoutil.CalculateDigestSetFromBytes(b, ctx.Hashes()) - if err != nil { - log.Debugf("(attestation/oci) error calculating image id: %w", err) - return nil, err - } - - return imageID, nil - } - } - return nil, fmt.Errorf("could not find config in tar file") -} - func New() *Attestor { return &Attestor{} } @@ -143,190 +100,158 @@ func (a *Attestor) Schema() *jsonschema.Schema { } func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { - if err := a.getCandidate(ctx); err != nil { - log.Debugf("(attestation/oci) error getting candidate: %w", err) + met, err := a.getDockerCandidate(ctx) + if err != nil { + log.Debugf("(attestation/oci) error getting docker candidate: %w", err) return err } + if met != nil { + err := a.setDockerCandidate(met) + if err != nil { + log.Debugf("(attestation/oci) error setting docker candidate: %w", err) + return err + } + } else { + // NOTE: our final attempt here is to try and find the sha256 image digest saved to a file. + // most tools provide the ability to do this (e.g., docker, podman), and if they don't other manual mechanisms could be + // established by a user + dig, err := a.getImageDigestFileCandidate(ctx) + if err != nil { + log.Debugf("(attestation/oci) error getting image digest from file: %w", err) + return err + } - if err := a.parseMaifest(ctx); err != nil { - log.Debugf("(attestation/oci) error parsing manifest: %w", err) - return err + trimmed, found := strings.CutPrefix(dig, "sha256:") + if found == false { + err := fmt.Errorf("failed to remove prefix from digest") + log.Debugf("(attestation/oci) %s", err.Error()) + return err + } + a.ImageDigest = map[cryptoutil.DigestValue]string{} + a.ImageDigest[cryptoutil.DigestValue{Hash: crypto.SHA256}] = trimmed } - imageID, err := a.Manifest[0].getImageID(ctx, a.tarFilePath) - if err != nil { - log.Debugf("(attestation/oci) error getting image id: %w", err) - return err - } + return nil +} - layerDiffIDs, err := a.Manifest[0].getLayerDIFFIDs(ctx, a.tarFilePath) - if err != nil { - return err +func (a *Attestor) setDockerCandidate(met *docker.BuildInfo) error { + if strings.HasPrefix(met.ContainerImageDigest, "sha256:") { + log.Debugf("(attestation/oci) found image digest '%s'", met.ContainerImageDigest) + a.ImageDigest = map[cryptoutil.DigestValue]string{} + log.Debugf("(attestation/oci) removing 'sha256:' prefix from digest '%s'", met.ContainerImageDigest) + trimmed, found := strings.CutPrefix(met.ContainerImageDigest, "sha256:") + if found == false { + err := fmt.Errorf("failed to remove prefix from digest") + log.Debugf("(attestation/oci) %s", err.Error()) + return err + } + log.Debugf("(attestation/oci) setting image digest as '%s'", trimmed) + a.ImageDigest[cryptoutil.DigestValue{Hash: crypto.SHA256}] = trimmed + } else { + log.Warnf("(attestation/oci) found metadata file does not contain image digest of expected format: '%s'", met.ContainerImageDigest) } - a.ImageID = imageID - a.LayerDiffIDs = layerDiffIDs - a.ImageTags = a.Manifest[0].RepoTags + if len(met.Provenance.Materials) != 0 { + a.Materials = []Material{} + for _, material := range met.Provenance.Materials { + a.Materials = append(a.Materials, Material{URI: material.URI, Digest: cryptoutil.DigestSet{ + cryptoutil.DigestValue{crypto.SHA256, false}: material.Digest.Sha256, + }}) + } + } + // NOTE: we can get the builder architecture information from another attestor + // if plat := met.Provenance.Invocation.Environment.Platform; plat != "" { + // s := strings.Split(plat, "/") + // if len(s) != 2 { + // log.Warnf("(attestation/oci) docker buildx metadata `invocation.environment.platform` field '%s' not in expected `os/arch` fomat. skipping", plat) + // } + // + // a.Environment = met.Provenance.Invocation.Environment.Platform + // } + + log.Debugf("setting image references as '%s'", met.ImageName) + a.ImageReferences = []string{} + a.ImageReferences = append(a.ImageReferences, met.ImageName) return nil } -func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { +func (a *Attestor) getImageDigestFileCandidate(ctx *attestation.AttestationContext) (string, error) { products := ctx.Products() - if len(products) == 0 { - return fmt.Errorf("no products to attest") - } - for path, product := range products { - if !strings.Contains(mimeTypes, product.MimeType) { - continue - } - - newDigestSet, err := cryptoutil.CalculateDigestSetFromFile(path, ctx.Hashes()) - if newDigestSet == nil || err != nil { - return fmt.Errorf("error calculating digest set from file: %s", path) - } - - if !newDigestSet.Equal(product.Digest) { - return fmt.Errorf("integrity error: product digest set does not match candidate digest set") + if strings.Contains(sha256MimeType, product.MimeType) { + f, err := os.ReadFile(filepath.Join(ctx.WorkingDir(), path)) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", path, err) + } + return string(f), nil } - - a.TarDigest = product.Digest - - a.tarFilePath = path - return nil } - return fmt.Errorf("no tar file found") + + return "", nil } -func (a *Attestor) parseMaifest(ctx *attestation.AttestationContext) error { - f, err := os.Open(a.tarFilePath) - if err != nil { - err = fmt.Errorf("error opening tar file: %w", err) - return err - } +func (a *Attestor) getDockerCandidate(ctx *attestation.AttestationContext) (*docker.BuildInfo, error) { + products := ctx.Products() - tarReader := tar.NewReader(f) - for { - h, err := tarReader.Next() - if err == io.EOF { - break - } + if len(products) == 0 { + return nil, fmt.Errorf("no products to attest") + } + //NOTE: it's not ideal to try and parse it without a mime type but the metadata file is completely different depending on how the buildx is executed + for path, _ := range products { + var met docker.BuildInfo + f, err := os.ReadFile(path) if err != nil { - return err + return nil, fmt.Errorf("failed to read file %s: %w", path, err) } - if h.FileInfo().IsDir() { + err = json.Unmarshal(f, &met) + if err != nil { + log.Debugf("(attestation/oci) error parsing file %s as docker metadata file: %w", path, err) continue } - if h.Name == "manifest.json" { - a.ManifestRaw = make([]byte, h.Size) - _, err = tarReader.Read(a.ManifestRaw) - if err != nil || err == io.EOF { - break - } - break - } - } - - manifestDigest, err := cryptoutil.CalculateDigestSetFromBytes(a.ManifestRaw, ctx.Hashes()) - if err != nil { - return err - } - a.ManifestDigest = manifestDigest + log.Info("found image metadata file") - err = json.Unmarshal(a.ManifestRaw, &a.Manifest) - if err != nil { - return err + return &met, nil } - return nil + return nil, nil } func (a *Attestor) Subjects() map[string]cryptoutil.DigestSet { hashes := []cryptoutil.DigestValue{{Hash: crypto.SHA256}} subj := make(map[string]cryptoutil.DigestSet) - subj[fmt.Sprintf("manifestdigest:%s", a.ManifestDigest[cryptoutil.DigestValue{Hash: crypto.SHA256}])] = a.ManifestDigest - subj[fmt.Sprintf("tardigest:%s", a.TarDigest[cryptoutil.DigestValue{Hash: crypto.SHA256}])] = a.TarDigest - subj[fmt.Sprintf("imageid:%s", a.ImageID[cryptoutil.DigestValue{Hash: crypto.SHA256}])] = a.ImageID + subj[fmt.Sprintf("imagedigest:%s", a.ImageDigest[cryptoutil.DigestValue{Hash: crypto.SHA256}])] = a.ImageDigest - // image tags - for _, tag := range a.ImageTags { - hash, err := cryptoutil.CalculateDigestSetFromBytes([]byte(tag), hashes) - if err != nil { - log.Debugf("(attestation/oci) error calculating image tag: %w", err) - continue + for _, ir := range a.ImageReferences { + if hash, err := cryptoutil.CalculateDigestSetFromBytes([]byte(ir), hashes); err == nil { + subj[fmt.Sprintf("imagereference:%s", ir)] = hash + } else { + log.Debugf("(attestation/oci) failed to record github imagereference subject: %w", err) } - subj[fmt.Sprintf("imagetag:%s", tag)] = hash - } - - // diff ids - for layer := range a.LayerDiffIDs { - subj[fmt.Sprintf("layerdiffid%02d:%s", layer, a.LayerDiffIDs[layer][cryptoutil.DigestValue{Hash: crypto.SHA256}])] = a.LayerDiffIDs[layer] } - return subj -} -func (m *Manifest) getLayerDIFFIDs(ctx *attestation.AttestationContext, tarFilePath string) ([]cryptoutil.DigestSet, error) { - var layerDiffIDs []cryptoutil.DigestSet - - tarFile, err := os.Open(tarFilePath) - if err != nil { - return nil, err + for _, m := range a.Materials { + subj[fmt.Sprintf("materialdigest:%s", m.Digest[cryptoutil.DigestValue{Hash: crypto.SHA256}])] = m.Digest + if hash, err := cryptoutil.CalculateDigestSetFromBytes([]byte(m.URI), hashes); err == nil { + subj[fmt.Sprintf("materialuri:%s", m.URI)] = hash + } else { + log.Debugf("(attestation/github) failed to record github materialuri subject: %w", err) + } } - defer tarFile.Close() - tarReader := tar.NewReader(tarFile) - for { - h, err := tarReader.Next() - if err == io.EOF { - break - } + // image tags + for _, ref := range a.ImageReferences { + hash, err := cryptoutil.CalculateDigestSetFromBytes([]byte(ref), hashes) if err != nil { - return nil, err - } - if h.FileInfo().IsDir() { + log.Debugf("(attestation/oci) error calculating image reference: %w", err) continue } - for _, layerFile := range m.Layers { - if h.Name == layerFile { - b := make([]byte, h.Size) - - _, err := tarReader.Read(b) - if err != nil && err != io.EOF { - return nil, err - } - - contentType := http.DetectContentType(b) - if contentType == "application/x-gzip" { - breader, err := gzip.NewReader(bytes.NewReader(b)) - if err != nil { - return nil, err - } - defer breader.Close() - c, err := io.ReadAll(breader) - if err != nil { - return nil, err - } - layerDiffID, err := cryptoutil.CalculateDigestSetFromBytes(c, ctx.Hashes()) - if err != nil { - return nil, err - } - layerDiffIDs = append(layerDiffIDs, layerDiffID) - - } else { - layerDiffID, err := cryptoutil.CalculateDigestSetFromBytes(b, ctx.Hashes()) - if err != nil { - return nil, err - } - layerDiffIDs = append(layerDiffIDs, layerDiffID) - } - - } - } + subj[fmt.Sprintf("imagereference:%s", ref)] = hash } - return layerDiffIDs, nil + + return subj } diff --git a/attestation/product/product.go b/attestation/product/product.go index b8ad1d1b..c7b51322 100644 --- a/attestation/product/product.go +++ b/attestation/product/product.go @@ -287,6 +287,11 @@ func getFileContentType(fileName string) (string, error) { return bytes.HasPrefix(buf, []byte(`{"@context":"https://openvex.dev/ns`)) }, "application/vex+json", ".vex.json") + // Add sha256 digest detector + mimetype.Lookup("text/plain").Extend(func(buf []byte, limit uint32) bool { + return bytes.HasPrefix(buf, []byte(`sha256:`)) + }, "text/sha256+text", ".sha256") + contentType, err := mimetype.DetectFile(fileName) if err != nil { return "", err diff --git a/internal/docker/metadata.go b/internal/docker/metadata.go new file mode 100644 index 00000000..ea1e369c --- /dev/null +++ b/internal/docker/metadata.go @@ -0,0 +1,66 @@ +package oci + +type Digest struct { + Sha256 string `json:"sha256"` +} + +type Material struct { + URI string `json:"uri"` + Digest Digest `json:"digest"` +} + +type ConfigSource struct { + EntryPoint string `json:"entryPoint"` +} + +type Args struct { + Cmdline string `json:"cmdline"` + Source string `json:"source"` +} + +type Local struct { + Name string `json:"name"` +} + +type Parameters struct { + Frontend string `json:"frontend"` + Args Args `json:"args"` + Locals []Local `json:"locals"` +} + +type Environment struct { + Platform string `json:"platform"` +} + +type Invocation struct { + ConfigSource ConfigSource `json:"configSource"` + Parameters Parameters `json:"parameters"` + Environment Environment `json:"environment"` +} + +type Provenance struct { + BuildType string `json:"buildType"` + Materials []Material `json:"materials"` + Invocation Invocation `json:"invocation"` +} + +type Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` +} + +type ContainerImageDescriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int `json:"size"` + Platform Platform `json:"platform"` +} + +type BuildInfo struct { + Provenance Provenance `json:"buildx.build.provenance"` + BuildRef string `json:"buildx.build.ref"` + ContainerImageConfigDigest string `json:"containerimage.config.digest"` + ContainerImageDescriptor ContainerImageDescriptor `json:"containerimage.descriptor"` + ContainerImageDigest string `json:"containerimage.digest"` + ImageName string `json:"image.name"` +}