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)}) +}