Skip to content

Commit

Permalink
New validator: Validate artifact provenance attestation if coming fro…
Browse files Browse the repository at this point in the history
…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
academo authored Feb 5, 2025
1 parent 7531b93 commit 58b5173
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
17 changes: 13 additions & 4 deletions pkg/analysis/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/analysis/passes/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -77,6 +78,7 @@ var Analyzers = []*analysis.Analyzer{
osvscanner.Analyzer,
packagejson.Analyzer,
pluginname.Analyzer,
provenance.Analyzer,
published.Analyzer,
readme.Analyzer,
restrictivedep.Analyzer,
Expand Down
181 changes: 181 additions & 0 deletions pkg/analysis/passes/provenance/provenance.go
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
}
110 changes: 110 additions & 0 deletions pkg/analysis/passes/provenance/provenance_test.go
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 not shown.
Binary file not shown.
18 changes: 18 additions & 0 deletions pkg/cmd/plugincheck2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down

0 comments on commit 58b5173

Please sign in to comment.