Skip to content

feat: add sail compliance collect and evaluate commands#225

Open
ethanolivertroy wants to merge 5 commits intosailpoint-oss:mainfrom
ethanolivertroy:feature/compliance-collect
Open

feat: add sail compliance collect and evaluate commands#225
ethanolivertroy wants to merge 5 commits intosailpoint-oss:mainfrom
ethanolivertroy:feature/compliance-collect

Conversation

@ethanolivertroy
Copy link

@ethanolivertroy ethanolivertroy commented Feb 25, 2026

Summary

  • add new sail compliance command with collect and evaluate subcommands
  • add evidence bundle and evaluation result schemas plus control rule engine
  • add embedded NIST 800-53 control pack and compliance docs/help text
  • register compliance command at root and add unit tests for command wiring/evaluation

Validation

  • go test ./cmd/compliance ./cmd/root ./internal/jsonpath
  • go 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

Copilot AI review requested due to automatic review settings February 25, 2026 02:16
@codey-bot
Copy link

codey-bot bot commented Feb 25, 2026

🎉 Thanks for opening this pull request! Please be sure to check out our contributing guidelines. 🙌

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compliance command with collect and evaluate subcommands 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.

Comment on lines +394 to +408
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
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
current = next
}

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +162
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
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

switch status.Status {
case "NOT_STARTED", "IN_PROGRESS":
time.Sleep(2 * time.Second)
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

severityOrder := []string{"critical", "high", "medium", "low", "info"}
for _, severity := range severityOrder {
b.WriteString(fmt.Sprintf("### %s\n\n", strings.Title(severity)))
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
b.WriteString(fmt.Sprintf("### %s\n\n", strings.Title(severity)))
b.WriteString(fmt.Sprintf("### %s\n\n", strings.ToUpper(severity[:1])+severity[1:]))

Copilot uses AI. Check for mistakes.
Comment on lines +346 to +378
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)
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
result.Metadata.GeneratedAt = time.Now().UTC()
}

if err := writeJSONOutput(outputFile, result, true); err != nil {
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,140 @@
package compliance
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,110 @@
package compliance
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,141 @@
package compliance
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
@ethanolivertroy
Copy link
Author

I would need a dev environment to test this out, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Compliance evidence collection and control evaluation command for SailPoint CLI

2 participants