Skip to content

Commit 2a018f1

Browse files
authored
fix: allowlist checks (#10)
* fix: allowlist checks Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> * fix: copilot issues Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com> --------- Signed-off-by: Gustavo Carvalho <gustavo.carvalho@container-solutions.com>
1 parent 4dc6ee0 commit 2a018f1

7 files changed

Lines changed: 244 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ For the moment, it is solely limited to authenticated Github organizations with
1111
- `read:org` for the organization to be queried. Note - you _might_ need to be an administrator of the GH Org to work correctly
1212
- `read:members` to be able to read teams
1313

14+
### IP allow-list data
15+
16+
GitHub organization IP allow-list collection is disabled by default. Enable it only when the plugin is configured with a Classic Personal Access Token with organization administrator permissions. Classic PATs are broad credentials, so prefer leaving `collect_ip_allow_list` set to `false` unless IP allow-list policies are required.
17+
18+
Fine-grained Personal Access Tokens and GitHub App installation tokens may be able to read other organization settings, but GitHub does not currently allow them to query `organization.ipAllowListEntries` through GraphQL.
19+
20+
If IP allow-list data cannot be fetched, the plugin continues with partial data and policies that depend on `ip_allow_list` should report a skip reason instead of evaluating incomplete evidence.
21+
1422
## Building
1523

1624
Once you are ready to serve the plugin, you need to build the binaries which can be used by the agent.
@@ -37,6 +45,7 @@ In the example above, setting an empty token, and an environment variable `CCF_P
3745

3846
```shell
3947
export CCF_PLUGINS_GITHUB_CONFIG_TOKEN="github_pat_1234..."
48+
export CCF_PLUGINS_GITHUB_CONFIG_COLLECT_IP_ALLOW_LIST="false"
4049
```
4150

4251
```yaml
@@ -46,6 +55,7 @@ plugins:
4655
config:
4756
token: "" # Will be read from the CCF_PLUGINS_GITHUB_CONFIG_TOKEN environment variable
4857
organization: test-org # The name of the organization
58+
collect_ip_allow_list: false # Set to true only when using a Classic PAT and IP allow-list evidence is required
4959
```
5060
5161
## Releasing

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/hashicorp/go-hclog v1.6.3
99
github.com/hashicorp/go-plugin v1.7.0
1010
github.com/mitchellh/mapstructure v1.5.0
11+
github.com/open-policy-agent/opa v1.14.1
1112
)
1213

1314
require (
@@ -36,7 +37,6 @@ require (
3637
github.com/mattn/go-colorable v0.1.14 // indirect
3738
github.com/mattn/go-isatty v0.0.20 // indirect
3839
github.com/oklog/run v1.2.0 // indirect
39-
github.com/open-policy-agent/opa v1.14.1 // indirect
4040
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
4141
github.com/segmentio/asm v1.2.1 // indirect
4242
github.com/sirupsen/logrus v1.9.4 // indirect

‎internal/data.go‎

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package internal
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
78

@@ -29,7 +30,7 @@ type GithubData struct {
2930
Teams []*github.Team `json:"teams"`
3031
Members []*github.User `json:"members"`
3132
SSO *OrgSSO `json:"sso"`
32-
IPAllowList []IPAllowListEntry `json:"ip_allow_list"`
33+
IPAllowList *[]IPAllowListEntry `json:"ip_allow_list"`
3334
}
3435

3536
type DataFetcher struct {
@@ -44,7 +45,7 @@ func NewDataFetcher(logger hclog.Logger, client *github.Client) *DataFetcher {
4445
}
4546
}
4647

47-
func (df DataFetcher) FetchData(ctx context.Context, organization string) (*GithubData, []*proto.Step, error) {
48+
func (df DataFetcher) FetchData(ctx context.Context, organization string, collectIPAllowList bool) (*GithubData, []*proto.Step, error) {
4849
steps := make([]*proto.Step, 0)
4950

5051
steps = append(steps, &proto.Step{
@@ -76,26 +77,30 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*Gith
7677
Remarks: policy_manager.Pointer("More information: https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-saml-single-sign-on-for-your-organization/about-identity-and-access-management-with-saml-single-sign-on"),
7778
})
7879

79-
steps = append(steps, &proto.Step{
80-
Title: "Get IP Allow-List",
81-
Description: "Fetches the IP allow-list entries for the organization via the GitHub GraphQL API",
82-
Remarks: policy_manager.Pointer("More information: https://docs.github.com/en/graphql/reference/objects#ipallowlistentry"),
83-
})
80+
if collectIPAllowList {
81+
steps = append(steps, &proto.Step{
82+
Title: "Get IP Allow-List",
83+
Description: "Fetches the IP allow-list entries for the organization via the GitHub GraphQL API",
84+
Remarks: policy_manager.Pointer("More information: https://docs.github.com/en/graphql/reference/objects#ipallowlistentry"),
85+
})
86+
}
8487

8588
org, _, err := df.client.Organizations.Get(ctx, organization)
8689
if err != nil {
8790
df.logger.Error("Error getting organization information", "org", organization, "error", err)
8891
return nil, nil, err
8992
}
9093

91-
var allTeams []*github.Team
94+
var accumulatedErrors error
95+
allTeams := make([]*github.Team, 0)
9296
paginationOpt := &github.ListOptions{PerPage: 100}
9397

9498
for {
9599
teams, resp, err := df.client.Teams.ListTeams(ctx, organization, paginationOpt)
96100
if err != nil {
97-
df.logger.Error("Error getting teams information", "org", organization, "error", err)
98-
return nil, nil, err
101+
df.logger.Warn("Skipping teams collection after GitHub API error", "org", organization, "error", err)
102+
accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("failed to fetch teams: %w", err))
103+
break
99104
}
100105

101106
allTeams = append(allTeams, teams...)
@@ -105,7 +110,7 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*Gith
105110
paginationOpt.Page = resp.NextPage
106111
}
107112

108-
var allAdminMembers []*github.User
113+
allAdminMembers := make([]*github.User, 0)
109114
memberOpt := &github.ListMembersOptions{
110115
Role: "admin",
111116
ListOptions: github.ListOptions{PerPage: 100},
@@ -114,8 +119,9 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*Gith
114119
for {
115120
members, resp, err := df.client.Organizations.ListMembers(ctx, organization, memberOpt)
116121
if err != nil {
117-
df.logger.Error("Error getting admin members", "org", organization, "error", err)
118-
return nil, nil, err
122+
df.logger.Warn("Skipping admin member collection after GitHub API error", "org", organization, "error", err)
123+
accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("failed to fetch admin members: %w", err))
124+
break
119125
}
120126

121127
allAdminMembers = append(allAdminMembers, members...)
@@ -127,23 +133,28 @@ func (df DataFetcher) FetchData(ctx context.Context, organization string) (*Gith
127133

128134
ssoData, err := df.fetchSSO(ctx, organization)
129135
if err != nil {
130-
df.logger.Error("Error getting SSO configuration", "org", organization, "error", err)
131-
return nil, nil, err
136+
df.logger.Warn("Skipping SSO collection after GitHub API error", "org", organization, "error", err)
137+
accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("failed to fetch SSO configuration: %w", err))
138+
ssoData = nil
132139
}
133140

134-
ipAllowList, err := df.fetchIPAllowList(ctx, organization)
135-
if err != nil {
136-
df.logger.Error("Error getting IP allow-list", "org", organization, "error", err)
137-
return nil, nil, err
141+
var ipAllowList []IPAllowListEntry
142+
if collectIPAllowList {
143+
ipAllowList, err = df.fetchIPAllowList(ctx, organization)
144+
if err != nil {
145+
df.logger.Warn("Skipping IP allow-list collection after GitHub API error", "org", organization, "error", err)
146+
accumulatedErrors = errors.Join(accumulatedErrors, fmt.Errorf("failed to fetch IP allow-list. Please confirm a Classic token PAT is used to gather IP allowlist information: %w", err))
147+
ipAllowList = nil
148+
}
138149
}
139150

140151
return &GithubData{
141152
Settings: org,
142153
Teams: allTeams,
143154
Members: allAdminMembers,
144155
SSO: ssoData,
145-
IPAllowList: ipAllowList,
146-
}, steps, nil
156+
IPAllowList: &ipAllowList,
157+
}, steps, accumulatedErrors
147158
}
148159

149160
func (df DataFetcher) fetchSSO(ctx context.Context, organization string) (*OrgSSO, error) {
@@ -233,7 +244,7 @@ func (df DataFetcher) fetchIPAllowList(ctx context.Context, organization string)
233244
}
234245
}`
235246

236-
var entries []IPAllowListEntry
247+
entries := make([]IPAllowListEntry, 0)
237248
var after *string
238249
for {
239250
gqlQuery := graphqlRequest{

‎internal/data_test.go‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/google/go-github/v71/github"
1212
"github.com/hashicorp/go-hclog"
13+
"github.com/open-policy-agent/opa/v1/rego"
1314
)
1415

1516
func testGithubClient(t *testing.T, handler http.Handler) (*github.Client, func()) {
@@ -27,6 +28,142 @@ func testGithubClient(t *testing.T, handler http.Handler) (*github.Client, func(
2728
return client, server.Close
2829
}
2930

31+
func TestFetchDataReturnsErrorWhenOrganizationFetchFails(t *testing.T) {
32+
client, cleanup := testGithubClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+
http.Error(w, "organization unavailable", http.StatusInternalServerError)
34+
}))
35+
defer cleanup()
36+
37+
fetcher := NewDataFetcher(hclog.NewNullLogger(), client)
38+
data, steps, err := fetcher.FetchData(context.Background(), "acme", false)
39+
if err == nil {
40+
t.Fatal("FetchData should return an error when the required organization endpoint fails")
41+
}
42+
if data != nil {
43+
t.Fatalf("data = %#v, want nil", data)
44+
}
45+
if steps != nil {
46+
t.Fatalf("steps = %#v, want nil", steps)
47+
}
48+
}
49+
50+
func TestFetchDataSkipsOptionalCollectionErrors(t *testing.T) {
51+
optionalRequests := make(map[string]int)
52+
client, cleanup := testGithubClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
switch r.URL.Path {
54+
case "/orgs/acme":
55+
w.Header().Set("Content-Type", "application/json")
56+
_, _ = w.Write([]byte(`{"login":"acme","name":"Acme","url":"https://api.github.com/orgs/acme"}`))
57+
case "/orgs/acme/teams", "/orgs/acme/members", "/orgs/acme/sso", "/graphql":
58+
optionalRequests[r.URL.Path]++
59+
http.Error(w, "optional data unavailable", http.StatusInternalServerError)
60+
default:
61+
t.Fatalf("unexpected request path %s", r.URL.Path)
62+
}
63+
}))
64+
defer cleanup()
65+
66+
fetcher := NewDataFetcher(hclog.NewNullLogger(), client)
67+
data, steps, err := fetcher.FetchData(context.Background(), "acme", true)
68+
if err == nil {
69+
t.Fatal("FetchData should return accumulated errors when optional collections fail")
70+
}
71+
if data == nil {
72+
t.Fatal("data should be returned when only optional collection fails")
73+
}
74+
if data.Settings.GetLogin() != "acme" {
75+
t.Fatalf("organization login = %q, want acme", data.Settings.GetLogin())
76+
}
77+
if len(data.Teams) != 0 {
78+
t.Fatalf("len(Teams) = %d, want 0", len(data.Teams))
79+
}
80+
if len(data.Members) != 0 {
81+
t.Fatalf("len(Members) = %d, want 0", len(data.Members))
82+
}
83+
if data.SSO != nil {
84+
t.Fatalf("SSO = %#v, want nil", data.SSO)
85+
}
86+
if data.IPAllowList == nil {
87+
t.Fatal("IPAllowList pointer should be set")
88+
}
89+
if *data.IPAllowList != nil {
90+
t.Fatalf("IPAllowList = %#v, want nil slice for skipped collection", *data.IPAllowList)
91+
}
92+
if len(steps) == 0 {
93+
t.Fatal("steps should still describe the collection activity")
94+
}
95+
for _, path := range []string{"/orgs/acme/teams", "/orgs/acme/members", "/orgs/acme/sso", "/graphql"} {
96+
if optionalRequests[path] != 1 {
97+
t.Fatalf("%s requests = %d, want 1", path, optionalRequests[path])
98+
}
99+
}
100+
}
101+
102+
func TestFetchDataDoesNotCollectIPAllowListWhenDisabled(t *testing.T) {
103+
graphqlRequests := 0
104+
client, cleanup := testGithubClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105+
w.Header().Set("Content-Type", "application/json")
106+
switch r.URL.Path {
107+
case "/orgs/acme":
108+
_, _ = w.Write([]byte(`{"login":"acme","name":"Acme","url":"https://api.github.com/orgs/acme"}`))
109+
case "/orgs/acme/teams", "/orgs/acme/members":
110+
_, _ = w.Write([]byte(`[]`))
111+
case "/orgs/acme/sso":
112+
http.NotFound(w, r)
113+
case "/graphql":
114+
graphqlRequests++
115+
t.Fatalf("GraphQL IP allow-list request should not be made when collection is disabled")
116+
default:
117+
t.Fatalf("unexpected request path %s", r.URL.Path)
118+
}
119+
}))
120+
defer cleanup()
121+
122+
fetcher := NewDataFetcher(hclog.NewNullLogger(), client)
123+
data, steps, err := fetcher.FetchData(context.Background(), "acme", false)
124+
if err != nil {
125+
t.Fatalf("FetchData returned error: %v", err)
126+
}
127+
if data.IPAllowList == nil {
128+
t.Fatal("IPAllowList pointer should be set")
129+
}
130+
if *data.IPAllowList != nil {
131+
t.Fatalf("IPAllowList = %#v, want nil slice when collection is disabled", *data.IPAllowList)
132+
}
133+
encoded, err := json.Marshal(data)
134+
if err != nil {
135+
t.Fatalf("marshaling GithubData: %v", err)
136+
}
137+
var payload map[string]interface{}
138+
if err := json.Unmarshal(encoded, &payload); err != nil {
139+
t.Fatalf("unmarshaling GithubData: %v", err)
140+
}
141+
if value, ok := payload["ip_allow_list"]; !ok || value != nil {
142+
t.Fatalf("JSON ip_allow_list = %#v, present = %v, want present null", value, ok)
143+
}
144+
evaluation, err := rego.New(
145+
rego.Query("input.ip_allow_list"),
146+
rego.Input(data),
147+
).Eval(context.Background())
148+
if err != nil {
149+
t.Fatalf("evaluating OPA input: %v", err)
150+
}
151+
if len(evaluation) != 1 || len(evaluation[0].Expressions) != 1 {
152+
t.Fatalf("OPA evaluation = %#v, want one null expression", evaluation)
153+
}
154+
if evaluation[0].Expressions[0].Value != nil {
155+
t.Fatalf("OPA input.ip_allow_list = %#v, want null", evaluation[0].Expressions[0].Value)
156+
}
157+
if graphqlRequests != 0 {
158+
t.Fatalf("GraphQL requests = %d, want 0", graphqlRequests)
159+
}
160+
for _, step := range steps {
161+
if step.Title == "Get IP Allow-List" {
162+
t.Fatal("IP allow-list collection step should not be present when collection is disabled")
163+
}
164+
}
165+
}
166+
30167
func TestFetchSSOUsesRelativeURL(t *testing.T) {
31168
client, cleanup := testGithubClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32169
if r.Method != http.MethodGet {

‎internal/eval_integration_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func TestDataFetcher_FetchData(t *testing.T) {
2121
client: github.NewClient(nil).WithAuthToken(os.Getenv("GITHUB_TOKEN")),
2222
}
2323

24-
org, _, err := fetcher.FetchData(ctx, "compliance-framework")
24+
org, _, err := fetcher.FetchData(ctx, "compliance-framework", false)
2525
if err != nil {
2626
t.Error(err)
2727
}

‎main.go‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import (
1515
)
1616

1717
type PluginConfig struct {
18-
Token string `mapstructure:"token"`
19-
Organization string `mapstructure:"organization"`
18+
Token string `mapstructure:"token"`
19+
Organization string `mapstructure:"organization"`
20+
CollectIPAllowList bool `mapstructure:"collect_ip_allow_list"`
2021
}
2122

2223
type Validator interface {
@@ -72,7 +73,7 @@ func (l *CompliancePlugin) Configure(req *proto.ConfigureRequest) (*proto.Config
7273
// In this method, you should save any configuration values to your plugin struct, so you can later
7374
// re-use them in PrepareForEval and Eval.
7475
config := &PluginConfig{}
75-
err := mapstructure.Decode(req.GetConfig(), config)
76+
err := mapstructure.WeakDecode(req.GetConfig(), config)
7677
if err != nil {
7778
l.logger.Error("Configuration cannot be decoded. Ensure the correct data has been passed.")
7879
return nil, err
@@ -127,11 +128,16 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api
127128

128129
dataFetcher := internal.NewDataFetcher(l.logger, l.githubClient)
129130

130-
data, collectSteps, err := dataFetcher.FetchData(ctx, l.config.Organization)
131-
if err != nil {
132-
return &proto.EvalResponse{
133-
Status: proto.ExecutionStatus_FAILURE,
134-
}, fmt.Errorf("failed to fetch data: %w", err)
131+
data, collectSteps, dataErr := dataFetcher.FetchData(ctx, l.config.Organization, l.config.CollectIPAllowList)
132+
if dataErr != nil {
133+
l.logger.Warn("Completed with non-fatal data collection errors", "error", dataErr)
134+
// Continue with partial data - errors are accumulated but not fatal
135+
// Policies will use skip_reason for fields that couldn't be fetched
136+
if data == nil {
137+
return &proto.EvalResponse{
138+
Status: proto.ExecutionStatus_FAILURE,
139+
}, fmt.Errorf("failed to fetch data: %w", dataErr)
140+
}
135141
}
136142

137143
stepActivities := append(activities, &proto.Activity{
@@ -163,7 +169,11 @@ func (l *CompliancePlugin) Eval(request *proto.EvalRequest, apiHelper runner.Api
163169
Status: evalStatus,
164170
}
165171

166-
return resp, nil
172+
if dataErr != nil {
173+
resp.Status = proto.ExecutionStatus_FAILURE
174+
}
175+
176+
return resp, dataErr
167177
}
168178

169179
func main() {

0 commit comments

Comments
 (0)