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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ each item completely; `task build` + `go test ./...` green before marking `[x]`.
- [x] **Honest AWS-only + parity matrix**: an audit workflow found the headline was already AWS-honest; the real overclaims were a handful of command help strings using generic "cloud"/"across providers" (`inventory`, `quota`, `secrets`/`secrets scan` which falsely listed GCP "Cloud Functions" + Azure "App Service" as scan targets, plus `cost`/`orphans`/`drift`). All rewritten to name AWS. README gains a `## Cloud support` section + a command×cloud parity matrix (✅ implemented / ⬡ seam-ready / — n/a): AWS full across all domains, GCP/Azure seam-ready (capability interfaces exist, no provider), k8s for RBAC; offline + `mcp` commands noted as cloud-agnostic. The pluggable-seam framing (the intentional design) is kept; only present-tense multi-cloud claims were removed. Verified by an adversarial review workflow (no overclaim survived, every matrix row accurate; fixed its 4 LOW nits — inventory/drift H3 headings, the cost/orphans/drift Shorts, the Platform footnote which had said "RBAC" instead of IRSA + tenant cluster objects, and `mcp` missing from the matrix note).
- **Output renderer cleanup** (split into ordered sub-items):
- [x] **9a — Severity on domain structs**: `cloud.QuotaUsage` gains a `Severity` field (`json:"severity"`), set at construction in the AWS provider's `ListQuotas` (derived from utilization). All readers — `output.WriteQuotas`, `quota.Summarize`, `compare.normalizeQuotas`, and the `report` HTML generator — now read it via a `QuotaUsage.EffectiveSeverity()` accessor that falls back to computing from `Utilization` when unset (back-compat for reports saved before the field + hand-built test data). QuotaUsage was the one struct recomputing severity per-reader; the other findingless structs (OrphanResource/CostDiff/InventoryResource) carry no severity by nature. Mock-tested; verified all read sites converted by grep.
- [ ] **9b — split the monolithic renderer files**: move the per-domain renderers out of the 715-line `output/table.go` (and `json.go`) into per-domain files so adding a domain doesn't edit a shared monolith. NOTE: the original "runtime `FindingRenderer` registry" was reframed after empirical review — commands dispatch type-specifically (compile-time-safe), renderers have heterogeneous signatures (IAM principal counts, audit `*Report`, compare `CompareResult`), and the one generic consumer (`report`) already routes through the centralized `compare.NormalizeReport` switch, so a uniform `any`-typed registry would trade type safety for indirection with no caller that needs it. The achievable win is the per-domain file split (pure code-org, behavior-preserving).
- [x] **9b — split the monolithic renderer files**: the 715-line `output/table.go` + 252-line `output/json.go` are split into per-domain files — `output/<domain>.go` (iam, storage, orphans, cost, network, certs, tags, drift, compliance, lambda, k8s, secrets, audit, inventory, quota, compare, platform) each owns that domain's table renderer + JSON report struct + writer; shared infra lives in `style.go` (lipgloss styles, `colorSeverity`, `formatTags`, `truncate`) and `jsoncore.go` (`writeJSON`). Adding a domain now adds one file instead of editing two shared monoliths. (`sarif.go` left cohesive as the SARIF concern.) Pure move — verified behavior-preserving by line-conservation (869 code lines in == 869 out, sorted-identical) plus the unchanged `output` test suite (table/json/sarif) passing. The original "runtime `FindingRenderer` registry" was reframed after empirical review: commands dispatch type-specifically (compile-time-safe), renderers have heterogeneous signatures (IAM principal counts, audit `*Report`, compare `CompareResult`), and the one generic consumer (`report`) already routes through `compare.NormalizeReport`, so a uniform `any`-typed registry would trade type safety for indirection with no caller that needs it.
- [ ] **Integration tests + CI floors**: cmd→scanner→provider→output tests with fixtures; per-package coverage floors + `golangci-lint` in `ci.yml` (folds in section 6).

## how to run a single improvement pass (headless)
Expand Down
67 changes: 67 additions & 0 deletions internal/output/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package output

