diff --git a/README.md b/README.md index db2da59..1431afb 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ Run "mage gen:readme" to regenerate this section. | Organization (exists) / `org` | Verifies the org specified in the plugin ID exists. | None | | package.json / `packagejson` | Ensures that package.json exists and the version matches the plugin.json | None | | Plugin Name formatting / `pluginname` | Validates the plugin ID used conforms to our naming convention. | None | +| Provenance attestation validation / `provenance` | Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions). | None | | Published / `published-plugin` | Detects whether any version of this plugin exists in the Grafana plugin catalog currently. | None | | Readme (exists) / `readme` | Ensures a `README.md` file exists within the zip file. | None | | Restrictive Dependency / `restrictivedep` | Specifies a valid range of Grafana versions that work with this version of the plugin. | None | diff --git a/pkg/analysis/analysis.go b/pkg/analysis/analysis.go index 140394c..2740b12 100644 --- a/pkg/analysis/analysis.go +++ b/pkg/analysis/analysis.go @@ -24,10 +24,19 @@ type Pass struct { } type CheckParams struct { - ArchiveDir string - SourceCodeDir string - Checksum string - ArchiveCalculatedMD5 string + // ArchiveFile contains the path passed to the validator. can be a file or a url + ArchiveFile string + // ArchiveDir contains the path to the extracted files from the ArchiveFile + ArchiveDir string + // SourceCodeDir contains the path to the plugin source code + SourceCodeDir string + // SourceCodeReference contains the reference passed to the validator as source code, can be a folder or an url + SourceCodeReference string + // Checksum contains the checksum passed to the validator as an argument + Checksum string + // ArchiveCalculatedMD5 contains the md5 checksum calculated from the archive + ArchiveCalculatedMD5 string + // ArchiveCalculatedSHA1 contains the sha1 checksum calculated from the archive ArchiveCalculatedSHA1 string } diff --git a/pkg/analysis/passes/analysis.go b/pkg/analysis/passes/analysis.go index cb85d2b..53a052a 100644 --- a/pkg/analysis/passes/analysis.go +++ b/pkg/analysis/passes/analysis.go @@ -32,6 +32,7 @@ import ( "github.com/grafana/plugin-validator/pkg/analysis/passes/osvscanner" "github.com/grafana/plugin-validator/pkg/analysis/passes/packagejson" "github.com/grafana/plugin-validator/pkg/analysis/passes/pluginname" + "github.com/grafana/plugin-validator/pkg/analysis/passes/provenance" "github.com/grafana/plugin-validator/pkg/analysis/passes/published" "github.com/grafana/plugin-validator/pkg/analysis/passes/readme" "github.com/grafana/plugin-validator/pkg/analysis/passes/restrictivedep" @@ -77,6 +78,7 @@ var Analyzers = []*analysis.Analyzer{ osvscanner.Analyzer, packagejson.Analyzer, pluginname.Analyzer, + provenance.Analyzer, published.Analyzer, readme.Analyzer, restrictivedep.Analyzer, diff --git a/pkg/analysis/passes/provenance/provenance.go b/pkg/analysis/passes/provenance/provenance.go new file mode 100644 index 0000000..77184b1 --- /dev/null +++ b/pkg/analysis/passes/provenance/provenance.go @@ -0,0 +1,181 @@ +package provenance + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/logme" +) + +var ( + noProvenanceAttestation = &analysis.Rule{ + Name: "no-provenance-attestation", + Severity: analysis.Warning, + } + invalidProvenanceAttestation = &analysis.Rule{ + Name: "invalid-provenance-attestation", + Severity: analysis.Warning, + } +) + +var Analyzer = &analysis.Analyzer{ + Name: "provenance", + Requires: []*analysis.Analyzer{}, + Run: run, + Rules: []*analysis.Rule{ + noProvenanceAttestation, + }, + ReadmeInfo: analysis.ReadmeInfo{ + Name: "Provenance attestation validation", + Description: "Validates the provenance attestation if the plugin was built with a pipeline supporting provenance attestation (e.g Github Actions).", + }, +} + +var githubRe = regexp.MustCompile(`https://github\.com\/([^/]+)/([^/]+)`) +var githubToken = os.Getenv("GITHUB_TOKEN") + +func run(pass *analysis.Pass) (interface{}, error) { + + if githubToken == "" { + logme.Debugln( + "Skipping provenance attestation check because GITHUB_TOKEN is not set", + ) + return nil, nil + } + + matches := githubRe.FindStringSubmatch(pass.CheckParams.SourceCodeReference) + if matches == nil || len(matches) < 3 { + detail := "Cannot verify plugin build. It is recommended to use a pipeline that supports provenance attestation, such as GitHub Actions. https://github.com/grafana/plugin-actions/tree/main/build-plugin" + + // add instructions if the source code reference is a github repo + if strings.Contains(pass.CheckParams.ArchiveFile, "github.com") { + detail = "Cannot verify plugin build. To enable verification, see the documentation on implementing build attestation: https://grafana.com/developers/plugin-tools/publish-a-plugin/build-automation#enable-provenance-attestation" + } + pass.ReportResult( + pass.AnalyzerName, + noProvenanceAttestation, + "No provenance attestation. This plugin was built without build verification", + detail, + ) + return nil, nil + } + + owner := matches[1] + ctx, canc := context.WithTimeout(context.Background(), time.Second*30) + defer canc() + + hasGithubProvenanceAttestationPipeline, err := hasGithubProvenanceAttestationPipeline( + ctx, + pass.CheckParams.ArchiveFile, + owner, + ) + if err != nil || !hasGithubProvenanceAttestationPipeline { + message := "Cannot verify plugin build provenance attestation." + pass.ReportResult( + pass.AnalyzerName, + invalidProvenanceAttestation, + message, + "Please verify your workflow attestation settings. See the documentation on implementing build attestation: https://grafana.com/developers/plugin-tools/publish-a-plugin/build-automation#enable-provenance-attestation", + ) + return nil, nil + } + + if noProvenanceAttestation.ReportAll { + noProvenanceAttestation.Severity = analysis.OK + pass.ReportResult( + pass.AnalyzerName, + noProvenanceAttestation, + "Provenance attestation found", + "Github replied with a confirmation that attestations were found", + ) + } + + return nil, nil +} + +func hasGithubProvenanceAttestationPipeline( + ctx context.Context, + assetPath string, + owner string, +) (bool, error) { + sha256sum, err := getFileSha256(assetPath) + if err != nil { + return false, err + } + + url := fmt.Sprintf("https://api.github.com/users/%s/attestations/sha256:%s", owner, sha256sum) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken)) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + // A 200 status code means attestations were found + if resp.StatusCode == http.StatusOK { + logme.Debugln("Provenance attestation found. Got a 200 status code") + return true, nil + } + + // A 404 means no attestations were found + if resp.StatusCode == http.StatusNotFound { + logme.Debugln("Provenance attestation not found. Got a 404 status code") + return false, nil + } + + // Any other status code is treated as an error + body, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf( + "unexpected response from GitHub API (status %d): %s", + resp.StatusCode, + string(body), + ) +} + +func getFileSha256(assetPath string) (string, error) { + // Check if file exists and is a regular file + fileInfo, err := os.Stat(assetPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("file does not exist: %s", assetPath) + } + return "", fmt.Errorf("error accessing file: %w", err) + } + + if !fileInfo.Mode().IsRegular() { + return "", fmt.Errorf("file is not a regular file: %s", assetPath) + } + + // Open and read the file + file, err := os.Open(assetPath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Calculate SHA256 hash + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", fmt.Errorf("failed to calculate hash: %w", err) + } + + // Convert hash to hex string + return fmt.Sprintf("%x", hasher.Sum(nil)), nil +} diff --git a/pkg/analysis/passes/provenance/provenance_test.go b/pkg/analysis/passes/provenance/provenance_test.go new file mode 100644 index 0000000..e85da28 --- /dev/null +++ b/pkg/analysis/passes/provenance/provenance_test.go @@ -0,0 +1,110 @@ +package provenance + +import ( + "os" + "path/filepath" + "testing" + + "github.com/grafana/plugin-validator/pkg/analysis" + "github.com/grafana/plugin-validator/pkg/testpassinterceptor" + "github.com/stretchr/testify/require" +) + +func canRunProvenanceTest() bool { + return os.Getenv("GITHUB_TOKEN") != "" +} + +func TestNoGithubUrlForAsset(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + + if !canRunProvenanceTest() { + t.Skip("github token not set") + } + + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{}, + Report: interceptor.ReportInterceptor(), + CheckParams: analysis.CheckParams{ + SourceCodeReference: "https://static.grafana.com/plugin.zip", + }, + } + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + // should skip the validation for non github urls + require.Len(t, interceptor.Diagnostics, 1) + require.Equal( + t, + "No provenance attestation. This plugin was built without build verification", + interceptor.Diagnostics[0].Title, + ) + require.Equal( + t, + "Cannot verify plugin build. It is recommended to use a pipeline that supports provenance attestation, such as GitHub Actions. https://github.com/grafana/plugin-actions/tree/main/build-plugin", + interceptor.Diagnostics[0].Detail, + ) + +} + +func TestValidBuildProvenanceAttestion(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + + if !canRunProvenanceTest() { + t.Skip("github token not set") + } + + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{}, + Report: interceptor.ReportInterceptor(), + CheckParams: analysis.CheckParams{ + SourceCodeReference: "https://github.com/grafana/provenance-test-plugin/releases/download/v1.0.7/grafana-provenancetest-panel-1.0.7.zip", + ArchiveFile: filepath.Join( + "testdata", + "valid", + "grafana-provenancetest-panel-1.0.7.zip", + ), + }, + } + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 0) +} + +func TestInvalidBuildProvenanceAttestion(t *testing.T) { + var interceptor testpassinterceptor.TestPassInterceptor + + if !canRunProvenanceTest() { + t.Skip("github token not set") + } + + pass := &analysis.Pass{ + RootDir: filepath.Join("./"), + ResultOf: map[*analysis.Analyzer]interface{}{}, + Report: interceptor.ReportInterceptor(), + CheckParams: analysis.CheckParams{ + SourceCodeReference: "https://github.com/grafana/provenance-test-plugin/releases/download/v1.0.7/grafana-provenancetest-panel-1.0.8.zip", + ArchiveFile: filepath.Join( + "testdata", + "invalid", + "grafana-provenancetest-panel-1.0.8.zip", + ), + }, + } + + _, err := Analyzer.Run(pass) + require.NoError(t, err) + require.Len(t, interceptor.Diagnostics, 1) + require.Equal( + t, + "Cannot verify plugin build provenance attestation.", + interceptor.Diagnostics[0].Title, + ) + require.Equal( + t, + "Please verify your workflow attestation settings. See the documentation on implementing build attestation: https://github.com/grafana/plugin-actions/tree/main/build-plugin#add-attestation-to-your-existing-workflow", + interceptor.Diagnostics[0].Detail, + ) +} diff --git a/pkg/analysis/passes/provenance/testdata/invalid/grafana-provenancetest-panel-1.0.8.zip b/pkg/analysis/passes/provenance/testdata/invalid/grafana-provenancetest-panel-1.0.8.zip new file mode 100644 index 0000000..df8b627 Binary files /dev/null and b/pkg/analysis/passes/provenance/testdata/invalid/grafana-provenancetest-panel-1.0.8.zip differ diff --git a/pkg/analysis/passes/provenance/testdata/valid/grafana-provenancetest-panel-1.0.7.zip b/pkg/analysis/passes/provenance/testdata/valid/grafana-provenancetest-panel-1.0.7.zip new file mode 100644 index 0000000..270f3de Binary files /dev/null and b/pkg/analysis/passes/provenance/testdata/valid/grafana-provenancetest-panel-1.0.7.zip differ diff --git a/pkg/cmd/plugincheck2/main.go b/pkg/cmd/plugincheck2/main.go index d4ea391..ad11337 100644 --- a/pkg/cmd/plugincheck2/main.go +++ b/pkg/cmd/plugincheck2/main.go @@ -83,12 +83,28 @@ func main() { pluginURL := flag.Args()[0] + // read archive file into bytes b, err := archivetool.ReadArchive(pluginURL) if err != nil { logme.Errorln(fmt.Errorf("couldn't fetch plugin archive: %w", err)) os.Exit(1) } + // write archive to a temp file + tmpZip, err := os.CreateTemp("", "plugin-archive") + if err != nil { + logme.Errorln(fmt.Errorf("couldn't create temporary file: %w", err)) + os.Exit(1) + } + defer os.Remove(tmpZip.Name()) + + if _, err := tmpZip.Write(b); err != nil { + logme.Errorln(fmt.Errorf("couldn't write temporary file: %w", err)) + os.Exit(1) + } + + logme.Debugln(fmt.Sprintf("Archive copied to tmp file: %s", tmpZip.Name())) + md5hasher := md5.New() md5hasher.Write(b) md5hash := md5hasher.Sum(nil) @@ -136,8 +152,10 @@ func main() { diags, err := runner.Check( analyzers, analysis.CheckParams{ + ArchiveFile: tmpZip.Name(), ArchiveDir: archiveDir, SourceCodeDir: sourceCodeDir, + SourceCodeReference: *sourceCodeUri, Checksum: *checksum, ArchiveCalculatedMD5: fmt.Sprintf("%x", md5hash), ArchiveCalculatedSHA1: fmt.Sprintf("%x", sha1hash),