From 9bbd902ce4f1a29f76e6abc75205cd5f29eceb85 Mon Sep 17 00:00:00 2001 From: chaosinthecrd Date: Wed, 29 Jan 2025 11:56:22 +0000 Subject: [PATCH 1/3] feat: golang attestor Signed-off-by: chaosinthecrd --- attestation/golang/golang.go | 271 +++++++++++++++++++++++++++++++++++ imports.go | 1 + 2 files changed, 272 insertions(+) create mode 100644 attestation/golang/golang.go diff --git a/attestation/golang/golang.go b/attestation/golang/golang.go new file mode 100644 index 00000000..154ca630 --- /dev/null +++ b/attestation/golang/golang.go @@ -0,0 +1,271 @@ +// Copyright 2022 The Witness Contributors +// +// 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 golang + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/in-toto/go-witness/attestation" + "github.com/in-toto/go-witness/cryptoutil" + "github.com/in-toto/go-witness/log" + "github.com/invopop/jsonschema" +) + +const ( + Name = "golang" + Type = "https://witness.dev/attestations/golang/v0.1" + RunType = attestation.PostProductRunType +) + +// This is a hacky way to create a compile time error in case the attestor +// doesn't implement the expected interfaces. +var ( + _ attestation.Attestor = &Attestor{} + + mimeTypes = []string{"text/plain", "application/json"} +) + +func init() { + attestation.RegisterAttestation(Name, Type, RunType, func() attestation.Attestor { + return New() + }) +} + +type Attestor struct { + OutputFile string `json:"outputFile"` + PercentageCoverage float64 `json:"percentageCoverage"` + Pass bool `json:"pass"` + Packages map[string]Package `json:"package"` + OutputFileDigestSet cryptoutil.DigestSet `json:"reportDigestSet"` +} + +type Package struct { + Element + PercentageCoverage float64 `json:"percentageCoverage"` + Tests map[string]Test `json:"tests"` +} + +type Element struct { + Name string `json:"name"` + Pass bool `json:"pass"` + Outputs []string `json:"output"` +} + +type Test struct { + Element +} + +type GoOutput struct { + Time string `json:"Time"` // Timestamp of the event (e.g., "2025-01-28T12:00:00.000Z") + Action string `json:"Action"` // Action type: "run", "output", or "pass" (also "fail" or "skip") + Package string `json:"Package"` // Package name (e.g., "example.com/mypackage") + Test string `json:"Test"` // Test name (only for test-related events) + Output string `json:"Output"` // Test output (for "output" actions) + Elapsed float64 `json:"Elapsed"` // Time taken for the test (only for "pass" or "fail" actions) +} + +func New() *Attestor { + return &Attestor{} +} + +func (a *Attestor) Name() string { + return Name +} + +func (a *Attestor) Type() string { + return Type +} + +func (a *Attestor) RunType() attestation.RunType { + return RunType +} + +func (a *Attestor) Schema() *jsonschema.Schema { + return jsonschema.Reflect(&a) +} + +func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { + if err := a.getCandidate(ctx); err != nil { + log.Debugf("(attestation/golang) error getting candidate: %w", err) + return err + } + + return nil +} + +func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { + products := ctx.Products() + if len(products) == 0 { + return fmt.Errorf("no products to attest") + } + + for path, product := range products { + for _, mimeType := range mimeTypes { + if !strings.Contains(mimeType, product.MimeType) { + continue + } + } + + newDigestSet, err := cryptoutil.CalculateDigestSetFromFile(path, ctx.Hashes()) + if newDigestSet == nil || err != nil { + return fmt.Errorf("error calculating digest set from file: %s", path) + } + + if !newDigestSet.Equal(product.Digest) { + return fmt.Errorf("integrity error: product digest set does not match candidate digest set") + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("error opening file: %s", path) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + + a.Packages = map[string]Package{} + var cancel bool + + totalPass := true + totalCoverage := 0.0 + for scanner.Scan() { + line := scanner.Bytes() + + var output GoOutput + err := json.Unmarshal(line, &output) + if err != nil { + log.Debugf("(attestation/golang) error unmarshaling go output file: %w", err) + // NOTE: we want to move to the next product + cancel = true + break + } + + var ok bool + var pack Package + if pack, ok = a.Packages[output.Package]; !ok { + pack = Package{ + Element: Element{ + Name: output.Package, + Outputs: []string{}, + }, + Tests: map[string]Test{}, + } + } + + if output.Test != "" { + var test Test + if test, ok = pack.Tests[output.Test]; !ok { + test = Test{ + Element: Element{ + Name: output.Package, + Outputs: []string{}, + }, + } + } + + percent := parseJsonBlock(&test.Element, output) + if percent != nil { + log.Debugf("(attestation/golang) unexpected percentage %f found in output %s for test %s", *percent, output.Output, output.Test) + } + + pack.Tests[output.Test] = test + } else { + percent := parseJsonBlock(&pack.Element, output) + if percent != nil { + pack.PercentageCoverage = *percent + totalCoverage += *percent + } + + // NOTE: we only need to check the total package's test for a pass/fail + if !pack.Pass { + totalPass = false + } + } + + a.Packages[output.Package] = pack + } + + if cancel { + continue + } + + // NOTE: to get the average we need to divide by the number of packages + totalCoverage = totalCoverage / float64(len(a.Packages)) + + a.PercentageCoverage = totalCoverage + a.Pass = totalPass + + a.OutputFile = path + a.OutputFileDigestSet = product.Digest + + return nil + } + + return fmt.Errorf("no golang file found") +} + +func parseJsonBlock(elem *Element, output GoOutput) *float64 { + switch output.Action { + case "output": + if output.Output == "" { + log.Debugf("(attestation/golang) empty output found for element %s", elem.Name) + return nil + } else if strings.HasSuffix(output.Output, "% of statements\n") { + percentage := parsePercentFromOutput(output.Output) + elem.Outputs = append(elem.Outputs, output.Output) + return &percentage + } + + elem.Outputs = append(elem.Outputs, output.Output) + case "pass": + elem.Pass = true + case "fail": + elem.Pass = false + default: + log.Debugf("(attestation/golang) ignoring action %s", output.Action) + return nil + } + + return nil +} + +func parsePercentFromOutput(output string) float64 { + start := strings.Index(output, "coverage:") + if start == -1 { + log.Debugf("(attestation/golang) failed to get percentage coverage on output %s", output) + return 0 + } + + substring := output[start+len("coverage: "):] + parts := strings.Split(substring, " ") + if len(parts) == 0 { + log.Debugf("(attestation/golang) failed to get percentage coverage on output %s", output) + return 0 + } + + percentageStr := strings.TrimSuffix(parts[0], "%") + percentage, err := strconv.ParseFloat(percentageStr, 64) + if err != nil { + log.Debugf("(attestation/golang) error parsing percentage on output %s: %w", output, err) + return 0 + } + + return percentage +} diff --git a/imports.go b/imports.go index 4db6e32c..f726b7f0 100644 --- a/imports.go +++ b/imports.go @@ -24,6 +24,7 @@ import ( _ "github.com/in-toto/go-witness/attestation/git" _ "github.com/in-toto/go-witness/attestation/github" _ "github.com/in-toto/go-witness/attestation/gitlab" + _ "github.com/in-toto/go-witness/attestation/golang" _ "github.com/in-toto/go-witness/attestation/jenkins" _ "github.com/in-toto/go-witness/attestation/jwt" _ "github.com/in-toto/go-witness/attestation/link" From 483b20da65d5522abf1bdc16d18c3f6b2d07ce95 Mon Sep 17 00:00:00 2001 From: chaosinthecrd Date: Wed, 29 Jan 2025 13:52:50 +0000 Subject: [PATCH 2/3] feat: adding flag for excluding packages and checking for minimum go version Signed-off-by: chaosinthecrd --- attestation/golang/golang.go | 173 ++++++++++++++++++++++++++++++----- go.mod | 1 + go.sum | 2 + 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/attestation/golang/golang.go b/attestation/golang/golang.go index 154ca630..77559ea8 100644 --- a/attestation/golang/golang.go +++ b/attestation/golang/golang.go @@ -16,15 +16,20 @@ package golang import ( "bufio" + "bytes" "encoding/json" "fmt" + "github.com/hashicorp/go-version" "os" + "os/exec" + "regexp" "strconv" "strings" "github.com/in-toto/go-witness/attestation" "github.com/in-toto/go-witness/cryptoutil" "github.com/in-toto/go-witness/log" + "github.com/in-toto/go-witness/registry" "github.com/invopop/jsonschema" ) @@ -45,10 +50,57 @@ var ( func init() { attestation.RegisterAttestation(Name, Type, RunType, func() attestation.Attestor { return New() - }) + }, + registry.StringSliceConfigOption( + "exclude-packages", + fmt.Sprintf("Packages to exclude from checking for unit test coverage."), + []string{}, + func(a attestation.Attestor, packages []string) (attestation.Attestor, error) { + goAttestor, ok := a.(*Attestor) + if !ok { + return a, fmt.Errorf("unexpected attestor type: %T is not a maven attestor", a) + } + + WithExcludePackages(packages)(goAttestor) + return goAttestor, nil + }, + ), + registry.StringConfigOption( + "binary-path", + fmt.Sprintf("The path to the go binary used to run the step (default uses `go` in $PATH)."), + "", + func(a attestation.Attestor, path string) (attestation.Attestor, error) { + goAttestor, ok := a.(*Attestor) + if !ok { + return a, fmt.Errorf("unexpected attestor type: %T is not a maven attestor", a) + } + + WithBinaryPath(path)(goAttestor) + return goAttestor, nil + }, + ), + ) +} + +type Option func(*Attestor) + +func WithBinaryPath(path string) Option { + return func(a *Attestor) { + a.binaryPath = path + } +} + +func WithExcludePackages(packages []string) Option { + return func(a *Attestor) { + a.ExcludedPackages = packages + } } type Attestor struct { + binaryPath string + GoVersion string `json:"goVersion"` + CoverageEnabled bool `json:"coverageEnabled"` + ExcludedPackages []string `json:"excludePackages"` OutputFile string `json:"outputFile"` PercentageCoverage float64 `json:"percentageCoverage"` Pass bool `json:"pass"` @@ -102,6 +154,10 @@ func (a *Attestor) Schema() *jsonschema.Schema { } func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { + if err := a.checkGoVersion(); err != nil { + log.Errorf("(attestation/golang) error getting go tool version: %w", err) + return err + } if err := a.getCandidate(ctx); err != nil { log.Debugf("(attestation/golang) error getting candidate: %w", err) return err @@ -110,6 +166,36 @@ func (a *Attestor) Attest(ctx *attestation.AttestationContext) error { return nil } +func (a *Attestor) checkGoVersion() error { + var ver string + var err error + if a.binaryPath != "" { + ver, err = runGoVersion(a.binaryPath) + } else { + ver, err = runGoVersion("go") + } + if err != nil { + return err + } + + minSupported := "1.24rc2" + rv, err := version.NewVersion(minSupported) + if err != nil { + return fmt.Errorf("(attestation/golang) failed to parse version %s: %w", minSupported, err) + } + cv, err := version.NewVersion(ver) + if err != nil { + return fmt.Errorf("(attestation/golang) failed to parse version %s: %w", ver, err) + } + if cv.LessThan(rv) { + return fmt.Errorf("(attestation/golang) version of go found (%s) is less than minimum supported version for attestor (%s)", ver, minSupported) + } + + a.GoVersion = ver + + return nil +} + func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { products := ctx.Products() if len(products) == 0 { @@ -141,10 +227,9 @@ func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { scanner := bufio.NewScanner(f) a.Packages = map[string]Package{} + a.CoverageEnabled = false var cancel bool - totalPass := true - totalCoverage := 0.0 for scanner.Scan() { line := scanner.Bytes() @@ -180,22 +265,17 @@ func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { } } - percent := parseJsonBlock(&test.Element, output) - if percent != nil { - log.Debugf("(attestation/golang) unexpected percentage %f found in output %s for test %s", *percent, output.Output, output.Test) + var tmpPercent float64 = 0 + parseJsonBlock(&test.Element, &tmpPercent, output) + if tmpPercent != 0 { + log.Debugf("(attestation/golang) unexpected percentage %f found in output %s for test %s", tmpPercent, output.Output, output.Test) } pack.Tests[output.Test] = test } else { - percent := parseJsonBlock(&pack.Element, output) - if percent != nil { - pack.PercentageCoverage = *percent - totalCoverage += *percent - } - - // NOTE: we only need to check the total package's test for a pass/fail - if !pack.Pass { - totalPass = false + foundPercent := parseJsonBlock(&pack.Element, &pack.PercentageCoverage, output) + if foundPercent { + a.CoverageEnabled = foundPercent } } @@ -206,6 +286,32 @@ func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { continue } + if len(a.ExcludedPackages) > 0 { + for _, name := range a.ExcludedPackages { + if pack, ok := a.Packages[name]; ok { + if len(pack.Tests) > 0 { + log.Warnf("(attestation/golang) package %s containing tests excluded from attestation", name) + } + delete(a.Packages, name) + } else { + log.Warnf("(attestation/golang) excluded package %s not found in test results", name) + } + } + } + + totalPass := true + totalCoverage := 0.0 + for _, p := range a.Packages { + if p.PercentageCoverage != 0 { + totalCoverage += p.PercentageCoverage + } + + // NOTE: we only need to check the total package's test for a pass/fail + if !p.Pass { + totalPass = false + } + } + // NOTE: to get the average we need to divide by the number of packages totalCoverage = totalCoverage / float64(len(a.Packages)) @@ -221,29 +327,29 @@ func (a *Attestor) getCandidate(ctx *attestation.AttestationContext) error { return fmt.Errorf("no golang file found") } -func parseJsonBlock(elem *Element, output GoOutput) *float64 { +func parseJsonBlock(elem *Element, percent *float64, output GoOutput) bool { switch output.Action { case "output": if output.Output == "" { log.Debugf("(attestation/golang) empty output found for element %s", elem.Name) - return nil - } else if strings.HasSuffix(output.Output, "% of statements\n") { - percentage := parsePercentFromOutput(output.Output) + return false + } else if strings.HasSuffix(output.Output, "% of statements\n") && *percent == 0 { + *percent = parsePercentFromOutput(output.Output) + elem.Outputs = append(elem.Outputs, output.Output) + return true + } else { elem.Outputs = append(elem.Outputs, output.Output) - return &percentage } - - elem.Outputs = append(elem.Outputs, output.Output) case "pass": elem.Pass = true case "fail": elem.Pass = false default: log.Debugf("(attestation/golang) ignoring action %s", output.Action) - return nil + return false } - return nil + return false } func parsePercentFromOutput(output string) float64 { @@ -269,3 +375,22 @@ func parsePercentFromOutput(output string) float64 { return percentage } + +func runGoVersion(path string) (string, error) { + cmd := exec.Command(path, "version") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + return "", err + } + + // Regex to extract only the Go version (e.g., "1.24rc2" or "1.23.1") + re := regexp.MustCompile(`go(\d+\.\d+(\.\d+)?(rc\d+)?)`) + match := re.FindStringSubmatch(out.String()) + if len(match) > 1 { + return match[1], nil + } + + return "", fmt.Errorf("(attestation/golang) failed to extract go version from output: %s", out.String()) +} diff --git a/go.mod b/go.mod index 6ad39eec..991b3155 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/googleapis/gax-go/v2 v2.12.3 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect diff --git a/go.sum b/go.sum index 4e86d0c7..d6b15989 100644 --- a/go.sum +++ b/go.sum @@ -212,6 +212,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/in-toto/archivista v0.5.4 h1:B3j7qzo7Nlcz9n1oHrSgqMXz1eZkTYuf7oyzI52pgug= github.com/in-toto/archivista v0.5.4/go.mod h1:DZzhlYgChw2JJ666z83tVFL2gU9u5yk/BSQZe06Pshg= github.com/in-toto/attestation v1.0.2 h1:ICqV41bfaDC3ixVUzAtFxFu+Dy56EPcjiIrJQe+4LVM= From c0eb26f304e8104a42c39bd3eed0cf9622f0da66 Mon Sep 17 00:00:00 2001 From: Tom Meadows Date: Thu, 30 Jan 2025 15:24:27 +0000 Subject: [PATCH 3/3] Update attestation/golang/golang.go Co-authored-by: Kairo Araujo Signed-off-by: Tom Meadows --- attestation/golang/golang.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attestation/golang/golang.go b/attestation/golang/golang.go index 77559ea8..1c4bb61c 100644 --- a/attestation/golang/golang.go +++ b/attestation/golang/golang.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Witness Contributors +// Copyright 2025 The Witness Contributors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.