feat: add sail compliance collect and evaluate commands#225
feat: add sail compliance collect and evaluate commands#225ethanolivertroy wants to merge 5 commits intosailpoint-oss:mainfrom
Conversation
|
🎉 Thanks for opening this pull request! Please be sure to check out our contributing guidelines. 🙌 |
There was a problem hiding this comment.
Pull request overview
This pull request introduces a new compliance command to the SailPoint CLI that enables automated evidence collection and control evaluation for compliance frameworks. The implementation adds two subcommands (collect and evaluate) with an embedded NIST 800-53 control pack, addressing the feature request in issue #224.
Changes:
- Added new
compliancecommand withcollectandevaluatesubcommands for gathering tenant evidence and evaluating controls - Implemented a flexible rule engine supporting 8 rule types (all_have_field, any_match, count_gte, field_exists, field_equals, all_field_gte, any_field_matches, value_gte)
- Embedded NIST 800-53 control pack with 8 controls and 11 checks covering account management, MFA, password policies, SOD, lifecycle states, event logging, and access certifications
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| cmd/root/root.go | Registers new compliance command and updates root command |
| cmd/root/root_test.go | Updates expected subcommand count from 16 to 17 |
| cmd/compliance/compliance.go | Main compliance command with collect/evaluate subcommands |
| cmd/compliance/collect.go | Evidence collection from 13 different data sources via SailPoint APIs |
| cmd/compliance/evaluate.go | Control evaluation against evidence bundles with JSON/Markdown output |
| cmd/compliance/controls.go | Rule engine and control pack loading/validation logic |
| cmd/compliance/schema.go | Schema definitions for evidence bundles and evaluation results |
| cmd/compliance/controls/nist_800_53.yaml | Embedded NIST 800-53 control pack definitions |
| cmd/compliance/compliance_test.go | Unit tests for command wiring and evaluation logic |
| cmd/compliance/*.md | Help documentation for commands |
| cmd/compliance/README.md | Comprehensive documentation for the compliance feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for { | ||
| page, resp, err := apiClient.V3.SearchAPI.SearchPost(ctx).Search(*search).Limit(limit).Offset(offset).Execute() | ||
| if err != nil { | ||
| if resp != nil { | ||
| return nil, fmt.Errorf("search failed for index %s query %q: %s: %w", index, query, resp.Status, err) | ||
| } | ||
| return nil, fmt.Errorf("search failed for index %s query %q: %w", index, query, err) | ||
| } | ||
|
|
||
| results = append(results, page...) | ||
| if len(page) < int(limit) { | ||
| break | ||
| } | ||
| offset += limit | ||
| } |
There was a problem hiding this comment.
The searchAll function does not check for context cancellation during pagination. For large datasets, this loop could run for a long time without respecting context cancellation/timeout. Consider checking ctx.Done() at the start of each iteration.
| } | ||
| current = next | ||
| } | ||
|
|
There was a problem hiding this comment.
The function treats empty strings as non-existent by returning (nil, false) on line 418-420. This behavior means that fields explicitly set to empty string will be treated as if they don't exist. This could be intentional for compliance checks, but it should be clearly documented if this is the desired behavior. Consider whether there are scenarios where an empty string should be considered as an existing but empty value versus a missing value.
| // For compliance checks, treat empty/whitespace-only strings as non-existent values. | |
| // This means callers cannot distinguish between a missing field and a field explicitly | |
| // set to an empty string using this function's (value, bool) return. |
| resp, err := state.rawClient.Get(ctx, "/v3/auth-org", map[string]string{"Accept": "application/json"}) | ||
| if err == nil { | ||
| defer resp.Body.Close() | ||
| body, readErr := io.ReadAll(resp.Body) | ||
| if readErr == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { | ||
| bundle.Data.AuthOrgConfig = json.RawMessage(body) | ||
| return nil | ||
| } | ||
| } |
There was a problem hiding this comment.
Potential resource leak: The defer resp.Body.Close() on line 156 only executes if err == nil, but if readErr != nil or the status code is not 2xx (line 158), the function falls through to the export logic without properly handling the response body. While the defer will still execute in those cases, the error handling could lead to confusion. Consider restructuring to ensure the response body is always properly closed before proceeding to the fallback logic.
|
|
||
| switch status.Status { | ||
| case "NOT_STARTED", "IN_PROGRESS": | ||
| time.Sleep(2 * time.Second) |
There was a problem hiding this comment.
The polling loop uses a fixed 2-second sleep between status checks, which could result in up to 180 seconds (3 minutes) of total wait time for 90 attempts. Consider using an exponential backoff strategy or making the polling interval configurable to improve responsiveness while avoiding excessive API calls.
|
|
||
| severityOrder := []string{"critical", "high", "medium", "low", "info"} | ||
| for _, severity := range severityOrder { | ||
| b.WriteString(fmt.Sprintf("### %s\n\n", strings.Title(severity))) |
There was a problem hiding this comment.
The function strings.Title is deprecated as of Go 1.18 and should not be used. It does not properly handle Unicode. For this use case where you're capitalizing simple English words, use strings.ToUpper(severity[:1]) + severity[1:] or the recommended replacement from the golang.org/x/text/cases package.
| b.WriteString(fmt.Sprintf("### %s\n\n", strings.Title(severity))) | |
| b.WriteString(fmt.Sprintf("### %s\n\n", strings.ToUpper(severity[:1])+severity[1:])) |
| for attempt := 0; attempt < 90; attempt++ { | ||
| status, _, err := apiClient.Beta.SPConfigAPI.GetSpConfigExportStatus(ctx, job.JobId).Execute() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| switch status.Status { | ||
| case "NOT_STARTED", "IN_PROGRESS": | ||
| time.Sleep(2 * time.Second) | ||
| continue | ||
| case "COMPLETE": | ||
| exported, _, err := apiClient.Beta.SPConfigAPI.GetSpConfigExport(ctx, job.JobId).Execute() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| objects := make([]map[string]interface{}, 0, len(exported.Objects)) | ||
| for _, obj := range exported.Objects { | ||
| if obj.Self != nil && obj.Self.Type != nil && *obj.Self.Type != includeType { | ||
| continue | ||
| } | ||
| if obj.Object != nil { | ||
| objects = append(objects, obj.Object) | ||
| } | ||
| } | ||
| return objects, nil | ||
| case "FAILED": | ||
| return nil, fmt.Errorf("spconfig export failed for %s", includeType) | ||
| case "CANCELLED": | ||
| return nil, fmt.Errorf("spconfig export cancelled for %s", includeType) | ||
| default: | ||
| return nil, fmt.Errorf("unexpected spconfig export status for %s: %s", includeType, status.Status) | ||
| } | ||
| } |
There was a problem hiding this comment.
The polling loop does not check for context cancellation. The loop should check ctx.Done() or use a select statement to respect context cancellation/timeout. This could cause the command to hang indefinitely or fail to respond to user interrupts (Ctrl+C).
| result.Metadata.GeneratedAt = time.Now().UTC() | ||
| } | ||
|
|
||
| if err := writeJSONOutput(outputFile, result, true); err != nil { |
There was a problem hiding this comment.
The evaluate command always writes JSON output with pretty-printing enabled (hardcoded true in line 54), while the collect command offers a --pretty flag for user control. For consistency, consider adding a --pretty flag to the evaluate command as well to give users control over output formatting.
| @@ -0,0 +1,140 @@ | |||
| package compliance | |||
There was a problem hiding this comment.
Missing copyright header. All Go source files in the cmd/ directory should include a copyright header as the first line following the pattern: "// Copyright (c) YEAR, SailPoint Technologies, Inc. All rights reserved." where YEAR should be the current year (2026).
| @@ -0,0 +1,110 @@ | |||
| package compliance | |||
There was a problem hiding this comment.
Missing copyright header. All Go source files in the cmd/ directory should include a copyright header as the first line following the pattern: "// Copyright (c) YEAR, SailPoint Technologies, Inc. All rights reserved." where YEAR should be the current year (2026).
| @@ -0,0 +1,141 @@ | |||
| package compliance | |||
There was a problem hiding this comment.
Missing copyright header. All Go source files in the cmd/ directory should include a copyright header as the first line following the pattern: "// Copyright (c) YEAR, SailPoint Technologies, Inc. All rights reserved." where YEAR should be the current year (2026).
|
I would need a dev environment to test this out, though. |
Summary
sail compliancecommand withcollectandevaluatesubcommandsValidation
go test ./cmd/compliance ./cmd/root ./internal/jsonpathgo build ./cmd/... ./internal/...go build -o ./build/sail .Notes
go build ./...currently fails due pre-existing non-package file in untracked path:tue_feb_24_2026_building_a_compliance_adapter_for_sail_point/v1_cmd/root/root.go.Fixes MYOSC-2
Fixes #224