import (
"fmt"
"io"

"github.com/nanohype/cloudgov/internal/audit"
)

// AuditReport renders a unified audit report with sections per domain.
func AuditReport(w io.Writer, report *audit.Report) {
fmt.Fprintf(w, "%s completed in %s\n", headerStyle.Render("[audit]"), dimStyle.Render(report.Duration))

if len(report.IAM) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── IAM"), len(report.IAM))
IAMFindings(w, report.IAM, 0)
}
if len(report.Storage) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── STORAGE"), len(report.Storage))
BucketFindings(w, report.Storage)
}
if len(report.Network) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── NETWORK"), len(report.Network))
NetworkFindings(w, report.Network)
}
if len(report.Orphans) > 0 {
fmt.Fprintf(w, "\n%s (%d resources)\n", headerStyle.Render("─── ORPHANS"), len(report.Orphans))
OrphanResources(w, report.Orphans)
}
if len(report.Certs) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── CERTS"), len(report.Certs))
CertFindings(w, report.Certs)
}
if len(report.Tags) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── TAGS"), len(report.Tags))
TagFindings(w, report.Tags)
}
if len(report.Secrets) > 0 {
fmt.Fprintf(w, "\n%s (%d findings)\n", headerStyle.Render("─── SECRETS"), len(report.Secrets))
SecretFindings(w, report.Secrets)
}

// Summary
s := report.Summary
fmt.Fprintf(w, "\n%s\n", headerStyle.Render("─── SUMMARY"))
fmt.Fprintf(w, " Total findings: %d across %d domains\n", s.TotalFindings, s.DomainsRun)
if s.BySeverity["CRITICAL"] > 0 {
fmt.Fprintf(w, " %s critical\n", critStyle.Render(fmt.Sprintf("%d", s.BySeverity["CRITICAL"])))
}
if s.BySeverity["HIGH"] > 0 {
fmt.Fprintf(w, " %s high\n", highStyle.Render(fmt.Sprintf("%d", s.BySeverity["HIGH"])))
}
if s.BySeverity["MEDIUM"] > 0 {
fmt.Fprintf(w, " %s medium\n", medStyle.Render(fmt.Sprintf("%d", s.BySeverity["MEDIUM"])))
}
if s.OrphanCost > 0 {
fmt.Fprintf(w, " Orphan cost: %s/month\n", highStyle.Render(fmt.Sprintf("$%.2f", s.OrphanCost)))
}
if s.DomainsSkipped > 0 {
fmt.Fprintf(w, " %s domains skipped\n", dimStyle.Render(fmt.Sprintf("%d", s.DomainsSkipped)))
}
}

// WriteAudit marshals a full audit report as JSON to w.
func WriteAudit(w io.Writer, report *audit.Report) error {
return writeJSON(w, report)
}
54 changes: 54 additions & 0 deletions internal/output/certs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package output

import (
"fmt"
"io"
"text/tabwriter"

"github.com/nanohype/cloudgov/internal/cloud"
)

// CertFindings renders a certificate expiry findings table.
func CertFindings(w io.Writer, findings []cloud.CertFinding) {
if len(findings) == 0 {
fmt.Fprintln(w, dimStyle.Render("no findings"))
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
headerStyle.Render("SEVERITY"),
headerStyle.Render("STATUS"),
headerStyle.Render("PROVIDER"),
headerStyle.Render("DOMAIN"),
headerStyle.Render("REGION"),
headerStyle.Render("EXPIRES"),
headerStyle.Render("DAYS"),
)
for _, f := range findings {
sev := colorSeverity(f.Severity).Render(string(f.Severity))
expires := f.ExpiresAt.Format("2006-01-02")
days := fmt.Sprintf("%d", f.DaysLeft)
if f.DaysLeft < 0 {
days = critStyle.Render(days)
expires = critStyle.Render(expires)
} else if f.DaysLeft < 7 {
days = critStyle.Render(days)
} else if f.DaysLeft < 30 {
days = highStyle.Render(days)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
sev, string(f.Status), f.Provider, f.Domain, f.Region, expires, days,
)
}
tw.Flush()
}

