From 8bb14b585bc277455ee31d0cbd2b0756208d793d Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 5 Apr 2026 12:44:20 +0100 Subject: [PATCH] feat: Add GitHub artifact attestation for Signed-Releases Consider a release signed if all assets a digest with a matching GitHub attestation. Signed-off-by: martincostello --- checks/evaluation/signed_releases.go | 25 +- checks/evaluation/signed_releases_test.go | 93 +++++++ clients/githubrepo/releases.go | 71 ++++- clients/githubrepo/releases_test.go | 281 ++++++++++++++++++++ clients/release.go | 6 +- docs/checks.md | 2 + docs/checks/internal/checks.yaml | 3 + docs/probes.md | 15 ++ probes/entries.go | 2 + probes/releasesHaveAttestation/def.yml | 39 +++ probes/releasesHaveAttestation/impl.go | 120 +++++++++ probes/releasesHaveAttestation/impl_test.go | 241 +++++++++++++++++ 12 files changed, 882 insertions(+), 16 deletions(-) create mode 100644 clients/githubrepo/releases_test.go create mode 100644 probes/releasesHaveAttestation/def.yml create mode 100644 probes/releasesHaveAttestation/impl.go create mode 100644 probes/releasesHaveAttestation/impl_test.go diff --git a/checks/evaluation/signed_releases.go b/checks/evaluation/signed_releases.go index 1fc9cfb0524..97b24dcd7a5 100644 --- a/checks/evaluation/signed_releases.go +++ b/checks/evaluation/signed_releases.go @@ -23,6 +23,7 @@ import ( sce "github.com/ossf/scorecard/v5/errors" "github.com/ossf/scorecard/v5/finding" "github.com/ossf/scorecard/v5/probes/releasesAreSigned" + "github.com/ossf/scorecard/v5/probes/releasesHaveAttestation" "github.com/ossf/scorecard/v5/probes/releasesHaveProvenance" "github.com/ossf/scorecard/v5/probes/releasesHaveVerifiedProvenance" ) @@ -37,6 +38,7 @@ func SignedReleases(name string, ) checker.CheckResult { expectedProbes := []string{ releasesAreSigned.Probe, + releasesHaveAttestation.Probe, releasesHaveProvenance.Probe, } @@ -45,9 +47,9 @@ func SignedReleases(name string, return checker.CreateRuntimeErrorResult(name, e) } - // keep track of releases which have provenance so we don't log about signatures + // keep track of releases which have provenance or attestation so we don't log about signatures // on our second pass through below - hasProvenance := make(map[string]bool) + hasProvenanceOrAttestation := make(map[string]bool) // Debug all releases and check for OutcomeNotApplicable // All probes have OutcomeNotApplicable in case the project has no @@ -78,8 +80,11 @@ func SignedReleases(name string, loggedReleases = append(loggedReleases, releaseName) } - if f.Probe == releasesHaveProvenance.Probe && f.Outcome == finding.OutcomeTrue { - hasProvenance[releaseName] = true + if f.Outcome == finding.OutcomeTrue { + switch f.Probe { + case releasesHaveProvenance.Probe, releasesHaveAttestation.Probe: + hasProvenanceOrAttestation[releaseName] = true + } } } @@ -112,12 +117,18 @@ func SignedReleases(name string, if _, ok := releaseMap[releaseName]; !ok { releaseMap[releaseName] = 8 } - case releasesHaveProvenance.Probe: + case releasesHaveProvenance.Probe, releasesHaveAttestation.Probe: releaseMap[releaseName] = 10 } case finding.OutcomeFalse: logLevel = checker.DetailWarn - if f.Probe == releasesAreSigned.Probe && hasProvenance[releaseName] { + if f.Probe == releasesAreSigned.Probe && hasProvenanceOrAttestation[releaseName] { + continue + } + // Attestation is an optional alternative to signing or provenance. + // Suppress warnings when attestation is absent to avoid noise for projects + // that use other verification methods. + if f.Probe == releasesHaveAttestation.Probe { continue } default: @@ -163,6 +174,8 @@ func getReleaseName(f *finding.Finding) string { key = releasesAreSigned.ReleaseNameKey case releasesHaveProvenance.Probe: key = releasesHaveProvenance.ReleaseNameKey + case releasesHaveAttestation.Probe: + key = releasesHaveAttestation.ReleaseNameKey } return f.Values[key] } diff --git a/checks/evaluation/signed_releases_test.go b/checks/evaluation/signed_releases_test.go index 6143d285853..57078a3d1b1 100644 --- a/checks/evaluation/signed_releases_test.go +++ b/checks/evaluation/signed_releases_test.go @@ -22,6 +22,7 @@ import ( sce "github.com/ossf/scorecard/v5/errors" "github.com/ossf/scorecard/v5/finding" "github.com/ossf/scorecard/v5/probes/releasesAreSigned" + "github.com/ossf/scorecard/v5/probes/releasesHaveAttestation" "github.com/ossf/scorecard/v5/probes/releasesHaveProvenance" scut "github.com/ossf/scorecard/v5/utests" ) @@ -64,6 +65,17 @@ func provenanceProbe(release, asset int, outcome finding.Outcome) finding.Findin } } +func attestationProbe(release, asset int, outcome finding.Outcome) finding.Finding { + return finding.Finding{ + Probe: releasesHaveAttestation.Probe, + Outcome: outcome, + Values: map[string]string{ + releasesHaveAttestation.ReleaseNameKey: fmt.Sprintf("v%d", release), + releasesHaveAttestation.AssetNameKey: fmt.Sprintf("artifact-%d", asset), + }, + } +} + func TestSignedReleases(t *testing.T) { t.Parallel() tests := []struct { @@ -76,6 +88,7 @@ func TestSignedReleases(t *testing.T) { findings: []finding.Finding{ signedProbe(0, 0, finding.OutcomeTrue), provenanceProbe(0, 0, finding.OutcomeFalse), + attestationProbe(0, 0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: 8, @@ -89,6 +102,7 @@ func TestSignedReleases(t *testing.T) { findings: []finding.Finding{ signedProbe(0, 0, finding.OutcomeTrue), provenanceProbe(0, 0, finding.OutcomeTrue), + attestationProbe(0, 0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: 10, @@ -101,6 +115,7 @@ func TestSignedReleases(t *testing.T) { findings: []finding.Finding{ signedProbe(0, 0, finding.OutcomeFalse), provenanceProbe(0, 0, finding.OutcomeTrue), + attestationProbe(0, 0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: checker.MaxResultScore, @@ -109,6 +124,34 @@ func TestSignedReleases(t *testing.T) { NumberOfDebug: 1, }, }, + { + name: "Has one release with attestation but no signature or provenance", + findings: []finding.Finding{ + signedProbe(0, 0, finding.OutcomeFalse), + provenanceProbe(0, 0, finding.OutcomeFalse), + attestationProbe(0, 0, finding.OutcomeTrue), + }, + result: scut.TestReturn{ + Score: checker.MaxResultScore, + NumberOfInfo: 1, + NumberOfWarn: 1, // provenance warning still fires + NumberOfDebug: 1, + }, + }, + { + name: "Has one release with attestation and signature", + findings: []finding.Finding{ + signedProbe(0, 0, finding.OutcomeTrue), + provenanceProbe(0, 0, finding.OutcomeFalse), + attestationProbe(0, 0, finding.OutcomeTrue), + }, + result: scut.TestReturn{ + Score: checker.MaxResultScore, + NumberOfInfo: 2, // signed + attestation + NumberOfWarn: 1, // provenance warning still fires + NumberOfDebug: 1, + }, + }, { name: "3 releases. One release has one signed, and one release has provenance.", @@ -116,12 +159,15 @@ func TestSignedReleases(t *testing.T) { // Release 1: signedProbe(release0, asset1, finding.OutcomeTrue), provenanceProbe(release0, asset0, finding.OutcomeFalse), + attestationProbe(release0, asset0, finding.OutcomeFalse), // Release 2 signedProbe(release1, asset0, finding.OutcomeFalse), provenanceProbe(release1, asset0, finding.OutcomeFalse), + attestationProbe(release1, asset0, finding.OutcomeFalse), // Release 3 signedProbe(release2, asset0, finding.OutcomeFalse), provenanceProbe(release2, asset1, finding.OutcomeTrue), + attestationProbe(release2, asset0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: 6, @@ -136,18 +182,23 @@ func TestSignedReleases(t *testing.T) { // Release 1: signedProbe(release0, asset1, finding.OutcomeTrue), provenanceProbe(release0, asset1, finding.OutcomeFalse), + attestationProbe(release0, asset1, finding.OutcomeFalse), // Release 2: signedProbe(release1, asset0, finding.OutcomeTrue), provenanceProbe(release1, asset0, finding.OutcomeFalse), + attestationProbe(release1, asset0, finding.OutcomeFalse), // Release 3: signedProbe(release2, asset0, finding.OutcomeFalse), provenanceProbe(release2, asset0, finding.OutcomeTrue), + attestationProbe(release2, asset0, finding.OutcomeFalse), // Release 4, Asset 1: signedProbe(release3, asset0, finding.OutcomeFalse), provenanceProbe(release3, asset0, finding.OutcomeTrue), + attestationProbe(release3, asset0, finding.OutcomeFalse), // Release 5, Asset 1: signedProbe(release4, asset0, finding.OutcomeFalse), provenanceProbe(release4, asset0, finding.OutcomeFalse), + attestationProbe(release4, asset0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: 7, @@ -162,18 +213,23 @@ func TestSignedReleases(t *testing.T) { // Release 1: signedProbe(release0, asset1, finding.OutcomeTrue), provenanceProbe(release0, asset1, finding.OutcomeFalse), + attestationProbe(release0, asset1, finding.OutcomeFalse), // Release 2: signedProbe(release1, asset0, finding.OutcomeTrue), provenanceProbe(release1, asset0, finding.OutcomeFalse), + attestationProbe(release1, asset0, finding.OutcomeFalse), // Release 3: signedProbe(release2, asset0, finding.OutcomeTrue), provenanceProbe(release2, asset0, finding.OutcomeFalse), + attestationProbe(release2, asset0, finding.OutcomeFalse), // Release 4: signedProbe(release3, asset0, finding.OutcomeTrue), provenanceProbe(release3, asset0, finding.OutcomeFalse), + attestationProbe(release3, asset0, finding.OutcomeFalse), // Release 5: signedProbe(release4, asset0, finding.OutcomeTrue), provenanceProbe(release4, asset0, finding.OutcomeFalse), + attestationProbe(release4, asset0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: 8, @@ -189,22 +245,28 @@ func TestSignedReleases(t *testing.T) { // Release 1, Asset 1: signedProbe(release0, asset0, finding.OutcomeTrue), provenanceProbe(release0, asset0, finding.OutcomeTrue), + attestationProbe(release0, asset0, finding.OutcomeFalse), // Release 2: // Release 2, Asset 1: signedProbe(release1, asset0, finding.OutcomeTrue), provenanceProbe(release1, asset0, finding.OutcomeTrue), + attestationProbe(release1, asset0, finding.OutcomeFalse), // Release 3, Asset 1: signedProbe(release2, asset0, finding.OutcomeTrue), provenanceProbe(release2, asset0, finding.OutcomeTrue), + attestationProbe(release2, asset0, finding.OutcomeFalse), // Release 4, Asset 1: signedProbe(release3, asset0, finding.OutcomeTrue), provenanceProbe(release3, asset0, finding.OutcomeTrue), + attestationProbe(release3, asset0, finding.OutcomeFalse), // Release 5, Asset 1: signedProbe(release4, asset0, finding.OutcomeTrue), provenanceProbe(release4, asset0, finding.OutcomeTrue), + attestationProbe(release4, asset0, finding.OutcomeFalse), // Release 6, Asset 1: signedProbe(release5, asset0, finding.OutcomeTrue), provenanceProbe(release5, asset0, finding.OutcomeTrue), + attestationProbe(release5, asset0, finding.OutcomeFalse), }, result: scut.TestReturn{ Score: checker.InconclusiveResultScore, @@ -213,6 +275,37 @@ func TestSignedReleases(t *testing.T) { NumberOfDebug: 6, // 1 for each release }, }, + { + name: "5 releases. All have attestation.", + findings: []finding.Finding{ + // Release 1: + signedProbe(release0, asset0, finding.OutcomeFalse), + provenanceProbe(release0, asset0, finding.OutcomeFalse), + attestationProbe(release0, asset0, finding.OutcomeTrue), + // Release 2: + signedProbe(release1, asset0, finding.OutcomeFalse), + provenanceProbe(release1, asset0, finding.OutcomeFalse), + attestationProbe(release1, asset0, finding.OutcomeTrue), + // Release 3: + signedProbe(release2, asset0, finding.OutcomeFalse), + provenanceProbe(release2, asset0, finding.OutcomeFalse), + attestationProbe(release2, asset0, finding.OutcomeTrue), + // Release 4: + signedProbe(release3, asset0, finding.OutcomeFalse), + provenanceProbe(release3, asset0, finding.OutcomeFalse), + attestationProbe(release3, asset0, finding.OutcomeTrue), + // Release 5: + signedProbe(release4, asset0, finding.OutcomeFalse), + provenanceProbe(release4, asset0, finding.OutcomeFalse), + attestationProbe(release4, asset0, finding.OutcomeTrue), + }, + result: scut.TestReturn{ + Score: 10, + NumberOfInfo: 5, // 1 attestation per release + NumberOfWarn: 5, // provenance warnings still fire + NumberOfDebug: 5, + }, + }, } for _, tt := range tests { diff --git a/clients/githubrepo/releases.go b/clients/githubrepo/releases.go index 96ee6151031..5f7492b4727 100644 --- a/clients/githubrepo/releases.go +++ b/clients/githubrepo/releases.go @@ -26,13 +26,19 @@ import ( sce "github.com/ossf/scorecard/v5/errors" ) +const ( + ownerEndpointUser = "users" + ownerEndpointOrg = "orgs" +) + type releasesHandler struct { - client *github.Client - once *sync.Once - ctx context.Context - errSetup error - repourl *Repo - releases []clients.Release + client *github.Client + once *sync.Once + ctx context.Context + errSetup error + repourl *Repo + releases []clients.Release + ownerEndpointPrefix string } func (handler *releasesHandler) init(ctx context.Context, repourl *Repo) { @@ -55,10 +61,58 @@ func (handler *releasesHandler) setup() error { handler.errSetup = sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("githubv4.Query: %v", err)) } handler.releases = releasesFrom(releases) + handler.ownerEndpointPrefix = handler.resolveOwnerEndpointPrefix() + handler.checkAttestations() }) return handler.errSetup } +// resolveOwnerEndpointPrefix determines whether the repo owner is a GitHub +// user or organization and returns the corresponding API path prefix. +// It calls the GitHub Users API once so that hasAttestation can use a single +// endpoint per asset instead of trying both. +func (handler *releasesHandler) resolveOwnerEndpointPrefix() string { + user, _, err := handler.client.Users.Get(handler.ctx, handler.repourl.owner) + if err != nil { + // Fall back to users; hasAttestation will skip on 404. + return ownerEndpointUser + } + if strings.EqualFold(user.GetType(), "Organization") { + return ownerEndpointOrg + } + return ownerEndpointUser +} + +func (handler *releasesHandler) checkAttestations() { + for i := range handler.releases { + for j := range handler.releases[i].Assets { + asset := &handler.releases[i].Assets[j] + if asset.Digest == "" { + continue + } + asset.HasAttestation = handler.hasAttestation(asset.Digest) + } + } +} + +type attestationResponse struct { + Attestations []interface{} `json:"attestations"` +} + +func (handler *releasesHandler) hasAttestation(digest string) bool { + endpoint := fmt.Sprintf("%s/%s/attestations/%s", handler.ownerEndpointPrefix, handler.repourl.owner, digest) + req, err := handler.client.NewRequest("GET", endpoint, nil) + if err != nil { + return false + } + var body attestationResponse + _, err = handler.client.Do(handler.ctx, req, &body) + if err != nil { + return false + } + return len(body.Attestations) > 0 +} + func (handler *releasesHandler) getReleases() ([]clients.Release, error) { if err := handler.setup(); err != nil { return nil, fmt.Errorf("error during graphqlHandler.setup: %w", err) @@ -76,8 +130,9 @@ func releasesFrom(data []*github.RepositoryRelease) []clients.Release { } for _, a := range r.Assets { release.Assets = append(release.Assets, clients.ReleaseAsset{ - Name: a.GetName(), - URL: r.GetHTMLURL(), + Name: a.GetName(), + URL: r.GetHTMLURL(), + Digest: a.GetDigest(), }) } releases = append(releases, release) diff --git a/clients/githubrepo/releases_test.go b/clients/githubrepo/releases_test.go new file mode 100644 index 00000000000..db7f477a6c3 --- /dev/null +++ b/clients/githubrepo/releases_test.go @@ -0,0 +1,281 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package githubrepo + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/google/go-github/v82/github" + + "github.com/ossf/scorecard/v5/clients" +) + +type releasesRoundTripper struct { + attestedDigests map[string]bool + userType string +} + +func (r *releasesRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + path := req.URL.Path + + // Handle GET /users/{owner} — owner type lookup. + if strings.HasPrefix(path, "/users/") && strings.Count(path, "/") == 2 { + userType := r.userType + if userType == "" { + userType = "User" + } + login := strings.TrimPrefix(path, "/users/") + body := map[string]string{"login": login, "type": userType} + return jsonRespOK(body) + } + + // Handle GET /users/{owner}/attestations/{digest} or /orgs/{owner}/attestations/{digest}. + if strings.Contains(path, "/attestations/") { + parts := strings.SplitN(path, "/attestations/", 2) + if len(parts) == 2 { + digest := parts[1] + var attestations []interface{} + if r.attestedDigests[digest] { + attestations = []interface{}{map[string]string{"id": "1"}} + } + return jsonRespOK(map[string]interface{}{"attestations": attestations}) + } + } + + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("")), + Header: http.Header{}, + }, nil +} + +// errorRoundTripper always returns a 500 error response. +type errorRoundTripper struct{} + +func (e *errorRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("")), + Header: http.Header{}, + }, nil +} + +func jsonRespOK(body interface{}) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(bytes.NewReader(data)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil +} + +func TestReleasesFrom(t *testing.T) { + t.Parallel() + tagName := "v1.0" + url := "https://api.github.com/repos/owner/repo/releases/1" + htmlURL := "https://github.com/owner/repo/releases/tag/v1.0" + commitish := "main" + assetName := "binary.tar.gz" + digest := "sha256:abc123" + + releases := releasesFrom([]*github.RepositoryRelease{ + { + TagName: &tagName, + URL: &url, + TargetCommitish: &commitish, + HTMLURL: &htmlURL, + Assets: []*github.ReleaseAsset{ + { + Name: &assetName, + Digest: &digest, + }, + }, + }, + }) + + if len(releases) != 1 { + t.Fatalf("expected 1 release, got %d", len(releases)) + } + r := releases[0] + if r.TagName != tagName { + t.Errorf("expected TagName %q, got %q", tagName, r.TagName) + } + if r.URL != url { + t.Errorf("expected URL %q, got %q", url, r.URL) + } + if r.TargetCommitish != commitish { + t.Errorf("expected TargetCommitish %q, got %q", commitish, r.TargetCommitish) + } + if len(r.Assets) != 1 { + t.Fatalf("expected 1 asset, got %d", len(r.Assets)) + } + if r.Assets[0].Name != assetName { + t.Errorf("expected asset Name %q, got %q", assetName, r.Assets[0].Name) + } + if r.Assets[0].URL != htmlURL { + t.Errorf("expected asset URL %q, got %q", htmlURL, r.Assets[0].URL) + } + if r.Assets[0].Digest != digest { + t.Errorf("expected asset Digest %q, got %q", digest, r.Assets[0].Digest) + } +} + +func TestReleasesFrom_NilDigest(t *testing.T) { + t.Parallel() + tagName := "v1.0" + assetName := "binary.tar.gz" + releases := releasesFrom([]*github.RepositoryRelease{ + { + TagName: &tagName, + Assets: []*github.ReleaseAsset{{Name: &assetName}}, + }, + }) + if len(releases) != 1 { + t.Fatalf("expected 1 release, got %d", len(releases)) + } + if releases[0].Assets[0].Digest != "" { + t.Errorf("expected empty Digest for nil pointer, got %q", releases[0].Assets[0].Digest) + } +} + +func TestReleasesFrom_Empty(t *testing.T) { + t.Parallel() + if releases := releasesFrom(nil); releases != nil { + t.Errorf("expected nil releases for nil input, got %v", releases) + } +} + +func TestResolveOwnerEndpointPrefix_User(t *testing.T) { + t.Parallel() + rt := &releasesRoundTripper{userType: "User"} + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: rt}), + ctx: t.Context(), + repourl: &Repo{owner: "testuser", repo: "repo"}, + } + if got := handler.resolveOwnerEndpointPrefix(); got != ownerEndpointUser { + t.Errorf("expected %q, got %q", ownerEndpointUser, got) + } +} + +func TestResolveOwnerEndpointPrefix_Org(t *testing.T) { + t.Parallel() + rt := &releasesRoundTripper{userType: "Organization"} + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: rt}), + ctx: t.Context(), + repourl: &Repo{owner: "testorg", repo: "repo"}, + } + if got := handler.resolveOwnerEndpointPrefix(); got != ownerEndpointOrg { + t.Errorf("expected %q, got %q", ownerEndpointOrg, got) + } +} + +func TestResolveOwnerEndpointPrefix_Error(t *testing.T) { + t.Parallel() + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: &errorRoundTripper{}}), + ctx: t.Context(), + repourl: &Repo{owner: "anyowner", repo: "repo"}, + } + if got := handler.resolveOwnerEndpointPrefix(); got != ownerEndpointUser { + t.Errorf("expected fallback to %q, got %q", ownerEndpointUser, got) + } +} + +func TestHasAttestation_Found(t *testing.T) { + t.Parallel() + const digest = "sha256:abc123" + rt := &releasesRoundTripper{attestedDigests: map[string]bool{digest: true}} + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: rt}), + ctx: t.Context(), + repourl: &Repo{owner: "testuser", repo: "repo"}, + ownerEndpointPrefix: ownerEndpointUser, + } + if !handler.hasAttestation(digest) { + t.Error("expected attestation to be found") + } +} + +func TestHasAttestation_NotFound(t *testing.T) { + t.Parallel() + rt := &releasesRoundTripper{attestedDigests: map[string]bool{}} + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: rt}), + ctx: t.Context(), + repourl: &Repo{owner: "testuser", repo: "repo"}, + ownerEndpointPrefix: ownerEndpointUser, + } + if handler.hasAttestation("sha256:notfound") { + t.Error("expected attestation not to be found") + } +} + +func TestHasAttestation_RequestError(t *testing.T) { + t.Parallel() + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: &errorRoundTripper{}}), + ctx: t.Context(), + repourl: &Repo{owner: "testuser", repo: "repo"}, + ownerEndpointPrefix: ownerEndpointUser, + } + if handler.hasAttestation("sha256:any") { + t.Error("expected false on request error") + } +} + +func TestCheckAttestations(t *testing.T) { + t.Parallel() + const attestedDigest = "sha256:attested" + rt := &releasesRoundTripper{attestedDigests: map[string]bool{attestedDigest: true}} + handler := &releasesHandler{ + client: github.NewClient(&http.Client{Transport: rt}), + ctx: t.Context(), + repourl: &Repo{owner: "testuser", repo: "repo"}, + ownerEndpointPrefix: ownerEndpointUser, + releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + {Name: "attested.tar.gz", Digest: attestedDigest}, + {Name: "no-digest.tar.gz", Digest: ""}, + {Name: "not-attested.tar.gz", Digest: "sha256:other"}, + }, + }, + }, + } + handler.checkAttestations() + + assets := handler.releases[0].Assets + if !assets[0].HasAttestation { + t.Errorf("expected asset %q to have attestation", assets[0].Name) + } + if assets[1].HasAttestation { + t.Errorf("expected asset %q (no digest) NOT to have attestation", assets[1].Name) + } + if assets[2].HasAttestation { + t.Errorf("expected asset %q NOT to have attestation", assets[2].Name) + } +} diff --git a/clients/release.go b/clients/release.go index 32d47a0f2ec..5c72c7dad8f 100644 --- a/clients/release.go +++ b/clients/release.go @@ -24,6 +24,8 @@ type Release struct { // ReleaseAsset is part of the Release bundle. type ReleaseAsset struct { - Name string - URL string + Name string + URL string + Digest string + HasAttestation bool } diff --git a/docs/checks.md b/docs/checks.md index 3c9325711cb..9ade2c1d9f7 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -633,6 +633,7 @@ This check looks for the following filenames in the project's last five If a signature is found in the assets for each release, a score of 8 is given. If a [SLSA provenance file](https://slsa.dev/spec/v0.1/index) is found in the assets for each release (*.intoto.jsonl), the maximum score of 10 is given. +If all release assets have a digest and a corresponding [GitHub artifact attestation](https://docs.github.com/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations), the maximum score of 10 is given. This check looks for the 30 most recent releases associated with an artifact. It ignores the source code-only releases that are created automatically by GitHub. @@ -646,6 +647,7 @@ Note: The check does not verify the signatures. - Sign the release archive with this key (should output a signature file). - Attach the signature file next to the release archive. - If the source is hosted on GitHub, check out the steps [here](https://wiki.debian.org/Creating%20signed%20GitHub%20releases). +- Alternatively, use [GitHub artifact attestations](https://docs.github.com/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) to attest all release assets. ## Token-Permissions diff --git a/docs/checks/internal/checks.yaml b/docs/checks/internal/checks.yaml index bab6591a16f..bce83191d4a 100644 --- a/docs/checks/internal/checks.yaml +++ b/docs/checks/internal/checks.yaml @@ -672,6 +672,7 @@ checks: If a signature is found in the assets for each release, a score of 8 is given. If a [SLSA provenance file](https://slsa.dev/spec/v0.1/index) is found in the assets for each release (*.intoto.jsonl), the maximum score of 10 is given. + If all release assets have a digest and a corresponding [GitHub artifact attestation](https://docs.github.com/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations), the maximum score of 10 is given. This check looks for the 30 most recent releases associated with an artifact. It ignores the source code-only releases that are created automatically by GitHub. @@ -690,6 +691,8 @@ checks: - >- If the source is hosted on GitHub, check out the steps [here](https://wiki.debian.org/Creating%20signed%20GitHub%20releases). + - >- + Alternatively, use [GitHub artifact attestations](https://docs.github.com/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations) to attest all release assets. Token-Permissions: risk: High tags: supply-chain, security, infrastructure diff --git a/docs/probes.md b/docs/probes.md index 58332f306ac..0a077ff1d3a 100644 --- a/docs/probes.md +++ b/docs/probes.md @@ -426,6 +426,21 @@ For each of the last 5 releases, the probe returns OutcomeFalse, if the release If the project has no releases, the probe returns OutcomeNotApplicable. +## releasesHaveAttestation + +**Lifecycle**: experimental + +**Description**: Check that the project's GitHub releases have artifact attestations. + +**Motivation**: GitHub artifact attestations provide cryptographically verifiable provenance for release assets, allowing consumers to verify that artifacts were produced by a known workflow before consuming them. + +**Implementation**: The probe checks whether all release assets in the last five GitHub releases have artifact attestations. It uses the release asset digest exposed by GitHub and verifies that a corresponding attestation exists via the GitHub attestations API. + +**Outcomes**: For each of the last 5 releases, the probe returns OutcomeTrue if all release assets have artifact attestations. +For each of the last 5 releases, the probe returns OutcomeFalse if one or more release assets do not have artifact attestations. +If the project has no GitHub releases, the probe returns OutcomeNotApplicable. + + ## releasesHaveProvenance **Lifecycle**: stable diff --git a/probes/entries.go b/probes/entries.go index 2a8e5c07b68..7de315264a5 100644 --- a/probes/entries.go +++ b/probes/entries.go @@ -47,6 +47,7 @@ import ( "github.com/ossf/scorecard/v5/probes/packagedWithAutomatedWorkflow" "github.com/ossf/scorecard/v5/probes/pinsDependencies" "github.com/ossf/scorecard/v5/probes/releasesAreSigned" + "github.com/ossf/scorecard/v5/probes/releasesHaveAttestation" "github.com/ossf/scorecard/v5/probes/releasesHaveProvenance" "github.com/ossf/scorecard/v5/probes/releasesHaveVerifiedProvenance" "github.com/ossf/scorecard/v5/probes/requiresApproversForPullRequests" @@ -140,6 +141,7 @@ var ( } SignedReleases = []ProbeImpl{ releasesAreSigned.Run, + releasesHaveAttestation.Run, releasesHaveProvenance.Run, } BranchProtection = []ProbeImpl{ diff --git a/probes/releasesHaveAttestation/def.yml b/probes/releasesHaveAttestation/def.yml new file mode 100644 index 00000000000..4a668c8bbb7 --- /dev/null +++ b/probes/releasesHaveAttestation/def.yml @@ -0,0 +1,39 @@ +# Copyright 2025 OpenSSF Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +id: releasesHaveAttestation +lifecycle: experimental +short: Check that the project's GitHub releases have artifact attestations. +motivation: > + GitHub artifact attestations provide cryptographically verifiable provenance for release assets, + allowing consumers to verify that artifacts were produced by a known workflow before consuming them. +implementation: > + The probe checks whether all release assets in the last five GitHub releases have artifact + attestations. It uses the release asset digest exposed by GitHub and verifies that a corresponding + attestation exists via the GitHub attestations API. +outcome: + - For each of the last 5 releases, the probe returns OutcomeTrue if all release assets have artifact attestations. + - For each of the last 5 releases, the probe returns OutcomeFalse if one or more release assets do not have artifact attestations. + - If the project has no GitHub releases, the probe returns OutcomeNotApplicable. +remediation: + onOutcome: False + effort: Medium + text: + - Use GitHub artifact attestations to attest your release artifacts. See https://docs.github.com/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations for details. + - Ensure that all release assets have a corresponding digest and attestation. +ecosystem: + languages: + - all + clients: + - github diff --git a/probes/releasesHaveAttestation/impl.go b/probes/releasesHaveAttestation/impl.go new file mode 100644 index 00000000000..9eb6cc255c8 --- /dev/null +++ b/probes/releasesHaveAttestation/impl.go @@ -0,0 +1,120 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package releasesHaveAttestation + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/checknames" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func init() { + probes.MustRegister(Probe, Run, []checknames.CheckName{checknames.SignedReleases}) +} + +//go:embed *.yml +var fs embed.FS + +const ( + Probe = "releasesHaveAttestation" + ReleaseNameKey = "releaseName" + AssetNameKey = "assetName" + releaseLookBack = 5 +) + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + var findings []finding.Finding + + releases := raw.SignedReleasesResults.Releases + + for i := range releases { + if i >= releaseLookBack { + break + } + release := releases[i] + if len(release.Assets) == 0 { + continue + } + + // A release is considered attested if all assets have a digest and + // each digest has a corresponding GitHub artifact attestation. + allAttested := true + for j := range release.Assets { + asset := release.Assets[j] + if asset.Digest == "" || !asset.HasAttestation { + allAttested = false + break + } + } + + if allAttested { + // All assets in this release have attestations — report the first one as representative. + asset := release.Assets[0] + loc := &finding.Location{ + Type: finding.FileTypeURL, + Path: asset.URL, + } + f, err := finding.NewWith(fs, Probe, + fmt.Sprintf("release artifact %s has attestations for all assets", release.TagName), + loc, + finding.OutcomeTrue) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + f.Values = map[string]string{ + ReleaseNameKey: release.TagName, + AssetNameKey: asset.Name, + } + findings = append(findings, *f) + continue + } + + // At least one asset is missing a digest or attestation. + loc := &finding.Location{ + Type: finding.FileTypeURL, + Path: release.URL, + } + f, err := finding.NewWith(fs, Probe, + fmt.Sprintf("release artifact %s does not have attestations for all assets", release.TagName), + loc, + finding.OutcomeFalse) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + f = f.WithValue(ReleaseNameKey, release.TagName) + findings = append(findings, *f) + } + + if len(findings) == 0 { + f, err := finding.NewWith(fs, Probe, + "no GitHub releases found", + nil, + finding.OutcomeNotApplicable) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + return findings, Probe, nil +} diff --git a/probes/releasesHaveAttestation/impl_test.go b/probes/releasesHaveAttestation/impl_test.go new file mode 100644 index 00000000000..70f5ee58242 --- /dev/null +++ b/probes/releasesHaveAttestation/impl_test.go @@ -0,0 +1,241 @@ +// Copyright 2025 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package releasesHaveAttestation + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" +) + +func Test_Run(t *testing.T) { + t.Parallel() + //nolint:govet + tests := []struct { + name string + raw *checker.RawResults + outcomes []finding.Outcome + err error + }{ + { + name: "no releases", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{}, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "release with no assets", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{}, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + name: "one release, all assets attested", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + { + Name: "binary.tar.gz", + Digest: "sha256:abc123", + HasAttestation: true, + }, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "one release, asset missing digest", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + { + Name: "binary.tar.gz", + Digest: "", + HasAttestation: false, + }, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "one release, asset has digest but no attestation", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + { + Name: "binary.tar.gz", + Digest: "sha256:abc123", + HasAttestation: false, + }, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "two releases, both fully attested", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v2.0", + Assets: []clients.ReleaseAsset{ + {Name: "binary.tar.gz", Digest: "sha256:def456", HasAttestation: true}, + }, + }, + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + {Name: "binary.tar.gz", Digest: "sha256:abc123", HasAttestation: true}, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + finding.OutcomeTrue, + }, + }, + { + name: "two releases, one attested and one not", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v2.0", + Assets: []clients.ReleaseAsset{ + {Name: "binary.tar.gz", Digest: "sha256:def456", HasAttestation: true}, + }, + }, + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + {Name: "binary.tar.gz", Digest: "sha256:abc123", HasAttestation: false}, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + finding.OutcomeFalse, + }, + }, + { + name: "release with multiple assets, one missing attestation", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + { + TagName: "v1.0", + Assets: []clients.ReleaseAsset{ + {Name: "binary.tar.gz", Digest: "sha256:abc123", HasAttestation: true}, + {Name: "binary.exe", Digest: "sha256:def456", HasAttestation: false}, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "enforce lookback limit of 5 releases", + raw: &checker.RawResults{ + SignedReleasesResults: checker.SignedReleasesData{ + Releases: []clients.Release{ + {TagName: "v6.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:1", HasAttestation: true}}}, + {TagName: "v5.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:2", HasAttestation: true}}}, + {TagName: "v4.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:3", HasAttestation: true}}}, + {TagName: "v3.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:4", HasAttestation: true}}}, + {TagName: "v2.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:5", HasAttestation: true}}}, + // v1.0 should not be checked + {TagName: "v1.0", Assets: []clients.ReleaseAsset{{Name: "f", Digest: "sha256:6", HasAttestation: false}}}, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + finding.OutcomeTrue, + finding.OutcomeTrue, + finding.OutcomeTrue, + finding.OutcomeTrue, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + findings, s, err := Run(tt.raw) + if !cmp.Equal(tt.err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +}