Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Available Commands:
Flags:
-h, --help help for dependabot
--proxy-image string container image to use for the proxy (default "ghcr.io/dependabot/proxy:latest")
--skip-image-verification skip updater and proxy image verification for trusted development images
--skip-image-verification skip updater, proxy, and configured OpenTelemetry collector image verification for trusted development images
--updater-image string container image to use for the updater
-v, --version version for dependabot

Expand All @@ -61,6 +61,7 @@ Use "dependabot [command] --help" for more information about a command.
### Image verification

The CLI requires valid GitHub build attestations for updater and proxy images before it starts any containers.
When Collector configuration is supplied, it also requires a valid OpenTelemetry Collector image signature.
An attestation is a signed claim about an artifact.
Provenance records an image's build origin.
Bundles contain the signed claims and verification evidence.
Expand All @@ -70,13 +71,24 @@ An image digest identifies its exact content.
The CLI requires a valid signature and matching digest, issuer, repository, workflow identity, and source ref.
It uses the verified digest for image pulls and container creation.

The accepted issuer is `https://token.actions.githubusercontent.com`, and the required source ref is `refs/heads/main`.
The accepted issuer is `https://token.actions.githubusercontent.com`.
Updater and proxy images require SLSA build provenance from `refs/heads/main`.
Collector images require Cosign image signatures from stable `refs/tags/vMAJOR.MINOR.PATCH` releases.
These signatures identify the image and publisher without describing the build.
The publisher workflows are:

| Image | Repository | Workflow |
| --- | --- | --- |
| Updater | `dependabot/dependabot-core` | `.github/workflows/images-latest.yml` |
| Proxy | `dependabot/proxy` | `.github/workflows/ghcr.yml` |
| Collector | `open-telemetry/opentelemetry-collector-releases` | `.github/workflows/base-release.yaml` |

For a Collector version such as `:0.161.0`, both the workflow identity and certificate source ref must name `refs/tags/v0.161.0`.
The version is still enforced when the reference also includes a digest.
For `latest`, other non-version tags, and digest-only references, any stable release from that same workflow is accepted.
The certificate source ref must still match the release in the workflow identity.
Prerelease and manually signed Collector images are not accepted.
Collector verification, pull, or startup failures stop the job.

Image overrides must satisfy the same producer policy.
Registry mirrors must preserve the image digest and attestation bundles.
Expand All @@ -87,12 +99,12 @@ It does not switch to a newer registry tag.
It still needs network access for attestations and Sigstore trust metadata.
The CLI uses existing registry credentials, not updater-job credentials, to retrieve bundles.

Storage and OpenTelemetry collector images are not verified.
Storage images are not verified.
The CLI records the signed producer commit, but does not enforce an independently approved commit or image freshness.
Branch builds and images from other producers require the development bypass below.

Only use `--skip-image-verification` with trusted development images.
This flag disables both updater and proxy verification and prints a warning.
This flag disables updater, proxy, and configured Collector verification and prints a warning.
For example:

```sh
Expand All @@ -102,7 +114,7 @@ dependabot update go_modules dependabot/cli \
```

The Docker Action exposes the same bypass through its `skip-image-verification` input, which defaults to `'false'`.
Do not use this bypass to ignore unexpected provenance in production.
Do not use this bypass to ignore unexpected signatures or provenance in production.

### `dependabot update`

Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ inputs:
description: Pull images before running; false uses images already on the runner
default: 'true'
skip-image-verification:
description: Skip updater and proxy image verification for trusted development images
description: Skip updater, proxy, and configured OpenTelemetry collector image verification for trusted development images
default: 'false'
timeout:
description: Maximum execution duration, excluding API bootstrap (Go duration)
Expand Down
2 changes: 1 addition & 1 deletion cmd/dependabot/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@ func init() {
rootCmd.PersistentFlags().StringVar(&proxyImage, "proxy-image", infra.ProxyImageName, "container image to use for the proxy")
rootCmd.PersistentFlags().StringVar(&collectorImage, "collector-image", infra.CollectorImageName, "container image to use for the OpenTelemetry collector")
rootCmd.PersistentFlags().StringVar(&storageImage, "storage-image", infra.StorageImageName, "container image to use for the storage service")
rootCmd.PersistentFlags().BoolVar(&skipImageVerification, "skip-image-verification", false, "skip updater and proxy image verification for trusted development images")
rootCmd.PersistentFlags().BoolVar(&skipImageVerification, "skip-image-verification", false, "skip updater, proxy, and configured OpenTelemetry collector image verification for trusted development images")
}
2 changes: 1 addition & 1 deletion docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Next, clone https://github.com/dependabot/dependabot-core. This project contains

