From f3554697da9387206cc8e579837e774cbd324824 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 2 Jul 2026 23:12:12 -0400 Subject: [PATCH 1/3] Integrate AWS Security Hub and Organizations API for NIST compliance reporting - Implement new AWS client for fetching live Security Hub findings and organization summary. - Refactor report generation to support both demo and live data. - Enhance TUI to asynchronously load findings and display loading state. - Add tests for finding aggregation and parsing NIST control IDs. - Update go.mod and go.sum with necessary AWS SDK dependencies. --- CLAUDE.md | 4 + cmd/fetch.go | 44 +++++++ cmd/report.go | 11 +- cmd/root.go | 11 +- go.mod | 16 +++ go.sum | 32 +++++ internal/awsclient/client.go | 49 ++++++++ internal/awsclient/findings.go | 179 ++++++++++++++++++++++++++++ internal/awsclient/findings_test.go | 130 ++++++++++++++++++++ internal/awsclient/org.go | 37 ++++++ internal/nist/types.go | 7 ++ internal/tui/model.go | 78 ++++++++++-- internal/tui/tui.go | 7 +- internal/tui/view.go | 17 +++ 14 files changed, 606 insertions(+), 16 deletions(-) create mode 100644 cmd/fetch.go create mode 100644 internal/awsclient/client.go create mode 100644 internal/awsclient/findings.go create mode 100644 internal/awsclient/findings_test.go create mode 100644 internal/awsclient/org.go diff --git a/CLAUDE.md b/CLAUDE.md index e1ddeca..f46a14a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,10 @@ go mod tidy 3. **Live Security Hub integration** — `internal/awsclient/` package, `GetFindings` with NIST standard filter + org scope, async tea.Cmd pattern 4. **Threat modeling wizard** — `huh` forms, STRIDE categories, Markdown output 5. **Report export** — `--format markdown|json|html`, output suitable for ATO evidence packages +6. **Stabilize headless JSON output** — lock the `report nist --format json` schema before anything automated depends on it; this becomes an on-disk contract once item 8 lands, not just a CLI convenience +7. **Scheduled Lambda execution** — thin handler (new `internal/lambda` or `cmd/lambda`) that calls the same `newFetcher`/`GetNISTFindings` path as `report nist` directly as a library, not by shelling out to the binary. Triggered by EventBridge Scheduler on a per-org cadence (daily/weekly/configurable). This is a separate deployable surface from the core binary — keep it additive, don't let it pull scheduling/AWS-deployment concerns into `cmd/` or `internal/tui` +8. **S3 result storage + drift detection** — one JSON object per run (`runs/{org-id}/{yyyy-mm-dd}/{unix-ts}.json`, plus a `latest.json` pointer per org); new `internal/drift` package with a pure `Compare(prev, curr []nist.Finding) []DriftEvent` function, no AWS SDK dependency, unit-testable like `buildTable()` +9. **Continuous control monitoring dashboard** — direction not yet decided (AWS-native QuickSight/Athena or CloudWatch metrics vs. a bespoke web app vs. a history/drift view added to the existing TUI). The bespoke-web-app option is the one path here that conflicts with the "no persistent install" constraint above — revisit once item 8 produces real drift data to look at ## IAM Requirements (for live mode) diff --git a/cmd/fetch.go b/cmd/fetch.go new file mode 100644 index 0000000..9919da7 --- /dev/null +++ b/cmd/fetch.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + "cloudcomply/internal/awsclient" + "cloudcomply/internal/nist" + "cloudcomply/internal/tui" +) + +// fetchTimeout bounds how long headless `report nist` waits on a live AWS +// call (the TUI has its own copy of this constant in internal/tui, since +// its async fetch is triggered independently via Bubble Tea's Init()). +const fetchTimeout = 30 * time.Second + +// newFetcher centralizes the demo-vs-live decision so root.go (TUI) and +// report.go (headless) don't each duplicate it. +func newFetcher(demo bool) tui.FindingsFetcher { + if demo { + return func(ctx context.Context) ([]nist.Finding, nist.OrgSummary, error) { + return nist.DemoFindings(), nist.OrgSummary{Name: "Acme Federal Org", AccountCount: 47}, nil + } + } + return func(ctx context.Context) ([]nist.Finding, nist.OrgSummary, error) { + client, err := awsclient.New(ctx) + if err != nil { + return nil, nist.OrgSummary{}, fmt.Errorf("connecting to AWS: %w (try --demo)", err) + } + if _, err := client.WhoAmI(ctx); err != nil { + return nil, nist.OrgSummary{}, fmt.Errorf("no valid AWS credentials found: %w (try --demo)", err) + } + findings, err := client.GetNISTFindings(ctx) + if err != nil { + return nil, nist.OrgSummary{}, fmt.Errorf("fetching Security Hub findings: %w", err) + } + org, err := client.GetOrgSummary(ctx) + if err != nil { + return nil, nist.OrgSummary{}, fmt.Errorf("fetching organization summary: %w", err) + } + return findings, org, nil + } +} diff --git a/cmd/report.go b/cmd/report.go index df6b0ca..551627b 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -1,9 +1,10 @@ package cmd import ( + "context" + "github.com/spf13/cobra" - "cloudcomply/internal/nist" "cloudcomply/internal/report" ) @@ -18,7 +19,13 @@ var reportNistCmd = &cobra.Command{ Use: "nist", Short: "Generate a NIST SP 800-53 findings report", RunE: func(cmd *cobra.Command, args []string) error { - return report.RenderNIST(cmd.OutOrStdout(), nist.DemoFindings(), reportFormat) + ctx, cancel := context.WithTimeout(cmd.Context(), fetchTimeout) + defer cancel() + findings, _, err := newFetcher(demoMode)(ctx) + if err != nil { + return err + } + return report.RenderNIST(cmd.OutOrStdout(), findings, reportFormat) }, } diff --git a/cmd/root.go b/cmd/root.go index 5246c39..a393c7d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,14 +10,23 @@ import ( "cloudcomply/internal/tui" ) +// demoMode is a persistent flag shared by the TUI and `report nist` — see +// cmd/fetch.go. Default is live AWS calls; --demo opts into the old +// fake-data-only behavior. +var demoMode bool + var rootCmd = &cobra.Command{ Use: "cloudcomply", Short: "Assess AWS Organizations against NIST SP 800-53 / RMF controls", RunE: func(cmd *cobra.Command, args []string) error { - return tui.Run() + return tui.Run(newFetcher(demoMode)) }, } +func init() { + rootCmd.PersistentFlags().BoolVar(&demoMode, "demo", false, "use built-in demo data instead of live AWS Security Hub calls") +} + // Execute runs the CLI. version/commit/date come from ldflags set at build // time (see .goreleaser.yml); main.go passes them through unchanged. func Execute(version, commit, date string) error { diff --git a/go.mod b/go.mod index ac537ec..d1e7572 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,22 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.27 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.26 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/organizations v1.51.12 // indirect + github.com/aws/aws-sdk-go-v2/service/securityhub v1.71.9 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect diff --git a/go.sum b/go.sum index 21b95a7..209b826 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,35 @@ +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI= +github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/organizations v1.51.12 h1:Jdm3qi2mCGoZTZVyNJfh1M3tlQrNca3iloYm96xw0Bc= +github.com/aws/aws-sdk-go-v2/service/organizations v1.51.12/go.mod h1:2ibX1FoyhvTXbIR4TP/Vf6BB6Tc3YW9jWbvNflSOcUM= +github.com/aws/aws-sdk-go-v2/service/securityhub v1.71.9 h1:822ZWzujVidm91W3v3DVyVwCXiWFtIB4ipXBlC6kcBs= +github.com/aws/aws-sdk-go-v2/service/securityhub v1.71.9/go.mod h1:GF8lzLRPcdCQ0OYuHVN0UsjFFymEF5zWBvMZz5JtDPw= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2 h1:69JEZSDTQ+UNbTWQJCZMmbpQb5sfc79KUt0O7Pyfjmo= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.2/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5 h1:xlK3Tdc8FO7Tq1k0+hL+otF33glj+dE+qeM5iINiDvU= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.5/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 h1:yX1IbiBfC7SdEgDwIGnRaZyPPDRbQPDOJxl8102PcGk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGnOFMKLbW+NGBHg04= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/awsclient/client.go b/internal/awsclient/client.go new file mode 100644 index 0000000..0388a8c --- /dev/null +++ b/internal/awsclient/client.go @@ -0,0 +1,49 @@ +// Package awsclient wraps live AWS Security Hub and Organizations calls, +// mapping results into the same internal/nist types the demo data path uses. +// This is the only package that imports the AWS SDK — internal/tui and +// internal/report never need to know whether findings came from here or +// from internal/nist.DemoFindings(). +package awsclient + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/organizations" + "github.com/aws/aws-sdk-go-v2/service/securityhub" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +type Client struct { + sh *securityhub.Client + org *organizations.Client + sts *sts.Client +} + +// New builds a Client from the ambient AWS config (CloudShell instance +// role, environment variables, SSO, or ~/.aws/credentials) — no credential +// material is ever hardcoded or read directly by this package. +func New(ctx context.Context) (*Client, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + return &Client{ + sh: securityhub.NewFromConfig(cfg), + org: organizations.NewFromConfig(cfg), + sts: sts.NewFromConfig(cfg), + }, nil +} + +// WhoAmI validates that credentials exist and are usable, so callers can +// fail fast with a clear message before hitting Security Hub/Organizations, +// whose own errors on bad credentials are less obviously actionable. +func (c *Client) WhoAmI(ctx context.Context) (string, error) { + out, err := c.sts.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return "", err + } + return aws.ToString(out.Arn), nil +} diff --git a/internal/awsclient/findings.go b/internal/awsclient/findings.go new file mode 100644 index 0000000..6073b66 --- /dev/null +++ b/internal/awsclient/findings.go @@ -0,0 +1,179 @@ +package awsclient + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/securityhub" + "github.com/aws/aws-sdk-go-v2/service/securityhub/types" + + "cloudcomply/internal/nist" +) + +// nistStandardFilter is the resource portion of the NIST SP 800-53 Rev 5 +// standard's subscription ARN in Security Hub (full ARN would be +// "arn:aws:securityhub:::standards/nist-800-53/v/5.0.0"). Update +// this if AWS revises the standard version. +const nistStandardFilter = "standards/nist-800-53/v/5.0.0" + +// nistControlPrefix is how Security Hub tags a finding's +// Compliance.RelatedRequirements entry when it maps to a NIST 800-53 Rev 5 +// control, e.g. "NIST.800-53.r5 AC-4". +const nistControlPrefix = "NIST.800-53.r5 " + +var statusMap = map[types.ComplianceStatus]nist.FindingStatus{ + types.ComplianceStatusPassed: nist.StatusPassed, + types.ComplianceStatusFailed: nist.StatusFailed, + types.ComplianceStatusWarning: nist.StatusFailed, + // NOT_AVAILABLE and any future values are intentionally absent — callers + // treat a missing map entry as "can't be scored, skip this finding". +} + +var severityMap = map[types.SeverityLabel]nist.Severity{ + types.SeverityLabelCritical: nist.SeverityCritical, + types.SeverityLabelHigh: nist.SeverityHigh, + types.SeverityLabelMedium: nist.SeverityMedium, + types.SeverityLabelLow: nist.SeverityLow, + // INFORMATIONAL and anything unrecognized fall back to SeverityLow. +} + +var severityRank = map[nist.Severity]int{ + nist.SeverityCritical: 3, + nist.SeverityHigh: 2, + nist.SeverityMedium: 1, + nist.SeverityLow: 0, +} + +// controlAggregate collects every raw ASFF finding for one NIST control ID +// into a single row, since a control can be checked by many Security Hub +// security controls across many resources/accounts. +type controlAggregate struct { + controlID string + family string + title string + severity nist.Severity + failed bool + accounts map[string]bool +} + +// GetNISTFindings fetches every active Security Hub finding associated with +// the NIST SP 800-53 standard and aggregates them into one nist.Finding per +// control ID. +func (c *Client) GetNISTFindings(ctx context.Context) ([]nist.Finding, error) { + filters := &types.AwsSecurityFindingFilters{ + ComplianceAssociatedStandardsId: []types.StringFilter{{ + Value: aws.String(nistStandardFilter), + Comparison: types.StringFilterComparisonEquals, + }}, + RecordState: []types.StringFilter{{ + Value: aws.String(string(types.RecordStateActive)), + Comparison: types.StringFilterComparisonEquals, + }}, + } + + agg := map[string]*controlAggregate{} + p := securityhub.NewGetFindingsPaginator(c.sh, &securityhub.GetFindingsInput{Filters: filters}) + for p.HasMorePages() { + page, err := p.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("get findings: %w", err) + } + for _, f := range page.Findings { + mergeFinding(agg, f) + } + } + return finalizeFindings(agg), nil +} + +func mergeFinding(agg map[string]*controlAggregate, f types.AwsSecurityFinding) { + controlID, family, ok := parseNISTControlID(f.Compliance) + if !ok { + return + } + status, scorable := statusMap[f.Compliance.Status] + if !scorable { + return + } + + a, exists := agg[controlID] + if !exists { + a = &controlAggregate{ + controlID: controlID, + family: family, + // Best-effort: Security Hub's per-security-control title, not an + // official NIST 800-53 control title (no such lookup exists via + // this API). TODO(nistmap): replace with a real control-title + // lookup alongside the DoD SRG impact-level crosswalk. + title: aws.ToString(f.Title), + severity: mapSeverity(f.Severity), + accounts: map[string]bool{}, + } + agg[controlID] = a + } + if status == nist.StatusFailed { + a.failed = true + a.accounts[aws.ToString(f.AwsAccountId)] = true + if sev := mapSeverity(f.Severity); severityRank[sev] > severityRank[a.severity] { + a.severity = sev + } + } +} + +func finalizeFindings(agg map[string]*controlAggregate) []nist.Finding { + out := make([]nist.Finding, 0, len(agg)) + for _, a := range agg { + status := nist.StatusPassed + rmfStep := "Monitor" + if a.failed { + status = nist.StatusFailed + rmfStep = "Assess" + } + out = append(out, nist.Finding{ + ControlID: a.controlID, + Title: a.title, + Family: a.family, + Status: status, + Severity: a.severity, + AccountsAffected: len(a.accounts), + RMFStep: rmfStep, + // TODO(nistmap): placeholder until the NIST 800-53 -> DoD SRG + // Impact Level crosswalk is built from the SRG data already + // gathered; AWS has no concept of DoD SRG impact levels. + MinImpactLevel: nist.IL2, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ControlID < out[j].ControlID }) + return out +} + +// parseNISTControlID extracts a NIST 800-53 control ID (e.g. "AC-4") and its +// family prefix (e.g. "AC") from a finding's Compliance.RelatedRequirements. +func parseNISTControlID(c *types.Compliance) (controlID, family string, ok bool) { + if c == nil { + return "", "", false + } + for _, req := range c.RelatedRequirements { + if !strings.HasPrefix(req, nistControlPrefix) { + continue + } + controlID = strings.TrimSpace(strings.TrimPrefix(req, nistControlPrefix)) + if idx := strings.Index(controlID, "-"); idx > 0 { + family = controlID[:idx] + } + return controlID, family, controlID != "" && family != "" + } + return "", "", false +} + +func mapSeverity(s *types.Severity) nist.Severity { + if s == nil { + return nist.SeverityLow + } + if sev, ok := severityMap[s.Label]; ok { + return sev + } + return nist.SeverityLow +} diff --git a/internal/awsclient/findings_test.go b/internal/awsclient/findings_test.go new file mode 100644 index 0000000..eb1d8c6 --- /dev/null +++ b/internal/awsclient/findings_test.go @@ -0,0 +1,130 @@ +package awsclient + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/securityhub/types" + + "cloudcomply/internal/nist" +) + +func finding(account, title string, relatedReqs []string, status types.ComplianceStatus, sev types.SeverityLabel) types.AwsSecurityFinding { + return types.AwsSecurityFinding{ + AwsAccountId: aws.String(account), + Title: aws.String(title), + Compliance: &types.Compliance{ + RelatedRequirements: relatedReqs, + Status: status, + }, + Severity: &types.Severity{Label: sev}, + } +} + +func TestParseNISTControlID(t *testing.T) { + tests := []struct { + name string + reqs []string + wantID string + wantFam string + wantOK bool + }{ + {"matches", []string{"NIST.800-53.r5 AC-4", "PCI.DSS.321 1.2"}, "AC-4", "AC", true}, + {"no match", []string{"PCI.DSS.321 1.2"}, "", "", false}, + {"nil requirements", nil, "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &types.Compliance{RelatedRequirements: tt.reqs} + id, fam, ok := parseNISTControlID(c) + if id != tt.wantID || fam != tt.wantFam || ok != tt.wantOK { + t.Errorf("parseNISTControlID(%v) = %q, %q, %v; want %q, %q, %v", + tt.reqs, id, fam, ok, tt.wantID, tt.wantFam, tt.wantOK) + } + }) + } + if _, _, ok := parseNISTControlID(nil); ok { + t.Error("parseNISTControlID(nil) should return ok=false") + } +} + +func TestMergeAndFinalize_AllPassed(t *testing.T) { + agg := map[string]*controlAggregate{} + mergeFinding(agg, finding("111111111111", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + mergeFinding(agg, finding("222222222222", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + + got := finalizeFindings(agg) + if len(got) != 1 { + t.Fatalf("expected 1 aggregated finding, got %d", len(got)) + } + f := got[0] + if f.Status != nist.StatusPassed { + t.Errorf("Status = %v, want PASSED", f.Status) + } + if f.AccountsAffected != 0 { + t.Errorf("AccountsAffected = %d, want 0 (no failures)", f.AccountsAffected) + } + if f.RMFStep != "Monitor" { + t.Errorf("RMFStep = %q, want Monitor", f.RMFStep) + } +} + +func TestMergeAndFinalize_SomeFailedAcrossAccounts(t *testing.T) { + agg := map[string]*controlAggregate{} + mergeFinding(agg, finding("111111111111", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusFailed, types.SeverityLabelHigh)) + mergeFinding(agg, finding("222222222222", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusFailed, types.SeverityLabelCritical)) + mergeFinding(agg, finding("333333333333", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + // Duplicate account+control failure shouldn't double-count. + mergeFinding(agg, finding("111111111111", "Account Management", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusFailed, types.SeverityLabelHigh)) + + got := finalizeFindings(agg) + if len(got) != 1 { + t.Fatalf("expected 1 aggregated finding, got %d", len(got)) + } + f := got[0] + if f.Status != nist.StatusFailed { + t.Errorf("Status = %v, want FAILED", f.Status) + } + if f.AccountsAffected != 2 { + t.Errorf("AccountsAffected = %d, want 2 distinct failing accounts", f.AccountsAffected) + } + if f.Severity != nist.SeverityCritical { + t.Errorf("Severity = %v, want CRITICAL (highest among failures)", f.Severity) + } + if f.RMFStep != "Assess" { + t.Errorf("RMFStep = %q, want Assess", f.RMFStep) + } + if f.MinImpactLevel != nist.IL2 { + t.Errorf("MinImpactLevel = %v, want IL2 placeholder", f.MinImpactLevel) + } +} + +func TestMergeFinding_SkipsUnscorableAndNonNIST(t *testing.T) { + agg := map[string]*controlAggregate{} + // Not a NIST 800-53 finding at all. + mergeFinding(agg, finding("111111111111", "Other", []string{"PCI.DSS.321 1.2"}, types.ComplianceStatusFailed, types.SeverityLabelHigh)) + // NIST finding but NOT_AVAILABLE — can't be scored. + mergeFinding(agg, finding("111111111111", "Other", []string{"NIST.800-53.r5 SC-7"}, types.ComplianceStatusNotAvailable, types.SeverityLabelHigh)) + + if len(agg) != 0 { + t.Errorf("expected no aggregated controls, got %d", len(agg)) + } +} + +func TestFinalizeFindings_SortedByControlID(t *testing.T) { + agg := map[string]*controlAggregate{} + mergeFinding(agg, finding("1", "t", []string{"NIST.800-53.r5 SI-2"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + mergeFinding(agg, finding("1", "t", []string{"NIST.800-53.r5 AC-2"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + mergeFinding(agg, finding("1", "t", []string{"NIST.800-53.r5 CM-6"}, types.ComplianceStatusPassed, types.SeverityLabelLow)) + + got := finalizeFindings(agg) + want := []string{"AC-2", "CM-6", "SI-2"} + if len(got) != len(want) { + t.Fatalf("got %d findings, want %d", len(got), len(want)) + } + for i, id := range want { + if got[i].ControlID != id { + t.Errorf("got[%d].ControlID = %q, want %q", i, got[i].ControlID, id) + } + } +} diff --git a/internal/awsclient/org.go b/internal/awsclient/org.go new file mode 100644 index 0000000..66d241d --- /dev/null +++ b/internal/awsclient/org.go @@ -0,0 +1,37 @@ +package awsclient + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/organizations" + + "cloudcomply/internal/nist" +) + +// GetOrgSummary fetches the org identifier and total account count. +// AWS Organizations has no "display name" concept for an organization, so +// the org's ID (e.g. "o-abc123xyz") is used in place of demo mode's +// fabricated "Acme Federal Org" name. +func (c *Client) GetOrgSummary(ctx context.Context) (nist.OrgSummary, error) { + desc, err := c.org.DescribeOrganization(ctx, &organizations.DescribeOrganizationInput{}) + if err != nil { + return nist.OrgSummary{}, fmt.Errorf("describe organization: %w", err) + } + + var count int + p := organizations.NewListAccountsPaginator(c.org, &organizations.ListAccountsInput{}) + for p.HasMorePages() { + page, err := p.NextPage(ctx) + if err != nil { + return nist.OrgSummary{}, fmt.Errorf("list accounts: %w", err) + } + count += len(page.Accounts) + } + + return nist.OrgSummary{ + Name: aws.ToString(desc.Organization.Id), + AccountCount: count, + }, nil +} diff --git a/internal/nist/types.go b/internal/nist/types.go index df34dd1..b3b36f7 100644 --- a/internal/nist/types.go +++ b/internal/nist/types.go @@ -62,3 +62,10 @@ type Finding struct { RMFStep string `json:"rmf_step"` // e.g. "Assess", "Monitor", "Implement" MinImpactLevel ImpactLevel `json:"min_impact_level"` // lowest DoD CC SRG Impact Level at which this control is required for a Mission Owner } + +// OrgSummary is a lightweight snapshot of the AWS Organization being assessed, +// sourced from either demo data or a live Organizations API call. +type OrgSummary struct { + Name string `json:"name"` + AccountCount int `json:"account_count"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f32a084..2850ae0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,8 +1,13 @@ package tui import ( + "context" + "time" + + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "cloudcomply/internal/nist" ) @@ -14,10 +19,37 @@ const ( viewFindings ) +// FindingsFetcher sources findings and an org summary — from demo data or a +// live AWS call, the tui package doesn't need to know which. +type FindingsFetcher func(ctx context.Context) ([]nist.Finding, nist.OrgSummary, error) + +const fetchTimeout = 30 * time.Second + +// findingsMsg carries the result of an async FindingsFetcher call back into +// the Bubble Tea update loop. +type findingsMsg struct { + findings []nist.Finding + org nist.OrgSummary + err error +} + +func fetchCmd(fetch FindingsFetcher) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + findings, org, err := fetch(ctx) + return findingsMsg{findings: findings, org: org, err: err} + } +} + type model struct { // shared currentView view quitting bool + fetch FindingsFetcher + loading bool + loadErr error + spinner spinner.Model // dashboard orgName string @@ -38,17 +70,20 @@ type model struct { impactFilter string } -func initialModel() model { - findings := nist.DemoFindings() +func initialModel(fetch FindingsFetcher) model { families := []string{"ALL", "AC", "AU", "CM", "IA", "SC", "SI"} impactLevels := []string{"ALL", string(nist.IL2), string(nist.IL4), string(nist.IL5), string(nist.IL6)} + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + return model{ - currentView: viewDashboard, - orgName: "Acme Federal Org", - accountCount: 47, - complianceScore: nist.ComplianceScore(findings), - selected: 0, + currentView: viewDashboard, + fetch: fetch, + loading: true, + spinner: s, + selected: 0, menuItems: []string{ "Run Full NIST Compliance Scan", "Browse Findings by Control Family", @@ -56,8 +91,7 @@ func initialModel() model { "View Best Practices Report", "Quit", }, - findings: findings, - findingsTable: buildTable(findings, "ALL", "ALL"), + findingsTable: buildTable(nil, "ALL", "ALL"), families: families, familyIdx: 0, familyFilter: "ALL", @@ -67,9 +101,33 @@ func initialModel() model { } } -func (m model) Init() tea.Cmd { return nil } +func (m model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, fetchCmd(m.fetch)) +} func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case findingsMsg: + m.loading = false + if msg.err != nil { + m.loadErr = msg.err + return m, nil + } + m.findings = msg.findings + m.orgName = msg.org.Name + m.accountCount = msg.org.AccountCount + m.complianceScore = nist.ComplianceScore(m.findings) + m.findingsTable = buildTable(m.findings, m.familyFilter, m.impactFilter) + return m, nil + case spinner.TickMsg: + if !m.loading { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + switch m.currentView { case viewDashboard: return m.updateDashboard(msg) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 73a5ad2..655380f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -5,9 +5,10 @@ package tui import tea "github.com/charmbracelet/bubbletea" // Run launches the full-screen interactive dashboard and blocks until the -// user quits. -func Run() error { - p := tea.NewProgram(initialModel(), tea.WithAltScreen()) +// user quits. fetch supplies the findings and org summary — from demo data +// or a live AWS call — asynchronously on startup. +func Run(fetch FindingsFetcher) error { + p := tea.NewProgram(initialModel(fetch), tea.WithAltScreen()) _, err := p.Run() return err } diff --git a/internal/tui/view.go b/internal/tui/view.go index 530e06c..2678e2b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -25,6 +25,23 @@ func (m model) View() string { func (m model) dashboardView() string { header := titleStyle.Render("cloudcomply — AWS Org Compliance Dashboard") + if m.loading { + return fmt.Sprintf("%s\n\n %s Loading findings...\n", header, m.spinner.View()) + } + + if m.loadErr != nil { + errBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(1, 2). + Margin(1, 2). + Width(70). + Foreground(lipgloss.Color("196")). + Render(fmt.Sprintf("Failed to load Security Hub findings:\n%v\n\nRetry, or run with --demo for offline demo data.", m.loadErr)) + help := helpStyle.Render("q: quit") + return fmt.Sprintf("%s\n\n%s\n\n%s", header, errBox, help) + } + scoreColor := "42" // green if m.complianceScore < 70 { scoreColor = "196" // red From 0c237d650c3b459b9784ae9fb19b8b094cca8c91 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 4 Jul 2026 23:23:40 -0400 Subject: [PATCH 2/3] Enhance AWS integration: support single-account mode and update org summary handling - Added IsOrgMode field to OrgSummary to indicate if the current credentials are part of an AWS Organization. - Updated GetOrgSummary to fall back to single-account summary when not in an organization. - Modified FindingsFetcher to reflect IsOrgMode in demo mode. - Improved TUI to display appropriate labels based on organization status. - Created TESTING.md for guidelines on connecting to AWS and smoke-testing builds. --- CLAUDE.md | 3 + TESTING.md | 219 ++++++++++++++++++++++++++++++++++++++ cmd/fetch.go | 2 +- internal/awsclient/org.go | 29 +++++ internal/nist/types.go | 5 + internal/tui/model.go | 2 + internal/tui/view.go | 12 ++- 7 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 TESTING.md diff --git a/CLAUDE.md b/CLAUDE.md index f46a14a..2c7688a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ Project context for Claude Code. Read this before making any changes. A lightweight Go CLI/TUI tool for assessing AWS Organizations against NIST SP 800-53 / RMF controls. The primary audience is Cloud Security Engineers and GRC practitioners working toward ATO packages or continuous compliance monitoring. Key design constraints: + - **Zero installation in AWS CloudShell** — ships as a single static Linux binary - **Low profile** — no agents, no sidecars, no persistent install - **Termux-compatible** — must work well in a mobile terminal with external keyboard @@ -28,6 +29,7 @@ Key design constraints: ### Hybrid TUI / CLI model The tool has two modes: + - **Interactive (default):** `cloudcomply` with no args launches the Bubble Tea dashboard - **Headless (planned):** `cloudcomply report nist --format json` for scripting/CI @@ -38,6 +40,7 @@ Do not collapse these into one. Keep TUI and non-interactive paths separate. Views are defined as `type view int` constants. The main `model` struct holds a `currentView` field. Each view has its own `update*` and `*View` methods. This pattern should be followed when adding new views — do not embed all logic in a single Update/View function. Current views: + - `viewDashboard` — org summary + compliance score + main menu - `viewFindings` — NIST 800-53 findings table with control family and DoD SRG Impact Level filter tabs diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..c6eb45c --- /dev/null +++ b/TESTING.md @@ -0,0 +1,219 @@ +# Testing cloudcomply Against Real AWS + +A repeatable plan for connecting cloudcomply to a real AWS environment and +smoke-testing each build before you ship it. Pairs with [README.md](README.md) +(install/build) and [CLI.md](CLI.md) (command reference). + +Since [f355469](../../commit/f355469), live mode is the **default** — +`cloudcomply` and `cloudcomply report nist` both call Security Hub and +Organizations unless you pass `--demo`. This doc covers the live path. + +--- + +## 1. What "connecting" actually means + +There's no config file or connection string. `internal/awsclient.New()` calls +`config.LoadDefaultConfig(ctx)`, so cloudcomply picks up whatever credentials +are ambient in the shell it's run from — same resolution order as the AWS +CLI: + +1. CloudShell's instance role (no setup — this is the primary target + environment) +2. `AWS_PROFILE` / `AWS_ACCESS_KEY_ID` env vars +3. `~/.aws/credentials` / `~/.aws/config` (SSO profiles, named profiles) + +Before testing, confirm the identity cloudcomply will see: + +```bash +aws sts get-caller-identity +``` + +If that fails, cloudcomply will fail the same way — fix it at the AWS CLI +level first. + +--- + +## 2. One-time test environment setup + +**A standalone AWS account (no Organization) now works** — cloudcomply +detects `AWSOrganizationsNotInUseException` from `DescribeOrganization` and +falls back to single-account mode automatically, scoring just that one +account instead of failing. This matters if org-wide access is stuck behind +approval: you can test and demo against whatever single account you already +have Security Hub access to, then get org-wide results for free the moment +broader access comes through — no flag, no rebuild. + +That means there are two setups worth testing, not one: + +- **Single-account mode**: any one AWS account with Security Hub enabled. + Fastest to stand up, and the one to use while org access is pending. +- **Org mode**: a real AWS Organization, for when you want to exercise + multi-account aggregation and pagination. + +**Recommended minimal setup (org mode):** + +1. An AWS Organization (management account + ideally 1-2 member accounts, so + `AccountsAffected` and pagination have something real to show). +2. **Security Hub enabled** in whichever account you'll run cloudcomply + from — ideally the delegated Security Hub admin account, since that's + the one with org-wide visibility. Security Hub has a 30-day free trial, + which is plenty for build-to-build testing. +3. **The NIST SP 800-53 Rev 5 standard subscribed** in Security Hub + (Security Hub console → Standards → enable "NIST SP 800-53 Rev. 5"). This + is the exact standard `internal/awsclient/findings.go` filters on + (`standards/nist-800-53/v/5.0.0`) — without it, every fetch legitimately + returns zero findings. +4. If you want multi-account results, turn on Security Hub's central + cross-account aggregation from the delegated admin account. Without it, + `GetNISTFindings` only sees findings local to whatever account/region the + credentials point at. +5. Apply the IAM policy from [README.md § AWS Permissions](README.md#aws-permissions) + to the role/user you'll test with. + +You don't need to seed fake findings — misconfigured resources in a sandbox +org will generate real ones within a few hours of enabling Security Hub. + +--- + +## 3. Build + +```bash +go build -ldflags="-s -w" -o cloudcomply . +``` + +Or cross-compile the CloudShell target if that's what you're testing against: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o cloudcomply-linux-amd64 . +``` + +--- + +## 4. Release smoke test checklist + +Run this top-to-bottom before shipping a build. Sections A-B need no AWS +access; C onward need the test account(s)/org from step 2. Section D only +needs a single account — start there if org access isn't available yet. + +### A. Unit tests (no AWS needed) + +- [ ] `go test ./...` passes — covers control-ID parsing, aggregation, + severity/status mapping in `internal/awsclient/findings_test.go` + +### B. Demo mode regression (no AWS needed) + +Confirms the `--demo` fallback still works, since it's what users reach for +when live mode fails. + +- [ ] `./cloudcomply --demo` — dashboard loads instantly, ~41% score, "Acme + Federal Org" / 47 accounts +- [ ] `./cloudcomply report nist --demo --format table` and `--format json` + both match the schema documented in [CLI.md](CLI.md) + +### C. Credential/permission failure paths + +These should all fail with a clear, actionable stderr message and a non-zero +exit — never a panic or a raw AWS SDK stack trace. + +- [ ] No credentials at all (unset `AWS_PROFILE`, run outside CloudShell): + error contains `no valid AWS credentials found` and `try --demo` +- [ ] Expired/invalid credentials: same message +- [ ] Role missing `securityhub:GetFindings`: error is prefixed + `fetching Security Hub findings:` +- [ ] Role missing `organizations:ListAccounts`/`DescribeOrganization`: + error is prefixed `fetching organization summary:` + +### D. Happy path — single-account mode + +Run against credentials for an account that is **not** part of any AWS +Organization (or use an org member account without org-level permissions). + +- [ ] `./cloudcomply` — dashboard shows `Account:` (not `Organization:`), + the numeric AWS account ID as the name, `Accounts Scanned: 1`, and the + orange "Single-account mode" notice below the summary box +- [ ] `cloudcomply report nist --format table` / `--format json` both + return findings scoped to that one account — no error, no org call + surfaced +- [ ] Confirm no `describe organization` / `fetching organization summary` + error appears anywhere — the fallback should be silent to the user + +### E. Happy path — org mode + +- [ ] `./cloudcomply` — spinner shows while loading, then dashboard + populates with `Organization:` label, the real org ID (e.g. + `o-abc123xyz`, not a friendly name — Organizations has no + display-name concept), and real account count under `Accounts in Org:` +- [ ] Findings browser (`Enter` on "Browse Findings by Control Family`): + family filter (`h`/`l`) and impact-level filter (`[`/`]`) both work + against real data volumes +- [ ] `cloudcomply report nist --format table` and `--format json` both + return non-empty, schema-correct output +- [ ] `cloudcomply report nist --format json | jq '[.[] | select(.status=="FAILED")]'` + round-trips cleanly +- [ ] Spot-check one control's `AccountsAffected` count against the Security + Hub console for the same control ID — confirms the aggregation logic + in `findings.go` isn't over/under-counting + +### F. Edge cases specific to the current implementation + +- [ ] Security Hub enabled but NIST standard **not** subscribed: expect zero + findings and a 0% score (not a crash) — `ComplianceScore` returns 0 + on an empty slice, but worth confirming the TUI renders that + gracefully rather than showing a stale/blank screen +- [ ] Single-account **org** (management account, no members): `AccountCount` + shows 1 with `IsOrgMode: true` — distinct from single-account *mode* + (`IsOrgMode: false`) in section D; both should render sensibly +- [ ] Enough findings to force pagination (Security Hub pages at 100): both + `GetFindingsPaginator` and `ListAccountsPaginator` should page fully — + compare total finding count against the Security Hub console +- [ ] Simulate a slow/unreachable AWS call (e.g. block the endpoint or use + a throttled network) and confirm the 30s `fetchTimeout` produces a + clean timeout error rather than a hang, in both the TUI and + `report nist` +- [ ] Credentials with `sts:GetCallerIdentity` denied *and* not in an org: + confirm the single-account fallback's own error + (`get caller identity:`) surfaces cleanly rather than masking the + original `DescribeOrganization` failure + +### G. Known rough edges (not bugs to chase, just don't be surprised) + +- `MinImpactLevel` is hardcoded to `IL2` for **every** live finding + (`findings.go` TODO — the real NIST→DoD SRG crosswalk isn't built yet). + Impact-level filtering in the TUI will look correct structurally but isn't + meaningful against live data until that lands. +- The dashboard's "Run Full NIST Compliance Scan" menu item always shows a + "(demo mode)" message, even in live mode (`internal/tui/model.go`, + `handleMenuSelection` case 0) — cosmetic only, don't read into it. +- [CLI.md](CLI.md) still says `report nist` "currently always reads from + demo dataset" — that's stale as of the live-integration commit; update it + alongside whichever release you're testing if you touch that doc. + +--- + +## 5. Keeping tests repeatable across builds + +A live AWS org's actual security posture drifts on its own (someone opens a +port, a finding gets remediated), which makes build-to-build diffs noisy. To +tell "the tool changed" apart from "the environment changed": + +- Keep one sandbox account with deliberately static, unremediated + misconfigurations as a fixed baseline, separate from any account real + engineers are actively fixing things in. +- Prefer diffing `report nist --format json` output between builds + (`jq`-diffable) over eyeballing the TUI. +- Re-run section A/B (unit tests + demo mode) on every build regardless — + they're free and catch regressions unrelated to AWS state. + +--- + +## Quick reference + +```bash +aws sts get-caller-identity # confirm identity first +go build -ldflags="-s -w" -o cloudcomply . +./cloudcomply --demo # baseline, no AWS +./cloudcomply # live TUI +./cloudcomply report nist --format table # live, human-readable +./cloudcomply report nist --format json | jq '.' # live, machine-readable +go test ./... # unit tests +``` diff --git a/cmd/fetch.go b/cmd/fetch.go index 9919da7..aacb483 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -20,7 +20,7 @@ const fetchTimeout = 30 * time.Second func newFetcher(demo bool) tui.FindingsFetcher { if demo { return func(ctx context.Context) ([]nist.Finding, nist.OrgSummary, error) { - return nist.DemoFindings(), nist.OrgSummary{Name: "Acme Federal Org", AccountCount: 47}, nil + return nist.DemoFindings(), nist.OrgSummary{Name: "Acme Federal Org", AccountCount: 47, IsOrgMode: true}, nil } } return func(ctx context.Context) ([]nist.Finding, nist.OrgSummary, error) { diff --git a/internal/awsclient/org.go b/internal/awsclient/org.go index 66d241d..9c44158 100644 --- a/internal/awsclient/org.go +++ b/internal/awsclient/org.go @@ -2,10 +2,13 @@ package awsclient import ( "context" + "errors" "fmt" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/organizations" + orgtypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" + "github.com/aws/aws-sdk-go-v2/service/sts" "cloudcomply/internal/nist" ) @@ -14,9 +17,20 @@ import ( // AWS Organizations has no "display name" concept for an organization, so // the org's ID (e.g. "o-abc123xyz") is used in place of demo mode's // fabricated "Acme Federal Org" name. +// +// Credentials that aren't part of an Organization at all (a standalone +// account, or org access still pending approval) fall back to +// singleAccountSummary rather than failing the whole fetch — org-wide +// access is frequently gated behind separate bureaucracy from Security Hub +// access, and cloudcomply should still be useful against the one account +// you can already reach. func (c *Client) GetOrgSummary(ctx context.Context) (nist.OrgSummary, error) { desc, err := c.org.DescribeOrganization(ctx, &organizations.DescribeOrganizationInput{}) if err != nil { + var notInOrg *orgtypes.AWSOrganizationsNotInUseException + if errors.As(err, ¬InOrg) { + return c.singleAccountSummary(ctx) + } return nist.OrgSummary{}, fmt.Errorf("describe organization: %w", err) } @@ -33,5 +47,20 @@ func (c *Client) GetOrgSummary(ctx context.Context) (nist.OrgSummary, error) { return nist.OrgSummary{ Name: aws.ToString(desc.Organization.Id), AccountCount: count, + IsOrgMode: true, + }, nil +} + +// singleAccountSummary scores exactly one account — the one the current +// credentials belong to — using its account ID in place of an org ID. +func (c *Client) singleAccountSummary(ctx context.Context) (nist.OrgSummary, error) { + out, err := c.sts.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nist.OrgSummary{}, fmt.Errorf("get caller identity: %w", err) + } + return nist.OrgSummary{ + Name: aws.ToString(out.Account), + AccountCount: 1, + IsOrgMode: false, }, nil } diff --git a/internal/nist/types.go b/internal/nist/types.go index b3b36f7..a4b93dc 100644 --- a/internal/nist/types.go +++ b/internal/nist/types.go @@ -68,4 +68,9 @@ type Finding struct { type OrgSummary struct { Name string `json:"name"` AccountCount int `json:"account_count"` + // IsOrgMode is false when the credentials in use aren't part of an AWS + // Organization at all (AWSOrganizationsNotInUseException) — cloudcomply + // falls back to scoring the single account rather than failing outright, + // since org-wide access is often gated behind separate approval. + IsOrgMode bool `json:"is_org_mode"` } diff --git a/internal/tui/model.go b/internal/tui/model.go index 2850ae0..60be76f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -54,6 +54,7 @@ type model struct { // dashboard orgName string accountCount int + isOrgMode bool complianceScore int selected int menuItems []string @@ -116,6 +117,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.findings = msg.findings m.orgName = msg.org.Name m.accountCount = msg.org.AccountCount + m.isOrgMode = msg.org.IsOrgMode m.complianceScore = nist.ComplianceScore(m.findings) m.findingsTable = buildTable(m.findings, m.familyFilter, m.impactFilter) return m, nil diff --git a/internal/tui/view.go b/internal/tui/view.go index 2678e2b..1f0bb18 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -61,14 +61,22 @@ func (m model) dashboardView() string { il5Str := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(il5Color)). Render(fmt.Sprintf("%d%% ready", il5Score)) + orgLabel, accountsLabel := "Organization:", "Accounts in Org:" + if !m.isOrgMode { + orgLabel, accountsLabel = "Account:", "Accounts Scanned:" + } summary := fmt.Sprintf( "%-29s%s\n%-29s%d\n%-29s%s\n%-29s%s", - "Organization:", m.orgName, - "Accounts in Org:", m.accountCount, + orgLabel, m.orgName, + accountsLabel, m.accountCount, "NIST 800-53:", scoreStr, "DoD SRG (IL5 Mission Owner):", il5Str, ) summaryBox := boxStyle.Render(summary) + if !m.isOrgMode { + summaryBox += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("214")). + Render("ℹ Single-account mode — no AWS Organization detected. Org-wide scoring will apply once org access is available.") + } menu := "Main Menu:\n\n" for i, item := range m.menuItems { From 8764ce2d2d03cd6e0c1518137ad59f8b05de430d Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 5 Jul 2026 16:20:35 -0400 Subject: [PATCH 3/3] Update Go version to 1.25 in CI workflow and simplify error handling in fetcher --- .github/workflows/ci.yml | 8 ++++---- cmd/fetch.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d75df2c..37ab65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" cache: true - uses: golangci/golangci-lint-action@v6 with: @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Run gosec uses: securego/gosec@master @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Download modules run: go mod download @@ -96,7 +96,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Build ${{ matrix.goos }}/${{ matrix.goarch }} env: diff --git a/cmd/fetch.go b/cmd/fetch.go index aacb483..fe802c6 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -28,7 +28,7 @@ func newFetcher(demo bool) tui.FindingsFetcher { if err != nil { return nil, nist.OrgSummary{}, fmt.Errorf("connecting to AWS: %w (try --demo)", err) } - if _, err := client.WhoAmI(ctx); err != nil { + if _, err = client.WhoAmI(ctx); err != nil { return nil, nist.OrgSummary{}, fmt.Errorf("no valid AWS credentials found: %w (try --demo)", err) } findings, err := client.GetNISTFindings(ctx)