From 3d27f18a67e12a251517ca9af35771a93da39526 Mon Sep 17 00:00:00 2001 From: Ian Lewis Date: Thu, 1 Sep 2022 11:16:35 +0900 Subject: [PATCH] Rename attestation-name (#777) * Refactor attest command Signed-off-by: Ian Lewis * Add testutil package Signed-off-by: Ian Lewis * Set default provenance name and add tests Signed-off-by: Ian Lewis * Update workflows to use updated builder Signed-off-by: Ian Lewis * Update doc for generic generator. Signed-off-by: Ian Lewis * Fix linter issues Signed-off-by: Ian Lewis * Use new output name Signed-off-by: Ian Lewis * Add generate command unit tests Signed-off-by: Ian Lewis * Fix deprecation warning. Signed-off-by: Ian Lewis * write newlines Signed-off-by: Ian Lewis * Add debug Signed-off-by: Ian Lewis * Fix provenance outputs Signed-off-by: Ian Lewis * Add comment on untrusted inputs Signed-off-by: Ian Lewis * Add test for dir traversal Signed-off-by: Ian Lewis Signed-off-by: Ian Lewis --- .../workflows/generator_container_slsa3.yml | 2 +- .github/workflows/generator_generic_slsa3.yml | 61 +++--- .../pre-submit.e2e.generic.default.yml | 4 +- internal/builders/generic/README.md | 20 +- internal/builders/generic/attest.go | 192 ++++------------- internal/builders/generic/attest_test.go | 200 +++++++++++++++++- internal/builders/generic/generate.go | 85 ++++++++ internal/builders/generic/generate_test.go | 133 ++++++++++++ internal/builders/generic/generic.go | 143 +++++++++++++ internal/builders/generic/main.go | 15 +- internal/builders/go/pkg/provenance_test.go | 38 +--- internal/testutil/signing.go | 92 ++++++++ 12 files changed, 742 insertions(+), 243 deletions(-) create mode 100644 internal/builders/generic/generate.go create mode 100644 internal/builders/generic/generate_test.go create mode 100644 internal/builders/generic/generic.go create mode 100644 internal/testutil/signing.go diff --git a/.github/workflows/generator_container_slsa3.yml b/.github/workflows/generator_container_slsa3.yml index bea5f327ab..943f13acbb 100644 --- a/.github/workflows/generator_container_slsa3.yml +++ b/.github/workflows/generator_container_slsa3.yml @@ -126,7 +126,7 @@ jobs: # Generate a predicate only. predicate_name="predicate.json" - ./"$BUILDER_BINARY" attest --signature="" --predicate="$predicate_name" + ./"$BUILDER_BINARY" generate --predicate="$predicate_name" COSIGN_EXPERIMENTAL=1 cosign attest --predicate="$predicate_name" \ --type slsaprovenance \ diff --git a/.github/workflows/generator_generic_slsa3.yml b/.github/workflows/generator_generic_slsa3.yml index ea86af8d47..42a4bbed0e 100644 --- a/.github/workflows/generator_generic_slsa3.yml +++ b/.github/workflows/generator_generic_slsa3.yml @@ -35,14 +35,13 @@ on: type: boolean default: false attestation-name: - description: > - The artifact name of the signed provenance. - The file must have the intoto.jsonl extension. - - Default: attestation.intoto.jsonl + description: "The artifact name of the signed provenance. The file must have the intoto.jsonl extension. Defaults to .intoto.jsonl for single artifact or multiple.intoto.jsonl for multiple artifacts. DEPRECATED: Use provenance-name instead." + required: false + type: string + provenance-name: + description: The artifact name of the signed provenance. The file must have the intoto.jsonl extension. Defaults to .intoto.jsonl for single artifact or multiple.intoto.jsonl for multiple artifacts. required: false type: string - default: "attestation.intoto.jsonl" compile-generator: description: "Build the generator from source. This increases build time by ~2m." required: false @@ -53,8 +52,11 @@ on: description: "The name of the release where provenance was uploaded." value: ${{ jobs.create-release.outputs.release-id }} attestation-name: + description: "DEPRECATED: use the provenance-name output instead." + value: ${{ jobs.generator.outputs.provenance-name }} + provenance-name: description: "The artifact name of the signed provenance. (A file with the intoto.jsonl extension)." - value: "${{ inputs.attestation-name }}" + value: ${{ jobs.generator.outputs.provenance-name }} jobs: # detect-env detects the reusable workflow's repository and ref for use later @@ -82,7 +84,8 @@ jobs: # reference. generator: outputs: - attestation-sha256: ${{ steps.sign-prov.outputs.attestation-sha256 }} + provenance-sha256: ${{ steps.sign-prov.outputs.provenance-sha256 }} + provenance-name: ${{ steps.sign-prov.outputs.provenance-name }} runs-on: ubuntu-latest needs: [detect-env] permissions: @@ -109,30 +112,36 @@ jobs: # order to avoid script injection. # See: https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections env: - SUBJECTS: "${{ inputs.base64-subjects }}" GITHUB_CONTEXT: "${{ toJSON(github) }}" - UNTRUSTED_ATTESTATION_NAME: "${{ inputs.attestation-name }}" + UNTRUSTED_SUBJECTS: "${{ inputs.base64-subjects }}" + UNTRUSTED_PROVENANCE_NAME: "${{ inputs.provenance-name }}" + UNTRUSTED_DEPRECATED_ATTESTATION_NAME: "${{ inputs.attestation-name }}" run: | set -euo pipefail - # NOTE: The generator binary allows the attestation to be "" in which - # case it does not sign or generate provenance. However, this workflow - # requires it to be non-empty so we validate it here. - if [ "$UNTRUSTED_ATTESTATION_NAME" == "" ]; then - echo "attestation-name cannot be empty." - exit 5 + untrusted_provenance_name="" + if [ "$UNTRUSTED_PROVENANCE_NAME" != "" ]; then + untrusted_provenance_name="$UNTRUSTED_PROVENANCE_NAME" + else + if [ "$UNTRUSTED_DEPRECATED_ATTESTATION_NAME" != "" ]; then + echo "WARNING: deprecated attestation-name was used. Use provenance-name instead." + untrusted_provenance_name="$UNTRUSTED_DEPRECATED_ATTESTATION_NAME" + fi fi # Create and sign provenance. - # Note: The builder verifies that the UNTRUSTED_ATTESTATION_NAME is located + # NOTE: The builder verifies that the provenance path is located # in the current directory. - ./"$BUILDER_BINARY" attest --subjects "${SUBJECTS}" -g "$UNTRUSTED_ATTESTATION_NAME" - attestation_sha256=$(sha256sum "$UNTRUSTED_ATTESTATION_NAME" | awk '{print $1}') - echo "::set-output name=attestation-sha256::$attestation_sha256" + # NOTE: $untrusted_provenance_path may be empty. In this case the + # attest command chooses a file name based on the subject name and + # number of subjects based on in-toto attestation bundle file naming conventions. + # See: https://github.com/in-toto/attestation/blob/main/spec/bundle.md#file-naming-convention + # NOTE: The attest commmand outputs the provenance-name and provenance-sha256 + ./"$BUILDER_BINARY" attest --subjects "${UNTRUSTED_SUBJECTS}" -g "$untrusted_provenance_name" - name: Upload the signed provenance uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3.1.0 with: - name: "${{ inputs.attestation-name }}" - path: "${{ inputs.attestation-name }}" + name: "${{ steps.sign-prov.outputs.provenance-name }}" + path: "${{ steps.sign-prov.outputs.provenance-name }}" if-no-files-found: error retention-days: 5 @@ -150,13 +159,13 @@ jobs: - name: Download the provenance uses: slsa-framework/slsa-github-generator/.github/actions/secure-download-artifact@07d42a79c8531365637e425fac6a95bf0f5dc29e with: - name: "${{ inputs.attestation-name }}" - path: "${{ inputs.attestation-name }}" - sha256: "${{ needs.generator.outputs.attestation-sha256 }}" + name: "${{ needs.generator.outputs.provenance-name }}" + path: "${{ needs.generator.outputs.provenance-name }}" + sha256: "${{ needs.generator.outputs.provenance-sha256 }}" - name: Release uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5 # tag=v0.1.14 id: release with: files: | - ${{ inputs.attestation-name }} + ${{ needs.generator.outputs.provenance-name }} diff --git a/.github/workflows/pre-submit.e2e.generic.default.yml b/.github/workflows/pre-submit.e2e.generic.default.yml index 59dff6a05d..82e28d87a8 100644 --- a/.github/workflows/pre-submit.e2e.generic.default.yml +++ b/.github/workflows/pre-submit.e2e.generic.default.yml @@ -34,8 +34,8 @@ jobs: - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # tag=v3.0.2 - uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741 # tag=v3.0.0 with: - name: ${{ needs.build.outputs.attestation-name }} + name: ${{ needs.build.outputs.provenance-name }} - env: BINARY: "binary-name" - PROVENANCE: ${{ needs.build.outputs.attestation-name }} + PROVENANCE: ${{ needs.build.outputs.provenance-name }} run: ./.github/workflows/scripts/pre-submit.e2e.generic.default.sh diff --git a/internal/builders/generic/README.md b/internal/builders/generic/README.md index 1d600def55..39859394db 100644 --- a/internal/builders/generic/README.md +++ b/internal/builders/generic/README.md @@ -135,8 +135,6 @@ jobs: uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.2.0 with: base64-subjects: "${{ needs.build.outputs.hashes }}" - # Set a custom name for the provenance attestation. - attestation-name: "artifacts.intoto.jsonl" # Upload provenance to a new release upload-assets: true @@ -183,19 +181,21 @@ issue](https://github.com/slsa-framework/slsa-github-generator/issues/new/choose The [generic workflow](https://github.com/slsa-framework/slsa-github-generator/blob/main/.github/workflows/generator_generic_slsa3.yml) accepts the following inputs: -| Name | Required | Default | Description | -| ------------------ | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `base64-subjects` | yes | | Artifact(s) for which to generate provenance, formatted the same as the output of sha256sum (SHA256 NAME\n[...]) and base64 encoded. The encoded value should decode to, for example: `90f3f7d6c862883ab9d856563a81ea6466eb1123b55bff11198b4ed0030cac86 foo.zip` | -| `upload-assets` | no | false | If true provenance is uploaded to a GitHub release for new tags. | -| `attestation-name` | no | "attestation.intoto.jsonl" | The artifact name of the signed provenance. The file must have the `intoto.jsonl` extension. | +| Name | Required | Default | Description | +| ------------------ | -------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `base64-subjects` | yes | | Artifact(s) for which to generate provenance, formatted the same as the output of sha256sum (SHA256 NAME\n[...]) and base64 encoded. The encoded value should decode to, for example: `90f3f7d6c862883ab9d856563a81ea6466eb1123b55bff11198b4ed0030cac86 foo.zip` | +| `upload-assets` | no | false | If true provenance is uploaded to a GitHub release for new tags. | +| `provenance-name` | no | "(subject name).intoto.jsonl" if a single subject. "multiple.intoto.json" if multiple subjects. | The artifact name of the signed provenance. The file must have the `intoto.jsonl` extension. | +| `attestation-name` | no | "(subject name).intoto.jsonl" if a single subject. "multiple.intoto.json" if multiple subjects. | The artifact name of the signed provenance. The file must have the `intoto.jsonl` extension. DEPRECATED: use `provenance-name` instead. | ### Workflow Outputs The [generic workflow](https://github.com/slsa-framework/slsa-github-generator/blob/main/.github/workflows/generator_generic_slsa3.yml) produces the following outputs: -| Name | Description | -| ------------------ | ------------------------------------------ | -| `attestation-name` | The artifact name of the signed provenance | +| Name | Description | +| ------------------ | -------------------------------------------------------------------------------------- | +| `provenance-name` | The artifact name of the signed provenance. | +| `attestation-name` | The artifact name of the signed provenance. DEPRECATED: use `provenance-name` instead. | ### Provenance Format diff --git a/internal/builders/generic/attest.go b/internal/builders/generic/attest.go index 2a251a1cd2..d3e9aa4990 100644 --- a/internal/builders/generic/attest.go +++ b/internal/builders/generic/attest.go @@ -15,132 +15,30 @@ package main import ( - "bufio" - "bytes" "context" - "encoding/base64" + "crypto/sha256" "encoding/json" + "fmt" "os" - "regexp" - "strings" intoto "github.com/in-toto/in-toto-golang/in_toto" - slsav02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" "github.com/spf13/cobra" "github.com/slsa-framework/slsa-github-generator/github" "github.com/slsa-framework/slsa-github-generator/internal/errors" "github.com/slsa-framework/slsa-github-generator/internal/utils" - "github.com/slsa-framework/slsa-github-generator/signing/sigstore" + "github.com/slsa-framework/slsa-github-generator/signing" "github.com/slsa-framework/slsa-github-generator/slsa" ) -var ( - // shaCheck verifies a hash is has only hexadecimal digits and is 64 - // characters long. - shaCheck = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) - - // wsSplit is used to split lines in the subjects input. - wsSplit = regexp.MustCompile(`[\t ]`) - - // provenanceOnlyBuildType is the URI for provenance only SLSA generation. - provenanceOnlyBuildType = "https://github.com/slsa-framework/slsa-github-generator/generic@v1" -) - -// errBase64 indicates a base64 error in the subject. -type errBase64 struct { - errors.WrappableError -} - -// errSha indicates a error in the hash format. -type errSha struct { - errors.WrappableError -} - -// errNoName indicates a missing subject name. -type errNoName struct { - errors.WrappableError -} - -// errDuplicateSubject indicates a duplicate subject name. -type errDuplicateSubject struct { - errors.WrappableError -} - -// errScan is an error scanning the SHA digest data. -type errScan struct { - errors.WrappableError -} - -// parseSubjects parses the value given to the subjects option. -func parseSubjects(b64str string) ([]intoto.Subject, error) { - var parsed []intoto.Subject - - subjects, err := base64.StdEncoding.DecodeString(b64str) - if err != nil { - return nil, errors.Errorf(&errBase64{}, "error decoding subjects (is it base64 encoded?): %w", err) - } - - scanner := bufio.NewScanner(bytes.NewReader(subjects)) - for scanner.Scan() { - // Split by whitespace, and get values. - parts := wsSplit.Split(strings.TrimSpace(scanner.Text()), 2) - - // Lowercase the sha digest to comply with the SLSA spec. - shaDigest := strings.ToLower(strings.TrimSpace(parts[0])) - if shaDigest == "" { - // Ignore empty lines. - continue - } - // Do a sanity check on the SHA to make sure it's a proper hex digest. - if !shaCheck.MatchString(shaDigest) { - return nil, errors.Errorf(&errSha{}, "unexpected sha256 hash format for %q", shaDigest) - } - - // Check for the subject name. - if len(parts) == 1 { - return nil, errors.Errorf(&errNoName{}, "expected subject name for hash %q", shaDigest) - } - name := strings.TrimSpace(parts[1]) - - for _, p := range parsed { - if p.Name == name { - return nil, errors.Errorf(&errDuplicateSubject{}, "duplicate subject %q", name) - } - } - - parsed = append(parsed, intoto.Subject{ - Name: name, - Digest: slsav02.DigestSet{ - "sha256": shaDigest, - }, - }) - } - if err := scanner.Err(); err != nil { - return nil, errors.Errorf(&errScan{}, "reading digest: %w", err) - } - - return parsed, nil -} - -type provenanceOnlyBuild struct { - *slsa.GithubActionsBuild -} - -// URI implements BuildType.URI. -func (b *provenanceOnlyBuild) URI() string { - return provenanceOnlyBuildType -} - // attestCmd returns the 'attest' command. -func attestCmd(provider slsa.ClientProvider) *cobra.Command { - var predicatePath string +func attestCmd(provider slsa.ClientProvider, check func(error), signer signing.Signer, tlog signing.TransparencyLog) *cobra.Command { var attPath string var subjects string c := &cobra.Command{ Use: "attest", - Short: "Create a signed SLSA attestation from a Github Action", + Short: "Create a signed SLSA provenance attestation from a Github Action", Long: `Generate and sign SLSA provenance from a Github Action to form an attestation and upload to a Rekor transparency log. This command assumes that it is being run in the context of a Github Actions workflow.`, @@ -148,21 +46,29 @@ run in the context of a Github Actions workflow.`, Run: func(cmd *cobra.Command, args []string) { ghContext, err := github.GetWorkflowContext() check(err) - var parsedSubjects []intoto.Subject - // We don't actually care about the subjects if we aren't writing an attestation. - if attPath != "" { - // Verify the extension path and extension. - err = utils.VerifyAttestationPath(attPath) - check(err) - parsedSubjects, err = parseSubjects(subjects) - check(err) + parsedSubjects, err := parseSubjects(subjects) + check(err) - if len(parsedSubjects) == 0 { - check(errors.New("expected at least one subject")) + if len(parsedSubjects) == 0 { + check(errors.New("expected at least one subject")) + } + + // NOTE: The provenance file path is untrusted and should be + // validated. This is done by CreateNewFileUnderCurrentDirectory. + if attPath == "" { + if len(parsedSubjects) == 1 { + attPath = fmt.Sprintf("%s.intoto.jsonl", parsedSubjects[0].Name) + } else { + // len(parsedSubjects) > 1 + attPath = "multiple.intoto.jsonl" } } + // Verify the extension path and extension. + err = utils.VerifyAttestationPath(attPath) + check(err) + ctx := context.Background() b := provenanceOnlyBuild{ @@ -191,48 +97,36 @@ run in the context of a Github Actions workflow.`, check(err) // Note: the path is validated within CreateNewFileUnderCurrentDirectory(). - if attPath != "" { - var attBytes []byte - if utils.IsPresubmitTests() { - attBytes, err = json.Marshal(p) - check(err) - } else { - s := sigstore.NewDefaultFulcio() - att, err := s.Sign(ctx, &intoto.Statement{ - StatementHeader: p.StatementHeader, - Predicate: p.Predicate, - }) - check(err) - - r := sigstore.NewDefaultRekor() - _, err = r.Upload(ctx, att) - check(err) - - attBytes = att.Bytes() - } - - f, err := utils.CreateNewFileUnderCurrentDirectory(attPath, os.O_WRONLY) + var attBytes []byte + if utils.IsPresubmitTests() { + attBytes, err = json.Marshal(p) + check(err) + } else { + att, err := signer.Sign(ctx, &intoto.Statement{ + StatementHeader: p.StatementHeader, + Predicate: p.Predicate, + }) check(err) - _, err = f.Write(attBytes) + _, err = tlog.Upload(ctx, att) check(err) + + attBytes = att.Bytes() } - if predicatePath != "" { - pb, err := json.Marshal(p.Predicate) - check(err) + f, err := utils.CreateNewFileUnderCurrentDirectory(attPath, os.O_WRONLY) + check(err) - pf, err := utils.CreateNewFileUnderCurrentDirectory(predicatePath, os.O_WRONLY) - check(err) + _, err = f.Write(attBytes) + check(err) - _, err = pf.Write(pb) - check(err) - } + // Print the provenance name and sha256 so it can be used by the workflow. + fmt.Printf("::set-output name=provenance-name::%s\n", attPath) + fmt.Printf("::set-output name=provenance-sha256::%x\n", sha256.Sum256(attBytes)) }, } - c.Flags().StringVarP(&predicatePath, "predicate", "p", "", "Path to write the unsigned provenance predicate.") - c.Flags().StringVarP(&attPath, "signature", "g", "attestation.intoto.jsonl", "Path to write the signed attestation.") + c.Flags().StringVarP(&attPath, "signature", "g", "", "Path to write the signed provenance.") c.Flags().StringVarP(&subjects, "subjects", "s", "", "Formatted list of subjects in the same format as sha256sum (base64 encoded).") return c diff --git a/internal/builders/generic/attest_test.go b/internal/builders/generic/attest_test.go index b4ae28422e..314310f7ff 100644 --- a/internal/builders/generic/attest_test.go +++ b/internal/builders/generic/attest_test.go @@ -2,6 +2,9 @@ package main import ( "bytes" + "encoding/base64" + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" @@ -10,6 +13,8 @@ import ( slsav02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" "github.com/slsa-framework/slsa-github-generator/internal/errors" + "github.com/slsa-framework/slsa-github-generator/internal/testutil" + "github.com/slsa-framework/slsa-github-generator/internal/utils" "github.com/slsa-framework/slsa-github-generator/slsa" ) @@ -149,15 +154,192 @@ func TestParseSubjects(t *testing.T) { } // Test_attestCmd tests the attest command. -func Test_attestCmd(t *testing.T) { - t.Run("empty attestation path", func(t *testing.T) { - t.Setenv("GITHUB_CONTEXT", "{}") - - c := attestCmd(&slsa.NilClientProvider{}) - c.SetOut(new(bytes.Buffer)) - c.SetArgs([]string{"--signature", ""}) - if err := c.Execute(); err != nil { - t.Errorf("unexpected failure: %v", err) +func Test_attestCmd_default_single_artifact(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + c := attestCmd(&slsa.NilClientProvider{}, checkTest(t), &testutil.TestSigner{}, &testutil.TestTransparencyLog{}) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{ + "--subjects", base64.StdEncoding.EncodeToString([]byte("b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact1")), + }) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the expected file exists. + if _, err := os.Stat(filepath.Join(dir, "artifact1.intoto.jsonl")); err != nil { + t.Errorf("error checking file: %v", err) + } +} + +func Test_attestCmd_default_multi_artifact(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + c := attestCmd(&slsa.NilClientProvider{}, checkTest(t), &testutil.TestSigner{}, &testutil.TestTransparencyLog{}) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{ + "--subjects", base64.StdEncoding.EncodeToString([]byte( + `b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact1 +b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact2`)), + }) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the expected file exists. + if _, err := os.Stat(filepath.Join(dir, "multiple.intoto.jsonl")); err != nil { + t.Errorf("error checking file: %v", err) + } +} + +func Test_attestCmd_custom_provenance_name(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + c := attestCmd(&slsa.NilClientProvider{}, checkTest(t), &testutil.TestSigner{}, &testutil.TestTransparencyLog{}) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{ + "--subjects", base64.StdEncoding.EncodeToString([]byte("b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact1")), + "--signature", "custom.intoto.jsonl", + }) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the file exists. + if _, err := os.Stat("custom.intoto.jsonl"); err != nil { + t.Errorf("error checking file: %v", err) + } +} + +func Test_attestCmd_invalid_extension(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + // A custom check function that checks the error type is the expected error type. + check := func(err error) { + if err != nil { + errInvalidPath := &utils.ErrInvalidPath{} + if !errors.As(err, &errInvalidPath) { + t.Fatalf("expected %v but got %v", &utils.ErrInvalidPath{}, err) + } + // Check should exit the program so we skip the rest of the test if we got the expected error. + t.SkipNow() + } + } + + c := attestCmd(&slsa.NilClientProvider{}, check, &testutil.TestSigner{}, &testutil.TestTransparencyLog{}) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{ + "--subjects", base64.StdEncoding.EncodeToString([]byte("b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact1")), + "--signature", "invalid_name", + }) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // If no error occurs we catch it here. SkipNow will exit the test process so this code should be unreachable. + t.Errorf("expected an error to occur.") +} + +func Test_attestCmd_invalid_path(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + // A custom check function that checks the error type is the expected error type. + check := func(err error) { + if err != nil { + errInvalidPath := &utils.ErrInvalidPath{} + if !errors.As(err, &errInvalidPath) { + t.Fatalf("expected %v but got %v", &utils.ErrInvalidPath{}, err) + } + // Check should exit the program so we skip the rest of the test if we got the expected error. + t.SkipNow() } + } + + c := attestCmd(&slsa.NilClientProvider{}, check, &testutil.TestSigner{}, &testutil.TestTransparencyLog{}) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{ + "--subjects", base64.StdEncoding.EncodeToString([]byte("b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c artifact1")), + "--signature", "/provenance.intoto.jsonl", }) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // If no error occurs we catch it here. SkipNow will exit the test process so this code should be unreachable. + t.Errorf("expected an error to occur.") } diff --git a/internal/builders/generic/generate.go b/internal/builders/generic/generate.go new file mode 100644 index 0000000000..67e4823553 --- /dev/null +++ b/internal/builders/generic/generate.go @@ -0,0 +1,85 @@ +// Copyright 2022 SLSA 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 +// +// https://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 main + +import ( + "context" + "encoding/json" + "os" + + "github.com/spf13/cobra" + + "github.com/slsa-framework/slsa-github-generator/github" + "github.com/slsa-framework/slsa-github-generator/internal/utils" + "github.com/slsa-framework/slsa-github-generator/slsa" +) + +// generateCmd returns the 'generate' command. +func generateCmd(provider slsa.ClientProvider, check func(error)) *cobra.Command { + var predicatePath string + + c := &cobra.Command{ + Use: "generate", + Short: "Create a SLSA provenance predicate from a GitHub Action", + Long: `Generate SLSA provenance predicate from a GitHub Action. This command assumes +that it is being run in the context of a Github Actions workflow.`, + + Run: func(cmd *cobra.Command, args []string) { + ghContext, err := github.GetWorkflowContext() + check(err) + + ctx := context.Background() + + b := provenanceOnlyBuild{ + // NOTE: Subjects are nil because we are only writing the predicate. + GithubActionsBuild: slsa.NewGithubActionsBuild(nil, ghContext), + } + if provider != nil { + b.WithClients(provider) + } else { + // TODO(github.com/slsa-framework/slsa-github-generator/issues/124): Remove + if utils.IsPresubmitTests() { + b.WithClients(&slsa.NilClientProvider{}) + } + } + + g := slsa.NewHostedActionsGenerator(&b) + if provider != nil { + g.WithClients(provider) + } else { + // TODO(github.com/slsa-framework/slsa-github-generator/issues/124): Remove + if utils.IsPresubmitTests() { + g.WithClients(&slsa.NilClientProvider{}) + } + } + + p, err := g.Generate(ctx) + check(err) + + pb, err := json.Marshal(p.Predicate) + check(err) + + pf, err := utils.CreateNewFileUnderCurrentDirectory(predicatePath, os.O_WRONLY) + check(err) + + _, err = pf.Write(pb) + check(err) + }, + } + + c.Flags().StringVarP(&predicatePath, "predicate", "p", "predicate.json", "Path to write the unsigned provenance predicate.") + + return c +} diff --git a/internal/builders/generic/generate_test.go b/internal/builders/generic/generate_test.go new file mode 100644 index 0000000000..1d6113b6fd --- /dev/null +++ b/internal/builders/generic/generate_test.go @@ -0,0 +1,133 @@ +// Copyright 2022 SLSA 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 +// +// https://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 main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/slsa-framework/slsa-github-generator/internal/errors" + "github.com/slsa-framework/slsa-github-generator/internal/utils" + "github.com/slsa-framework/slsa-github-generator/slsa" +) + +func Test_generateCmd_default_predicate(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + c := generateCmd(&slsa.NilClientProvider{}, checkTest(t)) + c.SetOut(new(bytes.Buffer)) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the expected file exists. + if _, err := os.Stat(filepath.Join(dir, "predicate.json")); err != nil { + t.Errorf("error checking file: %v", err) + } +} + +func Test_generateCmd_custom_predicate(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + c := generateCmd(&slsa.NilClientProvider{}, checkTest(t)) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{"--predicate", "custom.json"}) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the expected file exists. + if _, err := os.Stat(filepath.Join(dir, "custom.json")); err != nil { + t.Errorf("error checking file: %v", err) + } +} + +func Test_generateCmd_invalid_path(t *testing.T) { + t.Setenv("GITHUB_CONTEXT", "{}") + + // Change to temporary dir + currentDir, err := os.Getwd() + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.RemoveAll(dir) + if err := os.Chdir(dir); err != nil { + t.Errorf("unexpected failure: %v", err) + } + defer os.Chdir(currentDir) + + // A custom check function that checks the error type is the expected error type. + check := func(err error) { + if err != nil { + errInvalidPath := &utils.ErrInvalidPath{} + if !errors.As(err, &errInvalidPath) { + t.Fatalf("expected %v but got %v", &utils.ErrInvalidPath{}, err) + } + // Check should exit the program so we skip the rest of the test if we got the expected error. + t.SkipNow() + } + } + + c := generateCmd(&slsa.NilClientProvider{}, check) + c.SetOut(new(bytes.Buffer)) + c.SetArgs([]string{"--predicate", "/custom.json"}) + if err := c.Execute(); err != nil { + t.Errorf("unexpected failure: %v", err) + } + + // check that the expected file exists. + if _, err := os.Stat(filepath.Join(dir, "custom.json")); err != nil { + t.Errorf("error checking file: %v", err) + } + + // If no error occurs we catch it here. SkipNow will exit the test process so this code should be unreachable. + t.Errorf("expected an error to occur.") +} diff --git a/internal/builders/generic/generic.go b/internal/builders/generic/generic.go new file mode 100644 index 0000000000..69481e23fb --- /dev/null +++ b/internal/builders/generic/generic.go @@ -0,0 +1,143 @@ +// Copyright 2022 SLSA 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 +// +// https://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 main + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "os" + "regexp" + "strings" + "testing" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + slsav02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + "github.com/slsa-framework/slsa-github-generator/internal/errors" + "github.com/slsa-framework/slsa-github-generator/slsa" +) + +func checkExit(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func checkTest(t *testing.T) func(err error) { + return func(err error) { + if err != nil { + t.Fatalf("%v", err) + } + } +} + +var ( + // shaCheck verifies a hash is has only hexadecimal digits and is 64 + // characters long. + shaCheck = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) + + // wsSplit is used to split lines in the subjects input. + wsSplit = regexp.MustCompile(`[\t ]`) + + // provenanceOnlyBuildType is the URI for provenance only SLSA generation. + provenanceOnlyBuildType = "https://github.com/slsa-framework/slsa-github-generator/generic@v1" +) + +// errBase64 indicates a base64 error in the subject. +type errBase64 struct { + errors.WrappableError +} + +// errSha indicates a error in the hash format. +type errSha struct { + errors.WrappableError +} + +// errNoName indicates a missing subject name. +type errNoName struct { + errors.WrappableError +} + +// errDuplicateSubject indicates a duplicate subject name. +type errDuplicateSubject struct { + errors.WrappableError +} + +// errScan is an error scanning the SHA digest data. +type errScan struct { + errors.WrappableError +} + +// parseSubjects parses the value given to the subjects option. +func parseSubjects(b64str string) ([]intoto.Subject, error) { + var parsed []intoto.Subject + + subjects, err := base64.StdEncoding.DecodeString(b64str) + if err != nil { + return nil, errors.Errorf(&errBase64{}, "error decoding subjects (is it base64 encoded?): %w", err) + } + + scanner := bufio.NewScanner(bytes.NewReader(subjects)) + for scanner.Scan() { + // Split by whitespace, and get values. + parts := wsSplit.Split(strings.TrimSpace(scanner.Text()), 2) + + // Lowercase the sha digest to comply with the SLSA spec. + shaDigest := strings.ToLower(strings.TrimSpace(parts[0])) + if shaDigest == "" { + // Ignore empty lines. + continue + } + // Do a sanity check on the SHA to make sure it's a proper hex digest. + if !shaCheck.MatchString(shaDigest) { + return nil, errors.Errorf(&errSha{}, "unexpected sha256 hash format for %q", shaDigest) + } + + // Check for the subject name. + if len(parts) == 1 { + return nil, errors.Errorf(&errNoName{}, "expected subject name for hash %q", shaDigest) + } + name := strings.TrimSpace(parts[1]) + + for _, p := range parsed { + if p.Name == name { + return nil, errors.Errorf(&errDuplicateSubject{}, "duplicate subject %q", name) + } + } + + parsed = append(parsed, intoto.Subject{ + Name: name, + Digest: slsav02.DigestSet{ + "sha256": shaDigest, + }, + }) + } + if err := scanner.Err(); err != nil { + return nil, errors.Errorf(&errScan{}, "reading digest: %w", err) + } + + return parsed, nil +} + +type provenanceOnlyBuild struct { + *slsa.GithubActionsBuild +} + +// URI implements BuildType.URI. +func (b *provenanceOnlyBuild) URI() string { + return provenanceOnlyBuildType +} diff --git a/internal/builders/generic/main.go b/internal/builders/generic/main.go index 475ca2ec61..344c519237 100644 --- a/internal/builders/generic/main.go +++ b/internal/builders/generic/main.go @@ -16,23 +16,15 @@ package main import ( "errors" - "fmt" - "os" // TODO: Allow use of other OIDC providers? // Enable the github OIDC auth provider. _ "github.com/sigstore/cosign/pkg/providers/github" + "github.com/slsa-framework/slsa-github-generator/signing/sigstore" "github.com/spf13/cobra" ) -func check(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - func rootCmd() *cobra.Command { c := &cobra.Command{ Use: "slsa-github-generator", @@ -44,10 +36,11 @@ For more information on SLSA, visit https://slsa.dev`, }, } c.AddCommand(versionCmd()) - c.AddCommand(attestCmd(nil)) + c.AddCommand(attestCmd(nil, checkExit, sigstore.NewDefaultFulcio(), sigstore.NewDefaultRekor())) + c.AddCommand(generateCmd(nil, checkExit)) return c } func main() { - check(rootCmd().Execute()) + checkExit(rootCmd().Execute()) } diff --git a/internal/builders/go/pkg/provenance_test.go b/internal/builders/go/pkg/provenance_test.go index f9b79ceafc..b5784983ca 100644 --- a/internal/builders/go/pkg/provenance_test.go +++ b/internal/builders/go/pkg/provenance_test.go @@ -15,52 +15,20 @@ package pkg import ( - "context" - "errors" - "fmt" "testing" - intoto "github.com/in-toto/in-toto-golang/in_toto" - "github.com/slsa-framework/slsa-github-generator/signing" + "github.com/slsa-framework/slsa-github-generator/internal/testutil" "github.com/slsa-framework/slsa-github-generator/slsa" ) -type testAttestation struct { - cert []byte - bytes []byte -} - -func (a *testAttestation) Cert() []byte { - return a.cert -} - -func (a *testAttestation) Bytes() []byte { - return a.bytes -} - -type testSigner struct{} - -func (s testSigner) Sign(context.Context, *intoto.Statement) (signing.Attestation, error) { - return &testAttestation{}, nil -} - -type tLogWithErr struct{} - -var errTransparencyLog = errors.New("transparency log error") - -func (tLogWithErr) Upload(context.Context, signing.Attestation) (signing.LogEntry, error) { - fmt.Printf("Upload") - return nil, errTransparencyLog -} - func TestGenerateProvenance_withErr(t *testing.T) { // Disable pre-submit detection. // TODO(github.com/slsa-framework/slsa-github-generator/issues/124): Remove t.Setenv("GITHUB_EVENT_NAME", "non_event") t.Setenv("GITHUB_CONTEXT", "{}") sha256 := "2e0390eb024a52963db7b95e84a9c2b12c004054a7bad9a97ec0c7c89d4681d2" - _, err := GenerateProvenance("foo", sha256, "", "", "/home/foo", &testSigner{}, &tLogWithErr{}, &slsa.NilClientProvider{}) - if want, got := errTransparencyLog, err; want != got { + _, err := GenerateProvenance("foo", sha256, "", "", "/home/foo", &testutil.TestSigner{}, &testutil.TransparencyLogWithErr{}, &slsa.NilClientProvider{}) + if want, got := testutil.ErrTransparencyLog, err; want != got { t.Errorf("expected error, want: %v, got: %v", want, got) } } diff --git a/internal/testutil/signing.go b/internal/testutil/signing.go new file mode 100644 index 0000000000..d859c04520 --- /dev/null +++ b/internal/testutil/signing.go @@ -0,0 +1,92 @@ +// Copyright 2022 SLSA 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 +// +// https://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 testutil + +import ( + "context" + "errors" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/slsa-framework/slsa-github-generator/signing" +) + +// TestAttestation is a basic Attestation implementation. +type TestAttestation struct { + CertVal []byte + BytesVal []byte +} + +// Cert implements Attestation.Cert. +func (a *TestAttestation) Cert() []byte { + return a.CertVal +} + +// Bytes implements Attestation.Bytes. +func (a *TestAttestation) Bytes() []byte { + return a.BytesVal +} + +// TestSigner is a Signer implementation that returns the contained attestation. +type TestSigner struct { + Att TestAttestation +} + +// Sign implements Signer.Sign. +func (s TestSigner) Sign(context.Context, *intoto.Statement) (signing.Attestation, error) { + return &s.Att, nil +} + +// TestLogEntry is a basic LogEntry implementation. +type TestLogEntry struct { + IDVal string + LogIndexVal int64 + UUIDVal string +} + +// ID implements LogEntry.ID. +func (e *TestLogEntry) ID() string { + return e.IDVal +} + +// LogIndex implements LogEntry.LogIndex. +func (e *TestLogEntry) LogIndex() int64 { + return e.LogIndexVal +} + +// UUID implements LogEntry.UUID. +func (e *TestLogEntry) UUID() string { + return e.UUIDVal +} + +// TestTransparencyLog is an implementation of TransparencyLog that returns an ErrTransparencyLog. +type TestTransparencyLog struct { + Entry *TestLogEntry +} + +// Upload implements TransparencyLog.Upload. +func (l TestTransparencyLog) Upload(context.Context, signing.Attestation) (signing.LogEntry, error) { + return l.Entry, nil +} + +// TransparencyLogWithErr is an implementation of TransparencyLog that returns an ErrTransparencyLog. +type TransparencyLogWithErr struct{} + +// ErrTransparencyLog is returned by TransparencyLogWithErr.Upload. +var ErrTransparencyLog = errors.New("transparency log error") + +// Upload implements TransparencyLog.Upload. +func (TransparencyLogWithErr) Upload(context.Context, signing.Attestation) (signing.LogEntry, error) { + return nil, ErrTransparencyLog +}