Use `--skip-image-verification` only with trusted development images.
Locally built images do not carry the required publisher attestations.
This flag disables updater and proxy verification and prints a warning.
This flag disables updater, proxy, and configured OpenTelemetry Collector verification and prints a warning.
Verification remains enabled in debug mode unless you set this flag.

In the `dependabot-core` project directory, run:
Expand Down
97 changes: 81 additions & 16 deletions internal/infra/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"

"github.com/google/go-containerregistry/pkg/name"
Expand All @@ -21,18 +22,23 @@ import (
)

const (
actionsIssuer = "https://token.actions.githubusercontent.com"
provenancePredicate = "https://slsa.dev/provenance/v1"
sigstoreBundleType = "application/vnd.dev.sigstore.bundle"
maxBundleBytes = 16 << 20
actionsIssuer = "https://token.actions.githubusercontent.com"
provenancePredicate = "https://slsa.dev/provenance/v1"
imageSignaturePredicate = "https://sigstore.dev/cosign/sign/v1"
stableReleaseVersionPattern = `(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)`
stableReleaseRefPattern = `refs/tags/v` + stableReleaseVersionPattern
sigstoreBundleType = "application/vnd.dev.sigstore.bundle"
maxBundleBytes = 16 << 20
)

type imageRole struct {
name string
repository string
workflow string
issuer string
sourceRef string
name string
repository string
workflow string
issuer string
sourceRef string
predicate string
releaseWorkflow bool
}

var (
Expand All @@ -42,16 +48,54 @@ var (
workflow: "https://github.com/dependabot/dependabot-core/.github/workflows/images-latest.yml@refs/heads/main",
issuer: actionsIssuer,
sourceRef: "refs/heads/main",
predicate: provenancePredicate,
}
proxyImageRole = imageRole{
name: "proxy",
repository: "https://github.com/dependabot/proxy",
workflow: "https://github.com/dependabot/proxy/.github/workflows/ghcr.yml@refs/heads/main",
issuer: actionsIssuer,
sourceRef: "refs/heads/main",
predicate: provenancePredicate,
}
collectorImageRole = imageRole{
name: "collector",
repository: "https://github.com/open-telemetry/opentelemetry-collector-releases",
workflow: "https://github.com/open-telemetry/opentelemetry-collector-releases/.github/workflows/base-release.yaml",
issuer: actionsIssuer,
predicate: imageSignaturePredicate,
releaseWorkflow: true,
}
stableReleaseRefRegex = regexp.MustCompile("^" + stableReleaseRefPattern + "$")
stableImageVersionRegex = regexp.MustCompile("^v?" + stableReleaseVersionPattern + "$")
versionedImageTagRegex = regexp.MustCompile(`^v?[0-9]+\.[0-9]+\.[0-9]+($|-)`)
)

func collectorRoleForImage(imageName string) (imageRole, error) {
ref, err := name.ParseReference(imageName)
if err != nil {
return imageRole{}, err
}
if _, ok := ref.(name.Digest); ok {
// Digest parsing drops an optional tag that still constrains the signing release.
tagName, _, _ := strings.Cut(imageName, "@")
ref, err = name.NewTag(tagName)
if err != nil {
return imageRole{}, err
}
}
role := collectorImageRole
if tag, ok := ref.(name.Tag); ok {
version := tag.TagStr()
if stableImageVersionRegex.MatchString(version) {
role.sourceRef = "refs/tags/v" + strings.TrimPrefix(version, "v")
} else if versionedImageTagRegex.MatchString(version) {
return imageRole{}, fmt.Errorf("image tag %q must be a stable release version", version)
}
}
return role, nil
}

type verifiedImage struct {
reference name.Digest
producerCommit string
Expand Down Expand Up @@ -85,15 +129,24 @@ func (c contextHTTPClient) Do(req *http.Request) (*http.Response, error) {
}

func (v *imageVerifier) Verify(ctx context.Context, ref name.Digest, role imageRole) (verified verifiedImage, err error) {
workflow := role.workflow
if role.releaseWorkflow && role.sourceRef != "" {
workflow += "@" + role.sourceRef
}
defer func() {
if err != nil {
err = fmt.Errorf("%s image %s: attestation verification failed (expected %s): %w", role.name, ref, role.workflow, err)
err = fmt.Errorf("%s image %s: attestation verification failed (expected %s): %w", role.name, ref, workflow, err)
}
}()
if err := ctx.Err(); err != nil {
return verifiedImage{}, err
}
san, err := verify.NewSANMatcher(role.workflow, "")
sanValue, sanPattern := workflow, ""
if role.releaseWorkflow && role.sourceRef == "" {
sanValue = ""
sanPattern = "^" + regexp.QuoteMeta(workflow) + "@" + stableReleaseRefPattern + "$"
}
san, err := verify.NewSANMatcher(sanValue, sanPattern)
if err != nil {
return verifiedImage{}, err
}
Expand Down Expand Up @@ -175,7 +228,7 @@ func (v *imageVerifier) Verify(ctx context.Context, ref name.Digest, role imageR
failures = append(failures, fmt.Errorf("bundle %s: %w", bundleRef, err))
continue
}
commit, err := verifiedProducerCommit(result)
commit, err := verifiedProducerCommit(result, role)
if err != nil {
failures = append(failures, fmt.Errorf("bundle %s: %w", bundleRef, err))
continue
Expand Down Expand Up @@ -208,12 +261,24 @@ func readImageBundle(reader io.ReadCloser) (*bundle.Bundle, error) {
return b, nil
}

func verifiedProducerCommit(result *verify.VerificationResult) (string, error) {
if result.Statement == nil || result.Statement.PredicateType != provenancePredicate {
return "", fmt.Errorf("attestation must contain %s provenance", provenancePredicate)
func verifiedProducerCommit(result *verify.VerificationResult, role imageRole) (string, error) {
if result.Statement == nil || result.Statement.PredicateType != role.predicate {
return "", fmt.Errorf("attestation must contain a %s statement", role.predicate)
}
if result.Signature == nil || result.Signature.Certificate == nil || result.Signature.Certificate.SourceRepositoryDigest == "" {
return "", errors.New("attestation certificate has no producer commit")
}
return result.Signature.Certificate.SourceRepositoryDigest, nil
cert := result.Signature.Certificate
if role.releaseWorkflow {
if result.Statement.Type != "https://in-toto.io/Statement/v1" {
return "", errors.New("image signature must contain an in-toto v1 statement")
}
if !stableReleaseRefRegex.MatchString(cert.SourceRepositoryRef) {
return "", fmt.Errorf("image signature certificate has no stable release ref: %q", cert.SourceRepositoryRef)
}
if cert.SubjectAlternativeName != role.workflow+"@"+cert.SourceRepositoryRef {
return "", errors.New("image signature workflow identity does not match its source ref")
}
}
return cert.SourceRepositoryDigest, nil
}
97 changes: 94 additions & 3 deletions internal/infra/attestation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
sigroot "github.com/sigstore/sigstore-go/pkg/root"
"github.com/sigstore/sigstore-go/pkg/verify"
)
Expand Down Expand Up @@ -104,7 +105,11 @@ func newAttestationTestRegistry(t *testing.T, bundles map[string][][]byte, fallb
backing.ServeHTTP(w, req)
}))
t.Cleanup(r.server.Close)
for _, role := range []string{"updater", "proxy"} {
roles := []string{"updater", "proxy"}
if _, ok := bundles["collector"]; ok {
roles = append(roles, "collector")
}
for _, role := range roles {
repo, err := name.NewRepository(strings.TrimPrefix(r.server.URL, "http://") + "/" + role)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -190,6 +195,44 @@ func TestImageVerifier(t *testing.T) {
}
}

func TestImageVerifierCollectorRelease(t *testing.T) {
for _, fallback := range []bool{false, true} {
t.Run(fmt.Sprintf("fallback=%v", fallback), func(t *testing.T) {
reg := newAttestationTestRegistry(t, map[string][][]byte{
"collector": {attestationFixture(t, "collector.json")},
}, fallback)
ref := reg.references["collector"]
got, err := testImageVerifier(t).Verify(t.Context(), ref, collectorImageRole)
if err != nil {
t.Fatal(err)
}
want := ref.Context().Digest("sha256:fd328de2552466ad78385e1b1289c3f2402b1c45f265b252aab1955b42845ac1")
if got.reference != want || got.producerCommit != "619d1d0d06e9488a4a38e993f09605bbc13db592" {
t.Fatalf("Verify() = %+v, want %s at the signed release commit", got, want)
}
})
}
}

func TestImageVerifierCollectorPinnedRelease(t *testing.T) {
reg := newAttestationTestRegistry(t, map[string][][]byte{
"collector": {attestationFixture(t, "collector.json")},
}, false)
for _, release := range []string{"v0.161.0", "v0.160.0"} {
t.Run(release, func(t *testing.T) {
role := collectorImageRole
role.sourceRef = "refs/tags/" + release
_, err := testImageVerifier(t).Verify(t.Context(), reg.references["collector"], role)
if release == "v0.161.0" && err != nil {
t.Fatal(err)
}
if release == "v0.160.0" && err == nil {
t.Fatal("accepted a signature from a different release")
}
})
}
}

func TestImageVerifierRejectsUnexpectedProvenance(t *testing.T) {
reg := newAttestationTestRegistry(t, officialImageBundles(t), false)
verifier := testImageVerifier(t)
Expand Down Expand Up @@ -285,18 +328,66 @@ func TestVerifiedProducerCommit(t *testing.T) {
{"missing commit", provenancePredicate, ""},
} {
t.Run(tt.name, func(t *testing.T) {
data := fmt.Sprintf(`{"statement":{"predicateType":%q},"signature":{"certificate":{"sourceRepositoryDigest":%q}}}`, tt.predicate, tt.commit)
data := fmt.Sprintf(`{"statement":{"predicateType":%q}}`, tt.predicate)
var result verify.VerificationResult
if err := json.Unmarshal([]byte(data), &result); err != nil {
t.Fatal(err)
}
if _, err := verifiedProducerCommit(&result); err == nil {
result.Signature = &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{Extensions: certificate.Extensions{SourceRepositoryDigest: tt.commit}},
}
if _, err := verifiedProducerCommit(&result, updaterImageRole); err == nil {
t.Fatalf("accepted %s", tt.name)
}
})
}
}

func TestCollectorClaimPolicy(t *testing.T) {
for _, tt := range []struct {
name string
modify func(*verify.VerificationResult)
wantErr string
}{
{"wrong predicate", func(r *verify.VerificationResult) { r.Statement.PredicateType = provenancePredicate }, imageSignaturePredicate},
{"wrong statement type", func(r *verify.VerificationResult) { r.Statement.Type = "https://in-toto.io/Statement/v0.1" }, "in-toto v1"},
{"missing ref", func(r *verify.VerificationResult) { r.Signature.Certificate.SourceRepositoryRef = "" }, "stable release ref"},
{"branch ref", func(r *verify.VerificationResult) { r.Signature.Certificate.SourceRepositoryRef = "refs/heads/main" }, "stable release ref"},
{"prerelease ref", func(r *verify.VerificationResult) {
r.Signature.Certificate.SourceRepositoryRef = "refs/tags/v0.161.0-rc.1"
}, "stable release ref"},
{"different release", func(r *verify.VerificationResult) { r.Signature.Certificate.SourceRepositoryRef = "refs/tags/v0.160.0" }, "does not match its source ref"},
{"workflow prefix", func(r *verify.VerificationResult) {
r.Signature.Certificate.SubjectAlternativeName = "https://unexpected.example/" + r.Signature.Certificate.SubjectAlternativeName
}, "does not match its source ref"},
{"workflow suffix", func(r *verify.VerificationResult) { r.Signature.Certificate.SubjectAlternativeName += "/extra" }, "does not match its source ref"},
{"email identity", func(r *verify.VerificationResult) {
r.Signature.Certificate.SubjectAlternativeName = "operator@example.com"
}, "does not match its source ref"},
} {
t.Run(tt.name, func(t *testing.T) {
data := fmt.Sprintf(`{"statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":%q}}`, imageSignaturePredicate)
var result verify.VerificationResult
if err := json.Unmarshal([]byte(data), &result); err != nil {
t.Fatal(err)
}
result.Signature = &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
SubjectAlternativeName: "https://github.com/open-telemetry/opentelemetry-collector-releases/.github/workflows/base-release.yaml@refs/tags/v0.161.0",
Extensions: certificate.Extensions{
SourceRepositoryDigest: "producer",
SourceRepositoryRef: "refs/tags/v0.161.0",
},
},
}
tt.modify(&result)
if _, err := verifiedProducerCommit(&result, collectorImageRole); err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("accepted invalid Collector claim or returned the wrong error: %v", err)
}
})
}
}

func TestImageVerifierCancellation(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("DOCKER_CONFIG", t.TempDir())
Expand Down
Loading
Loading