Files
slsa-verifier/verifiers/internal/gha/provenance_forgeable.go
Ramon Petgrave 7f3db9211e feat: support npm cli provenance v1 attestations (#776)
Fixes #614, #450, #449, #515

Adds support for NPM CLIs build provenances, generated when running `npm
publish --provenance --access public` from a [GitHub Actions
workflow](https://github.com/ramonpetgrave64/gundam-visor/blob/599500821344b070902a7a5666064bfdaba715df/.github/workflows/npm-publish.yml#L21).

## Testing

- added unit tests for some new helper functions
- added regression test cases

## Future work

- https://github.com/slsa-framework/slsa-verifier/issues/493, so we can
do `--print-provenance`
- implemented in
https://github.com/slsa-framework/slsa-verifier/pull/768#discussion_r1662938115

---------

Signed-off-by: Ramon Petgrave <ramon.petgrave64@gmail.com>
2024-07-30 19:46:04 +00:00

455 lines
13 KiB
Go

package gha
import (
"errors"
"fmt"
"strings"
serrors "github.com/slsa-framework/slsa-verifier/v2/errors"
"github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha/slsaprovenance/common"
"github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha/slsaprovenance/iface"
slsav02 "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha/slsaprovenance/v0.2"
slsav1 "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha/slsaprovenance/v1.0"
)
func verifyProvenanceMatchesCertificate(prov iface.Provenance, workflow *WorkflowIdentity) error {
// See the generation at https://github.com/npm/cli/blob/latest/workspaces/libnpmpublish/lib/provenance.js.
// Verify systemParameters.
switch typedProv := prov.(type) {
case *slsav1.NpmCLIGithubActionsProvenance:
if err := verifyNpmCLIGithubActionsV1SystemParameters(typedProv, workflow); err != nil {
return err
}
default:
if err := verifySystemParameters(typedProv, workflow); err != nil {
return err
}
}
// Verify v0.2 parameters.
if err := verifyV02Parameters(prov); err != nil {
return err
}
// Verify metadata.
if err := verifyMetadata(prov, workflow); err != nil {
return err
}
// Verify subjects.
if err := verifyPublishAttestationSubjectDigestName(prov, "sha512"); err != nil {
return err
}
// Verify trigger config.
if err := verifyBuildConfig(prov, workflow); err != nil {
return err
}
// Verify resolved dependencies.
if err := verifyResolvedDependencies(prov); err != nil {
return err
}
// Verify v0.2 build config.
if err := verifyV02BuildConfig(prov); err != nil {
return err
}
// Additional fields can only be present in fields
// defined as interface{}. We already verified buildConfig,
// parameters and environment for v0.2.
// In addition, fields not defined in the structures will cause an error
// because we use stric unmarshaling in slsaprovenance.go.
// TODO(#571): add tests for additional fields in the provenance.
// Other fields such as material and config source URI / sha are verified
// as part of the common verification.
// TODO(#566): verify fields for v1.0 provenance.
return nil
}
func verifyPublishAttestationSubjectDigestName(prov iface.Provenance, digestName string) error {
subjects, err := prov.Subjects()
if err != nil {
return err
}
if len(subjects) != 1 {
return fmt.Errorf("%w: invalid number of digests: %v",
serrors.ErrorNonVerifiableClaim, subjects)
}
_, ok := subjects[0].Digest[digestName]
if !ok {
return fmt.Errorf("%w: digest '%s' not present",
serrors.ErrorNonVerifiableClaim, digestName)
}
return nil
}
func verifyBuildConfig(prov iface.Provenance, workflow *WorkflowIdentity) error {
triggerPath, err := prov.GetBuildTriggerPath()
if err != nil {
// If the field is not available in the provenance,
// we can safely skip the verification against the certificate.
if errors.Is(err, serrors.ErrorNotPresent) {
return nil
}
return err
}
return equalCertificateValue(workflow.BuildConfigPath, triggerPath, "trigger workflow")
}
func verifyResolvedDependencies(prov iface.Provenance) error {
n, err := prov.GetNumberResolvedDependencies()
if err != nil {
return err
}
if n != 1 {
return fmt.Errorf("%w: unexpected number of resolved dependencies: %v",
serrors.ErrorNonVerifiableClaim, n)
}
return nil
}
func verifyMetadata(prov iface.Provenance, workflow *WorkflowIdentity) error {
if err := verifyCommonMetadata(prov, workflow); err != nil {
return err
}
// Verify v0.2 claims.
if err := verifyV02Metadata(prov); err != nil {
return err
}
// TODO(#566): verify fields for v1.0 provenance
return nil
}
func verifyCommonMetadata(prov iface.Provenance, workflow *WorkflowIdentity) error {
// Verify build invocation ID.
provInvocationID, err := prov.GetBuildInvocationID()
if err != nil {
return err
}
if provInvocationID != "" {
// Verify runID and runAttempt.
var provRunID string
var provRunAttempt string
switch prov.(type) {
case *slsav1.NpmCLIGithubActionsProvenance:
provenanceInvocationIDParts := strings.Split(strings.TrimPrefix(provInvocationID, "https://github.com/"), "/")
lenParts := len(provenanceInvocationIDParts)
if lenParts != 7 {
return fmt.Errorf("%w: invalid invocation ID: %v", serrors.ErrorInvalidFormat, provInvocationID)
}
provRunID = provenanceInvocationIDParts[lenParts-3]
provRunAttempt = provenanceInvocationIDParts[lenParts-1]
default:
provenanceInvocationIDParts := strings.Split(provInvocationID, "-")
if len(provenanceInvocationIDParts) != 2 {
return fmt.Errorf("%w: invalid invocation ID: %v", serrors.ErrorInvalidFormat, provInvocationID)
}
provRunID = provenanceInvocationIDParts[0]
provRunAttempt = provenanceInvocationIDParts[1]
}
certRunID, certRunAttempt, err := getRunIDs(workflow)
if err != nil {
return err
}
if provRunID != certRunID {
return fmt.Errorf("%w: run ID: '%v' != '%v'",
serrors.ErrorMismatchCertificate, provRunID, certRunID)
}
if provRunAttempt != certRunAttempt {
return fmt.Errorf("%w: run ID: '%v' != '%v'",
serrors.ErrorMismatchCertificate, provRunAttempt, certRunAttempt)
}
}
// Verify start time.
startTime, err := prov.GetBuildStartTime()
if err != nil {
return err
}
if startTime != nil {
return fmt.Errorf("%w: build start time: %v",
serrors.ErrorNonVerifiableClaim, *startTime)
}
// Verify finish time.
finishTime, err := prov.GetBuildFinishTime()
if err != nil {
return err
}
if finishTime != nil {
return fmt.Errorf("%w: build finish time: %v",
serrors.ErrorNonVerifiableClaim, *finishTime)
}
return nil
}
func verifyV02Metadata(prov iface.Provenance) error {
// https://github.com/in-toto/in-toto-golang/blob/master/in_toto/slsa_provenance/v0.2/provenance.go
/*
v0.2:
"buildInvocationId": "4757060009-1",
"completeness": {
"parameters": false,
"environment": false,
"materials": false
},
"reproducible": false
*/
prov02, ok := prov.(slsav02.ProvenanceV02)
if !ok {
return nil
}
predicate := prov02.Predicate()
if predicate.Metadata == nil {
return nil
}
if predicate.Metadata.Reproducible {
return fmt.Errorf("%w: reproducible: %v",
serrors.ErrorNonVerifiableClaim,
predicate.Metadata.Reproducible)
}
completeness := predicate.Metadata.Completeness
if completeness.Parameters || completeness.Materials ||
completeness.Environment {
return fmt.Errorf("%w: completeness: %v",
serrors.ErrorNonVerifiableClaim,
completeness)
}
return nil
}
func verifyV02Parameters(prov iface.Provenance) error {
// https://github.com/in-toto/in-toto-golang/blob/master/in_toto/slsa_provenance/v0.2/provenance.go
prov02, ok := prov.(slsav02.ProvenanceV02)
if !ok {
return nil
}
predicate := prov02.Predicate()
if predicate.Invocation.Parameters == nil {
return nil
}
m, ok := predicate.Invocation.Parameters.(map[string]any)
if !ok || len(m) > 0 {
return fmt.Errorf("%w: parameters: %v",
serrors.ErrorNonVerifiableClaim, predicate.Invocation.Parameters)
}
return nil
}
func verifyV02BuildConfig(prov iface.Provenance) error {
// https://github.com/in-toto/in-toto-golang/blob/master/in_toto/slsa_provenance/v0.2/provenance.go
prov02, ok := prov.(slsav02.ProvenanceV02)
if !ok {
return nil
}
predicate := prov02.Predicate()
if predicate.BuildConfig == nil {
return nil
}
m, ok := predicate.BuildConfig.(map[string]any)
if !ok || len(m) > 0 {
return fmt.Errorf("%w: buildConfig: %v",
serrors.ErrorNonVerifiableClaim, predicate.BuildConfig)
}
return nil
}
func verifyNpmCLIGithubActionsV1SystemParameters(prov *slsav1.NpmCLIGithubActionsProvenance, workflow *WorkflowIdentity) error {
sysParams, err := prov.GetSystemParameters()
if err != nil {
return err
}
githubParams, ok := sysParams["github"].(map[string]interface{})
if !ok {
return fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, "github parameters")
}
// Verify that the parameters contain only fields we are able to verify
// and that the values match the certificate.
supportedNames := map[string]*string{
"event_name": &workflow.BuildTrigger,
"repository_id": workflow.SourceID,
"repository_owner_id": workflow.SourceOwnerID,
}
for k := range githubParams {
certValue, ok := supportedNames[k]
if !ok {
return fmt.Errorf("%w: unknown '%s' parameter", serrors.ErrorMismatchCertificate, k)
}
if err := verifySystemParameter(githubParams, k, certValue); err != nil {
return err
}
}
return nil
}
func verifySystemParameters(prov iface.Provenance, workflow *WorkflowIdentity) error {
/*
"environment": {
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REF": "refs/heads/main",
"GITHUB_REPOSITORY": "laurentsimon/provenance-npm-test",
"GITHUB_REPOSITORY_ID": "602223945",
"GITHUB_REPOSITORY_OWNER_ID": "64505099",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_RUN_ID": "4757060009",
"GITHUB_SHA": "b38894f2dda4355ea5606fccb166e61565e12a14",
"GITHUB_WORKFLOW_REF": "laurentsimon/provenance-npm-test/.github/workflows/release.yml@refs/heads/main",
"GITHUB_WORKFLOW_SHA": "b38894f2dda4355ea5606fccb166e61565e12a14"
}
*/
sysParams, err := prov.GetSystemParameters()
if err != nil {
return err
}
// Verify that the parameters contain only fields we are able to verify.
// There are 10 fields to verify.
supportedNames := map[string]bool{
"GITHUB_EVENT_NAME": true,
"GITHUB_REF": true,
"GITHUB_REPOSITORY": true,
"GITHUB_REPOSITORY_ID": true,
"GITHUB_REPOSITORY_OWNER_ID": true,
"GITHUB_RUN_ATTEMPT": true,
"GITHUB_RUN_ID": true,
"GITHUB_SHA": true,
"GITHUB_WORKFLOW_REF": true,
"GITHUB_WORKFLOW_SHA": true,
}
for k := range sysParams {
if !supportedNames[k] {
return fmt.Errorf("%w: unknown '%s' parameter", serrors.ErrorMismatchCertificate, k)
}
}
// 1. GITHUB_EVENT_NAME.
if err := verifySystemParameter(sysParams, "GITHUB_EVENT_NAME", &workflow.BuildTrigger); err != nil {
return err
}
// 2. GITHUB_REPOSITORY
if err := verifySystemParameter(sysParams, "GITHUB_REPOSITORY", &workflow.SourceRepository); err != nil {
return err
}
// 3. GITHUB_REF
if err := verifySystemParameter(sysParams, "GITHUB_REF", workflow.SourceRef); err != nil {
return err
}
// 4. GITHUB_REPOSITORY_ID
if err := verifySystemParameter(sysParams, "GITHUB_REPOSITORY_ID", workflow.SourceID); err != nil {
return err
}
// 5. GITHUB_REPOSITORY_OWNER_ID
if err := verifySystemParameter(sysParams, "GITHUB_REPOSITORY_OWNER_ID", workflow.SourceOwnerID); err != nil {
return err
}
// 6. GITHUB_REPOSITORY_SHA
if err := verifySystemParameter(sysParams, "GITHUB_SHA", &workflow.SourceSha1); err != nil {
return err
}
// 7. GITHUB_WORKFLOW_REF
// NOTE: GITHUB_WORKFLOW_REF does not include the server url or leading '/'
workflowPath := strings.TrimLeft(workflow.SubjectWorkflow.Path, "/")
if err := verifySystemParameter(sysParams, "GITHUB_WORKFLOW_REF", &workflowPath); err != nil {
return err
}
// 8. GITHUB_WORKFLOW_SHA
if err := verifySystemParameter(sysParams, "GITHUB_WORKFLOW_SHA", workflow.SubjectSha1); err != nil {
return err
}
// 9-10. GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT
if err := verifySystemRun(sysParams, workflow); err != nil {
return err
}
return nil
}
func getRunIDs(workflow *WorkflowIdentity) (string, string, error) {
if workflow == nil {
return "", "", fmt.Errorf("%w: empty workflow", serrors.ErrorInvalidFormat)
}
if workflow.RunID == nil {
return "", "", nil
}
parts := strings.Split(*workflow.RunID, "/")
if len(parts) != 3 {
return "", "", fmt.Errorf("%w: %s", serrors.ErrorInvalidFormat, *workflow.RunID)
}
return parts[0], parts[2], nil
}
func verifySystemRun(params map[string]any, workflow *WorkflowIdentity) error {
// Verify only if the values are provided in the provenance.
if !common.Exists(params, "GITHUB_RUN_ID") && !common.Exists(params, "GITHUB_RUN_ATTEMPT") {
return nil
}
// The certificate contains runID as '4757060009/attempts/1'.
if workflow.RunID == nil {
return fmt.Errorf("%w: empty certificate value to verify 'GITHUB_RUN_*'",
serrors.ErrorMismatchCertificate)
}
runID, runAttempt, err := getRunIDs(workflow)
if err != nil {
return err
}
if err := verifySystemParameter(params, "GITHUB_RUN_ID", &runID); err != nil {
return err
}
if err := verifySystemParameter(params, "GITHUB_RUN_ATTEMPT", &runAttempt); err != nil {
return err
}
return nil
}
func verifySystemParameter(params map[string]any, name string, certValue *string) error {
// If the provenance does not contain an env variable.
if !common.Exists(params, name) {
return nil
}
// Provenance contains the field, we must verify it.
provValue, err := common.GetAsString(params, name)
if err != nil {
return err
}
// The certificate must have the value. Old Fulcio certs are not
// supported.
return equalCertificateValue(certValue, provValue, name)
}
func equalCertificateValue(expected *string, actual, logName string) error {
if expected == nil {
return fmt.Errorf("%w: empty certificate value to verify '%s'",
serrors.ErrorMismatchCertificate, logName)
}
if actual != *expected {
return fmt.Errorf("%w: %s: '%s' != '%s'", serrors.ErrorMismatchCertificate,
logName, actual, *expected)
}
return nil
}