type certsReport struct {
Findings []cloud.CertFinding `json:"findings"`
Total int `json:"total"`
}

// WriteCerts marshals certificate findings as JSON to w.
func WriteCerts(w io.Writer, findings []cloud.CertFinding) error {
return writeJSON(w, certsReport{Findings: findings, Total: len(findings)})
}
120 changes: 120 additions & 0 deletions internal/output/compare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package output

import (
"fmt"
"io"
"text/tabwriter"
)

// CompareTable renders a diff comparison table.
func CompareTable(w io.Writer, result CompareResult) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
headerStyle.Render("STATUS"),
headerStyle.Render("DOMAIN"),
headerStyle.Render("TYPE"),
headerStyle.Render("RESOURCE"),
headerStyle.Render("DETAIL"),
)

for _, f := range result.New {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
critStyle.Render("+NEW"),
f.Domain, f.Type, truncate(f.ResourceID, 40), truncate(f.Detail, 60),
)
}
for _, f := range result.Resolved {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
greenStyle.Render("-RESOLVED"),
f.Domain, f.Type, truncate(f.ResourceID, 40), truncate(f.Detail, 60),
)
}
for _, f := range result.Unchanged {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
dimStyle.Render("=UNCHANGED"),
f.Domain, f.Type, truncate(f.ResourceID, 40), truncate(f.Detail, 60),
)
}
tw.Flush()

fmt.Fprintf(w, "\n%s new, %s resolved, %s unchanged\n",
critStyle.Render(fmt.Sprintf("%d", len(result.New))),
greenStyle.Render(fmt.Sprintf("%d", len(result.Resolved))),
dimStyle.Render(fmt.Sprintf("%d", len(result.Unchanged))),
)
}

// CompareResult mirrors compare.DiffResult to avoid import cycle.
type CompareResult struct {
New []CompareFindingType
Resolved []CompareFindingType
Unchanged []CompareFindingType
}

// CompareFindingType mirrors compare.NormalizedFinding to avoid import cycle.
type CompareFindingType struct {
Domain string
Provider string
Type string
ResourceID string
Detail string
Severity string
}

// NewCompareResult creates a CompareResult from raw data.
func NewCompareResult(newF, resolved, unchanged []CompareFindingType) CompareResult {
return CompareResult{New: newF, Resolved: resolved, Unchanged: unchanged}
}

// NewCompareFinding creates a CompareFindingType.
func NewCompareFinding(domain, provider, typ, resourceID, detail, severity string) CompareFindingType {
return CompareFindingType{
Domain: domain, Provider: provider, Type: typ,
ResourceID: resourceID, Detail: detail, Severity: severity,
}
}

type compareReport struct {
New []CompareFindingJSONType `json:"new"`
Resolved []CompareFindingJSONType `json:"resolved"`
Unchanged []CompareFindingJSONType `json:"unchanged"`
Summary compareSummary `json:"summary"`
}

// CompareFindingJSONType is a finding for JSON comparison output.
type CompareFindingJSONType struct {
Domain string `json:"domain"`
Provider string `json:"provider"`
Type string `json:"type"`
ResourceID string `json:"resource_id"`
Detail string `json:"detail"`
Severity string `json:"severity"`
}

type compareSummary struct {
New int `json:"new"`
Resolved int `json:"resolved"`
Unchanged int `json:"unchanged"`
}

// WriteCompare marshals comparison results as JSON to w.
func WriteCompare(w io.Writer, newF, resolved, unchanged []CompareFindingJSONType) error {
return writeJSON(w, compareReport{
New: newF,
Resolved: resolved,
Unchanged: unchanged,
Summary: compareSummary{
New: len(newF),
Resolved: len(resolved),
Unchanged: len(unchanged),
},
})
}

// CompareFindingJSON creates a CompareFindingJSONType.
func CompareFindingJSON(domain, provider, typ, resourceID, detail, severity string) CompareFindingJSONType {
return CompareFindingJSONType{
Domain: domain, Provider: provider, Type: typ,
ResourceID: resourceID, Detail: detail, Severity: severity,
}
}
54 changes: 54 additions & 0 deletions internal/output/compliance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package output

