Skip to content
Open
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
37 changes: 37 additions & 0 deletions data/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ type payloadCache struct {
documentation []DocumentationFile
documentationErr error
documentationLoaded bool
// refLicenses caches LicenseAtRef lookups by ref. More than one control
// evaluates the latest release's license, so without this the same
// endpoint would be hit once per control.
refLicenses map[string]refLicenseCacheEntry
}

type refLicenseCacheEntry struct {
license RefLicense
found bool
err error
}

// AddEvidence, GetEvidence, and ClearEvidence implement gemara.HasEvidence.
Expand Down Expand Up @@ -275,3 +285,30 @@ func (p *Payload) GetDocumentationFiles() ([]DocumentationFile, error) {
p.cache.documentationLoaded = true
return files, err
}

// GetLicenseAtRef returns the license GitHub detects at the given git ref,
// fetched once per payload per ref. Errors are cached deliberately, like
// GetDocumentationFiles: more than one control reads the same ref, and a
// lookup that just failed should surface the same evidence to all of them
// rather than being retried within one run. A payload without a cache (as
// built directly in tests) simply calls through uncached.
func (p *Payload) GetLicenseAtRef(ref string) (RefLicense, bool, error) {
if p.RestData == nil {
return RefLicense{}, false, fmt.Errorf("payload missing required repository data")
}
if p.cache != nil {
if p.cache.refLicenses != nil {
if entry, ok := p.cache.refLicenses[ref]; ok {
return entry.license, entry.found, entry.err
}
}
}
license, found, err := p.LicenseAtRef(ref)
if p.cache != nil {
if p.cache.refLicenses == nil {
p.cache.refLicenses = make(map[string]refLicenseCacheEntry)
}
p.cache.refLicenses[ref] = refLicenseCacheEntry{license: license, found: found, err: err}
}
return license, found, err
}
161 changes: 155 additions & 6 deletions data/rest-data.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package data
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"

Expand Down Expand Up @@ -54,12 +56,20 @@ type RepoContent struct {
}

type ReleaseData struct {
Id int `json:"id"`
Name string `json:"name"`
TagName string `json:"tag_name"`
URL string `json:"url"`
Draft bool `json:"draft"`
Assets []ReleaseAsset `json:"assets"`
Id int `json:"id"`
Name string `json:"name"`
TagName string `json:"tag_name"`
URL string `json:"url"`
Draft bool `json:"draft"`
// Prerelease is GitHub's own flag for a release marked as not
// production-ready; it must be excluded from "latest published release"
// selection alongside drafts.
Prerelease bool `json:"prerelease"`
// PublishedAt is an RFC3339 timestamp. The /releases listing endpoint is
// ordered by creation time, not publish time, so selecting "the latest"
// release requires comparing this field rather than trusting list order.
PublishedAt string `json:"published_at"`
Assets []ReleaseAsset `json:"assets"`
}

type ReleaseAsset struct {
Expand Down Expand Up @@ -523,6 +533,145 @@ func (r *RestData) getReleases() error {
}
}

// RefLicense describes the license GitHub detects in the repository tree at a
// specific git ref.
type RefLicense struct {
// SpdxId is GitHub's classification of the license file, or "NOASSERTION"
// when a license file exists but could not be identified.
SpdxId string
// Path is the location of the license file within the tree at the ref.
Path string
}

// ErrRefUnresolvable is returned by RefExists when GitHub reports no commit
// at the given ref (a deleted or re-pointed tag). Distinguishing this from
// "ref exists but has no license file" matters because a 404 from the
// license-at-ref endpoint means the same thing in both cases.
var ErrRefUnresolvable = errors.New("ref does not resolve to a commit")

// ErrRateLimited is returned when GitHub responds 403, which the retry layer
// treats as permanent rather than transient. A caller basing a verdict on a
// missing signal should treat this differently from "the signal is absent",
// since a rate-limited scan cannot observe the signal at all.
var ErrRateLimited = errors.New("request was rate limited (403)")

