From afeb1a52e9f07ca05f896279b06746c0fe3f0b99 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 27 Mar 2026 07:09:26 -0300 Subject: [PATCH 01/11] feat: adds granular mode Signed-off-by: Gustavo Carvalho --- go.mod | 8 +- go.sum | 6 +- main.go | 282 +++++++++++++++++++++++++++++++++++++++++++-------- main_test.go | 145 ++++++++++++++++++++++++++ 4 files changed, 392 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index a7bbc85..f4aab18 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,15 @@ require ( github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/mitchellh/mapstructure v1.5.0 + github.com/stretchr/testify v1.11.1 ) require ( github.com/agnivade/levenshtein v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/compliance-framework/api v0.13.0 // indirect + github.com/compliance-framework/api v0.14.1 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/defenseunicorns/go-oscal v0.7.0 // indirect github.com/docker/docker v28.5.2+incompatible // indirect @@ -37,6 +39,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/oklog/run v1.2.0 // indirect github.com/open-policy-agent/opa v1.14.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -62,5 +65,8 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/compliance-framework/agent => ../agent diff --git a/go.sum b/go.sum index fd517de..b47a30d 100644 --- a/go.sum +++ b/go.sum @@ -56,10 +56,8 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compliance-framework/agent v0.3.1 h1:RikYgITNcu5Wc8i4sTzTzfZvbon2/r8Hot6ZcGZ+1UA= -github.com/compliance-framework/agent v0.3.1/go.mod h1:S0x4qpbUdlVZD6NlyGlrsSLUODdtB3M1rOHHcXQdadU= -github.com/compliance-framework/api v0.13.0 h1:pW0JS4e9ZwRIwSZM32ObjdCBIxuxL+TL4nHAcopqMO0= -github.com/compliance-framework/api v0.13.0/go.mod h1:CMHwcOOCcVRf1u/n3BeqbrP09WWCuwnFAlD7dQfIWCA= +github.com/compliance-framework/api v0.14.1 h1:Lig39GBwYpVVn8RdC94bcBKWMvt9KuCeG/k5wjUtFCU= +github.com/compliance-framework/api v0.14.1/go.mod h1:CMHwcOOCcVRf1u/n3BeqbrP09WWCuwnFAlD7dQfIWCA= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= diff --git a/main.go b/main.go index a67dd49..b912723 100644 --- a/main.go +++ b/main.go @@ -16,19 +16,28 @@ import ( "github.com/mitchellh/mapstructure" ) +type OperationalMode string + +const ( + OperationalModeBundled OperationalMode = "Bundled" // default: one evidence per repo + OperationalModeGranular OperationalMode = "Granular" // one evidence per alert/CVE +) + type PluginConfig struct { - Token string `mapstructure:"token"` - Organization *string `mapstructure:"organization"` - IncludedRepositories *string `mapstructure:"included-repositories"` - User *string `mapstructure:"user"` - SecurityTeamName *string `mapstructure:"security-team-name"` + Token string `mapstructure:"token"` + Organization *string `mapstructure:"organization"` + IncludedRepositories *string `mapstructure:"included-repositories"` + User *string `mapstructure:"user"` + SecurityTeamName *string `mapstructure:"security-team-name"` + OperationalMode OperationalMode `mapstructure:"operational-mode"` } type ParsedConfig struct { - Token string `mapstructure:"token"` - Organization *string `mapstructure:"organization"` - IncludedRepositories []string `mapstructure:"included-repositories"` - User *string `mapstructure:"user"` - SecurityTeamName *string `mapstructure:"security-team-name"` + Token string `mapstructure:"token"` + Organization *string `mapstructure:"organization"` + IncludedRepositories []string `mapstructure:"included-repositories"` + User *string `mapstructure:"user"` + SecurityTeamName *string `mapstructure:"security-team-name"` + OperationalMode OperationalMode `mapstructure:"operational-mode"` } type DependabotPlugin struct { logger hclog.Logger @@ -53,6 +62,10 @@ func (l *DependabotPlugin) ParseConfig() { l.parsedConfig.Organization = l.config.Organization l.parsedConfig.User = l.config.User l.parsedConfig.SecurityTeamName = l.config.SecurityTeamName + l.parsedConfig.OperationalMode = l.config.OperationalMode + if l.parsedConfig.OperationalMode == "" { + l.parsedConfig.OperationalMode = OperationalModeBundled + } } func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { @@ -70,27 +83,52 @@ func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.Config func (l *DependabotPlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { ctx := context.Background() - subjectTemplates := []*proto.SubjectTemplate{ - { - Name: "dependabot-repository", - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - TitleTemplate: "Dependabot for repository: {{ .repository }}", - DescriptionTemplate: "Dependabot alerts for GitHub repository {{ .repository }} in organization {{ .organization }}", - PurposeTemplate: "Represents Dependabot monitoring for a GitHub repository being evaluated for compliance", - IdentityLabelKeys: []string{"repository", "organization"}, - SelectorLabels: []*proto.SubjectLabelSelector{}, - LabelSchema: []*proto.SubjectLabelSchema{ - {Key: "repository", Description: "The name of the GitHub repository"}, - {Key: "organization", Description: "The GitHub organization owning the repository"}, + var subjectTemplates []*proto.SubjectTemplate + switch l.parsedConfig.OperationalMode { + case OperationalModeGranular: + subjectTemplates = []*proto.SubjectTemplate{ + { + Name: "dependabot-alert", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "{{ .cve_id }} in {{ .repository }}", + DescriptionTemplate: "Dependabot alert for {{ .cve_id }} affecting {{ .package_name }} ({{ .ecosystem }}) in {{ .repository }}", + PurposeTemplate: "Represents a specific CVE vulnerability alert detected by Dependabot in a GitHub repository", + IdentityLabelKeys: []string{"repository", "organization", "cve_id"}, + SelectorLabels: []*proto.SubjectLabelSelector{}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "repository", Description: "The name of the GitHub repository"}, + {Key: "organization", Description: "The GitHub organization owning the repository"}, + {Key: "cve_id", Description: "The CVE or GHSA identifier for the vulnerability"}, + {Key: "package_name", Description: "The name of the affected package"}, + {Key: "ecosystem", Description: "The package ecosystem (go, npm, pip, etc.)"}, + {Key: "severity", Description: "The vulnerability severity level (critical, high, medium, low)"}, + {Key: "cvss_score", Description: "The CVSS numeric score of the vulnerability"}, + }, }, - }, + } + default: + subjectTemplates = []*proto.SubjectTemplate{ + { + Name: "dependabot-repository", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "Dependabot for repository: {{ .repository }}", + DescriptionTemplate: "Dependabot alerts for GitHub repository {{ .repository }} in organization {{ .organization }}", + PurposeTemplate: "Represents Dependabot monitoring for a GitHub repository being evaluated for compliance", + IdentityLabelKeys: []string{"repository", "organization"}, + SelectorLabels: []*proto.SubjectLabelSelector{}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "repository", Description: "The name of the GitHub repository"}, + {Key: "organization", Description: "The GitHub organization owning the repository"}, + }, + }, + } } return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, subjectTemplates) } func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { - ctx := context.TODO() + ctx := context.Background() repochan, errchan := l.FetchRepositories(ctx) l.logger.Debug("Fetching repositories from Github API") var securityTeamMembers []*github.User @@ -139,26 +177,15 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp }, err } - data := &DependabotData{ - Alerts: alerts, - } - if securityTeamMembers != nil { - data.SecurityTeamMembers = securityTeamMembers - } - - evidences, err := l.EvaluatePolicies(ctx, repo, data, req) - if err != nil { - l.logger.Error("Failed to evaluate policies", "repo", repo.GetFullName(), "error", err) - return &proto.EvalResponse{ - Status: proto.ExecutionStatus_FAILURE, - }, err - } - - if err = apiHelper.CreateEvidence(ctx, evidences); err != nil { - l.logger.Error("Failed to send evidence", "repo", repo.GetFullName(), "error", err) - return &proto.EvalResponse{ - Status: proto.ExecutionStatus_FAILURE, - }, err + switch l.parsedConfig.OperationalMode { + case OperationalModeGranular: + if err := l.evalForGranular(ctx, repo, alerts, req, apiHelper); err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err + } + default: + if err := l.evalForBundle(ctx, repo, alerts, securityTeamMembers, req, apiHelper); err != nil { + return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err + } } } } @@ -172,6 +199,42 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp }, nil } +func (l *DependabotPlugin) evalForGranular(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { + for _, alert := range alerts { + alertEvidences, err := l.EvaluateGranularPolicies(ctx, repo, alert, req) + if err != nil { + l.logger.Error("Failed to evaluate granular policies", "repo", repo.GetFullName(), "error", err) + return err + } + if err = apiHelper.CreateEvidence(ctx, alertEvidences); err != nil { + l.logger.Error("Failed to send granular evidence", "repo", repo.GetFullName(), "error", err) + return err + } + } + return nil +} + +func (l *DependabotPlugin) evalForBundle(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, securityTeamMembers []*github.User, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { + data := &DependabotData{ + Alerts: alerts, + } + if securityTeamMembers != nil { + data.SecurityTeamMembers = securityTeamMembers + } + + evidences, err := l.EvaluatePolicies(ctx, repo, data, req) + if err != nil { + l.logger.Error("Failed to evaluate policies", "repo", repo.GetFullName(), "error", err) + return err + } + + if err = apiHelper.CreateEvidence(ctx, evidences); err != nil { + l.logger.Error("Failed to send evidence", "repo", repo.GetFullName(), "error", err) + return err + } + return nil +} + func (l *DependabotPlugin) FetchSecurityTeamMembers(ctx context.Context) ([]*github.User, error) { members, _, err := l.githubClient.Teams.ListTeamMembersBySlug(ctx, *l.parsedConfig.Organization, *l.parsedConfig.SecurityTeamName, nil) if err != nil { @@ -402,6 +465,137 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re return evidences, accumulatedErrors } +func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *github.Repository, alert *github.DependabotAlert, req *proto.EvalRequest) ([]*proto.Evidence, error) { + var accumulatedErrors error + + // Extract alert fields + cveID := alert.GetSecurityAdvisory().GetCVEID() + if cveID == "" { + cveID = alert.GetSecurityAdvisory().GetGHSAID() + } + packageName := alert.GetDependency().GetPackage().GetName() + ecosystem := alert.GetDependency().GetPackage().GetEcosystem() + severity := alert.GetSecurityVulnerability().GetSeverity() + var cvssScoreVal float64 + if score := alert.GetSecurityAdvisory().GetCVSS().GetScore(); score != nil { + cvssScoreVal = *score + } + cvssScore := fmt.Sprintf("%.1f", cvssScoreVal) + + // Map GitHub severity ("medium") → CCF standard ("moderate") + impact := severity + if severity == "medium" { + impact = "moderate" + } + + labels := map[string]string{ + "provider": "github", + "type": "dependabot", + "repository": repo.GetName(), + "organization": repo.GetOwner().GetLogin(), + "cve_id": cveID, + "package_name": packageName, + "ecosystem": ecosystem, + "severity": severity, + "impact": impact, + "cvss_score": cvssScore, + } + + activities := []*proto.Activity{ + {Title: "Collect Individual Dependabot Alert"}, + } + actors := []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework"), + }, + }, + }, + { + Title: "Continuous Compliance Framework - Dependabot Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-dependabot", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework Dependabot Plugin"), + }, + }, + }, + } + components := []*proto.Component{ + { + Identifier: "common-components/github-repository", + Type: "service", + Title: "GitHub Repository", + Description: "A GitHub repository is a discrete codebase or project workspace hosted within a GitHub Organization or user account.", + Purpose: "To serve as the authoritative and version-controlled location for a specific software project.", + }, + } + inventory := []*proto.InventoryItem{ + { + Identifier: fmt.Sprintf("github-repository/%s", repo.GetFullName()), + Type: "github-repository", + Title: fmt.Sprintf("Github Repository [%s]", repo.GetName()), + Props: []*proto.Property{ + {Name: "name", Value: repo.GetName()}, + {Name: "path", Value: repo.GetFullName()}, + {Name: "organization", Value: repo.GetOwner().GetLogin()}, + }, + Links: []*proto.Link{ + { + Href: repo.GetURL(), + Text: policyManager.Pointer("Repository URL"), + }, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: "common-components/github-repository"}, + }, + }, + } + subjects := []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: fmt.Sprintf("github-repository/%s", repo.GetFullName()), + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: fmt.Sprintf("github-organization/%s", repo.GetOwner().GetLogin()), + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: "common-components/github-repository", + }, + } + + evidences := make([]*proto.Evidence, 0) + for _, policyPath := range req.GetPolicyPaths() { + processor := policyManager.NewPolicyProcessor( + l.logger, + labels, + subjects, + components, + inventory, + actors, + activities, + ) + evidence, err := processor.GenerateResults(ctx, policyPath, alert) + evidences = slices.Concat(evidences, evidence) + if err != nil { + accumulatedErrors = errors.Join(accumulatedErrors, err) + } + } + + l.logger.Info("collected granular evidence", "cve_id", cveID, "repo", repo.GetFullName(), "count", len(evidences)) + + return evidences, accumulatedErrors +} + // isPermissionError returns true if the error from the GitHub client indicates // a permissions or visibility issue (e.g., 401/403/404). func isPermissionError(err error) bool { diff --git a/main_test.go b/main_test.go index 8123ec7..786cf45 100644 --- a/main_test.go +++ b/main_test.go @@ -1,9 +1,154 @@ package main import ( + "context" + "errors" "testing" + + "github.com/compliance-framework/agent/runner/proto" + "github.com/google/go-github/v71/github" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" ) +// mockApiHelper implements runner.ApiHelper for unit tests. +type mockApiHelper struct { + createEvidenceErr error + createEvidenceCalls int +} + +func (m *mockApiHelper) CreateEvidence(_ context.Context, _ []*proto.Evidence) error { + m.createEvidenceCalls++ + return m.createEvidenceErr +} + +func (m *mockApiHelper) UpsertRiskTemplates(_ context.Context, _ string, _ []*proto.RiskTemplate) error { + return nil +} + +func (m *mockApiHelper) UpsertSubjectTemplates(_ context.Context, _ []*proto.SubjectTemplate) error { + return nil +} + +// ptr returns a pointer to s, useful for building github objects in tests. +func ptr(s string) *string { return &s } + +// DependabotPluginSuite holds shared helpers for all plugin unit tests. +type DependabotPluginSuite struct { + suite.Suite +} + +func TestDependabotPluginSuite(t *testing.T) { + suite.Run(t, new(DependabotPluginSuite)) +} + +func (s *DependabotPluginSuite) newPlugin(mode OperationalMode) *DependabotPlugin { + org := "test-org" + return &DependabotPlugin{ + logger: hclog.NewNullLogger(), + parsedConfig: &ParsedConfig{ + Organization: &org, + OperationalMode: mode, + }, + } +} + +func (s *DependabotPluginSuite) newRepo() *github.Repository { + return &github.Repository{ + Name: ptr("test-repo"), + FullName: ptr("test-org/test-repo"), + Owner: &github.User{ + Login: ptr("test-org"), + Name: ptr("test-org"), + }, + URL: ptr("https://github.com/test-org/test-repo"), + } +} + +func (s *DependabotPluginSuite) newAlert() *github.DependabotAlert { + return &github.DependabotAlert{} +} + +// --- evalForGranular --- + +func (s *DependabotPluginSuite) TestEvalForGranular_NoAlerts() { + plugin := s.newPlugin(OperationalModeGranular) + helper := &mockApiHelper{} + + err := plugin.evalForGranular(context.Background(), s.newRepo(), nil, &proto.EvalRequest{}, helper) + + require.NoError(s.T(), err) + assert.Equal(s.T(), 0, helper.createEvidenceCalls) +} + +func (s *DependabotPluginSuite) TestEvalForGranular_CallsCreateEvidencePerAlert() { + plugin := s.newPlugin(OperationalModeGranular) + helper := &mockApiHelper{} + alerts := []*github.DependabotAlert{s.newAlert(), s.newAlert(), s.newAlert()} + + // no policy paths → EvaluateGranularPolicies returns empty evidence without invoking OPA + err := plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) + + require.NoError(s.T(), err) + assert.Equal(s.T(), len(alerts), helper.createEvidenceCalls) +} + +func (s *DependabotPluginSuite) TestEvalForGranular_CreateEvidenceError() { + plugin := s.newPlugin(OperationalModeGranular) + wantErr := errors.New("api unavailable") + helper := &mockApiHelper{createEvidenceErr: wantErr} + + err := plugin.evalForGranular(context.Background(), s.newRepo(), []*github.DependabotAlert{s.newAlert()}, &proto.EvalRequest{}, helper) + + require.ErrorIs(s.T(), err, wantErr) +} + +func (s *DependabotPluginSuite) TestEvalForGranular_StopsOnFirstCreateEvidenceError() { + plugin := s.newPlugin(OperationalModeGranular) + helper := &mockApiHelper{createEvidenceErr: errors.New("fail")} + alerts := []*github.DependabotAlert{s.newAlert(), s.newAlert(), s.newAlert()} + + _ = plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) + + assert.Equal(s.T(), 1, helper.createEvidenceCalls, "loop should stop after first error") +} + +// --- evalForBundle --- + +func (s *DependabotPluginSuite) TestEvalForBundle_CallsCreateEvidenceOnce() { + plugin := s.newPlugin(OperationalModeBundled) + helper := &mockApiHelper{} + + // no policy paths → EvaluatePolicies returns empty evidence without invoking OPA + err := plugin.evalForBundle(context.Background(), s.newRepo(), nil, nil, &proto.EvalRequest{}, helper) + + require.NoError(s.T(), err) + assert.Equal(s.T(), 1, helper.createEvidenceCalls) +} + +func (s *DependabotPluginSuite) TestEvalForBundle_WithSecurityTeamMembers() { + plugin := s.newPlugin(OperationalModeBundled) + helper := &mockApiHelper{} + members := []*github.User{{Login: ptr("security-bot")}} + + err := plugin.evalForBundle(context.Background(), s.newRepo(), nil, members, &proto.EvalRequest{}, helper) + + require.NoError(s.T(), err) + assert.Equal(s.T(), 1, helper.createEvidenceCalls) +} + +func (s *DependabotPluginSuite) TestEvalForBundle_CreateEvidenceError() { + plugin := s.newPlugin(OperationalModeBundled) + wantErr := errors.New("api unavailable") + helper := &mockApiHelper{createEvidenceErr: wantErr} + + err := plugin.evalForBundle(context.Background(), s.newRepo(), nil, nil, &proto.EvalRequest{}, helper) + + require.ErrorIs(s.T(), err, wantErr) +} + func TestDependabotPlugin_FetchRepositories(t *testing.T) { } From 986b108a9c295fd0a1b51bc9a76e34a23d43f0cc Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 27 Mar 2026 07:13:19 -0300 Subject: [PATCH 02/11] fix: go mod Signed-off-by: Gustavo Carvalho --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f4aab18..0ed9a17 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/compliance-framework/plugin-dependabot go 1.25.8 require ( - github.com/compliance-framework/agent v0.3.1 + github.com/compliance-framework/agent v0.3.2 github.com/google/go-github/v71 v71.0.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 @@ -68,5 +68,3 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) - -replace github.com/compliance-framework/agent => ../agent diff --git a/go.sum b/go.sum index b47a30d..e205b1b 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/compliance-framework/agent v0.3.2 h1:TBMQAiO5l1k3KB0/jUesEyfinoYVbOTJ4iiVdTcZ0qk= +github.com/compliance-framework/agent v0.3.2/go.mod h1:RM2C+FSFUZzsWrvCS0la+GKf3B8zC24vdPhfDXTnFU0= github.com/compliance-framework/api v0.14.1 h1:Lig39GBwYpVVn8RdC94bcBKWMvt9KuCeG/k5wjUtFCU= github.com/compliance-framework/api v0.14.1/go.mod h1:CMHwcOOCcVRf1u/n3BeqbrP09WWCuwnFAlD7dQfIWCA= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= From 51e302c579ad289746b9a8468d5b7fe5f683992d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 27 Mar 2026 10:01:15 -0300 Subject: [PATCH 03/11] fix: debug messages and several fixes Signed-off-by: Gustavo Carvalho --- main.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index b912723..b879230 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -56,23 +57,37 @@ func (l *DependabotPlugin) ParseConfig() { l.parsedConfig = &ParsedConfig{} if l.config.IncludedRepositories != nil { l.parsedConfig.IncludedRepositories = strings.Split(*l.config.IncludedRepositories, ",") - l.logger.Debug("successfully parsed config", "includedRepositories", l.parsedConfig.IncludedRepositories) } l.parsedConfig.Token = l.config.Token l.parsedConfig.Organization = l.config.Organization l.parsedConfig.User = l.config.User l.parsedConfig.SecurityTeamName = l.config.SecurityTeamName - l.parsedConfig.OperationalMode = l.config.OperationalMode - if l.parsedConfig.OperationalMode == "" { + switch strings.ToLower(string(l.config.OperationalMode)) { + case strings.ToLower(string(OperationalModeGranular)): + l.parsedConfig.OperationalMode = OperationalModeGranular + case strings.ToLower(string(OperationalModeBundled)): + l.parsedConfig.OperationalMode = OperationalModeBundled + default: + l.logger.Debug("ParseConfig: operational-mode not set or unrecognised, defaulting to Bundled", "raw_value", l.config.OperationalMode) l.parsedConfig.OperationalMode = OperationalModeBundled } + l.logger.Debug("ParseConfig: resolved operational mode", + "raw_value", l.config.OperationalMode, + "resolved_value", l.parsedConfig.OperationalMode, + "included_repositories", l.parsedConfig.IncludedRepositories, + ) } func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { config := &PluginConfig{} mapstructure.Decode(req.GetConfig(), config) l.config = config - l.logger.Debug("successfully got config", "includedRepositories", l.config.IncludedRepositories) + l.logger.Debug("Configure: received raw config", + "operational_mode", l.config.OperationalMode, + "organization", l.config.Organization, + "included_repositories", l.config.IncludedRepositories, + "security_team_name", l.config.SecurityTeamName, + ) l.ParseConfig() l.githubClient = github.NewClient(nil).WithAuthToken(l.parsedConfig.Token) @@ -82,6 +97,7 @@ func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.Config func (l *DependabotPlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelper) (*proto.InitResponse, error) { ctx := context.Background() + l.logger.Debug("Init: starting with operational mode", "operational_mode", l.parsedConfig.OperationalMode) var subjectTemplates []*proto.SubjectTemplate switch l.parsedConfig.OperationalMode { @@ -129,6 +145,7 @@ func (l *DependabotPlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelp func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelper) (*proto.EvalResponse, error) { ctx := context.Background() + l.logger.Debug("Eval: starting", "operational_mode", l.parsedConfig.OperationalMode, "policy_paths", req.GetPolicyPaths()) repochan, errchan := l.FetchRepositories(ctx) l.logger.Debug("Fetching repositories from Github API") var securityTeamMembers []*github.User @@ -177,12 +194,20 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp }, err } + l.logger.Debug("Eval: dispatching repo", + "repo", repo.GetFullName(), + "operational_mode", l.parsedConfig.OperationalMode, + "operational_mode_bytes", fmt.Sprintf("%q", string(l.parsedConfig.OperationalMode)), + "is_granular", l.parsedConfig.OperationalMode == OperationalModeGranular, + ) switch l.parsedConfig.OperationalMode { case OperationalModeGranular: + l.logger.Debug("Eval: using granular path", "repo", repo.GetFullName()) if err := l.evalForGranular(ctx, repo, alerts, req, apiHelper); err != nil { return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err } default: + l.logger.Debug("Eval: using bundle path", "repo", repo.GetFullName()) if err := l.evalForBundle(ctx, repo, alerts, securityTeamMembers, req, apiHelper); err != nil { return &proto.EvalResponse{Status: proto.ExecutionStatus_FAILURE}, err } @@ -200,21 +225,31 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp } func (l *DependabotPlugin) evalForGranular(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { - for _, alert := range alerts { + l.logger.Debug("evalForGranular: starting", "repo", repo.GetFullName(), "alert_count", len(alerts), "policy_paths", req.GetPolicyPaths()) + for i, alert := range alerts { + cveID := alert.GetSecurityAdvisory().GetCVEID() + if cveID == "" { + cveID = alert.GetSecurityAdvisory().GetGHSAID() + } + l.logger.Debug("evalForGranular: evaluating alert", "index", i, "cve_id", cveID, "state", alert.GetState()) alertEvidences, err := l.EvaluateGranularPolicies(ctx, repo, alert, req) if err != nil { - l.logger.Error("Failed to evaluate granular policies", "repo", repo.GetFullName(), "error", err) + l.logger.Error("Failed to evaluate granular policies", "repo", repo.GetFullName(), "cve_id", cveID, "error", err) return err } + l.logger.Debug("evalForGranular: evidence produced", "cve_id", cveID, "count", len(alertEvidences)) if err = apiHelper.CreateEvidence(ctx, alertEvidences); err != nil { - l.logger.Error("Failed to send granular evidence", "repo", repo.GetFullName(), "error", err) + l.logger.Error("Failed to send granular evidence", "repo", repo.GetFullName(), "cve_id", cveID, "error", err) return err } + l.logger.Debug("evalForGranular: evidence sent", "cve_id", cveID) } + l.logger.Debug("evalForGranular: done", "repo", repo.GetFullName()) return nil } func (l *DependabotPlugin) evalForBundle(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, securityTeamMembers []*github.User, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { + l.logger.Debug("evalForBundle: starting", "repo", repo.GetFullName(), "alert_count", len(alerts), "policy_paths", req.GetPolicyPaths()) data := &DependabotData{ Alerts: alerts, } @@ -227,11 +262,13 @@ func (l *DependabotPlugin) evalForBundle(ctx context.Context, repo *github.Repos l.logger.Error("Failed to evaluate policies", "repo", repo.GetFullName(), "error", err) return err } + l.logger.Debug("evalForBundle: evidence produced", "repo", repo.GetFullName(), "count", len(evidences)) if err = apiHelper.CreateEvidence(ctx, evidences); err != nil { l.logger.Error("Failed to send evidence", "repo", repo.GetFullName(), "error", err) return err } + l.logger.Debug("evalForBundle: evidence sent", "repo", repo.GetFullName()) return nil } @@ -438,6 +475,7 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re } for _, policyPath := range req.GetPolicyPaths() { + l.logger.Debug("EvaluatePolicies: running policy", "repo", repo.GetFullName(), "policy_path", policyPath) // Explicitly reset steps to make things readable processor := policyManager.NewPolicyProcessor( l.logger, @@ -453,7 +491,11 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re actors, activities, ) + if inputJSON, jsonErr := json.Marshal(data); jsonErr == nil { + l.logger.Debug("EvaluatePolicies: policy input", "policy_path", policyPath, "input", string(inputJSON)) + } evidence, err := processor.GenerateResults(ctx, policyPath, data) + l.logger.Debug("EvaluatePolicies: policy result", "policy_path", policyPath, "evidence_count", len(evidence), "error", err) evidences = slices.Concat(evidences, evidence) if err != nil { accumulatedErrors = errors.Join(accumulatedErrors, err) @@ -575,6 +617,7 @@ func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *g evidences := make([]*proto.Evidence, 0) for _, policyPath := range req.GetPolicyPaths() { + l.logger.Debug("EvaluateGranularPolicies: running policy", "cve_id", cveID, "repo", repo.GetFullName(), "policy_path", policyPath) processor := policyManager.NewPolicyProcessor( l.logger, labels, @@ -584,7 +627,11 @@ func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *g actors, activities, ) + if inputJSON, jsonErr := json.Marshal(alert); jsonErr == nil { + l.logger.Debug("EvaluateGranularPolicies: policy input", "cve_id", cveID, "policy_path", policyPath, "input", string(inputJSON)) + } evidence, err := processor.GenerateResults(ctx, policyPath, alert) + l.logger.Debug("EvaluateGranularPolicies: policy result", "cve_id", cveID, "policy_path", policyPath, "evidence_count", len(evidence), "error", err) evidences = slices.Concat(evidences, evidence) if err != nil { accumulatedErrors = errors.Join(accumulatedErrors, err) From e967a10833dfff5c6be3e4f9c514599adbe0a5f5 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 05:48:35 -0300 Subject: [PATCH 04/11] fix: subject templates shoould be the same Signed-off-by: Gustavo Carvalho --- main.go | 51 +++++++++++++-------------------------------------- 1 file changed, 13 insertions(+), 38 deletions(-) diff --git a/main.go b/main.go index b879230..0b676dd 100644 --- a/main.go +++ b/main.go @@ -99,45 +99,20 @@ func (l *DependabotPlugin) Init(req *proto.InitRequest, apiHelper runner.ApiHelp ctx := context.Background() l.logger.Debug("Init: starting with operational mode", "operational_mode", l.parsedConfig.OperationalMode) - var subjectTemplates []*proto.SubjectTemplate - switch l.parsedConfig.OperationalMode { - case OperationalModeGranular: - subjectTemplates = []*proto.SubjectTemplate{ - { - Name: "dependabot-alert", - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - TitleTemplate: "{{ .cve_id }} in {{ .repository }}", - DescriptionTemplate: "Dependabot alert for {{ .cve_id }} affecting {{ .package_name }} ({{ .ecosystem }}) in {{ .repository }}", - PurposeTemplate: "Represents a specific CVE vulnerability alert detected by Dependabot in a GitHub repository", - IdentityLabelKeys: []string{"repository", "organization", "cve_id"}, - SelectorLabels: []*proto.SubjectLabelSelector{}, - LabelSchema: []*proto.SubjectLabelSchema{ - {Key: "repository", Description: "The name of the GitHub repository"}, - {Key: "organization", Description: "The GitHub organization owning the repository"}, - {Key: "cve_id", Description: "The CVE or GHSA identifier for the vulnerability"}, - {Key: "package_name", Description: "The name of the affected package"}, - {Key: "ecosystem", Description: "The package ecosystem (go, npm, pip, etc.)"}, - {Key: "severity", Description: "The vulnerability severity level (critical, high, medium, low)"}, - {Key: "cvss_score", Description: "The CVSS numeric score of the vulnerability"}, - }, - }, - } - default: - subjectTemplates = []*proto.SubjectTemplate{ - { - Name: "dependabot-repository", - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - TitleTemplate: "Dependabot for repository: {{ .repository }}", - DescriptionTemplate: "Dependabot alerts for GitHub repository {{ .repository }} in organization {{ .organization }}", - PurposeTemplate: "Represents Dependabot monitoring for a GitHub repository being evaluated for compliance", - IdentityLabelKeys: []string{"repository", "organization"}, - SelectorLabels: []*proto.SubjectLabelSelector{}, - LabelSchema: []*proto.SubjectLabelSchema{ - {Key: "repository", Description: "The name of the GitHub repository"}, - {Key: "organization", Description: "The GitHub organization owning the repository"}, - }, + subjectTemplates := []*proto.SubjectTemplate{ + { + Name: "dependabot-repository", + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + TitleTemplate: "Dependabot for repository: {{ .repository }}", + DescriptionTemplate: "Dependabot alerts for GitHub repository {{ .repository }} in organization {{ .organization }}", + PurposeTemplate: "Represents Dependabot monitoring for a GitHub repository being evaluated for compliance", + IdentityLabelKeys: []string{"repository", "organization"}, + SelectorLabels: []*proto.SubjectLabelSelector{}, + LabelSchema: []*proto.SubjectLabelSchema{ + {Key: "repository", Description: "The name of the GitHub repository"}, + {Key: "organization", Description: "The GitHub organization owning the repository"}, }, - } + }, } return runner.InitWithSubjectsAndRisksFromPolicies(ctx, l.logger, req, apiHelper, subjectTemplates) From dc0b06b60165f085cad76ef1311f0e2fdcaee67a Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 06:05:06 -0300 Subject: [PATCH 05/11] Fix granular alert evaluation and review issues --- main.go | 287 +++++++++++++++++++++++++++++---------------------- main_test.go | 58 ++++++++++- 2 files changed, 219 insertions(+), 126 deletions(-) diff --git a/main.go b/main.go index 0b676dd..ed6e693 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,53 @@ type DependabotData struct { SecurityTeamMembers []*github.User } +var errDependabotAlertsPermissionDenied = errors.New("insufficient permissions to fetch dependabot alerts") + +var ( + granularActivities = []*proto.Activity{ + {Title: "Collect Individual Dependabot Alert"}, + } + granularActors = []*proto.OriginActor{ + { + Title: "The Continuous Compliance Framework", + Type: "assessment-platform", + Links: []*proto.Link{ + { + Href: "https://compliance-framework.github.io/docs/", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework"), + }, + }, + }, + { + Title: "Continuous Compliance Framework - Dependabot Plugin", + Type: "tool", + Links: []*proto.Link{ + { + Href: "https://github.com/compliance-framework/plugin-dependabot", + Rel: policyManager.Pointer("reference"), + Text: policyManager.Pointer("The Continuous Compliance Framework Dependabot Plugin"), + }, + }, + }, + } + granularComponents = []*proto.Component{ + { + Identifier: "common-components/github-repository", + Type: "service", + Title: "GitHub Repository", + Description: "A GitHub repository is a discrete codebase or project workspace hosted within a GitHub Organization or user account.", + Purpose: "To serve as the authoritative and version-controlled location for a specific software project.", + }, + } +) + +type granularPolicyContext struct { + labelsBase map[string]string + inventory []*proto.InventoryItem + subjects []*proto.Subject +} + func (l *DependabotPlugin) ParseConfig() { l.parsedConfig = &ParsedConfig{} if l.config.IncludedRepositories != nil { @@ -158,7 +205,7 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp l.logger.Debug("Fetching repository dependabot alerts from Github API", "repo", repo.GetFullName()) alerts, err := l.FetchRepositoryDependabotAlerts(ctx, repo) if err != nil { - if isPermissionError(err) { + if errors.Is(err, errDependabotAlertsPermissionDenied) { l.logger.Warn("Skipping repository due to insufficient permissions for alerts fetch", "repo", repo.GetFullName(), "error", err) reposAlertsPermissionDenied = append(reposAlertsPermissionDenied, repo.GetFullName()) continue @@ -201,25 +248,25 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp func (l *DependabotPlugin) evalForGranular(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { l.logger.Debug("evalForGranular: starting", "repo", repo.GetFullName(), "alert_count", len(alerts), "policy_paths", req.GetPolicyPaths()) + policyContext := newGranularPolicyContext(repo) + totalEvidence := 0 for i, alert := range alerts { - cveID := alert.GetSecurityAdvisory().GetCVEID() - if cveID == "" { - cveID = alert.GetSecurityAdvisory().GetGHSAID() - } + cveID := granularAlertIdentifier(alert) l.logger.Debug("evalForGranular: evaluating alert", "index", i, "cve_id", cveID, "state", alert.GetState()) - alertEvidences, err := l.EvaluateGranularPolicies(ctx, repo, alert, req) + alertEvidences, err := l.EvaluateGranularPolicies(ctx, repo, alert, req, policyContext) if err != nil { l.logger.Error("Failed to evaluate granular policies", "repo", repo.GetFullName(), "cve_id", cveID, "error", err) return err } l.logger.Debug("evalForGranular: evidence produced", "cve_id", cveID, "count", len(alertEvidences)) + totalEvidence += len(alertEvidences) if err = apiHelper.CreateEvidence(ctx, alertEvidences); err != nil { l.logger.Error("Failed to send granular evidence", "repo", repo.GetFullName(), "cve_id", cveID, "error", err) return err } l.logger.Debug("evalForGranular: evidence sent", "cve_id", cveID) } - l.logger.Debug("evalForGranular: done", "repo", repo.GetFullName()) + l.logger.Info("Granular evaluation summary", "repo", repo.GetFullName(), "alert_count", len(alerts), "evidence_count", totalEvidence) return nil } @@ -266,7 +313,7 @@ func (l *DependabotPlugin) FetchRepositoryDependabotAlerts(ctx context.Context, ListCursorOptions: github.ListCursorOptions{}, }) if isPermissionError(err) { - return nil, nil + return nil, fmt.Errorf("%w: %s: %w", errDependabotAlertsPermissionDenied, repo.GetFullName(), err) } l.logger.Debug("Fetched repository dependabot alerts from Github API", "repo", repo.GetFullName(), "count", len(alerts)) return alerts, err @@ -399,7 +446,7 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re { Identifier: fmt.Sprintf("github-repository/%s", repo.GetFullName()), Type: "github-repository", - Title: fmt.Sprintf("Github Repository [%s]", repo.GetName()), + Title: fmt.Sprintf("GitHub Repository [%s]", repo.GetName()), Props: []*proto.Property{ { Name: "name", @@ -466,8 +513,12 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re actors, activities, ) - if inputJSON, jsonErr := json.Marshal(data); jsonErr == nil { - l.logger.Debug("EvaluatePolicies: policy input", "policy_path", policyPath, "input", string(inputJSON)) + if l.logger.IsTrace() { + if inputJSON, jsonErr := json.Marshal(data); jsonErr == nil { + l.logger.Trace("EvaluatePolicies: policy input", "policy_path", policyPath, "input", string(inputJSON)) + } else { + l.logger.Trace("EvaluatePolicies: failed to marshal policy input", "policy_path", policyPath, "error", jsonErr) + } } evidence, err := processor.GenerateResults(ctx, policyPath, data) l.logger.Debug("EvaluatePolicies: policy result", "policy_path", policyPath, "evidence_count", len(evidence), "error", err) @@ -482,113 +533,11 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re return evidences, accumulatedErrors } -func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *github.Repository, alert *github.DependabotAlert, req *proto.EvalRequest) ([]*proto.Evidence, error) { +func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *github.Repository, alert *github.DependabotAlert, req *proto.EvalRequest, policyContext *granularPolicyContext) ([]*proto.Evidence, error) { var accumulatedErrors error - // Extract alert fields - cveID := alert.GetSecurityAdvisory().GetCVEID() - if cveID == "" { - cveID = alert.GetSecurityAdvisory().GetGHSAID() - } - packageName := alert.GetDependency().GetPackage().GetName() - ecosystem := alert.GetDependency().GetPackage().GetEcosystem() - severity := alert.GetSecurityVulnerability().GetSeverity() - var cvssScoreVal float64 - if score := alert.GetSecurityAdvisory().GetCVSS().GetScore(); score != nil { - cvssScoreVal = *score - } - cvssScore := fmt.Sprintf("%.1f", cvssScoreVal) - - // Map GitHub severity ("medium") → CCF standard ("moderate") - impact := severity - if severity == "medium" { - impact = "moderate" - } - - labels := map[string]string{ - "provider": "github", - "type": "dependabot", - "repository": repo.GetName(), - "organization": repo.GetOwner().GetLogin(), - "cve_id": cveID, - "package_name": packageName, - "ecosystem": ecosystem, - "severity": severity, - "impact": impact, - "cvss_score": cvssScore, - } - - activities := []*proto.Activity{ - {Title: "Collect Individual Dependabot Alert"}, - } - actors := []*proto.OriginActor{ - { - Title: "The Continuous Compliance Framework", - Type: "assessment-platform", - Links: []*proto.Link{ - { - Href: "https://compliance-framework.github.io/docs/", - Rel: policyManager.Pointer("reference"), - Text: policyManager.Pointer("The Continuous Compliance Framework"), - }, - }, - }, - { - Title: "Continuous Compliance Framework - Dependabot Plugin", - Type: "tool", - Links: []*proto.Link{ - { - Href: "https://github.com/compliance-framework/plugin-dependabot", - Rel: policyManager.Pointer("reference"), - Text: policyManager.Pointer("The Continuous Compliance Framework Dependabot Plugin"), - }, - }, - }, - } - components := []*proto.Component{ - { - Identifier: "common-components/github-repository", - Type: "service", - Title: "GitHub Repository", - Description: "A GitHub repository is a discrete codebase or project workspace hosted within a GitHub Organization or user account.", - Purpose: "To serve as the authoritative and version-controlled location for a specific software project.", - }, - } - inventory := []*proto.InventoryItem{ - { - Identifier: fmt.Sprintf("github-repository/%s", repo.GetFullName()), - Type: "github-repository", - Title: fmt.Sprintf("Github Repository [%s]", repo.GetName()), - Props: []*proto.Property{ - {Name: "name", Value: repo.GetName()}, - {Name: "path", Value: repo.GetFullName()}, - {Name: "organization", Value: repo.GetOwner().GetLogin()}, - }, - Links: []*proto.Link{ - { - Href: repo.GetURL(), - Text: policyManager.Pointer("Repository URL"), - }, - }, - ImplementedComponents: []*proto.InventoryItemImplementedComponent{ - {Identifier: "common-components/github-repository"}, - }, - }, - } - subjects := []*proto.Subject{ - { - Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, - Identifier: fmt.Sprintf("github-repository/%s", repo.GetFullName()), - }, - { - Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, - Identifier: fmt.Sprintf("github-organization/%s", repo.GetOwner().GetLogin()), - }, - { - Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, - Identifier: "common-components/github-repository", - }, - } + labels := buildGranularPolicyLabels(policyContext.labelsBase, alert) + cveID := labels["cve_id"] evidences := make([]*proto.Evidence, 0) for _, policyPath := range req.GetPolicyPaths() { @@ -596,16 +545,20 @@ func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *g processor := policyManager.NewPolicyProcessor( l.logger, labels, - subjects, - components, - inventory, - actors, - activities, + policyContext.subjects, + granularComponents, + policyContext.inventory, + granularActors, + granularActivities, ) - if inputJSON, jsonErr := json.Marshal(alert); jsonErr == nil { - l.logger.Debug("EvaluateGranularPolicies: policy input", "cve_id", cveID, "policy_path", policyPath, "input", string(inputJSON)) + if l.logger.IsTrace() { + if inputJSON, jsonErr := json.Marshal(alert); jsonErr == nil { + l.logger.Trace("EvaluateGranularPolicies: policy input", "cve_id", cveID, "policy_path", policyPath, "input", string(inputJSON)) + } else { + l.logger.Trace("EvaluateGranularPolicies: failed to marshal policy input", "cve_id", cveID, "policy_path", policyPath, "error", jsonErr) + } } - evidence, err := processor.GenerateResults(ctx, policyPath, alert) + evidence, err := processor.GenerateResults(ctx, policyPath, granularPolicyInput(alert)) l.logger.Debug("EvaluateGranularPolicies: policy result", "cve_id", cveID, "policy_path", policyPath, "evidence_count", len(evidence), "error", err) evidences = slices.Concat(evidences, evidence) if err != nil { @@ -613,11 +566,95 @@ func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *g } } - l.logger.Info("collected granular evidence", "cve_id", cveID, "repo", repo.GetFullName(), "count", len(evidences)) + l.logger.Debug("collected granular evidence", "cve_id", cveID, "repo", repo.GetFullName(), "count", len(evidences)) return evidences, accumulatedErrors } +func newGranularPolicyContext(repo *github.Repository) *granularPolicyContext { + repositoryIdentifier := fmt.Sprintf("github-repository/%s", repo.GetFullName()) + return &granularPolicyContext{ + labelsBase: map[string]string{ + "provider": "github", + "type": "dependabot", + "repository": repo.GetName(), + "organization": repo.GetOwner().GetLogin(), + }, + inventory: []*proto.InventoryItem{ + { + Identifier: repositoryIdentifier, + Type: "github-repository", + Title: fmt.Sprintf("GitHub Repository [%s]", repo.GetName()), + Props: []*proto.Property{ + {Name: "name", Value: repo.GetName()}, + {Name: "path", Value: repo.GetFullName()}, + {Name: "organization", Value: repo.GetOwner().GetLogin()}, + }, + Links: []*proto.Link{ + { + Href: repo.GetURL(), + Text: policyManager.Pointer("Repository URL"), + }, + }, + ImplementedComponents: []*proto.InventoryItemImplementedComponent{ + {Identifier: "common-components/github-repository"}, + }, + }, + }, + subjects: []*proto.Subject{ + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: repositoryIdentifier, + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_INVENTORY_ITEM, + Identifier: fmt.Sprintf("github-organization/%s", repo.GetOwner().GetLogin()), + }, + { + Type: proto.SubjectType_SUBJECT_TYPE_COMPONENT, + Identifier: "common-components/github-repository", + }, + }, + } +} + +func buildGranularPolicyLabels(baseLabels map[string]string, alert *github.DependabotAlert) map[string]string { + severity := alert.GetSecurityVulnerability().GetSeverity() + impact := severity + if severity == "medium" { + impact = "moderate" + } + + var cvssScoreVal float64 + if score := alert.GetSecurityAdvisory().GetCVSS().GetScore(); score != nil { + cvssScoreVal = *score + } + + labels := make(map[string]string, len(baseLabels)+6) + for key, value := range baseLabels { + labels[key] = value + } + labels["cve_id"] = granularAlertIdentifier(alert) + labels["package_name"] = alert.GetDependency().GetPackage().GetName() + labels["ecosystem"] = alert.GetDependency().GetPackage().GetEcosystem() + labels["severity"] = severity + labels["impact"] = impact + labels["cvss_score"] = fmt.Sprintf("%.1f", cvssScoreVal) + return labels +} + +func granularAlertIdentifier(alert *github.DependabotAlert) string { + cveID := alert.GetSecurityAdvisory().GetCVEID() + if cveID == "" { + cveID = alert.GetSecurityAdvisory().GetGHSAID() + } + return cveID +} + +func granularPolicyInput(alert *github.DependabotAlert) []*github.DependabotAlert { + return []*github.DependabotAlert{alert} +} + // isPermissionError returns true if the error from the GitHub client indicates // a permissions or visibility issue (e.g., 401/403/404). func isPermissionError(err error) bool { diff --git a/main_test.go b/main_test.go index 786cf45..a2c3fba 100644 --- a/main_test.go +++ b/main_test.go @@ -71,6 +71,28 @@ func (s *DependabotPluginSuite) newAlert() *github.DependabotAlert { return &github.DependabotAlert{} } +func (s *DependabotPluginSuite) newDetailedAlert(cveID, ghsaID, severity, packageName, ecosystem string, cvssScore *float64) *github.DependabotAlert { + return &github.DependabotAlert{ + State: ptr("open"), + Dependency: &github.Dependency{ + Package: &github.VulnerabilityPackage{ + Name: ptr(packageName), + Ecosystem: ptr(ecosystem), + }, + }, + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + CVEID: ptr(cveID), + GHSAID: ptr(ghsaID), + CVSS: &github.AdvisoryCVSS{ + Score: cvssScore, + }, + }, + SecurityVulnerability: &github.AdvisoryVulnerability{ + Severity: ptr(severity), + }, + } +} + // --- evalForGranular --- func (s *DependabotPluginSuite) TestEvalForGranular_NoAlerts() { @@ -149,6 +171,40 @@ func (s *DependabotPluginSuite) TestEvalForBundle_CreateEvidenceError() { require.ErrorIs(s.T(), err, wantErr) } -func TestDependabotPlugin_FetchRepositories(t *testing.T) { +func (s *DependabotPluginSuite) TestBuildGranularPolicyLabels_UsesGHSAFallbackAndMapsMediumToModerate() { + repo := s.newRepo() + policyContext := newGranularPolicyContext(repo) + score := 7.23 + alert := s.newDetailedAlert("", "GHSA-123", "medium", "openssl", "gomod", &score) + + labels := buildGranularPolicyLabels(policyContext.labelsBase, alert) + + assert.Equal(s.T(), "GHSA-123", labels["cve_id"]) + assert.Equal(s.T(), "moderate", labels["impact"]) + assert.Equal(s.T(), "7.2", labels["cvss_score"]) + assert.Equal(s.T(), "test-repo", labels["repository"]) + assert.Equal(s.T(), "test-org", labels["organization"]) +} + +func (s *DependabotPluginSuite) TestBuildGranularPolicyLabels_UsesCVEDefaultsAndGitHubBranding() { + repo := s.newRepo() + policyContext := newGranularPolicyContext(repo) + alert := s.newDetailedAlert("CVE-2026-0001", "GHSA-ignored", "critical", "lodash", "npm", nil) + + labels := buildGranularPolicyLabels(policyContext.labelsBase, alert) + + assert.Equal(s.T(), "CVE-2026-0001", labels["cve_id"]) + assert.Equal(s.T(), "critical", labels["impact"]) + assert.Equal(s.T(), "0.0", labels["cvss_score"]) + require.Len(s.T(), policyContext.inventory, 1) + assert.Equal(s.T(), "GitHub Repository [test-repo]", policyContext.inventory[0].GetTitle()) +} + +func (s *DependabotPluginSuite) TestGranularPolicyInput_WrapsAlertInSingleElementSlice() { + alert := s.newDetailedAlert("CVE-2026-0002", "GHSA-456", "high", "requests", "pip", nil) + + input := granularPolicyInput(alert) + require.Len(s.T(), input, 1) + assert.Same(s.T(), alert, input[0]) } From fa85538fa0205e24315e7b712ae72a2639eab034 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 06:28:08 -0300 Subject: [PATCH 06/11] Handle config decode errors and normalize organization metadata --- main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index ed6e693..c230d41 100644 --- a/main.go +++ b/main.go @@ -127,7 +127,10 @@ func (l *DependabotPlugin) ParseConfig() { func (l *DependabotPlugin) Configure(req *proto.ConfigureRequest) (*proto.ConfigureResponse, error) { config := &PluginConfig{} - mapstructure.Decode(req.GetConfig(), config) + if err := mapstructure.Decode(req.GetConfig(), config); err != nil { + l.logger.Error("Configure: failed to decode config", "error", err) + return nil, err + } l.config = config l.logger.Debug("Configure: received raw config", "operational_mode", l.config.OperationalMode, @@ -458,7 +461,7 @@ func (l *DependabotPlugin) EvaluatePolicies(ctx context.Context, repo *github.Re }, { Name: "organization", - Value: repo.GetOwner().GetName(), + Value: repo.GetOwner().GetLogin(), }, }, Links: []*proto.Link{ From f296c408477b61fbabf23deacef5fd1bcac2eb24 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 07:23:18 -0300 Subject: [PATCH 07/11] Fix invalid error wrapping for permission-denied alerts --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index c230d41..2299566 100644 --- a/main.go +++ b/main.go @@ -316,7 +316,7 @@ func (l *DependabotPlugin) FetchRepositoryDependabotAlerts(ctx context.Context, ListCursorOptions: github.ListCursorOptions{}, }) if isPermissionError(err) { - return nil, fmt.Errorf("%w: %s: %w", errDependabotAlertsPermissionDenied, repo.GetFullName(), err) + return nil, fmt.Errorf("%w: %s: %v", errDependabotAlertsPermissionDenied, repo.GetFullName(), err) } l.logger.Debug("Fetched repository dependabot alerts from Github API", "repo", repo.GetFullName(), "count", len(alerts)) return alerts, err From 2be6ee0eb2e1852181f3a2886a2b0f1a7cb350ce Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 07:28:56 -0300 Subject: [PATCH 08/11] Assert granular eval returns the first create-evidence error --- main_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/main_test.go b/main_test.go index a2c3fba..b08afe4 100644 --- a/main_test.go +++ b/main_test.go @@ -129,11 +129,13 @@ func (s *DependabotPluginSuite) TestEvalForGranular_CreateEvidenceError() { func (s *DependabotPluginSuite) TestEvalForGranular_StopsOnFirstCreateEvidenceError() { plugin := s.newPlugin(OperationalModeGranular) - helper := &mockApiHelper{createEvidenceErr: errors.New("fail")} + wantErr := errors.New("fail") + helper := &mockApiHelper{createEvidenceErr: wantErr} alerts := []*github.DependabotAlert{s.newAlert(), s.newAlert(), s.newAlert()} - _ = plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) + err := plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) + require.ErrorIs(s.T(), err, wantErr) assert.Equal(s.T(), 1, helper.createEvidenceCalls, "loop should stop after first error") } From c71d5bfe3da0aca6d83d41d4edf1abc1b385f5ab Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 07:45:32 -0300 Subject: [PATCH 09/11] Skip no-op evidence submissions in granular mode --- main.go | 8 ++++++++ main_test.go | 19 +++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/main.go b/main.go index 2299566..7e98fc0 100644 --- a/main.go +++ b/main.go @@ -251,6 +251,10 @@ func (l *DependabotPlugin) Eval(req *proto.EvalRequest, apiHelper runner.ApiHelp func (l *DependabotPlugin) evalForGranular(ctx context.Context, repo *github.Repository, alerts []*github.DependabotAlert, req *proto.EvalRequest, apiHelper runner.ApiHelper) error { l.logger.Debug("evalForGranular: starting", "repo", repo.GetFullName(), "alert_count", len(alerts), "policy_paths", req.GetPolicyPaths()) + if len(req.GetPolicyPaths()) == 0 { + l.logger.Debug("evalForGranular: skipping repo because no policy paths were configured", "repo", repo.GetFullName()) + return nil + } policyContext := newGranularPolicyContext(repo) totalEvidence := 0 for i, alert := range alerts { @@ -263,6 +267,10 @@ func (l *DependabotPlugin) evalForGranular(ctx context.Context, repo *github.Rep } l.logger.Debug("evalForGranular: evidence produced", "cve_id", cveID, "count", len(alertEvidences)) totalEvidence += len(alertEvidences) + if len(alertEvidences) == 0 { + l.logger.Debug("evalForGranular: skipping evidence submission because policy evaluation produced no evidence", "cve_id", cveID, "repo", repo.GetFullName()) + continue + } if err = apiHelper.CreateEvidence(ctx, alertEvidences); err != nil { l.logger.Error("Failed to send granular evidence", "repo", repo.GetFullName(), "cve_id", cveID, "error", err) return err diff --git a/main_test.go b/main_test.go index b08afe4..36a75e7 100644 --- a/main_test.go +++ b/main_test.go @@ -105,38 +105,37 @@ func (s *DependabotPluginSuite) TestEvalForGranular_NoAlerts() { assert.Equal(s.T(), 0, helper.createEvidenceCalls) } -func (s *DependabotPluginSuite) TestEvalForGranular_CallsCreateEvidencePerAlert() { +func (s *DependabotPluginSuite) TestEvalForGranular_NoPolicyPathsSkipsCreateEvidence() { plugin := s.newPlugin(OperationalModeGranular) helper := &mockApiHelper{} alerts := []*github.DependabotAlert{s.newAlert(), s.newAlert(), s.newAlert()} - // no policy paths → EvaluateGranularPolicies returns empty evidence without invoking OPA err := plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) require.NoError(s.T(), err) - assert.Equal(s.T(), len(alerts), helper.createEvidenceCalls) + assert.Equal(s.T(), 0, helper.createEvidenceCalls) } -func (s *DependabotPluginSuite) TestEvalForGranular_CreateEvidenceError() { +func (s *DependabotPluginSuite) TestEvalForGranular_NoPolicyPathsDoNotSurfaceCreateEvidenceErrors() { plugin := s.newPlugin(OperationalModeGranular) wantErr := errors.New("api unavailable") helper := &mockApiHelper{createEvidenceErr: wantErr} err := plugin.evalForGranular(context.Background(), s.newRepo(), []*github.DependabotAlert{s.newAlert()}, &proto.EvalRequest{}, helper) - require.ErrorIs(s.T(), err, wantErr) + require.NoError(s.T(), err) + assert.Equal(s.T(), 0, helper.createEvidenceCalls) } -func (s *DependabotPluginSuite) TestEvalForGranular_StopsOnFirstCreateEvidenceError() { +func (s *DependabotPluginSuite) TestEvalForGranular_NoPolicyPathsSkipAllNoOpApiCalls() { plugin := s.newPlugin(OperationalModeGranular) - wantErr := errors.New("fail") - helper := &mockApiHelper{createEvidenceErr: wantErr} + helper := &mockApiHelper{createEvidenceErr: errors.New("fail")} alerts := []*github.DependabotAlert{s.newAlert(), s.newAlert(), s.newAlert()} err := plugin.evalForGranular(context.Background(), s.newRepo(), alerts, &proto.EvalRequest{}, helper) - require.ErrorIs(s.T(), err, wantErr) - assert.Equal(s.T(), 1, helper.createEvidenceCalls, "loop should stop after first error") + require.NoError(s.T(), err) + assert.Equal(s.T(), 0, helper.createEvidenceCalls) } // --- evalForBundle --- From 53c403bcfea0fcc28d8d1e6d3e1c142a5e18b535 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 08:22:50 -0300 Subject: [PATCH 10/11] Log granular policy input instead of raw alert --- main.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 7e98fc0..e43dd7c 100644 --- a/main.go +++ b/main.go @@ -562,14 +562,15 @@ func (l *DependabotPlugin) EvaluateGranularPolicies(ctx context.Context, repo *g granularActors, granularActivities, ) + policyInput := granularPolicyInput(alert) if l.logger.IsTrace() { - if inputJSON, jsonErr := json.Marshal(alert); jsonErr == nil { + if inputJSON, jsonErr := json.Marshal(policyInput); jsonErr == nil { l.logger.Trace("EvaluateGranularPolicies: policy input", "cve_id", cveID, "policy_path", policyPath, "input", string(inputJSON)) } else { l.logger.Trace("EvaluateGranularPolicies: failed to marshal policy input", "cve_id", cveID, "policy_path", policyPath, "error", jsonErr) } } - evidence, err := processor.GenerateResults(ctx, policyPath, granularPolicyInput(alert)) + evidence, err := processor.GenerateResults(ctx, policyPath, policyInput) l.logger.Debug("EvaluateGranularPolicies: policy result", "cve_id", cveID, "policy_path", policyPath, "evidence_count", len(evidence), "error", err) evidences = slices.Concat(evidences, evidence) if err != nil { From 6fd946ddcf521a515a8391aef3aa7e487c005781 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 30 Mar 2026 08:35:41 -0300 Subject: [PATCH 11/11] Paginate Dependabot alert fetching --- main.go | 58 ++++++++++++++++++++++++++++++++++++++++++++-------- main_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index e43dd7c..113543d 100644 --- a/main.go +++ b/main.go @@ -317,17 +317,59 @@ func (l *DependabotPlugin) FetchSecurityTeamMembers(ctx context.Context) ([]*git } func (l *DependabotPlugin) FetchRepositoryDependabotAlerts(ctx context.Context, repo *github.Repository) ([]*github.DependabotAlert, error) { - alerts, _, err := l.githubClient.Dependabot.ListRepoAlerts(ctx, repo.GetOwner().GetLogin(), repo.GetName(), &github.ListAlertsOptions{ - ListOptions: github.ListOptions{ + opts := &github.ListAlertsOptions{ + ListCursorOptions: github.ListCursorOptions{ PerPage: 100, }, - ListCursorOptions: github.ListCursorOptions{}, - }) - if isPermissionError(err) { - return nil, fmt.Errorf("%w: %s: %v", errDependabotAlertsPermissionDenied, repo.GetFullName(), err) } - l.logger.Debug("Fetched repository dependabot alerts from Github API", "repo", repo.GetFullName(), "count", len(alerts)) - return alerts, err + + allAlerts := make([]*github.DependabotAlert, 0) + for { + alerts, resp, err := l.githubClient.Dependabot.ListRepoAlerts(ctx, repo.GetOwner().GetLogin(), repo.GetName(), opts) + if isPermissionError(err) { + return nil, fmt.Errorf("%w: %s: %v", errDependabotAlertsPermissionDenied, repo.GetFullName(), err) + } + if err != nil { + return nil, err + } + + allAlerts = append(allAlerts, alerts...) + if !advanceDependabotAlertsPage(opts, resp) { + break + } + } + + l.logger.Debug("Fetched repository dependabot alerts from Github API", "repo", repo.GetFullName(), "count", len(allAlerts)) + return allAlerts, nil +} + +func advanceDependabotAlertsPage(opts *github.ListAlertsOptions, resp *github.Response) bool { + if resp == nil { + return false + } + + if resp.Cursor != "" { + opts.ListCursorOptions.Cursor = resp.Cursor + opts.ListCursorOptions.Page = "" + opts.ListOptions.Page = 0 + return true + } + + if resp.NextPageToken != "" { + opts.ListCursorOptions.Page = resp.NextPageToken + opts.ListCursorOptions.Cursor = "" + opts.ListOptions.Page = 0 + return true + } + + if resp.NextPage != 0 { + opts.ListOptions.Page = resp.NextPage + opts.ListCursorOptions.Page = "" + opts.ListCursorOptions.Cursor = "" + return true + } + + return false } func (l *DependabotPlugin) FetchRepositories(ctx context.Context) (<-chan *github.Repository, <-chan error) { diff --git a/main_test.go b/main_test.go index 36a75e7..cad1882 100644 --- a/main_test.go +++ b/main_test.go @@ -209,3 +209,53 @@ func (s *DependabotPluginSuite) TestGranularPolicyInput_WrapsAlertInSingleElemen require.Len(s.T(), input, 1) assert.Same(s.T(), alert, input[0]) } + +func (s *DependabotPluginSuite) TestAdvanceDependabotAlertsPage_UsesCursor() { + opts := &github.ListAlertsOptions{} + + hasNext := advanceDependabotAlertsPage(opts, &github.Response{Cursor: "cursor-2"}) + + require.True(s.T(), hasNext) + assert.Equal(s.T(), "cursor-2", opts.ListCursorOptions.Cursor) + assert.Empty(s.T(), opts.ListCursorOptions.Page) + assert.Zero(s.T(), opts.ListOptions.Page) +} + +func (s *DependabotPluginSuite) TestAdvanceDependabotAlertsPage_UsesNextPageToken() { + opts := &github.ListAlertsOptions{ + ListCursorOptions: github.ListCursorOptions{ + Cursor: "cursor-1", + }, + } + + hasNext := advanceDependabotAlertsPage(opts, &github.Response{NextPageToken: "page-token-2"}) + + require.True(s.T(), hasNext) + assert.Equal(s.T(), "page-token-2", opts.ListCursorOptions.Page) + assert.Empty(s.T(), opts.ListCursorOptions.Cursor) + assert.Zero(s.T(), opts.ListOptions.Page) +} + +func (s *DependabotPluginSuite) TestAdvanceDependabotAlertsPage_UsesNextPageNumber() { + opts := &github.ListAlertsOptions{ + ListCursorOptions: github.ListCursorOptions{ + Cursor: "cursor-1", + Page: "page-token-1", + }, + } + + hasNext := advanceDependabotAlertsPage(opts, &github.Response{NextPage: 2}) + + require.True(s.T(), hasNext) + assert.Equal(s.T(), 2, opts.ListOptions.Page) + assert.Empty(s.T(), opts.ListCursorOptions.Page) + assert.Empty(s.T(), opts.ListCursorOptions.Cursor) +} + +func (s *DependabotPluginSuite) TestAdvanceDependabotAlertsPage_StopsWithoutPaginationMetadata() { + opts := &github.ListAlertsOptions{} + + hasNext := advanceDependabotAlertsPage(opts, &github.Response{}) + + assert.False(s.T(), hasNext) +}