From 21997ddecb876dcf71b695188884b2a3ad6a12bd Mon Sep 17 00:00:00 2001 From: stxkxs Date: Thu, 4 Jun 2026 19:50:57 -0700 Subject: [PATCH] Split the output renderers into per-domain files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit output/table.go (715 lines) and output/json.go (252 lines) were two shared monoliths that every domain's rendering had to be threaded through. They're now per-domain files: output/.go owns that domain's table renderer, JSON report struct, and JSON writer (iam, storage, orphans, cost, network, certs, tags, drift, compliance, lambda, k8s, secrets, audit, inventory, quota, compare, platform). Shared infrastructure moved to: - style.go — lipgloss styles, colorSeverity, formatTags, truncate - jsoncore.go — writeJSON Adding a domain now adds one file instead of editing two shared files. sarif.go is left intact as the cohesive SARIF concern. This is a pure code reorganization with no behavior change, verified two ways: line-conservation (869 non-import code lines in the old files, 869 in the new files, sorted-identical) and the unchanged output test suite (table/json/sarif round-trips) passing. Sub-item 9b of the output-renderer cleanup. The original "runtime FindingRenderer registry" was reframed after empirical review — commands dispatch type-specifically, the renderers have heterogeneous signatures, and the one generic consumer already routes through compare.NormalizeReport, so a uniform registry would cost type safety with no caller that needs it. --- CLAUDE.md | 2 +- internal/output/audit.go | 67 ++++ internal/output/certs.go | 54 +++ internal/output/compare.go | 120 ++++++ internal/output/compliance.go | 54 +++ internal/output/cost.go | 57 +++ internal/output/drift.go | 63 +++ internal/output/iam.go | 71 ++++ internal/output/inventory.go | 63 +++ internal/output/json.go | 252 ------------ internal/output/jsoncore.go | 12 + internal/output/k8s.go | 59 +++ internal/output/lambda.go | 59 +++ internal/output/network.go | 45 +++ internal/output/orphans.go | 59 +++ internal/output/platform.go | 42 ++ internal/output/quota.go | 73 ++++ internal/output/secrets.go | 62 +++ internal/output/storage.go | 45 +++ internal/output/style.go | 55 +++ internal/output/table.go | 715 ---------------------------------- internal/output/tags.go | 50 +++ 22 files changed, 1111 insertions(+), 968 deletions(-) create mode 100644 internal/output/audit.go create mode 100644 internal/output/certs.go create mode 100644 internal/output/compare.go create mode 100644 internal/output/compliance.go create mode 100644 internal/output/cost.go create mode 100644 internal/output/drift.go create mode 100644 internal/output/iam.go create mode 100644 internal/output/inventory.go delete mode 100644 internal/output/json.go create mode 100644 internal/output/jsoncore.go create mode 100644 internal/output/k8s.go create mode 100644 internal/output/lambda.go create mode 100644 internal/output/network.go create mode 100644 internal/output/orphans.go create mode 100644 internal/output/platform.go create mode 100644 internal/output/quota.go create mode 100644 internal/output/secrets.go create mode 100644 internal/output/storage.go create mode 100644 internal/output/style.go delete mode 100644 internal/output/table.go create mode 100644 internal/output/tags.go diff --git a/CLAUDE.md b/CLAUDE.md index 96932c1..6869f3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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) diff --git a/internal/output/audit.go b/internal/output/audit.go new file mode 100644 index 0000000..b2c9e3d --- /dev/null +++ b/internal/output/audit.go @@ -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) +} diff --git a/internal/output/certs.go b/internal/output/certs.go new file mode 100644 index 0000000..aa26939 --- /dev/null +++ b/internal/output/certs.go @@ -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)}) +} diff --git a/internal/output/compare.go b/internal/output/compare.go new file mode 100644 index 0000000..37b20fc --- /dev/null +++ b/internal/output/compare.go @@ -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, + } +} diff --git a/internal/output/compliance.go b/internal/output/compliance.go new file mode 100644 index 0000000..82e5976 --- /dev/null +++ b/internal/output/compliance.go @@ -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) +} diff --git a/internal/output/cost.go b/internal/output/cost.go new file mode 100644 index 0000000..e5c9817 --- /dev/null +++ b/internal/output/cost.go @@ -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}) +} diff --git a/internal/output/drift.go b/internal/output/drift.go new file mode 100644 index 0000000..07aae28 --- /dev/null +++ b/internal/output/drift.go @@ -0,0 +1,63 @@ +package output + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// DriftResults renders a drift detection results table. +func DriftResults(w io.Writer, results []cloud.DriftResult) { + if len(results) == 0 { + fmt.Fprintln(w, dimStyle.Render("no resources checked")) + 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("RESOURCE"), + headerStyle.Render("TYPE"), + headerStyle.Render("ID"), + headerStyle.Render("DETAIL"), + ) + for _, r := range results { + var statusStyled string + switch r.Status { + case cloud.DriftInSync: + statusStyled = greenStyle.Render("IN_SYNC") + case cloud.DriftModified: + statusStyled = critStyle.Render("MODIFIED") + case cloud.DriftDeleted: + statusStyled = critStyle.Render("DELETED") + case cloud.DriftError: + statusStyled = medStyle.Render("ERROR") + } + + detail := r.Detail + if len(r.Fields) > 0 && detail == "" { + var parts []string + for _, f := range r.Fields { + parts = append(parts, fmt.Sprintf("%s: %s→%s", f.Field, f.Expected, f.Actual)) + } + detail = strings.Join(parts, "; ") + } + + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + statusStyled, r.ResourceName, r.ResourceType, truncate(r.ResourceID, 30), truncate(detail, 60), + ) + } + tw.Flush() +} + +type driftReport struct { + Results []cloud.DriftResult `json:"results"` + Total int `json:"total"` +} + +// WriteDrift marshals drift results as JSON to w. +func WriteDrift(w io.Writer, results []cloud.DriftResult) error { + return writeJSON(w, driftReport{Results: results, Total: len(results)}) +} diff --git a/internal/output/iam.go b/internal/output/iam.go new file mode 100644 index 0000000..1238585 --- /dev/null +++ b/internal/output/iam.go @@ -0,0 +1,71 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// IAMFindings renders a findings table to w, followed by a severity summary line. +func IAMFindings(w io.Writer, findings []cloud.Finding, totalPrincipals int) { + 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\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("TYPE"), + headerStyle.Render("PRINCIPAL"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + principal := "" + if f.Principal != nil { + principal = f.Principal.Name + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + sev, string(f.Type), principal, truncate(f.Detail, 80), + ) + } + tw.Flush() + + var crit, high, med int + for _, f := range findings { + switch f.Severity { + case cloud.SeverityCritical: + crit++ + case cloud.SeverityHigh: + high++ + case cloud.SeverityMedium: + med++ + } + } + summary := fmt.Sprintf("%s critical, %s high, %s medium across %d principals", + critStyle.Render(fmt.Sprintf("%d", crit)), + highStyle.Render(fmt.Sprintf("%d", high)), + medStyle.Render(fmt.Sprintf("%d", med)), + totalPrincipals, + ) + fmt.Fprintf(w, "\n%s\n", summary) +} + +type iamReport struct { + Findings []cloud.Finding `json:"findings"` + Total int `json:"total"` + Principals int `json:"principals_scanned"` + UsedPermissions map[string][]cloud.Permission `json:"used_permissions,omitempty"` +} + +// WriteIAM marshals IAM findings as JSON to w. +func WriteIAM(w io.Writer, findings []cloud.Finding, principalsScanned int, usedPerms map[string][]cloud.Permission) error { + return writeJSON(w, iamReport{ + Findings: findings, + Total: len(findings), + Principals: principalsScanned, + UsedPermissions: usedPerms, + }) +} diff --git a/internal/output/inventory.go b/internal/output/inventory.go new file mode 100644 index 0000000..7ce533d --- /dev/null +++ b/internal/output/inventory.go @@ -0,0 +1,63 @@ +package output + +import ( + "fmt" + "io" + "sort" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// InventoryResources renders an inventory table with resource details. +func InventoryResources(w io.Writer, resources []cloud.InventoryResource) { + if len(resources) == 0 { + fmt.Fprintln(w, dimStyle.Render("no resources found")) + return + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + headerStyle.Render("TYPE"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("NAME"), + headerStyle.Render("REGION"), + headerStyle.Render("STATUS"), + headerStyle.Render("TAGS"), + ) + for _, r := range resources { + tagStr := formatTags(r.Tags, 50) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + r.Type, r.Provider, truncate(r.Name, 30), r.Region, r.Status, tagStr, + ) + } + tw.Flush() + + // Summary by type + typeCounts := make(map[string]int) + for _, r := range resources { + typeCounts[r.Type]++ + } + types := make([]string, 0, len(typeCounts)) + for t := range typeCounts { + types = append(types, t) + } + sort.Strings(types) + fmt.Fprintf(w, "\n%s: %d resources", headerStyle.Render("Total"), len(resources)) + for _, t := range types { + fmt.Fprintf(w, ", %s: %d", t, typeCounts[t]) + } + fmt.Fprintln(w) +} + +type inventoryReport struct { + Resources []cloud.InventoryResource `json:"resources"` + Total int `json:"total"` +} + +// WriteInventory marshals inventory resources as JSON to w. +func WriteInventory(w io.Writer, resources []cloud.InventoryResource) error { + return writeJSON(w, inventoryReport{ + Resources: resources, + Total: len(resources), + }) +} diff --git a/internal/output/json.go b/internal/output/json.go deleted file mode 100644 index 1e0ef30..0000000 --- a/internal/output/json.go +++ /dev/null @@ -1,252 +0,0 @@ -package output - -import ( - "encoding/json" - "io" - - "github.com/nanohype/cloudgov/internal/audit" - "github.com/nanohype/cloudgov/internal/cloud" - "github.com/nanohype/cloudgov/internal/compliance" -) - -type iamReport struct { - Findings []cloud.Finding `json:"findings"` - Total int `json:"total"` - Principals int `json:"principals_scanned"` - UsedPermissions map[string][]cloud.Permission `json:"used_permissions,omitempty"` -} - -type storageReport struct { - Findings []cloud.BucketFinding `json:"findings"` - Total int `json:"total"` -} - -type orphansReport struct { - Resources []cloud.OrphanResource `json:"resources"` - Total int `json:"total"` - EstimatedMonthlyUSD float64 `json:"estimated_monthly_usd"` -} - -type costReport struct { - Diffs []cloud.CostDiff `json:"diffs"` -} - -// WriteIAM marshals IAM findings as JSON to w. -func WriteIAM(w io.Writer, findings []cloud.Finding, principalsScanned int, usedPerms map[string][]cloud.Permission) error { - return writeJSON(w, iamReport{ - Findings: findings, - Total: len(findings), - Principals: principalsScanned, - UsedPermissions: usedPerms, - }) -} - -// WriteStorage marshals storage findings as JSON to w. -func WriteStorage(w io.Writer, findings []cloud.BucketFinding) error { - return writeJSON(w, storageReport{ - Findings: findings, - Total: len(findings), - }) -} - -// WriteOrphans marshals orphan resources as JSON to w. -func WriteOrphans(w io.Writer, orphans []cloud.OrphanResource) error { - var total float64 - for _, o := range orphans { - total += o.MonthlyCost - } - return writeJSON(w, orphansReport{ - Resources: orphans, - Total: len(orphans), - EstimatedMonthlyUSD: total, - }) -} - -// WriteCost marshals cost diffs as JSON to w. -func WriteCost(w io.Writer, diffs []cloud.CostDiff) error { - return writeJSON(w, costReport{Diffs: diffs}) -} - -type networkReport struct { - Findings []cloud.NetworkFinding `json:"findings"` - Total int `json:"total"` -} - -type certsReport struct { - Findings []cloud.CertFinding `json:"findings"` - Total int `json:"total"` -} - -type tagsReport struct { - Findings []cloud.TagFinding `json:"findings"` - Total int `json:"total"` -} - -// WriteNetwork marshals network findings as JSON to w. -func WriteNetwork(w io.Writer, findings []cloud.NetworkFinding) error { - return writeJSON(w, networkReport{Findings: findings, Total: len(findings)}) -} - -// 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)}) -} - -// WriteTags marshals tag findings as JSON to w. -func WriteTags(w io.Writer, findings []cloud.TagFinding) error { - return writeJSON(w, tagsReport{Findings: findings, Total: len(findings)}) -} - -type secretsReport struct { - Findings []cloud.SecretFinding `json:"findings"` - Total int `json:"total"` -} - -// WriteSecrets marshals secret findings as JSON to w. -func WriteSecrets(w io.Writer, findings []cloud.SecretFinding) error { - return writeJSON(w, secretsReport{Findings: findings, Total: len(findings)}) -} - -type driftReport struct { - Results []cloud.DriftResult `json:"results"` - Total int `json:"total"` -} - -// WriteDrift marshals drift results as JSON to w. -func WriteDrift(w io.Writer, results []cloud.DriftResult) error { - return writeJSON(w, driftReport{Results: results, Total: len(results)}) -} - -// WriteCompliance marshals a compliance report as JSON to w. -func WriteCompliance(w io.Writer, report compliance.ComplianceReport) error { - return writeJSON(w, report) -} - -// WriteAudit marshals a full audit report as JSON to w. -func WriteAudit(w io.Writer, report *audit.Report) error { - return writeJSON(w, report) -} - -type k8sReport struct { - Findings []cloud.K8sFinding `json:"findings"` - Total int `json:"total"` -} - -// WriteK8sFindings marshals Kubernetes findings as JSON to w. -func WriteK8sFindings(w io.Writer, findings []cloud.K8sFinding) error { - return writeJSON(w, k8sReport{Findings: findings, Total: len(findings)}) -} - -type lambdaPolicyReport struct { - Findings []cloud.LambdaPolicyFinding `json:"findings"` - Total int `json:"total"` -} - -// WriteLambdaPolicy marshals Lambda resource-policy findings as JSON to w. -func WriteLambdaPolicy(w io.Writer, findings []cloud.LambdaPolicyFinding) error { - return writeJSON(w, lambdaPolicyReport{Findings: findings, Total: len(findings)}) -} - -type inventoryReport struct { - Resources []cloud.InventoryResource `json:"resources"` - Total int `json:"total"` -} - -// WriteInventory marshals inventory resources as JSON to w. -func WriteInventory(w io.Writer, resources []cloud.InventoryResource) error { - return writeJSON(w, inventoryReport{ - Resources: resources, - Total: len(resources), - }) -} - -type quotaReport struct { - Quotas []cloud.QuotaUsage `json:"quotas"` - Total int `json:"total"` - Critical int `json:"critical"` - High int `json:"high"` - Medium int `json:"medium"` -} - -// WriteQuotas marshals quota usage data as JSON to w. -func WriteQuotas(w io.Writer, quotas []cloud.QuotaUsage) error { - var crit, high, med int - for _, q := range quotas { - switch q.EffectiveSeverity() { - case cloud.SeverityCritical: - crit++ - case cloud.SeverityHigh: - high++ - case cloud.SeverityMedium: - med++ - } - } - return writeJSON(w, quotaReport{ - Quotas: quotas, - Total: len(quotas), - Critical: crit, - High: high, - Medium: med, - }) -} - -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, - } -} - -type platformReport struct { - Findings []cloud.PlatformFinding `json:"findings"` - Total int `json:"total"` -} - -// WritePlatform marshals Platform conformance findings as JSON to w. -func WritePlatform(w io.Writer, findings []cloud.PlatformFinding) error { - return writeJSON(w, platformReport{Findings: findings, Total: len(findings)}) -} - -func writeJSON(w io.Writer, v any) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/internal/output/jsoncore.go b/internal/output/jsoncore.go new file mode 100644 index 0000000..bef5c16 --- /dev/null +++ b/internal/output/jsoncore.go @@ -0,0 +1,12 @@ +package output + +import ( + "encoding/json" + "io" +) + +func writeJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/internal/output/k8s.go b/internal/output/k8s.go new file mode 100644 index 0000000..3faa38b --- /dev/null +++ b/internal/output/k8s.go @@ -0,0 +1,59 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// K8sFindings renders a Kubernetes RBAC findings table. +func K8sFindings(w io.Writer, findings []cloud.K8sFinding) { + 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\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("TYPE"), + headerStyle.Render("KIND"), + headerStyle.Render("NAME"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + sev, string(f.Type), f.Kind, f.Name, truncate(f.Detail, 80), + ) + } + tw.Flush() + + var crit, high, med int + for _, f := range findings { + switch f.Severity { + case cloud.SeverityCritical: + crit++ + case cloud.SeverityHigh: + high++ + case cloud.SeverityMedium: + med++ + } + } + fmt.Fprintf(w, "\n%s critical, %s high, %s medium\n", + critStyle.Render(fmt.Sprintf("%d", crit)), + highStyle.Render(fmt.Sprintf("%d", high)), + medStyle.Render(fmt.Sprintf("%d", med)), + ) +} + +type k8sReport struct { + Findings []cloud.K8sFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteK8sFindings marshals Kubernetes findings as JSON to w. +func WriteK8sFindings(w io.Writer, findings []cloud.K8sFinding) error { + return writeJSON(w, k8sReport{Findings: findings, Total: len(findings)}) +} diff --git a/internal/output/lambda.go b/internal/output/lambda.go new file mode 100644 index 0000000..8614bad --- /dev/null +++ b/internal/output/lambda.go @@ -0,0 +1,59 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// LambdaPolicyFindings renders a Lambda resource-policy findings table. +func LambdaPolicyFindings(w io.Writer, findings []cloud.LambdaPolicyFinding) { + 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\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("TYPE"), + headerStyle.Render("FUNCTION"), + headerStyle.Render("STATEMENT"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + sev, string(f.Type), f.FunctionName, f.StatementID, truncate(f.Detail, 70), + ) + } + tw.Flush() + + var crit, high, med int + for _, f := range findings { + switch f.Severity { + case cloud.SeverityCritical: + crit++ + case cloud.SeverityHigh: + high++ + case cloud.SeverityMedium: + med++ + } + } + fmt.Fprintf(w, "\n%s critical, %s high, %s medium\n", + critStyle.Render(fmt.Sprintf("%d", crit)), + highStyle.Render(fmt.Sprintf("%d", high)), + medStyle.Render(fmt.Sprintf("%d", med)), + ) +} + +type lambdaPolicyReport struct { + Findings []cloud.LambdaPolicyFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteLambdaPolicy marshals Lambda resource-policy findings as JSON to w. +func WriteLambdaPolicy(w io.Writer, findings []cloud.LambdaPolicyFinding) error { + return writeJSON(w, lambdaPolicyReport{Findings: findings, Total: len(findings)}) +} diff --git a/internal/output/network.go b/internal/output/network.go new file mode 100644 index 0000000..e9694e6 --- /dev/null +++ b/internal/output/network.go @@ -0,0 +1,45 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// NetworkFindings renders a network security findings table. +func NetworkFindings(w io.Writer, findings []cloud.NetworkFinding) { + 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\t%s\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("TYPE"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("RESOURCE"), + headerStyle.Render("REGION"), + headerStyle.Render("PORT"), + headerStyle.Render("CIDR"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + sev, string(f.Type), f.Provider, f.Resource, f.Region, f.Port, f.CIDR, truncate(f.Detail, 60), + ) + } + tw.Flush() +} + +type networkReport struct { + Findings []cloud.NetworkFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteNetwork marshals network findings as JSON to w. +func WriteNetwork(w io.Writer, findings []cloud.NetworkFinding) error { + return writeJSON(w, networkReport{Findings: findings, Total: len(findings)}) +} diff --git a/internal/output/orphans.go b/internal/output/orphans.go new file mode 100644 index 0000000..007a130 --- /dev/null +++ b/internal/output/orphans.go @@ -0,0 +1,59 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// OrphanResources renders an orphan resources table with a TOTAL row at the bottom. +func OrphanResources(w io.Writer, orphans []cloud.OrphanResource) { + if len(orphans) == 0 { + fmt.Fprintln(w, dimStyle.Render("no orphaned resources found")) + return + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + headerStyle.Render("KIND"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("NAME"), + headerStyle.Render("REGION"), + headerStyle.Render("$/MONTH"), + headerStyle.Render("DETAIL"), + ) + var total float64 + for _, o := range orphans { + cost := fmt.Sprintf("$%.2f", o.MonthlyCost) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + string(o.Kind), o.Provider, o.Name, o.Region, + highStyle.Render(cost), truncate(o.Detail, 60), + ) + total += o.MonthlyCost + } + fmt.Fprintf(tw, "%s\t\t\t\t%s\t\n", + headerStyle.Render("TOTAL"), + headerStyle.Render(fmt.Sprintf("$%.2f", total)), + ) + tw.Flush() +} + +type orphansReport struct { + Resources []cloud.OrphanResource `json:"resources"` + Total int `json:"total"` + EstimatedMonthlyUSD float64 `json:"estimated_monthly_usd"` +} + +// WriteOrphans marshals orphan resources as JSON to w. +func WriteOrphans(w io.Writer, orphans []cloud.OrphanResource) error { + var total float64 + for _, o := range orphans { + total += o.MonthlyCost + } + return writeJSON(w, orphansReport{ + Resources: orphans, + Total: len(orphans), + EstimatedMonthlyUSD: total, + }) +} diff --git a/internal/output/platform.go b/internal/output/platform.go new file mode 100644 index 0000000..9a0b048 --- /dev/null +++ b/internal/output/platform.go @@ -0,0 +1,42 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// PlatformFindings renders Platform-tenant conformance findings to w. +func PlatformFindings(w io.Writer, findings []cloud.PlatformFinding) { + if len(findings) == 0 { + fmt.Fprintln(w, dimStyle.Render("no platform conformance findings")) + return + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("PLATFORM"), + headerStyle.Render("TYPE"), + headerStyle.Render("RESOURCE"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + sev, f.Platform, string(f.Type), f.Resource, truncate(f.Detail, 70), + ) + } + tw.Flush() +} + +type platformReport struct { + Findings []cloud.PlatformFinding `json:"findings"` + Total int `json:"total"` +} + +// WritePlatform marshals Platform conformance findings as JSON to w. +func WritePlatform(w io.Writer, findings []cloud.PlatformFinding) error { + return writeJSON(w, platformReport{Findings: findings, Total: len(findings)}) +} diff --git a/internal/output/quota.go b/internal/output/quota.go new file mode 100644 index 0000000..8bd53a3 --- /dev/null +++ b/internal/output/quota.go @@ -0,0 +1,73 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// QuotaUsages renders a quota utilization table. +func QuotaUsages(w io.Writer, quotas []cloud.QuotaUsage) { + if len(quotas) == 0 { + fmt.Fprintln(w, dimStyle.Render("no quotas found")) + return + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + headerStyle.Render("PROVIDER"), + headerStyle.Render("SERVICE"), + headerStyle.Render("QUOTA"), + headerStyle.Render("USED"), + headerStyle.Render("LIMIT"), + headerStyle.Render("UTILIZATION"), + ) + for _, q := range quotas { + pct := fmt.Sprintf("%.1f%%", q.Utilization) + var pctStyled string + switch { + case q.Utilization >= 80: + pctStyled = critStyle.Render(pct) + case q.Utilization >= 50: + pctStyled = medStyle.Render(pct) + default: + pctStyled = greenStyle.Render(pct) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%.0f\t%.0f\t%s\n", + q.Provider, q.Service, q.QuotaName, + q.Used, q.Limit, pctStyled, + ) + } + tw.Flush() +} + +type quotaReport struct { + Quotas []cloud.QuotaUsage `json:"quotas"` + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` +} + +// WriteQuotas marshals quota usage data as JSON to w. +func WriteQuotas(w io.Writer, quotas []cloud.QuotaUsage) error { + var crit, high, med int + for _, q := range quotas { + switch q.EffectiveSeverity() { + case cloud.SeverityCritical: + crit++ + case cloud.SeverityHigh: + high++ + case cloud.SeverityMedium: + med++ + } + } + return writeJSON(w, quotaReport{ + Quotas: quotas, + Total: len(quotas), + Critical: crit, + High: high, + Medium: med, + }) +} diff --git a/internal/output/secrets.go b/internal/output/secrets.go new file mode 100644 index 0000000..4069ddc --- /dev/null +++ b/internal/output/secrets.go @@ -0,0 +1,62 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// SecretFindings renders a secret findings table. +func SecretFindings(w io.Writer, findings []cloud.SecretFinding) { + 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("TYPE"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("RESOURCE"), + headerStyle.Render("KEY"), + headerStyle.Render("MATCH"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + sev, string(f.Type), f.Provider, f.Resource, f.Key, f.Match, truncate(f.Detail, 60), + ) + } + tw.Flush() + + var crit, high, med int + for _, f := range findings { + switch f.Severity { + case cloud.SeverityCritical: + crit++ + case cloud.SeverityHigh: + high++ + case cloud.SeverityMedium: + med++ + } + } + summary := fmt.Sprintf("\n%s critical, %s high, %s medium", + critStyle.Render(fmt.Sprintf("%d", crit)), + highStyle.Render(fmt.Sprintf("%d", high)), + medStyle.Render(fmt.Sprintf("%d", med)), + ) + fmt.Fprintln(w, summary) +} + +type secretsReport struct { + Findings []cloud.SecretFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteSecrets marshals secret findings as JSON to w. +func WriteSecrets(w io.Writer, findings []cloud.SecretFinding) error { + return writeJSON(w, secretsReport{Findings: findings, Total: len(findings)}) +} diff --git a/internal/output/storage.go b/internal/output/storage.go new file mode 100644 index 0000000..592a8c7 --- /dev/null +++ b/internal/output/storage.go @@ -0,0 +1,45 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// BucketFindings renders a storage findings table. +func BucketFindings(w io.Writer, findings []cloud.BucketFinding) { + 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\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("TYPE"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("BUCKET"), + headerStyle.Render("DETAIL"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + sev, string(f.Type), f.Provider, f.Bucket, truncate(f.Detail, 70), + ) + } + tw.Flush() +} + +type storageReport struct { + Findings []cloud.BucketFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteStorage marshals storage findings as JSON to w. +func WriteStorage(w io.Writer, findings []cloud.BucketFinding) error { + return writeJSON(w, storageReport{ + Findings: findings, + Total: len(findings), + }) +} diff --git a/internal/output/style.go b/internal/output/style.go new file mode 100644 index 0000000..47984fb --- /dev/null +++ b/internal/output/style.go @@ -0,0 +1,55 @@ +package output + +import ( + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/nanohype/cloudgov/internal/cloud" +) + +var ( + critStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true) + highStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6600")) + medStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC00")) + lowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + headerStyle = lipgloss.NewStyle().Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA00")) +) + +func colorSeverity(s cloud.Severity) lipgloss.Style { + switch s { + case cloud.SeverityCritical: + return critStyle + case cloud.SeverityHigh: + return highStyle + case cloud.SeverityMedium: + return medStyle + case cloud.SeverityLow: + return lowStyle + default: + return infoStyle + } +} + +func formatTags(tags map[string]string, maxLen int) string { + if len(tags) == 0 { + return "" + } + parts := make([]string, 0, len(tags)) + for k, v := range tags { + parts = append(parts, k+"="+v) + } + sort.Strings(parts) + s := strings.Join(parts, ", ") + return truncate(s, maxLen) +} + +func truncate(s string, n int) string { + if n < 4 || len(s) <= n { + return s + } + return s[:n-3] + "..." +} diff --git a/internal/output/table.go b/internal/output/table.go deleted file mode 100644 index 4104014..0000000 --- a/internal/output/table.go +++ /dev/null @@ -1,715 +0,0 @@ -package output - -import ( - "fmt" - "io" - "sort" - "strings" - "text/tabwriter" - - "github.com/charmbracelet/lipgloss" - "github.com/nanohype/cloudgov/internal/audit" - "github.com/nanohype/cloudgov/internal/cloud" - "github.com/nanohype/cloudgov/internal/compliance" -) - -var ( - critStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Bold(true) - highStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6600")) - medStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFCC00")) - lowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) - headerStyle = lipgloss.NewStyle().Bold(true) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) - greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AA00")) -) - -func colorSeverity(s cloud.Severity) lipgloss.Style { - switch s { - case cloud.SeverityCritical: - return critStyle - case cloud.SeverityHigh: - return highStyle - case cloud.SeverityMedium: - return medStyle - case cloud.SeverityLow: - return lowStyle - default: - return infoStyle - } -} - -// PlatformFindings renders Platform-tenant conformance findings to w. -func PlatformFindings(w io.Writer, findings []cloud.PlatformFinding) { - if len(findings) == 0 { - fmt.Fprintln(w, dimStyle.Render("no platform conformance findings")) - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("PLATFORM"), - headerStyle.Render("TYPE"), - headerStyle.Render("RESOURCE"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - sev, f.Platform, string(f.Type), f.Resource, truncate(f.Detail, 70), - ) - } - tw.Flush() -} - -// IAMFindings renders a findings table to w, followed by a severity summary line. -func IAMFindings(w io.Writer, findings []cloud.Finding, totalPrincipals int) { - 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\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("TYPE"), - headerStyle.Render("PRINCIPAL"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - principal := "" - if f.Principal != nil { - principal = f.Principal.Name - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", - sev, string(f.Type), principal, truncate(f.Detail, 80), - ) - } - tw.Flush() - - var crit, high, med int - for _, f := range findings { - switch f.Severity { - case cloud.SeverityCritical: - crit++ - case cloud.SeverityHigh: - high++ - case cloud.SeverityMedium: - med++ - } - } - summary := fmt.Sprintf("%s critical, %s high, %s medium across %d principals", - critStyle.Render(fmt.Sprintf("%d", crit)), - highStyle.Render(fmt.Sprintf("%d", high)), - medStyle.Render(fmt.Sprintf("%d", med)), - totalPrincipals, - ) - fmt.Fprintf(w, "\n%s\n", summary) -} - -// BucketFindings renders a storage findings table. -func BucketFindings(w io.Writer, findings []cloud.BucketFinding) { - 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\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("TYPE"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("BUCKET"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - sev, string(f.Type), f.Provider, f.Bucket, truncate(f.Detail, 70), - ) - } - tw.Flush() -} - -// OrphanResources renders an orphan resources table with a TOTAL row at the bottom. -func OrphanResources(w io.Writer, orphans []cloud.OrphanResource) { - if len(orphans) == 0 { - fmt.Fprintln(w, dimStyle.Render("no orphaned resources found")) - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - headerStyle.Render("KIND"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("NAME"), - headerStyle.Render("REGION"), - headerStyle.Render("$/MONTH"), - headerStyle.Render("DETAIL"), - ) - var total float64 - for _, o := range orphans { - cost := fmt.Sprintf("$%.2f", o.MonthlyCost) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - string(o.Kind), o.Provider, o.Name, o.Region, - highStyle.Render(cost), truncate(o.Detail, 60), - ) - total += o.MonthlyCost - } - fmt.Fprintf(tw, "%s\t\t\t\t%s\t\n", - headerStyle.Render("TOTAL"), - headerStyle.Render(fmt.Sprintf("$%.2f", total)), - ) - tw.Flush() -} - -// 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) - } -} - -// NetworkFindings renders a network security findings table. -func NetworkFindings(w io.Writer, findings []cloud.NetworkFinding) { - 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\t%s\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("TYPE"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("RESOURCE"), - headerStyle.Render("REGION"), - headerStyle.Render("PORT"), - headerStyle.Render("CIDR"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - sev, string(f.Type), f.Provider, f.Resource, f.Region, f.Port, f.CIDR, truncate(f.Detail, 60), - ) - } - tw.Flush() -} - -// 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() -} - -// TagFindings renders a missing tags findings table. -func TagFindings(w io.Writer, findings []cloud.TagFinding) { - 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\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("TYPE"), - headerStyle.Render("RESOURCE"), - headerStyle.Render("REGION"), - headerStyle.Render("MISSING"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - missing := "" - for i, t := range f.MissingTags { - if i > 0 { - missing += ", " - } - missing += t - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - sev, f.Provider, f.ResourceType, f.ResourceID, f.Region, missing, - ) - } - tw.Flush() -} - -// DriftResults renders a drift detection results table. -func DriftResults(w io.Writer, results []cloud.DriftResult) { - if len(results) == 0 { - fmt.Fprintln(w, dimStyle.Render("no resources checked")) - 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("RESOURCE"), - headerStyle.Render("TYPE"), - headerStyle.Render("ID"), - headerStyle.Render("DETAIL"), - ) - for _, r := range results { - var statusStyled string - switch r.Status { - case cloud.DriftInSync: - statusStyled = greenStyle.Render("IN_SYNC") - case cloud.DriftModified: - statusStyled = critStyle.Render("MODIFIED") - case cloud.DriftDeleted: - statusStyled = critStyle.Render("DELETED") - case cloud.DriftError: - statusStyled = medStyle.Render("ERROR") - } - - detail := r.Detail - if len(r.Fields) > 0 && detail == "" { - var parts []string - for _, f := range r.Fields { - parts = append(parts, fmt.Sprintf("%s: %s→%s", f.Field, f.Expected, f.Actual)) - } - detail = strings.Join(parts, "; ") - } - - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - statusStyled, r.ResourceName, r.ResourceType, truncate(r.ResourceID, 30), truncate(detail, 60), - ) - } - tw.Flush() -} - -// 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) -} - -// LambdaPolicyFindings renders a Lambda resource-policy findings table. -func LambdaPolicyFindings(w io.Writer, findings []cloud.LambdaPolicyFinding) { - 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\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("TYPE"), - headerStyle.Render("FUNCTION"), - headerStyle.Render("STATEMENT"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - sev, string(f.Type), f.FunctionName, f.StatementID, truncate(f.Detail, 70), - ) - } - tw.Flush() - - var crit, high, med int - for _, f := range findings { - switch f.Severity { - case cloud.SeverityCritical: - crit++ - case cloud.SeverityHigh: - high++ - case cloud.SeverityMedium: - med++ - } - } - fmt.Fprintf(w, "\n%s critical, %s high, %s medium\n", - critStyle.Render(fmt.Sprintf("%d", crit)), - highStyle.Render(fmt.Sprintf("%d", high)), - medStyle.Render(fmt.Sprintf("%d", med)), - ) -} - -// K8sFindings renders a Kubernetes RBAC findings table. -func K8sFindings(w io.Writer, findings []cloud.K8sFinding) { - 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\n", - headerStyle.Render("SEVERITY"), - headerStyle.Render("TYPE"), - headerStyle.Render("KIND"), - headerStyle.Render("NAME"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", - sev, string(f.Type), f.Kind, f.Name, truncate(f.Detail, 80), - ) - } - tw.Flush() - - var crit, high, med int - for _, f := range findings { - switch f.Severity { - case cloud.SeverityCritical: - crit++ - case cloud.SeverityHigh: - high++ - case cloud.SeverityMedium: - med++ - } - } - fmt.Fprintf(w, "\n%s critical, %s high, %s medium\n", - critStyle.Render(fmt.Sprintf("%d", crit)), - highStyle.Render(fmt.Sprintf("%d", high)), - medStyle.Render(fmt.Sprintf("%d", med)), - ) -} - -// SecretFindings renders a secret findings table. -func SecretFindings(w io.Writer, findings []cloud.SecretFinding) { - 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("TYPE"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("RESOURCE"), - headerStyle.Render("KEY"), - headerStyle.Render("MATCH"), - headerStyle.Render("DETAIL"), - ) - for _, f := range findings { - sev := colorSeverity(f.Severity).Render(string(f.Severity)) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - sev, string(f.Type), f.Provider, f.Resource, f.Key, f.Match, truncate(f.Detail, 60), - ) - } - tw.Flush() - - var crit, high, med int - for _, f := range findings { - switch f.Severity { - case cloud.SeverityCritical: - crit++ - case cloud.SeverityHigh: - high++ - case cloud.SeverityMedium: - med++ - } - } - summary := fmt.Sprintf("\n%s critical, %s high, %s medium", - critStyle.Render(fmt.Sprintf("%d", crit)), - highStyle.Render(fmt.Sprintf("%d", high)), - medStyle.Render(fmt.Sprintf("%d", med)), - ) - fmt.Fprintln(w, summary) -} - -// 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))) - } -} - -// InventoryResources renders an inventory table with resource details. -func InventoryResources(w io.Writer, resources []cloud.InventoryResource) { - if len(resources) == 0 { - fmt.Fprintln(w, dimStyle.Render("no resources found")) - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - headerStyle.Render("TYPE"), - headerStyle.Render("PROVIDER"), - headerStyle.Render("NAME"), - headerStyle.Render("REGION"), - headerStyle.Render("STATUS"), - headerStyle.Render("TAGS"), - ) - for _, r := range resources { - tagStr := formatTags(r.Tags, 50) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - r.Type, r.Provider, truncate(r.Name, 30), r.Region, r.Status, tagStr, - ) - } - tw.Flush() - - // Summary by type - typeCounts := make(map[string]int) - for _, r := range resources { - typeCounts[r.Type]++ - } - types := make([]string, 0, len(typeCounts)) - for t := range typeCounts { - types = append(types, t) - } - sort.Strings(types) - fmt.Fprintf(w, "\n%s: %d resources", headerStyle.Render("Total"), len(resources)) - for _, t := range types { - fmt.Fprintf(w, ", %s: %d", t, typeCounts[t]) - } - fmt.Fprintln(w) -} - -func formatTags(tags map[string]string, maxLen int) string { - if len(tags) == 0 { - return "" - } - parts := make([]string, 0, len(tags)) - for k, v := range tags { - parts = append(parts, k+"="+v) - } - sort.Strings(parts) - s := strings.Join(parts, ", ") - return truncate(s, maxLen) -} - -func truncate(s string, n int) string { - if n < 4 || len(s) <= n { - return s - } - return s[:n-3] + "..." -} - -// QuotaUsages renders a quota utilization table. -func QuotaUsages(w io.Writer, quotas []cloud.QuotaUsage) { - if len(quotas) == 0 { - fmt.Fprintln(w, dimStyle.Render("no quotas found")) - return - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", - headerStyle.Render("PROVIDER"), - headerStyle.Render("SERVICE"), - headerStyle.Render("QUOTA"), - headerStyle.Render("USED"), - headerStyle.Render("LIMIT"), - headerStyle.Render("UTILIZATION"), - ) - for _, q := range quotas { - pct := fmt.Sprintf("%.1f%%", q.Utilization) - var pctStyled string - switch { - case q.Utilization >= 80: - pctStyled = critStyle.Render(pct) - case q.Utilization >= 50: - pctStyled = medStyle.Render(pct) - default: - pctStyled = greenStyle.Render(pct) - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%.0f\t%.0f\t%s\n", - q.Provider, q.Service, q.QuotaName, - q.Used, q.Limit, pctStyled, - ) - } - tw.Flush() -} - -// 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, - } -} diff --git a/internal/output/tags.go b/internal/output/tags.go new file mode 100644 index 0000000..55ae5dd --- /dev/null +++ b/internal/output/tags.go @@ -0,0 +1,50 @@ +package output + +import ( + "fmt" + "io" + "text/tabwriter" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +// TagFindings renders a missing tags findings table. +func TagFindings(w io.Writer, findings []cloud.TagFinding) { + 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\n", + headerStyle.Render("SEVERITY"), + headerStyle.Render("PROVIDER"), + headerStyle.Render("TYPE"), + headerStyle.Render("RESOURCE"), + headerStyle.Render("REGION"), + headerStyle.Render("MISSING"), + ) + for _, f := range findings { + sev := colorSeverity(f.Severity).Render(string(f.Severity)) + missing := "" + for i, t := range f.MissingTags { + if i > 0 { + missing += ", " + } + missing += t + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + sev, f.Provider, f.ResourceType, f.ResourceID, f.Region, missing, + ) + } + tw.Flush() +} + +type tagsReport struct { + Findings []cloud.TagFinding `json:"findings"` + Total int `json:"total"` +} + +// WriteTags marshals tag findings as JSON to w. +func WriteTags(w io.Writer, findings []cloud.TagFinding) error { + return writeJSON(w, tagsReport{Findings: findings, Total: len(findings)}) +}