// RefExists reports whether ref resolves to a commit. It is used to
// disambiguate a 404 from the license-at-ref endpoint: GitHub returns the same
// 404 whether the ref has no recognized license file or the ref itself no
// longer resolves (e.g. a tag deleted or force-moved after a release was
// published), and only the latter should be reported as ambiguous rather than
// "no license found".
func (r *RestData) RefExists(ref string) (bool, error) {
var logger hclog.Logger
if r.Config != nil {
logger = r.Config.Logger
}
if logger == nil {
logger = hclog.NewNullLogger()
}
if r.HttpClient == nil {
r.HttpClient = &http.Client{}
}
endpoint := fmt.Sprintf("%s/repos/%s/%s/commits/%s", APIBase, r.owner, r.repo, url.QueryEscape(ref))
var exists bool
err := withRetry(logger, fmt.Sprintf("GET %s", endpoint), func() error {
request, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+r.token)
response, err := r.HttpClient.Do(request)
if err != nil {
return fmt.Errorf("error making http call: %s", err.Error())
}
defer func() { _ = response.Body.Close() }()
switch {
case response.StatusCode == http.StatusNotFound:
exists = false
return nil
case response.StatusCode == http.StatusForbidden:
return ErrRateLimited
case response.StatusCode != http.StatusOK:
return fmt.Errorf("unexpected response: %s", response.Status)
default:
exists = true
return nil
}
})
if err != nil {
return false, err
}
return exists, nil
}

// LicenseAtRef fetches the license GitHub detects for the repository at the
// given git ref (typically a release tag). GitHub's auto-generated release
// archives contain the tree at the tag, so this observes the license actually
// shipped with a release, which the default-branch license may no longer match.
//
// A 404 means GitHub found no license file at that ref; it is reported as
// found=false with a nil error, distinct from request failures. MakeApiCall is
// not used here because its uniform non-200 error cannot make that distinction.
// A 403 is reported as ErrRateLimited rather than a generic error, since a
// caller basing a verdict on this lookup's absence needs to tell "checked and
// found nothing" apart from "could not check".
func (r *RestData) LicenseAtRef(ref string) (license RefLicense, found bool, err error) {
var logger hclog.Logger
if r.Config != nil {
logger = r.Config.Logger
}
if logger == nil {
logger = hclog.NewNullLogger()
}
if r.HttpClient == nil {
r.HttpClient = &http.Client{}
}
endpoint := fmt.Sprintf("%s/repos/%s/%s/license?ref=%s", APIBase, r.owner, r.repo, url.QueryEscape(ref))
err = withRetry(logger, fmt.Sprintf("GET %s", endpoint), func() error {
request, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+r.token)
response, err := r.HttpClient.Do(request)
if err != nil {
return fmt.Errorf("error making http call: %s", err.Error())
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode == http.StatusNotFound {
// No license file exists at this ref. That is an observation about
// the release, not a request failure.
return nil
}
if response.StatusCode == http.StatusForbidden {
return ErrRateLimited
}
if response.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected response: %s", response.Status)
}
body, err := io.ReadAll(response.Body)
if err != nil {
return err
}
var decoded struct {
Path string `json:"path"`
License struct {
SpdxId string `json:"spdx_id"`
} `json:"license"`
}
if err := json.Unmarshal(body, &decoded); err != nil {
return fmt.Errorf("failed to decode license data for ref %q: %w", ref, err)
}
license = RefLicense{SpdxId: decoded.License.SpdxId, Path: decoded.Path}
found = true
return nil
})
if err != nil {
return RefLicense{}, false, err
}
return license, found, nil
}

func (r *RestData) getWorkflowPermissions() error {
endpoint := fmt.Sprintf("%s/repos/%s/%s/actions/permissions", APIBase, r.owner, r.repo)
responseData, err := r.MakeApiCall(endpoint, true)
Expand Down
Loading