import (
"fmt"
"io"
"text/tabwriter"

"github.com/nanohype/cloudgov/internal/compliance"
)

// ComplianceReport renders a compliance evaluation table.
func ComplianceReport(w io.Writer, report compliance.ComplianceReport) {
if len(report.Results) == 0 {
fmt.Fprintln(w, dimStyle.Render("no controls evaluated"))
return
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
headerStyle.Render("STATUS"),
headerStyle.Render("ID"),
headerStyle.Render("SEVERITY"),
headerStyle.Render("TITLE"),
headerStyle.Render("DETAIL"),
)
for _, r := range report.Results {
var statusStyled string
switch r.Status {
case compliance.StatusPass:
statusStyled = greenStyle.Render("PASS")
case compliance.StatusFail:
statusStyled = critStyle.Render("FAIL")
default:
statusStyled = dimStyle.Render("N/A")
}
sev := colorSeverity(r.Control.Severity).Render(string(r.Control.Severity))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
statusStyled, r.Control.ID, sev, truncate(r.Control.Title, 55), truncate(r.Detail, 50),
)
}
tw.Flush()

summary := fmt.Sprintf("\n%s passed, %s failed, %s not evaluated (%d total)",
greenStyle.Render(fmt.Sprintf("%d", report.Summary.Passed)),
critStyle.Render(fmt.Sprintf("%d", report.Summary.Failed)),
dimStyle.Render(fmt.Sprintf("%d", report.Summary.NotEvaluated)),
report.Summary.Total,
)
fmt.Fprintln(w, summary)
}

// WriteCompliance marshals a compliance report as JSON to w.
func WriteCompliance(w io.Writer, report compliance.ComplianceReport) error {
return writeJSON(w, report)
}
57 changes: 57 additions & 0 deletions internal/output/cost.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package output

import (
"fmt"
"io"
"text/tabwriter"

"github.com/nanohype/cloudgov/internal/cloud"
)

// CostDiffs renders cost diff tables for each provider.
func CostDiffs(w io.Writer, diffs []cloud.CostDiff) {
for _, d := range diffs {
fmt.Fprintf(w, "\n%s %s → %s vs %s → %s\n",
headerStyle.Render("["+d.Provider+"]"),
d.BeforeStart.Format("2006-01-02"), d.BeforeEnd.Format("2006-01-02"),
d.AfterStart.Format("2006-01-02"), d.AfterEnd.Format("2006-01-02"),
)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
headerStyle.Render("SERVICE"),
headerStyle.Render("BEFORE"),
headerStyle.Render("AFTER"),
headerStyle.Render("DELTA"),
headerStyle.Render("CHANGE%"),
)
for _, e := range d.Entries {
deltaStr := fmt.Sprintf("%+.2f", e.Delta)
pctStr := fmt.Sprintf("%+.1f%%", e.PctChange)
var deltaStyled, pctStyled string
if e.PctChange > 10 {
deltaStyled = critStyle.Render(deltaStr)
pctStyled = critStyle.Render(pctStr)
} else if e.Delta < 0 {
deltaStyled = greenStyle.Render(deltaStr)
pctStyled = greenStyle.Render(pctStr)
} else {
deltaStyled = deltaStr
pctStyled = pctStr
}
fmt.Fprintf(tw, "%s\t$%.2f\t$%.2f\t%s\t%s\n",
e.Service, e.Before, e.After, deltaStyled, pctStyled,
)
}
tw.Flush()
fmt.Fprintf(w, "\nTotal: $%.2f → $%.2f (%+.2f)\n", d.TotalBefore, d.TotalAfter, d.TotalDelta)
}
}

type costReport struct {
Diffs []cloud.CostDiff `json:"diffs"`
}

// WriteCost marshals cost diffs as JSON to w.
func WriteCost(w io.Writer, diffs []cloud.CostDiff) error {
return writeJSON(w, costReport{Diffs: diffs})
}
Loading
Loading