-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New validator: Validate artifact provenance attestation if coming fro…
…m a github pipeline (#303) * New validator: Validate artifact provenance attestation if coming from a github pipeline * Use api direct call instead of GH CLI * Update links to documentation * Apply PR suggestions
- Loading branch information
Showing
8 changed files
with
325 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
) | ||
} |
Binary file added
BIN
+12.5 KB
pkg/analysis/passes/provenance/testdata/invalid/grafana-provenancetest-panel-1.0.8.zip
Binary file not shown.
Binary file added
BIN
+12.5 KB
pkg/analysis/passes/provenance/testdata/valid/grafana-provenancetest-panel-1.0.7.zip
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters