Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -103,6 +106,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)

Expand Down
219 changes: 219 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -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
```
44 changes: 44 additions & 0 deletions cmd/fetch.go
Original file line number Diff line number Diff line change
@@ -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, IsOrgMode: true}, 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
}
}
11 changes: 9 additions & 2 deletions cmd/report.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package cmd

import (
"context"

"github.com/spf13/cobra"

"cloudcomply/internal/nist"
"cloudcomply/internal/report"
)

Expand All @@ -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)
},
}

Expand Down
11 changes: 10 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading