From 4c8244f23ca8b32740d317f79e86b2dc44199d8e Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Apr 2026 15:47:01 +0300 Subject: [PATCH 1/5] feat: add new catalog|rbac report/get commands and facet components --- api/catalog_report.go | 364 ++ api/rbac_report.go | 22 +- api/scraper_report.go | 17 + api/v1/playbook_actions.go | 16 +- application/render_facet.go | 122 +- cmd/application.go | 2 + cmd/auth_login.go | 71 +- cmd/catalog.go | 71 +- cmd/catalog_get.go | 504 +++ cmd/catalog_get_test.go | 249 ++ cmd/catalog_report.go | 191 + cmd/catalog_tree.go | 162 + cmd/connection_browser.go | 226 +- cmd/connection_test_cmd.go | 19 +- cmd/context.go | 136 +- cmd/rbac.go | 8 +- cmd/view.go | 2 + config/schemas/playbook-spec.schema.json | 25 +- config/schemas/playbook.schema.json | 25 +- db/rbac_test.go | 68 + fixtures/views/ui-ref-examples.yaml | 13 + rbac_report/export.go | 10 +- rbac_report/render_facet.go | 118 +- rbac_report/report.go | 49 +- report/.gitignore | 1 + report/Application.tsx | 158 +- report/CatalogReport.tsx | 211 + report/FindingsReport.tsx | 571 +++ report/KitchenSink.tsx | 65 + report/MatrixDemo.tsx | 218 + report/RBACByUserReport.tsx | 105 +- report/RBACMatrixReport.tsx | 71 + report/RBACReport.tsx | 102 - report/ViewReport.tsx | 112 +- report/build-kitchen-sink.ts | 165 + report/catalog-report-types.ts | 164 + report/catalog/change_mappings.go | 309 ++ report/catalog/change_mappings_test.go | 100 + report/catalog/default-settings.yaml | 34 + report/catalog/export.go | 264 ++ report/catalog/render_facet.go | 36 + report/catalog/report.go | 670 +++ report/catalog/report_test.go | 185 + report/catalog/settings.go | 129 + report/catalog/settings_test.go | 158 + report/components/ApplicationDetails.tsx | 19 +- report/components/ArtifactAppendix.tsx | 69 + report/components/AuditPage.tsx | 163 + report/components/BackupActivityCalendar.tsx | 148 + report/components/BackupChanges.tsx | 216 + report/components/BackupsSection.tsx | 178 +- .../components/CatalogAccessLogsSection.tsx | 47 + report/components/CatalogAccessSection.tsx | 66 + report/components/CatalogList.tsx | 65 + report/components/ConfigChangesExamples.tsx | 81 + report/components/ConfigChangesSection.tsx | 318 ++ report/components/ConfigInsightsSection.tsx | 114 + report/components/ConfigItemCard.tsx | 40 + report/components/ConfigLink.tsx | 24 + report/components/ConfigRelationshipGraph.tsx | 79 + report/components/ConfigTreeSection.tsx | 48 + report/components/CoverPage.tsx | 146 + report/components/DeploymentChanges.tsx | 131 + report/components/DynamicSection.tsx | 105 +- report/components/FindingsSection.tsx | 16 +- report/components/GitRef.tsx | 56 + report/components/IncidentsSection.tsx | 16 +- report/components/PageFooter.tsx | 24 + report/components/PageHeader.tsx | 15 + report/components/RBACChangelogSection.tsx | 66 +- report/components/RBACChanges.tsx | 138 + report/components/RBACCoverContent.tsx | 28 + report/components/RBACMatrixSection.tsx | 161 + report/components/RBACResourceSection.tsx | 244 -- report/components/RBACSummarySection.tsx | 44 +- report/components/RBACUserSection.tsx | 50 +- report/components/ScraperCard.tsx | 70 + report/components/ViewResultSection.tsx | 46 +- report/components/change-section-utils.ts | 1059 +++++ report/components/rbac-visual.tsx | 119 + report/components/utils.ts | 60 +- report/config-types.ts | 87 + report/embed.go | 29 +- report/facet.go | 281 ++ report/finding-schema.json | 438 ++ report/icons.ts | 219 + report/kitchen-sink-data.ts | 14 + report/kitchen-sink.json | 3806 +++++++++++++++++ report/kitchen-sink/ApplicationPage.tsx | 36 + report/kitchen-sink/CatalogPage.tsx | 47 + report/kitchen-sink/ChangesPage.tsx | 88 + report/kitchen-sink/ConfigComponentsPage.tsx | 63 + report/kitchen-sink/DynamicSectionsPage.tsx | 60 + report/kitchen-sink/InsightsAndGraphPage.tsx | 57 + report/kitchen-sink/KitchenSinkTypes.ts | 21 + report/kitchen-sink/LayoutComponentsPage.tsx | 80 + report/kitchen-sink/RBACPage.tsx | 51 + report/kitchen-sink/ViewPage.tsx | 27 + report/mission-control.ts | 6 +- report/package-lock.json | 1681 ++------ report/package.json | 12 +- report/rbac-types.ts | 23 + report/sample-findings.json | 806 ++++ report/scraper-types.ts | 27 + report/scraper/scraper.go | 110 + report/testdata/kitchen-sink.yaml | 1979 +++++++++ report/types.ts | 11 + report/view-types.ts | 3 + views/render_facet.go | 295 +- views/render_facet_test.go | 15 +- 110 files changed, 18173 insertions(+), 2786 deletions(-) create mode 100644 api/catalog_report.go create mode 100644 api/scraper_report.go create mode 100644 cmd/catalog_get.go create mode 100644 cmd/catalog_get_test.go create mode 100644 cmd/catalog_report.go create mode 100644 cmd/catalog_tree.go create mode 100644 db/rbac_test.go create mode 100644 report/CatalogReport.tsx create mode 100644 report/FindingsReport.tsx create mode 100644 report/KitchenSink.tsx create mode 100644 report/MatrixDemo.tsx create mode 100644 report/RBACMatrixReport.tsx delete mode 100644 report/RBACReport.tsx create mode 100644 report/build-kitchen-sink.ts create mode 100644 report/catalog-report-types.ts create mode 100644 report/catalog/change_mappings.go create mode 100644 report/catalog/change_mappings_test.go create mode 100644 report/catalog/default-settings.yaml create mode 100644 report/catalog/export.go create mode 100644 report/catalog/render_facet.go create mode 100644 report/catalog/report.go create mode 100644 report/catalog/report_test.go create mode 100644 report/catalog/settings.go create mode 100644 report/catalog/settings_test.go create mode 100644 report/components/ArtifactAppendix.tsx create mode 100644 report/components/AuditPage.tsx create mode 100644 report/components/BackupActivityCalendar.tsx create mode 100644 report/components/BackupChanges.tsx create mode 100644 report/components/CatalogAccessLogsSection.tsx create mode 100644 report/components/CatalogAccessSection.tsx create mode 100644 report/components/CatalogList.tsx create mode 100644 report/components/ConfigChangesExamples.tsx create mode 100644 report/components/ConfigChangesSection.tsx create mode 100644 report/components/ConfigInsightsSection.tsx create mode 100644 report/components/ConfigItemCard.tsx create mode 100644 report/components/ConfigLink.tsx create mode 100644 report/components/ConfigRelationshipGraph.tsx create mode 100644 report/components/ConfigTreeSection.tsx create mode 100644 report/components/CoverPage.tsx create mode 100644 report/components/DeploymentChanges.tsx create mode 100644 report/components/GitRef.tsx create mode 100644 report/components/PageFooter.tsx create mode 100644 report/components/PageHeader.tsx create mode 100644 report/components/RBACChanges.tsx create mode 100644 report/components/RBACCoverContent.tsx create mode 100644 report/components/RBACMatrixSection.tsx delete mode 100644 report/components/RBACResourceSection.tsx create mode 100644 report/components/ScraperCard.tsx create mode 100644 report/components/change-section-utils.ts create mode 100644 report/components/rbac-visual.tsx create mode 100644 report/config-types.ts create mode 100644 report/facet.go create mode 100644 report/finding-schema.json create mode 100644 report/icons.ts create mode 100644 report/kitchen-sink-data.ts create mode 100644 report/kitchen-sink.json create mode 100644 report/kitchen-sink/ApplicationPage.tsx create mode 100644 report/kitchen-sink/CatalogPage.tsx create mode 100644 report/kitchen-sink/ChangesPage.tsx create mode 100644 report/kitchen-sink/ConfigComponentsPage.tsx create mode 100644 report/kitchen-sink/DynamicSectionsPage.tsx create mode 100644 report/kitchen-sink/InsightsAndGraphPage.tsx create mode 100644 report/kitchen-sink/KitchenSinkTypes.ts create mode 100644 report/kitchen-sink/LayoutComponentsPage.tsx create mode 100644 report/kitchen-sink/RBACPage.tsx create mode 100644 report/kitchen-sink/ViewPage.tsx create mode 100644 report/sample-findings.json create mode 100644 report/scraper-types.ts create mode 100644 report/scraper/scraper.go create mode 100644 report/testdata/kitchen-sink.yaml diff --git a/api/catalog_report.go b/api/catalog_report.go new file mode 100644 index 000000000..dbc1b5cef --- /dev/null +++ b/api/catalog_report.go @@ -0,0 +1,364 @@ +package api + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/flanksource/duty/models" +) + +func ConfigPermalink(configID string) string { + if FrontendURL == "" { + return "" + } + return fmt.Sprintf("%s/catalog/%s", FrontendURL, configID) +} + +type CatalogReportThresholds struct { + StaleDays int `json:"staleDays"` + ReviewOverdueDays int `json:"reviewOverdueDays"` +} + +type CatalogReportCategoryMapping struct { + Category string `json:"category,omitempty"` + Filter string `json:"filter"` + Transform string `json:"transform,omitempty"` +} + +type CatalogReportOptions struct { + Title string `json:"title"` + Since string `json:"since"` + Sections CatalogReportSections `json:"sections"` + Recursive bool `json:"recursive"` + GroupBy string `json:"groupBy"` + ChangeArtifacts bool `json:"changeArtifacts"` + Filters []string `json:"filters,omitempty"` + Thresholds *CatalogReportThresholds `json:"thresholds,omitempty"` + CategoryMappings []CatalogReportCategoryMapping `json:"categoryMappings,omitempty"` +} + +type CatalogReportAudit struct { + BuildCommit string `json:"buildCommit"` + BuildVersion string `json:"buildVersion"` + GitStatus string `json:"gitStatus,omitempty"` + Options CatalogReportOptions `json:"options"` + Scrapers []ScraperInfo `json:"scrapers"` + Queries []CatalogReportQuery `json:"queries"` + Groups []CatalogReportGroup `json:"groups"` +} + +type CatalogReportGroup struct { + ID string `json:"id"` + Name string `json:"name"` + GroupType string `json:"groupType,omitempty"` + Members []CatalogReportGroupMember `json:"members"` +} + +type CatalogReportGroupMember struct { + UserID string `json:"userId"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserType string `json:"userType,omitempty"` + LastSignedInAt *string `json:"lastSignedInAt,omitempty"` + MembershipAddedAt string `json:"membershipAddedAt"` + MembershipDeletedAt *string `json:"membershipDeletedAt,omitempty"` +} + +type CatalogReportQuery struct { + Name string `json:"name"` + Args string `json:"args,omitempty"` + Count int `json:"count"` + Duration int64 `json:"duration"` + Error string `json:"error,omitempty"` + Summary string `json:"summary,omitempty"` + Pretty string `json:"pretty"` +} + +type CatalogReport struct { + Title string `json:"title"` + GeneratedAt time.Time `json:"generatedAt"` + PublicURL string `json:"publicURL,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Sections CatalogReportSections `json:"sections"` + Recursive bool `json:"recursive,omitempty"` + GroupBy string `json:"groupBy,omitempty"` + Entries []CatalogReportEntry `json:"entries"` + + CategoryMappings []CatalogReportCategoryMapping `json:"categoryMappings,omitempty"` + Thresholds *CatalogReportThresholds `json:"thresholds,omitempty"` + Audit *CatalogReportAudit `json:"audit,omitempty"` + + // Deprecated: use Entries[0] for single-config reports + ConfigItem models.ConfigItem `json:"configItem"` + Parents []models.ConfigItem `json:"parents"` + + Changes []CatalogReportChange `json:"changes,omitempty"` + Analyses []CatalogReportAnalysis `json:"analyses,omitempty"` + Relationships []CatalogReportRelationship `json:"relationships,omitempty"` + RelatedConfigs []CatalogReportConfigItem `json:"relatedConfigs,omitempty"` + RelationshipTree *CatalogReportTreeNode `json:"relationshipTree,omitempty"` + Access []CatalogReportAccess `json:"access,omitempty"` + AccessLogs []CatalogReportAccessLog `json:"accessLogs,omitempty"` + ConfigJSON *string `json:"configJSON,omitempty"` + ConfigGroups []CatalogReportConfigGroup `json:"configGroups,omitempty"` +} + +type CatalogReportEntry struct { + ConfigItem CatalogReportConfigItem `json:"configItem"` + Parents []CatalogReportConfigItem `json:"parents,omitempty"` + RelationshipTree *CatalogReportTreeNode `json:"relationshipTree,omitempty"` + ChangeCount int `json:"changeCount"` + InsightCount int `json:"insightCount"` + AccessCount int `json:"accessCount"` + RBACResources []RBACResource `json:"rbacResources,omitempty"` + Changes []CatalogReportChange `json:"changes,omitempty"` + Analyses []CatalogReportAnalysis `json:"analyses,omitempty"` + Access []CatalogReportAccess `json:"access,omitempty"` + AccessLogs []CatalogReportAccessLog `json:"accessLogs,omitempty"` +} + +type CatalogReportConfigGroup struct { + ConfigItem CatalogReportConfigItem `json:"configItem"` + Changes []CatalogReportChange `json:"changes,omitempty"` + Analyses []CatalogReportAnalysis `json:"analyses,omitempty"` + Access []CatalogReportAccess `json:"access,omitempty"` + AccessLogs []CatalogReportAccessLog `json:"accessLogs,omitempty"` +} + +type CatalogReportSections struct { + Changes bool `json:"changes"` + Insights bool `json:"insights"` + Relationships bool `json:"relationships"` + Access bool `json:"access"` + AccessLogs bool `json:"accessLogs"` + ConfigJSON bool `json:"configJSON"` +} + +// CatalogReportChange wraps models.ConfigChange with camelCase JSON tags +// to match report/config-types.ts ConfigChange interface. +type CatalogReportArtifact struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Size int64 `json:"size"` + DataURI string `json:"dataUri,omitempty"` +} + +type CatalogReportChange struct { + ID string `json:"id,omitempty"` + ConfigID string `json:"configID,omitempty"` + ConfigName string `json:"configName,omitempty"` + ConfigType string `json:"configType,omitempty"` + Permalink string `json:"permalink,omitempty"` + ChangeType string `json:"changeType"` + Category string `json:"category,omitempty"` + Severity string `json:"severity,omitempty"` + Source string `json:"source,omitempty"` + Summary string `json:"summary,omitempty"` + Details map[string]any `json:"details,omitempty"` + TypedChange map[string]any `json:"typedChange,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + ExternalCreatedBy string `json:"externalCreatedBy,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + Count int `json:"count,omitempty"` + Artifacts []CatalogReportArtifact `json:"artifacts,omitempty"` +} + +func NewCatalogReportChange(c models.ConfigChange, configName, configType string) CatalogReportChange { + r := CatalogReportChange{ + ID: c.ID, + ConfigID: c.ConfigID, + ConfigName: configName, + ConfigType: configType, + Permalink: ConfigPermalink(c.ConfigID), + ChangeType: c.ChangeType, + Severity: string(c.Severity), + Source: c.Source, + Summary: c.Summary, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + Count: c.Count, + } + if c.CreatedBy != nil { + r.CreatedBy = c.CreatedBy.String() + } + if c.ExternalCreatedBy != nil { + r.ExternalCreatedBy = *c.ExternalCreatedBy + } + if len(c.Details) > 0 { + var details map[string]any + if err := json.Unmarshal(c.Details, &details); err == nil { + r.Details = details + } + } + return r +} + +type CatalogReportAnalysis struct { + ID string `json:"id,omitempty"` + ConfigID string `json:"configID,omitempty"` + ConfigName string `json:"configName,omitempty"` + ConfigType string `json:"configType,omitempty"` + Permalink string `json:"permalink,omitempty"` + Analyzer string `json:"analyzer"` + Message string `json:"message,omitempty"` + Summary string `json:"summary,omitempty"` + Status string `json:"status,omitempty"` + Severity string `json:"severity,omitempty"` + AnalysisType string `json:"analysisType,omitempty"` + Source string `json:"source,omitempty"` + FirstObserved string `json:"firstObserved,omitempty"` + LastObserved string `json:"lastObserved,omitempty"` +} + +func NewCatalogReportAnalysis(a models.ConfigAnalysis, configName, configType string) CatalogReportAnalysis { + r := CatalogReportAnalysis{ + ID: a.ID.String(), + ConfigID: a.ConfigID.String(), + ConfigName: configName, + ConfigType: configType, + Permalink: ConfigPermalink(a.ConfigID.String()), + Analyzer: a.Analyzer, + Message: a.Message, + Summary: a.Summary, + Status: a.Status, + Severity: string(a.Severity), + AnalysisType: string(a.AnalysisType), + Source: a.Source, + } + if a.FirstObserved != nil { + r.FirstObserved = a.FirstObserved.Format(time.RFC3339) + } + if a.LastObserved != nil { + r.LastObserved = a.LastObserved.Format(time.RFC3339) + } + return r +} + +type CatalogReportRelationship struct { + ConfigID string `json:"configID"` + RelatedID string `json:"relatedID"` + Relation string `json:"relation"` + Direction string `json:"direction,omitempty"` +} + +type CatalogReportTreeNode struct { + CatalogReportConfigItem `json:",inline"` + EdgeType string `json:"edgeType,omitempty"` // "parent", "child", "related", "target" + Relation string `json:"relation,omitempty"` + Children []CatalogReportTreeNode `json:"children,omitempty"` +} + +type CatalogReportConfigItem struct { + ID string `json:"id"` + Permalink string `json:"permalink,omitempty"` + Name string `json:"name"` + Type string `json:"type,omitempty"` + ConfigClass string `json:"configClass,omitempty"` + Status string `json:"status,omitempty"` + Health string `json:"health,omitempty"` + Description string `json:"description,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +func NewCatalogReportConfigItem(ci models.ConfigItem) CatalogReportConfigItem { + r := CatalogReportConfigItem{ + ID: ci.ID.String(), + Permalink: ConfigPermalink(ci.ID.String()), + Name: ci.GetName(), + ConfigClass: ci.ConfigClass, + Tags: ci.Tags, + } + if ci.Type != nil { + r.Type = *ci.Type + } + if ci.Status != nil { + r.Status = *ci.Status + } + if ci.Health != nil { + r.Health = string(*ci.Health) + } + if ci.Description != nil { + r.Description = *ci.Description + } + if ci.Labels != nil { + r.Labels = *ci.Labels + } + if !ci.CreatedAt.IsZero() { + r.CreatedAt = ci.CreatedAt.Format(time.RFC3339) + } + if ci.UpdatedAt != nil { + r.UpdatedAt = ci.UpdatedAt.Format(time.RFC3339) + } + return r +} + +type CatalogReportAccess struct { + ConfigID string `json:"configId,omitempty"` + ConfigName string `json:"configName,omitempty"` + ConfigType string `json:"configType,omitempty"` + Permalink string `json:"permalink,omitempty"` + UserID string `json:"userId"` + UserName string `json:"userName"` + Email string `json:"email"` + Role string `json:"role"` + UserType string `json:"userType"` + CreatedAt string `json:"createdAt"` + LastSignedInAt *string `json:"lastSignedInAt,omitempty"` + LastReviewedAt *string `json:"lastReviewedAt,omitempty"` +} + +func NewCatalogReportAccess(a models.ConfigAccessSummary) CatalogReportAccess { + r := CatalogReportAccess{ + ConfigID: a.ConfigID.String(), + ConfigName: a.ConfigName, + ConfigType: a.ConfigType, + Permalink: ConfigPermalink(a.ConfigID.String()), + UserID: a.ExternalUserID.String(), + UserName: a.User, + Email: a.Email, + Role: a.Role, + UserType: a.UserType, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + } + if a.LastSignedInAt != nil { + s := a.LastSignedInAt.Format(time.RFC3339) + r.LastSignedInAt = &s + } + if a.LastReviewedAt != nil { + s := a.LastReviewedAt.Format(time.RFC3339) + r.LastReviewedAt = &s + } + return r +} + +type CatalogReportAccessLog struct { + ConfigID string `json:"configId,omitempty"` + Permalink string `json:"permalink,omitempty"` + UserID string `json:"userId"` + UserName string `json:"userName"` + ConfigName string `json:"configName"` + ConfigType string `json:"configType"` + CreatedAt string `json:"createdAt"` + MFA bool `json:"mfa"` + Count int `json:"count"` + Properties map[string]string `json:"properties,omitempty"` +} + +func NewCatalogReportRelationship(configID string, rc models.ConfigRelationship) CatalogReportRelationship { + r := CatalogReportRelationship{ + ConfigID: rc.ConfigID, + RelatedID: rc.RelatedID, + Relation: rc.Relation, + } + if rc.ConfigID == configID { + r.Direction = "outgoing" + } else { + r.Direction = "incoming" + } + return r +} diff --git a/api/rbac_report.go b/api/rbac_report.go index 24aa3555f..72f870975 100644 --- a/api/rbac_report.go +++ b/api/rbac_report.go @@ -3,23 +3,29 @@ package api import ( "time" + "github.com/flanksource/duty/models" "github.com/flanksource/duty/types" ) type RBACReport struct { - Title string `json:"title"` - Query string `json:"query,omitempty"` - GeneratedAt time.Time `json:"generatedAt"` - Resources []RBACResource `json:"resources"` - Changelog []RBACChangeEntry `json:"changelog"` - Summary RBACSummary `json:"summary"` - Users []RBACUserReport `json:"users,omitempty"` + Title string `json:"title"` + Query string `json:"query,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` + Subject *models.ConfigItem `json:"subject,omitempty"` + Parents []models.ConfigItem `json:"parents,omitempty"` + Resources []RBACResource `json:"resources"` + Changelog []RBACChangeEntry `json:"changelog"` + Summary RBACSummary `json:"summary"` + Users []RBACUserReport `json:"users,omitempty"` } type RBACResource struct { ConfigID string `json:"configId"` ConfigName string `json:"configName"` ConfigType string `json:"configType"` + ConfigClass string `json:"configClass,omitempty"` + ParentID string `json:"parentId,omitempty"` + Path string `json:"path,omitempty"` Status string `json:"status,omitempty"` Health string `json:"health,omitempty"` Description string `json:"description,omitempty"` @@ -71,6 +77,8 @@ type RBACUserResource struct { ConfigID string `json:"configId"` ConfigName string `json:"configName"` ConfigType string `json:"configType"` + ConfigClass string `json:"configClass,omitempty"` + Path string `json:"path,omitempty"` Role string `json:"role"` RoleSource string `json:"roleSource"` CreatedAt time.Time `json:"createdAt"` diff --git a/api/scraper_report.go b/api/scraper_report.go new file mode 100644 index 000000000..829da3d3d --- /dev/null +++ b/api/scraper_report.go @@ -0,0 +1,17 @@ +package api + +import "github.com/flanksource/duty/query" + +type ScraperInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Description string `json:"description,omitempty"` + Source string `json:"source,omitempty"` + Types []string `json:"types"` + SpecHash string `json:"specHash"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` + GitOps *query.GitOpsSource `json:"gitops,omitempty"` +} diff --git a/api/v1/playbook_actions.go b/api/v1/playbook_actions.go index 15f2eea19..3de861949 100644 --- a/api/v1/playbook_actions.go +++ b/api/v1/playbook_actions.go @@ -670,12 +670,16 @@ type FacetPDFOptions struct { } type FacetOptions struct { - Connection string `json:"connection,omitempty" yaml:"connection,omitempty" template:"true"` - URL string `json:"url,omitempty" yaml:"url,omitempty" template:"true"` - PDFOptions *FacetPDFOptions `json:"pdfOptions,omitempty" yaml:"pdfOptions,omitempty"` - Header string `json:"header,omitempty" yaml:"header,omitempty" template:"true"` - Footer string `json:"footer,omitempty" yaml:"footer,omitempty" template:"true"` - TimestampURL string `json:"timestampUrl,omitempty" yaml:"timestampUrl,omitempty" template:"true"` + Connection string `json:"connection,omitempty" yaml:"connection,omitempty" template:"true"` + URL string `json:"url,omitempty" yaml:"url,omitempty" template:"true"` + FacetRenderOptions `json:",inline" yaml:",inline"` +} + +type FacetRenderOptions struct { + *FacetPDFOptions `json:",inline" yaml:",inline"` + Header string `json:"header,omitempty" yaml:"header,omitempty" template:"true"` + Footer string `json:"footer,omitempty" yaml:"footer,omitempty" template:"true"` + TimestampURL string `json:"timestampUrl,omitempty" yaml:"timestampUrl,omitempty" template:"true"` } // CatalogAction creates a config item in the catalog. diff --git a/application/render_facet.go b/application/render_facet.go index 59ba766fe..9df0553a8 100644 --- a/application/render_facet.go +++ b/application/render_facet.go @@ -1,116 +1,34 @@ package application import ( - "bytes" - "encoding/json" "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "strings" icapi "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/report" ) func RenderFacetHTML(app *icapi.Application) ([]byte, error) { - return renderWithFacet(app, "html") -} - -func RenderFacetPDF(app *icapi.Application) ([]byte, error) { - return renderWithFacet(app, "pdf") -} - -func renderWithFacet(app *icapi.Application, format string) ([]byte, error) { if app == nil { return nil, fmt.Errorf("application must not be nil") } - - facetBin, err := exec.LookPath("facet") - if err != nil { - return nil, fmt.Errorf("facet not found on PATH: install with 'npm install -g @flanksource/facet'") - } - - srcDir, err := facetSrcDir() - if err != nil { - return nil, fmt.Errorf("prepare facet src dir: %w", err) - } - - dataJSON, err := json.MarshalIndent(initSlices(app), "", " ") - if err != nil { - return nil, fmt.Errorf("marshal application: %w", err) - } - - dataFile, err := os.CreateTemp("", "facet-data-*.json") - if err != nil { - return nil, fmt.Errorf("create data temp file: %w", err) - } - defer os.Remove(dataFile.Name()) - - if _, err := dataFile.Write(dataJSON); err != nil { - return nil, fmt.Errorf("write data file: %w", err) - } - dataFile.Close() - - ext := format - if format == "html" { - ext = "html" - } - - outFile, err := os.CreateTemp("", "facet-output-*."+ext) - if err != nil { - return nil, fmt.Errorf("create output temp file: %w", err) - } - outFile.Close() - defer os.Remove(outFile.Name()) - - var stderr bytes.Buffer - cmd := exec.Command(facetBin, format, "Application.tsx", "-d", dataFile.Name(), "-o", outFile.Name()) - cmd.Dir = srcDir - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("facet %s failed: %w\n%s", format, err, stderr.String()) - } - - data, err := os.ReadFile(outFile.Name()) + result, err := report.RenderCLI(initSlices(app), "html", "Application.tsx") if err != nil { - entries, _ := os.ReadDir(srcDir) - names := make([]string, 0, len(entries)) - for _, e := range entries { - names = append(names, e.Name()) - } - return nil, fmt.Errorf("read facet output %s: %w (srcDir contains: %s)", outFile.Name(), err, strings.Join(names, ", ")) + return nil, err } - - return data, nil + return result.Data, nil } -// facetSrcDir returns a stable directory containing the embedded report TSX files. -// On first call it extracts the files; subsequent calls reuse the directory so that -// facet can cache its .facet/node_modules there across invocations. -func facetSrcDir() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - cacheDir = os.TempDir() - } - dir := filepath.Join(cacheDir, "incident-commander", "facet-report") - - if err := os.MkdirAll(dir, 0750); err != nil { - return "", fmt.Errorf("create cache dir: %w", err) +func RenderFacetPDF(app *icapi.Application) ([]byte, error) { + if app == nil { + return nil, fmt.Errorf("application must not be nil") } - - // Always (re)extract so embedded changes are picked up on binary upgrade. - if err := extractReportFiles(dir); err != nil { - return "", err + result, err := report.RenderCLI(initSlices(app), "pdf", "Application.tsx") + if err != nil { + return nil, err } - - return dir, nil + return result.Data, nil } -// initSlices returns a shallow copy of app with nil slices replaced by empty -// slices so the TSX renderer receives [] instead of null. func initSlices(app *icapi.Application) icapi.Application { out := *app if out.Incidents == nil { @@ -139,23 +57,3 @@ func initSlices(app *icapi.Application) icapi.Application { } return out } - -func extractReportFiles(destDir string) error { - return fs.WalkDir(report.FS, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == "." { - return nil - } - dest := filepath.Join(destDir, path) - if d.IsDir() { - return os.MkdirAll(dest, 0750) - } - data, err := report.FS.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(dest, data, 0600) - }) -} diff --git a/cmd/application.go b/cmd/application.go index 4cbe6909e..f7bd3cad9 100644 --- a/cmd/application.go +++ b/cmd/application.go @@ -17,6 +17,7 @@ import ( v1 "github.com/flanksource/incident-commander/api/v1" "github.com/flanksource/incident-commander/application" "github.com/flanksource/incident-commander/db" + "github.com/flanksource/incident-commander/report" ) var ApplicationCmd = &cobra.Command{ @@ -90,5 +91,6 @@ var ExportApplication = &cobra.Command{ func init() { ExportApplication.Flags().StringVarP(&exportFormat, "format", "f", "json", "Output format: json, html, pdf, facet-html, facet-pdf") ExportApplication.Flags().StringVarP(&exportOutfile, "out-file", "o", "", "Write output to file instead of stdout") + ExportApplication.Flags().StringVar(&report.SourceDir, "report-source", "", "Local directory or TSX file for report rendering (overrides embedded reports)") ApplicationCmd.AddCommand(ExportApplication) } diff --git a/cmd/auth_login.go b/cmd/auth_login.go index 10b54ddc2..401079594 100644 --- a/cmd/auth_login.go +++ b/cmd/auth_login.go @@ -25,14 +25,10 @@ var authLoginCmd = &cobra.Command{ RunE: runAuthLogin, } -var ( - loginServer string - loginPrintToken bool -) +var loginServer string func init() { authLoginCmd.Flags().StringVar(&loginServer, "server", "", "Mission Control server URL (required)") - authLoginCmd.Flags().BoolVar(&loginPrintToken, "print-token", false, "Print access and refresh tokens to stdout") _ = authLoginCmd.MarkFlagRequired("server") Auth.AddCommand(authLoginCmd) } @@ -49,14 +45,8 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("PKCE generation failed: %w", err) } - state, err := oidcclient.RandomBase64(16) - if err != nil { - return fmt.Errorf("state generation failed: %w", err) - } - nonce, err := oidcclient.RandomBase64(16) - if err != nil { - return fmt.Errorf("nonce generation failed: %w", err) - } + state := oidcclient.RandomBase64(16) + nonce := oidcclient.RandomBase64(16) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -115,7 +105,9 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error { ) fmt.Fprintf(cmd.OutOrStdout(), "Opening browser for login...\n%s\n\n", authURL) - openBrowser(authURL) + if err := openBrowser(authURL); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Failed to open browser: %v\nOpen the URL manually.\n\n", err) + } var code string select { @@ -140,46 +132,13 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to save tokens: %w", err) } - if err := saveContextFromLogin(serverURL, tokens.AccessToken); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to save context: %v\n", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "\nLogin successful!\n\n") - fmt.Fprintf(cmd.OutOrStdout(), "Tokens saved to: %s\n\n", tokenPath) - - if loginPrintToken { - fmt.Fprintf(cmd.OutOrStdout(), "Access token (expires %s):\n%s\n\n", tokens.ExpiresAt.Format("15:04:05"), tokens.AccessToken) - fmt.Fprintf(cmd.OutOrStdout(), "Refresh token:\n%s\n\n", tokens.RefreshToken) - fmt.Fprintf(cmd.OutOrStdout(), "curl -H 'Authorization: Bearer %s' %s/whoami\n\n", tokens.AccessToken, serverURL) - } else { - fmt.Fprintf(cmd.OutOrStdout(), "Use --print-token to print tokens to stdout.\n") - fmt.Fprintf(cmd.OutOrStdout(), "curl -H 'Authorization: Bearer ' %s/whoami\n\n", serverURL) - } + fmt.Fprintf(cmd.OutOrStdout(), "Tokens saved to: %s\n", tokenPath) + fmt.Fprintf(cmd.OutOrStdout(), "Access token expires: %s\n", tokens.ExpiresAt.Format(time.RFC3339)) return nil } -func saveContextFromLogin(serverURL, accessToken string) error { - cfg, err := LoadConfig() - if err != nil { - return err - } - name := ServerToContextName(serverURL) - existing := cfg.GetContext(name) - ctx := MCContext{ - Name: name, - Server: serverURL, - Token: accessToken, - } - if existing != nil { - ctx.DB = existing.DB - ctx.Properties = existing.Properties - } - cfg.SetContext(ctx) - cfg.CurrentContext = name - return SaveConfig(cfg) -} - func storeTokens(serverURL string, tokens *oidcclient.Tokens) (string, error) { dir, err := os.UserConfigDir() if err != nil { @@ -200,19 +159,13 @@ func storeTokens(serverURL string, tokens *oidcclient.Tokens) (string, error) { return path, os.WriteFile(path, data, 0600) } -func openBrowser(url string) { - var cmd string - var args []string +func openBrowser(url string) error { switch runtime.GOOS { case "darwin": - cmd = "open" - args = []string{url} + return exec.Command("open", url).Start() case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} + return exec.Command("cmd", "/c", "start", "", url).Start() default: - cmd = "xdg-open" - args = []string{url} + return exec.Command("xdg-open", url).Start() } - _ = exec.Command(cmd, args...).Start() } diff --git a/cmd/catalog.go b/cmd/catalog.go index cb6fc587d..58c83c3c5 100644 --- a/cmd/catalog.go +++ b/cmd/catalog.go @@ -1,9 +1,7 @@ package cmd import ( - "fmt" "os" - "strconv" "strings" "time" @@ -26,53 +24,10 @@ var Catalog = &cobra.Command{ func parseQuery(args []string) query.SearchResourcesRequest { logger.Infof("Search query %v", args) - request := query.SearchResourcesRequest{ - Limit: 5, + return query.SearchResourcesRequest{ + Limit: 100, + Configs: []types.ResourceSelector{{Cache: "no-cache", Search: strings.Join(args, " ")}}, } - tags := make(map[string]string) - selector := types.ResourceSelector{ - Cache: "no-cache", - } - for _, arg := range args { - parts := strings.Split(arg, "=") - if len(parts) != 2 { - logger.Warnf("Invalid param: %s", arg) - continue - } - - switch parts[0] { - case "limit": - l, _ := strconv.Atoi(parts[1]) - request.Limit = l - case "search": - selector.Search = parts[1] - case "scope": - selector.Scope = parts[1] - case "type": - selector.Types = append(selector.Types, parts[1]) - case "name": - selector.Name = parts[1] - case "namespace": - selector.Namespace = parts[1] - case "id": - selector.ID = parts[1] - case "status": - selector.Statuses = append(selector.Statuses, parts[1]) - default: - tags[parts[0]] = parts[1] - } - } - - for k, v := range tags { - if strings.HasPrefix(k, "@") { - selector.TagSelector += fmt.Sprintf(" %s=%s", k[1:], v) - } else { - selector.LabelSelector += fmt.Sprintf(" %s=%s", k, v) - } - } - request.Configs = []types.ResourceSelector{selector} - - return request } var Query = &cobra.Command{ @@ -95,23 +50,18 @@ var Query = &cobra.Command{ start := time.Now() var response *query.SearchResourcesResponse - ctx.DB().Begin() response, err = query.SearchResources(ctx, req) if err != nil { logger.Fatalf(err.Error()) os.Exit(1) } - ctx.DB().Commit() for time.Since(start) < catalogWaitFor { if len(response.Configs) > 0 || len(response.Components) > 0 || len(response.Checks) > 0 { break } - ctx.DB().Begin() response, err = query.SearchResources(ctx, req) - ctx.DB().Commit() - if err != nil { logger.Fatalf(err.Error()) os.Exit(1) @@ -123,17 +73,12 @@ var Query = &cobra.Command{ if catalogOutfile != "" { logger.Infof("Writing output to %s", catalogOutfile) - if err := clicky.FormatToFile(*response, clicky.FormatOptions{}, catalogOutfile); err != nil { + if err := clicky.FormatToFile(*response, clicky.Flags.FormatOptions, catalogOutfile); err != nil { logger.Fatalf(err.Error()) os.Exit(1) } } else { - out, err := clicky.Format(*response) - if err != nil { - logger.Fatalf(err.Error()) - os.Exit(1) - } - fmt.Println(out) + clicky.MustPrint(*response, clicky.Flags.FormatOptions) } }, } @@ -176,7 +121,13 @@ var Mock = &cobra.Command{ func init() { Query.Flags().StringVarP(&catalogOutfile, "out-file", "o", "", "Write catalog output to a file instead of stdout") Query.Flags().DurationVarP(&catalogWaitFor, "wait", "w", 60*time.Second, "Wait for this long for resources to be discovered") + clicky.BindAllFlags(Query.PersistentFlags(), "format") + + Get.Flags().StringVar(&catalogGetSince, "since", "7d", "Time range for changes (supports d/w/y e.g. 7d, 2w, 30d)") + clicky.BindAllFlags(Get.PersistentFlags(), "format") + Catalog.AddCommand(Query) Catalog.AddCommand(Mock) + Catalog.AddCommand(Get) Root.AddCommand(Catalog) } diff --git a/cmd/catalog_get.go b/cmd/catalog_get.go new file mode 100644 index 000000000..814821004 --- /dev/null +++ b/cmd/catalog_get.go @@ -0,0 +1,504 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/duration" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + "github.com/flanksource/duty" + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + "github.com/google/uuid" + "github.com/samber/lo" + "github.com/spf13/cobra" +) + +var catalogGetSince string + +var Get = &cobra.Command{ + Use: "get ", + Short: "Get a full config item with relationships, insights, changes, access, and playbook runs", + Args: cobra.MinimumNArgs(1), + PersistentPreRun: PreRun, + RunE: func(cmd *cobra.Command, args []string) error { + logger.UseSlog() + if err := properties.LoadFile("mission-control.properties"); err != nil { + logger.Errorf(err.Error()) + } + ctx, stop, err := duty.Start("mission-control", duty.ClientOnly) + if err != nil { + return err + } + defer stop() + + result, err := runCatalogGet(ctx, args, catalogGetSince) + if err != nil { + return err + } + + clicky.MustPrint(result, clicky.Flags.FormatOptions) + return nil + }, +} + +func resolveConfigID(ctx context.Context, args []string) (*models.ConfigItem, error) { + configs, err := resolveConfigs(ctx, args, 2) + if err != nil { + return nil, err + } + if len(configs) > 1 { + return nil, fmt.Errorf("query matched multiple configs, expected exactly one") + } + return &configs[0], nil +} + +func resolveConfigs(ctx context.Context, args []string, limit int) ([]models.ConfigItem, error) { + if id, err := uuid.Parse(args[0]); err == nil { + config, err := query.GetCachedConfig(ctx, id.String()) + if err != nil { + return nil, fmt.Errorf("failed to get config %s: %w", id, err) + } + if config == nil { + return nil, fmt.Errorf("config item %s not found", id) + } + return []models.ConfigItem{*config}, nil + } + + req := parseQuery(args) + if limit > 0 { + req.Limit = limit + } + response, err := query.SearchResources(ctx, req) + if err != nil { + return nil, fmt.Errorf("search failed: %w", err) + } + if len(response.Configs) == 0 { + return nil, fmt.Errorf("no config found matching query") + } + + var configs []models.ConfigItem + for _, c := range response.Configs { + config, err := query.GetCachedConfig(ctx, c.ID) + if err != nil { + return nil, fmt.Errorf("failed to get config %s: %w", c.ID, err) + } + if config != nil { + configs = append(configs, *config) + } + } + if len(configs) == 0 { + return nil, fmt.Errorf("no config found matching query") + } + return configs, nil +} + +type CatalogGetResult struct { + models.ConfigItem `json:",inline"` + LastScrapedTime *time.Time `json:"last_scraped_time,omitempty"` + Related []query.RelatedConfig `json:"related,omitempty"` + Insights []models.ConfigAnalysis `json:"insights,omitempty"` + Changes []models.ConfigChange `json:"changes,omitempty"` + Access []models.ConfigAccessSummary `json:"access,omitempty"` + PlaybookRuns []models.PlaybookRun `json:"playbook_runs,omitempty"` + + since string +} + +func runCatalogGet(ctx context.Context, args []string, sinceStr string) (*CatalogGetResult, error) { + config, err := resolveConfigID(ctx, args) + if err != nil { + return nil, err + } + + since, err := duration.ParseDuration(sinceStr) + if err != nil { + return nil, fmt.Errorf("invalid --since value %q: %w", sinceStr, err) + } + sinceTime := time.Now().Add(-time.Duration(since)) + id := config.ID + + result := &CatalogGetResult{ConfigItem: *config, since: sinceStr} + + var lastScraped models.ConfigItemLastScrapedTime + if err := ctx.DB().Where("config_id = ?", id).First(&lastScraped).Error; err == nil { + result.LastScrapedTime = lastScraped.LastScrapedTime + } + + result.Related, err = query.GetRelatedConfigs(ctx, query.RelationQuery{ID: id}) + if err != nil { + return nil, fmt.Errorf("failed to get related configs: %w", err) + } + + changesResp, err := query.FindCatalogChanges(ctx, query.CatalogChangesSearchRequest{ + BaseCatalogSearch: query.BaseCatalogSearch{ + CatalogID: id.String(), + FromTime: &sinceTime, + PageSize: 50, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get changes: %w", err) + } + result.Changes = make([]models.ConfigChange, len(changesResp.Changes)) + for i, c := range changesResp.Changes { + result.Changes[i] = models.ConfigChange{ + ID: c.ID, + ConfigID: c.ConfigID, + ChangeType: c.ChangeType, + Severity: models.Severity(c.Severity), + Source: c.Source, + Summary: c.Summary, + Count: c.Count, + CreatedAt: c.CreatedAt, + CreatedBy: c.CreatedBy, + } + } + + insightsResp, err := query.FindCatalogInsights(ctx, query.CatalogInsightsSearchRequest{ + BaseCatalogSearch: query.BaseCatalogSearch{ + CatalogID: id.String(), + PageSize: 50, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get insights: %w", err) + } + result.Insights = insightsResp.Insights + + result.Access, err = query.FindConfigAccessByConfigIDs(ctx, []uuid.UUID{id}) + if err != nil { + return nil, fmt.Errorf("failed to get access: %w", err) + } + + if err := ctx.DB().Where("config_id = ? AND created_at >= ?", id, sinceTime). + Order("created_at DESC").Limit(50). + Find(&result.PlaybookRuns).Error; err != nil { + return nil, fmt.Errorf("failed to get playbook runs: %w", err) + } + + return result, nil +} + +func (r CatalogGetResult) Pretty() api.Text { + t := r.ConfigItem.Pretty() + t = t.NewLine().Append(buildDetailsSection(r)) + + if r.ConfigItem.Config != nil && *r.ConfigItem.Config != "" { + t = t.NewLine().Append(clicky.Collapsed("Config", configCodeBlock(*r.ConfigItem.Config))) + } + + if len(r.Related) > 0 { + tree := buildRelationshipTree(&r.ConfigItem, r.Related) + t = t.NewLine().Append(clicky.Collapsed( + fmt.Sprintf("Relationships (%d)", len(r.Related)), + tree, + )) + } + + sinceLabel := r.since + + if len(r.Insights) > 0 { + rows := lo.Map(r.Insights, func(a models.ConfigAnalysis, _ int) analysisRow { + return analysisRow{a} + }) + t = t.NewLine().Append(clicky.Collapsed( + fmt.Sprintf("Open Insights (%d)", len(rows)), + api.NewTableFrom(rows), + )) + } + + if len(r.Changes) > 0 { + t = t.NewLine().Append(clicky.Collapsed( + fmt.Sprintf("Changes since %s (%d)", sinceLabel, len(r.Changes)), + api.NewTableFrom(r.Changes), + )) + } + + if len(r.Access) > 0 { + rows := lo.Map(r.Access, func(a models.ConfigAccessSummary, _ int) accessRow { + return accessRow{a} + }) + t = t.NewLine().Append(clicky.Collapsed( + fmt.Sprintf("Access (%d)", len(rows)), + api.NewTableFrom(rows), + )) + } + + if len(r.PlaybookRuns) > 0 { + rows := lo.Map(r.PlaybookRuns, func(p models.PlaybookRun, _ int) playbookRunRow { + return playbookRunRow{p} + }) + t = t.NewLine().Append(clicky.Collapsed( + fmt.Sprintf("Playbook Runs since %s (%d)", sinceLabel, len(rows)), + api.NewTableFrom(rows), + )) + } + + return t +} + +func buildDetailsSection(r CatalogGetResult) api.DescriptionList { + c := &r.ConfigItem + items := []api.KeyValuePair{ + {Key: "ID", Value: c.ID.String()}, + {Key: "Type", Value: lo.FromPtrOr(c.Type, "-")}, + {Key: "Class", Value: c.ConfigClass}, + } + + if c.Health != nil { + items = append(items, api.KeyValuePair{Key: "Health", Value: c.Health.Pretty()}) + } + if c.Status != nil { + items = append(items, api.KeyValuePair{Key: "Status", Value: *c.Status}) + } + if c.Description != nil && *c.Description != "" { + items = append(items, api.KeyValuePair{Key: "Description", Value: *c.Description}) + } + if c.Source != nil && *c.Source != "" { + items = append(items, api.KeyValuePair{Key: "Source", Value: *c.Source}) + } + if c.ScraperID != nil && *c.ScraperID != "" { + items = append(items, api.KeyValuePair{Key: "Scraper", Value: *c.ScraperID}) + } + if r.LastScrapedTime != nil { + items = append(items, api.KeyValuePair{Key: "Last Scraped", Value: api.Human(time.Since(*r.LastScrapedTime), "text-gray-600")}) + } + if c.AgentID != uuid.Nil { + items = append(items, api.KeyValuePair{Key: "Agent", Value: c.AgentID.String()}) + } + if c.Ready { + items = append(items, api.KeyValuePair{Key: "Ready", Value: "true"}) + } + if c.Path != "" { + items = append(items, api.KeyValuePair{Key: "Path", Value: c.Path}) + } + if c.ParentID != nil { + items = append(items, api.KeyValuePair{Key: "Parent", Value: c.ParentID.String()}) + } + if len(c.ExternalID) > 0 { + items = append(items, api.KeyValuePair{Key: "External ID", Value: strings.Join(c.ExternalID, ", ")}) + } + + if c.CostTotal30d > 0 { + items = append(items, api.KeyValuePair{Key: "Cost (30d)", Value: fmt.Sprintf("$%.2f", c.CostTotal30d)}) + } + + if !c.CreatedAt.IsZero() { + items = append(items, api.KeyValuePair{Key: "Created", Value: api.Human(time.Since(c.CreatedAt), "text-gray-600")}) + } + if c.UpdatedAt != nil { + items = append(items, api.KeyValuePair{Key: "Updated", Value: api.Human(time.Since(*c.UpdatedAt), "text-gray-600")}) + } + if c.DeletedAt != nil { + items = append(items, api.KeyValuePair{Key: "Deleted", Value: api.Human(time.Since(*c.DeletedAt), "text-red-600")}) + if c.DeleteReason != "" { + items = append(items, api.KeyValuePair{Key: "Delete Reason", Value: c.DeleteReason}) + } + } + + if c.Labels != nil && len(*c.Labels) > 0 { + items = append(items, api.KeyValuePair{Key: "Labels", Value: clicky.Map(*c.Labels, "text-xs")}) + } + if len(c.Tags) > 0 { + items = append(items, api.KeyValuePair{Key: "Tags", Value: clicky.Map(c.Tags, "text-xs")}) + } + + if c.Properties != nil { + for _, p := range *c.Properties { + val := p.Text + if val == "" && p.Value != nil { + val = fmt.Sprintf("%d", *p.Value) + } + if val == "" { + continue + } + label := p.Label + if label == "" { + label = p.Name + } + items = append(items, api.KeyValuePair{Key: label, Value: val}) + } + } + + return api.DescriptionList{Items: items} +} + +func configCodeBlock(configJSON string) api.Code { + var parsed any + if err := json.Unmarshal([]byte(configJSON), &parsed); err == nil { + if pretty, err := json.MarshalIndent(parsed, "", " "); err == nil { + configJSON = string(pretty) + } + } + return api.CodeBlock("json", configJSON) +} + +// relatedConfigNode implements api.TreeNode for relationship tree rendering. +type relatedConfigNode struct { + label api.Text + children []api.TreeNode +} + +func (n relatedConfigNode) Pretty() api.Text { return n.label } +func (n relatedConfigNode) GetChildren() []api.TreeNode { return n.children } + +func buildRelationshipTree(config *models.ConfigItem, related []query.RelatedConfig) api.TextTree { + // Index all nodes by ID + nodes := make(map[string]*relatedConfigNode) + rootID := config.ID.String() + nodes[rootID] = &relatedConfigNode{label: config.Pretty()} + + for _, rc := range related { + nodes[rc.ID.String()] = &relatedConfigNode{label: relatedConfigLabel(rc)} + } + + // Build parent-child edges from Path (format: "grandparent.parent.child") + for _, rc := range related { + parentID := parentIDFromPath(rc.Path, rc.ID.String()) + if parentID == "" { + parentID = rootID + } + if parent, ok := nodes[parentID]; ok { + parent.children = append(parent.children, nodes[rc.ID.String()]) + } else { + // parent not in result set, attach to root + nodes[rootID].children = append(nodes[rootID].children, nodes[rc.ID.String()]) + } + } + + return api.NewTree[api.TreeNode](nodes[rootID]) +} + +// parentIDFromPath extracts the parent ID from a dot-separated path. +// For path "a.b.c" and id "c", returns "b". +func parentIDFromPath(path, id string) string { + if path == "" { + return "" + } + segments := strings.Split(path, ".") + for i, seg := range segments { + if seg == id && i > 0 { + return segments[i-1] + } + } + return "" +} + +func relatedConfigLabel(rc query.RelatedConfig) api.Text { + t := clicky.Text("") + if rc.Health != nil { + t = t.Add(rc.Health.Pretty()).AddText(" ") + } + t = t.AddText(rc.Name, "font-bold") + t = t.AddText(" ").Add(clicky.Text(rc.Type, "text-xs text-gray-600").Wrap("(", ")")) + if rc.Status != nil && *rc.Status != "" { + t = t.AddText(" ").Add(clicky.Text(*rc.Status, "text-xs text-gray-500")) + } + return t +} + +func formatDuration(d time.Duration) string { + if d >= 24*time.Hour { + days := int(d.Hours() / 24) + return fmt.Sprintf("%dd", days) + } + return d.String() +} + +// analysisRow wraps ConfigAnalysis for TableProvider. +type analysisRow struct { + models.ConfigAnalysis +} + +func (r analysisRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("Severity").Build(), + api.Column("Type").Build(), + api.Column("Analyzer").Build(), + api.Column("Summary").Build(), + api.Column("Status").Build(), + } +} + +func (r analysisRow) Row() map[string]any { + return map[string]any{ + "Severity": r.ConfigAnalysis.Severity.Pretty(), + "Type": r.ConfigAnalysis.AnalysisType.Pretty(), + "Analyzer": clicky.Text(r.Analyzer, "font-bold"), + "Summary": clicky.Text(r.Summary, "text-gray-700"), + "Status": clicky.Text(r.ConfigAnalysis.Status, "text-blue-600"), + } +} + +// accessRow wraps ConfigAccessSummary for TableProvider. +type accessRow struct { + models.ConfigAccessSummary +} + +func (r accessRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("User").Build(), + api.Column("Role").Build(), + api.Column("Email").Build(), + api.Column("UserType").Label("Type").Build(), + api.Column("LastSignedIn").Label("Last Signed In").Build(), + } +} + +func (r accessRow) Row() map[string]any { + lastSignedIn := clicky.Text("-", "text-gray-400") + if r.LastSignedInAt != nil { + lastSignedIn = api.Human(time.Since(*r.LastSignedInAt), "text-gray-600") + } + return map[string]any{ + "User": clicky.Text(r.User, "font-bold"), + "Role": clicky.Text(r.Role), + "Email": clicky.Text(r.Email, "text-gray-600"), + "UserType": clicky.Text(r.UserType, "text-gray-500"), + "LastSignedIn": lastSignedIn, + } +} + +// playbookRunRow wraps PlaybookRun for TableProvider. +type playbookRunRow struct { + models.PlaybookRun +} + +func (r playbookRunRow) Columns() []api.ColumnDef { + return []api.ColumnDef{ + api.Column("Status").Build(), + api.Column("ID").Build(), + api.Column("Duration").Build(), + api.Column("CreatedAt").Label("Created").Build(), + api.Column("Error").Build(), + } +} + +func (r playbookRunRow) Row() map[string]any { + row := map[string]any{ + "Status": r.PlaybookRun.Status.Pretty(), + "ID": clicky.Text(r.PlaybookRun.ID.String()[:8], "font-mono text-xs"), + "Duration": clicky.Text("-", "text-gray-400"), + "CreatedAt": api.Human(time.Since(r.CreatedAt), "text-gray-600"), + "Error": clicky.Text(""), + } + + if r.StartTime != nil && r.EndTime != nil { + row["Duration"] = api.Human(r.EndTime.Sub(*r.StartTime), "text-gray-600") + } else if r.StartTime != nil { + row["Duration"] = api.Human(time.Since(*r.StartTime), "text-blue-600") + } + + if r.PlaybookRun.Error != nil && *r.PlaybookRun.Error != "" { + row["Error"] = clicky.Text(*r.PlaybookRun.Error, "text-red-600 text-sm") + } + + return row +} diff --git a/cmd/catalog_get_test.go b/cmd/catalog_get_test.go new file mode 100644 index 000000000..a59533cd7 --- /dev/null +++ b/cmd/catalog_get_test.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "time" + + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + "github.com/flanksource/duty/types" + "github.com/google/uuid" + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/samber/lo" +) + +var _ = ginkgo.Describe("buildCatalogGetOutput", func() { + configID := uuid.New() + since := 7 * 24 * time.Hour + + makeConfig := func() *models.ConfigItem { + return &models.ConfigItem{ + ID: configID, + Name: lo.ToPtr("my-deployment"), + Type: lo.ToPtr("Kubernetes::Deployment"), + ConfigClass: "Deployment", + Health: lo.ToPtr(models.HealthHealthy), + } + } + + ginkgo.It("renders all sections when data is present", func() { + configJSON := `{"replicas": 3}` + c := makeConfig() + c.Config = &configJSON + r := CatalogGetResult{ + ConfigItem: *c, + since: since.String(), + Related: []query.RelatedConfig{ + {ID: uuid.New(), Name: "my-pod", Type: "Kubernetes::Pod", Relation: "outgoing", Health: lo.ToPtr(models.HealthHealthy)}, + }, + Insights: []models.ConfigAnalysis{ + {ID: uuid.New(), ConfigID: configID, Analyzer: "test-analyzer", AnalysisType: models.AnalysisTypeSecurity, Severity: models.SeverityHigh, Status: "open", Summary: "test finding"}, + }, + Changes: []models.ConfigChange{ + {ID: uuid.NewString(), ConfigID: configID.String(), ChangeType: "diff", Severity: models.SeverityInfo, Summary: "field changed", CreatedAt: lo.ToPtr(time.Now())}, + }, + Access: []models.ConfigAccessSummary{ + {ConfigID: configID, User: "alice", Role: "admin", Email: "alice@example.com", UserType: "user"}, + }, + PlaybookRuns: []models.PlaybookRun{ + {ID: uuid.New(), ConfigID: &configID, Status: models.PlaybookRunStatusCompleted, CreatedAt: time.Now()}, + }, + } + + out := r.Pretty().String() + Expect(out).To(ContainSubstring("my-deployment")) + Expect(out).To(ContainSubstring("Relationships")) + Expect(out).To(ContainSubstring("Open Insights")) + Expect(out).To(ContainSubstring("Changes since")) + Expect(out).To(ContainSubstring("Access")) + Expect(out).To(ContainSubstring("Playbook Runs")) + }) + + ginkgo.It("omits empty sections but always includes header and details", func() { + r := CatalogGetResult{ConfigItem: *makeConfig(), since: since.String()} + out := r.Pretty().String() + Expect(out).To(ContainSubstring("my-deployment")) + Expect(out).NotTo(ContainSubstring("Relationships")) + Expect(out).NotTo(ContainSubstring("Open Insights")) + }) + + ginkgo.It("includes config code block when config JSON is present", func() { + configJSON := `{"foo":"bar"}` + c := makeConfig() + c.Config = &configJSON + r := CatalogGetResult{ConfigItem: *c, since: since.String()} + out := r.Pretty().String() + Expect(out).To(ContainSubstring("Config")) + Expect(out).To(ContainSubstring("foo")) + }) +}) + +var _ = ginkgo.Describe("buildDetailsSection", func() { + ginkgo.It("includes scraper and last scraped time", func() { + scraperID := "scraper-123" + lastScraped := time.Now().Add(-10 * time.Minute) + r := CatalogGetResult{ + ConfigItem: models.ConfigItem{ + ID: uuid.New(), + Name: lo.ToPtr("test"), + Type: lo.ToPtr("Kubernetes::Pod"), + ScraperID: &scraperID, + }, + LastScrapedTime: &lastScraped, + } + dl := buildDetailsSection(r) + + var foundScraper, foundLastScraped bool + for _, item := range dl.Items { + if item.Key == "Scraper" { + foundScraper = true + Expect(item.Value).To(Equal("scraper-123")) + } + if item.Key == "Last Scraped" { + foundLastScraped = true + } + } + Expect(foundScraper).To(BeTrue()) + Expect(foundLastScraped).To(BeTrue()) + }) + + ginkgo.It("includes properties", func() { + val := int64(42) + r := CatalogGetResult{ + ConfigItem: models.ConfigItem{ + ID: uuid.New(), + Name: lo.ToPtr("test"), + Type: lo.ToPtr("AWS::EC2::Instance"), + Properties: &types.Properties{ + {Label: "Instance Type", Text: "t3.micro"}, + {Name: "cpu_count", Value: &val}, + }, + }, + } + dl := buildDetailsSection(r) + + var foundInstanceType, foundCPU bool + for _, item := range dl.Items { + if item.Key == "Instance Type" { + foundInstanceType = true + Expect(item.Value).To(Equal("t3.micro")) + } + if item.Key == "cpu_count" { + foundCPU = true + Expect(item.Value).To(Equal("42")) + } + } + Expect(foundInstanceType).To(BeTrue()) + Expect(foundCPU).To(BeTrue()) + }) +}) + +var _ = ginkgo.Describe("buildRelationshipTree", func() { + ginkgo.It("builds parent-child tree from paths", func() { + rootID := uuid.New() + childID := uuid.New() + grandchildID := uuid.New() + config := &models.ConfigItem{ + ID: rootID, + Name: lo.ToPtr("root"), + Type: lo.ToPtr("Kubernetes::Deployment"), + } + related := []query.RelatedConfig{ + {ID: childID, Name: "child", Type: "Kubernetes::Pod", Path: rootID.String() + "." + childID.String()}, + {ID: grandchildID, Name: "grandchild", Type: "Kubernetes::Container", Path: rootID.String() + "." + childID.String() + "." + grandchildID.String()}, + } + tree := buildRelationshipTree(config, related) + // root has 1 child, that child has 1 grandchild + Expect(tree.Children).To(HaveLen(1)) + Expect(tree.Children[0].Children).To(HaveLen(1)) + }) + + ginkgo.It("attaches orphans to root", func() { + rootID := uuid.New() + orphanID := uuid.New() + config := &models.ConfigItem{ + ID: rootID, + Name: lo.ToPtr("root"), + Type: lo.ToPtr("Kubernetes::Deployment"), + } + related := []query.RelatedConfig{ + {ID: orphanID, Name: "orphan", Type: "Kubernetes::Pod", Path: ""}, + } + tree := buildRelationshipTree(config, related) + Expect(tree.Children).To(HaveLen(1)) + }) +}) + +var _ = ginkgo.Describe("parentIDFromPath", func() { + for _, tt := range []struct { + name string + path string + id string + expected string + }{ + {"middle of path", "a.b.c", "c", "b"}, + {"root child", "a.b", "b", "a"}, + {"first element", "a.b", "a", ""}, + {"empty path", "", "a", ""}, + {"not in path", "a.b.c", "d", ""}, + } { + ginkgo.It(tt.name, func() { + Expect(parentIDFromPath(tt.path, tt.id)).To(Equal(tt.expected)) + }) + } +}) + +var _ = ginkgo.Describe("configCodeBlock", func() { + ginkgo.It("pretty prints JSON", func() { + code := configCodeBlock(`{"a":1,"b":"c"}`) + Expect(code.String()).To(ContainSubstring("\"a\": 1")) + }) +}) + +var _ = ginkgo.Describe("formatDuration", func() { + for _, tt := range []struct { + name string + input time.Duration + expected string + }{ + {"7 days", 7 * 24 * time.Hour, "7d"}, + {"1 day", 24 * time.Hour, "1d"}, + {"30 days", 30 * 24 * time.Hour, "30d"}, + {"sub-day", 6 * time.Hour, "6h0m0s"}, + } { + ginkgo.It(tt.name, func() { + Expect(formatDuration(tt.input)).To(Equal(tt.expected)) + }) + } +}) + +var _ = ginkgo.Describe("TableProvider wrappers", func() { + ginkgo.It("analysisRow returns correct columns", func() { + r := analysisRow{models.ConfigAnalysis{Analyzer: "test", Severity: models.SeverityHigh}} + Expect(r.Columns()).To(HaveLen(5)) + row := r.Row() + Expect(row).To(HaveKey("Severity")) + Expect(row).To(HaveKey("Analyzer")) + }) + + ginkgo.It("accessRow handles nil LastSignedInAt", func() { + r := accessRow{models.ConfigAccessSummary{User: "bob", Role: "viewer"}} + row := r.Row() + Expect(row).To(HaveKey("LastSignedIn")) + }) + + ginkgo.It("playbookRunRow computes duration", func() { + start := time.Now().Add(-5 * time.Minute) + end := time.Now() + r := playbookRunRow{models.PlaybookRun{ + ID: uuid.New(), + Status: models.PlaybookRunStatusCompleted, + CreatedAt: start, + StartTime: &start, + EndTime: &end, + }} + row := r.Row() + Expect(row).To(HaveKey("Duration")) + Expect(row).To(HaveKey("Status")) + }) +}) diff --git a/cmd/catalog_report.go b/cmd/catalog_report.go new file mode 100644 index 000000000..02bd10e28 --- /dev/null +++ b/cmd/catalog_report.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/flanksource/commons/duration" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + "github.com/flanksource/duty" + "github.com/flanksource/duty/shutdown" + "github.com/spf13/cobra" + + "github.com/flanksource/incident-commander/api" + "github.com/flanksource/incident-commander/report" + "github.com/flanksource/incident-commander/report/catalog" +) + +var ( + catalogReportFormat string + catalogReportOutFile string + catalogReportSince string + catalogReportTitle string + catalogReportSettings string + + catalogReportChanges bool + catalogReportInsights bool + catalogReportRelationships bool + catalogReportAccess bool + catalogReportAccessLogs bool + catalogReportConfigJSON bool + catalogReportRecursive bool + catalogReportGroupBy string + catalogReportChangeArtifacts bool + catalogReportAudit bool + catalogReportLimit int + catalogReportMaxItems int + catalogReportMaxChanges int +) + +var CatalogReportCmd = &cobra.Command{ + Use: "report ", + Short: "Generate a catalog report for a config item", + Long: `Generate a PDF/HTML report for a config item including changes, insights, +relationships, RBAC access, and access logs. + +Examples: + # By config ID + catalog report 018f4e6a-1234-5678-9abc-def012345678 + + # By query + catalog report type=Kubernetes::Namespace name=default + + # HTML output + catalog report 018f4e6a-... --format facet-html -o report.html + + # JSON with config body included + catalog report 018f4e6a-... --format json --config-json`, + Args: cobra.MinimumNArgs(1), + PersistentPreRun: PreRun, + RunE: func(cmd *cobra.Command, args []string) error { + logger.UseSlog() + if err := properties.LoadFile("mission-control.properties"); err != nil { + logger.Errorf(err.Error()) + } + + ctx, stop, err := duty.Start("mission-control", duty.ClientOnly) + if err != nil { + return err + } + shutdown.AddHookWithPriority("database", shutdown.PriorityCritical, stop) + shutdown.WaitForSignal() + + opts, err := buildCatalogReportOptions() + if err != nil { + return err + } + + queryArgs := args + if opts.Settings != nil { + if fq := opts.Settings.FilterQuery(); fq != "" { + queryArgs = append(queryArgs, fq) + } + } + + configs, err := resolveConfigs(ctx, queryArgs, catalogReportLimit) + if err != nil { + return err + } + + result, err := catalog.Export(ctx, configs, opts, catalogReportFormat) + if err != nil { + shutdown.ShutdownAndExit(1, err.Error()) + return err + } + + out := catalogReportOutFile + if out == "" { + out = "stdout" + } + + details := fmt.Sprintf("Rendering catalog report to %s (%s) %dKB", out, catalogReportFormat, len(result.Data)/1024) + if opts.Settings != nil { + details += fmt.Sprintf(" settings=%s\n%s", result.Settings, opts.Settings.Pretty().ANSI()) + } + if result.SrcDir != "" { + details += fmt.Sprintf(" dir=%s", result.SrcDir) + } + if result.Entry != "" { + details += fmt.Sprintf(" entry=%s", result.Entry) + } + if result.DataFile != "" { + details += fmt.Sprintf(" data=%s", result.DataFile) + } + logger.Infof(details) + + if catalogReportOutFile != "" { + if err := os.WriteFile(catalogReportOutFile, result.Data, 0600); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + } else { + fmt.Print(string(result.Data)) + } + + return nil + }, +} + +func buildCatalogReportOptions() (catalog.Options, error) { + opts := catalog.Options{ + Title: catalogReportTitle, + Recursive: catalogReportRecursive, + GroupBy: catalogReportGroupBy, + ChangeArtifacts: catalogReportChangeArtifacts, + Audit: catalogReportAudit, + Limit: catalogReportLimit, + MaxItems: catalogReportMaxItems, + MaxChanges: catalogReportMaxChanges, + Sections: api.CatalogReportSections{ + Changes: catalogReportChanges, + Insights: catalogReportInsights, + Relationships: catalogReportRelationships, + Access: catalogReportAccess, + AccessLogs: catalogReportAccessLogs, + ConfigJSON: catalogReportConfigJSON, + }, + } + + if catalogReportSince != "" { + d, err := duration.ParseDuration(catalogReportSince) + if err != nil { + return catalog.Options{}, fmt.Errorf("invalid --since: %w", err) + } + opts.Since = time.Duration(d) + } + + settings, settingsSource, err := catalog.ResolveSettings(catalogReportSettings) + if err != nil { + return catalog.Options{}, fmt.Errorf("failed to load settings: %w", err) + } + opts.Settings = settings + opts.SettingsPath = settingsSource + + return opts, nil +} + +func init() { + CatalogReportCmd.Flags().StringVarP(&catalogReportFormat, "format", "f", "facet-pdf", "Output format: json, facet-html, facet-pdf") + CatalogReportCmd.Flags().StringVarP(&catalogReportOutFile, "out-file", "o", "", "Write output to file instead of stdout") + CatalogReportCmd.Flags().StringVar(&catalogReportSince, "since", "30d", "Time range for changes and access logs (supports d/w/y e.g. 7d, 2w, 30d)") + CatalogReportCmd.Flags().StringVar(&catalogReportTitle, "title", "", "Report title (default auto-generated)") + CatalogReportCmd.Flags().StringVar(&report.SourceDir, "report-source", "", "Local directory or TSX file for report rendering (overrides embedded reports)") + + CatalogReportCmd.Flags().BoolVar(&catalogReportRecursive, "recursive", false, "Include all descendant config items") + CatalogReportCmd.Flags().StringVar(&catalogReportGroupBy, "group-by", "merged", "Group descendant data: 'merged' or 'config'") + CatalogReportCmd.Flags().BoolVar(&catalogReportChangeArtifacts, "change-artifacts", false, "Embed change artifacts (images/screenshots) in the report") + CatalogReportCmd.Flags().IntVar(&catalogReportLimit, "limit", 50, "Maximum number of config items to report on, including recursive descendants (0 = unlimited)") + CatalogReportCmd.Flags().IntVar(&catalogReportMaxItems, "max-items", 50, "Maximum items per section (changes, analyses, access, access-logs). Section-specific flags override this. (0 = unlimited)") + CatalogReportCmd.Flags().IntVar(&catalogReportMaxChanges, "max-changes", 100, "Maximum changes per entry, overrides --max-items for the changes section (0 = unlimited)") + CatalogReportCmd.Flags().BoolVar(&catalogReportChanges, "changes", true, "Include config changes section") + CatalogReportCmd.Flags().BoolVar(&catalogReportInsights, "insights", true, "Include config insights section") + CatalogReportCmd.Flags().BoolVar(&catalogReportRelationships, "relationships", true, "Include relationships section") + CatalogReportCmd.Flags().BoolVar(&catalogReportAccess, "access", true, "Include RBAC access section") + CatalogReportCmd.Flags().BoolVar(&catalogReportAccessLogs, "access-logs", true, "Include access logs section") + CatalogReportCmd.Flags().BoolVar(&catalogReportConfigJSON, "config-json", false, "Include raw config JSON") + CatalogReportCmd.Flags().StringVar(&catalogReportSettings, "settings", "", "Path to report settings YAML file") + CatalogReportCmd.Flags().BoolVar(&catalogReportAudit, "audit", false, "Append an audit page with settings, build info, queries, and scraper provenance") + + Catalog.AddCommand(CatalogReportCmd) +} diff --git a/cmd/catalog_tree.go b/cmd/catalog_tree.go new file mode 100644 index 000000000..f1ebfaa30 --- /dev/null +++ b/cmd/catalog_tree.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "fmt" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/commons/logger" + "github.com/flanksource/commons/properties" + "github.com/flanksource/duty" + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/query" + "github.com/samber/lo" + "github.com/spf13/cobra" +) + +var ( + treeDirection string + treeSoft bool + treeHealth bool +) + +var Tree = &cobra.Command{ + Use: "tree ", + Short: "Show a config item's parent/child hierarchy and relationships as a tree", + Long: `Display config item hierarchy (parents + children) and relationships as a tree. + +Parent/child edges are shown normally. Relationship edges are marked with ~. + +Examples: + catalog tree 018f4e6a-1234-5678-9abc-def012345678 + catalog tree type=Kubernetes::Namespace name=default + catalog tree --direction=incoming + catalog tree --direction=outgoing --soft`, + Args: cobra.MinimumNArgs(1), + PersistentPreRun: PreRun, + RunE: func(cmd *cobra.Command, args []string) error { + logger.UseSlog() + if err := properties.LoadFile("mission-control.properties"); err != nil { + logger.Errorf(err.Error()) + } + ctx, stop, err := duty.Start("mission-control", duty.ClientOnly) + if err != nil { + return err + } + defer stop() + + result, err := runCatalogTree(ctx, args) + if err != nil { + return err + } + + clicky.MustPrint(result, clicky.Flags.FormatOptions) + return nil + }, +} + +type CatalogTreeResult struct { + *query.ConfigTreeNode +} + +func (r CatalogTreeResult) Pretty() api.Text { + return treeNodeLabel(r.ConfigTreeNode) +} + +func (r CatalogTreeResult) GetChildren() []api.TreeNode { + nodes := make([]api.TreeNode, len(r.Children)) + for i, c := range r.Children { + nodes[i] = treeNodeAdapter{c} + } + return nodes +} + +type treeNodeAdapter struct { + *query.ConfigTreeNode +} + +func (n treeNodeAdapter) Pretty() api.Text { + return treeNodeLabel(n.ConfigTreeNode) +} + +func (n treeNodeAdapter) GetChildren() []api.TreeNode { + nodes := make([]api.TreeNode, len(n.Children)) + for i, c := range n.Children { + nodes[i] = treeNodeAdapter{c} + } + return nodes +} + +func treeNodeLabel(n *query.ConfigTreeNode) api.Text { + isTarget := n.EdgeType == "target" + isRelated := n.EdgeType == "related" + + t := clicky.Text("") + if isRelated { + t = t.AddText("~ ", "text-purple-500") + } + if n.Type != nil { + t = t.Add(clicky.Text(lo.FromPtr(n.Type), "text-xs text-gray-600")) + t = t.AddText("/") + } + style := "font-bold" + if isTarget { + style = "font-bold underline" + } + t = t.AddText(lo.FromPtrOr(n.Name, ""), style) + if isRelated && n.Relation != "" { + t = t.AddText(" ").Add(clicky.Text(n.Relation, "text-xs text-purple-400 italic")) + } + if treeHealth { + if n.Health != nil { + t = t.AddText(" ").Add(n.Health.Pretty()) + } + if n.Status != nil && *n.Status != "" { + t = t.AddText(" ").Add(clicky.Text(*n.Status, "text-xs text-gray-500")) + } + } + if clicky.Flags.LevelCount >= 2 { + t = t.AddText(" ").Add(clicky.Text(n.ID.String(), "text-xs font-mono text-gray-400")) + if n.Path != "" { + t = t.AddText(" ").Add(clicky.Text(n.Path, "text-xs text-gray-400")) + } + } + return t +} + +func runCatalogTree(ctx context.Context, args []string) (*CatalogTreeResult, error) { + config, err := resolveConfigID(ctx, args) + if err != nil { + return nil, err + } + + switch treeDirection { + case "all", "incoming", "outgoing": + default: + return nil, fmt.Errorf("invalid --direction %q: must be all, incoming, or outgoing", treeDirection) + } + + relType := query.Hard + if treeSoft { + relType = query.Both + } + + tree, err := query.ConfigTree(ctx, config.ID, query.ConfigTreeOptions{ + Direction: query.RelationDirection(treeDirection), + Incoming: relType, + Outgoing: relType, + }) + if err != nil { + return nil, err + } + + return &CatalogTreeResult{ConfigTreeNode: tree}, nil +} + +func init() { + Tree.Flags().StringVar(&treeDirection, "direction", "all", "Relationship direction: all, incoming, outgoing") + Tree.Flags().BoolVar(&treeSoft, "soft", false, "Include soft relationships") + Tree.Flags().BoolVar(&treeHealth, "health", false, "Show health and status") + clicky.BindAllFlags(Tree.PersistentFlags(), "format") + Catalog.AddCommand(Tree) +} diff --git a/cmd/connection_browser.go b/cmd/connection_browser.go index 05eb9131a..61dfa7a2d 100644 --- a/cmd/connection_browser.go +++ b/cmd/connection_browser.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "os" + "sort" "strings" "time" @@ -18,6 +19,8 @@ import ( "github.com/chromedp/cdproto/storage" "github.com/chromedp/chromedp" "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/api/icons" "github.com/flanksource/duty" "github.com/flanksource/duty/models" "github.com/flanksource/duty/shutdown" @@ -30,15 +33,17 @@ import ( ) type browserLoginFlags struct { - Name string - Namespace string - URL string - Domains []string - WaitForURL string - Timeout time.Duration - Cookies bool - Session bool - Bearer bool + Name string + Namespace string + URL string + Domains []string + WaitForURL string + Timeout time.Duration + Cookies bool + Session bool + Bearer bool + RequireBearerAud string + RequireBearerScope string } type browserSessionData struct { @@ -62,6 +67,8 @@ Examples: var browserFlags browserLoginFlags var azureLoginURL string +var azurePageURL string +var azureRequiredScope string var ( browserTestName string @@ -113,9 +120,11 @@ func init() { connectionLoginCmd.RunE = runBrowserLogin connectionLoginAzureCmd.PersistentFlags().StringVar(&azureLoginURL, "login-url", "https://portal.azure.com", "URL to open for browser login") + connectionLoginAzureCmd.PersistentFlags().StringVar(&azurePageURL, "page", "https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview", "Portal page to navigate to after login") + connectionLoginAzureCmd.PersistentFlags().StringVar(&azureRequiredScope, "required-scope", "AuditLog.Read.All", "Required scope substring in the captured msgraph token") connectionLoginAzureCmd.PreRun = func(cmd *cobra.Command, args []string) { - browserFlags.URL = azureLoginURL + browserFlags.URL = azurePageURL if len(browserFlags.Domains) == 0 { browserFlags.Domains = []string{".azure.com", ".microsoft.com", ".microsoftonline.com", ".windows.net", ".live.com"} } @@ -124,6 +133,8 @@ func init() { browserFlags.Session = true browserFlags.Bearer = true } + browserFlags.RequireBearerAud = "graph.microsoft.com" + browserFlags.RequireBearerScope = azureRequiredScope } connectionLoginCmd.AddCommand(connectionLoginAzureCmd) @@ -160,6 +171,12 @@ func runBrowserLogin(cmd *cobra.Command, args []string) error { return err } + if browserFlags.RequireBearerAud != "" || browserFlags.RequireBearerScope != "" { + if !hasRequiredToken(data.BearerTokens, browserFlags) { + return fmt.Errorf("no valid token found matching audience=%q scope=%q", browserFlags.RequireBearerAud, browserFlags.RequireBearerScope) + } + } + return saveConnection(cmd, browserFlags, data) } @@ -189,6 +206,7 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b fmt.Fprintln(os.Stderr, "Please log in. Press Enter when done.") } + autoSelectAccountPicker(browserCtx) waitForLoginComplete(browserCtx, flags) data := &browserSessionData{} @@ -231,10 +249,14 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b } else if verbose >= 1 { fmt.Fprintln(os.Stderr, state.Pretty().ANSI()) } else { - // Default: just show bearer tokens - for _, token := range data.BearerTokens { - if jwt := connection.DecodeJWT(token); jwt != nil { - fmt.Fprintln(os.Stderr, jwt.Pretty().ANSI()) + selectedAud, _ := selectBearerToken(data.BearerTokens, flags.RequireBearerAud, flags.RequireBearerScope) + for _, aud := range sortedAudiences(data.BearerTokens) { + if jwt := connection.DecodeJWT(data.BearerTokens[aud]); jwt != nil { + t := jwt.Pretty() + if aud == selectedAud { + t = api.Text{}.Add(icons.Check.WithStyle("text-green-500")).Append(" bearer ").Add(t) + } + fmt.Fprintln(os.Stderr, t.ANSI()) } } } @@ -242,13 +264,40 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b return data, nil } +func autoSelectAccountPicker(browserCtx gocontext.Context) { + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + var nodes int + err := chromedp.Run(browserCtx, chromedp.Evaluate( + `document.querySelectorAll('#tilesHolder .tile-container .table[role="button"]').length`, &nodes)) + if err != nil || nodes == 0 { + continue + } + fmt.Fprintf(os.Stderr, "Account picker detected, selecting first account\n") + _ = chromedp.Run(browserCtx, chromedp.Click( + `#tilesHolder .tile-container:first-child .table[role="button"]`, chromedp.ByQuery)) + return + case <-browserCtx.Done(): + return + } + } + }() +} + func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) { - doneCh := make(chan struct{}, 1) + doneCh := make(chan struct{}, 3) go func() { reader := bufio.NewReader(os.Stdin) _, _ = reader.ReadString('\n') - doneCh <- struct{}{} + select { + case doneCh <- struct{}{}: + default: + } }() if flags.WaitForURL != "" { @@ -277,6 +326,7 @@ func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) go func() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() + lastReported := "" for { select { case <-ticker.C: @@ -284,12 +334,40 @@ func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) if err != nil { continue } + validAuds := make([]string, 0) + var matched string + var matchedJWT *connection.JWT for aud, token := range extractBearerTokens(session) { jwt := connection.DecodeJWT(token) - if jwt != nil && time.Until(jwt.ExpiresAt) > 0 { - fmt.Fprintf(os.Stderr, "Found valid token for %s (expires in %s)\n", aud, time.Until(jwt.ExpiresAt).Round(time.Second)) - doneCh <- struct{}{} - return + if jwt == nil || time.Until(jwt.ExpiresAt) <= 0 { + continue + } + validAuds = append(validAuds, aud) + if matched != "" { + continue + } + audMatches := flags.RequireBearerAud == "" || strings.Contains(aud, flags.RequireBearerAud) + scopeMatches := flags.RequireBearerScope == "" || strings.Contains(jwt.Scopes, flags.RequireBearerScope) + if audMatches && scopeMatches { + matched = aud + matchedJWT = jwt + } + } + if matched != "" { + fmt.Fprintf(os.Stderr, "Found valid token for %s (scopes=%d, expires in %s)\n", matched, matchedJWT.ScopeCount(), time.Until(matchedJWT.ExpiresAt).Round(time.Second)) + doneCh <- struct{}{} + return + } + if (flags.RequireBearerAud != "" || flags.RequireBearerScope != "") && len(validAuds) > 0 { + sort.Strings(validAuds) + summary := strings.Join(validAuds, ", ") + if summary != lastReported { + waiting := flags.RequireBearerAud + if flags.RequireBearerScope != "" { + waiting += " with scope " + flags.RequireBearerScope + } + fmt.Fprintf(os.Stderr, "Waiting for %s token (have: %s)\n", waiting, summary) + lastReported = summary } } case <-browserCtx.Done(): @@ -346,8 +424,26 @@ func extractSessionStorage(browserCtx gocontext.Context) (map[string]string, err return result, nil } +func hasRequiredToken(tokens map[string]string, flags browserLoginFlags) bool { + for aud, token := range tokens { + if flags.RequireBearerAud != "" && !strings.Contains(aud, flags.RequireBearerAud) { + continue + } + jwt := connection.DecodeJWT(token) + if jwt == nil || time.Until(jwt.ExpiresAt) <= 0 { + continue + } + if flags.RequireBearerScope != "" && !strings.Contains(jwt.Scopes, flags.RequireBearerScope) { + continue + } + return true + } + return false +} + func extractBearerTokens(session map[string]string) map[string]string { tokens := make(map[string]string) + scopeCounts := make(map[string]int) for key, value := range session { if !strings.Contains(key, "accesstoken") { continue @@ -364,18 +460,57 @@ func extractBearerTokens(session map[string]string) map[string]string { if jwt == nil || jwt.Audience == "" { continue } - tokens[jwt.Audience] = secret + if !jwt.ExpiresAt.IsZero() && time.Until(jwt.ExpiresAt) <= 0 { + continue + } + if jwt.ScopeCount() > scopeCounts[jwt.Audience] { + tokens[jwt.Audience] = secret + scopeCounts[jwt.Audience] = jwt.ScopeCount() + } } return tokens } +func selectBearerToken(tokens map[string]string, requiredAud, requiredScope string) (string, error) { + var bestAud string + var bestScopes int + for aud, token := range tokens { + if !strings.Contains(aud, requiredAud) { + continue + } + jwt := connection.DecodeJWT(token) + if jwt == nil { + continue + } + if requiredScope != "" && !strings.Contains(jwt.Scopes, requiredScope) { + continue + } + if jwt.ScopeCount() > bestScopes { + bestAud = aud + bestScopes = jwt.ScopeCount() + } + } + if bestAud != "" { + return bestAud, nil + } + return "", fmt.Errorf("no token found for required audience %q (have: %s)", requiredAud, strings.Join(sortedAudiences(tokens), ", ")) +} + +func sortedAudiences(tokens map[string]string) []string { + auds := make([]string, 0, len(tokens)) + for aud := range tokens { + auds = append(auds, aud) + } + sort.Strings(auds) + return auds +} + func saveConnection(cmd *cobra.Command, flags browserLoginFlags, data *browserSessionData) error { ctx, stop, err := duty.Start("mission-control", duty.ClientOnly) if err != nil { return err } shutdown.AddHookWithPriority("database", shutdown.PriorityCritical, stop) - defer stop() props := make(map[string]string) @@ -435,23 +570,16 @@ func saveConnection(cmd *cobra.Command, flags browserLoginFlags, data *browserSe for aud, token := range data.BearerTokens { props["bearer_"+aud] = token } - for aud, token := range data.BearerTokens { - if strings.Contains(aud, "graph.microsoft.com") { - props["bearer"] = token - break - } - props["bearer"] = token + selectedAud, err := selectBearerToken(data.BearerTokens, flags.RequireBearerAud, flags.RequireBearerScope) + if err != nil { + return err } + props["bearer"] = data.BearerTokens[selectedAud] } connURL := flags.URL - if props["bearer"] != "" { - for aud := range data.BearerTokens { - if strings.Contains(aud, "graph.microsoft.com") { - connURL = "https://graph.microsoft.com/v1.0/me" - break - } - } + if props["bearer"] != "" && strings.Contains(flags.RequireBearerAud, "graph.microsoft.com") { + connURL = "https://graph.microsoft.com/v1.0/me" } conn := models.Connection{ @@ -483,6 +611,33 @@ func saveConnection(cmd *cobra.Command, flags browserLoginFlags, data *browserSe action = "updated" } fmt.Fprintf(cmd.OutOrStdout(), "Connection '%s' %s in namespace '%s'\n", flags.Name, action, flags.Namespace) + + if len(data.Cookies) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), " Cookies: %d\n", len(data.Cookies)) + } + if len(data.SessionStorage) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), " Session storage: %d keys\n", len(data.SessionStorage)) + } + if len(data.BearerTokens) > 0 { + selectedAud, _ := selectBearerToken(data.BearerTokens, flags.RequireBearerAud, flags.RequireBearerScope) + for _, aud := range sortedAudiences(data.BearerTokens) { + jwt := connection.DecodeJWT(data.BearerTokens[aud]) + if jwt == nil { + continue + } + t := api.Text{} + if aud == selectedAud { + t = t.Add(icons.Check.WithStyle("text-green-500")).Append(" bearer", "font-bold") + } else { + t = t.Append(" bearer_"+aud, "text-muted") + } + t = t.Appendf(" aud=%s", jwt.Audience). + Appendf(" scopes=%d", jwt.ScopeCount()). + Appendf(" expires=%s", time.Until(jwt.ExpiresAt).Round(time.Second)) + fmt.Fprintln(cmd.OutOrStdout(), t.ANSI()) + } + } + return nil } @@ -492,7 +647,6 @@ func runBrowserTest(cmd *cobra.Command, args []string) error { return err } shutdown.AddHookWithPriority("database", shutdown.PriorityCritical, stop) - defer stop() verbose := clicky.Flags.LevelCount diff --git a/cmd/connection_test_cmd.go b/cmd/connection_test_cmd.go index 3592506ab..ff96f3f4e 100644 --- a/cmd/connection_test_cmd.go +++ b/cmd/connection_test_cmd.go @@ -2,6 +2,7 @@ package cmd import ( gocontext "context" + "errors" "fmt" "os" @@ -72,7 +73,6 @@ func runConnectionTestFromDB(name, namespace string, overrides *connectionFlags) return nil, err } shutdown.AddHookWithPriority("database", shutdown.PriorityCritical, stop) - defer stop() var conn models.Connection if err := ctx.DB().Where("name = ? AND namespace = ? AND deleted_at IS NULL", name, namespace).First(&conn).Error; err != nil { @@ -101,6 +101,23 @@ func runConnectionTestFromDB(name, namespace string, overrides *connectionFlags) } func runConnectionTestViaAPI(mcCtx *MCContext, name, namespace string) (any, error) { + result, err := callConnectionTestAPI(mcCtx, name, namespace) + if !errors.Is(err, sdk.ErrHTMLResponse) { + return result, err + } + + upgraded, upErr := ensureAPIBase(mcCtx) + if upErr != nil { + return nil, fmt.Errorf("%w (probe failed: %v)", err, upErr) + } + if !upgraded { + return nil, err + } + fmt.Fprintf(os.Stderr, "Upgraded context %q server to %s\n", mcCtx.Name, mcCtx.Server) + return callConnectionTestAPI(mcCtx, name, namespace) +} + +func callConnectionTestAPI(mcCtx *MCContext, name, namespace string) (any, error) { client := sdk.New(mcCtx.Server, mcCtx.Token) conn, err := client.GetConnection(name, namespace) diff --git a/cmd/context.go b/cmd/context.go index cf1472a6d..6352c67f4 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -3,6 +3,7 @@ package cmd import ( "encoding/json" "fmt" + nethttp "net/http" "net/url" "os" "path/filepath" @@ -217,8 +218,141 @@ var contextCurrentCmd = &cobra.Command{ }, } +var ( + contextAddName string + contextAddServer string + contextAddDB string + contextAddToken string + contextAddUse bool +) + +var contextAddCmd = &cobra.Command{ + Use: "add", + Short: "Add or update a Mission Control context", + Long: `Add a new context (or update an existing one by name). At least one of --server +or --db-url is required. Pass --use to switch to the new context immediately. + +Examples: + mission-control context add --name local --db-url "$DB_URL" + mission-control context add --name beta --server https://beta.flanksource.com --token "$TOKEN" --use`, + RunE: func(cmd *cobra.Command, args []string) error { + if contextAddName == "" { + return fmt.Errorf("--name is required") + } + if contextAddServer == "" && contextAddDB == "" { + return fmt.Errorf("at least one of --server or --db-url is required") + } + + cfg, err := LoadConfig() + if err != nil { + return err + } + + existingCtx := cfg.GetContext(contextAddName) + existing := existingCtx != nil + ctx := MCContext{Name: contextAddName} + if existingCtx != nil { + ctx = *existingCtx + } + if cmd.Flags().Changed("server") { + ctx.Server = strings.TrimRight(contextAddServer, "/") + } + if cmd.Flags().Changed("db-url") { + ctx.DB = contextAddDB + } + if cmd.Flags().Changed("token") { + ctx.Token = contextAddToken + } + cfg.SetContext(ctx) + + if contextAddUse || cfg.CurrentContext == "" { + cfg.CurrentContext = contextAddName + } + + if err := SaveConfig(cfg); err != nil { + return err + } + + action := "Added" + if existing { + action = "Updated" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s context %q\n", action, contextAddName) + if cfg.CurrentContext == contextAddName { + fmt.Fprintf(cmd.OutOrStdout(), "Switched to context %q\n", contextAddName) + } + return nil + }, +} + +// ensureAPIBase probes serverURL + "/api/db/connections" and, if that path +// returns JSON, appends "/api" to the stored server URL and saves the config. +// Returns true when the context was upgraded. Used to self-heal after the SDK +// reports ErrHTMLResponse. +func ensureAPIBase(ctx *MCContext) (bool, error) { + if ctx == nil || ctx.Server == "" { + return false, nil + } + if strings.HasSuffix(strings.TrimRight(ctx.Server, "/"), "/api") { + return false, nil + } + + probeURL := strings.TrimRight(ctx.Server, "/") + "/api/db/connections?limit=0" + req, err := nethttp.NewRequest(nethttp.MethodGet, probeURL, nil) + if err != nil { + return false, err + } + if ctx.Token != "" { + req.Header.Set("Authorization", "Bearer "+ctx.Token) + } + req.Header.Set("Accept", "application/json") + + resp, err := nethttp.DefaultClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + buf := make([]byte, 512) + n, _ := resp.Body.Read(buf) + body := strings.TrimLeft(string(buf[:n]), " \t\r\n") + switch resp.StatusCode { + case nethttp.StatusOK, nethttp.StatusUnauthorized, nethttp.StatusForbidden: + default: + return false, nil + } + ct := strings.ToLower(resp.Header.Get("Content-Type")) + if strings.Contains(ct, "text/html") || strings.HasPrefix(body, "<") { + return false, nil + } + if !strings.Contains(ct, "json") && !strings.HasPrefix(body, "[") && !strings.HasPrefix(body, "{") { + return false, nil + } + + cfg, err := LoadConfig() + if err != nil { + return false, err + } + stored := cfg.GetContext(ctx.Name) + if stored == nil { + return false, nil + } + stored.Server = strings.TrimRight(stored.Server, "/") + "/api" + ctx.Server = stored.Server + if err := SaveConfig(cfg); err != nil { + return false, err + } + return true, nil +} + func init() { - ContextCmd.AddCommand(contextUseCmd, contextListCmd, contextCurrentCmd) + contextAddCmd.Flags().StringVar(&contextAddName, "name", "", "Context name (required)") + contextAddCmd.Flags().StringVar(&contextAddServer, "server", "", "Mission Control server URL") + contextAddCmd.Flags().StringVar(&contextAddDB, "db-url", "", "Direct database connection URL") + contextAddCmd.Flags().StringVar(&contextAddToken, "token", "", "API token for the server") + contextAddCmd.Flags().BoolVar(&contextAddUse, "use", false, "Switch to this context after adding") + + ContextCmd.AddCommand(contextUseCmd, contextListCmd, contextCurrentCmd, contextAddCmd) Root.AddCommand(ContextCmd) Root.PersistentFlags().StringVar(&contextFlag, "context", "", "Mission Control context to use") } diff --git a/cmd/rbac.go b/cmd/rbac.go index 91eb407af..5ec6a64f8 100644 --- a/cmd/rbac.go +++ b/cmd/rbac.go @@ -16,6 +16,7 @@ import ( v1 "github.com/flanksource/incident-commander/api/v1" "github.com/flanksource/incident-commander/rbac_report" + "github.com/flanksource/incident-commander/report" ) var RBACCmd = &cobra.Command{ @@ -29,7 +30,7 @@ var ( rbacReviewDays int rbacSince string rbacTitle string - rbacByUser bool + rbacView string ) var ExportRBAC = &cobra.Command{ @@ -98,7 +99,7 @@ func buildRBACOptions(args []string) rbac_report.Options { Title: rbacTitle, StaleDays: rbacStaleDays, ReviewOverdueDays: rbacReviewDays, - ByUser: rbacByUser, + View: rbacView, } if rbacSince != "" { @@ -164,6 +165,7 @@ func init() { ExportRBAC.Flags().IntVar(&rbacReviewDays, "review-days", 90, "Days without review before access is flagged overdue") ExportRBAC.Flags().StringVar(&rbacSince, "since", "2160h", "Changelog time range (Go duration, default 90 days)") ExportRBAC.Flags().StringVar(&rbacTitle, "title", "", "Report title (default auto-generated)") - ExportRBAC.Flags().BoolVar(&rbacByUser, "by-user", false, "Group report by user instead of resource") + ExportRBAC.Flags().StringVar(&rbacView, "view", "resource", "Report view: resource, user, or matrix") + ExportRBAC.Flags().StringVar(&report.SourceDir, "report-source", "", "Local directory or TSX file for report rendering (overrides embedded reports)") RBACCmd.AddCommand(ExportRBAC) } diff --git a/cmd/view.go b/cmd/view.go index 2df506433..ff8df9a3f 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -13,6 +13,7 @@ import ( yamlutil "k8s.io/apimachinery/pkg/util/yaml" v1 "github.com/flanksource/incident-commander/api/v1" + "github.com/flanksource/incident-commander/report" "github.com/flanksource/incident-commander/views" ) @@ -109,5 +110,6 @@ func init() { ViewRun.Flags().StringVarP(&viewFormat, "format", "f", "json", "Output format: json, csv, html, pdf, facet-html, facet-pdf") ViewRun.Flags().StringVarP(&viewOutFile, "out-file", "o", "", "Write output to file instead of stdout") ViewRun.Flags().StringSliceVar(&viewVars, "var", nil, "Template variables as key=value pairs") + ViewRun.Flags().StringVar(&report.SourceDir, "report-source", "", "Local directory or TSX file for report rendering (overrides embedded reports)") ViewCmd.AddCommand(ViewRun) } diff --git a/config/schemas/playbook-spec.schema.json b/config/schemas/playbook-spec.schema.json index 912a184e3..71e59151c 100644 --- a/config/schemas/playbook-spec.schema.json +++ b/config/schemas/playbook-spec.schema.json @@ -582,8 +582,14 @@ "url": { "type": "string" }, - "pdfOptions": { - "$ref": "#/$defs/FacetPDFOptions" + "pageSize": { + "type": "string" + }, + "landscape": { + "type": "boolean" + }, + "margins": { + "$ref": "#/$defs/FacetPDFMargins" }, "header": { "type": "string" @@ -616,21 +622,6 @@ "additionalProperties": false, "type": "object" }, - "FacetPDFOptions": { - "properties": { - "pageSize": { - "type": "string" - }, - "landscape": { - "type": "boolean" - }, - "margins": { - "$ref": "#/$defs/FacetPDFMargins" - } - }, - "additionalProperties": false, - "type": "object" - }, "FieldMappingConfig": { "properties": { "id": { diff --git a/config/schemas/playbook.schema.json b/config/schemas/playbook.schema.json index 107dce2e3..132c445a1 100644 --- a/config/schemas/playbook.schema.json +++ b/config/schemas/playbook.schema.json @@ -613,8 +613,14 @@ "url": { "type": "string" }, - "pdfOptions": { - "$ref": "#/$defs/FacetPDFOptions" + "pageSize": { + "type": "string" + }, + "landscape": { + "type": "boolean" + }, + "margins": { + "$ref": "#/$defs/FacetPDFMargins" }, "header": { "type": "string" @@ -647,21 +653,6 @@ "additionalProperties": false, "type": "object" }, - "FacetPDFOptions": { - "properties": { - "pageSize": { - "type": "string" - }, - "landscape": { - "type": "boolean" - }, - "margins": { - "$ref": "#/$defs/FacetPDFMargins" - } - }, - "additionalProperties": false, - "type": "object" - }, "FieldMappingConfig": { "properties": { "id": { diff --git a/db/rbac_test.go b/db/rbac_test.go new file mode 100644 index 000000000..59079ad49 --- /dev/null +++ b/db/rbac_test.go @@ -0,0 +1,68 @@ +package db + +import ( + "github.com/flanksource/duty/tests/fixtures/dummy" + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetGroupMembersForConfigs", func() { + It("returns members of a group referenced by config_access", func() { + rows, err := GetGroupMembersForConfigs(DefaultContext, []uuid.UUID{dummy.MissionControlNamespace.ID}) + Expect(err).ToNot(HaveOccurred()) + + // The MissionControlNamespace config has access rows for both the + // admins group (JohnDoe + Alice) and the readers group (Bob + Charlie). + userIDsByGroup := map[uuid.UUID]map[uuid.UUID]bool{} + groupNames := map[uuid.UUID]string{} + for _, r := range rows { + if userIDsByGroup[r.GroupID] == nil { + userIDsByGroup[r.GroupID] = map[uuid.UUID]bool{} + } + userIDsByGroup[r.GroupID][r.UserID] = true + groupNames[r.GroupID] = r.GroupName + } + + Expect(groupNames).To(HaveKey(dummy.MissionControlAdminsGroup.ID)) + Expect(groupNames[dummy.MissionControlAdminsGroup.ID]).To(Equal("mission-control-admins")) + Expect(userIDsByGroup[dummy.MissionControlAdminsGroup.ID]).To(HaveKey(dummy.JohnDoeExternalUser.ID)) + Expect(userIDsByGroup[dummy.MissionControlAdminsGroup.ID]).To(HaveKey(dummy.AliceExternalUser.ID)) + + Expect(groupNames).To(HaveKey(dummy.MissionControlReadersGroup.ID)) + Expect(userIDsByGroup[dummy.MissionControlReadersGroup.ID]).To(HaveKey(dummy.BobExternalUser.ID)) + Expect(userIDsByGroup[dummy.MissionControlReadersGroup.ID]).To(HaveKey(dummy.CharlieExternalUser.ID)) + }) + + It("returns no rows for configs without group-based access", func() { + // Use a random config ID that has no config_access group rows. + rows, err := GetGroupMembersForConfigs(DefaultContext, []uuid.UUID{uuid.New()}) + Expect(err).ToNot(HaveOccurred()) + Expect(rows).To(BeEmpty()) + }) + + It("returns nil when no config IDs are provided", func() { + rows, err := GetGroupMembersForConfigs(DefaultContext, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(rows).To(BeNil()) + }) + + It("populates member identity fields from external_users", func() { + rows, err := GetGroupMembersForConfigs(DefaultContext, []uuid.UUID{dummy.MissionControlNamespace.ID}) + Expect(err).ToNot(HaveOccurred()) + + var johnRow *GroupMemberRow + for i := range rows { + if rows[i].UserID == dummy.JohnDoeExternalUser.ID { + johnRow = &rows[i] + break + } + } + Expect(johnRow).ToNot(BeNil(), "expected john doe in admins group") + Expect(johnRow.UserName).To(Equal("John Doe")) + Expect(johnRow.Email).To(Equal("johndoe@flanksource.com")) + Expect(johnRow.UserType).To(Equal("user")) + Expect(johnRow.GroupType).To(Equal("group")) + Expect(johnRow.MembershipAddedAt).ToNot(BeZero()) + }) +}) diff --git a/fixtures/views/ui-ref-examples.yaml b/fixtures/views/ui-ref-examples.yaml index fee828683..137ba7c32 100644 --- a/fixtures/views/ui-ref-examples.yaml +++ b/fixtures/views/ui-ref-examples.yaml @@ -22,3 +22,16 @@ spec: uiRef: configs: health: -healthy + + - title: Stale Access + icon: shield + uiRef: + access: + stale: "2160h" + + - title: Recent Sign-ins (No MFA) + icon: key + uiRef: + accessLogs: + from: "720h" + mfa: "false" diff --git a/rbac_report/export.go b/rbac_report/export.go index 5ee122d63..96dcfec31 100644 --- a/rbac_report/export.go +++ b/rbac_report/export.go @@ -23,14 +23,14 @@ func Export(ctx context.Context, opts Options, format string) ([]byte, error) { switch format { case "csv": - if opts.ByUser { + if opts.View == "user" { return renderCSVByUser(report) } return renderCSV(report) - case "facet-html": - return RenderFacetHTML(ctx, report, opts.ByUser) - case "facet-pdf": - return RenderFacetPDF(ctx, report, opts.ByUser) + case "html", "facet-html": + return RenderFacetHTML(ctx, report, opts.View) + case "pdf", "facet-pdf": + return RenderFacetPDF(ctx, report, opts.View) default: return json.MarshalIndent(report, "", " ") } diff --git a/rbac_report/render_facet.go b/rbac_report/render_facet.go index a13c580b3..f1f01c4d9 100644 --- a/rbac_report/render_facet.go +++ b/rbac_report/render_facet.go @@ -1,134 +1,50 @@ package rbac_report import ( - "bytes" - "encoding/json" "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/report" ) -func RenderFacetHTML(ctx context.Context, r *api.RBACReport, byUser bool) ([]byte, error) { - return renderWithFacet(ctx, r, "html", byUser) +func RenderFacetHTML(ctx context.Context, r *api.RBACReport, view string) ([]byte, error) { + return renderWithFacet(ctx, r, "html", view) } -func RenderFacetPDF(ctx context.Context, r *api.RBACReport, byUser bool) ([]byte, error) { - return renderWithFacet(ctx, r, "pdf", byUser) +func RenderFacetPDF(ctx context.Context, r *api.RBACReport, view string) ([]byte, error) { + return renderWithFacet(ctx, r, "pdf", view) } -func renderWithFacet(ctx context.Context, r *api.RBACReport, format string, byUser bool) ([]byte, error) { +func renderWithFacet(ctx context.Context, r *api.RBACReport, format string, view string) ([]byte, error) { if r == nil { return nil, fmt.Errorf("RBAC report must not be nil") } - facetBin, err := exec.LookPath("facet") - if err != nil { - return nil, fmt.Errorf("facet not found on PATH: install with 'npm install -g @flanksource/facet'") - } - - srcDir, err := facetSrcDir() - if err != nil { - return nil, fmt.Errorf("prepare facet src dir: %w", err) - } - - dataJSON, err := json.MarshalIndent(initSlices(r), "", " ") - if err != nil { - return nil, fmt.Errorf("marshal RBAC report: %w", err) - } - - ctx.Logger.V(3).Infof("facet binary: %s, data size: %dKB", facetBin, len(dataJSON)/1024) - - dataFile, err := os.CreateTemp("", "facet-rbac-data-*.json") - if err != nil { - return nil, fmt.Errorf("create data temp file: %w", err) - } - defer os.Remove(dataFile.Name()) - - if _, err := dataFile.Write(dataJSON); err != nil { - return nil, fmt.Errorf("write data file: %w", err) - } - dataFile.Close() - - outFile, err := os.CreateTemp("", "facet-rbac-output-*."+format) - if err != nil { - return nil, fmt.Errorf("create output temp file: %w", err) - } - outFile.Close() - defer os.Remove(outFile.Name()) - - entryFile := "RBACReport.tsx" - if byUser { + entryFile := "RBACMatrixReport.tsx" + switch view { + case "user": entryFile = "RBACByUserReport.tsx" } - var stderr, stdout bytes.Buffer - cmd := exec.Command(facetBin, format, entryFile, "-d", dataFile.Name(), "-o", outFile.Name()) - cmd.Dir = srcDir - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - ctx.Logger.V(3).Infof("Rendering facet-%s (%dKB data)", format, len(dataJSON)/1024) + ctx.Logger.V(3).Infof("Rendering facet-%s", format) - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("facet %s failed: %w\n%s", format, err, stderr.String()) - } - - result, err := os.ReadFile(outFile.Name()) + result, err := report.RenderCLI(initSlices(r), format, entryFile) if err != nil { - return nil, fmt.Errorf("read facet output: %w", err) - } - - ctx.Logger.V(3).Infof("Facet rendered %dKB of %s", len(result)/1024, format) - return result, nil -} - -func facetSrcDir() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - cacheDir = os.TempDir() - } - dir := filepath.Join(cacheDir, "incident-commander", "facet-report") - - if err := os.MkdirAll(dir, 0750); err != nil { - return "", fmt.Errorf("create cache dir: %w", err) + return nil, ctx.Oops().Wrapf(err, "failed to render RBAC %s report", format) } - if err := extractReportFiles(dir); err != nil { - return "", err - } - - return dir, nil -} - -func extractReportFiles(destDir string) error { - return fs.WalkDir(report.FS, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == "." { - return nil - } - dest := filepath.Join(destDir, path) - if d.IsDir() { - return os.MkdirAll(dest, 0750) - } - data, err := report.FS.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(dest, data, 0600) - }) + ctx.Logger.V(3).Infof("Facet rendered %dKB of %s", len(result.Data)/1024, format) + return result.Data, nil } func initSlices(r *api.RBACReport) api.RBACReport { out := *r + if out.Parents == nil { + out.Parents = []models.ConfigItem{} + } if out.Resources == nil { out.Resources = []api.RBACResource{} } diff --git a/rbac_report/report.go b/rbac_report/report.go index c5803ea0d..da608b86b 100644 --- a/rbac_report/report.go +++ b/rbac_report/report.go @@ -19,10 +19,11 @@ import ( type Options struct { Title string Selectors []types.ResourceSelector + Recursive bool StaleDays int ReviewOverdueDays int ChangelogSince time.Duration - ByUser bool + View string } func (o Options) WithDefaults() Options { @@ -44,7 +45,7 @@ func (o Options) WithDefaults() Options { func BuildReport(ctx context.Context, opts Options) (*api.RBACReport, error) { opts = opts.WithDefaults() - rows, err := db.GetRBACAccess(ctx, opts.Selectors) + rows, err := db.GetRBACAccess(ctx, opts.Selectors, opts.Recursive) if err != nil { return nil, ctx.Oops().Wrapf(err, "failed to query RBAC access") } @@ -80,16 +81,20 @@ func BuildReport(ctx context.Context, opts Options) (*api.RBACReport, error) { ctx.Logger.V(3).Infof("Changelog: %d entries, temporary access: %d entries", len(changelog), len(tempAccess)) + subject, parents := resolveSubjectAndParents(ctx, configItems, configMap) + report := &api.RBACReport{ Title: opts.Title, Query: formatSelectors(opts.Selectors), GeneratedAt: time.Now(), + Subject: subject, + Parents: parents, Resources: resources, Changelog: changelog, Summary: summary, } - if opts.ByUser { + if opts.View == "user" { report.Users = groupByUser(rows, opts, configMap) } @@ -187,7 +192,43 @@ func groupByConfigItem(rows []db.RBACAccessRow, opts Options, configMap map[stri }) } +func resolveSubjectAndParents(ctx context.Context, configItems []models.ConfigItem, configMap map[string]models.ConfigItem) (*models.ConfigItem, []models.ConfigItem) { + if len(configItems) == 0 { + return nil, nil + } + + first := configItems[0] + + var parents []models.ConfigItem + current := first + for current.ParentID != nil { + parent, ok := configMap[current.ParentID.String()] + if !ok { + loaded, err := query.GetConfigsByIDs(ctx, []uuid.UUID{*current.ParentID}) + if err != nil || len(loaded) == 0 { + break + } + parent = loaded[0] + configMap[parent.ID.String()] = parent + } + parents = append(parents, parent) + current = parent + } + + // Reverse so root is first + for i, j := 0, len(parents)-1; i < j; i, j = i+1, j-1 { + parents[i], parents[j] = parents[j], parents[i] + } + + return &first, parents +} + func enrichResourceFromConfigItem(resource *api.RBACResource, ci models.ConfigItem) { + resource.ConfigClass = ci.ConfigClass + resource.Path = ci.Path + if ci.ParentID != nil { + resource.ParentID = ci.ParentID.String() + } if ci.Status != nil { resource.Status = *ci.Status } @@ -258,6 +299,8 @@ func groupByUser(rows []db.RBACAccessRow, opts Options, configMap map[string]mod IsReviewOverdue: isReviewOverdue, } if ci, found := configMap[row.ConfigID.String()]; found { + res.ConfigClass = ci.ConfigClass + res.Path = ci.Path if ci.Status != nil { res.Status = *ci.Status } diff --git a/report/.gitignore b/report/.gitignore index 4c6b715d8..493ec8ea7 100644 --- a/report/.gitignore +++ b/report/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .facet/ out.* +.playwright-mcp/ diff --git a/report/Application.tsx b/report/Application.tsx index e08b21bd6..278add9da 100644 --- a/report/Application.tsx +++ b/report/Application.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Page, PageBreak } from '@flanksource/facet'; +import { Document, Page, Header, Footer } from '@flanksource/facet'; import type { Application } from './types.ts'; import ApplicationDetails from './components/ApplicationDetails.tsx'; import AccessControlSection from './components/AccessControlSection.tsx'; @@ -8,57 +8,18 @@ import BackupsSection from './components/BackupsSection.tsx'; import FindingsSection from './components/FindingsSection.tsx'; import LocationsSection from './components/LocationsSection.tsx'; import DynamicSection from './components/DynamicSection.tsx'; +import CoverPage from './components/CoverPage.tsx'; +import PageHeader from './components/PageHeader.tsx'; +import PageFooter from './components/PageFooter.tsx'; -function PageHeader({ app }: { app: Application }) { +function AppCoverPage({ app }: { app: Application }) { return ( -
- {app.name} - Application Report -
- ); -} - -function PageFooter() { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric' - }); - return ( -
- Generated {date} -
- ); -} - -function CoverPage({ app }: { app: Application }) { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric' - }); - return ( -
-
-
- Application Report -
-

- {app.name} -

-
- {app.type} · {app.namespace} -
- {app.description && ( -

- {app.description} -

- )} -
-
-
- Generated on {date} -
-
+ ); } @@ -67,87 +28,32 @@ interface ApplicationReportProps { } export default function ApplicationReport({ data }: ApplicationReportProps) { - const header = ; - const footer = ; - const pageProps = { - pageSize: 'a4' as const, - margins: { top: 5, bottom: 5, left: 5, right: 5 }, - header, - headerHeight: 10, - footer, - footerHeight: 10, - }; - return ( - <> - {/* Cover page — no header/footer */} - - + +
+ +
+
+ +
+ + + - - - {/* Application details + properties */} - + - - - - - {/* Access control */} - + + {(data.backups.length > 0 || data.restores.length > 0) && ( + + )} + + {data.sections.map((section, idx) => ( + + ))} + - - {/* Incidents */} - {data.incidents.length > 0 && ( - <> - - - - - - )} - - {/* Backups & Restores */} - {(data.backups.length > 0 || data.restores.length > 0) && ( - <> - - - - - - )} - - {/* Security findings */} - {data.findings.length > 0 && ( - <> - - - - - - )} - - {/* Dynamic sections (view / changes / configs) */} - {data.sections.map((section, idx) => ( - - - - - - - ))} - - {/* Locations */} - {data.locations.length > 0 && ( - <> - - - - - - )} - +
); } diff --git a/report/CatalogReport.tsx b/report/CatalogReport.tsx new file mode 100644 index 000000000..cb0ffb24a --- /dev/null +++ b/report/CatalogReport.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { Document, Page, Header, Footer, Section } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { CatalogReportData, CatalogReportConfigGroup, CatalogReportCategoryMapping } from './catalog-report-types.ts'; +import type { ConfigChange } from './config-types.ts'; +import ConfigChangesSection from './components/ConfigChangesSection.tsx'; +import ConfigInsightsSection from './components/ConfigInsightsSection.tsx'; +import RBACChanges from './components/RBACChanges.tsx'; +import BackupChanges from './components/BackupChanges.tsx'; +import DeploymentChanges from './components/DeploymentChanges.tsx'; +import { categorizeChanges, configChangeToApplicationChange } from './components/change-section-utils.ts'; +import ConfigRelationshipGraph from './components/ConfigRelationshipGraph.tsx'; +import ConfigTreeSection from './components/ConfigTreeSection.tsx'; +import CatalogAccessSection from './components/CatalogAccessSection.tsx'; +import CatalogAccessLogsSection from './components/CatalogAccessLogsSection.tsx'; +import RBACMatrixSection from './components/RBACMatrixSection.tsx'; +import ArtifactAppendix from './components/ArtifactAppendix.tsx'; +import AuditPage from './components/AuditPage.tsx'; +import CoverPage from './components/CoverPage.tsx'; +import CatalogList from './components/CatalogList.tsx'; +import PageHeader from './components/PageHeader.tsx'; +import PageFooter from './components/PageFooter.tsx'; + +function CatalogCoverPage({ data }: { data: CatalogReportData }) { + const ci = data.configItem || {}; + const stats: Array<{ label: string; value: number }> = []; + if (data.sections?.changes) stats.push({ label: 'changes', value: (data.changes || []).length }); + if (data.sections?.insights) stats.push({ label: 'insights', value: (data.analyses || []).length }); + if (data.sections?.relationships) stats.push({ label: 'relationships', value: (data.relatedConfigs || []).length }); + if (data.sections?.access) stats.push({ label: 'access entries', value: (data.access || []).length }); + if (data.sections?.accessLogs) stats.push({ label: 'access logs', value: (data.accessLogs || []).length }); + + return ( + + {data.recursive && ( +
+ Including all descendant config items + {data.groupBy === 'config' && ` · Grouped by config (${(data.configGroups || []).length} items)`} +
+ )} + {data.thresholds && ( +
+ Stale access: {data.thresholds.staleDays}d + Review overdue: {data.thresholds.reviewOverdueDays}d +
+ )} +
+ ); +} + +function ConfigJSONSection({ json }: { json: string }) { + let formatted = json; + try { + formatted = JSON.stringify(JSON.parse(json), null, 2); + } catch { } + + return ( +
+
+        {formatted}
+      
+
+ ); +} + +function CategorizedChangesSection({ changes, categoryMappings, hideConfigName }: { + changes?: ConfigChange[]; + categoryMappings?: CatalogReportCategoryMapping[]; + hideConfigName?: boolean; +}) { + if (!changes?.length) return null; + const { rbac, backup, deployment, uncategorized } = categorizeChanges(changes, categoryMappings); + return ( + <> + {rbac.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {backup.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {deployment.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {uncategorized.length > 0 && ( + + )} + + ); +} + +function ConfigGroupHeader({ group }: { group: CatalogReportConfigGroup }) { + const ci = group.configItem; + return ( +
+ {ci.type && } + {ci.name} + {ci.type && {ci.type}} + {ci.permalink && ( + {ci.permalink} + )} +
+ ); +} + +interface CatalogReportProps { + data: CatalogReportData; +} + +export default function CatalogReportPage({ data }: CatalogReportProps) { + const configItem = { + id: data.configItem.id, + name: data.configItem.name, + type: data.configItem.type, + configClass: data.configItem.configClass, + status: data.configItem.status, + health: data.configItem.health, + description: data.configItem.description, + labels: data.configItem.labels, + tags: data.configItem.tags, + createdAt: data.configItem.created_at, + updatedAt: data.configItem.updated_at, + }; + + return ( + +
+ +
+
+ +
+ + + + + + + + + {data.groupBy === 'config' && (data.entries || []).map((entry, idx) => ( + + + + + {(entry.rbacResources || []).map((resource, rIdx) => ( + + ))} + + ))} + + {data.groupBy !== 'config' && ( + <> + {data.sections?.changes && } + {data.sections?.insights && } + + )} + + {data.sections?.relationships && ( + data.relationshipTree + ? + : + )} + + {data.groupBy !== 'config' && ( + <> + {data.sections?.access && } + {data.sections?.accessLogs && } + + )} + + {data.groupBy === 'config' && (data.configGroups || []).map((group, idx) => ( + + + {data.sections?.changes && } + {data.sections?.insights && } + {data.sections?.access && } + {data.sections?.accessLogs && } + + ))} + + {data.sections?.configJSON && data.configJSON && } + + e.changes ?? []), + ...(data.configGroups ?? []).flatMap((g) => g.changes ?? []), + ]} /> + + + {data.audit && ( + + + + )} +
+ ); +} diff --git a/report/FindingsReport.tsx b/report/FindingsReport.tsx new file mode 100644 index 000000000..4552a9251 --- /dev/null +++ b/report/FindingsReport.tsx @@ -0,0 +1,571 @@ +import React from "react"; +import { Document, Page, Header, Footer, SeverityStatCard, ListTable, Badge as FacetBadge, PageNo } from "@flanksource/facet"; +import { OUTCOME_ICONS, CATEGORY_ICONS, KILL_CHAIN_ICONS, IDENTITY_ICONS, ENDPOINT_ICONS, RESOURCE_ICONS, APP_ICONS, type IconDef } from "./icons"; +import { Sqlserver, K8S, Aws, Azure, MissionControl, MissionControlLogo } from "@flanksource/icons/mi"; +import { Icon } from "@flanksource/icons/icon"; +import vscodeIcons from "@iconify-json/vscode-icons/icons.json"; + +function VscodeIcon({ name, size = 20 }: { name: string; size?: number }) { + const iconName = name.replace("vscode-icons:", ""); + const iconData = (vscodeIcons as any).icons[iconName]; + if (!iconData) return null; + const w = iconData.width || (vscodeIcons as any).width || 32; + const h = iconData.height || (vscodeIcons as any).height || 32; + return ; +} + + +function SvgIcon({ icon, size = 14 }: { icon: IconDef; size?: number }) { + return ; +} + +function svgIconComponent(icon: IconDef): React.ComponentType<{ className?: string }> { + return ({ className }: { className?: string }) => ( + + ); +} + +type Severity = "critical" | "high" | "medium" | "low" | "info"; +type Platform = "sql-server" | "kubernetes" | "aws" | "azure" | "mission-control"; +type Outcome = "safety-switch" | "page-oncall" | "high-ticket" | "low-ticket" | "informational"; +interface Identity { name: string; type: string; displayName?: string } +interface Endpoint { ip?: string; hostname?: string; type?: string; network?: string; tags?: string[] } +interface AppRef { name: string; type?: string; tags?: string[] } +interface Resource { name: string; type: string; scope?: string; tags?: string[] } +interface Actor { identity?: Identity; endpoint?: Endpoint; app?: AppRef; resource?: Resource } +interface AuditSample { timestamp: string; action: string; detail?: string; succeeded?: boolean; src?: Actor; dst?: Actor } +interface FileInfo { + name?: string; size?: string; created?: string; modified?: string; + location?: string; host?: string; +} +interface DataSource { + type?: string; categories?: string[]; connection?: string; path?: string; query?: string; + timeRange?: { start: string; end: string; durationSeconds?: number }; + git?: { sha?: string; repo?: string; file?: string; lineNo?: number; branch?: string; tag?: string }; + contentSha?: string; + app?: { name?: string; version?: string; icon?: string }; + file?: FileInfo; +} +interface AuditFinding { + title: string; severity: Severity; platform: Platform; category: string; outcome: Outcome; + detection: { pattern: string; threshold?: string }; + dataSource?: DataSource; + evidence: { + summary: string; + timeRange?: { start: string; end: string; durationSeconds?: number }; + metrics?: Record; + samples?: AuditSample[]; + }; + recommendation: { action: string; mitigations?: string[]; references?: string[] }; + context?: { + killChainPhase?: string; mitreTechnique?: string; compliance?: string[]; + relatedFindings?: string[]; + baseline?: { normalValue?: number; observedValue?: number; deviationFactor?: number; baselinePeriod?: string }; + }; + provenance?: { generatedAt?: string; generatedBy?: string; version?: string; runId?: string; model?: string }; +} + +const SEVERITY_STYLES: Record = { + critical: { className: "bg-red-50 text-red-700", dot: "bg-red-500", border: "border-red-200", order: 0, color: "red" }, + high: { className: "bg-orange-50 text-orange-700", dot: "bg-orange-500", border: "border-orange-200", order: 1, color: "orange" }, + medium: { className: "bg-yellow-50 text-yellow-700", dot: "bg-yellow-500", border: "border-yellow-200", order: 2, color: "yellow" }, + low: { className: "bg-blue-50 text-blue-700", dot: "bg-blue-400", border: "border-blue-200", order: 3, color: "blue" }, + info: { className: "bg-gray-50 text-gray-600", dot: "bg-gray-400", border: "border-gray-200", order: 4, color: "gray" }, +}; + +const OUTCOME_STYLES: Record; label: string }> = { + "safety-switch": { className: "bg-red-900/10 text-red-900 border border-red-900/20", icon: svgIconComponent(OUTCOME_ICONS["safety-switch"]), label: "Kill Switch" }, + "page-oncall": { className: "bg-red-50 text-red-500 border border-red-200", icon: svgIconComponent(OUTCOME_ICONS["page-oncall"]), label: "Page On-Call" }, + "high-ticket": { className: "bg-orange-50 text-orange-600 border border-orange-200", icon: svgIconComponent(OUTCOME_ICONS["high-ticket"]), label: "High Priority" }, + "low-ticket": { className: "bg-yellow-50 text-yellow-600 border border-yellow-200", icon: svgIconComponent(OUTCOME_ICONS["low-ticket"]), label: "Track Issue" }, + "informational": { className: "bg-gray-50 text-gray-500 border border-gray-200", icon: svgIconComponent(OUTCOME_ICONS["informational"]), label: "Log Only" }, +}; +const PLATFORM_LABELS: Record = { + "sql-server": "SQL Server", kubernetes: "Kubernetes", aws: "AWS", azure: "Azure", "mission-control": "Mission Control", +}; +const PLATFORM_ICONS: Record> = { + "sql-server": Sqlserver, kubernetes: K8S, aws: Aws, azure: Azure, "mission-control": MissionControl, +}; + +function formatDateTime(iso: string): string { + return new Date(iso).toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h`; + return `${Math.round(seconds / 86400)}d`; +} +function formatKey(key: string): string { + return key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()); +} + +interface BadgeProps { label?: string; icon?: React.ComponentType<{ className?: string }>; className?: string; size?: "xs" | "sm" } + +function Badge({ label, icon: Icon, className = "bg-gray-100 text-gray-500", size = "sm" }: BadgeProps) { + return ( + + {Icon && } + {label && {label}} + + ); +} + +function severityBadge(s: Severity) { + return { label: s, className: SEVERITY_STYLES[s].className, dot: SEVERITY_STYLES[s].dot }; +} + +const CATEGORY_ICON_COMPONENTS: Record> = Object.fromEntries( + Object.entries(CATEGORY_ICONS).map(([k, v]) => [k, svgIconComponent(v)]) +); + +function findingSubtitleTags(f: AuditFinding): BadgeProps[] { + const tags: BadgeProps[] = [ + { label: f.category, className: "bg-gray-100 text-gray-500", icon: CATEGORY_ICON_COMPONENTS[f.category] }, + { label: PLATFORM_LABELS[f.platform], className: "bg-gray-100 text-gray-500", icon: PLATFORM_ICONS[f.platform] }, + ]; + if (f.context?.killChainPhase) { + const kcIcon = KILL_CHAIN_ICONS[f.context.killChainPhase]; + tags.push({ label: f.context.killChainPhase, className: "bg-purple-50 text-purple-600", icon: kcIcon ? svgIconComponent(kcIcon) : undefined }); + } + if (f.context?.mitreTechnique) tags.push({ label: `MITRE ${f.context.mitreTechnique}`, className: "bg-purple-50 text-purple-600" }); + return tags; +} + +function findingComplianceTags(f: AuditFinding): BadgeProps[] { + return [ + ...(f.context?.compliance?.map((c) => ({ label: c, className: "bg-gray-50 text-gray-500" })) || []), + ...(f.context?.relatedFindings?.map((r) => ({ label: `→ ${r}`, className: "font-mono bg-gray-50 text-gray-500" })) || []), + ]; +} + +type EntityEntry = { name: string; type: string; scope?: string; className?: string; icon?: React.ComponentType<{ className?: string }> }; + +function iconFor(map: Record, key: string): React.ComponentType<{ className?: string }> | undefined { + const def = map[key]; + return def ? svgIconComponent(def) : undefined; +} +function findingEntities(f: AuditFinding): EntityEntry[] { + const seen = new Set(); + const entities: EntityEntry[] = []; + for (const s of f.evidence.samples || []) { + for (const actor of [s.src, s.dst].filter(Boolean) as Actor[]) { + if (actor.identity && !seen.has(actor.identity.name)) { + seen.add(actor.identity.name); + entities.push({ name: actor.identity.displayName || actor.identity.name, type: actor.identity.type, className: "font-mono bg-gray-100 text-gray-700", icon: iconFor(IDENTITY_ICONS, actor.identity.type) }); + } + if (actor.endpoint?.ip && !seen.has(actor.endpoint.ip)) { + seen.add(actor.endpoint.ip); + entities.push({ name: actor.endpoint.ip, type: actor.endpoint.type || "ip", className: "font-mono bg-gray-100 text-gray-700", icon: iconFor(ENDPOINT_ICONS, actor.endpoint.type || "ip") }); + } + if (actor.app && !seen.has(actor.app.name)) { + seen.add(actor.app.name); + entities.push({ name: actor.app.name, type: actor.app.type || "app", className: "bg-indigo-50 text-indigo-700", icon: iconFor(APP_ICONS, actor.app.type || "default") }); + } + if (actor.resource && !seen.has(actor.resource.name)) { + seen.add(actor.resource.name); + entities.push({ name: actor.resource.name, type: actor.resource.type, scope: actor.resource.scope, className: "bg-blue-50 text-blue-700", icon: iconFor(RESOURCE_ICONS, actor.resource.type) }); + } + } + } + return entities; +} + +function findingMetrics(f: AuditFinding): Record | undefined { + const m: Record = { ...f.evidence.metrics }; + if (f.context?.baseline?.deviationFactor) m["Deviation"] = `${f.context.baseline.deviationFactor}x`; + return Object.keys(m).length > 0 ? m : undefined; +} + +function formatShortDateTime(iso: string): string { + return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function ActorPart({ icon, text, cls }: { icon?: React.ComponentType<{ className?: string }>; text: string; cls: string }) { + return {icon && React.createElement(icon, { className: "w-3 h-3" })}{text}; +} +function ActorCell({ actor }: { actor?: Actor }) { + if (!actor) return null; + const parts: React.ReactNode[] = []; + if (actor.identity) parts.push(); + if (actor.endpoint) parts.push(); + if (actor.app) parts.push(); + if (actor.resource) parts.push(); + return <>{parts.map((p, i) => {i > 0 && ·}{p})}; +} + +function EvidenceRows({ samples }: { samples: AuditSample[] }) { + const hasSrc = samples.some((s) => s.src); + const hasDst = samples.some((s) => s.dst); + const hasOk = samples.some((s) => s.succeeded != null); + const colCount = 2 + (hasSrc ? 1 : 0) + (hasDst ? 1 : 0) + (hasOk ? 1 : 0); + const th = "text-left pr-2 py-0.5 font-semibold"; + return ( + + + + + {hasSrc && } + + {hasDst && } + {hasOk && } + + + + {samples.map((s, i) => ( + + + + {hasSrc && } + + {hasDst && } + {hasOk && } + + {s.detail && ( + + + + )} + + ))} + +
TimeSourceActionDestinationOK
{formatShortDateTime(s.timestamp)}{s.action}{s.succeeded != null ? (s.succeeded ? "✓" : "✗") : ""}
{s.detail}
+ ); +} + +interface FindingProps { + id: string; title: string; summary: string; className?: string; + severity: { label: string; className?: string; dot?: string }; + subtitleTags?: BadgeProps[]; complianceTags?: BadgeProps[]; + timeRange?: { start: string; end: string; durationSeconds?: number }; + metrics?: Record; entities?: EntityEntry[]; + samples?: AuditSample[]; recommendation?: string; mitigations?: string[]; +} + +function Finding({ id, title, summary, severity, subtitleTags, complianceTags, className, + timeRange, metrics, entities, samples, recommendation, mitigations }: FindingProps) { + return ( +
+
+ {id} +

{title}

+ +
+ {subtitleTags && subtitleTags.length > 0 && ( +
+ {subtitleTags.map((t, i) => )} +
+ )} +

{summary}

+ {entities && entities.length > 0 && ( +
+ Affected Assets +
+ {entities.map((e, i) => ( + + ))} +
+
+ )} + {(samples?.length || timeRange || metrics) && ( +
+
+ Evidence + {timeRange && ( + + {formatDateTime(timeRange.start)} — {formatDateTime(timeRange.end)} + {timeRange.durationSeconds != null && ` (${formatDuration(timeRange.durationSeconds)})`} + + )} + {metrics && Object.entries(metrics).filter(([, v]) => v != null).map(([key, val]) => ( + {formatKey(key)} {typeof val === "number" ? val.toLocaleString() : String(val)} + ))} +
+ {samples && samples.length > 0 && } +
+ )} + {recommendation && ( +
+ Recommended Action +

{recommendation}

+ {mitigations && mitigations.length > 0 && ( +
    + {mitigations.map((m, i) =>
  1. {m}
  2. )} +
+ )} +
+ )} + {complianceTags && complianceTags.length > 0 && ( +
+ {complianceTags.map((t, i) => )} +
+ )} +
+ ); +} + + +function countBy(items: AuditFinding[], key: (f: AuditFinding) => string): { name: string; count: number }[] { + const map = new Map(); + for (const f of items) { const v = key(f); map.set(v, (map.get(v) || 0) + 1); } + return [...map.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count })); +} + +function BreakdownTable({ title, rows, iconMap }: { + title: string; + rows: { name: string; count: number }[]; + iconMap?: (value: unknown) => React.ReactNode; +}) { + return ( + + ); +} + +function dedupDataSources(findings: AuditFinding[]): DataSource[] { + const seen = new Set(); + return findings.filter((f) => { + if (!f.dataSource) return false; + const key = f.dataSource.connection || f.dataSource.path || ""; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).map((f) => f.dataSource!); +} + +function repoIcon(repo?: string): string { + if (!repo) return "git"; + if (repo.includes("github")) return "github"; + if (repo.includes("azure") || repo.includes("dev.azure")) return "azure-devops"; + if (repo.includes("gitlab")) return "gitlab"; + if (repo.includes("bitbucket")) return "bitbucket"; + return "git"; +} +function gitFileUrl(git: NonNullable): string | undefined { + if (!git.repo || !git.file) return undefined; + const sha = git.sha || git.branch || "main"; + if (git.repo.includes("github")) return `https://${git.repo}/blob/${sha}/${git.file}${git.lineNo ? `#L${git.lineNo}` : ""}`; + if (git.repo.includes("dev.azure")) return `https://${git.repo}?path=/${git.file}&version=GC${sha}${git.lineNo ? `&line=${git.lineNo}` : ""}`; + if (git.repo.includes("gitlab")) return `https://${git.repo}/-/blob/${sha}/${git.file}${git.lineNo ? `#L${git.lineNo}` : ""}`; + return undefined; +} +function fileTypeIcon(path?: string): string { + if (!path) return "vscode-icons:default-file"; + const ext = path.split(".").pop()?.toLowerCase(); + if (ext === "xlsx" || ext === "xls") return "vscode-icons:file-type-excel"; + if (ext === "csv") return "vscode-icons:file-type-excel2"; + if (ext === "json") return "vscode-icons:file-type-json"; + if (ext === "xml") return "vscode-icons:file-type-xml"; + if (ext === "parquet") return "vscode-icons:file-type-sql"; + if (ext === "yaml" || ext === "yml") return "vscode-icons:file-type-yaml"; + if (ext === "sql" || ext === "sqlaudit") return "vscode-icons:file-type-sql"; + if (ext === "log") return "vscode-icons:file-type-log"; + if (ext === "pdf") return "vscode-icons:file-type-pdf2"; + if (ext === "sqlite" || ext === "db") return "vscode-icons:file-type-sqlite"; + return "vscode-icons:default-file"; +} +function locationIcon(loc?: string): string { + if (!loc) return "server"; + if (loc === "sharepoint") return "sharepoint"; + if (loc === "google-drive") return "google-drive"; + if (loc === "onedrive") return "onedrive"; + if (loc === "network-share") return "server"; + return "server"; +} +const CATEGORY_BADGE: Record = { + "ai": "bg-purple-50 text-purple-600 border-purple-200", + "users": "bg-blue-50 text-blue-600 border-blue-200", + "groups": "bg-blue-50 text-blue-600 border-blue-200", + "roles": "bg-blue-50 text-blue-600 border-blue-200", + "access-logs": "bg-amber-50 text-amber-600 border-amber-200", + "audit-logs": "bg-amber-50 text-amber-600 border-amber-200", + "flow-logs": "bg-cyan-50 text-cyan-600 border-cyan-200", + "configuration": "bg-gray-100 text-gray-600 border-gray-200", +}; +function DataSourceCard({ ds }: { ds: DataSource }) { + const isFile = ds.type === "file" || ds.file; + const fileName = ds.file?.name || ds.path?.split("/").pop(); + const typeIcon = isFile ? fileTypeIcon(ds.file?.name || ds.path) : (ds.type || "database"); + const url = ds.git ? gitFileUrl(ds.git) : undefined; + const fullGitPath = ds.git ? [ds.git.repo, ds.git.file].filter(Boolean).join("/") + (ds.git.lineNo ? `:${ds.git.lineNo}` : "") : undefined; + const DsTypeIcon = typeIcon.startsWith("vscode-icons:") + ? + : ; + return ( +
+
+ {DsTypeIcon} + {isFile && fileName + ? {fileName} + : {ds.type || "file"}} + {ds.connection && {ds.connection}} + {ds.categories?.map((c) => ( + + ))} + + {ds.app && ( + + {ds.app.icon && } + {ds.app.name}{ds.app.version && ` v${ds.app.version}`} + + )} +
+ {ds.path &&
{ds.path}
} + {ds.file && ( +
+ {ds.file.location && ( + {ds.file.host || ds.file.location} + )} + {ds.file.size && {ds.file.size}} + {ds.file.created && Created {formatDateTime(ds.file.created)}} + {ds.file.modified && Modified {formatDateTime(ds.file.modified)}} +
+ )} + {ds.git && ( +
+ + {fullGitPath && (url + ? {fullGitPath} + : {fullGitPath} + )} + {ds.git.sha && {ds.git.sha}} + {ds.git.tag && {ds.git.tag}} + {ds.git.branch && {ds.git.branch}} +
+ )} + {ds.contentSha && ( +
+ + sha256:{ds.contentSha} +
+ )} + {ds.query &&
{ds.query}
} +
+ ); +} +function DataSourcesList({ findings }: { findings: AuditFinding[] }) { + const sources = dedupDataSources(findings); + const p = findings.find((f) => f.provenance)?.provenance; + const aiVendorIcon = (model?: string): string => { + if (!model) return "brain"; + const m = model.toLowerCase(); + if (m.includes("claude") || m.includes("anthropic")) return "claude"; + if (m.includes("gemini")) return "gemini"; + if (m.includes("gpt") || m.includes("openai") || m.includes("chatgpt")) return "openai"; + if (m.includes("ollama")) return "ollama"; + if (m.includes("mistral")) return "mistral"; + return "brain"; + }; + const aiCard: DataSource | null = p?.model ? { + type: aiVendorIcon(p.model), categories: ["ai"], + app: { name: p.generatedBy || "audit-log-analyzer", version: p.version, icon: aiVendorIcon(p.model) }, + connection: p.model, + path: p.runId ? `Run: ${p.runId}` : undefined, + } : null; + return ( +
+ {aiCard && } + {sources.map((ds, i) => )} +
+ ); +} + +function SummaryContent({ findings }: { findings: AuditFinding[] }) { + const criticalCount = findings.filter((f) => f.severity === "critical").length; + const highCount = findings.filter((f) => f.severity === "high").length; + const mediumCount = findings.filter((f) => f.severity === "medium").length; + + const outcomeCounts = countBy(findings, (f) => OUTCOME_STYLES[f.outcome].label); + const platformCounts = countBy(findings, (f) => PLATFORM_LABELS[f.platform]); + const categoryCounts = countBy(findings, (f) => f.category); + + return ( + <> +
+

Audit Findings Report

+

+ Generated {new Date().toLocaleDateString("en-ZA", { dateStyle: "long" })} · {findings.length} findings +

+
+ +
+ + + + +
+ +
+ { + const o = Object.entries(OUTCOME_STYLES).find(([, s]) => s.label === v); + return o ? : null; + }} /> + + { + const icon = CATEGORY_ICONS[v as string]; + return icon ? : null; + }} /> +
+ + + ); +} + +export default function FindingsReport(props: Record) { + const data = (props.data ?? props) as Record; + const findings: AuditFinding[] = Array.isArray(data.findings) ? data.findings + : Array.isArray(data) ? data : []; + const sorted = [...findings].sort((a, b) => SEVERITY_STYLES[a.severity].order - SEVERITY_STYLES[b.severity].order); + + const reportDate = new Date().toLocaleDateString("en-ZA", { dateStyle: "long" }); + return ( + +
+
+ + Audit Findings Report +
+
+
+
+ Confidential + {reportDate} + +
+
+ + + + + {sorted.map((f, i) => ( +
+ #{i + 1} + + {f.title} + {PLATFORM_LABELS[f.platform]} +
+ ))} +
+ + + + + {sorted.map((f, i) => ( + + ))} + +
+ ); +} diff --git a/report/KitchenSink.tsx b/report/KitchenSink.tsx new file mode 100644 index 000000000..114fad824 --- /dev/null +++ b/report/KitchenSink.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Page } from '@flanksource/facet'; +import type { KitchenSinkData } from './kitchen-sink/KitchenSinkTypes.ts'; +import CoverPage from './components/CoverPage.tsx'; +import PageHeaderComponent from './components/PageHeader.tsx'; +import PageFooterComponent from './components/PageFooter.tsx'; +import LayoutComponentsPage from './kitchen-sink/LayoutComponentsPage.tsx'; +import ConfigComponentsPage from './kitchen-sink/ConfigComponentsPage.tsx'; +import ChangesPage from './kitchen-sink/ChangesPage.tsx'; +import DynamicSectionsPage from './kitchen-sink/DynamicSectionsPage.tsx'; +import InsightsAndGraphPage from './kitchen-sink/InsightsAndGraphPage.tsx'; +import ApplicationPage from './kitchen-sink/ApplicationPage.tsx'; +import RBACPage from './kitchen-sink/RBACPage.tsx'; +import CatalogPage from './kitchen-sink/CatalogPage.tsx'; +import ViewPage from './kitchen-sink/ViewPage.tsx'; + +interface KitchenSinkProps { + data: KitchenSinkData; +} + +export default function KitchenSink({ data }: KitchenSinkProps) { + const generatedAt = new Date().toISOString(); + + const header = ; + const footer = ; + const pageProps = { + pageSize: 'a4' as const, + margins: { top: 5, bottom: 5, left: 5, right: 5 }, + header, + headerHeight: 10, + footer, + footerHeight: 10, + }; + + return ( + <> + + +

+ PDF-compatible components for rendering config items, changes, insights, and relationships. +

+
+
+ + + + + + + + + + + + ); +} diff --git a/report/MatrixDemo.tsx b/report/MatrixDemo.tsx new file mode 100644 index 000000000..d4f87b85a --- /dev/null +++ b/report/MatrixDemo.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { Document, Page } from '@flanksource/facet'; +import { MatrixTable, Dot } from '@flanksource/facet'; +import { + AccessIndicator, + IdentityIcon, + identityType, + ACCESS_COLORS, + STALE_COLORS, + ReviewOverdueBadge, + ReviewOverdueLegendSwatch, +} from './components/rbac-visual.tsx'; + +function Cell({ direct, overdue }: { direct: boolean; overdue?: boolean }) { + const color = direct ? ACCESS_COLORS.direct : ACCESS_COLORS.group; + return ( +
+ + {overdue && } +
+ ); +} + +function UserLabel({ name, userId, roleSource, staleBorderColor }: { name: string; userId: string; roleSource?: string; staleBorderColor?: string }) { + return ( + + + {name} + + ); +} + +export default function MatrixDemo() { + const roles = ['Read', 'Write', 'Execute', 'Admin', 'Delete', 'Audit']; + + return ( + + +

+ RBAC Matrix - Visual System Demo +

+ + {/* --- Reference Section --- */} +
+
+ Identity Types +
+
+ {(['user', 'group', 'service', 'bot'] as const).map((type) => ( + + + {identityType( + type === 'service' ? 'svc-x' : type === 'bot' ? 'bot-x' : 'x', + type === 'group' ? 'group:x' : 'direct', + ).label} + + ))} +
+
+ +
+
+ Access Pattern - Filled vs Unfilled +
+
+ + Direct (filled) + + + Indirect / Group (unfilled) + +
+
+ + {/* --- Matrix 1: Simple Permissions --- */} +
+ Simple Permissions +
+ , + cells: [ + , + , + , + , + null, + null, + ], + }, + { + label: , + cells: [ + , + , + null, null, null, null, + ], + }, + { + label: , + cells: [ + , + null, null, null, null, + , + ], + }, + { + label: , + cells: [ + , + , + , + null, null, null, + ], + }, + { + label: , + cells: [ + , + null, null, null, null, + , + ], + }, + { + label: , + cells: [ + null, null, null, + , + , + null, + ], + }, + { + label: , + cells: [ + , + , + null, null, null, null, + ], + }, + ]} + /> + + {/* --- Matrix 2: Database Roles --- */} +
+ Database Roles +
+ {(() => { + const dbRoles = ['db_datareader', 'db_datawriter', 'db_owner', 'db_securityadmin', 'db_backupoperator', 'db_ddladmin', 'db_accessadmin']; + return ( + , + cells: [null, null, , null, null, null, null], + }, + { + label: , + cells: [, null, null, null, null, null, null], + }, + { + label: , + cells: [null, null, , null, null, null, null], + }, + { + label: , + cells: [null, , null, null, , null, null], + }, + { + label: , + cells: [, null, null, null, null, null, ], + }, + { + label: , + cells: [, , null, null, null, null, null], + }, + ]} + /> + ); + })()} + + {/* --- Legend --- */} +
+ Access: + + Direct + + + Indirect + + + Last Login: + + + > 7d + + + + > 30d + + + Review: + +
+
+
+ ); +} diff --git a/report/RBACByUserReport.tsx b/report/RBACByUserReport.tsx index 150fb39b9..6fb7905c0 100644 --- a/report/RBACByUserReport.tsx +++ b/report/RBACByUserReport.tsx @@ -1,104 +1,43 @@ import React from 'react'; -import { Page, PageBreak } from '@flanksource/facet'; +import { Document, Page, Header, Footer } from '@flanksource/facet'; import type { RBACReport } from './rbac-types.ts'; import RBACSummarySection from './components/RBACSummarySection.tsx'; import RBACUserSection from './components/RBACUserSection.tsx'; import RBACChangelogSection from './components/RBACChangelogSection.tsx'; - -function PageHeader({ title }: { title: string }) { - return ( -
- {title} - RBAC Report (By User) -
- ); -} - -function PageFooter() { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); - return ( -
- Generated {date} -
- ); -} - -function CoverPage({ title, query }: { title: string; query?: string }) { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); - return ( -
-
-
- RBAC Report - By User -
-

- {title} -

- {query && ( -
- {query} -
- )} -
-
-
Generated on {date}
-
- ); -} +import RBACCoverContent from './components/RBACCoverContent.tsx'; +import { MatrixLegend } from './components/RBACMatrixSection.tsx'; +import PageHeader from './components/PageHeader.tsx'; +import PageFooter from './components/PageFooter.tsx'; interface Props { data: RBACReport; } export default function RBACByUserReportPage({ data }: Props) { - const header = ; - const footer = ; - const pageProps = { - pageSize: 'a4' as const, - margins: { top: 3, bottom: 3, left: 3, right: 3 }, - header, - headerHeight: 10, - footer, - footerHeight: 10, - }; - const users = data.users || []; return ( - <> - - + +
+ +
+
+ +
+ + + - - - + - - {users.map((user, idx) => ( - - - -
- -
-
-
- ))} + {users.map((user, idx) => ( + + ))} - {data.changelog.length > 0 && ( - <> - - - - - - )} - + +
+
); } diff --git a/report/RBACMatrixReport.tsx b/report/RBACMatrixReport.tsx new file mode 100644 index 000000000..82aea2312 --- /dev/null +++ b/report/RBACMatrixReport.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Document, Page, Header, Footer } from '@flanksource/facet'; +import type { RBACReport, RBACResource } from './rbac-types.ts'; +import RBACSummarySection from './components/RBACSummarySection.tsx'; +import RBACMatrixSection, { MatrixLegend } from './components/RBACMatrixSection.tsx'; +import RBACChangelogSection from './components/RBACChangelogSection.tsx'; +import RBACCoverContent from './components/RBACCoverContent.tsx'; +import PageHeader from './components/PageHeader.tsx'; +import PageFooter from './components/PageFooter.tsx'; + +function estimateHeight(resource: RBACResource): number { + const uniqueUsers = new Set((resource.users || []).map((u) => u.userId)).size; + return 20 + uniqueUsers * 3 + 5; +} + +function packResources(resources: RBACResource[], maxHeight: number): RBACResource[][] { + const pages: RBACResource[][] = []; + let current: RBACResource[] = []; + let currentHeight = 0; + + for (const r of resources) { + const h = estimateHeight(r); + if (currentHeight + h > maxHeight && current.length > 0) { + pages.push(current); + current = [r]; + currentHeight = h; + } else { + current.push(r); + currentHeight += h; + } + } + if (current.length > 0) pages.push(current); + return pages; +} + +interface RBACMatrixReportProps { + data: RBACReport; +} + +export default function RBACMatrixReportPage({ data }: RBACMatrixReportProps) { + const resourcePages = packResources(data.resources || [], 160); + + return ( + +
+ +
+
+ +
+ + + + + + + + + {resourcePages.map((group, pageIdx) => ( +
+ {group.map((resource, idx) => ( + + ))} +
+ ))} + + +
+
+ ); +} diff --git a/report/RBACReport.tsx b/report/RBACReport.tsx deleted file mode 100644 index c22925bee..000000000 --- a/report/RBACReport.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import { Page, PageBreak } from '@flanksource/facet'; -import type { RBACReport } from './rbac-types.ts'; -import RBACSummarySection from './components/RBACSummarySection.tsx'; -import RBACResourceSection from './components/RBACResourceSection.tsx'; -import RBACChangelogSection from './components/RBACChangelogSection.tsx'; - -function PageHeader({ title }: { title: string }) { - return ( -
- {title} - RBAC Report -
- ); -} - -function PageFooter() { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); - return ( -
- Generated {date} -
- ); -} - -function CoverPage({ title, query }: { title: string; query?: string }) { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); - return ( -
-
-
- RBAC Report -
-

- {title} -

- {query && ( -
- {query} -
- )} -
-
-
Generated on {date}
-
- ); -} - -interface RBACReportProps { - data: RBACReport; -} - -export default function RBACReportPage({ data }: RBACReportProps) { - const header = ; - const footer = ; - const pageProps = { - pageSize: 'a4' as const, - margins: { top: 3, bottom: 3, left: 3, right: 3 }, - header, - headerHeight: 10, - footer, - footerHeight: 10, - }; - - return ( - <> - - - - - - - - - - - {data.resources.map((resource, idx) => ( - - - -
- -
-
-
- ))} - - {data.changelog.length > 0 && ( - <> - - - - - - )} - - ); -} diff --git a/report/ViewReport.tsx b/report/ViewReport.tsx index f225b4df3..66612527b 100644 --- a/report/ViewReport.tsx +++ b/report/ViewReport.tsx @@ -1,66 +1,24 @@ import React from 'react'; -import { Page, PageBreak } from '@flanksource/facet'; -import { Icon } from '@flanksource/icons/icon'; +import { Document, Page, Header, Footer } from '@flanksource/facet'; import type { ViewReportData, MultiViewReportData } from './view-types.ts'; import ViewResultSection from './components/ViewResultSection.tsx'; +import CoverPage from './components/CoverPage.tsx'; +import PageHeader from './components/PageHeader.tsx'; +import PageFooter from './components/PageFooter.tsx'; -function PageHeader({ title, icon }: { title: string; icon?: string }) { - return ( -
- - {icon && } - {title} - - View Report -
- ); -} +function ViewCoverPage({ data }: { data: ViewReportData }) { + const variableTags = (data.variables || []).reduce((acc, v) => { + acc[v.label || v.key] = v.default || '-'; + return acc; + }, {} as Record); -function PageFooter() { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); return ( -
- Generated {date} -
- ); -} - -function CoverPage({ data }: { data: ViewReportData }) { - const date = new Date().toLocaleDateString('en-US', { - year: 'numeric', month: 'long', day: 'numeric', - }); - return ( -
-
- {data.icon && ( -
- -
- )} -

- {data.title || data.name} -

- {data.namespace && ( -
- {data.namespace}/{data.name} -
- )} -
- {data.variables && data.variables.length > 0 && ( -
- {data.variables.map((v) => ( - - {v.label || v.key}: - {v.default || '-'} - - ))} -
- )} -
-
Generated on {date}
-
+ 0 ? variableTags : undefined} + /> ); } @@ -74,35 +32,29 @@ interface ViewReportProps { export default function ViewReportPage({ data }: ViewReportProps) { const viewsList = isMultiView(data) ? data.views : [data]; + if (!viewsList.length) return null; const firstView = viewsList[0]; - const header = ; - const footer = ; - const pageProps = { - pageSize: 'a4' as const, - margins: { top: 3, bottom: 3, left: 3, right: 3 }, - header, - headerHeight: 10, - footer, - footerHeight: 10, - }; - return ( - <> - - + +
+ +
+
+ +
+ + + {viewsList.map((view, idx) => ( - - - -
- -
-
-
+ +
+ +
+
))} - +
); } diff --git a/report/build-kitchen-sink.ts b/report/build-kitchen-sink.ts new file mode 100644 index 000000000..42c08afa6 --- /dev/null +++ b/report/build-kitchen-sink.ts @@ -0,0 +1,165 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import yaml from 'js-yaml'; +import type { ConfigChange, ConfigSeverity } from './config-types.ts'; +import type { KitchenSinkData } from './kitchen-sink/KitchenSinkTypes.ts'; + +type SchemaExample = Record & { kind: string }; + +interface SchemaDefinition { + examples?: unknown[]; +} + +interface SchemaDocument { + $defs: Record; +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const baseDataPath = resolve(__dirname, 'testdata/kitchen-sink.yaml'); +// Schema examples are sourced from duty's generated change-types schema at compile time. +const schemaPath = resolve(__dirname, '../../duty/schema/openapi/change-types.schema.json'); +const outputPath = resolve(__dirname, 'kitchen-sink.json'); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSchemaExample(value: unknown): value is SchemaExample { + return isRecord(value) && typeof value.kind === 'string'; +} + +function asText(value: unknown): string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function kindBase(kind: string): string { + return kind.split('/')[0] ?? kind; +} + +function statusText(example: SchemaExample): string { + return asText(example.status)?.toLowerCase() ?? ''; +} + +function changeTypeForExample(example: SchemaExample): string { + const kind = example.kind; + const status = statusText(example); + + switch (kind) { + case 'Approval/v1': + if (status.includes('approved')) return 'Approved'; + if (status.includes('rejected')) return 'Rejected'; + return 'Approval'; + case 'Backup/v1': + if (status.includes('fail') || status.includes('error')) return 'BackupFailed'; + if (status.includes('running') || status.includes('pending') || status.includes('started') || status.includes('progress')) { + return 'BackupStarted'; + } + if (status.includes('complete') || status.includes('success')) return 'BackupCompleted'; + return 'Backup'; + case 'Restore/v1': + if (status.includes('complete') || status.includes('success')) return 'RestoreCompleted'; + return 'Restore'; + case 'Scale/v1': + return 'Scaling'; + case 'ConfigChange/v1': + return 'diff'; + default: + return kindBase(kind); + } +} + +function severityForExample(example: SchemaExample): ConfigSeverity { + const kind = example.kind; + const status = statusText(example); + + if (kind === 'Backup/v1' || kind === 'Restore/v1' || kind === 'Test/v1') { + if (status.includes('fail') || status.includes('error')) return 'high'; + if (status.includes('pending') || status.includes('running') || status.includes('started')) return 'low'; + } + + if (kind === 'Approval/v1') { + if (status.includes('rejected')) return 'medium'; + if (status.includes('pending')) return 'low'; + } + + if (kind === 'Scale/v1' || kind === 'PermissionChange/v1' || kind === 'UserChange/v1') { + return 'low'; + } + + return 'info'; +} + +function extractStandaloneExamples(schema: SchemaDocument): SchemaExample[] { + const standalone: SchemaExample[] = []; + const defs = schema.$defs ?? {}; + + const rootExamples = defs.ConfigChangeDetailsSchema?.examples ?? []; + for (const example of rootExamples) { + if (isSchemaExample(example)) { + standalone.push(clone(example)); + } + } + + for (const [name, definition] of Object.entries(defs)) { + if (name === 'ConfigChangeDetailsSchema') { + continue; + } + + for (const example of definition.examples ?? []) { + if (isSchemaExample(example)) { + standalone.push(clone(example)); + } + } + } + + return standalone; +} + +function buildSchemaExampleChanges(schema: SchemaDocument): ConfigChange[] { + const examples = extractStandaloneExamples(schema); + const startTimestamp = Date.parse('2026-04-10T23:59:00Z'); + + return examples.map((typedChange, index) => ({ + id: `schema-example-${String(index + 1).padStart(3, '0')}`, + configID: 'schema-example-catalog', + configName: 'Schema Example Catalog', + configType: 'Schema::Example', + changeType: changeTypeForExample(typedChange), + severity: severityForExample(typedChange), + source: 'schema-examples', + createdBy: 'schema-generator', + createdAt: new Date(startTimestamp - (index * 60_000)).toISOString(), + count: 1, + typedChange, + })); +} + +function compileKitchenSink(): KitchenSinkData { + const baseData = yaml.load(readFileSync(baseDataPath, 'utf-8')) as KitchenSinkData; + const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')) as SchemaDocument; + const schemaExampleChanges = buildSchemaExampleChanges(schema); + + return { + ...baseData, + changes: [...(baseData.changes ?? []), ...schemaExampleChanges], + }; +} + +const compiled = compileKitchenSink(); +writeFileSync(outputPath, JSON.stringify(compiled, null, 2) + '\n'); + +if (process.argv.includes('--stdout')) { + process.stdout.write(JSON.stringify(compiled, null, 2)); +} diff --git a/report/catalog-report-types.ts b/report/catalog-report-types.ts new file mode 100644 index 000000000..4b3560e11 --- /dev/null +++ b/report/catalog-report-types.ts @@ -0,0 +1,164 @@ +import type { ConfigChange, ConfigAnalysis, ConfigRelationship, ConfigItem } from './config-types.ts'; +import type { RBACResource } from './rbac-types.ts'; +import type { ScraperInfo } from './scraper-types.ts'; + +export interface CatalogReportCategoryMapping { + category?: string; + filter: string; + transform?: string; +} + +export interface CatalogReportSections { + changes: boolean; + insights: boolean; + relationships: boolean; + access: boolean; + accessLogs: boolean; + configJSON: boolean; +} + +export interface CatalogReportAccess { + configId?: string; + configName?: string; + configType?: string; + permalink?: string; + userId: string; + userName: string; + email: string; + role: string; + userType: string; + createdAt: string; + lastSignedInAt?: string; + lastReviewedAt?: string; +} + +export interface CatalogReportAccessLog { + configId?: string; + permalink?: string; + userId: string; + userName: string; + configName: string; + configType: string; + createdAt: string; + mfa: boolean; + count: number; + properties?: Record; +} + +export interface CatalogReportTreeNode extends ConfigItem { + edgeType?: 'parent' | 'child' | 'related' | 'target'; + relation?: string; + permalink?: string; + children?: CatalogReportTreeNode[]; +} + +export interface CatalogReportConfigGroup { + configItem: ConfigItem; + changes: ConfigChange[]; + analyses: ConfigAnalysis[]; + access: CatalogReportAccess[]; + accessLogs: CatalogReportAccessLog[]; +} + +export interface QueryLogEntry { + name: string; + args?: string; + count: number; + duration: number; + error?: string; + summary?: string; + pretty: string; +} + +export interface CatalogReportOptions { + title: string; + since: string; + sections: CatalogReportSections; + recursive: boolean; + groupBy: string; + changeArtifacts: boolean; + filters?: string[]; + thresholds?: { staleDays: number; reviewOverdueDays: number }; + categoryMappings?: CatalogReportCategoryMapping[]; +} + +export interface CatalogReportGroupMember { + userId: string; + name: string; + email?: string; + userType?: string; + lastSignedInAt?: string; + membershipAddedAt: string; + membershipDeletedAt?: string; +} + +export interface CatalogReportGroup { + id: string; + name: string; + groupType?: string; + members: CatalogReportGroupMember[]; +} + +export interface CatalogReportAudit { + buildCommit: string; + buildVersion: string; + gitStatus?: string; + options: CatalogReportOptions; + scrapers: ScraperInfo[]; + queries: QueryLogEntry[]; + groups: CatalogReportGroup[]; +} + +export interface CatalogReportData { + title: string; + generatedAt: string; + publicURL?: string; + from?: string; + to?: string; + recursive?: boolean; + groupBy?: string; + categoryMappings?: CatalogReportCategoryMapping[]; + thresholds?: { staleDays?: number; reviewOverdueDays?: number }; + configItem: ConfigItem & { + config?: string; + name: string; + type?: string; + id: string; + tags?: Record; + labels?: Record; + parent_id?: string; + created_at?: string; + updated_at?: string; + }; + parents: Array<{ + id: string; + name: string; + type?: string; + }>; + sections: CatalogReportSections; + changes: ConfigChange[]; + analyses: ConfigAnalysis[]; + relationships: ConfigRelationship[]; + relatedConfigs: ConfigItem[]; + access: CatalogReportAccess[]; + accessLogs: CatalogReportAccessLog[]; + configJSON?: string; + configGroups?: CatalogReportConfigGroup[]; + relationshipTree?: CatalogReportTreeNode; + entries?: CatalogReportEntry[]; + audit?: CatalogReportAudit; +} + +export interface CatalogReportEntry { + configItem: ConfigItem & { permalink?: string }; + parents?: Array; + relationshipTree?: CatalogReportTreeNode; + changeCount: number; + insightCount: number; + accessCount: number; + rbacResources?: RBACResource[]; + changes: ConfigChange[]; + analyses: ConfigAnalysis[]; + access: CatalogReportAccess[]; + accessLogs: CatalogReportAccessLog[]; +} diff --git a/report/catalog/change_mappings.go b/report/catalog/change_mappings.go new file mode 100644 index 000000000..425fa0376 --- /dev/null +++ b/report/catalog/change_mappings.go @@ -0,0 +1,309 @@ +package catalog + +import ( + "fmt" + "reflect" + + dutyAPI "github.com/flanksource/duty/api" + dutyContext "github.com/flanksource/duty/context" + reportAPI "github.com/flanksource/incident-commander/api" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" +) + +type changeMapper struct { + mappings []compiledCategoryMapping +} + +type compiledCategoryMapping struct { + mapping reportAPI.CatalogReportCategoryMapping + filter cel.Program + transform cel.Program +} + +func newChangeMapper(ctx dutyContext.Context, mappings []reportAPI.CatalogReportCategoryMapping) (*changeMapper, error) { + if len(mappings) == 0 { + return nil, nil + } + + envOptions := []cel.EnvOption{ + cel.Variable("change", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("details", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("typedChange", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("artifacts", cel.ListType(cel.DynType)), + cel.Variable("id", cel.StringType), + cel.Variable("configID", cel.StringType), + cel.Variable("configName", cel.StringType), + cel.Variable("configType", cel.StringType), + cel.Variable("permalink", cel.StringType), + cel.Variable("changeType", cel.StringType), + cel.Variable("category", cel.StringType), + cel.Variable("severity", cel.StringType), + cel.Variable("source", cel.StringType), + cel.Variable("summary", cel.StringType), + cel.Variable("createdBy", cel.StringType), + cel.Variable("externalCreatedBy", cel.StringType), + cel.Variable("createdAt", cel.StringType), + cel.Variable("count", cel.IntType), + } + + for _, fn := range dutyContext.CelEnvFuncs { + envOptions = append(envOptions, fn(ctx)) + } + + env, err := cel.NewEnv(envOptions...) + if err != nil { + return nil, ctx.Oops().Wrap(err) + } + + compiled := make([]compiledCategoryMapping, 0, len(mappings)) + for i, mapping := range mappings { + if mapping.Filter == "" { + return nil, dutyAPI.Errorf(dutyAPI.EINVALID, "categoryMappings[%d] filter is required", i) + } + if mapping.Category == "" && mapping.Transform == "" { + return nil, dutyAPI.Errorf(dutyAPI.EINVALID, "categoryMappings[%d] must define category or transform", i) + } + + filter, err := compileChangeMappingProgram(env, mapping.Filter) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to compile categoryMappings[%d] filter", i) + } + + var transform cel.Program + if mapping.Transform != "" { + transform, err = compileChangeMappingProgram(env, mapping.Transform) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to compile categoryMappings[%d] transform", i) + } + } + + compiled = append(compiled, compiledCategoryMapping{ + mapping: mapping, + filter: filter, + transform: transform, + }) + } + + return &changeMapper{mappings: compiled}, nil +} + +func compileChangeMappingProgram(env *cel.Env, expression string) (cel.Program, error) { + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + return nil, issues.Err() + } + + return env.Program(ast) +} + +func (m *changeMapper) Apply(change *reportAPI.CatalogReportChange) error { + if change == nil { + return nil + } + + env := changeMappingEnv(change) + category := change.Category + typedChange := change.TypedChange + + if m != nil { + for _, mapping := range m.mappings { + matched, err := evalMappingFilter(mapping.filter, env) + if err != nil { + return fmt.Errorf("failed to evaluate filter %q: %w", mapping.mapping.Filter, err) + } + if !matched { + continue + } + + if category == "" && mapping.mapping.Category != "" { + category = mapping.mapping.Category + env["category"] = category + changeEnv := env["change"].(map[string]any) + changeEnv["category"] = category + } + + if typedChange == nil && mapping.transform != nil { + typedChange, err = evalMappingTransform(mapping.transform, env) + if err != nil { + return fmt.Errorf("failed to evaluate transform %q: %w", mapping.mapping.Transform, err) + } + if typedChange != nil { + env["typedChange"] = typedChange + changeEnv := env["change"].(map[string]any) + changeEnv["typedChange"] = typedChange + } + } + + if category != "" && typedChange != nil { + break + } + } + } + + if change.Category == "" { + change.Category = category + } + if change.TypedChange == nil { + if typedChange != nil { + change.TypedChange = typedChange + } else { + change.TypedChange = typedChangeFromDetails(change.Details) + } + } + + return nil +} + +func evalMappingFilter(program cel.Program, env map[string]any) (bool, error) { + out, _, err := program.Eval(env) + if err != nil { + return false, err + } + + value, ok := celValueToNative(out).(bool) + if !ok { + return false, fmt.Errorf("filter returned %T, expected bool", celValueToNative(out)) + } + + return value, nil +} + +func evalMappingTransform(program cel.Program, env map[string]any) (map[string]any, error) { + out, _, err := program.Eval(env) + if err != nil { + return nil, err + } + + value, ok := celValueToNative(out).(map[string]any) + if !ok { + return nil, nil + } + + kind, _ := value["kind"].(string) + if kind == "" { + return nil, nil + } + + return value, nil +} + +func typedChangeFromDetails(details map[string]any) map[string]any { + if len(details) == 0 { + return nil + } + + kind, _ := details["kind"].(string) + if kind == "" { + return nil + } + + return celValueToNative(details).(map[string]any) +} + +func changeMappingEnv(change *reportAPI.CatalogReportChange) map[string]any { + details := map[string]any{} + if change.Details != nil { + details = celValueToNative(change.Details).(map[string]any) + } + + typedChange := map[string]any{} + if change.TypedChange != nil { + typedChange = celValueToNative(change.TypedChange).(map[string]any) + } + + artifacts := make([]any, 0, len(change.Artifacts)) + for _, artifact := range change.Artifacts { + artifacts = append(artifacts, map[string]any{ + "id": artifact.ID, + "filename": artifact.Filename, + "contentType": artifact.ContentType, + "size": artifact.Size, + "dataUri": artifact.DataURI, + }) + } + + changeEnv := map[string]any{ + "id": change.ID, + "configID": change.ConfigID, + "configName": change.ConfigName, + "configType": change.ConfigType, + "permalink": change.Permalink, + "changeType": change.ChangeType, + "category": change.Category, + "severity": change.Severity, + "source": change.Source, + "summary": change.Summary, + "details": details, + "typedChange": typedChange, + "createdBy": change.CreatedBy, + "externalCreatedBy": change.ExternalCreatedBy, + "createdAt": change.CreatedAt, + "count": int64(change.Count), + "artifacts": artifacts, + } + + return map[string]any{ + "change": changeEnv, + "details": details, + "typedChange": typedChange, + "artifacts": artifacts, + "id": change.ID, + "configID": change.ConfigID, + "configName": change.ConfigName, + "configType": change.ConfigType, + "permalink": change.Permalink, + "changeType": change.ChangeType, + "category": change.Category, + "severity": change.Severity, + "source": change.Source, + "summary": change.Summary, + "createdBy": change.CreatedBy, + "externalCreatedBy": change.ExternalCreatedBy, + "createdAt": change.CreatedAt, + "count": int64(change.Count), + } +} + +func celValueToNative(value any) any { + switch v := value.(type) { + case nil, bool, string, int, int32, int64, uint, uint32, uint64, float32, float64: + return v + case map[string]any: + out := make(map[string]any, len(v)) + for key, item := range v { + out[key] = celValueToNative(item) + } + return out + case []any: + out := make([]any, 0, len(v)) + for _, item := range v { + out = append(out, celValueToNative(item)) + } + return out + case ref.Val: + return celValueToNative(v.Value()) + } + + rv := reflect.ValueOf(value) + if !rv.IsValid() { + return nil + } + + switch rv.Kind() { + case reflect.Map: + out := map[string]any{} + for _, key := range rv.MapKeys() { + out[fmt.Sprint(celValueToNative(key.Interface()))] = celValueToNative(rv.MapIndex(key).Interface()) + } + return out + case reflect.Slice, reflect.Array: + out := make([]any, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out = append(out, celValueToNative(rv.Index(i).Interface())) + } + return out + } + + return value +} diff --git a/report/catalog/change_mappings_test.go b/report/catalog/change_mappings_test.go new file mode 100644 index 000000000..527d4214b --- /dev/null +++ b/report/catalog/change_mappings_test.go @@ -0,0 +1,100 @@ +package catalog + +import ( + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + dutyContext "github.com/flanksource/duty/context" + "github.com/flanksource/duty/query" + "github.com/flanksource/incident-commander/api" +) + +var _ = ginkgo.Describe("ChangeMappings", func() { + var ctx dutyContext.Context + + ginkgo.BeforeEach(func() { + ctx = dutyContext.New() + }) + + ginkgo.It("applies category and transform independently", func() { + mapper, err := newChangeMapper(ctx, []api.CatalogReportCategoryMapping{ + { + Category: "backup.failed", + Filter: `changeType == "BackupFailed"`, + }, + { + Filter: `"kind" in details && details["kind"] == "Backup/v1"`, + Transform: `details`, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + change := api.CatalogReportChange{ + ChangeType: "BackupFailed", + Details: map[string]any{ + "kind": "Backup/v1", + "status": "failed", + "target": "prod-db", + }, + } + + Expect(mapper.Apply(&change)).To(Succeed()) + Expect(change.Category).To(Equal("backup.failed")) + Expect(change.TypedChange).To(HaveKeyWithValue("kind", "Backup/v1")) + Expect(change.TypedChange).To(HaveKeyWithValue("target", "prod-db")) + }) + + ginkgo.It("ignores transform results without a kind", func() { + mapper, err := newChangeMapper(ctx, []api.CatalogReportCategoryMapping{ + { + Filter: `changeType == "ScalingReplicaSet"`, + Transform: `{"from_replicas": 1, "to_replicas": 3}`, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + change := api.CatalogReportChange{ChangeType: "ScalingReplicaSet"} + Expect(mapper.Apply(&change)).To(Succeed()) + Expect(change.TypedChange).To(BeNil()) + }) + + ginkgo.It("hydrates typedChange from typed details without a transform rule", func() { + var mapper *changeMapper + change := api.CatalogReportChange{ + ChangeType: "PipelineRunCompleted", + Details: map[string]any{ + "kind": "PipelineRun/v1", + "pipeline_name": "deploy-api", + "status": "completed", + }, + } + + Expect(mapper.Apply(&change)).To(Succeed()) + Expect(change.TypedChange).To(HaveKeyWithValue("kind", "PipelineRun/v1")) + Expect(change.TypedChange).To(HaveKeyWithValue("pipeline_name", "deploy-api")) + }) + + ginkgo.It("threads decoded details onto report changes", func() { + change := newCatalogReportChangeFromRow(queryChange("chg-1", "BackupCompleted"), "prod-db", "AWS::RDS::DBInstance", map[string]any{ + "kind": "Backup/v1", + "status": "completed", + }) + + Expect(change.ConfigName).To(Equal("prod-db")) + Expect(change.ConfigType).To(Equal("AWS::RDS::DBInstance")) + Expect(change.Details).To(HaveKeyWithValue("kind", "Backup/v1")) + Expect(change.Details).To(HaveKeyWithValue("status", "completed")) + }) +}) + +func queryChange(id, changeType string) query.ConfigChangeRow { + return query.ConfigChangeRow{ + ID: id, + ConfigID: "cfg-1", + ChangeType: changeType, + Severity: "info", + Source: "unit-test", + Summary: "summary", + Count: 1, + } +} diff --git a/report/catalog/default-settings.yaml b/report/catalog/default-settings.yaml new file mode 100644 index 000000000..6e50ae1ef --- /dev/null +++ b/report/catalog/default-settings.yaml @@ -0,0 +1,34 @@ +filters: + - "type!=Kubernetes::ConfigMap" + - "type!=Kubernetes::Secret" + - "type!=Kubernetes::Event" + +thresholds: + staleDays: 90 + reviewOverdueDays: 90 + +categoryMappings: + - category: rbac.granted + filter: 'changeType == "PermissionGranted" || changeType == "PermissionAdded" || changeType == "IAMRoleAdded"' + - category: rbac.revoked + filter: 'changeType == "PermissionRevoked" || changeType == "PermissionRemoved" || changeType == "IAMRoleRemoved"' + - category: backup.success + filter: 'changeType == "BackupCompleted" || changeType == "BackupSuccessful" || (changeType == "BACKUP_DB" && severity == "low") || (("kind" in details && details["kind"] == "Backup/v1") && ("status" in details && (details["status"] == "success" || details["status"] == "successful" || details["status"] == "completed")))' + - category: backup.failed + filter: 'changeType == "BackupFailed" || (changeType == "BACKUP_DB" && severity == "high") || (("kind" in details && details["kind"] == "Backup/v1") && ("status" in details && (details["status"] == "failed" || details["status"] == "error")))' + - category: backup.success + filter: 'changeType == "BACKUP_DB" && severity != "high"' + - category: backup.progress + filter: 'changeType == "BackupStarted" || changeType == "BackupRunning" || changeType == "BackupEnqueued" || (("kind" in details && details["kind"] == "Backup/v1") && ("status" in details && (details["status"] == "started" || details["status"] == "running" || details["status"] == "enqueued" || details["status"] == "progress")))' + - category: backup.restore + filter: 'changeType == "BackupRestored" || changeType == "RestoreCompleted" || (("kind" in details && details["kind"] == "Backup/v1") && ("status" in details && (details["status"] == "restored" || details["status"] == "restore_completed")))' + - category: deployment.spec + filter: 'changeType == "diff" || ("kind" in details && details["kind"] == "Deployment/v1")' + - category: deployment.success + filter: 'changeType == "CodeDeployment" && severity == "info"' + - category: deployment.failed + filter: 'changeType == "CodeDeployment" && severity == "failed"' + - category: deployment.scale + filter: 'changeType == "ScalingReplicaSet" || ("kind" in details && details["kind"] == "Scaling/v1")' + - category: deployment.policy + filter: 'changeType == "PolicyUpdate"' diff --git a/report/catalog/export.go b/report/catalog/export.go new file mode 100644 index 000000000..67fca7709 --- /dev/null +++ b/report/catalog/export.go @@ -0,0 +1,264 @@ +package catalog + +import ( + "encoding/json" + "os/exec" + "sort" + "strings" + "time" + + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + "github.com/google/uuid" + + "github.com/flanksource/incident-commander/api" + "github.com/flanksource/incident-commander/db" + "github.com/flanksource/incident-commander/report/scraper" +) + +type ExportResult struct { + Data []byte + SrcDir string + Entry string + DataFile string + Settings string +} + +func Export(ctx context.Context, configs []models.ConfigItem, opts Options, format string) (*ExportResult, error) { + var queryLog *query.QueryLog + if opts.Audit { + ctx, queryLog = query.WithQueryLog(ctx) + } + + r, scraperIDs, err := BuildReport(ctx, configs, opts) + if err != nil { + return nil, err + } + + ctx.Logger.V(3).Infof("Report built: %d entries, %d changes, %d analyses", + len(r.Entries), len(r.Changes), len(r.Analyses)) + + if opts.Audit { + r.Audit = buildAudit(ctx, opts, configs, scraperIDs, queryLog) + } + + result := &ExportResult{} + if opts.Settings != nil { + result.Settings = opts.SettingsPath + } + + switch format { + case "html", "facet-html": + result.Data, result.SrcDir, result.Entry, result.DataFile, err = renderFacetResult(ctx, r, "html") + case "pdf", "facet-pdf": + result.Data, result.SrcDir, result.Entry, result.DataFile, err = renderFacetResult(ctx, r, "pdf") + default: + result.Data, err = json.MarshalIndent(r, "", " ") + } + + return result, err +} + +func buildAudit(ctx context.Context, opts Options, configs []models.ConfigItem, scraperIDs []string, queryLog *query.QueryLog) *api.CatalogReportAudit { + audit := &api.CatalogReportAudit{ + BuildCommit: api.BuildCommit, + BuildVersion: api.BuildVersion, + Options: api.CatalogReportOptions{ + Title: opts.Title, + Since: opts.Since.String(), + Sections: opts.Sections, + Recursive: opts.Recursive, + GroupBy: opts.GroupBy, + ChangeArtifacts: opts.ChangeArtifacts, + }, + Scrapers: []api.ScraperInfo{}, + Queries: []api.CatalogReportQuery{}, + Groups: []api.CatalogReportGroup{}, + } + + if opts.Settings != nil { + audit.Options.Filters = opts.Settings.Filters + if opts.Settings.Thresholds.StaleDays > 0 || opts.Settings.Thresholds.ReviewOverdueDays > 0 { + audit.Options.Thresholds = &api.CatalogReportThresholds{ + StaleDays: opts.StaleDays(), + ReviewOverdueDays: opts.ReviewOverdueDays(), + } + } + audit.Options.CategoryMappings = opts.Settings.CategoryMappings + } + + audit.GitStatus = gitStatus() + + if queryLog != nil { + for _, e := range queryLog.Entries() { + audit.Queries = append(audit.Queries, api.CatalogReportQuery{ + Name: e.Name, + Args: e.Args, + Count: e.Count, + Duration: e.Duration, + Error: e.Error, + Summary: e.Summary, + Pretty: e.Pretty, + }) + } + } + + sort.Strings(scraperIDs) + for _, sid := range scraperIDs { + id, err := uuid.Parse(sid) + if err != nil { + continue + } + info, err := scraper.BuildScraperInfo(ctx, id) + if err != nil { + ctx.Logger.V(2).Infof("failed to build scraper info for %s: %v", sid, err) + continue + } + audit.Scrapers = append(audit.Scrapers, *info) + } + + audit.Groups = buildAuditGroups(ctx, configs) + + return audit +} + +func buildAuditGroups(ctx context.Context, configs []models.ConfigItem) []api.CatalogReportGroup { + if len(configs) == 0 { + return []api.CatalogReportGroup{} + } + + configIDs := make([]uuid.UUID, 0, len(configs)) + for _, c := range configs { + configIDs = append(configIDs, c.ID) + } + + rows, err := db.GetGroupMembersForConfigs(ctx, configIDs) + if err != nil { + ctx.Logger.V(2).Infof("failed to load group members for audit: %v", err) + return []api.CatalogReportGroup{} + } + + // Preserve the SQL ORDER BY (group_name, then deleted-last, then user_name) + // by accumulating in first-seen order. + byID := map[uuid.UUID]*api.CatalogReportGroup{} + order := []uuid.UUID{} + for _, r := range rows { + g, ok := byID[r.GroupID] + if !ok { + g = &api.CatalogReportGroup{ + ID: r.GroupID.String(), + Name: r.GroupName, + GroupType: r.GroupType, + Members: []api.CatalogReportGroupMember{}, + } + byID[r.GroupID] = g + order = append(order, r.GroupID) + } + + member := api.CatalogReportGroupMember{ + UserID: r.UserID.String(), + Name: r.UserName, + Email: r.Email, + UserType: r.UserType, + MembershipAddedAt: r.MembershipAddedAt.Format(time.RFC3339), + } + if r.LastSignedInAt != nil { + s := r.LastSignedInAt.Format(time.RFC3339) + member.LastSignedInAt = &s + } + if r.MembershipDeletedAt != nil { + s := r.MembershipDeletedAt.Format(time.RFC3339) + member.MembershipDeletedAt = &s + } + g.Members = append(g.Members, member) + } + + out := make([]api.CatalogReportGroup, 0, len(order)) + for _, id := range order { + out = append(out, *byID[id]) + } + return out +} + +func gitStatus() string { + out, err := exec.Command("git", "status", "--short").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func initSlices(r *api.CatalogReport) api.CatalogReport { + out := *r + if out.Entries == nil { + out.Entries = []api.CatalogReportEntry{} + } + for i := range out.Entries { + if out.Entries[i].Parents == nil { + out.Entries[i].Parents = []api.CatalogReportConfigItem{} + } + if out.Entries[i].Changes == nil { + out.Entries[i].Changes = []api.CatalogReportChange{} + } + if out.Entries[i].Analyses == nil { + out.Entries[i].Analyses = []api.CatalogReportAnalysis{} + } + if out.Entries[i].Access == nil { + out.Entries[i].Access = []api.CatalogReportAccess{} + } + if out.Entries[i].AccessLogs == nil { + out.Entries[i].AccessLogs = []api.CatalogReportAccessLog{} + } + } + if out.Parents == nil { + out.Parents = []models.ConfigItem{} + } + if out.Changes == nil { + out.Changes = []api.CatalogReportChange{} + } + if out.Analyses == nil { + out.Analyses = []api.CatalogReportAnalysis{} + } + if out.Relationships == nil { + out.Relationships = []api.CatalogReportRelationship{} + } + if out.RelatedConfigs == nil { + out.RelatedConfigs = []api.CatalogReportConfigItem{} + } + if out.Access == nil { + out.Access = []api.CatalogReportAccess{} + } + if out.AccessLogs == nil { + out.AccessLogs = []api.CatalogReportAccessLog{} + } + if out.ConfigGroups == nil { + out.ConfigGroups = []api.CatalogReportConfigGroup{} + } + for i := range out.ConfigGroups { + if out.ConfigGroups[i].Changes == nil { + out.ConfigGroups[i].Changes = []api.CatalogReportChange{} + } + if out.ConfigGroups[i].Analyses == nil { + out.ConfigGroups[i].Analyses = []api.CatalogReportAnalysis{} + } + if out.ConfigGroups[i].Access == nil { + out.ConfigGroups[i].Access = []api.CatalogReportAccess{} + } + if out.ConfigGroups[i].AccessLogs == nil { + out.ConfigGroups[i].AccessLogs = []api.CatalogReportAccessLog{} + } + } + if out.Audit != nil { + if out.Audit.Scrapers == nil { + out.Audit.Scrapers = []api.ScraperInfo{} + } + if out.Audit.Queries == nil { + out.Audit.Queries = []api.CatalogReportQuery{} + } + if out.Audit.Groups == nil { + out.Audit.Groups = []api.CatalogReportGroup{} + } + } + return out +} diff --git a/report/catalog/render_facet.go b/report/catalog/render_facet.go new file mode 100644 index 000000000..102988d4b --- /dev/null +++ b/report/catalog/render_facet.go @@ -0,0 +1,36 @@ +package catalog + +import ( + "fmt" + + "github.com/flanksource/duty/context" + + "github.com/flanksource/incident-commander/api" + "github.com/flanksource/incident-commander/report" +) + +func renderFacetResult(ctx context.Context, r *api.CatalogReport, format string) (data []byte, srcDir, entry, dataFile string, err error) { + if r == nil { + return nil, "", "", "", fmt.Errorf("catalog report must not be nil") + } + + ctx.Logger.V(3).Infof("Rendering catalog facet-%s", format) + + result, err := report.RenderCLI(initSlices(r), format, "CatalogReport.tsx") + if err != nil { + return nil, "", "", "", err + } + + ctx.Logger.V(3).Infof("Facet rendered %dKB of %s", len(result.Data)/1024, format) + return result.Data, result.SrcDir, result.Entry, result.DataFile, nil +} + +func RenderFacetHTML(ctx context.Context, r *api.CatalogReport) ([]byte, error) { + data, _, _, _, err := renderFacetResult(ctx, r, "html") + return data, err +} + +func RenderFacetPDF(ctx context.Context, r *api.CatalogReport) ([]byte, error) { + data, _, _, _, err := renderFacetResult(ctx, r, "pdf") + return data, err +} diff --git a/report/catalog/report.go b/report/catalog/report.go new file mode 100644 index 000000000..7985cd45e --- /dev/null +++ b/report/catalog/report.go @@ -0,0 +1,670 @@ +package catalog + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + dutyTypes "github.com/flanksource/duty/types" + "github.com/google/uuid" + "github.com/samber/lo" + + "github.com/flanksource/incident-commander/api" + "github.com/flanksource/incident-commander/db" +) + +type Options struct { + Title string + Since time.Duration + Sections api.CatalogReportSections + Recursive bool + GroupBy string // "merged" (default) or "config" + ChangeArtifacts bool + Audit bool + Settings *Settings + SettingsPath string + + // Limit caps the number of config items (including recursive descendants) + // included in the report. 0 = unlimited. + Limit int + // MaxItems caps each per-entry section (changes, analyses, access, + // access-logs). Section-specific overrides take precedence. 0 = unlimited. + MaxItems int + // MaxChanges overrides MaxItems for the changes section. 0 = unlimited. + MaxChanges int +} + +// effectiveMax resolves the cap for a section, taking the tighter of an +// optional section-specific override and the generic MaxItems floor. A return +// of 0 means "no cap". +func (o Options) effectiveMax(override int) int { + switch { + case override > 0 && o.MaxItems > 0: + if override < o.MaxItems { + return override + } + return o.MaxItems + case override > 0: + return override + default: + return o.MaxItems + } +} + +// pageSizeFor converts an effectiveMax result into a duty PageSize value. +// duty's BaseCatalogSearch.SetDefaults forces PageSize<=0 to 50, so "unlimited" +// must be expressed as a large sentinel. +func (o Options) pageSizeFor(override int) int { + if n := o.effectiveMax(override); n > 0 { + return n + } + return math.MaxInt32 +} + +func (o Options) StaleDays() int { + if o.Settings != nil && o.Settings.Thresholds.StaleDays > 0 { + return o.Settings.Thresholds.StaleDays + } + return 90 +} + +func (o Options) ReviewOverdueDays() int { + if o.Settings != nil && o.Settings.Thresholds.ReviewOverdueDays > 0 { + return o.Settings.Thresholds.ReviewOverdueDays + } + return 90 +} + +func (o Options) WithDefaults() Options { + if o.Since == 0 { + o.Since = 30 * 24 * time.Hour + } + if o.Title == "" { + o.Title = "Catalog Report" + } + if o.GroupBy == "" { + o.GroupBy = "merged" + } + return o +} + +func BuildReport(ctx context.Context, configs []models.ConfigItem, opts Options) (*api.CatalogReport, []string, error) { + if len(configs) == 0 { + return nil, nil, fmt.Errorf("no config items provided") + } + opts = opts.WithDefaults() + sinceTime := time.Now().Add(-opts.Since) + var mappings []api.CatalogReportCategoryMapping + if opts.Settings != nil { + mappings = opts.Settings.CategoryMappings + } + mapper, err := newChangeMapper(ctx, mappings) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize change mappings: %w", err) + } + + report := &api.CatalogReport{ + Title: opts.Title, + GeneratedAt: time.Now(), + PublicURL: api.FrontendURL, + From: sinceTime.Format(time.RFC3339), + ConfigItem: configs[0], + Sections: opts.Sections, + Recursive: opts.Recursive, + GroupBy: opts.GroupBy, + } + + report.Parents = resolveParents(ctx, &configs[0]) + + if opts.Limit > 0 && len(configs) > opts.Limit { + configs = configs[:opts.Limit] + } + + scraperIDSet := make(map[string]bool) + for _, config := range configs { + if config.ScraperID != nil && *config.ScraperID != "" { + scraperIDSet[*config.ScraperID] = true + } + } + + for _, config := range configs { + entry, entryScraperIDs, err := buildEntryWithMapper(ctx, &config, opts, sinceTime, mapper) + if err != nil { + return nil, nil, fmt.Errorf("failed to build entry for %s: %w", config.GetName(), err) + } + report.Entries = append(report.Entries, *entry) + + report.Changes = append(report.Changes, entry.Changes...) + report.Analyses = append(report.Analyses, entry.Analyses...) + report.Access = append(report.Access, entry.Access...) + report.AccessLogs = append(report.AccessLogs, entry.AccessLogs...) + if report.RelationshipTree == nil && entry.RelationshipTree != nil { + report.RelationshipTree = entry.RelationshipTree + } + + for _, id := range entryScraperIDs { + scraperIDSet[id] = true + } + } + + if opts.Sections.ConfigJSON && configs[0].Config != nil { + report.ConfigJSON = configs[0].Config + } + + if opts.GroupBy == "config" { + report.Changes = nil + report.Analyses = nil + report.Access = nil + report.AccessLogs = nil + } + + if opts.Settings != nil { + if len(opts.Settings.CategoryMappings) > 0 { + report.CategoryMappings = opts.Settings.CategoryMappings + } + report.Thresholds = &api.CatalogReportThresholds{ + StaleDays: opts.StaleDays(), + ReviewOverdueDays: opts.ReviewOverdueDays(), + } + } + + var scraperIDs []string + for id := range scraperIDSet { + scraperIDs = append(scraperIDs, id) + } + + return report, scraperIDs, nil +} + +func buildEntryWithMapper(ctx context.Context, config *models.ConfigItem, opts Options, sinceTime time.Time, mapper *changeMapper) (*api.CatalogReportEntry, []string, error) { + entry := &api.CatalogReportEntry{ + ConfigItem: api.NewCatalogReportConfigItem(*config), + } + + parents := resolveParents(ctx, config) + entry.Parents = lo.Map(parents, func(p models.ConfigItem, _ int) api.CatalogReportConfigItem { + return api.NewCatalogReportConfigItem(p) + }) + + tree, err := query.ConfigTree(ctx, config.ID, query.ConfigTreeOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to build config tree: %w", err) + } + + targetIDs := tree.OutgoingIDs() + configMap := make(map[uuid.UUID]models.ConfigItem) + items, err := query.GetConfigsByIDs(ctx, targetIDs) + if err != nil { + return nil, nil, fmt.Errorf("failed to load config items: %w", err) + } + var scraperIDs []string + for _, ci := range items { + configMap[ci.ID] = ci + if ci.ScraperID != nil && *ci.ScraperID != "" { + scraperIDs = append(scraperIDs, *ci.ScraperID) + } + } + + configMeta := func(configID string) (string, string) { + if id, err := uuid.Parse(configID); err == nil { + if ci, ok := configMap[id]; ok { + typ := "" + if ci.Type != nil { + typ = *ci.Type + } + return ci.GetName(), typ + } + } + return "", "" + } + + catalogIDsCSV := strings.Join(lo.Map(targetIDs, func(id uuid.UUID, _ int) string { return id.String() }), ",") + + if opts.Sections.Changes { + resp, err := query.FindCatalogChanges(ctx, query.CatalogChangesSearchRequest{ + BaseCatalogSearch: query.BaseCatalogSearch{ + CatalogID: catalogIDsCSV, + FromTime: &sinceTime, + PageSize: opts.pageSizeFor(opts.MaxChanges), + }, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to get changes: %w", err) + } + detailsByID, err := loadCatalogChangeDetails(ctx, resp.Changes) + if err != nil { + return nil, nil, fmt.Errorf("failed to load change details: %w", err) + } + entry.Changes = make([]api.CatalogReportChange, 0, len(resp.Changes)) + for _, c := range resp.Changes { + name, typ := configMeta(c.ConfigID) + r := newCatalogReportChangeFromRow(c, name, typ, detailsByID[c.ID]) + if err := mapper.Apply(&r); err != nil { + return nil, nil, fmt.Errorf("failed to apply change mappings for %s: %w", c.ID, err) + } + entry.Changes = append(entry.Changes, r) + } + entry.ChangeCount = len(entry.Changes) + + if opts.ChangeArtifacts && len(entry.Changes) > 0 { + attachChangeArtifacts(ctx, entry.Changes) + } + } + + if opts.Sections.Insights { + resp, err := query.FindCatalogInsights(ctx, query.CatalogInsightsSearchRequest{ + BaseCatalogSearch: query.BaseCatalogSearch{ + CatalogID: catalogIDsCSV, + PageSize: opts.pageSizeFor(0), + }, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to get insights: %w", err) + } + entry.Analyses = lo.Map(resp.Insights, func(a models.ConfigAnalysis, _ int) api.CatalogReportAnalysis { + name, typ := configMeta(a.ConfigID.String()) + return api.NewCatalogReportAnalysis(a, name, typ) + }) + entry.InsightCount = len(entry.Analyses) + } + + if opts.Sections.Access { + rbacRows, err := db.GetRBACAccessByConfigIDs(ctx, targetIDs) + if err != nil { + return nil, nil, fmt.Errorf("failed to get access: %w", err) + } + if limit := opts.effectiveMax(0); limit > 0 && len(rbacRows) > limit { + rbacRows = rbacRows[:limit] + } + entry.RBACResources = groupRBACByConfig(rbacRows, configMap, opts) + for _, r := range entry.RBACResources { + entry.AccessCount += len(r.Users) + } + entry.Access = make([]api.CatalogReportAccess, 0, len(rbacRows)) + for _, row := range rbacRows { + entry.Access = append(entry.Access, rbacRowToAccess(row)) + } + } + + if opts.Sections.AccessLogs { + logs, err := getAccessLogs(ctx, targetIDs, sinceTime, opts.effectiveMax(0)) + if err != nil { + return nil, nil, fmt.Errorf("failed to get access logs: %w", err) + } + entry.AccessLogs = lo.Map(logs, func(l accessLogRow, _ int) api.CatalogReportAccessLog { + return newAccessLogEntry(l) + }) + } + + if opts.Sections.Relationships && tree != nil { + entry.RelationshipTree = configTreeNodeToReport(tree) + } + + return entry, scraperIDs, nil +} + +func newCatalogReportChangeFromRow(c query.ConfigChangeRow, configName, configType string, details map[string]any) api.CatalogReportChange { + r := api.CatalogReportChange{ + ID: c.ID, + ConfigID: c.ConfigID, + ConfigName: configName, + ConfigType: configType, + Permalink: api.ConfigPermalink(c.ConfigID), + ChangeType: c.ChangeType, + Severity: c.Severity, + Source: c.Source, + Summary: c.Summary, + Details: details, + ExternalCreatedBy: c.ExternalCreatedBy, + Count: c.Count, + } + if c.CreatedAt != nil { + r.CreatedAt = c.CreatedAt.Format(time.RFC3339) + } + if c.CreatedBy != nil { + r.CreatedBy = c.CreatedBy.String() + } + return r +} + +func loadCatalogChangeDetails(ctx context.Context, rows []query.ConfigChangeRow) (map[string]map[string]any, error) { + if len(rows) == 0 { + return map[string]map[string]any{}, nil + } + + ids := make([]uuid.UUID, 0, len(rows)) + for _, row := range rows { + id, err := uuid.Parse(row.ID) + if err != nil { + continue + } + ids = append(ids, id) + } + + if len(ids) == 0 { + return map[string]map[string]any{}, nil + } + + changes, err := query.GetCatalogChangesByIDs(ctx, ids) + if err != nil { + return nil, err + } + + detailsByID := make(map[string]map[string]any, len(changes)) + for _, change := range changes { + detailsByID[change.ID.String()] = decodeJSONMap(change.Details) + } + + return detailsByID, nil +} + +func decodeJSONMap(raw dutyTypes.JSON) map[string]any { + if len(raw) == 0 { + return nil + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil + } + + return decoded +} + +// resolveParents derives report ancestry from config.Path to avoid recursive +// ParentID walks that can loop forever on cyclic catalog data. +func resolveParents(ctx context.Context, config *models.ConfigItem) []models.ConfigItem { + parentIDs := parentIDsFromPath(config) + if len(parentIDs) == 0 { + return nil + } + + loaded, err := query.GetConfigsByIDs(ctx, parentIDs) + if err != nil || len(loaded) == 0 { + return nil + } + + byID := make(map[uuid.UUID]models.ConfigItem, len(loaded)) + for _, ci := range loaded { + byID[ci.ID] = ci + } + + parents := make([]models.ConfigItem, 0, len(parentIDs)) + for _, id := range parentIDs { + if ci, ok := byID[id]; ok { + parents = append(parents, ci) + } + } + + return parents +} + +func parentIDsFromPath(config *models.ConfigItem) []uuid.UUID { + if config == nil || config.Path == "" { + return nil + } + + segments := strings.Split(config.Path, ".") + parentIDs := make([]uuid.UUID, 0, len(segments)) + for _, seg := range segments { + id, err := uuid.Parse(seg) + if err != nil || id == config.ID { + continue + } + parentIDs = append(parentIDs, id) + } + + return parentIDs +} + +type accessLogRow struct { + ConfigID uuid.UUID `gorm:"column:config_id"` + ConfigName string `gorm:"column:config_name"` + ConfigType string `gorm:"column:config_type"` + ExternalUserID uuid.UUID `gorm:"column:external_user_id"` + UserName string `gorm:"column:user_name"` + CreatedAt time.Time `gorm:"column:created_at"` + MFA bool `gorm:"column:mfa"` + Count *int `gorm:"column:count"` + Properties map[string]any `gorm:"column:properties;serializer:json"` +} + +func (r accessLogRow) QueryLogSummary() string { + return r.ConfigType +} + +func getAccessLogs(ctx context.Context, configIDs []uuid.UUID, since time.Time, limit int) (results []accessLogRow, err error) { + timer := query.NewQueryLogger(ctx).Start("AccessLogs").Arg("configIDs", len(configIDs)) + defer timer.End(&err) + + q := ctx.DB(). + Table("config_access_logs"). + Select(`config_access_logs.config_id, + config_items.name AS config_name, + config_items.type AS config_type, + config_access_logs.external_user_id, + external_users.name AS user_name, + config_access_logs.created_at, + config_access_logs.mfa, + config_access_logs.count, + config_access_logs.properties`). + Joins("JOIN config_items ON config_items.id = config_access_logs.config_id"). + Joins("JOIN external_users ON external_users.id = config_access_logs.external_user_id"). + Where("config_access_logs.config_id IN ?", configIDs). + Where("config_access_logs.created_at >= ?", since). + Order("config_access_logs.created_at DESC") + if limit > 0 { + q = q.Limit(limit) + } + if err = q.Scan(&results).Error; err != nil { + return nil, err + } + timer.Results(results) + return results, nil +} + +func rbacRowToAccess(r db.RBACAccessRow) api.CatalogReportAccess { + a := api.CatalogReportAccess{ + ConfigID: r.ConfigID.String(), + ConfigName: r.ConfigName, + ConfigType: r.ConfigType, + Permalink: api.ConfigPermalink(r.ConfigID.String()), + UserID: r.UserID.String(), + UserName: r.UserName, + Email: r.Email, + Role: r.Role, + UserType: r.UserType, + CreatedAt: r.CreatedAt.Format(time.RFC3339), + } + if r.LastSignedInAt != nil { + s := r.LastSignedInAt.Format(time.RFC3339) + a.LastSignedInAt = &s + } + if r.LastReviewedAt != nil { + s := r.LastReviewedAt.Format(time.RFC3339) + a.LastReviewedAt = &s + } + return a +} + +func newAccessLogEntry(r accessLogRow) api.CatalogReportAccessLog { + var props map[string]string + if r.Properties != nil { + props = make(map[string]string, len(r.Properties)) + for k, v := range r.Properties { + props[k] = fmt.Sprintf("%v", v) + } + } + return api.CatalogReportAccessLog{ + ConfigID: r.ConfigID.String(), + Permalink: api.ConfigPermalink(r.ConfigID.String()), + UserID: r.ExternalUserID.String(), + UserName: r.UserName, + ConfigName: r.ConfigName, + ConfigType: r.ConfigType, + CreatedAt: r.CreatedAt.Format(time.RFC3339), + MFA: r.MFA, + Count: lo.FromPtr(r.Count), + Properties: props, + } +} + +func attachChangeArtifacts(ctx context.Context, changes []api.CatalogReportChange) { + changeIDs := make([]uuid.UUID, 0, len(changes)) + for _, c := range changes { + if id, err := uuid.Parse(c.ID); err == nil { + changeIDs = append(changeIDs, id) + } + } + if len(changeIDs) == 0 { + return + } + + var artifacts []models.Artifact + if err := ctx.DB().Where("config_change_id IN ?", changeIDs).Find(&artifacts).Error; err != nil { + ctx.Logger.V(2).Infof("failed to query change artifacts: %v", err) + return + } + if len(artifacts) == 0 { + return + } + + byChangeID := lo.GroupBy(artifacts, func(a models.Artifact) string { + if a.ConfigChangeID != nil { + return a.ConfigChangeID.String() + } + return "" + }) + + for i := range changes { + arts, ok := byChangeID[changes[i].ID] + if !ok { + continue + } + for _, a := range arts { + ra := api.CatalogReportArtifact{ + ID: a.ID.String(), + Filename: a.Filename, + ContentType: a.ContentType, + Size: a.Size, + } + if isEmbeddableContentType(a.ContentType) { + if data, err := a.GetContent(); err == nil && len(data) > 0 { + ra.DataURI = fmt.Sprintf("data:%s;base64,%s", a.ContentType, base64.StdEncoding.EncodeToString(data)) + } + } + changes[i].Artifacts = append(changes[i].Artifacts, ra) + } + } +} + +func isEmbeddableContentType(ct string) bool { + for _, prefix := range []string{"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg"} { + if strings.HasPrefix(ct, prefix) { + return true + } + } + return false +} + +func groupRBACByConfig(rows []db.RBACAccessRow, configMap map[uuid.UUID]models.ConfigItem, opts Options) []api.RBACResource { + staleThreshold := time.Now().AddDate(0, 0, -opts.StaleDays()) + reviewThreshold := time.Now().AddDate(0, 0, -opts.ReviewOverdueDays()) + + grouped := make(map[uuid.UUID]*api.RBACResource) + var order []uuid.UUID + + for _, row := range rows { + resource, ok := grouped[row.ConfigID] + if !ok { + resource = &api.RBACResource{ + ConfigID: row.ConfigID.String(), + ConfigName: row.ConfigName, + ConfigType: row.ConfigType, + } + if ci, found := configMap[row.ConfigID]; found { + resource.ConfigClass = ci.ConfigClass + resource.Path = ci.Path + if ci.Status != nil { + resource.Status = *ci.Status + } + if ci.Health != nil { + resource.Health = string(*ci.Health) + } + resource.Tags = ci.Tags + if ci.Labels != nil { + resource.Labels = *ci.Labels + } + } + grouped[row.ConfigID] = resource + order = append(order, row.ConfigID) + } + + resource.Users = append(resource.Users, api.RBACUserRole{ + UserID: row.UserID.String(), + UserName: row.UserName, + Email: row.Email, + Role: row.Role, + RoleSource: row.RoleSource(), + SourceSystem: row.UserType, + CreatedAt: row.CreatedAt, + LastSignedInAt: row.LastSignedInAt, + LastReviewedAt: row.LastReviewedAt, + IsStale: row.LastSignedInAt == nil || row.LastSignedInAt.Before(staleThreshold), + IsReviewOverdue: row.LastReviewedAt == nil || row.LastReviewedAt.Before(reviewThreshold), + }) + } + + return lo.Map(order, func(id uuid.UUID, _ int) api.RBACResource { + return *grouped[id] + }) +} + +func configTreeNodeToReport(n *query.ConfigTreeNode) *api.CatalogReportTreeNode { + return buildReportTreeNode(n, make(map[uuid.UUID]bool)) +} + +func buildReportTreeNode(n *query.ConfigTreeNode, visited map[uuid.UUID]bool) *api.CatalogReportTreeNode { + result := &api.CatalogReportTreeNode{ + CatalogReportConfigItem: api.NewCatalogReportConfigItem(n.ConfigItem), + EdgeType: n.EdgeType, + Relation: n.Relation, + } + if visited[n.ID] { + return result + } + visited[n.ID] = true + for _, c := range n.Children { + result.Children = append(result.Children, *buildReportTreeNode(c, visited)) + } + return result +} + +func RelatedConfigToReportItem(rc query.RelatedConfig) api.CatalogReportConfigItem { + r := api.CatalogReportConfigItem{ + ID: rc.ID.String(), + Permalink: api.ConfigPermalink(rc.ID.String()), + Name: rc.Name, + Type: rc.Type, + Tags: rc.Tags, + } + if rc.Status != nil { + r.Status = *rc.Status + } + if rc.Health != nil { + r.Health = string(*rc.Health) + } + if !rc.CreatedAt.IsZero() { + r.CreatedAt = rc.CreatedAt.Format(time.RFC3339) + } + if !rc.UpdatedAt.IsZero() { + r.UpdatedAt = rc.UpdatedAt.Format(time.RFC3339) + } + return r +} diff --git a/report/catalog/report_test.go b/report/catalog/report_test.go new file mode 100644 index 000000000..a30a43992 --- /dev/null +++ b/report/catalog/report_test.go @@ -0,0 +1,185 @@ +package catalog + +import ( + "testing" + "time" + + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + "github.com/google/uuid" + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/incident-commander/api" +) + +func TestCatalogReport(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "CatalogReport") +} + +var _ = ginkgo.Describe("Options", func() { + ginkgo.It("WithDefaults sets 30-day since", func() { + opts := Options{}.WithDefaults() + Expect(opts.Since).To(Equal(30 * 24 * time.Hour)) + }) + + ginkgo.It("WithDefaults preserves custom since", func() { + opts := Options{Since: 7 * 24 * time.Hour}.WithDefaults() + Expect(opts.Since).To(Equal(7 * 24 * time.Hour)) + }) +}) + +var _ = ginkgo.Describe("Report date range", func() { + ginkgo.It("From is set from sinceTime", func() { + opts := Options{Since: 48 * time.Hour}.WithDefaults() + sinceTime := time.Now().Add(-opts.Since) + + report := &api.CatalogReport{ + From: sinceTime.Format(time.RFC3339), + } + + parsed, err := time.Parse(time.RFC3339, report.From) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).To(BeTemporally("~", time.Now().Add(-48*time.Hour), 2*time.Second)) + }) + + ginkgo.It("From matches sinceTime for 30-day default", func() { + opts := Options{}.WithDefaults() + sinceTime := time.Now().Add(-opts.Since) + + report := &api.CatalogReport{ + From: sinceTime.Format(time.RFC3339), + } + + parsed, err := time.Parse(time.RFC3339, report.From) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed).To(BeTemporally("~", time.Now().Add(-30*24*time.Hour), 2*time.Second)) + }) + + ginkgo.It("query FromTime matches report From", func() { + opts := Options{Since: 7 * 24 * time.Hour}.WithDefaults() + sinceTime := time.Now().Add(-opts.Since) + + report := &api.CatalogReport{ + From: sinceTime.Format(time.RFC3339), + } + + reportFrom, err := time.Parse(time.RFC3339, report.From) + Expect(err).ToNot(HaveOccurred()) + Expect(reportFrom).To(BeTemporally("~", sinceTime, time.Second)) + }) +}) + +var _ = ginkgo.Describe("Options.effectiveMax", func() { + cases := []struct { + name string + maxItems int + override int + expected int + }{ + {"both unlimited", 0, 0, 0}, + {"only MaxItems", 50, 0, 50}, + {"only override", 0, 100, 100}, + {"override tighter than MaxItems", 50, 20, 20}, + {"override looser than MaxItems", 50, 100, 50}, + {"override equals MaxItems", 50, 50, 50}, + } + for _, tc := range cases { + ginkgo.It(tc.name, func() { + opts := Options{MaxItems: tc.maxItems} + Expect(opts.effectiveMax(tc.override)).To(Equal(tc.expected)) + }) + } +}) + +var _ = ginkgo.Describe("parentIDsFromPath", func() { + ginkgo.It("returns parents in path order", func() { + parentA := uuid.New() + parentB := uuid.New() + child := uuid.New() + + config := &models.ConfigItem{ + ID: child, + Path: parentA.String() + "." + parentB.String() + "." + child.String(), + } + + Expect(parentIDsFromPath(config)).To(Equal([]uuid.UUID{parentA, parentB})) + }) + + ginkgo.It("ignores invalid segments and the config itself", func() { + parent := uuid.New() + child := uuid.New() + + config := &models.ConfigItem{ + ID: child, + Path: "not-a-uuid." + child.String() + "." + parent.String() + ".still-not-a-uuid", + } + + Expect(parentIDsFromPath(config)).To(Equal([]uuid.UUID{parent})) + }) + + ginkgo.It("returns nil for nil config or empty path", func() { + Expect(parentIDsFromPath(nil)).To(BeNil()) + Expect(parentIDsFromPath(&models.ConfigItem{})).To(BeNil()) + }) + + ginkgo.It("uses path only even when ParentID is cyclic", func() { + parentA := uuid.New() + parentB := uuid.New() + cycleParent := uuid.New() + child := uuid.New() + + config := &models.ConfigItem{ + ID: child, + ParentID: &cycleParent, + Path: parentA.String() + "." + parentB.String() + "." + child.String(), + } + + Expect(parentIDsFromPath(config)).To(Equal([]uuid.UUID{parentA, parentB})) + }) +}) + +var _ = ginkgo.Describe("configTreeNodeToReport cycle protection", func() { + ginkgo.It("terminates on a self-referential cycle", func() { + idA := uuid.New() + nodeA := &query.ConfigTreeNode{ + ConfigItem: models.ConfigItem{ID: idA}, + } + // A -> A (self-loop) + nodeA.Children = []*query.ConfigTreeNode{nodeA} + + result := configTreeNodeToReport(nodeA) + Expect(result).ToNot(BeNil()) + Expect(result.Children).To(HaveLen(1)) + Expect(result.Children[0].Children).To(BeEmpty()) + }) + + ginkgo.It("terminates on an A -> B -> A cycle", func() { + idA := uuid.New() + idB := uuid.New() + nodeA := &query.ConfigTreeNode{ConfigItem: models.ConfigItem{ID: idA}} + nodeB := &query.ConfigTreeNode{ConfigItem: models.ConfigItem{ID: idB}} + nodeA.Children = []*query.ConfigTreeNode{nodeB} + nodeB.Children = []*query.ConfigTreeNode{nodeA} + + result := configTreeNodeToReport(nodeA) + Expect(result).ToNot(BeNil()) + Expect(result.Children).To(HaveLen(1)) + // nodeB's child is nodeA again, but A was already visited — empty. + Expect(result.Children[0].Children).To(HaveLen(1)) + Expect(result.Children[0].Children[0].Children).To(BeEmpty()) + }) + + ginkgo.It("preserves acyclic subtrees", func() { + idA, idB, idC := uuid.New(), uuid.New(), uuid.New() + nodeC := &query.ConfigTreeNode{ConfigItem: models.ConfigItem{ID: idC}} + nodeB := &query.ConfigTreeNode{ConfigItem: models.ConfigItem{ID: idB}, Children: []*query.ConfigTreeNode{nodeC}} + nodeA := &query.ConfigTreeNode{ConfigItem: models.ConfigItem{ID: idA}, Children: []*query.ConfigTreeNode{nodeB}} + + result := configTreeNodeToReport(nodeA) + Expect(result.Children).To(HaveLen(1)) + Expect(result.Children[0].Children).To(HaveLen(1)) + Expect(result.Children[0].Children[0].Children).To(BeEmpty()) + }) +}) diff --git a/report/catalog/settings.go b/report/catalog/settings.go new file mode 100644 index 000000000..926809c6a --- /dev/null +++ b/report/catalog/settings.go @@ -0,0 +1,129 @@ +package catalog + +import ( + _ "embed" + "fmt" + "os" + "strings" + + clickyAPI "github.com/flanksource/clicky/api" + reportAPI "github.com/flanksource/incident-commander/api" + "sigs.k8s.io/yaml" +) + +const EmbeddedSettingsSource = "embedded defaults" + +//go:embed default-settings.yaml +var defaultSettingsYAML []byte + +type Settings struct { + Filters []string `json:"filters,omitempty" yaml:"filters,omitempty"` + Thresholds SettingsThresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"` + CategoryMappings []reportAPI.CatalogReportCategoryMapping `json:"categoryMappings,omitempty" yaml:"categoryMappings,omitempty"` +} + +type SettingsThresholds struct { + StaleDays int `json:"staleDays,omitempty" yaml:"staleDays,omitempty"` + ReviewOverdueDays int `json:"reviewOverdueDays,omitempty" yaml:"reviewOverdueDays,omitempty"` +} + +func (s *Settings) Clone() *Settings { + if s == nil { + return &Settings{} + } + + out := &Settings{ + Thresholds: s.Thresholds, + } + + if len(s.Filters) > 0 { + out.Filters = append([]string(nil), s.Filters...) + } + + if len(s.CategoryMappings) > 0 { + out.CategoryMappings = append([]reportAPI.CatalogReportCategoryMapping(nil), s.CategoryMappings...) + } + + return out +} + +func parseSettings(data []byte, source string, base *Settings) (*Settings, error) { + settings := base.Clone() + if err := yaml.Unmarshal(data, settings); err != nil { + return nil, fmt.Errorf("failed to parse settings %s: %w", source, err) + } + return settings, nil +} + +func LoadDefaultSettings() (*Settings, error) { + return parseSettings(defaultSettingsYAML, EmbeddedSettingsSource, nil) +} + +func LoadSettings(path string) (*Settings, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read settings file %s: %w", path, err) + } + return parseSettings(data, path, nil) +} + +func ResolveSettings(path string) (*Settings, string, error) { + defaults, err := LoadDefaultSettings() + if err != nil { + return nil, "", err + } + + if path == "" { + return defaults, EmbeddedSettingsSource, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, "", fmt.Errorf("failed to read settings file %s: %w", path, err) + } + + settings, err := parseSettings(data, path, defaults) + if err != nil { + return nil, "", err + } + + return settings, fmt.Sprintf("%s + %s", EmbeddedSettingsSource, path), nil +} + +func (s *Settings) Pretty() clickyAPI.Text { + if s == nil { + return clickyAPI.Text{Content: "", Style: "text-gray-500"} + } + items := []clickyAPI.KeyValuePair{} + if len(s.Filters) > 0 { + items = append(items, clickyAPI.KeyValue("Filters", strings.Join(s.Filters, ", "))) + } + if s.Thresholds.StaleDays > 0 || s.Thresholds.ReviewOverdueDays > 0 { + items = append(items, clickyAPI.KeyValue("Stale", fmt.Sprintf("%dd", s.Thresholds.StaleDays))) + items = append(items, clickyAPI.KeyValue("Review Overdue", fmt.Sprintf("%dd", s.Thresholds.ReviewOverdueDays))) + } + if len(s.CategoryMappings) > 0 { + var mappings []string + for _, mapping := range s.CategoryMappings { + summary := fmt.Sprintf("filter=%s", mapping.Filter) + if mapping.Category != "" { + summary = fmt.Sprintf("category=%s %s", mapping.Category, summary) + } + if mapping.Transform != "" { + summary += fmt.Sprintf(" transform=%s", mapping.Transform) + } + mappings = append(mappings, summary) + } + items = append(items, clickyAPI.KeyValue("Categories", strings.Join(mappings, " | "))) + } + return clickyAPI.Text{}.Add(clickyAPI.DescriptionList{Items: items}) +} + +// FilterQuery returns the filters as a single search query string +// that can be appended to the ResourceSelector search. +func (s *Settings) FilterQuery() string { + if s == nil || len(s.Filters) == 0 { + return "" + } + return strings.Join(s.Filters, " ") +} diff --git a/report/catalog/settings_test.go b/report/catalog/settings_test.go new file mode 100644 index 000000000..a300b5dd5 --- /dev/null +++ b/report/catalog/settings_test.go @@ -0,0 +1,158 @@ +package catalog + +import ( + "os" + "path/filepath" + "strings" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("Settings", func() { + ginkgo.Describe("LoadSettings", func() { + ginkgo.It("parses valid YAML", func() { + content := ` +filters: + - "type!=Kubernetes::ConfigMap" + - "type!=Kubernetes::Secret" +thresholds: + staleDays: 60 + reviewOverdueDays: 30 +categoryMappings: + - category: rbac.granted + filter: 'changeType == "PermissionGranted" || changeType == "PermissionAdded"' + - category: backup.failed + filter: 'changeType == "BackupFailed"' +` + path := filepath.Join(os.TempDir(), "test-settings.yaml") + Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) + defer os.Remove(path) + + s, err := LoadSettings(path) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Filters).To(Equal([]string{"type!=Kubernetes::ConfigMap", "type!=Kubernetes::Secret"})) + Expect(s.Thresholds.StaleDays).To(Equal(60)) + Expect(s.Thresholds.ReviewOverdueDays).To(Equal(30)) + Expect(s.CategoryMappings).To(HaveLen(2)) + Expect(s.CategoryMappings[0].Category).To(Equal("rbac.granted")) + Expect(s.CategoryMappings[0].Filter).To(Equal(`changeType == "PermissionGranted" || changeType == "PermissionAdded"`)) + Expect(s.CategoryMappings[1].Category).To(Equal("backup.failed")) + Expect(s.CategoryMappings[1].Filter).To(Equal(`changeType == "BackupFailed"`)) + }) + + ginkgo.It("returns error for missing file", func() { + _, err := LoadSettings("/nonexistent/path.yaml") + Expect(err).To(HaveOccurred()) + }) + + ginkgo.It("rejects the old category mapping shape", func() { + content := ` +categoryMappings: + backup.failed: + - BackupFailed +` + path := filepath.Join(os.TempDir(), "legacy-settings.yaml") + Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) + defer os.Remove(path) + + _, err := LoadSettings(path) + Expect(err).To(HaveOccurred()) + }) + }) + + ginkgo.Describe("LoadDefaultSettings", func() { + ginkgo.It("loads embedded defaults", func() { + s, err := LoadDefaultSettings() + Expect(err).ToNot(HaveOccurred()) + Expect(s.Filters).To(ContainElement("type!=Kubernetes::ConfigMap")) + Expect(s.Thresholds.StaleDays).To(Equal(90)) + Expect(s.Thresholds.ReviewOverdueDays).To(Equal(90)) + Expect(s.CategoryMappings).ToNot(BeEmpty()) + Expect(s.CategoryMappings[0].Category).To(Equal("rbac.granted")) + Expect(s.CategoryMappings[0].Filter).To(ContainSubstring("PermissionGranted")) + Expect(s.CategoryMappings).To(ContainElement(HaveField("Category", "deployment.failed"))) + }) + }) + + ginkgo.Describe("ResolveSettings", func() { + ginkgo.It("uses embedded defaults when no path is provided", func() { + s, source, err := ResolveSettings("") + Expect(err).ToNot(HaveOccurred()) + Expect(source).To(Equal(EmbeddedSettingsSource)) + Expect(s.Filters).To(ContainElement("type!=Kubernetes::Secret")) + Expect(s.CategoryMappings).To(ContainElement(HaveField("Category", "backup.failed"))) + }) + + ginkgo.It("overlays file settings on top of embedded defaults", func() { + content := ` +filters: + - "name=test" +thresholds: + staleDays: 60 +categoryMappings: + - category: backup.failed + filter: 'changeType == "BACKUP_DB" && severity == "high"' + - category: deployment.failed + filter: 'changeType == "CodeDeployment" && severity == "failed"' +` + path := filepath.Join(os.TempDir(), "overlay-settings.yaml") + Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) + defer os.Remove(path) + + s, source, err := ResolveSettings(path) + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Contains(source, EmbeddedSettingsSource)).To(BeTrue()) + Expect(strings.Contains(source, path)).To(BeTrue()) + Expect(s.Filters).To(Equal([]string{"name=test"})) + Expect(s.Thresholds.StaleDays).To(Equal(60)) + Expect(s.Thresholds.ReviewOverdueDays).To(Equal(90)) + Expect(s.CategoryMappings).To(HaveLen(2)) + Expect(s.CategoryMappings[0].Category).To(Equal("backup.failed")) + Expect(s.CategoryMappings[0].Filter).To(Equal(`changeType == "BACKUP_DB" && severity == "high"`)) + Expect(s.CategoryMappings[1].Category).To(Equal("deployment.failed")) + Expect(s.CategoryMappings[1].Filter).To(Equal(`changeType == "CodeDeployment" && severity == "failed"`)) + }) + }) + + ginkgo.Describe("FilterQuery", func() { + ginkgo.It("joins filters into search string", func() { + s := &Settings{Filters: []string{"type!=Kubernetes::ConfigMap", "type!=Kubernetes::Secret"}} + Expect(s.FilterQuery()).To(Equal("type!=Kubernetes::ConfigMap type!=Kubernetes::Secret")) + }) + + ginkgo.It("returns empty for nil settings", func() { + var s *Settings + Expect(s.FilterQuery()).To(Equal("")) + }) + + ginkgo.It("returns empty for no filters", func() { + s := &Settings{} + Expect(s.FilterQuery()).To(Equal("")) + }) + }) + + ginkgo.Describe("Options threshold methods", func() { + ginkgo.It("returns defaults when no settings", func() { + opts := Options{} + Expect(opts.StaleDays()).To(Equal(90)) + Expect(opts.ReviewOverdueDays()).To(Equal(90)) + }) + + ginkgo.It("returns settings values", func() { + opts := Options{ + Settings: &Settings{ + Thresholds: SettingsThresholds{StaleDays: 60, ReviewOverdueDays: 30}, + }, + } + Expect(opts.StaleDays()).To(Equal(60)) + Expect(opts.ReviewOverdueDays()).To(Equal(30)) + }) + + ginkgo.It("returns defaults when settings thresholds are zero", func() { + opts := Options{Settings: &Settings{}} + Expect(opts.StaleDays()).To(Equal(90)) + Expect(opts.ReviewOverdueDays()).To(Equal(90)) + }) + }) +}) diff --git a/report/components/ApplicationDetails.tsx b/report/components/ApplicationDetails.tsx index d707ce8ea..289022d6f 100644 --- a/report/components/ApplicationDetails.tsx +++ b/report/components/ApplicationDetails.tsx @@ -7,6 +7,8 @@ interface Props { app: Application; } +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; + export default function ApplicationDetails({ app }: Props) { const sortedProps = [...(app.properties ?? [])].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0) @@ -18,15 +20,16 @@ export default function ApplicationDetails({ app }: Props) {

{app.description}

)} {sortedProps.length > 0 && ( -
+
{sortedProps.map((prop) => ( - +
+ +
))}
)} diff --git a/report/components/ArtifactAppendix.tsx b/report/components/ArtifactAppendix.tsx new file mode 100644 index 000000000..f23762211 --- /dev/null +++ b/report/components/ArtifactAppendix.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Section } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { ConfigChange } from '../config-types.ts'; +import { formatMonthDay, formatTime } from './utils.ts'; + +interface Props { + changes?: ConfigChange[]; +} + +export default function ArtifactAppendix({ changes }: Props) { + const withArtifacts = (changes || []).filter((c) => (c.artifacts || []).length > 0); + if (withArtifacts.length === 0) return null; + + const grouped = new Map(); + for (const c of withArtifacts) { + const key = c.configName || c.configID || 'unknown'; + if (!grouped.has(key)) { + grouped.set(key, { configName: c.configName || 'Unknown', configType: c.configType, changes: [] }); + } + grouped.get(key)!.changes.push(c); + } + + return ( +
+ {[...grouped.entries()].map(([key, group]) => ( +
+
+ {group.configType && } + {group.configName} + {group.configType && ({group.configType})} +
+ + {group.changes.map((change) => ( +
+
+ {change.createdAt ? `${formatMonthDay(change.createdAt)} ${formatTime(change.createdAt)}` : ''} + + {change.changeType} + {change.summary ?? ''} +
+ {(change.artifacts || []).map((a) => { + if (a.dataUri && a.contentType.startsWith('image/')) { + return ( + + {a.filename} +
{a.filename}
+
+ ); + } + return ( +
+ {a.filename} ({a.contentType}, {formatSize(a.size)}) +
+ ); + })} +
+ ))} +
+ ))} +
+ ); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} diff --git a/report/components/AuditPage.tsx b/report/components/AuditPage.tsx new file mode 100644 index 000000000..2d0da3e1f --- /dev/null +++ b/report/components/AuditPage.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { Badge, Section, ListTable } from '@flanksource/facet'; +import type { CatalogReportAudit } from '../catalog-report-types.ts'; +import ScraperCard from './ScraperCard.tsx'; + +interface Props { + audit: CatalogReportAudit; +} + +function MetadataRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( + + {label} + {value} + + ); +} + +function SectionBadge({ label, enabled }: { label: string; enabled: boolean }) { + return ( + + ); +} + +export default function AuditPage({ audit }: Props) { + const opts = audit.options; + const sections = opts.sections; + + return ( + <> +
+ + + + + + + + + + {opts.thresholds && ( + <> + + + + )} + +
+ +
+ + + + + + +
+ + {(opts.filters || []).length > 0 && ( +
+
Filters
+ {opts.filters!.map((f, i) => ( +
{f}
+ ))} +
+ )} + + {opts.categoryMappings && opts.categoryMappings.length > 0 && ( +
+
Category Mappings
+ {opts.categoryMappings.map((mapping, index) => ( +
+ {mapping.category && {mapping.category}} + {mapping.category && : } + {mapping.filter} + {mapping.transform && => {mapping.transform}} +
+ ))} +
+ )} +
+ + {audit.gitStatus && ( +
+
+            {audit.gitStatus}
+          
+
+ )} + + {audit.queries.length > 0 && ( +
+ ({ + id: String(i), + subject: q.pretty, + count: String(q.count), + }))} + subject="subject" + keys={['count']} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt] font-mono" + /> +
+ )} + + {audit.scrapers.length > 0 && ( +
+
+ {audit.scrapers.map((s) => ( + + ))} +
+
+ )} + + {audit.groups.length > 0 && ( +
+
+ {audit.groups.map((g) => ( +
+
+ {g.name} + {g.groupType && ( + ({g.groupType}) + )} + — {g.members.length} member(s) +
+ ({ + id: m.userId, + subject: m.email ? `${m.name} <${m.email}>` : m.name, + type: m.userType ?? '', + lastSignedIn: m.lastSignedInAt ?? '—', + added: m.membershipAddedAt, + removed: m.membershipDeletedAt ?? '', + }))} + subject="subject" + keys={['type', 'lastSignedIn', 'added', 'removed']} + size="xs" + density="compact" + cellClassName="text-xs font-mono" + /> +
+ ))} +
+
+ )} + + ); +} diff --git a/report/components/BackupActivityCalendar.tsx b/report/components/BackupActivityCalendar.tsx new file mode 100644 index 000000000..fafeebfe3 --- /dev/null +++ b/report/components/BackupActivityCalendar.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import type { BackupCalendarEntry, BackupCalendarStatus } from './change-section-utils.ts'; + +interface Props { + entries: BackupCalendarEntry[]; +} + +const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +const CELL_CLASSES: Record = { + success: 'bg-green-50 border border-green-600', + failed: 'bg-red-50 border border-red-600', + warning: 'bg-amber-50 border border-amber-500', + none: 'bg-gray-100 border border-gray-200', +}; + +const LABEL_CLASSES: Record = { + success: 'text-green-700', + failed: 'text-red-600', + warning: 'text-amber-700', +}; + +const STATUS_RANK: Record = { + success: 1, + warning: 2, + failed: 3, +}; + +interface AggregatedEntry { + date: string; + status: BackupCalendarStatus; + label?: string; + count: number; +} + +function aggregateEntries(entries: BackupCalendarEntry[]): AggregatedEntry[] { + const byDay = new Map(); + + for (const entry of entries) { + const key = entry.date.slice(0, 10); + const current = byDay.get(key); + + if (!current) { + byDay.set(key, { + date: entry.date, + status: entry.status, + label: entry.label, + count: 1, + }); + continue; + } + + current.count += 1; + if (new Date(entry.date).getTime() >= new Date(current.date).getTime()) { + current.date = entry.date; + current.label = entry.label ?? current.label; + } + if (STATUS_RANK[entry.status] >= STATUS_RANK[current.status]) { + current.status = entry.status; + } + } + + return [...byDay.values()]; +} + +export default function BackupActivityCalendar({ entries }: Props) { + if (!entries.length) { + return null; + } + + const aggregated = aggregateEntries(entries); + const referenceKey = aggregated.reduce((latest, entry) => { + const key = entry.date.slice(0, 10); + return key > latest ? key : latest; + }, aggregated[0].date.slice(0, 10)); + + const [yearStr, monthStr] = referenceKey.split('-'); + const year = Number(yearStr); + const month = Number(monthStr) - 1; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDow = new Date(year, month, 1).getDay(); + + const dateMap: Record = {}; + for (const entry of aggregated) { + dateMap[entry.date.slice(0, 10)] = entry; + } + + const monthLabel = new Date(Date.UTC(year, month, 1)).toLocaleString('en-US', { + month: 'long', year: 'numeric', timeZone: 'UTC', + }); + const cells: (number | null)[] = [ + ...Array(firstDow).fill(null), + ...Array.from({ length: daysInMonth }, (_, index) => index + 1), + ]; + + return ( +
+

{monthLabel}

+
+ {DAY_HEADERS.map((day) => ( +
{day}
+ ))} + {cells.map((day, index) => { + if (day === null) { + return
; + } + + const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const entry = dateMap[key]; + const cellClass = entry ? CELL_CLASSES[entry.status] : CELL_CLASSES.none; + const label = entry ? (entry.count > 1 ? `×${entry.count}` : entry.label ?? entry.status) : ''; + + return ( +
+ {day} + {entry && ( + + {label} + + )} +
+ ); + })} +
+
+ + + Success + + + + Failed + + + + In Progress + + + + No backup + +
+
+ ); +} diff --git a/report/components/BackupChanges.tsx b/report/components/BackupChanges.tsx new file mode 100644 index 000000000..a39bfc9c7 --- /dev/null +++ b/report/components/BackupChanges.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { StatCard, ListTable } from '@flanksource/facet'; +import type { ApplicationChange } from '../types.ts'; +import { formatDateTime } from './utils.ts'; +import BackupActivityCalendar from './BackupActivityCalendar.tsx'; +import { + filterBackupChanges, + getBackupCalendarStatus, + getChangeActor, + isRestoreChange, + toBackupCalendarEntries, +} from './change-section-utils.ts'; + +interface Props { + changes: ApplicationChange[]; +} + +const COUNT_VALUE_CLASS = 'text-[16pt] leading-[18pt]'; +const TIMESTAMP_VALUE_CLASS = 'text-[8pt] leading-[10pt]'; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; +const BACKUP_TAG_MAPPING = (key: string, value: unknown): string => { + if (key !== 'state' && key !== 'type') { + return ''; + } + + const normalized = String(value).toLowerCase(); + if (normalized.includes('fail')) { + return 'text-red-700 bg-red-50 border-red-200'; + } + if (normalized.includes('running') || normalized.includes('progress') || normalized.includes('started') || normalized.includes('queued')) { + return 'text-orange-700 bg-orange-50 border-orange-200'; + } + if (normalized.includes('complete') || normalized.includes('success')) { + return 'text-green-700 bg-green-50 border-green-200'; + } + if (normalized.includes('restore')) { + return 'text-blue-700 bg-blue-50 border-blue-200'; + } + return 'text-gray-600 bg-gray-50 border-gray-200'; +}; + +function attentionLabel(change: ApplicationChange): string { + const status = getBackupCalendarStatus(change); + if (status === 'failed') { + return 'Failed'; + } + if (status === 'warning') { + return 'In Progress'; + } + return change.changeType ?? 'Backup'; +} + +export default function BackupChanges({ changes }: Props) { + const relevant = filterBackupChanges(changes); + if (!relevant.length) { + return null; + } + + const backupEvents = relevant.filter((change) => !isRestoreChange(change)); + const restoreEvents = relevant.filter(isRestoreChange); + const completed = backupEvents.filter((change) => getBackupCalendarStatus(change) === 'success'); + const failed = backupEvents.filter((change) => getBackupCalendarStatus(change) === 'failed'); + const inProgress = backupEvents.filter((change) => getBackupCalendarStatus(change) === 'warning'); + const attentionEvents = [...failed, ...inProgress]; + const latestSuccessful = completed.reduce((latest, change) => { + if (!latest) return change; + return new Date(change.date).getTime() > new Date(latest.date).getTime() ? change : latest; + }, null); + const latestSuccessfulValue = latestSuccessful ? formatDateTime(latestSuccessful.date) : 'None'; + const latestSuccessfulColor = latestSuccessful + ? 'green' + : backupEvents.length > 0 + ? 'red' + : 'gray'; + + return ( + <> +
+
+ 0 ? 'Needs attention' : 'No failures'} + variant="summary" + size="sm" + color={failed.length > 0 ? 'red' : 'gray'} + shrink + valueClassName={COUNT_VALUE_CLASS} + /> +
+
+ 0 ? 'orange' : 'gray'} + shrink + valueClassName={COUNT_VALUE_CLASS} + /> +
+
+ +
+ {restoreEvents.length > 0 && ( +
+ +
+ )} +
+ + {backupEvents.length > 0 && ( +
+ +
+ )} + + {attentionEvents.length > 0 && ( +
+

Exceptions & Running Jobs

+ ({ + date: change.date, + subject: change.description, + subtitle: `Changed by ${getChangeActor(change)}`, + state: attentionLabel(change), + sourceLabel: `Source: ${change.source || '-'}`, + }))} + subject="subject" + subtitle="subtitle" + date="date" + dateFormat="long" + primaryTags={['state']} + keys={['sourceLabel']} + tagMapping={BACKUP_TAG_MAPPING} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + /> +
+ )} + + {restoreEvents.length > 0 && ( +
+

Restore Jobs

+ ({ + date: change.date, + subject: change.description, + subtitle: `Changed by ${getChangeActor(change)}`, + type: change.changeType ?? 'Restore', + sourceLabel: `Source: ${change.source || '-'}`, + }))} + subject="subject" + subtitle="subtitle" + date="date" + dateFormat="long" + primaryTags={['type']} + keys={['sourceLabel']} + tagMapping={BACKUP_TAG_MAPPING} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + /> +
+ )} + +
+

Event Stream

+ ({ + id: change.id, + date: change.date, + subject: change.description, + subtitle: `Changed by ${getChangeActor(change)}`, + type: change.changeType ?? 'Event', + sourceLabel: `Source: ${change.source || '-'}`, + }))} + subject="subject" + subtitle="subtitle" + date="date" + dateFormat="long" + primaryTags={['type']} + keys={['sourceLabel']} + tagMapping={BACKUP_TAG_MAPPING} + groups={[{ by: 'date' }]} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + /> +
+ + ); +} diff --git a/report/components/BackupsSection.tsx b/report/components/BackupsSection.tsx index 364ea273e..071f30c0c 100644 --- a/report/components/BackupsSection.tsx +++ b/report/components/BackupsSection.tsx @@ -1,128 +1,110 @@ import React from 'react'; -import { Section, StatCard, CompactTable } from '@flanksource/facet'; +import { Section, StatCard, ListTable } from '@flanksource/facet'; import type { ApplicationBackup, ApplicationBackupRestore } from '../types.ts'; import { formatDateTime } from './utils.ts'; +import BackupActivityCalendar from './BackupActivityCalendar.tsx'; +import type { BackupCalendarStatus } from './change-section-utils.ts'; interface Props { backups: ApplicationBackup[]; restores: ApplicationBackupRestore[]; } -const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; - -const CELL_CLASSES: Record = { - success: 'bg-green-50 border border-green-600', - failed: 'bg-red-50 border border-red-600', - none: 'bg-gray-100 border border-gray-200', -}; - -const SIZE_TEXT_CLASSES: Record = { - success: 'text-green-700', - failed: 'text-red-600', -}; - -function BackupCalendar({ backups }: { backups: ApplicationBackup[] }) { - const referenceDate = backups.length > 0 - ? new Date(backups.reduce((a, b) => (a.date > b.date ? a : b)).date) - : new Date(); - - const year = referenceDate.getFullYear(); - const month = referenceDate.getMonth(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const firstDow = new Date(year, month, 1).getDay(); - - const dateMap: Record = {}; - for (const b of backups) { - dateMap[b.date.slice(0, 10)] = b; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; +const BACKUP_TAG_MAPPING = (key: string, value: unknown): string => { + if (key !== 'status') { + return ''; } - const monthLabel = referenceDate.toLocaleString('default', { month: 'long', year: 'numeric' }); - const cells: (number | null)[] = [ - ...Array(firstDow).fill(null), - ...Array.from({ length: daysInMonth }, (_, i) => i + 1), - ]; - - return ( -
-

{monthLabel}

-
- {DAY_HEADERS.map((d) => ( -
{d}
- ))} - {cells.map((day, idx) => { - if (day === null) return
; - const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - const backup = dateMap[key]; - const cellClass = backup ? CELL_CLASSES[backup.status] ?? CELL_CLASSES.none : CELL_CLASSES.none; - return ( -
- {day} - {backup && ( - - {backup.size} - - )} -
- ); - })} -
-
- - - Success - - - - Failed - - - - No backup - -
-
- ); -} - + const normalized = String(value).toLowerCase(); + if (normalized.includes('fail')) { + return 'text-red-700 bg-red-50 border-red-200'; + } + if (normalized.includes('success')) { + return 'text-green-700 bg-green-50 border-green-200'; + } + if (normalized.includes('running') || normalized.includes('progress') || normalized.includes('started') || normalized.includes('queued')) { + return 'text-orange-700 bg-orange-50 border-orange-200'; + } + return 'text-gray-600 bg-gray-50 border-gray-200'; +}; export default function BackupsSection({ backups, restores }: Props) { + const isFailed = (status: string) => String(status).toLowerCase().includes('fail'); const successCount = backups.filter((b) => b.status === 'success').length; - const failedCount = backups.filter((b) => b.status !== 'success').length; - - const failedRows = backups - .filter((b) => b.status !== 'success') - .map((b) => [b.database, formatDateTime(b.date), b.size, b.status]); + const failedCount = backups.filter((b) => isFailed(b.status)).length; + const calendarEntries = backups.map((backup) => ({ + date: backup.date, + status: (backup.status === 'success' ? 'success' : isFailed(backup.status) ? 'failed' : 'warning') as BackupCalendarStatus, + label: backup.size || undefined, + })); - const restoreRows = restores.map((r) => [ - r.database, - formatDateTime(r.date), - r.status, - formatDateTime(r.completedAt), - ]); + const failedRows = backups.filter((b) => isFailed(b.status)); return (
-
- - - +
+
+ +
+
+ +
+
+ +
- +
{failedRows.length > 0 && (

Failed Backups

- + ({ + subject: backup.database, + subtitle: backup.size || 'Size unavailable', + date: backup.date, + status: backup.status, + sourceLabel: `Source: ${backup.source || '-'}`, + }))} + subject="subject" + subtitle="subtitle" + date="date" + dateFormat="long" + primaryTags={['status']} + keys={['sourceLabel']} + tagMapping={BACKUP_TAG_MAPPING} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + />
)} - {restoreRows.length > 0 && ( + {restores.length > 0 && (
-

Restore History

- +

Restore Jobs

+ ({ + subject: restore.database, + subtitle: `Completed ${formatDateTime(restore.completedAt)}`, + date: restore.date, + status: restore.status, + sourceLabel: `Source: ${restore.source || '-'}`, + }))} + subject="subject" + subtitle="subtitle" + date="date" + dateFormat="long" + primaryTags={['status']} + keys={['sourceLabel']} + tagMapping={BACKUP_TAG_MAPPING} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + />
)}
diff --git a/report/components/CatalogAccessLogsSection.tsx b/report/components/CatalogAccessLogsSection.tsx new file mode 100644 index 000000000..51c929121 --- /dev/null +++ b/report/components/CatalogAccessLogsSection.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Badge, Section, CompactTable } from '@flanksource/facet'; +import type { CatalogReportAccessLog } from '../catalog-report-types.ts'; +import { formatMonthDay, formatTime } from './utils.ts'; + +interface Props { + logs?: CatalogReportAccessLog[]; +} + +function MFABadge({ mfa }: { mfa: boolean }) { + if (mfa) { + return ; + } + return ; +} + +export default function CatalogAccessLogsSection({ logs }: Props) { + if (!logs?.length) return null; + + const rows = logs.map((log) => [ + {log.userName}, + log.createdAt ? `${formatMonthDay(log.createdAt)} ${formatTime(log.createdAt)}` : '-', + , + log.count > 1 ? ( + + ) : ( + '1' + ), + log.properties && Object.keys(log.properties).length > 0 ? ( + + {Object.entries(log.properties).map(([k, v]) => `${k}=${v}`).join(', ')} + + ) : ( + - + ), + ]); + + return ( +
+ +
+ ); +} diff --git a/report/components/CatalogAccessSection.tsx b/report/components/CatalogAccessSection.tsx new file mode 100644 index 000000000..c465b1f5a --- /dev/null +++ b/report/components/CatalogAccessSection.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Badge, Section, CompactTable } from '@flanksource/facet'; +import type { CatalogReportAccess } from '../catalog-report-types.ts'; +import { formatMonthDay } from './utils.ts'; + +interface Props { + access?: CatalogReportAccess[]; +} + +function StaleBadge({ lastSignedInAt }: { lastSignedInAt?: string }) { + if (!lastSignedInAt) { + return ; + } + const days = Math.floor((Date.now() - new Date(lastSignedInAt).getTime()) / 86400000); + if (days > 90) { + return ; + } + if (days > 30) { + return ; + } + return null; +} + +export default function CatalogAccessSection({ access }: Props) { + if (!access?.length) return null; + + const hasMultipleConfigs = access.some((a) => a.configName); + const rows = access.map((a) => { + const row = [ + {a.userName}, + a.role, + {a.email}, + {a.userType}, + a.lastSignedInAt ? ( + + {formatMonthDay(a.lastSignedInAt)} + + + ) : ( + + - + + + ), + a.lastReviewedAt ? formatMonthDay(a.lastReviewedAt) : -, + ]; + if (hasMultipleConfigs) { + row.splice(0, 0, {a.configName || '-'}); + } + return row; + }); + + const columns = hasMultipleConfigs + ? ['Config', 'User', 'Role', 'Email', 'Type', 'Last Sign In', 'Last Reviewed'] + : ['User', 'Role', 'Email', 'Type', 'Last Sign In', 'Last Reviewed']; + + return ( +
+ +
+ ); +} diff --git a/report/components/CatalogList.tsx b/report/components/CatalogList.tsx new file mode 100644 index 000000000..0b7e66782 --- /dev/null +++ b/report/components/CatalogList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Section, ListTable } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { CatalogReportEntry } from '../catalog-report-types.ts'; +import ConfigTreeSection from './ConfigTreeSection.tsx'; +import RBACMatrixSection from './RBACMatrixSection.tsx'; + +function isUnknown(v?: string): boolean { + return !v || v.toLowerCase() === 'unknown'; +} + +function entryToRow(entry: CatalogReportEntry): Record { + const ci = entry.configItem; + const row: Record = { + name: ci.name, + type: ci.type, + }; + if (!isUnknown(ci.health)) row.health = ci.health; + if (!isUnknown(ci.status)) row.status = ci.status; + if (entry.changeCount > 0) row.changes = `${entry.changeCount} changes`; + if (entry.insightCount > 0) row.insights = `${entry.insightCount} insights`; + if (entry.accessCount > 0) row.access = `${entry.accessCount} access`; + return row; +} + +function EntryDetail({ entry }: { entry: CatalogReportEntry }) { + const hasTree = entry.relationshipTree && (entry.relationshipTree.children || []).length > 0; + const hasRbac = (entry.rbacResources || []).length > 0; + if (!hasTree && !hasRbac) return null; + + return ( +
+ {hasTree && } + {hasRbac && entry.rbacResources!.map((resource, idx) => ( + + ))} +
+ ); +} + +interface CatalogListProps { + entries?: CatalogReportEntry[]; +} + +export default function CatalogList({ entries }: CatalogListProps) { + if (!entries?.length) return null; + return ( +
+ type ? : null} + primaryTags={['health', 'status']} + secondaryTags={['changes', 'insights', 'access']} + size="sm" + density="compact" + /> + {entries.map((entry, idx) => ( + + ))} +
+ ); +} diff --git a/report/components/ConfigChangesExamples.tsx b/report/components/ConfigChangesExamples.tsx new file mode 100644 index 000000000..706276e8c --- /dev/null +++ b/report/components/ConfigChangesExamples.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Section } from '@flanksource/facet'; +import type { ConfigChange } from '../config-types.ts'; +import ConfigChangesSection from './ConfigChangesSection.tsx'; + +interface Props { + changes?: ConfigChange[]; +} + +function pickMatching(changes: ConfigChange[], predicate: (change: ConfigChange) => boolean, limit: number): ConfigChange[] { + return changes.filter(predicate).slice(0, limit); +} + +export default function ConfigChangesExamples({ changes }: Props) { + const available = (changes ?? []).filter((change) => change.source !== 'schema-examples'); + if (!available.length) { + return null; + } + + const singleLine = pickMatching( + available, + (change) => !change.summary || change.summary.length <= 72 || Boolean(change.typedChange?.kind), + 6, + ); + const typedDiffs = pickMatching( + available, + (change) => [ + 'ConfigChange/v1', + 'Promotion/v1', + 'Scale/v1', + 'Restore/v1', + 'Deployment/v1', + 'Rollback/v1', + 'Scaling/v1', + 'CostChange/v1', + ].includes(change.typedChange?.kind ?? ''), + 5, + ); + const visualStates = pickMatching( + available, + (change) => ( + (change.severity && change.severity !== 'info') + || Boolean(change.artifacts?.length) + || (change.changeType || '').toLowerCase().includes('backup') + || (change.changeType || '').toLowerCase().includes('permission') + || change.typedChange?.kind === 'Screenshot/v1' + ), + 6, + ); + + return ( + <> + {singleLine.length > 0 && ( +
+
+ Compact rows optimized for one-line scanning. Change type, diff chips, config, actor, counters, and severity stay inline whenever the summary is short enough. +
+ +
+ )} + + {typedDiffs.length > 0 && ( +
+
+ Typed changes show richer before/after chips for nested config diffs, promotions, restores, replicas, and legacy deployment/version transitions instead of a generic diff label. +
+ +
+ )} + + {visualStates.length > 0 && ( +
+
+ Permission, backup, artifact, release, and higher-severity changes now use distinct badge accents to separate activity types at a glance. +
+ +
+ )} + + ); +} diff --git a/report/components/ConfigChangesSection.tsx b/report/components/ConfigChangesSection.tsx new file mode 100644 index 000000000..e5791c11b --- /dev/null +++ b/report/components/ConfigChangesSection.tsx @@ -0,0 +1,318 @@ +import React from 'react'; +import { Badge, Section, SeverityStatCard } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { ConfigChange, ConfigSeverity } from '../config-types.ts'; +import { getChangeTypeLabel, getTypedChangeDisplay, type TypedChangeDiff } from './change-section-utils.ts'; +import { getTimeBucket, formatEntryDate, type TimeBucketFormat } from './utils.ts'; + +interface Props { + changes?: ConfigChange[]; + hideConfigName?: boolean; +} + +const SEVERITY_ORDER: ConfigSeverity[] = ['critical', 'high', 'medium', 'low', 'info']; +const SEVERITY_COLOR: Record = { + critical: 'red', + high: 'orange', + medium: 'yellow', + low: 'blue', + info: 'blue', +}; +const SEVERITY_TEXT: Record = { + critical: 'text-red-700 bg-red-50 border-red-200', + high: 'text-orange-700 bg-orange-50 border-orange-200', + medium: 'text-yellow-700 bg-yellow-50 border-yellow-200', + low: 'text-blue-700 bg-blue-50 border-blue-200', + info: 'text-gray-600 bg-gray-50 border-gray-200', +}; +const SEVERITY_ACCENT_TEXT: Record = { + critical: 'text-red-600', + high: 'text-orange-600', + medium: 'text-yellow-700', + low: 'text-blue-600', + info: 'text-gray-500', +}; +type ChangeBadgeStyle = { color: string; textColor: string; borderColor: string }; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; + +const CHANGE_BADGE_STYLES: Record = { + default: { color: 'bg-slate-100', textColor: 'text-slate-700', borderColor: 'border-slate-200' }, + diff: { color: 'bg-indigo-50', textColor: 'text-indigo-700', borderColor: 'border-indigo-200' }, + policy: { color: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200' }, + scale: { color: 'bg-sky-50', textColor: 'text-sky-700', borderColor: 'border-sky-200' }, + backup: { color: 'bg-emerald-50', textColor: 'text-emerald-700', borderColor: 'border-emerald-200' }, + permission: { color: 'bg-violet-50', textColor: 'text-violet-700', borderColor: 'border-violet-200' }, + release: { color: 'bg-fuchsia-50', textColor: 'text-fuchsia-700', borderColor: 'border-fuchsia-200' }, + artifact: { color: 'bg-cyan-50', textColor: 'text-cyan-700', borderColor: 'border-cyan-200' }, + cost: { color: 'bg-amber-50', textColor: 'text-amber-700', borderColor: 'border-amber-200' }, +}; + +function getChangeAccent(change: ConfigChange, label: string): ChangeBadgeStyle { + const kind = change.typedChange?.kind ?? ''; + const type = (change.changeType || '').toLowerCase(); + const category = (change.category || '').toLowerCase(); + const normalizedLabel = label.toLowerCase(); + + if (kind === 'Screenshot/v1' || type.includes('screenshot')) return CHANGE_BADGE_STYLES.artifact; + if (kind === 'PermissionChange/v1' || category.startsWith('rbac') || type.includes('permission')) return CHANGE_BADGE_STYLES.permission; + if (kind === 'Backup/v1' || kind === 'Restore/v1' || category.startsWith('backup') || type.includes('backup') || type.includes('restore')) return CHANGE_BADGE_STYLES.backup; + if (kind === 'CostChange/v1' || type.includes('cost')) return CHANGE_BADGE_STYLES.cost; + if (kind === 'Promotion/v1' || kind === 'Approval/v1' || kind === 'Rollback/v1' || kind === 'PipelineRun/v1' || kind === 'PlaybookExecution/v1') return CHANGE_BADGE_STYLES.release; + if (kind === 'Scale/v1' || kind === 'Scaling/v1' || type.includes('replica') || type.includes('scaling')) return CHANGE_BADGE_STYLES.scale; + if (kind === 'ConfigChange/v1' || kind === 'Change/v1' || kind === 'Deployment/v1' || type === 'diff' || category.startsWith('deployment')) return CHANGE_BADGE_STYLES.diff; + if (type.includes('policy') || normalizedLabel.includes('policy')) return CHANGE_BADGE_STYLES.policy; + return CHANGE_BADGE_STYLES.default; +} + +function getChangeIconName(change: ConfigChange): string { + return change.typedChange?.kind ? change.typedChange.kind.split('/')[0] : change.changeType; +} + +function ChangeIcon({ change }: { change: ConfigChange }) { + return ( + + + + ); +} + +function ChangeTypeBadge({ change, label, className }: { change: ConfigChange; label: string; className?: string }) { + const accent = getChangeAccent(change, label); + + return ( + + ); +} + +function SecondaryMeta({ label, className = 'text-gray-500' }: { label: string; className?: string }) { + return ( + + {label} + + ); +} + +function TypedDiffBadges({ diff }: { diff: TypedChangeDiff }) { + return ( + <> + {diff.label && ( + + )} + + → + + + ); +} + +function ChangeEntry({ change, dateFormat, hideConfigName }: { change: ConfigChange; dateFormat: TimeBucketFormat; hideConfigName?: boolean }) { + const sev = change.severity ?? 'info'; + const author = change.createdBy || change.externalCreatedBy || change.source || ''; + const artifactCount = (change.artifacts || []).length; + const typedDisplay = getTypedChangeDisplay(change); + const summary = change.summary || typedDisplay?.summary; + const changeTypeLabel = getChangeTypeLabel(change, typedDisplay); + const hasSecondaryMeta = sev !== 'info' || Boolean(author); + const inlineMetaBadgeClass = 'align-middle mb-[0.35mm] max-w-full whitespace-normal break-all'; + const inlineTypeBadgeClass = 'align-middle mr-[0.8mm] mb-[0.35mm]'; + return ( +
+ + {change.createdAt ? formatEntryDate(change.createdAt, dateFormat) : '-'} + + +
+
+ + {summary && {summary}} + {typedDisplay?.diff && ( + <> + {' '} + + + )} + {!hideConfigName && change.configName && ( + <> + {' '} + + + )} + {typedDisplay?.meta?.map((meta) => ( + + {' '} + + + ))} + {(change.count ?? 0) > 1 && ( + <> + {' '} + + + )} + {artifactCount > 0 && ( + <> + {' '} + + 1 ? 's' : ''}`} + color="bg-purple-50" + textColor="text-purple-700" + borderColor="border-purple-200" + className={inlineMetaBadgeClass} + /> + + + )} +
+ {hasSecondaryMeta && ( +
+ {sev !== 'info' && ( + + )} + {author && ( + + )} +
+ )} +
+
+ ); +} + +interface BucketGroup { + key: string; + label: string; + dateFormat: TimeBucketFormat; + changes: ConfigChange[]; +} + +function groupByTimeBucket(changes: ConfigChange[]): BucketGroup[] { + const sorted = [...changes].sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return tb - ta; + }); + + const groups: BucketGroup[] = []; + const groupMap = new Map(); + + for (const c of sorted) { + const bucket = c.createdAt ? getTimeBucket(c.createdAt) : { key: 'unknown', label: 'Unknown', dateFormat: 'monthDay' as TimeBucketFormat }; + let group = groupMap.get(bucket.key); + if (!group) { + group = { key: bucket.key, label: bucket.label, dateFormat: bucket.dateFormat, changes: [] }; + groupMap.set(bucket.key, group); + groups.push(group); + } + group.changes.push(c); + } + + return groups; +} + +export default function ConfigChangesSection({ changes, hideConfigName: hideConfigNameProp }: Props) { + if (!changes?.length) return null; + const uniqueConfigs = new Set(changes.map((c) => c.configID || c.configName).filter(Boolean)); + const hideConfigName = hideConfigNameProp || uniqueConfigs.size <= 1; + const bySeverity = Object.fromEntries( + SEVERITY_ORDER.map((sev) => [sev, changes.filter((c) => (c.severity ?? 'info') === sev).length]) + ); + + const groups = groupByTimeBucket(changes); + + return ( +
+
+ {SEVERITY_ORDER.filter((sev) => bySeverity[sev] > 0).map((sev) => ( +
+ +
+ ))} +
+ {groups.map((group) => ( +
+
+ {group.label} + ({group.changes.length}) +
+
+ {group.changes.map((c) => )} +
+
+ ))} +
+ ); +} diff --git a/report/components/ConfigInsightsSection.tsx b/report/components/ConfigInsightsSection.tsx new file mode 100644 index 000000000..642b9a004 --- /dev/null +++ b/report/components/ConfigInsightsSection.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Badge, Section, SeverityStatCard } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { ConfigAnalysis, ConfigSeverity, AnalysisType } from '../config-types.ts'; +import { formatDate } from './utils.ts'; + +interface Props { + analyses?: ConfigAnalysis[]; +} + +const SEVERITY_ORDER: ConfigSeverity[] = ['critical', 'high', 'medium', 'low', 'info']; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; +const SEVERITY_COLOR: Record = { + critical: 'red', + high: 'orange', + medium: 'yellow', + low: 'blue', + info: 'blue', +}; +const SEVERITY_TEXT: Record = { + critical: 'text-red-700 bg-red-50 border-red-200', + high: 'text-orange-700 bg-orange-50 border-orange-200', + medium: 'text-yellow-700 bg-yellow-50 border-yellow-200', + low: 'text-blue-700 bg-blue-50 border-blue-200', + info: 'text-gray-600 bg-gray-50 border-gray-200', +}; +const STATUS_TEXT: Record = { + open: 'text-red-700 bg-red-50 border-red-200', + silenced: 'text-yellow-700 bg-yellow-50 border-yellow-200', + resolved: 'text-green-700 bg-green-50 border-green-200', +}; + +const ANALYSIS_TYPES: AnalysisType[] = [ + 'security', 'compliance', 'cost', 'performance', + 'reliability', 'recommendation', 'availability', 'integration', +]; + +function InsightEntry({ analysis }: { analysis: ConfigAnalysis }) { + const sev = analysis.severity ?? 'info'; + return ( +
+ + + + {analysis.analyzer} + {analysis.configName && ( + + )} + {analysis.message || analysis.summary || '-'} + + {analysis.status && ( + + )} + {analysis.lastObserved && ( + {formatDate(analysis.lastObserved)} + )} +
+ ); +} + +function AnalysisTypeGroup({ type, analyses }: { type: string; analyses: ConfigAnalysis[] }) { + if (analyses.length === 0) return null; + + const sorted = [...analyses].sort((a, b) => { + const statusOrder = ['open', 'silenced', 'resolved']; + const statusDiff = statusOrder.indexOf(a.status ?? '') - statusOrder.indexOf(b.status ?? ''); + if (statusDiff !== 0) return statusDiff; + return SEVERITY_ORDER.indexOf(a.severity as ConfigSeverity) - SEVERITY_ORDER.indexOf(b.severity as ConfigSeverity); + }); + + return ( +
+
+ {type} + +
+
+ {sorted.map((a) => )} +
+
+ ); +} + +export default function ConfigInsightsSection({ analyses }: Props) { + if (!analyses?.length) return null; + const bySeverity = Object.fromEntries( + SEVERITY_ORDER.map((sev) => [sev, analyses.filter((a) => (a.severity ?? 'info') === sev).length]) + ); + const byType: Record = {}; + for (const a of analyses) { + const t = a.analysisType && ANALYSIS_TYPES.includes(a.analysisType as AnalysisType) ? a.analysisType : 'other'; + (byType[t] ??= []).push(a); + } + const typeOrder = [...ANALYSIS_TYPES.filter((t) => byType[t]?.length), ...(byType['other']?.length ? ['other' as const] : [])]; + + return ( +
+
+ {SEVERITY_ORDER.map((sev) => ( +
+ +
+ ))} +
+ {typeOrder.map((type) => ( + + ))} +
+ ); +} diff --git a/report/components/ConfigItemCard.tsx b/report/components/ConfigItemCard.tsx new file mode 100644 index 000000000..a882b2e29 --- /dev/null +++ b/report/components/ConfigItemCard.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Badge } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { ConfigItem } from '../rbac-types.ts'; +import { formatDate } from './utils.ts'; + +interface Props { + config: ConfigItem; +} + +export default function ConfigItemCard({ config }: Props) { + const tags = { ...config.tags, ...config.labels }; + + return ( +
+
+ {config.type && } + {config.name} + {Object.entries(tags).map(([k, v]) => ( + + ))} +
+
+ {config.id} + {config.created_at && created: {formatDate(config.created_at)}} + {config.updated_at && updated: {formatDate(config.updated_at)}} +
+
+ ); +} diff --git a/report/components/ConfigLink.tsx b/report/components/ConfigLink.tsx new file mode 100644 index 000000000..6a8ed1f5f --- /dev/null +++ b/report/components/ConfigLink.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Icon } from '@flanksource/icons/icon'; +import { HEALTH_COLORS } from './utils.ts'; +import type { ConfigItem } from '../config-types.ts'; + +interface Props { + config: Pick; + showHealth?: boolean; +} + +export default function ConfigLink({ config, showHealth }: Props) { + return ( + + {config.type && } + {showHealth && config.health && ( + + )} + {config.name} + + ); +} diff --git a/report/components/ConfigRelationshipGraph.tsx b/report/components/ConfigRelationshipGraph.tsx new file mode 100644 index 000000000..9f7c8b9e2 --- /dev/null +++ b/report/components/ConfigRelationshipGraph.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Section, CompactTable } from '@flanksource/facet'; +import type { ConfigItem, ConfigRelationship } from '../config-types.ts'; +import { HEALTH_COLORS } from './utils.ts'; +import ConfigLink from './ConfigLink.tsx'; + +interface Props { + centralConfig: ConfigItem; + relationships?: ConfigRelationship[]; + relatedConfigs?: ConfigItem[]; +} + +function HealthDot({ health }: { health: string }) { + const color = HEALTH_COLORS[health.toLowerCase()] ?? '#6B7280'; + return ( + + + {health} + + ); +} + +function RelationshipGroup({ title, relationships, configLookup }: { + title: string; + relationships: ConfigRelationship[]; + configLookup: Map; +}) { + if (relationships.length === 0) return null; + + const rows = relationships.map((rel) => { + const targetID = rel.direction === 'incoming' ? rel.configID : rel.relatedID; + const config = configLookup.get(targetID); + return [ + config ? : targetID, + config?.type ?? '-', + rel.relation, + config?.health ? : '-', + ]; + }); + + return ( +
+
+ {title} + + {relationships.length} + +
+
+ +
+
+ ); +} + +export default function ConfigRelationshipGraph({ centralConfig, relationships, relatedConfigs }: Props) { + if (!relatedConfigs?.length) return null; + const configLookup = new Map(relatedConfigs.map((c) => [c.id, c])); + + const rels = relationships || []; + const incoming = rels.filter((r) => r.direction === 'incoming'); + const outgoing = rels.filter((r) => r.direction === 'outgoing'); + + return ( +
+
+ + {centralConfig.status && ( + ({centralConfig.status}) + )} +
+ + +
+ ); +} diff --git a/report/components/ConfigTreeSection.tsx b/report/components/ConfigTreeSection.tsx new file mode 100644 index 000000000..90855eb75 --- /dev/null +++ b/report/components/ConfigTreeSection.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Section } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import type { CatalogReportTreeNode } from '../catalog-report-types.ts'; + +function TreeNodeRow({ node, isRoot = false }: { node: CatalogReportTreeNode; isRoot?: boolean }) { + const isTarget = node.edgeType === 'target'; + const children = node.children || []; + + return ( +
+
+ {!isRoot && } + {node.type && } + + {node.name} + + {node.type && ( + ({node.type}) + )} + {node.relation && ( + {node.relation} + )} +
+ {children.length > 0 && ( +
+ {children.map((child, idx) => ( + + ))} +
+ )} +
+ ); +} + +interface Props { + tree: CatalogReportTreeNode; +} + +export default function ConfigTreeSection({ tree }: Props) { + if (!tree || !(tree.children || []).length) return null; + + return ( +
+ +
+ ); +} diff --git a/report/components/CoverPage.tsx b/report/components/CoverPage.tsx new file mode 100644 index 000000000..b562ecbd0 --- /dev/null +++ b/report/components/CoverPage.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Badge } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; +import { formatDate, formatDateTime } from './utils.ts'; + +interface CoverPageSubject { + name?: string; + type?: string; + status?: string; + health?: string; + description?: string; + tags?: Record; + labels?: Record; +} + +interface CoverPageProps { + title: string; + subtitle?: string; + icon?: string; + query?: string; + breadcrumbs?: Array<{ id: string; name?: string; type?: string }>; + subjects?: CoverPageSubject[]; + tags?: Record; + stats?: Array<{ label: string; value: string | number }>; + dateRange?: { from?: string; to?: string }; + generatedAt?: string; + children?: React.ReactNode; +} + +function SubjectBadge({ subject }: { subject: CoverPageSubject }) { + return ( +
+ {subject.type && } + {subject.name} + {subject.type} + {subject.status && ( + + )} +
+ ); +} + +function TagBadges({ tags }: { tags: Record }) { + if (Object.keys(tags).length === 0) return null; + return ( +
+ {Object.entries(tags).map(([k, v]) => ( + + ))} +
+ ); +} + +export default function CoverPage({ title, subtitle, icon, query, breadcrumbs, subjects, tags, stats, dateRange, generatedAt, children }: CoverPageProps) { + const allTags = tags || {}; + if (!tags && subjects?.length === 1) { + Object.assign(allTags, subjects[0].tags || {}, subjects[0].labels || {}); + } + + return ( +
+
+ {subtitle && ( +
+ {subtitle} +
+ )} + + {icon && ( +
+ +
+ )} + +

{title}

+ + {query && ( +
+ {query} +
+ )} +
+ + {breadcrumbs && breadcrumbs.length > 0 && ( +
+ {breadcrumbs.map((p, i) => ( + + {i > 0 && } + + {p.type && } + {p.name} + + + ))} +
+ )} + + {subjects && subjects.length > 0 && ( +
+ {subjects.map((s, i) => ( + + ))} + {subjects.length === 1 && subjects[0].description && ( +
+ {subjects[0].description} +
+ )} +
+ )} + + {Object.keys(allTags).length > 0 && } + +
+ + {stats && stats.length > 0 && ( +
+ {stats.map((s) => ( + {s.value} {s.label} + ))} +
+ )} + + {dateRange && (dateRange.from || dateRange.to) && ( +
+ Period: {formatDate(dateRange.from || generatedAt || new Date().toISOString())} – {formatDate(dateRange.to || generatedAt || new Date().toISOString())} +
+ )} + +
+ Generated {generatedAt ? formatDateTime(generatedAt) : new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} +
+ + {children} +
+ ); +} diff --git a/report/components/DeploymentChanges.tsx b/report/components/DeploymentChanges.tsx new file mode 100644 index 000000000..55827456f --- /dev/null +++ b/report/components/DeploymentChanges.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { ListTable, StatCard } from '@flanksource/facet'; +import type { ApplicationChange } from '../types.ts'; +import { + classifyDeploymentChange, + filterDeploymentChanges, + getChangeActor, +} from './change-section-utils.ts'; + +interface Props { + changes: ApplicationChange[]; +} + +const COUNT_VALUE_CLASS = 'text-[16pt] leading-[18pt]'; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; +const CATEGORY_STYLES: Record<'scale' | 'policy' | 'spec', string> = { + scale: 'bg-blue-50 text-blue-700 border-blue-200', + policy: 'bg-orange-50 text-orange-700 border-orange-200', + spec: 'bg-slate-50 text-slate-700 border-slate-200', +}; +const CATEGORY_LABELS: Record<'scale' | 'policy' | 'spec', string> = { + scale: 'Scale', + policy: 'Policy', + spec: 'Spec', +}; +const CATEGORY_TAG_MAPPING = (key: string, value: unknown): string => { + if (key !== 'category') { + return ''; + } + + const normalized = String(value).toLowerCase(); + if (normalized === 'scale') { + return CATEGORY_STYLES.scale; + } + if (normalized === 'policy') { + return CATEGORY_STYLES.policy; + } + return CATEGORY_STYLES.spec; +}; + +export default function DeploymentChanges({ changes }: Props) { + const relevant = filterDeploymentChanges(changes).sort((a, b) => ( + new Date(b.date).getTime() - new Date(a.date).getTime() + )); + + if (!relevant.length) { + return null; + } + + const categorized = relevant.map((change) => ({ + change, + category: classifyDeploymentChange(change) ?? 'spec', + })); + + const counts = categorized.reduce( + (acc, { category }) => { acc[category] += 1; return acc; }, + { scale: 0, policy: 0, spec: 0 } as Record, + ); + + return ( + <> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ({ + id: change.id, + date: change.date, + subject: change.description, + subtitle: change.changeType ?? '-', + category: CATEGORY_LABELS[category], + actor: getChangeActor(change), + }))} + subject="subject" + subtitle="subtitle" + date="date" + primaryTags={['category']} + keys={['actor']} + tagMapping={CATEGORY_TAG_MAPPING} + groups={[{ by: 'date' }]} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + /> + + ); +} diff --git a/report/components/DynamicSection.tsx b/report/components/DynamicSection.tsx index 665eda1fe..348013596 100644 --- a/report/components/DynamicSection.tsx +++ b/report/components/DynamicSection.tsx @@ -1,6 +1,9 @@ import React from 'react'; -import { Section, CompactTable } from '@flanksource/facet'; +import { Badge, Section, CompactTable } from '@flanksource/facet'; import type { ApplicationSection, ViewColumnType } from '../types.ts'; +import RBACChanges from './RBACChanges.tsx'; +import BackupChanges from './BackupChanges.tsx'; +import DeploymentChanges from './DeploymentChanges.tsx'; import { formatDate, formatRelative, @@ -10,6 +13,12 @@ import { SEVERITY_COLORS, SEVERITY_BG, } from './utils.ts'; +import { + filterBackupChanges, + filterDeploymentChanges, + filterRBACChanges, + inferChangeSectionVariant, +} from './change-section-utils.ts'; interface Props { section: ApplicationSection; @@ -22,19 +31,26 @@ const HEALTH_CLASSES: Record = { unknown: 'bg-gray-400', }; -const REFRESH_CLASSES: Record = { - fresh: 'bg-green-100 text-green-800', - cache: 'bg-yellow-100 text-yellow-800', +const REFRESH_STYLES: Record = { + fresh: { color: 'bg-green-100', textColor: 'text-green-800', borderColor: 'border-green-200' }, + cache: { color: 'bg-yellow-100', textColor: 'text-yellow-800', borderColor: 'border-yellow-200' }, }; function TagBadges({ value }: { value: Record }) { return ( {Object.entries(value).map(([k, v]) => ( - - {k} - {v} - + ))} ); @@ -83,9 +99,7 @@ function SeverityBadge({ severity }: { severity: string }) { const color = SEVERITY_COLORS[key] ?? '#6B7280'; const bg = SEVERITY_BG[key] ?? '#F3F4F6'; return ( - - {severity} - + ); } @@ -96,8 +110,8 @@ function ViewSection({ section }: { section: ApplicationSection }) { const visibleCols = view.columns.filter((c) => !c.hidden); const headers = visibleCols.map((c) => c.name.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())); - const refreshClass = view.refreshStatus - ? (REFRESH_CLASSES[view.refreshStatus] ?? 'bg-red-100 text-red-800') + const refreshStyle = view.refreshStatus + ? (REFRESH_STYLES[view.refreshStatus] ?? { color: 'bg-red-100', textColor: 'text-red-800', borderColor: 'border-red-200' }) : null; const rows = view.rows.map((row) => { @@ -114,9 +128,16 @@ function ViewSection({ section }: { section: ApplicationSection }) {
{view.refreshStatus && ( - - {view.refreshStatus} - + )} {view.lastRefreshedAt && ( @@ -129,7 +150,7 @@ function ViewSection({ section }: { section: ApplicationSection }) { ); } -function ChangesSection({ section }: { section: ApplicationSection }) { +function GenericChangesSection({ section }: { section: ApplicationSection }) { const rows = (section.changes ?? []).map((c) => [ formatRelative(c.date), c.changeType ?? '-', @@ -140,6 +161,22 @@ function ChangesSection({ section }: { section: ApplicationSection }) { return ; } +function ChangesSection({ section }: { section: ApplicationSection }) { + const changes = section.changes ?? []; + if (!changes.length) return null; + + switch (inferChangeSectionVariant(section.title, changes)) { + case 'rbac': + return ; + case 'backup': + return ; + case 'deployment': + return ; + default: + return ; + } +} + function ConfigsSection({ section }: { section: ApplicationSection }) { const rows = (section.configs ?? []).map((c) => [ c.name, @@ -152,11 +189,39 @@ function ConfigsSection({ section }: { section: ApplicationSection }) { } export default function DynamicSection({ section }: Props) { + if (section.type === 'changes') { + const changes = section.changes ?? []; + const variant = inferChangeSectionVariant(section.title, changes); + const renderable = variant === 'rbac' + ? filterRBACChanges(changes).length > 0 + : variant === 'backup' + ? filterBackupChanges(changes).length > 0 + : variant === 'deployment' + ? filterDeploymentChanges(changes).length > 0 + : changes.length > 0; + + if (!renderable) { + return null; + } + } + + let content: React.ReactNode = null; + + if (section.type === 'view') { + content = ; + } else if (section.type === 'changes') { + content = ; + } else if (section.type === 'configs') { + content = ; + } + + if (!content) { + return null; + } + return (
- {section.type === 'view' && } - {section.type === 'changes' && } - {section.type === 'configs' && } + {content}
); } diff --git a/report/components/FindingsSection.tsx b/report/components/FindingsSection.tsx index 2b190908d..efe470d39 100644 --- a/report/components/FindingsSection.tsx +++ b/report/components/FindingsSection.tsx @@ -10,6 +10,7 @@ interface Props { const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'] as const; const FINDING_TYPES = ['security', 'compliance', 'reliability', 'performance'] as const; const ACTIVE_STATUSES = new Set(['open', 'in-progress']); +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; const SEVERITY_COLOR: Record = { critical: 'red', @@ -78,14 +79,15 @@ export default function FindingsSection({ findings }: Props) { return (
-
+
{SEVERITY_ORDER.map((sev) => ( - +
+ +
))}
{findings.length === 0 ? ( diff --git a/report/components/GitRef.tsx b/report/components/GitRef.tsx new file mode 100644 index 000000000..b7e68ab68 --- /dev/null +++ b/report/components/GitRef.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Badge } from '@flanksource/facet'; +import { Icon } from '@flanksource/icons/icon'; + +interface GitRefProps { + url?: string; + branch?: string; + file?: string; + dir?: string; + link?: string; + size?: 'xs' | 'sm'; +} + +const SIZE_CLASSES = { + xs: 'text-[5pt]', + sm: 'text-xs', +}; + +function Tag({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( + + ); +} + +export default function GitRef({ url, branch, file, dir, link, size = 'xs' }: GitRefProps) { + if (!url && !file) return null; + const textClass = SIZE_CLASSES[size]; + + const content = ( + + + {url && {url}{branch ? ` @ ${branch}` : ''}} + {dir && {dir}/} + {file && {file}} + + ); + + if (link) { + return {content}; + } + return content; +} + +export function GitRefFromSource({ gitops, size }: { gitops?: { git: { url: string; branch: string; file: string; dir: string; link: string } }; size?: 'xs' | 'sm' }) { + if (!gitops?.git?.url) return null; + return ; +} diff --git a/report/components/IncidentsSection.tsx b/report/components/IncidentsSection.tsx index 3c06ec616..769665aa6 100644 --- a/report/components/IncidentsSection.tsx +++ b/report/components/IncidentsSection.tsx @@ -8,6 +8,7 @@ interface Props { } const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'] as const; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; const SEVERITY_COLOR: Record = { critical: 'red', high: 'orange', @@ -30,14 +31,15 @@ export default function IncidentsSection({ incidents }: Props) { return (
-
+
{SEVERITY_ORDER.map((sev) => ( - +
+ +
))}
{rows.length > 0 ? ( diff --git a/report/components/PageFooter.tsx b/report/components/PageFooter.tsx new file mode 100644 index 000000000..9a66e8b75 --- /dev/null +++ b/report/components/PageFooter.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { formatDateTime } from './utils.ts'; + +interface PageFooterProps { + publicURL?: string; + generatedAt?: string; + children?: React.ReactNode; +} + +export default function PageFooter({ publicURL, generatedAt, children }: PageFooterProps) { + const date = generatedAt ? formatDateTime(generatedAt) : formatDateTime(new Date().toISOString()); + + return ( +
+ {children} +
+ Generated {date} + {publicURL && ( + {publicURL} + )} +
+
+ ); +} diff --git a/report/components/PageHeader.tsx b/report/components/PageHeader.tsx new file mode 100644 index 000000000..9803e4035 --- /dev/null +++ b/report/components/PageHeader.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Icon } from '@flanksource/icons/icon'; + +interface PageHeaderProps { + subtitle?: string; +} + +export default function PageHeader({ subtitle }: PageHeaderProps) { + return ( +
+ + {subtitle && {subtitle}} +
+ ); +} diff --git a/report/components/RBACChangelogSection.tsx b/report/components/RBACChangelogSection.tsx index 3bee343e0..6664d6c5d 100644 --- a/report/components/RBACChangelogSection.tsx +++ b/report/components/RBACChangelogSection.tsx @@ -1,32 +1,62 @@ import React from 'react'; -import { Section, CompactTable } from '@flanksource/facet'; +import { Badge, Section } from '@flanksource/facet'; import type { RBACChangeEntry } from '../rbac-types.ts'; import { formatDate } from './utils.ts'; +const CHANGELOG_TYPE_COLORS: Record = { + PermissionGranted: { bg: '#DCFCE7', fg: '#166534' }, + PermissionRevoked: { bg: '#FEE2E2', fg: '#991B1B' }, + AccessReviewed: { bg: '#DBEAFE', fg: '#1E40AF' }, +}; + +function ChangeTypeBadge({ type }: { type: string }) { + const colors = CHANGELOG_TYPE_COLORS[type] || { bg: '#E2E8F0', fg: '#334155' }; + return ( + + ); +} + interface Props { - changelog: RBACChangeEntry[]; + changelog?: RBACChangeEntry[]; } export default function RBACChangelogSection({ changelog }: Props) { - if (changelog.length === 0) return null; - - const rows = changelog.map((entry) => [ - formatDate(entry.date), - entry.changeType, - entry.user, - entry.role, - entry.configName, - entry.source, - entry.description, - ]); + if (!changelog?.length) return null; return (
- +
+ Legend: + {Object.entries(CHANGELOG_TYPE_COLORS).map(([key, colors]) => ( + + + {key} + + ))} +
+
+ {changelog.map((entry, i) => ( +
+ {formatDate(entry.date)} + + {entry.user} + + {entry.role} + {entry.configName} + {entry.source && ({entry.source})} + {entry.description && {entry.description}} +
+ ))} +
); } diff --git a/report/components/RBACChanges.tsx b/report/components/RBACChanges.tsx new file mode 100644 index 000000000..e39fd1e57 --- /dev/null +++ b/report/components/RBACChanges.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Badge, ListTable, StatCard } from '@flanksource/facet'; +import type { ApplicationChange } from '../types.ts'; +import { ConfigTypeIcon } from './configTypeIcon.tsx'; +import { IdentityIcon } from './rbac-visual.tsx'; +import { filterRBACChanges, groupRBACChanges, type RBACChangeRow } from './change-section-utils.ts'; + +interface Props { + changes: ApplicationChange[]; +} + +const COUNT_VALUE_CLASS = 'text-[16pt] leading-[18pt]'; +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; +const ACTION_BADGE_COLORS: Record<'Granted' | 'Revoked', { color: string; textColor: string; borderColor: string }> = { + Granted: { + color: 'bg-green-50', + textColor: 'text-green-700', + borderColor: 'border-green-200', + }, + Revoked: { + color: 'bg-red-50', + textColor: 'text-red-700', + borderColor: 'border-red-200', + }, +}; + +export default function RBACChanges({ changes }: Props) { + const relevant = filterRBACChanges(changes); + if (!relevant.length) { + return null; + } + + const groups = groupRBACChanges(relevant); + const rows = groups + .flatMap((group) => group.rows.map((row) => ({ ...row, configName: group.configName, configType: group.configType }))) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + const grantedCount = groups.reduce((total, group) => total + group.rows.filter((row) => row.action === 'Granted').length, 0); + const revokedCount = groups.reduce((total, group) => total + group.rows.filter((row) => row.action === 'Revoked').length, 0); + const netCount = grantedCount - revokedCount; + const netColor = netCount > 0 ? 'orange' : netCount < 0 ? 'green' : 'gray'; + + return ( + <> +
+
+ 0 ? 'orange' : 'gray'} + shrink + valueClassName={COUNT_VALUE_CLASS} + /> +
+
+ 0 ? 'green' : 'gray'} + shrink + valueClassName={COUNT_VALUE_CLASS} + /> +
+
+ 0 ? `+${netCount}` : String(netCount)} + sublabel="Granted minus revoked" + variant="summary" + size="sm" + color={netColor} + shrink + valueClassName={COUNT_VALUE_CLASS} + /> +
+
+ { + const identityRoleSource = row.subjectKind === 'group' ? `group:${row.subject}` : undefined; + return { + id: row.id, + date: row.date, + configName: row.configName, + configType: row.configType, + subject: ( + + + {row.role || 'Access'} + to + + + {row.subject} + + + ), + subtitle: row.viaGroup ? `via ${row.viaGroup}` : undefined, + changedByLabel: `Changed by ${row.changedBy !== '-' ? row.changedBy : row.source}`, + notes: row.notes, + }; + })} + subject="subject" + subtitle="subtitle" + body="notes" + date="date" + keys={['changedByLabel']} + groups={[{ by: 'date' }, { by: 'field', field: 'configName' }]} + iconRenderer={(_value, context) => { + if (context.kind !== 'group' || context.field !== 'configName') { + return null; + } + + const configType = context.group?.sampleRow?.configType; + return typeof configType === 'string' && configType + ? + : null; + }} + size="xs" + density="compact" + wrap + cellClassName="text-[8pt]" + /> + + ); +} diff --git a/report/components/RBACCoverContent.tsx b/report/components/RBACCoverContent.tsx new file mode 100644 index 000000000..b933d5b97 --- /dev/null +++ b/report/components/RBACCoverContent.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import type { RBACReport } from '../rbac-types.ts'; +import CoverPage from './CoverPage.tsx'; + +interface Props { + report: RBACReport; + subtitle: string; +} + +export default function RBACCoverContent({ report, subtitle }: Props) { + const { subject, parents } = report; + const subjects = subject ? [subject] : undefined; + + return ( + + ); +} diff --git a/report/components/RBACMatrixSection.tsx b/report/components/RBACMatrixSection.tsx new file mode 100644 index 000000000..2f04fa2e3 --- /dev/null +++ b/report/components/RBACMatrixSection.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Icon } from '@flanksource/icons/icon'; +import type { RBACResource, RBACUserRole } from '../rbac-types.ts'; +import { Badge, MatrixTable, Dot } from '@flanksource/facet'; +import { ACCESS_COLORS, STALE_COLORS, ReviewOverdueBadge, ReviewOverdueLegendSwatch, IdentityIcon } from './rbac-visual.tsx'; + +interface Props { + resource: RBACResource; +} + +interface UserRow { + userId: string; + userName: string; + email: string; + roles: Map; +} + +function buildMatrix(resource: RBACResource) { + const roleSet = new Set(); + const userMap = new Map(); + + for (const u of resource.users || []) { + roleSet.add(u.role); + let row = userMap.get(u.userId); + if (!row) { + row = { userId: u.userId, userName: u.userName, email: u.email, roles: new Map() }; + userMap.set(u.userId, row); + } + row.roles.set(u.role, u); + } + + const roles = [...roleSet].sort(); + const users = [...userMap.values()].sort((a, b) => a.userName.localeCompare(b.userName)); + return { roles, users }; +} + +function loginAgeDays(lastSignedInAt?: string | null): number | null { + if (!lastSignedInAt) return null; + return Math.floor((Date.now() - new Date(lastSignedInAt).getTime()) / 86400000); +} + +function staleColor(lastSignedInAt?: string | null): string | null { + const days = loginAgeDays(lastSignedInAt); + if (days === null) return null; + if (days > 30) return STALE_COLORS.stale30d; + if (days > 7) return STALE_COLORS.stale7d; + return null; +} + +function Indicator({ entry }: { entry?: RBACUserRole }) { + if (!entry) return null; + const indirect = entry.roleSource.startsWith('group:'); + const color = indirect ? ACCESS_COLORS.group : ACCESS_COLORS.direct; + return ( +
+ + {entry.isReviewOverdue && } +
+ ); +} + +export function MatrixLegend() { + return ( +
+ Legend: + + Direct + + + Indirect + + + + Last login > 7d + + + + Last login > 30d + + +
+ ); +} + +export default function RBACMatrixSection({ resource }: Props) { + const { roles, users } = buildMatrix(resource); + if (users.length === 0) return null; + + const matrixRows = users.map((user) => { + const worstStale = [...user.roles.values()].reduce((worst, r) => { + const c = staleColor(r.lastSignedInAt); + if (c === STALE_COLORS.stale30d) return c; + return worst ?? c; + }, null); + const firstRole = [...user.roles.values()][0]; + const roleSource = firstRole?.roleSource; + return { + label: ( + + + {user.userName} + + ), + cells: roles.map((role) => ), + }; + }); + + const tags = { ...(resource.tags || {}), ...(resource.labels || {}) }; + const pathParts = resource.path?.split('.').filter(Boolean) ?? []; + const corner = ( +
+ {pathParts.length > 0 && ( +
+ {pathParts.map((p, i) => ( + + {i > 0 && /} + {p} + + ))} +
+ )} +
+ + {resource.configName} +
+ {Object.keys(tags).length > 0 && ( +
+ {Object.entries(tags).map(([k, v]) => ( + + ))} +
+ )} +
+ +
+
+ ); + + return ( +
+ +
+ ); +} diff --git a/report/components/RBACResourceSection.tsx b/report/components/RBACResourceSection.tsx deleted file mode 100644 index 417e3887b..000000000 --- a/report/components/RBACResourceSection.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React from 'react'; -import { Section, CompactTable } from '@flanksource/facet'; -import { Icon } from '@flanksource/icons/icon'; -import type { RBACResource, RBACUserRole } from '../rbac-types.ts'; -import { HEALTH_COLORS } from './utils.ts'; -import { ConfigTypeIcon } from './configTypeIcon.tsx'; - -interface Props { - resource: RBACResource; -} - -function fmtDate(iso: string): string { - const d = new Date(iso); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; -} - -function fmtDateTime(iso: string): string { - const d = new Date(iso); - const h = String(d.getHours()).padStart(2, '0'); - const min = String(d.getMinutes()).padStart(2, '0'); - return `${fmtDate(iso)}T${h}:${min}`; -} - -function age(iso?: string | null): string { - if (!iso) return 'Never'; - const diff = Date.now() - new Date(iso).getTime(); - const days = Math.floor(diff / 86400000); - if (days < 1) return '<1d'; - if (days < 30) return `${days}d`; - if (days < 365) return `${Math.floor(days / 30)}mo`; - return `${Math.floor(days / 365)}y ${Math.floor((days % 365) / 30)}mo`; -} - -function roleColumn(u: RBACUserRole): string { - const parts = [u.role]; - if (u.roleSource && u.roleSource !== 'direct') { - parts.push(`via ${u.roleSource}`); - } - if (u.sourceSystem && u.sourceSystem !== u.roleSource) { - parts.push(`(${u.sourceSystem})`); - } - return parts.join(' '); -} - -function ReviewAge({ u }: { u: RBACUserRole }) { - const text = age(u.lastReviewedAt); - if (u.isReviewOverdue && text !== 'Never') { - return {text}; - } - if (text === 'Never') { - return Never; - } - return <>{text}; -} - -function LabelBadge({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - - {value} - - - ); -} - -function Pill({ label, color, icon }: { label: string; color?: string; icon?: React.ReactNode }) { - return ( - - {icon} - {label.toUpperCase()} - - ); -} - -function SubHeader({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { - return ( -
- {icon} - {children} -
- ); -} - -function TagsRow({ tags, labels }: { tags?: Record; labels?: Record }) { - const tagKeys = new Set(tags ? Object.keys(tags) : []); - const entries: [string, string][] = []; - if (tags) entries.push(...Object.entries(tags)); - if (labels) { - for (const [k, v] of Object.entries(labels)) { - if (!tagKeys.has(k)) entries.push([k, v]); - } - } - if (entries.length === 0) return null; - - return ( -
- {entries.map(([k, v]) => ( - - ))} -
- ); -} - -function ResourceMeta({ resource }: Props) { - const dateParts: string[] = []; - if (resource.createdAt) dateParts.push(`Created: ${fmtDate(resource.createdAt)}`); - if (resource.updatedAt) dateParts.push(`Updated: ${fmtDate(resource.updatedAt)}`); - - const hasPills = resource.status || resource.health; - const hasTags = (resource.tags && Object.keys(resource.tags).length > 0) || - (resource.labels && Object.keys(resource.labels).length > 0); - - return ( -
-
- - ID: - - {resource.configId} - - - {dateParts.length > 0 && ( - - {dateParts.join(' \u2022 ')} - - )} -
- - {hasPills && ( -
- {resource.health && ( - } - /> - )} - {resource.status && } -
- )} - - {resource.description && ( -
{resource.description}
- )} - {hasTags && } -
- ); -} - -function ChangelogTable({ resource }: Props) { - if (!resource.changelog || resource.changelog.length === 0) return null; - - const rows = resource.changelog.map((e) => [ - fmtDateTime(e.date), - e.changeType, - e.user, - e.role, - e.description, - ]); - - return ( -
- }>Changelog - -
- ); -} - -function TemporaryAccessTable({ resource }: Props) { - if (!resource.temporaryAccess || resource.temporaryAccess.length === 0) return null; - - const rows = resource.temporaryAccess.map((e) => [ - e.user, - e.role, - e.source, - fmtDateTime(e.grantedAt), - fmtDateTime(e.revokedAt), - e.duration, - ]); - - return ( -
- }>Temporary Access (<72h) - -
- ); -} - -export default function RBACResourceSection({ resource }: Props) { - const rows = resource.users.map((u) => [ - u.userName, - u.email, - roleColumn(u), - fmtDate(u.createdAt), - age(u.lastSignedInAt), - , - ]); - - const title = ( - - - {resource.configName} ({resource.configType}) - - ); - - return ( -
- - }>Users - - - -
- ); -} diff --git a/report/components/RBACSummarySection.tsx b/report/components/RBACSummarySection.tsx index a10168be1..5d8b4da55 100644 --- a/report/components/RBACSummarySection.tsx +++ b/report/components/RBACSummarySection.tsx @@ -6,24 +6,38 @@ interface Props { summary: RBACSummary; } +const NO_BREAK_STYLE = { pageBreakInside: 'avoid' as const, breakInside: 'avoid' as const }; + export default function RBACSummarySection({ summary }: Props) { return (
-
- - - - - 0 ? 'warning' : 'default'} - /> - 0 ? 'warning' : 'default'} - /> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ 0 ? 'orange' : undefined} + /> +
+
+ 0 ? 'red' : undefined} + /> +
); diff --git a/report/components/RBACUserSection.tsx b/report/components/RBACUserSection.tsx index b24262835..56e0ea80b 100644 --- a/report/components/RBACUserSection.tsx +++ b/report/components/RBACUserSection.tsx @@ -1,9 +1,14 @@ import React from 'react'; -import { CompactTable } from '@flanksource/facet'; +import { Badge, CompactTable } from '@flanksource/facet'; import { Icon } from '@flanksource/icons/icon'; import type { RBACUserReport, RBACUserResource } from '../rbac-types.ts'; import { ConfigTypeIcon } from './configTypeIcon.tsx'; +const ROLE_SOURCE_COLORS: Record = { + direct: { bg: '#DBEAFE', fg: '#1E40AF' }, + group: { bg: '#F3E8FF', fg: '#6B21A8' }, +}; + function fmtDate(iso: string): string { const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; @@ -29,12 +34,30 @@ function ReviewAge({ r }: { r: RBACUserResource }) { return <>{text}; } -function roleColumn(r: RBACUserResource): string { - const parts = [r.role]; - if (r.roleSource && r.roleSource !== 'direct') { - parts.push(`via ${r.roleSource}`); - } - return parts.join(' '); +function RoleSourceBadge({ source }: { source: string }) { + const key = source.startsWith('group:') ? 'group' : source; + const colors = ROLE_SOURCE_COLORS[key] || ROLE_SOURCE_COLORS.direct; + return ( + + ); +} + +function roleColumn(r: RBACUserResource): React.ReactNode { + return ( + + {r.role} + + + ); } interface Props { @@ -53,13 +76,13 @@ function groupByConfigType(resources: RBACUserResource[]): Map {user.userName} - + ({user.email}) @@ -67,10 +90,10 @@ export default function RBACUserSection({ user }: Props) { return (
-
+
{title}
-
+
Source: {user.sourceSystem} @@ -81,7 +104,7 @@ export default function RBACUserSection({ user }: Props) { Resources: - {user.resources.length} + {(user.resources || []).length}
{[...grouped.entries()].map(([configType, resources]) => { @@ -97,7 +120,7 @@ export default function RBACUserSection({ user }: Props) { ]); return (
-
+
{configType} @@ -105,6 +128,7 @@ export default function RBACUserSection({ user }: Props) {
= { + kubernetes: 'Kubernetes', + aws: 'AWS', + azure: 'Azure', + gcp: 'GCP', + file: 'file', + sql: 'database', + http: 'http', + trivy: 'trivy', + terraform: 'Terraform', + githubActions: 'GitHub', + slack: 'Slack', + kubernetesFile: 'Kubernetes', +}; + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +interface Props { + scraper: ScraperInfo; +} + +export default function ScraperCard({ scraper }: Props) { + return ( +
+ {(scraper.types || []).map((t) => ( + + ))} + {scraper.name} + {scraper.source && ( + + )} + {scraper.id.slice(0, 8)} + {scraper.createdBy && {scraper.createdBy}} + {scraper.updatedAt + ? modified {formatDate(scraper.updatedAt)} + : scraper.createdAt && created {formatDate(scraper.createdAt)} + } + +
+ ); +} diff --git a/report/components/ViewResultSection.tsx b/report/components/ViewResultSection.tsx index 54902c703..e8bee0603 100644 --- a/report/components/ViewResultSection.tsx +++ b/report/components/ViewResultSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Section, CompactTable } from '@flanksource/facet'; +import { Badge, Section, CompactTable } from '@flanksource/facet'; import { Icon } from '@flanksource/icons/icon'; import type { ViewReportData, ViewColumnDef, @@ -50,12 +50,7 @@ const STATUS_COLORS: Record = { function StatusBadge({ value }: { value: string }) { const colors = STATUS_COLORS[value.toLowerCase()] ?? { bg: '#F3F4F6', fg: '#374151' }; return ( - - {value} - + ); } @@ -72,12 +67,7 @@ function BadgeCell({ value, config }: { value: string; config?: BadgeConfig }) { } return ( - - {value} - + ); } @@ -131,10 +121,17 @@ function TagBadges({ value }: { value: Record }) { return ( {Object.entries(value).map(([k, v]) => ( - - {k} - {v} - + ))} ); @@ -1021,10 +1018,17 @@ export default function ViewResultSection({ data }: Props) { {data.variables && data.variables.length > 0 && (
{data.variables.map((v) => ( - - {v.label || v.key}: - {v.default || '-'} - + ))}
)} diff --git a/report/components/change-section-utils.ts b/report/components/change-section-utils.ts new file mode 100644 index 000000000..df7e5cecc --- /dev/null +++ b/report/components/change-section-utils.ts @@ -0,0 +1,1059 @@ +import type { ApplicationChange, ApplicationPermissionChange } from '../types.ts'; +import type { ConfigChange, ConfigTypedChange } from '../config-types.ts'; +import type { CatalogReportCategoryMapping } from '../catalog-report-types.ts'; + +export type ChangeSectionVariant = 'generic' | 'rbac' | 'backup' | 'deployment'; +export type BackupCalendarStatus = 'success' | 'failed' | 'warning'; +export type RBACChangeAction = 'added' | 'removed'; + +export interface BackupCalendarEntry { + date: string; + status: BackupCalendarStatus; + label?: string; +} + +export interface RBACChangeRow { + id: string; + date: string; + configId?: string; + configName: string; + configType?: string; + action: 'Granted' | 'Revoked'; + subject: string; + subjectKind: 'user' | 'group'; + role?: string; + viaGroup?: string; + changedBy: string; + source: string; + notes?: string; +} + +export interface RBACChangeGroup { + key: string; + configId?: string; + configName: string; + configType?: string; + latestDate: string; + rows: RBACChangeRow[]; +} + +export interface CategorizedChanges { + rbac: ConfigChange[]; + backup: ConfigChange[]; + deployment: ConfigChange[]; + uncategorized: ConfigChange[]; +} + +export interface TypedChangeDisplay { + label?: string; + summary?: string; + meta: string[]; + diff?: TypedChangeDiff; +} + +export interface TypedChangeDiff { + label?: string; + from: string; + to: string; +} + +const RBAC_ADDED_TYPES = new Set(['PermissionGranted', 'PermissionAdded']); +const RBAC_REMOVED_TYPES = new Set(['PermissionRevoked', 'PermissionRemoved']); +const BACKUP_SUCCESS_TYPES = new Set(['BackupCompleted', 'BackupSuccessful']); +const BACKUP_FAILED_TYPES = new Set(['BackupFailed']); +const BACKUP_PROGRESS_TYPES = new Set(['BackupStarted', 'BackupRunning', 'BackupEnqueued']); +const RESTORE_CHANGE_TYPES = new Set(['BackupRestored', 'RestoreCompleted']); +const DEPLOYMENT_CHANGE_TYPES = new Set(['ScalingReplicaSet', 'PolicyUpdate', 'CodeDeployment', 'diff']); + +function normalizedType(change: ApplicationChange): string { + return change.changeType ?? ''; +} + +export function getChangeActor(change: ApplicationChange): string { + return change.createdBy || change.source || '-'; +} + +function getCategoryKey(change: ApplicationChange): string { + return change.category ?? ''; +} + +function normalizeRBACAction(change: ApplicationChange): RBACChangeAction | null { + const category = getCategoryKey(change); + if (category === 'rbac.granted') { + return 'added'; + } + if (category === 'rbac.revoked') { + return 'removed'; + } + + const type = normalizedType(change); + if (RBAC_ADDED_TYPES.has(type)) { + return 'added'; + } + if (RBAC_REMOVED_TYPES.has(type)) { + return 'removed'; + } + return null; +} + +export function isRBACChange(change: ApplicationChange): boolean { + return normalizeRBACAction(change) !== null; +} + +export function isBackupChange(change: ApplicationChange): boolean { + if (getCategoryKey(change).startsWith('backup.')) { + return true; + } + + const type = normalizedType(change); + return BACKUP_SUCCESS_TYPES.has(type) || BACKUP_FAILED_TYPES.has(type) || BACKUP_PROGRESS_TYPES.has(type) || RESTORE_CHANGE_TYPES.has(type); +} + +export function isRestoreChange(change: ApplicationChange): boolean { + if (getCategoryKey(change) === 'backup.restore') { + return true; + } + + return RESTORE_CHANGE_TYPES.has(normalizedType(change)); +} + +export function classifyDeploymentChange(change: ApplicationChange): 'scale' | 'policy' | 'spec' | null { + const category = getCategoryKey(change); + if (category.startsWith('deployment.')) { + const suffix = category.slice('deployment.'.length); + if (suffix === 'scale' || suffix === 'scaling') { + return 'scale'; + } + if (suffix === 'policy') { + return 'policy'; + } + return 'spec'; + } + if (category === 'deployment') { + return 'spec'; + } + + const type = normalizedType(change); + const lowerType = type.toLowerCase(); + + if (type === 'ScalingReplicaSet' || lowerType.includes('replicaset')) { + return 'scale'; + } + + if (type === 'PolicyUpdate') { + return 'policy'; + } + + if (DEPLOYMENT_CHANGE_TYPES.has(type)) { + return 'spec'; + } + + return null; +} + +export function isDeploymentChange(change: ApplicationChange): boolean { + return classifyDeploymentChange(change) !== null; +} + +export function filterRBACChanges(changes: ApplicationChange[]): ApplicationChange[] { + return changes.filter(isRBACChange); +} + +export function filterBackupChanges(changes: ApplicationChange[]): ApplicationChange[] { + return changes.filter(isBackupChange); +} + +export function filterDeploymentChanges(changes: ApplicationChange[]): ApplicationChange[] { + return changes.filter(isDeploymentChange); +} + +export function inferChangeSectionVariant( + title: string, + changes: ApplicationChange[], + _categoryMappings?: CatalogReportCategoryMapping[], +): ChangeSectionVariant { + const lowerTitle = title.toLowerCase(); + + if (/\brbac\b|\bpermission/.test(lowerTitle)) { + return 'rbac'; + } + + if (/\bbackup\b|\brestore\b/.test(lowerTitle)) { + return 'backup'; + } + + if (/\bdeployment\b|\brollout\b/.test(lowerTitle)) { + return 'deployment'; + } + + let rbacCount = 0; + let backupCount = 0; + let deploymentCount = 0; + for (const change of changes) { + const category = getCategoryKey(change); + if (!category) { + continue; + } + + if (category === 'rbac' || category.startsWith('rbac.')) { + rbacCount += 1; + } else if (category === 'backup' || category.startsWith('backup.')) { + backupCount += 1; + } else if (category === 'deployment' || category.startsWith('deployment.')) { + deploymentCount += 1; + } + } + + if (rbacCount > 0 && rbacCount === changes.length) { + return 'rbac'; + } + if (backupCount > 0 && backupCount === changes.length) { + return 'backup'; + } + if (deploymentCount > 0 && deploymentCount >= Math.ceil(changes.length / 2)) { + return 'deployment'; + } + + return 'generic'; +} + +export function getBackupCalendarStatus(change: ApplicationChange): BackupCalendarStatus | null { + const category = getCategoryKey(change); + if (category === 'backup.success') { + return 'success'; + } + if (category === 'backup.failed') { + return 'failed'; + } + if (category === 'backup.progress') { + return 'warning'; + } + + const type = normalizedType(change); + if (BACKUP_SUCCESS_TYPES.has(type)) { + return 'success'; + } + if (BACKUP_FAILED_TYPES.has(type)) { + return 'failed'; + } + if (BACKUP_PROGRESS_TYPES.has(type)) { + return 'warning'; + } + return null; +} + +export function extractBackupLabel(change: ApplicationChange): string | undefined { + const match = change.description.match(/(\d+(?:\.\d+)?)\s*([KMGT]i?B|bytes?)/i); + if (!match) { + return undefined; + } + + return `${match[1]} ${match[2].toUpperCase()}`; +} + +export function toBackupCalendarEntries(changes: ApplicationChange[]): BackupCalendarEntry[] { + return filterBackupChanges(changes) + .filter((change) => !isRestoreChange(change)) + .map((change) => { + const status = getBackupCalendarStatus(change); + if (!status) { + return null; + } + + return { + date: change.date, + status, + label: extractBackupLabel(change), + }; + }) + .filter(Boolean) as BackupCalendarEntry[]; +} + +interface ParsedPermissionSummary { + user?: string; + role?: string; + group?: string; + resourceName?: string; +} + +function cleanField(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function parseStructuredPermissionSummary(description: string): ParsedPermissionSummary { + const match = description.match(/^Permission(?:Added|Removed):\s*(.+)$/i); + if (!match) { + return {}; + } + + const parsed: ParsedPermissionSummary = {}; + for (const part of match[1].split(/\s*,\s*/)) { + const userMatch = part.match(/^user\s+(.+)$/i); + if (userMatch) { + parsed.user = cleanField(userMatch[1]); + continue; + } + + const roleMatch = part.match(/^role\s+(.+)$/i); + if (roleMatch) { + parsed.role = cleanField(roleMatch[1]); + continue; + } + + const groupMatch = part.match(/^group\s+(.+)$/i); + if (groupMatch) { + parsed.group = cleanField(groupMatch[1]); + } + } + + return parsed; +} + +function parseLegacyPermissionSummary(description: string): ParsedPermissionSummary { + const grantedWithPermissions = description.match(/^Granted\s+(.+?)\s+permissions?\s+to\s+(.+?)\s+on\s+(.+)$/i); + if (grantedWithPermissions) { + return { + role: cleanField(grantedWithPermissions[1]), + user: cleanField(grantedWithPermissions[2]), + resourceName: cleanField(grantedWithPermissions[3]), + }; + } + + const granted = description.match(/^Granted\s+(.+?)\s+to\s+(.+?)\s+on\s+(.+)$/i); + if (granted) { + return { + role: cleanField(granted[1]), + user: cleanField(granted[2]), + resourceName: cleanField(granted[3]), + }; + } + + const revoked = description.match(/^Revoked\s+(.+?)\s+access\s+for\s+(.+?)\s+on\s+(.+)$/i); + if (revoked) { + return { + role: cleanField(revoked[1]), + user: cleanField(revoked[2]), + resourceName: cleanField(revoked[3]), + }; + } + + return {}; +} + +function parsePermissionSummary(description: string): ParsedPermissionSummary { + const structured = parseStructuredPermissionSummary(description); + if (structured.user || structured.role || structured.group) { + return structured; + } + return parseLegacyPermissionSummary(description); +} + +function isRedundantPermissionDescription(change: ApplicationChange, permission: ApplicationPermissionChange, parsed: ParsedPermissionSummary): boolean { + const description = change.description.trim(); + if (!description) { + return true; + } + + if (/^Permission(?:Added|Removed):/i.test(description)) { + return true; + } + + if (/^(Granted|Revoked)\b/i.test(description)) { + const matchedUser = cleanField(permission.user) || parsed.user; + const matchedRole = cleanField(permission.role) || parsed.role; + const matchedResource = cleanField(change.configName) || parsed.resourceName; + + if ( + (!matchedUser || description.includes(matchedUser)) && + (!matchedRole || description.includes(matchedRole)) && + (!matchedResource || description.includes(matchedResource)) + ) { + return true; + } + } + + return false; +} + +export function groupRBACChanges(changes: ApplicationChange[]): RBACChangeGroup[] { + const grouped = new Map(); + + for (const change of filterRBACChanges(changes)) { + const action = normalizeRBACAction(change); + if (!action) { + continue; + } + + const parsed = parsePermissionSummary(change.description); + const permission = change.permission ?? {}; + const configName = cleanField(change.configName) || parsed.resourceName || 'Unknown resource'; + const configId = cleanField(change.configId); + const key = configId || configName; + const explicitUser = cleanField(permission.user) || parsed.user; + const explicitGroup = cleanField(permission.group) || parsed.group; + const subject = explicitUser || explicitGroup || '-'; + const subjectKind = explicitUser ? 'user' : explicitGroup ? 'group' : 'user'; + const role = cleanField(permission.role) || parsed.role; + const viaGroup = explicitUser && explicitGroup ? explicitGroup : undefined; + const notes = isRedundantPermissionDescription(change, permission, parsed) + ? undefined + : cleanField(change.description); + + if (!grouped.has(key)) { + grouped.set(key, { + key, + configId, + configName, + configType: cleanField(change.configType), + latestDate: change.date, + rows: [], + }); + } + + const group = grouped.get(key)!; + if (!group.configType && change.configType) { + group.configType = change.configType; + } + if (new Date(change.date).getTime() > new Date(group.latestDate).getTime()) { + group.latestDate = change.date; + } + + group.rows.push({ + id: change.id, + date: change.date, + configId, + configName, + configType: cleanField(change.configType), + action: action === 'added' ? 'Granted' : 'Revoked', + subject, + subjectKind, + role, + viaGroup, + changedBy: cleanField(change.createdBy) || '-', + source: cleanField(change.source) || '-', + notes, + }); + } + + return [...grouped.values()] + .map((group) => ({ + ...group, + rows: group.rows.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()), + })) + .sort((a, b) => new Date(b.latestDate).getTime() - new Date(a.latestDate).getTime()); +} + +export function categorizeChanges( + changes: ConfigChange[], + _categoryMappings?: CatalogReportCategoryMapping[], +): CategorizedChanges { + const result: CategorizedChanges = { rbac: [], backup: [], deployment: [], uncategorized: [] }; + + for (const change of changes) { + const category = change.category ?? ''; + + if (category === 'rbac' || category.startsWith('rbac.')) { result.rbac.push(change); continue; } + if (category === 'backup' || category.startsWith('backup.')) { result.backup.push(change); continue; } + if (category === 'deployment' || category.startsWith('deployment.')) { result.deployment.push(change); continue; } + + if (!category) { + const asApp = configChangeToApplicationChange(change); + if (isRBACChange(asApp)) { result.rbac.push(change); continue; } + if (isBackupChange(asApp)) { result.backup.push(change); continue; } + if (isDeploymentChange(asApp)) { result.deployment.push(change); continue; } + } + + result.uncategorized.push(change); + } + + return result; +} + +function asText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === 'string') { + return cleanField(value); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +function compactMeta(values: Array): string[] { + return values.filter((value): value is string => Boolean(value)); +} + +function joinText(values: Array, separator = ', '): string | undefined { + const filtered = compactMeta(values); + return filtered.length > 0 ? filtered.join(separator) : undefined; +} + +function labelValue(label: string, value: unknown): string | undefined { + const text = asText(value); + return text ? `${label}: ${text}` : undefined; +} + +function transition(label: string, from: unknown, to: unknown): string | undefined { + const fromText = asText(from); + const toText = asText(to); + if (!fromText && !toText) { + return undefined; + } + if (!fromText) { + return `${label}: ${toText}`; + } + if (!toText) { + return `${label}: ${fromText}`; + } + return `${label}: ${fromText} -> ${toText}`; +} + +function toDiff(label: string, from: unknown, to: unknown): TypedChangeDiff | undefined { + const fromText = asText(from); + const toText = asText(to); + if (!fromText || !toText || fromText === toText) { + return undefined; + } + + return { label, from: fromText, to: toText }; +} + +function formatDimensions(width: unknown, height: unknown): string | undefined { + const widthText = asText(width); + const heightText = asText(height); + if (!widthText || !heightText) { + return undefined; + } + return `${widthText}x${heightText}`; +} + +function formatCurrencyAmount(value: unknown, currency: unknown): string | undefined { + if (typeof value !== 'number') { + return asText(value); + } + + const code = asText(currency)?.toUpperCase(); + if (code && code.length === 3) { + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(value); + } catch { + return `${code} ${value}`; + } + } + + return String(value); +} + +function identityLabel(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return asText(record.name) || asText(record.id) || asText(record.type); +} + +function environmentLabel(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return asText(record.name) || asText(record.identifier); +} + +function dimensionLabel(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) { + return asText(value); + } + + const desired = asText(record.desired); + if (desired) { + return desired; + } + + const min = asText(record.min); + const max = asText(record.max); + if (min || max) { + return joinText([min, max], '..'); + } + + return undefined; +} + +function formatObjectPreview(value: unknown): string | undefined { + const record = asRecord(value); + if (record) { + const entries = Object.entries(record); + if (entries.length === 1) { + const [key, nested] = entries[0]; + const nestedText = asText(nested) || formatObjectPreview(nested); + return nestedText ? `${key}: ${nestedText}` : key; + } + + try { + return JSON.stringify(record); + } catch { + return undefined; + } + } + + if (Array.isArray(value)) { + try { + return JSON.stringify(value); + } catch { + return undefined; + } + } + + return asText(value); +} + +function arrayCountLabel(label: string, value: unknown): string | undefined { + return Array.isArray(value) && value.length > 0 ? `${label}: ${value.length}` : undefined; +} + +function objectCountLabel(label: string, value: unknown): string | undefined { + const record = asRecord(value); + return record && Object.keys(record).length > 0 ? `${label}: ${Object.keys(record).length}` : undefined; +} + +function sourceSummary(value: unknown): string | undefined { + const source = asRecord(value); + if (!source) { + return undefined; + } + + const git = asRecord(source.git) ?? asRecord(source.kustomization) ?? asRecord(source.argocd); + if (git) { + return joinText(['Git', asText(git.url) || asText(git.branch) || asText(git.commit_sha)], ': '); + } + + const helm = asRecord(source.helm); + if (helm) { + return joinText(['Helm', asText(helm.chart_name) || asText(helm.repo_url)], ': '); + } + + const image = asRecord(source.image); + if (image) { + const imageRef = joinText([asText(image.registry), asText(image.image)], '/'); + return joinText(['Image', imageRef || asText(image.version)], ': '); + } + + const database = asRecord(source.database); + if (database) { + return joinText(['Database', asText(database.name) || asText(database.endpoint)], ': '); + } + + const other = asText(source.other); + if (other) { + return joinText(['Other', other], ': '); + } + + return undefined; +} + +function changePathsLabel(value: unknown): string | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const paths = value + .map((item) => asText(asRecord(item)?.path)) + .filter((item): item is string => Boolean(item)); + + if (!paths.length) { + return undefined; + } + + const preview = paths.slice(0, 2).join(', '); + return `Paths: ${preview}${paths.length > 2 ? ` +${paths.length - 2} more` : ''}`; +} + +function humanizeLabel(value: string): string { + return value + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + .replace(/^./, (char) => char.toUpperCase()); +} + +function humanizeKind(kind: string): string { + const base = kind.split('/')[0] ?? kind; + return humanizeLabel(base); +} + +export function getChangeTypeLabel(change: ConfigChange, typedDisplay?: TypedChangeDisplay): string { + const typeLabel = humanizeLabel(change.changeType || 'Change'); + const normalizedType = (change.changeType || '').trim().toLowerCase(); + + if (typedDisplay?.label && ['diff', 'change', 'changed', 'update', 'updated'].includes(normalizedType)) { + return typedDisplay.label; + } + + return typeLabel; +} + +function permissionFromTypedChange(typedChange?: ConfigTypedChange): ApplicationPermissionChange | undefined { + if (typedChange?.kind !== 'PermissionChange/v1') { + return undefined; + } + + const user = asText(typedChange.user_name); + const group = asText(typedChange.group_name); + const role = asText(typedChange.role_name); + if (!user && !group && !role) { + return undefined; + } + + return { user, group, role }; +} + +const TYPED_CHANGE_RENDERERS: Record Omit> = { + 'UserChange/v1': (typedChange) => ({ + summary: asText(typedChange.user_name) || asText(typedChange.user_id), + meta: compactMeta([ + asText(typedChange.user_email), + labelValue('Group', typedChange.group_name || typedChange.group_id), + labelValue('Type', typedChange.user_type), + labelValue('Tenant', typedChange.tenant), + ]), + }), + 'Screenshot/v1': (typedChange) => ({ + summary: asText(typedChange.url) || asText(typedChange.artifact_id), + meta: compactMeta([ + labelValue('Artifact', typedChange.artifact_id), + labelValue('Type', typedChange.content_type), + labelValue('Size', formatDimensions(typedChange.width, typedChange.height)), + labelValue('URL', typedChange.url), + ]), + }), + 'PermissionChange/v1': (typedChange) => ({ + summary: asText(typedChange.user_name) || asText(typedChange.group_name) || asText(typedChange.user_id) || asText(typedChange.group_id), + meta: compactMeta([ + labelValue('Role', typedChange.role_name || typedChange.role_id), + labelValue('Role Type', typedChange.role_type), + labelValue('Scope', typedChange.scope), + ]), + }), + 'Identity/v1': (typedChange) => ({ + summary: identityLabel(typedChange), + meta: compactMeta([ + labelValue('Type', typedChange.type), + labelValue('Comment', typedChange.comment), + ]), + }), + 'GitSource/v1': (typedChange) => ({ + summary: asText(typedChange.url), + meta: compactMeta([ + labelValue('Branch', typedChange.branch), + labelValue('Commit', typedChange.commit_sha), + labelValue('Version', typedChange.version), + labelValue('Tags', typedChange.tags), + ]), + }), + 'HelmSource/v1': (typedChange) => ({ + summary: asText(typedChange.chart_name), + meta: compactMeta([ + labelValue('Version', typedChange.chart_version), + labelValue('Repo', typedChange.repo_url), + ]), + }), + 'ImageSource/v1': (typedChange) => ({ + summary: joinText([asText(typedChange.registry), asText(typedChange.image)], '/'), + meta: compactMeta([ + labelValue('Version', typedChange.version), + labelValue('SHA', typedChange.sha), + ]), + }), + 'DatabaseSource/v1': (typedChange) => ({ + summary: asText(typedChange.name) || asText(typedChange.endpoint), + meta: compactMeta([ + labelValue('Type', typedChange.type), + labelValue('Schema', typedChange.schema), + labelValue('Version', typedChange.version), + labelValue('Endpoint', typedChange.endpoint), + ]), + }), + 'Source/v1': (typedChange) => ({ + summary: sourceSummary(typedChange), + meta: compactMeta([ + labelValue('Path', typedChange.path), + labelValue('Other', typedChange.other), + ]), + }), + 'Environment/v1': (typedChange) => ({ + summary: environmentLabel(typedChange), + meta: compactMeta([ + labelValue('Type', typedChange.type), + labelValue('Stage', typedChange.stage), + labelValue('Identifier', typedChange.identifier), + objectCountLabel('Tags', typedChange.tags), + ]), + }), + 'Event/v1': (typedChange) => ({ + summary: asText(typedChange.id), + meta: compactMeta([ + labelValue('URL', typedChange.url), + labelValue('Timestamp', typedChange.timestamp), + objectCountLabel('Tags', typedChange.tags), + objectCountLabel('Properties', typedChange.properties), + ]), + }), + 'Deployment/v1': (typedChange) => { + const imageDiff = toDiff('Image', typedChange.previous_image, typedChange.new_image); + return { + summary: asText(typedChange.container), + meta: compactMeta([ + labelValue('Container', typedChange.container), + imageDiff ? undefined : transition('Image', typedChange.previous_image, typedChange.new_image), + labelValue('Namespace', typedChange.namespace), + labelValue('Strategy', typedChange.strategy), + ]), + diff: imageDiff, + }; + }, + 'Promotion/v1': (typedChange) => { + const fromEnvironment = environmentLabel(typedChange.from) || asText(typedChange.from_environment); + const toEnvironment = environmentLabel(typedChange.to) || asText(typedChange.to_environment); + const environmentDiff = toDiff('Environment', fromEnvironment, toEnvironment); + return { + summary: asText(typedChange.artifact) || asText(typedChange.version), + meta: compactMeta([ + environmentDiff ? undefined : transition('Environment', fromEnvironment, toEnvironment), + labelValue('Version', typedChange.version), + labelValue('Artifact', typedChange.artifact), + labelValue('Source', sourceSummary(typedChange.source)), + arrayCountLabel('Approvals', typedChange.approvals), + ]), + diff: environmentDiff, + }; + }, + 'Approval/v1': (typedChange) => { + const submittedBy = identityLabel(typedChange.submitted_by) || asText(typedChange.submitted_by); + const approver = identityLabel(typedChange.approver) || asText(typedChange.approved_by) || asText(typedChange.rejected_by); + const status = asText(typedChange.status) + || (asText(typedChange.approved_by) ? 'Approved' : undefined) + || (asText(typedChange.rejected_by) ? 'Rejected' : undefined); + const summary = approver && status + ? `${status} by ${approver}` + : submittedBy + ? `Submitted by ${submittedBy}` + : status + ? `${status} approval` + : 'Approval decision'; + return { + summary, + meta: compactMeta([ + labelValue('Submitted By', submittedBy), + labelValue('Approver', approver), + labelValue('Stage', typedChange.stage), + labelValue('Status', status), + labelValue('Playbook', typedChange.playbook_id), + labelValue('Run', typedChange.run_id), + labelValue('Reason', typedChange.reason), + ]), + }; + }, + 'Rollback/v1': (typedChange) => { + const versionDiff = toDiff('Version', typedChange.from_version, typedChange.to_version); + return { + summary: labelValue('Reason', typedChange.reason), + meta: compactMeta([ + versionDiff ? undefined : transition('Version', typedChange.from_version, typedChange.to_version), + labelValue('Trigger', typedChange.trigger), + ]), + diff: versionDiff, + }; + }, + 'Backup/v1': (typedChange) => ({ + summary: environmentLabel(typedChange.environment) || asText(typedChange.target) || asText(typedChange.backup_type), + meta: compactMeta([ + labelValue('Status', typedChange.status), + labelValue('Type', typedChange.backup_type), + labelValue('Created By', identityLabel(typedChange.created_by)), + labelValue('Environment', environmentLabel(typedChange.environment)), + labelValue('Target', typedChange.target), + labelValue('Size', typedChange.size), + labelValue('Delta', typedChange.delta), + labelValue('Duration', typedChange.duration), + labelValue('End', typedChange.end), + labelValue('Snapshot', typedChange.snapshot_id), + ]), + }), + 'PlaybookExecution/v1': (typedChange) => { + const playbook = asText(typedChange.playbook_name) || asText(typedChange.playbook_id); + return { + summary: playbook, + meta: compactMeta([ + labelValue('Run', typedChange.run_id), + labelValue('Status', typedChange.status), + labelValue('Duration', typedChange.duration), + labelValue('Error', typedChange.error), + ]), + }; + }, + 'Scaling/v1': (typedChange) => { + const replicaDiff = toDiff('Replicas', typedChange.from_replicas, typedChange.to_replicas); + return { + summary: asText(typedChange.resource_type), + meta: compactMeta([ + labelValue('Resource', typedChange.resource_type), + replicaDiff ? undefined : transition('Replicas', typedChange.from_replicas, typedChange.to_replicas), + labelValue('Trigger', typedChange.trigger), + ]), + diff: replicaDiff, + }; + }, + 'Scale/v1': (typedChange) => { + const previousValue = dimensionLabel(typedChange.previous_value); + const currentValue = dimensionLabel(typedChange.value); + const label = asText(typedChange.dimension) || 'Value'; + const scaleDiff = toDiff(label, previousValue, currentValue); + return { + summary: typedChange.dimension ? `${typedChange.dimension} scaling` : 'Scale change', + meta: compactMeta([ + scaleDiff ? undefined : transition(label, previousValue, currentValue), + ]), + diff: scaleDiff, + }; + }, + 'Certificate/v1': (typedChange) => ({ + summary: labelValue('Subject', typedChange.subject), + meta: compactMeta([ + labelValue('Issuer', typedChange.issuer), + labelValue('Valid To', typedChange.not_after), + labelValue('Serial', typedChange.serial), + labelValue('DNS', typedChange.dns_names), + ]), + }), + 'CostChange/v1': (typedChange) => { + const costDiff = toDiff( + 'Cost', + formatCurrencyAmount(typedChange.previous_cost, typedChange.currency), + formatCurrencyAmount(typedChange.new_cost, typedChange.currency), + ); + return { + summary: labelValue('Reason', typedChange.reason), + meta: compactMeta([ + costDiff ? undefined : transition('Cost', formatCurrencyAmount(typedChange.previous_cost, typedChange.currency), formatCurrencyAmount(typedChange.new_cost, typedChange.currency)), + labelValue('Period', typedChange.period), + ]), + diff: costDiff, + }; + }, + 'PipelineRun/v1': (typedChange) => { + const pipeline = asText(typedChange.pipeline_name) || asText(typedChange.pipeline_id) || environmentLabel(typedChange.environment); + return { + summary: pipeline, + meta: compactMeta([ + labelValue('Run', typedChange.run_number ?? typedChange.run_id), + labelValue('Branch', typedChange.branch), + labelValue('Environment', environmentLabel(typedChange.environment)), + labelValue('Status', typedChange.status), + labelValue('Duration', typedChange.duration), + labelValue('Error', typedChange.error), + ]), + }; + }, + 'Change/v1': (typedChange) => { + const changeDiff = toDiff('Value', formatObjectPreview(typedChange.from), formatObjectPreview(typedChange.to)); + return { + summary: asText(typedChange.path) || 'Field change', + meta: compactMeta([ + labelValue('Type', typedChange.type), + changeDiff ? undefined : transition('Value', formatObjectPreview(typedChange.from), formatObjectPreview(typedChange.to)), + ]), + diff: changeDiff, + }; + }, + 'ConfigChange/v1': (typedChange) => { + const changeCount = Array.isArray(typedChange.changes) ? typedChange.changes.length : 0; + return { + summary: changeCount > 0 ? `${changeCount} field change${changeCount === 1 ? '' : 's'}` : 'Config change', + meta: compactMeta([ + labelValue('Author', identityLabel(typedChange.author)), + labelValue('Environment', environmentLabel(typedChange.environment)), + labelValue('Source', sourceSummary(typedChange.source)), + changePathsLabel(typedChange.changes), + ]), + }; + }, + 'Restore/v1': (typedChange) => { + const fromEnvironment = environmentLabel(typedChange.from); + const toEnvironment = environmentLabel(typedChange.to); + const environmentDiff = toDiff('Environment', fromEnvironment, toEnvironment); + return { + summary: sourceSummary(typedChange.source) || asText(typedChange.status) || 'Restore job', + meta: compactMeta([ + environmentDiff ? undefined : transition('Environment', fromEnvironment, toEnvironment), + labelValue('Source', sourceSummary(typedChange.source)), + labelValue('Status', typedChange.status), + ]), + diff: environmentDiff, + }; + }, + 'Test/v1': (typedChange) => ({ + summary: asText(typedChange.name) || asText(typedChange.id), + meta: compactMeta([ + labelValue('Type', typedChange.type), + labelValue('Status', typedChange.status), + labelValue('Result', typedChange.result), + labelValue('Description', typedChange.description), + ]), + }), + 'Dimension/v1': (typedChange) => ({ + summary: dimensionLabel(typedChange), + meta: compactMeta([ + labelValue('Min', typedChange.min), + labelValue('Max', typedChange.max), + labelValue('Desired', typedChange.desired), + ]), + }), +}; + +export function getTypedChangeDisplay(change: ConfigChange): TypedChangeDisplay | undefined { + const typedChange = change.typedChange; + if (!typedChange?.kind) { + return undefined; + } + + const renderer = TYPED_CHANGE_RENDERERS[typedChange.kind]; + const display = renderer ? renderer(typedChange) : { meta: [] }; + return { + label: humanizeKind(typedChange.kind), + summary: display.summary, + meta: display.meta ?? [], + diff: display.diff, + }; +} + +export function configChangeToApplicationChange(c: ConfigChange, category?: string): ApplicationChange { + const permission = (c.details?.permission as ApplicationPermissionChange | undefined) ?? permissionFromTypedChange(c.typedChange); + return { + id: c.id ?? '', + date: c.createdAt ?? '', + changeType: c.changeType, + category: category ?? c.category, + source: c.source, + createdBy: c.createdBy ?? c.externalCreatedBy, + configId: c.configID, + configName: c.configName, + configType: c.configType, + permission, + description: c.summary ?? '', + status: c.severity ?? 'info', + createdAt: c.createdAt ?? '', + }; +} diff --git a/report/components/rbac-visual.tsx b/report/components/rbac-visual.tsx new file mode 100644 index 000000000..b5228ccc6 --- /dev/null +++ b/report/components/rbac-visual.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Icon } from '@flanksource/icons/icon'; + +// --- Identity Types --- + +export type IdentityType = 'user' | 'group' | 'service' | 'bot'; + +export interface IdentityInfo { + type: IdentityType; + icon: string; + color: string; + label: string; +} + +const IDENTITY_COLOR = '#64748B'; + +const IDENTITY_ICON_NAMES: Record = { + user: 'user', + group: 'group', + service: 'server', + bot: 'bot', +}; + +const IDENTITY_LABELS: Record = { + user: 'User', + group: 'Group', + service: 'Service Account', + bot: 'Bot', +}; + +export function identityType(userId: string, roleSource?: string): IdentityInfo { + const resolve = (type: IdentityType): IdentityInfo => ({ + type, icon: type, color: IDENTITY_COLOR, label: IDENTITY_LABELS[type], + }); + if (roleSource?.startsWith('group:')) return resolve('group'); + if (/svc[-_]|service[-_]/i.test(userId)) return resolve('service'); + if (/bot[-_]|automation[-_]|pipeline[-_]/i.test(userId)) return resolve('bot'); + return resolve('user'); +} + +// --- Access Pattern --- + +export const ACCESS_COLORS = { + direct: '#2563EB', + group: '#7C3AED', +}; + +export function isDirect(roleSource: string): boolean { + return !roleSource.startsWith('group:'); +} + +// --- Staleness --- + +export const STALE_COLORS = { + stale7d: '#EAB308', + stale30d: '#DC2626', +}; + +// --- Review Status --- + +export const REVIEW_OVERDUE_COLOR = '#DC2626'; + +export function ReviewOverdueBadge() { + return ( +
+ ); +} + +export function ReviewOverdueLegendSwatch() { + return ( + + + + + Review Overdue + + ); +} + +// --- Reusable Visual Components --- + +export function IdentityIcon({ userId, roleSource, size = 14 }: { userId: string; roleSource?: string; size?: number }) { + const info = identityType(userId, roleSource); + return ; +} + +export function AccessIndicator({ direct, color, size = 2.5 }: { direct: boolean; color: string; size?: number }) { + return ( +
+ ); +} diff --git a/report/components/utils.ts b/report/components/utils.ts index 1a3fe8280..cd3d51aca 100644 --- a/report/components/utils.ts +++ b/report/components/utils.ts @@ -12,12 +12,60 @@ export function formatDateTime(iso: string): string { } export function formatRelative(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; + return formatMonthDay(iso); +} + +export function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); +} + +export function formatMonthDay(iso: string): string { + const d = new Date(iso); + const now = new Date(); + if (d.getFullYear() !== now.getFullYear()) { + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + } + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export type TimeBucketFormat = 'time' | 'monthDay'; + +export interface TimeBucket { + key: string; + label: string; + dateFormat: TimeBucketFormat; +} + +export function getTimeBucket(iso: string): TimeBucket { + const d = new Date(iso); + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const diffDays = Math.floor((startOfToday.getTime() - new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()) / 86400000); + + if (diffDays <= 0) { + return { key: 'today', label: formatDayLabel(d), dateFormat: 'time' }; + } + if (diffDays <= 6) { + return { key: `day-${diffDays}`, label: formatDayLabel(d), dateFormat: 'time' }; + } + if (diffDays <= 30) { + const weekStart = new Date(d); + weekStart.setDate(d.getDate() - d.getDay() + 1); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 4); + const fmt = (dt: Date) => dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return { key: `week-${fmt(weekStart)}`, label: `${fmt(weekStart)} – ${fmt(weekEnd)}`, dateFormat: 'monthDay' }; + } + const monthLabel = d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + return { key: `month-${d.getFullYear()}-${d.getMonth()}`, label: monthLabel, dateFormat: 'monthDay' }; +} + +function formatDayLabel(d: Date): string { + return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); +} + +export function formatEntryDate(iso: string, fmt: TimeBucketFormat): string { + return fmt === 'time' ? formatTime(iso) : formatMonthDay(iso); } export function formatBytes(bytes: number): string { diff --git a/report/config-types.ts b/report/config-types.ts new file mode 100644 index 000000000..5fb529a57 --- /dev/null +++ b/report/config-types.ts @@ -0,0 +1,87 @@ +export type ConfigSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical'; +export type ConfigHealth = 'healthy' | 'warning' | 'unhealthy' | 'unknown'; +export type AnalysisType = + | 'security' | 'compliance' | 'cost' | 'performance' + | 'reliability' | 'recommendation' | 'integration' | 'availability'; + +export interface ConfigItem { + id: string; + name: string; + type?: string; + configClass?: string; + status?: string; + health?: ConfigHealth; + description?: string; + permalink?: string; + labels?: Record; + tags?: Record; + costTotal30d?: number; + createdAt?: string; + updatedAt?: string; +} + +export interface ConfigChangeArtifact { + id: string; + filename: string; + contentType: string; + size: number; + dataUri?: string; +} + +export interface ConfigTypedChange { + kind: string; + [key: string]: any; +} + +export interface ConfigChange { + id?: string; + configID?: string; + configName?: string; + configType?: string; + permalink?: string; + changeType: string; + category?: string; + severity?: ConfigSeverity; + source?: string; + summary?: string; + details?: Record; + typedChange?: ConfigTypedChange; + createdBy?: string; + externalCreatedBy?: string; + createdAt?: string; + firstObserved?: string; + count?: number; + artifacts?: ConfigChangeArtifact[]; +} + +export interface ConfigAnalysis { + id?: string; + configID?: string; + configName?: string; + configType?: string; + permalink?: string; + analyzer: string; + message?: string; + summary?: string; + status?: string; + severity?: ConfigSeverity; + analysisType?: AnalysisType; + source?: string; + firstObserved?: string; + lastObserved?: string; +} + +export interface ConfigRelationship { + configID: string; + relatedID: string; + relation: string; + direction?: 'incoming' | 'outgoing'; +} + +export interface ConfigReportData { + configItem: ConfigItem; + changes: ConfigChange[]; + analyses: ConfigAnalysis[]; + relationships: ConfigRelationship[]; + relatedConfigs: ConfigItem[]; +} diff --git a/report/embed.go b/report/embed.go index 7abc39d4f..1d10ae75f 100644 --- a/report/embed.go +++ b/report/embed.go @@ -1,7 +1,34 @@ // Package report exposes the embedded TSX source files for the facet renderer. package report -import "embed" +import ( + "embed" + "os" + "path/filepath" +) //go:embed *.tsx *.ts package.json tsconfig.json components var FS embed.FS + +// SourceDir overrides the embedded report files with a local directory or file. +// When set to a directory, facet renders use it directly instead of extracting +// embedded files. When set to a file, the file's directory is used and the +// filename overrides the entry file. +var SourceDir string + +// ResolveSource returns the source directory and entry file override. +// If SourceDir points to a file, returns (dir, basename). +// If SourceDir points to a directory or is empty, returns (SourceDir, ""). +func ResolveSource() (dir string, entryFile string) { + if SourceDir == "" { + return "", "" + } + info, err := os.Stat(SourceDir) + if err != nil { + return SourceDir, "" + } + if !info.IsDir() { + return filepath.Dir(SourceDir), filepath.Base(SourceDir) + } + return SourceDir, "" +} diff --git a/report/facet.go b/report/facet.go new file mode 100644 index 000000000..19193c082 --- /dev/null +++ b/report/facet.go @@ -0,0 +1,281 @@ +package report + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/fs" + "mime/multipart" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + commonshttp "github.com/flanksource/commons/http" + "github.com/flanksource/commons/logger" + "github.com/flanksource/duty/context" +) + +// RenderResult contains the rendered output and metadata about the render. +type RenderResult struct { + Data []byte + SrcDir string + Entry string + DataFile string +} + +// RenderCLI renders data to the given format using the local facet CLI binary. +// With -v (log level 1): prints the facet command and tees stdout/stderr. +// With -vv (log level 2): also keeps the data file and report dir for re-rendering. +func RenderCLI(data any, format, entryFile string) (*RenderResult, error) { + verbose := logger.IsLevelEnabled(1) + keepFiles := logger.IsLevelEnabled(2) + + facetBin, err := exec.LookPath("facet") + if err != nil { + return nil, fmt.Errorf("facet not found on PATH: install with 'npm install -g @flanksource/facet'") + } + + srcDir, err := SrcDir() + if err != nil { + return nil, fmt.Errorf("prepare facet src dir: %w", err) + } + if _, override := ResolveSource(); override != "" { + entryFile = override + } + + dataJSON, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal data: %w", err) + } + + dataFile, err := os.CreateTemp("", "facet-data-*.json") + if err != nil { + return nil, fmt.Errorf("create data temp file: %w", err) + } + if !keepFiles { + defer os.Remove(dataFile.Name()) + } + + if _, err := dataFile.Write(dataJSON); err != nil { + return nil, fmt.Errorf("write data file: %w", err) + } + dataFile.Close() + + outFile, err := os.CreateTemp("", "facet-output-*."+format) + if err != nil { + return nil, fmt.Errorf("create output temp file: %w", err) + } + outFile.Close() + defer os.Remove(outFile.Name()) + + var stderr bytes.Buffer + cmd := exec.Command(facetBin, format, entryFile, "-d", dataFile.Name(), "-o", outFile.Name()) + cmd.Dir = srcDir + + if verbose { + fmt.Fprintf(os.Stderr, "$ cd %s\n", srcDir) + fmt.Fprintf(os.Stderr, "$ %s\n", strings.Join(cmd.Args, " ")) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + } else { + cmd.Stderr = &stderr + } + + if err = cmd.Run(); err != nil { + if stderr.Len() > 0 { + return nil, fmt.Errorf("facet %s failed: %w\n%s", format, err, stderr.String()) + } + return nil, fmt.Errorf("facet %s failed: %w", format, err) + } + + result, err := os.ReadFile(outFile.Name()) + if err != nil { + return nil, err + } + + return &RenderResult{ + Data: result, + SrcDir: srcDir, + Entry: entryFile, + DataFile: dataFile.Name(), + }, nil +} + +type RenderHTTPOptions struct { + TimestampURL string +} + +// RenderHTTP renders data via a remote facet rendering service. +func RenderHTTP(ctx context.Context, baseURL, token string, data any, format, entryFile string, opts ...RenderHTTPOptions) ([]byte, error) { + archive, err := BuildArchive() + if err != nil { + return nil, fmt.Errorf("build report archive: %w", err) + } + + dataJSON, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal data: %w", err) + } + + renderOpts := map[string]any{ + "format": format, + "entryFile": entryFile, + } + if len(opts) > 0 && opts[0].TimestampURL != "" { + renderOpts["timestampUrl"] = opts[0].TimestampURL + } + optionsJSON, err := json.Marshal(renderOpts) + if err != nil { + return nil, fmt.Errorf("marshal options: %w", err) + } + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + + fw, err := mw.CreateFormFile("archive", "report.tar.gz") + if err != nil { + return nil, fmt.Errorf("create archive form field: %w", err) + } + if _, err := fw.Write(archive); err != nil { + return nil, fmt.Errorf("write archive field: %w", err) + } + + if err := mw.WriteField("data", string(dataJSON)); err != nil { + return nil, fmt.Errorf("write data field: %w", err) + } + + if err := mw.WriteField("options", string(optionsJSON)); err != nil { + return nil, fmt.Errorf("write options field: %w", err) + } + + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("close multipart writer: %w", err) + } + + client := commonshttp.NewClient().BaseURL(baseURL) + if token != "" { + client = client.Header("X-API-Key", token) + } + + response, err := client.R(ctx). + Header("Content-Type", mw.FormDataContentType()). + Post("/render", &body) + if err != nil { + return nil, fmt.Errorf("facet render request failed: %w", err) + } + if !response.IsOK() { + errBody, _ := response.AsString() + return nil, fmt.Errorf("facet render failed (status %d): %s", response.StatusCode, errBody) + } + + if format == "html" { + return io.ReadAll(response.Body) + } + + renderResult, err := response.AsJSON() + if err != nil { + return nil, fmt.Errorf("failed to parse render response: %w", err) + } + resultURL, _ := renderResult["url"].(string) + if resultURL == "" { + return nil, fmt.Errorf("render response missing 'url' field") + } + + pdfResponse, err := client.R(ctx).Get(resultURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch rendered result: %w", err) + } + if !pdfResponse.IsOK() { + errBody, _ := pdfResponse.AsString() + return nil, fmt.Errorf("result fetch failed (status %d): %s", pdfResponse.StatusCode, errBody) + } + + return io.ReadAll(pdfResponse.Body) +} + +// SrcDir returns a stable directory containing the embedded report TSX files. +// On first call it extracts the files; subsequent calls reuse the directory. +var SrcDir = sync.OnceValues(func() (string, error) { + if dir, _ := ResolveSource(); dir != "" { + return dir, nil + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + dir := filepath.Join(cacheDir, "incident-commander", "facet-report") + + if err := os.MkdirAll(dir, 0750); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + if err := ExtractFiles(dir); err != nil { + return "", err + } + + return dir, nil +}) + +// ExtractFiles writes all embedded report files to destDir. +func ExtractFiles(destDir string) error { + return fs.WalkDir(FS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == "." { + return nil + } + dest := filepath.Join(destDir, path) + if d.IsDir() { + return os.MkdirAll(dest, 0750) + } + data, err := FS.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dest, data, 0600) + }) +} + +// BuildArchive creates a tar.gz archive of all embedded report files. +func BuildArchive() ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + err := fs.WalkDir(FS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := FS.ReadFile(path) + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{ + Name: path, + Size: int64(len(data)), + Mode: 0600, + }); err != nil { + return err + } + _, err = tw.Write(data) + return err + }) + if err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + if err := gw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/report/finding-schema.json b/report/finding-schema.json new file mode 100644 index 000000000..416e3fe8e --- /dev/null +++ b/report/finding-schema.json @@ -0,0 +1,438 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "audit-finding.schema.json", + "title": "Audit Log Finding", + "type": "object", + "required": ["title", "severity", "platform", "category", "outcome", "detection", "evidence", "recommendation"], + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "description": "Human-readable finding title" + }, + "severity": { + "enum": ["critical", "high", "medium", "low", "info"], + "description": "Impact severity level" + }, + "platform": { + "enum": ["sql-server", "kubernetes", "aws", "azure", "mission-control"], + "description": "Source platform where the finding was detected" + }, + "outcome": { + "enum": ["safety-switch", "page-oncall", "high-ticket", "low-ticket", "informational"], + "description": "Recommended response action: safety-switch (breach confirmed, auto-contain), page-oncall (breach suspected, page team), high-ticket (open high-severity ticket), low-ticket (open low-severity ticket), informational (log for awareness)" + }, + "category": { + "enum": [ + "credential-attack", + "privilege-escalation", + "privilege-accumulation", + "data-exfiltration", + "lateral-movement", + "persistence", + "audit-tampering", + "after-hours", + "break-glass", + "shared-account", + "service-account-misuse", + "network-exposure", + "destructive-action", + "coverage-gap" + ], + "description": "Attack pattern or finding category from the kill chain" + }, + "detection": { + "type": "object", + "required": ["pattern"], + "additionalProperties": false, + "properties": { + "pattern": { + "type": "string", + "description": "Name of the detection pattern that triggered this finding, e.g. 'brute-force', 'credential-spraying'" + }, + "threshold": { + "type": "string", + "description": "Threshold that was exceeded, e.g. '>= 50 failures from single IP in < 1 hour'" + } + } + }, + "dataSource": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "description": "Source type: s3-parquet, cloudtrail-athena, k8s-audit-log, azure-log-analytics, mission-control, file, etc." + }, + "categories": { + "type": "array", + "items": { + "enum": ["ai", "users", "groups", "roles", "access-logs", "flow-logs", "audit-logs", "configuration"] + }, + "description": "Data categories this source provides (e.g. audit-logs, access-logs, roles)" + }, + "connection": { + "type": "string", + "description": "Connection name or identifier (e.g. connection://monitoring/sql-server, AWS account ID)" + }, + "path": { + "type": "string", + "description": "Data path: S3 URI, Athena table, log file path, API endpoint" + }, + "query": { + "type": "string", + "description": "Query or filter used to extract the data" + }, + "timeRange": { + "$ref": "#/$defs/timeRange", + "description": "Time range of the data queried" + }, + "git": { + "type": "object", + "additionalProperties": false, + "properties": { + "repo": { "type": "string", "description": "Git repository URL or name" }, + "file": { "type": "string", "description": "File path within the repository" }, + "lineNo": { "type": "integer", "description": "Line number in the file" }, + "sha": { "type": "string", "description": "Git commit SHA" }, + "branch": { "type": "string", "description": "Git branch name" }, + "tag": { "type": "string", "description": "Git tag (e.g. v1.2.0)" } + } + }, + "contentSha": { + "type": "string", + "description": "SHA-256 hash of the data content for integrity verification" + }, + "app": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string", "description": "Application or scraper name (e.g. config-db, mission-control-agent)" }, + "version": { "type": "string", "description": "Application version" }, + "icon": { "type": "string", "description": "Icon name for display" } + } + }, + "file": { + "type": "object", + "additionalProperties": false, + "description": "File metadata for local/remote file data sources", + "properties": { + "name": { "type": "string", "description": "File name" }, + "size": { "type": "string", "description": "File size (e.g. 2.4MB, 156KB)" }, + "created": { "type": "string", "format": "date-time", "description": "File creation timestamp" }, + "modified": { "type": "string", "format": "date-time", "description": "File last modified timestamp" }, + "location": { "type": "string", "description": "Storage location type: local, sharepoint, google-drive, onedrive, network-share" }, + "host": { "type": "string", "description": "Host machine, SharePoint site, or drive name" } + } + } + } + }, + "evidence": { + "type": "object", + "required": ["summary"], + "additionalProperties": false, + "properties": { + "summary": { + "type": "string", + "description": "Human-readable description of what was observed" + }, + "timeRange": { + "$ref": "#/$defs/timeRange", + "description": "Time window of the observed activity" + }, + "metrics": { + "$ref": "#/$defs/metrics", + "description": "Quantitative measurements supporting the finding" + }, + "samples": { + "type": "array", + "maxItems": 10, + "items": { "$ref": "#/$defs/eventSample" }, + "description": "Representative event samples (max 10)" + } + } + }, + "recommendation": { + "type": "object", + "required": ["action"], + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "description": "Primary remediation action" + }, + "mitigations": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional mitigating steps" + }, + "references": { + "type": "array", + "items": { "type": "string", "format": "uri" }, + "description": "Links to relevant documentation, CIS benchmarks, etc." + } + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "properties": { + "killChainPhase": { + "enum": [ + "reconnaissance", + "initial-access", + "persistence", + "privilege-escalation", + "lateral-movement", + "collection", + "exfiltration", + "impact" + ], + "description": "MITRE ATT&CK kill chain phase" + }, + "mitreTechnique": { + "type": "string", + "pattern": "^T[0-9]{4}(\\.[0-9]{3})?$", + "description": "MITRE ATT&CK technique ID, e.g. T1078 or T1078.004" + }, + "compliance": { + "type": "array", + "items": { + "type": "string", + "description": "Compliance framework reference, e.g. 'PCI-DSS 10.2.4', 'SOX 404', 'CIS AWS 3.1'" + } + }, + "relatedFindings": { + "type": "array", + "items": { "type": "string" }, + "description": "IDs of related findings that may form a larger attack chain" + }, + "baseline": { + "type": "object", + "additionalProperties": false, + "properties": { + "normalValue": { + "type": "number", + "description": "Expected baseline value (e.g. avg events/day)" + }, + "observedValue": { + "type": "number", + "description": "Observed value during this finding" + }, + "deviationFactor": { + "type": "number", + "description": "Ratio of observed/normal (e.g. 5.2 means 5.2x the baseline)" + }, + "baselinePeriod": { + "type": "string", + "description": "Period used for baseline calculation, e.g. 'previous 4 weeks'" + } + } + } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "When this finding was generated (ISO 8601 UTC)" + }, + "generatedBy": { + "type": "string", + "description": "Tool or agent that produced the finding (e.g. 'audit-log-analyzer', 'mission-control-scraper')" + }, + "version": { + "type": "string", + "description": "Version of the detection rules or tool" + }, + "runId": { + "type": "string", + "description": "Unique identifier for the analysis run that produced this finding" + }, + "model": { + "type": "string", + "description": "AI model used if finding was AI-generated (e.g. 'claude-opus-4-6')" + } + } + } + }, + "$defs": { + "identity": { + "type": "object", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Identity name (UPN, ARN, service account name, SQL principal)" + }, + "type": { + "enum": ["human", "service-account", "break-glass", "admin", "machine", "root", "unknown"], + "description": "Identity classification" + }, + "displayName": { + "type": "string", + "description": "Resolved human-readable display name" + }, + "id": { + "type": "string", + "description": "Unique identifier (e.g. SID, ARN, UUID)" + } + } + }, + "resource": { + "type": "object", + "required": ["name", "type"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Resource identifier (database name, bucket ARN, namespace, etc.)" + }, + "type": { + "type": "string", + "description": "Resource type (database, s3-bucket, namespace, role, secret, etc.)" + }, + "id": { + "type": "string", + "description": "Unique resource ID if available (ARN, UUID, etc.)" + }, + "scope": { + "type": "string", + "description": "Scope or container (server instance, subscription, cluster, etc.), multiple resources may share the same scope" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "timeRange": { + "type": "object", + "required": ["start", "end"], + "additionalProperties": false, + "properties": { + "start": { + "type": "string", + "format": "date-time", + "description": "Start of the observed activity (ISO 8601 UTC)" + }, + "end": { + "type": "string", + "format": "date-time", + "description": "End of the observed activity (ISO 8601 UTC)" + }, + "durationSeconds": { + "type": "integer", + "description": "Duration of the activity window in seconds" + } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "eventCount": { + "type": "integer", + "description": "Total number of events matching the detection" + }, + "failedCount": { + "type": "integer", + "description": "Number of failed attempts (logins, API calls)" + }, + "successCount": { + "type": "integer", + "description": "Number of successful events" + }, + "uniqueIdentities": { + "type": "integer", + "description": "Distinct identity count involved" + }, + "uniqueIPs": { + "type": "integer", + "description": "Distinct source IP count" + }, + "uniqueResources": { + "type": "integer", + "description": "Distinct resource count affected" + }, + "rowsAccessed": { + "type": "integer", + "description": "Total rows read or modified (SQL Server specific)" + }, + "permissionChanges": { + "type": "integer", + "description": "Number of grant/revoke/role-change operations" + } + } + }, + "actor": { + "type": "object", + "additionalProperties": false, + "properties": { + "identity": { "$ref": "#/$defs/identity" }, + "endpoint": { "$ref": "#/$defs/endpoint" }, + "app": { "$ref": "#/$defs/appRef" }, + "resource": { "$ref": "#/$defs/resource" } + } + }, + "endpoint": { + "type": "object", + "additionalProperties": false, + "properties": { + "ip": { "type": "string", "description": "IP address" }, + "hostname": { "type": "string", "description": "Hostname or FQDN" }, + "type": { "type": "string", "description": "Endpoint type (e.g. workstation, server, vpn)" }, + "network": { "type": "string", "description": "Network segment or VPC" }, + "tags": { "type": "array", "items": { "type": "string" } } + } + }, + "appRef": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "description": "Application name (e.g. SSMS, kubectl, aws-cli)" }, + "type": { "type": "string", "description": "Application type (e.g. database-client, cli, sdk)" }, + "tags": { "type": "array", "items": { "type": "string" } } + } + }, + "eventSample": { + "type": "object", + "required": ["timestamp", "action"], + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Event timestamp (ISO 8601 UTC)" + }, + "action": { + "type": "string", + "description": "Action name or API call" + }, + "detail": { + "type": "string", + "maxLength": 500, + "description": "Statement text, request parameters, or other detail (truncated)" + }, + "succeeded": { + "type": "boolean", + "description": "Whether the action succeeded" + }, + "src": { + "$ref": "#/$defs/actor", + "description": "Source actor (who/what initiated the action)" + }, + "dst": { + "$ref": "#/$defs/actor", + "description": "Destination actor (target of the action)" + } + } + } + } +} diff --git a/report/icons.ts b/report/icons.ts new file mode 100644 index 000000000..7bdd70100 --- /dev/null +++ b/report/icons.ts @@ -0,0 +1,219 @@ +// Audit Findings Icon Set — Selected from REQUIREMENTS-audit-icon-set.pdf +// Sources: Phosphor (ph/256), MDI (24), Tabler (24), Lucide (24), Icons8 (50) +// Render: + +export interface IconDef { body: string; viewBox: string } + +function ph(body: string): IconDef { return { body, viewBox: "0 0 256 256" }; } +function mdi(body: string): IconDef { return { body, viewBox: "0 0 24 24" }; } +function tabler(body: string): IconDef { return { body, viewBox: "0 0 24 24" }; } +function lucide(body: string): IconDef { return { body, viewBox: "0 0 24 24" }; } + +// ── Outcomes ──────────────────────────────────────────────────────── + +// ph:power +export const ICON_SAFETY_SWITCH = ph(``); + +// icons8:fire-alarm (kept as tabler:bell-ringing equivalent — stroke 24x24) +export const ICON_PAGE_ONCALL = tabler(``); + +// icons8:high-risk (kept as tabler:alert-triangle — stroke 24x24) +export const ICON_HIGH_TICKET = tabler(``); + +// icons8:ticket (kept as tabler:ticket — stroke 24x24) +export const ICON_LOW_TICKET = tabler(``); + +// ph:scroll +export const ICON_INFORMATIONAL = ph(``); + +// ── Attack Categories ─────────────────────────────────────────────── + +// mdi:key-alert +export const ICON_CREDENTIAL_ATTACK = mdi(``); + +// mdi:shield-account +export const ICON_PRIVILEGE_ESCALATION = mdi(``); + +// tabler:stack-push +export const ICON_PRIVILEGE_ACCUMULATION = tabler(``); + +// mdi:database-export +export const ICON_DATA_EXFILTRATION = mdi(``); + +// (no selection — kept original) +export const ICON_LATERAL_MOVEMENT = tabler(``); + +// (no selection — kept original) +export const ICON_PERSISTENCE = tabler(``); + +// lucide:shredder +export const ICON_AUDIT_TAMPERING = lucide(``); + +// ph:moon-stars +export const ICON_AFTER_HOURS = ph(``); + +// icons8:hammer (kept as tabler:hammer — stroke 24x24) +export const ICON_BREAK_GLASS = tabler(``); + +// mdi:account-switch +export const ICON_SHARED_ACCOUNT = mdi(``); + +// (no selection — kept original) +export const ICON_SERVICE_ACCOUNT_MISUSE = tabler(``); + +// mdi:firewall (alias: wall-fire) +export const ICON_NETWORK_EXPOSURE = mdi(``); + +// ph:bomb +export const ICON_DESTRUCTIVE_ACTION = ph(``); + +// ph:eye-slash +export const ICON_COVERAGE_GAP = ph(``); + +// ── Kill Chain Phases (unique, others reuse above) ────────────────── + +// mdi:radar +export const ICON_RECONNAISSANCE = mdi(``); + +// icons8:door-opened (kept as tabler equivalent — stroke 24x24) +export const ICON_INITIAL_ACCESS = tabler(``); + +// mdi:database-search +export const ICON_COLLECTION = mdi(``); + +// mdi:database-export +export const ICON_EXFILTRATION = mdi(``); + +// mdi:flash-alert +export const ICON_IMPACT = mdi(``); + +// ── Actor Type Icons ───────────────────────────────────────────────�� + +// tabler:user (human identity) +export const ICON_IDENTITY_HUMAN = tabler(``); + +// tabler:robot (machine identity) +export const ICON_IDENTITY_MACHINE = tabler(``); + +// tabler:crown (root identity) +export const ICON_IDENTITY_ROOT = tabler(``); + +// tabler:question-mark (unknown identity) +export const ICON_IDENTITY_UNKNOWN = tabler(``); + +// tabler:world (IP / endpoint) +export const ICON_ENDPOINT_IP = tabler(``); + +// tabler:device-desktop (workstation endpoint) +export const ICON_ENDPOINT_WORKSTATION = tabler(``); + +// tabler:server (server endpoint) +export const ICON_ENDPOINT_SERVER = tabler(``); + +// tabler:app-window (app reference) +export const ICON_APP = tabler(``); + +// mdi:database +export const ICON_DATABASE = mdi(``); + +// mdi:lock +export const ICON_SECRET = mdi(``); + +// ── Provenance & Data Source Icons ───────────────────────────────── + +// tabler:brain (AI model) +export const ICON_AI_MODEL = tabler(``); + +// tabler:clock (timestamp) +export const ICON_CLOCK = tabler(``); + +// tabler:hash (run ID) +export const ICON_HASH = tabler(``); + +// tabler:tool (analyzer tool) +export const ICON_TOOL = tabler(``); + +// tabler:tag (version) +export const ICON_VERSION = tabler(``); + +// tabler:bucket (S3/storage) +export const ICON_BUCKET = tabler(``); + +// tabler:file-analytics (audit log) +export const ICON_AUDIT_LOG = tabler(``); + +// tabler:cloud (cloud trail / cloud source) +export const ICON_CLOUD = tabler(``); + +// ── Lookup Maps ───────────────────────────────────────────────────── + +export const OUTCOME_ICONS: Record = { + "safety-switch": ICON_SAFETY_SWITCH, + "page-oncall": ICON_PAGE_ONCALL, + "high-ticket": ICON_HIGH_TICKET, + "low-ticket": ICON_LOW_TICKET, + "informational": ICON_INFORMATIONAL, +}; + +export const CATEGORY_ICONS: Record = { + "credential-attack": ICON_CREDENTIAL_ATTACK, + "privilege-escalation": ICON_PRIVILEGE_ESCALATION, + "privilege-accumulation": ICON_PRIVILEGE_ACCUMULATION, + "data-exfiltration": ICON_DATA_EXFILTRATION, + "lateral-movement": ICON_LATERAL_MOVEMENT, + "persistence": ICON_PERSISTENCE, + "audit-tampering": ICON_AUDIT_TAMPERING, + "after-hours": ICON_AFTER_HOURS, + "break-glass": ICON_BREAK_GLASS, + "shared-account": ICON_SHARED_ACCOUNT, + "service-account-misuse": ICON_SERVICE_ACCOUNT_MISUSE, + "network-exposure": ICON_NETWORK_EXPOSURE, + "destructive-action": ICON_DESTRUCTIVE_ACTION, + "coverage-gap": ICON_COVERAGE_GAP, +}; + +export const KILL_CHAIN_ICONS: Record = { + "reconnaissance": ICON_RECONNAISSANCE, + "initial-access": ICON_INITIAL_ACCESS, + "persistence": ICON_PERSISTENCE, + "privilege-escalation": ICON_PRIVILEGE_ESCALATION, + "lateral-movement": ICON_LATERAL_MOVEMENT, + "collection": ICON_COLLECTION, + "exfiltration": ICON_EXFILTRATION, + "impact": ICON_IMPACT, +}; + +export const IDENTITY_ICONS: Record = { + "human": ICON_IDENTITY_HUMAN, + "service-account": ICON_SERVICE_ACCOUNT_MISUSE, + "break-glass": ICON_BREAK_GLASS, + "admin": ICON_PRIVILEGE_ESCALATION, + "machine": ICON_IDENTITY_MACHINE, + "root": ICON_IDENTITY_ROOT, + "unknown": ICON_IDENTITY_UNKNOWN, +}; + +export const ENDPOINT_ICONS: Record = { + "ip": ICON_ENDPOINT_IP, + "workstation": ICON_ENDPOINT_WORKSTATION, + "server": ICON_ENDPOINT_SERVER, + "vpn": ICON_ENDPOINT_IP, +}; + +export const RESOURCE_ICONS: Record = { + "database": ICON_DATABASE, + "role": ICON_PRIVILEGE_ESCALATION, + "clusterrolebinding": ICON_PRIVILEGE_ESCALATION, + "clusterrole": ICON_PRIVILEGE_ESCALATION, + "security-group": ICON_NETWORK_EXPOSURE, + "rds-instance": ICON_DATABASE, + "audit-type": ICON_AUDIT_TAMPERING, + "namespace": ICON_LATERAL_MOVEMENT, + "secret": ICON_SECRET, + "s3-bucket": ICON_DATA_EXFILTRATION, +}; + +export const APP_ICONS: Record = { + "default": ICON_APP, +}; + diff --git a/report/kitchen-sink-data.ts b/report/kitchen-sink-data.ts new file mode 100644 index 000000000..fb979e157 --- /dev/null +++ b/report/kitchen-sink-data.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import type { KitchenSinkData } from './kitchen-sink/KitchenSinkTypes.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const raw = readFileSync(resolve(__dirname, 'kitchen-sink.json'), 'utf-8'); +export const data = JSON.parse(raw) as KitchenSinkData; + +export default data; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(data)); +} diff --git a/report/kitchen-sink.json b/report/kitchen-sink.json new file mode 100644 index 000000000..5baf9728b --- /dev/null +++ b/report/kitchen-sink.json @@ -0,0 +1,3806 @@ +{ + "configItem": { + "id": "cfg-eks-001", + "name": "prod-eks-cluster", + "type": "AWS::EKS::Cluster", + "configClass": "Cluster", + "status": "Active", + "health": "healthy", + "description": "Production EKS cluster running Mission Control workloads in us-east-1", + "labels": { + "env": "production", + "team": "platform", + "region": "us-east-1" + }, + "costTotal30d": 4280.5, + "createdAt": "2025-03-15T09:00:00Z", + "updatedAt": "2026-03-28T12:00:00Z" + }, + "categoryMappings": [ + { + "category": "rbac.granted", + "filter": "changeType == \"PermissionGranted\" || changeType == \"PermissionAdded\" || changeType == \"IAMRoleAdded\"" + }, + { + "category": "rbac.revoked", + "filter": "changeType == \"PermissionRevoked\" || changeType == \"PermissionRemoved\" || changeType == \"IAMRoleRemoved\"" + }, + { + "category": "backup.success", + "filter": "changeType == \"BackupCompleted\" || changeType == \"BackupSuccessful\"" + }, + { + "category": "backup.failed", + "filter": "changeType == \"BackupFailed\"" + }, + { + "category": "backup.progress", + "filter": "changeType == \"BackupStarted\" || changeType == \"BackupRunning\" || changeType == \"BackupEnqueued\"" + }, + { + "category": "backup.restore", + "filter": "changeType == \"BackupRestored\" || changeType == \"RestoreCompleted\"" + }, + { + "category": "deployment.spec", + "filter": "changeType == \"diff\"" + }, + { + "category": "deployment.scale", + "filter": "changeType == \"ScalingReplicaSet\"" + }, + { + "category": "deployment.policy", + "filter": "changeType == \"PolicyUpdate\"" + } + ], + "changes": [ + { + "id": "chg-001", + "configID": "cfg-eks-001", + "changeType": "diff", + "category": "deployment.spec", + "severity": "info", + "source": "kubernetes", + "summary": "Node pool autoscaler adjusted desired count from 3 to 5", + "createdBy": "cluster-autoscaler", + "createdAt": "2026-03-30T08:15:00Z", + "count": 1 + }, + { + "id": "chg-002", + "configID": "cfg-eks-001", + "changeType": "Pulled", + "severity": "info", + "source": "kubernetes", + "summary": "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42", + "createdAt": "2026-03-30T07:30:00Z", + "count": 3 + }, + { + "id": "chg-003", + "configID": "cfg-eks-001", + "changeType": "ScalingReplicaSet", + "category": "deployment.scale", + "severity": "low", + "source": "kubernetes", + "summary": "Deployment incident-commander scaled from 2 to 3 replicas", + "externalCreatedBy": "hpa-controller", + "createdAt": "2026-03-29T22:00:00Z" + }, + { + "id": "chg-004", + "configID": "cfg-eks-001", + "changeType": "diff", + "category": "deployment.spec", + "severity": "medium", + "source": "terraform", + "summary": "EKS cluster version upgraded from 1.28 to 1.29", + "createdBy": "alice@flanksource.com", + "createdAt": "2026-03-29T14:00:00Z" + }, + { + "id": "chg-005", + "configID": "cfg-eks-001", + "changeType": "PolicyUpdate", + "category": "deployment.policy", + "severity": "high", + "source": "argocd", + "summary": "Network policy updated: restricted egress to 10.0.0.0/8 for namespace mc", + "createdBy": "bob@flanksource.com", + "createdAt": "2026-03-28T16:00:00Z" + }, + { + "id": "chg-006", + "configID": "cfg-eks-001", + "changeType": "diff", + "category": "deployment.spec", + "severity": "critical", + "source": "aws-config", + "summary": "IAM role policy detached: eks-admin-access removed from cluster role", + "createdBy": "security-automation", + "createdAt": "2026-03-28T10:00:00Z" + }, + { + "id": "chg-007", + "configID": "cfg-eks-001", + "changeType": "FieldsV1", + "severity": "info", + "source": "kubernetes", + "summary": "ConfigMap kube-proxy updated with new CIDR ranges", + "createdAt": "2026-03-27T18:00:00Z", + "count": 2 + }, + { + "id": "chg-008", + "configID": "cfg-eks-001", + "changeType": "diff", + "severity": "low", + "source": "terraform", + "summary": "Added tag cost-center=platform-engineering to cluster", + "createdBy": "carol@flanksource.com", + "createdAt": "2026-03-27T09:00:00Z" + }, + { + "id": "chg-009", + "configID": "cfg-eks-001", + "changeType": "ScalingReplicaSet", + "category": "deployment.scale", + "severity": "info", + "source": "kubernetes", + "summary": "Deployment canary-checker scaled from 1 to 2 replicas", + "externalCreatedBy": "hpa-controller", + "createdAt": "2026-03-26T20:00:00Z" + }, + { + "id": "chg-010", + "configID": "cfg-eks-001", + "changeType": "diff", + "category": "deployment.spec", + "severity": "medium", + "source": "argocd", + "summary": "Helm release cert-manager upgraded from v1.13.3 to v1.14.1", + "createdBy": "alice@flanksource.com", + "createdAt": "2026-03-26T11:00:00Z" + }, + { + "id": "chg-011", + "configID": "cfg-eks-001", + "changeType": "Pulled", + "severity": "info", + "source": "kubernetes", + "summary": "Image flanksource/canary-checker:v1.0.350 pulled", + "createdAt": "2026-03-25T15:00:00Z", + "count": 5 + }, + { + "id": "chg-012", + "configID": "cfg-eks-001", + "changeType": "PolicyUpdate", + "category": "deployment.policy", + "severity": "high", + "source": "aws-config", + "summary": "Security group sg-0abc123 ingress rule added: allow 443 from 0.0.0.0/0", + "createdBy": "terraform", + "createdAt": "2026-03-25T10:00:00Z" + }, + { + "id": "chg-013", + "configID": "cfg-eks-001", + "changeType": "diff", + "category": "deployment.spec", + "severity": "low", + "source": "kubernetes", + "summary": "PodDisruptionBudget added for incident-commander (minAvailable: 2)", + "createdBy": "bob@flanksource.com", + "createdAt": "2026-03-24T14:00:00Z" + }, + { + "id": "chg-014", + "configID": "cfg-eks-001", + "changeType": "PermissionGranted", + "category": "rbac.granted", + "severity": "info", + "source": "okta", + "summary": "Granted db_owner to alice@flanksource.com on prod-rds-01", + "createdBy": "admin@flanksource.com", + "createdAt": "2026-03-28T09:00:00Z", + "details": { + "permission": { + "user": "alice@flanksource.com", + "role": "db_owner" + } + } + }, + { + "id": "chg-015", + "configID": "cfg-eks-001", + "changeType": "PermissionRevoked", + "category": "rbac.revoked", + "severity": "info", + "source": "okta", + "summary": "Revoked Secrets Reader access for bob@flanksource.com on prod-eks-cluster", + "createdBy": "admin@flanksource.com", + "createdAt": "2026-03-27T15:00:00Z", + "details": { + "permission": { + "user": "bob@flanksource.com", + "role": "Secrets Reader" + } + } + }, + { + "id": "chg-016", + "configID": "cfg-eks-001", + "changeType": "BackupCompleted", + "category": "backup.success", + "severity": "info", + "source": "velero", + "summary": "Full cluster backup completed successfully (2.4 GiB)", + "createdAt": "2026-03-29T03:00:00Z" + }, + { + "id": "chg-017", + "configID": "cfg-eks-001", + "changeType": "BackupFailed", + "category": "backup.failed", + "severity": "high", + "source": "velero", + "summary": "Incremental backup failed: PVC snapshot timeout after 300s", + "createdAt": "2026-03-28T03:00:00Z" + }, + { + "id": "chg-018", + "configID": "cfg-eks-001", + "changeType": "BackupStarted", + "category": "backup.progress", + "severity": "info", + "source": "velero", + "summary": "Scheduled backup initiated for prod-eks-cluster", + "createdAt": "2026-03-30T03:00:00Z" + }, + { + "id": "chg-019", + "configID": "cfg-eks-001", + "changeType": "UserCreated", + "severity": "info", + "source": "okta", + "createdAt": "2026-03-24T11:30:00Z", + "typedChange": { + "kind": "UserChange/v1", + "user_name": "alice", + "user_email": "alice@flanksource.com", + "user_type": "human", + "group_name": "platform-admins", + "tenant": "production" + } + }, + { + "id": "chg-020", + "configID": "cfg-eks-001", + "changeType": "Screenshot", + "severity": "info", + "source": "synthetics", + "createdAt": "2026-03-24T10:15:00Z", + "typedChange": { + "kind": "Screenshot/v1", + "artifact_id": "art-001", + "content_type": "image/png", + "width": 1440, + "height": 900, + "url": "https://prod-eks-cluster.example.com/login" + } + }, + { + "id": "chg-021", + "configID": "cfg-eks-001", + "changeType": "PermissionSync", + "severity": "low", + "source": "iam-reconciler", + "createdAt": "2026-03-24T09:45:00Z", + "typedChange": { + "kind": "PermissionChange/v1", + "user_name": "jane@flanksource.com", + "role_name": "cluster-admin", + "scope": "namespace/mc" + } + }, + { + "id": "chg-022", + "configID": "cfg-eks-001", + "changeType": "Deployment", + "severity": "info", + "source": "argocd", + "createdAt": "2026-03-24T09:00:00Z", + "typedChange": { + "kind": "Deployment/v1", + "previous_image": "flanksource/incident-commander:v1.4.190", + "new_image": "flanksource/incident-commander:v1.4.200", + "container": "incident-commander", + "namespace": "mc", + "strategy": "rolling" + } + }, + { + "id": "chg-023", + "configID": "cfg-eks-001", + "changeType": "Promotion", + "severity": "info", + "source": "release-bot", + "createdAt": "2026-03-24T08:30:00Z", + "typedChange": { + "kind": "Promotion/v1", + "from_environment": "staging", + "to_environment": "production", + "version": "v1.4.200", + "artifact": "incident-commander" + } + }, + { + "id": "chg-024", + "configID": "cfg-eks-001", + "changeType": "Approved", + "severity": "info", + "source": "playbooks", + "createdAt": "2026-03-24T08:00:00Z", + "typedChange": { + "kind": "Approval/v1", + "playbook_id": "pb-001", + "run_id": "run-approve-001", + "approved_by": "ops-lead@flanksource.com", + "reason": "Change window approved" + } + }, + { + "id": "chg-025", + "configID": "cfg-eks-001", + "changeType": "Rollback", + "severity": "high", + "source": "argocd", + "createdAt": "2026-03-24T07:30:00Z", + "typedChange": { + "kind": "Rollback/v1", + "from_version": "v1.4.200", + "to_version": "v1.4.190", + "trigger": "health-check", + "reason": "Elevated error rate" + } + }, + { + "id": "chg-026", + "configID": "cfg-eks-001", + "changeType": "BackupArchived", + "severity": "info", + "source": "velero", + "createdAt": "2026-03-24T07:00:00Z", + "typedChange": { + "kind": "Backup/v1", + "status": "completed", + "backup_type": "full", + "size": "2.4 GiB", + "duration": "4m12s", + "target": "s3://velero-prod", + "snapshot_id": "snap-019" + } + }, + { + "id": "chg-027", + "configID": "cfg-eks-001", + "changeType": "PlaybookCompleted", + "severity": "info", + "source": "playbooks", + "createdAt": "2026-03-24T06:30:00Z", + "typedChange": { + "kind": "PlaybookExecution/v1", + "playbook_name": "Restart Incident Commander", + "run_id": "pb-run-019", + "status": "completed", + "duration": "2m11s" + } + }, + { + "id": "chg-028", + "configID": "cfg-eks-001", + "changeType": "Scaling", + "severity": "low", + "source": "keda", + "createdAt": "2026-03-24T06:00:00Z", + "typedChange": { + "kind": "Scaling/v1", + "from_replicas": 2, + "to_replicas": 4, + "resource_type": "Deployment", + "trigger": "queue-depth" + } + }, + { + "id": "chg-029", + "configID": "cfg-eks-001", + "changeType": "CertificateRenewed", + "severity": "info", + "source": "cert-manager", + "createdAt": "2026-03-24T05:30:00Z", + "typedChange": { + "kind": "Certificate/v1", + "subject": "prod-eks-cluster.internal", + "issuer": "letsencrypt-prod", + "not_after": "2026-06-22T00:00:00Z", + "serial": "09AF23", + "dns_names": "prod-eks-cluster.internal,api.prod.example.com" + } + }, + { + "id": "chg-030", + "configID": "cfg-eks-001", + "changeType": "CostChange", + "severity": "medium", + "source": "cost-analyzer", + "createdAt": "2026-03-24T05:00:00Z", + "typedChange": { + "kind": "CostChange/v1", + "previous_cost": 4280.5, + "new_cost": 4631.25, + "currency": "USD", + "period": "30d", + "reason": "Node group scale-out" + } + }, + { + "id": "chg-031", + "configID": "cfg-eks-001", + "changeType": "PipelineRunCompleted", + "severity": "info", + "source": "github-actions", + "createdAt": "2026-03-24T04:30:00Z", + "typedChange": { + "kind": "PipelineRun/v1", + "pipeline_name": "deploy-incident-commander", + "run_number": 841, + "branch": "main", + "status": "completed", + "duration": "9m31s" + } + }, + { + "id": "schema-example-001", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "UserChange", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:59:00.000Z", + "count": 1, + "typedChange": { + "kind": "UserChange/v1", + "user_id": "user-123", + "user_name": "alice@example.com", + "user_email": "alice@example.com", + "user_type": "human", + "group_id": "group-platform", + "group_name": "platform-admins", + "tenant": "acme" + } + }, + { + "id": "schema-example-002", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "PermissionChange", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:58:00.000Z", + "count": 1, + "typedChange": { + "kind": "PermissionChange/v1", + "user_id": "user-123", + "user_name": "alice@example.com", + "role_id": "role-admin", + "role_name": "cluster-admin", + "role_type": "kubernetes", + "scope": "prod-cluster" + } + }, + { + "id": "schema-example-003", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Promotion", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:57:00.000Z", + "count": 1, + "typedChange": { + "kind": "Promotion/v1", + "id": "evt-promo-1", + "timestamp": "2026-04-10T12:00:00Z", + "from": { + "kind": "Environment/v1", + "name": "staging", + "stage": "Staging" + }, + "to": { + "kind": "Environment/v1", + "name": "production", + "stage": "Production" + }, + "source": { + "kind": "Source/v1", + "git": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456" + }, + "path": "deploy/production" + }, + "version": "v1.2.3", + "approvals": [ + { + "kind": "Approval/v1", + "id": "approval-1", + "submitted_by": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "approver": { + "kind": "Identity/v1", + "id": "user-456", + "type": "User", + "name": "bob@example.com" + }, + "stage": "Manual", + "status": "Approved" + } + ], + "artifact": "api" + } + }, + { + "id": "schema-example-004", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "BackupCompleted", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:56:00.000Z", + "count": 1, + "typedChange": { + "kind": "Backup/v1", + "id": "backup-42", + "timestamp": "2026-04-10T11:30:00Z", + "backup_type": "Snapshot", + "created_by": { + "kind": "Identity/v1", + "type": "System:Auto", + "name": "nightly-backup" + }, + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production" + }, + "end": "2026-04-10T11:36:12Z", + "status": "Completed", + "size": "4.2GB", + "delta": "275MB" + } + }, + { + "id": "schema-example-005", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "diff", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:55:00.000Z", + "count": 1, + "typedChange": { + "kind": "ConfigChange/v1", + "id": "evt-config-1", + "timestamp": "2026-04-10T12:05:00Z", + "author": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "changes": [ + { + "kind": "Change/v1", + "path": ".spec.template.spec.containers[0].image", + "from": { + "image": "ghcr.io/flanksource/duty:v1.2.2" + }, + "to": { + "image": "ghcr.io/flanksource/duty:v1.2.3" + }, + "type": "update" + }, + { + "kind": "Change/v1", + "path": ".spec.replicas", + "from": { + "desired": "2" + }, + "to": { + "desired": "3" + }, + "type": "update" + } + ], + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production", + "identifier": "prod-cluster-1" + }, + "source": { + "kind": "Source/v1", + "image": { + "kind": "ImageSource/v1", + "registry": "ghcr.io", + "image": "flanksource/duty", + "version": "v1.2.3", + "sha": "sha256:1234abcd" + } + } + } + }, + { + "id": "schema-example-006", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Screenshot", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:54:00.000Z", + "count": 1, + "typedChange": { + "kind": "Screenshot/v1", + "artifact_id": "artifact-789", + "url": "https://artifacts.example.com/screenshots/login-flow.png", + "content_type": "image/png", + "width": 1920, + "height": 1080 + } + }, + { + "id": "schema-example-007", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "GroupMembership", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:53:00.000Z", + "count": 1, + "typedChange": { + "kind": "GroupMembership/v1", + "group": { + "kind": "Identity/v1", + "id": "group-platform", + "type": "Group", + "name": "platform-admins" + }, + "member": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "action": "Added", + "tenant": "acme" + } + }, + { + "id": "schema-example-008", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Identity", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:52:00.000Z", + "count": 1, + "typedChange": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + } + }, + { + "id": "schema-example-009", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Approved", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:51:00.000Z", + "count": 1, + "typedChange": { + "kind": "Approval/v1", + "id": "approval-42", + "timestamp": "2026-04-10T12:10:00Z", + "submitted_by": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "approver": { + "kind": "Identity/v1", + "id": "user-456", + "type": "User", + "name": "bob@example.com" + }, + "stage": "Manual", + "status": "Approved" + } + }, + { + "id": "schema-example-010", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "GitSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:50:00.000Z", + "count": 1, + "typedChange": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456", + "version": "v1.2.3", + "tags": "release,v1.2.3" + } + }, + { + "id": "schema-example-011", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "HelmSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:49:00.000Z", + "count": 1, + "typedChange": { + "kind": "HelmSource/v1", + "chart_name": "mission-control", + "chart_version": "0.42.0", + "repo_url": "https://flanksource.github.io/charts" + } + }, + { + "id": "schema-example-012", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "ImageSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:48:00.000Z", + "count": 1, + "typedChange": { + "kind": "ImageSource/v1", + "registry": "ghcr.io", + "image": "flanksource/duty", + "version": "v1.2.3", + "sha": "sha256:1234abcd" + } + }, + { + "id": "schema-example-013", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "DatabaseSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:47:00.000Z", + "count": 1, + "typedChange": { + "kind": "DatabaseSource/v1", + "type": "PostgreSQL", + "name": "incidents", + "schema": "public", + "version": "15.4", + "endpoint": "incidents.cluster-abc.us-east-1.rds.amazonaws.com:5432" + } + }, + { + "id": "schema-example-014", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Source", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:46:00.000Z", + "count": 1, + "typedChange": { + "kind": "Source/v1", + "git": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456" + }, + "path": "deploy/production" + } + }, + { + "id": "schema-example-015", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Environment", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:45:00.000Z", + "count": 1, + "typedChange": { + "kind": "Environment/v1", + "name": "production", + "description": "Primary production cluster", + "type": "Kubernetes", + "stage": "Production", + "identifier": "prod-cluster-1", + "tags": { + "cost_center": "eng", + "team": "platform" + } + } + }, + { + "id": "schema-example-016", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Event", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:44:00.000Z", + "count": 1, + "typedChange": { + "kind": "Event/v1", + "id": "evt-9", + "url": "https://events.example.com/evt-9", + "tags": { + "source": "ci" + }, + "timestamp": "2026-04-10T12:15:00Z" + } + }, + { + "id": "schema-example-017", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Test", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:43:00.000Z", + "count": 1, + "typedChange": { + "kind": "Test/v1", + "id": "test-101", + "timestamp": "2026-04-10T12:20:00Z", + "name": "api-smoke", + "description": "Smoke tests for the public API", + "type": "Integration", + "status": "Passed", + "result": "Passed" + } + }, + { + "id": "schema-example-018", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "PipelineRun", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:42:00.000Z", + "count": 1, + "typedChange": { + "kind": "PipelineRun/v1", + "id": "pipeline-55", + "timestamp": "2026-04-10T12:25:00Z", + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production" + }, + "status": "Completed" + } + }, + { + "id": "schema-example-019", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Change", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:41:00.000Z", + "count": 1, + "typedChange": { + "kind": "Change/v1", + "path": ".spec.replicas", + "from": { + "desired": "2" + }, + "to": { + "desired": "3" + }, + "type": "update" + } + }, + { + "id": "schema-example-020", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "RestoreCompleted", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:40:00.000Z", + "count": 1, + "typedChange": { + "kind": "Restore/v1", + "id": "restore-7", + "timestamp": "2026-04-10T12:30:00Z", + "from": { + "kind": "Environment/v1", + "name": "backup-store", + "type": "Cloud" + }, + "to": { + "kind": "Environment/v1", + "name": "staging", + "type": "Kubernetes", + "stage": "Staging" + }, + "source": { + "kind": "Source/v1", + "database": { + "kind": "DatabaseSource/v1", + "type": "PostgreSQL", + "name": "incidents", + "version": "15.4" + } + }, + "status": "Completed" + } + }, + { + "id": "schema-example-021", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Dimension", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:39:00.000Z", + "count": 1, + "typedChange": { + "kind": "Dimension/v1", + "min": "2", + "max": "10", + "desired": "5" + } + }, + { + "id": "schema-example-022", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Scaling", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:38:00.000Z", + "count": 1, + "typedChange": { + "kind": "Scale/v1", + "dimension": "Replicas", + "previous_value": { + "kind": "Dimension/v1", + "desired": "2" + }, + "value": { + "kind": "Dimension/v1", + "desired": "3" + } + } + }, + { + "id": "schema-example-023", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Approved", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:37:00.000Z", + "count": 1, + "typedChange": { + "kind": "Approval/v1", + "id": "approval-42", + "timestamp": "2026-04-10T12:10:00Z", + "submitted_by": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "approver": { + "kind": "Identity/v1", + "id": "user-456", + "type": "User", + "name": "bob@example.com" + }, + "stage": "Manual", + "status": "Approved" + } + }, + { + "id": "schema-example-024", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "BackupCompleted", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:36:00.000Z", + "count": 1, + "typedChange": { + "kind": "Backup/v1", + "id": "backup-42", + "timestamp": "2026-04-10T11:30:00Z", + "backup_type": "Snapshot", + "created_by": { + "kind": "Identity/v1", + "type": "System:Auto", + "name": "nightly-backup" + }, + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production" + }, + "end": "2026-04-10T11:36:12Z", + "status": "Completed", + "size": "4.2GB", + "delta": "275MB" + } + }, + { + "id": "schema-example-025", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Change", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:35:00.000Z", + "count": 1, + "typedChange": { + "kind": "Change/v1", + "path": ".spec.replicas", + "from": { + "desired": "2" + }, + "to": { + "desired": "3" + }, + "type": "update" + } + }, + { + "id": "schema-example-026", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "diff", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:34:00.000Z", + "count": 1, + "typedChange": { + "kind": "ConfigChange/v1", + "id": "evt-config-1", + "timestamp": "2026-04-10T12:05:00Z", + "author": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "changes": [ + { + "kind": "Change/v1", + "path": ".spec.template.spec.containers[0].image", + "from": { + "image": "ghcr.io/flanksource/duty:v1.2.2" + }, + "to": { + "image": "ghcr.io/flanksource/duty:v1.2.3" + }, + "type": "update" + }, + { + "kind": "Change/v1", + "path": ".spec.replicas", + "from": { + "desired": "2" + }, + "to": { + "desired": "3" + }, + "type": "update" + } + ], + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production", + "identifier": "prod-cluster-1" + }, + "source": { + "kind": "Source/v1", + "image": { + "kind": "ImageSource/v1", + "registry": "ghcr.io", + "image": "flanksource/duty", + "version": "v1.2.3", + "sha": "sha256:1234abcd" + } + } + } + }, + { + "id": "schema-example-027", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "DatabaseSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:33:00.000Z", + "count": 1, + "typedChange": { + "kind": "DatabaseSource/v1", + "type": "PostgreSQL", + "name": "incidents", + "schema": "public", + "version": "15.4", + "endpoint": "incidents.cluster-abc.us-east-1.rds.amazonaws.com:5432" + } + }, + { + "id": "schema-example-028", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Dimension", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:32:00.000Z", + "count": 1, + "typedChange": { + "kind": "Dimension/v1", + "min": "2", + "max": "10", + "desired": "5" + } + }, + { + "id": "schema-example-029", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Environment", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:31:00.000Z", + "count": 1, + "typedChange": { + "kind": "Environment/v1", + "name": "production", + "description": "Primary production cluster", + "type": "Kubernetes", + "stage": "Production", + "identifier": "prod-cluster-1", + "tags": { + "cost_center": "eng", + "team": "platform" + } + } + }, + { + "id": "schema-example-030", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Event", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:30:00.000Z", + "count": 1, + "typedChange": { + "kind": "Event/v1", + "id": "evt-9", + "url": "https://events.example.com/evt-9", + "tags": { + "source": "ci" + }, + "timestamp": "2026-04-10T12:15:00Z" + } + }, + { + "id": "schema-example-031", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "GitSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:29:00.000Z", + "count": 1, + "typedChange": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456", + "version": "v1.2.3", + "tags": "release,v1.2.3" + } + }, + { + "id": "schema-example-032", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "GroupMembership", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:28:00.000Z", + "count": 1, + "typedChange": { + "kind": "GroupMembership/v1", + "group": { + "kind": "Identity/v1", + "id": "group-platform", + "type": "Group", + "name": "platform-admins" + }, + "member": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "action": "Added", + "tenant": "acme" + } + }, + { + "id": "schema-example-033", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "HelmSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:27:00.000Z", + "count": 1, + "typedChange": { + "kind": "HelmSource/v1", + "chart_name": "mission-control", + "chart_version": "0.42.0", + "repo_url": "https://flanksource.github.io/charts" + } + }, + { + "id": "schema-example-034", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Identity", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:26:00.000Z", + "count": 1, + "typedChange": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + } + }, + { + "id": "schema-example-035", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "ImageSource", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:25:00.000Z", + "count": 1, + "typedChange": { + "kind": "ImageSource/v1", + "registry": "ghcr.io", + "image": "flanksource/duty", + "version": "v1.2.3", + "sha": "sha256:1234abcd" + } + }, + { + "id": "schema-example-036", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "PermissionChange", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:24:00.000Z", + "count": 1, + "typedChange": { + "kind": "PermissionChange/v1", + "user_id": "user-123", + "user_name": "alice@example.com", + "role_id": "role-admin", + "role_name": "cluster-admin", + "role_type": "kubernetes", + "scope": "prod-cluster" + } + }, + { + "id": "schema-example-037", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "PipelineRun", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:23:00.000Z", + "count": 1, + "typedChange": { + "kind": "PipelineRun/v1", + "id": "pipeline-55", + "timestamp": "2026-04-10T12:25:00Z", + "environment": { + "kind": "Environment/v1", + "name": "production", + "type": "Kubernetes", + "stage": "Production" + }, + "status": "Completed" + } + }, + { + "id": "schema-example-038", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Promotion", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:22:00.000Z", + "count": 1, + "typedChange": { + "kind": "Promotion/v1", + "id": "evt-promo-1", + "timestamp": "2026-04-10T12:00:00Z", + "from": { + "kind": "Environment/v1", + "name": "staging", + "stage": "Staging" + }, + "to": { + "kind": "Environment/v1", + "name": "production", + "stage": "Production" + }, + "source": { + "kind": "Source/v1", + "git": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456" + }, + "path": "deploy/production" + }, + "version": "v1.2.3", + "approvals": [ + { + "kind": "Approval/v1", + "id": "approval-1", + "submitted_by": { + "kind": "Identity/v1", + "id": "user-123", + "type": "User", + "name": "alice@example.com" + }, + "approver": { + "kind": "Identity/v1", + "id": "user-456", + "type": "User", + "name": "bob@example.com" + }, + "stage": "Manual", + "status": "Approved" + } + ], + "artifact": "api" + } + }, + { + "id": "schema-example-039", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "RestoreCompleted", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:21:00.000Z", + "count": 1, + "typedChange": { + "kind": "Restore/v1", + "id": "restore-7", + "timestamp": "2026-04-10T12:30:00Z", + "from": { + "kind": "Environment/v1", + "name": "backup-store", + "type": "Cloud" + }, + "to": { + "kind": "Environment/v1", + "name": "staging", + "type": "Kubernetes", + "stage": "Staging" + }, + "source": { + "kind": "Source/v1", + "database": { + "kind": "DatabaseSource/v1", + "type": "PostgreSQL", + "name": "incidents", + "version": "15.4" + } + }, + "status": "Completed" + } + }, + { + "id": "schema-example-040", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Scaling", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:20:00.000Z", + "count": 1, + "typedChange": { + "kind": "Scale/v1", + "dimension": "Replicas", + "previous_value": { + "kind": "Dimension/v1", + "desired": "2" + }, + "value": { + "kind": "Dimension/v1", + "desired": "3" + } + } + }, + { + "id": "schema-example-041", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Screenshot", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:19:00.000Z", + "count": 1, + "typedChange": { + "kind": "Screenshot/v1", + "artifact_id": "artifact-789", + "url": "https://artifacts.example.com/screenshots/login-flow.png", + "content_type": "image/png", + "width": 1920, + "height": 1080 + } + }, + { + "id": "schema-example-042", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Source", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:18:00.000Z", + "count": 1, + "typedChange": { + "kind": "Source/v1", + "git": { + "kind": "GitSource/v1", + "url": "https://github.com/flanksource/duty.git", + "branch": "main", + "commit_sha": "abc123def456" + }, + "path": "deploy/production" + } + }, + { + "id": "schema-example-043", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "Test", + "severity": "info", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:17:00.000Z", + "count": 1, + "typedChange": { + "kind": "Test/v1", + "id": "test-101", + "timestamp": "2026-04-10T12:20:00Z", + "name": "api-smoke", + "description": "Smoke tests for the public API", + "type": "Integration", + "status": "Passed", + "result": "Passed" + } + }, + { + "id": "schema-example-044", + "configID": "schema-example-catalog", + "configName": "Schema Example Catalog", + "configType": "Schema::Example", + "changeType": "UserChange", + "severity": "low", + "source": "schema-examples", + "createdBy": "schema-generator", + "createdAt": "2026-04-10T23:16:00.000Z", + "count": 1, + "typedChange": { + "kind": "UserChange/v1", + "user_id": "user-123", + "user_name": "alice@example.com", + "user_email": "alice@example.com", + "user_type": "human", + "group_id": "group-platform", + "group_name": "platform-admins", + "tenant": "acme" + } + } + ], + "analyses": [ + { + "id": "ana-001", + "configID": "cfg-eks-001", + "analyzer": "Trivy", + "message": "Container image flanksource/incident-commander:v1.4.200 has 3 high CVEs (CVE-2026-1234, CVE-2026-1235, CVE-2026-1236)", + "status": "open", + "severity": "high", + "analysisType": "security", + "source": "trivy-operator", + "firstObserved": "2026-03-28T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-002", + "configID": "cfg-eks-001", + "analyzer": "Trivy", + "message": "Base image golang:1.23-alpine has known vulnerability in libcrypto (CVE-2026-0891)", + "status": "open", + "severity": "critical", + "analysisType": "security", + "source": "trivy-operator", + "firstObserved": "2026-03-25T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-003", + "configID": "cfg-eks-001", + "analyzer": "OPA/Gatekeeper", + "message": "Pod incident-commander-7f8b9c running as root user in namespace mc", + "status": "open", + "severity": "medium", + "analysisType": "compliance", + "source": "gatekeeper", + "firstObserved": "2026-03-20T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-004", + "configID": "cfg-eks-001", + "analyzer": "OPA/Gatekeeper", + "message": "Namespace mc missing required label: data-classification", + "status": "silenced", + "severity": "low", + "analysisType": "compliance", + "source": "gatekeeper", + "firstObserved": "2026-03-15T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-005", + "configID": "cfg-eks-001", + "analyzer": "AWS Cost Optimizer", + "message": "EKS node group i3.xlarge instances are underutilized (avg CPU 18%). Consider downsizing to i3.large.", + "status": "open", + "severity": "medium", + "analysisType": "cost", + "source": "aws-cost-explorer", + "firstObserved": "2026-03-01T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-006", + "configID": "cfg-eks-001", + "analyzer": "AWS Cost Optimizer", + "message": "NAT Gateway data processing charges are 40% above baseline ($320/mo). Review egress traffic patterns.", + "status": "open", + "severity": "low", + "analysisType": "cost", + "source": "aws-cost-explorer", + "firstObserved": "2026-03-10T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-007", + "configID": "cfg-eks-001", + "analyzer": "Prometheus Advisor", + "message": "P99 API response latency exceeded 500ms threshold 12 times in the last 7 days", + "status": "open", + "severity": "high", + "analysisType": "performance", + "source": "prometheus", + "firstObserved": "2026-03-23T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-008", + "configID": "cfg-eks-001", + "analyzer": "AWS Best Practices", + "message": "EKS cluster running version 1.29 - version 1.30 is available with security patches", + "status": "open", + "severity": "info", + "analysisType": "recommendation", + "source": "aws-advisor", + "firstObserved": "2026-03-28T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-009", + "configID": "cfg-eks-001", + "analyzer": "AWS Best Practices", + "message": "Enable EKS control plane logging for audit, authenticator, and scheduler components", + "status": "resolved", + "severity": "medium", + "analysisType": "reliability", + "source": "aws-advisor", + "firstObserved": "2026-02-15T09:00:00Z", + "lastObserved": "2026-03-20T09:00:00Z" + }, + { + "id": "ana-010", + "configID": "cfg-eks-001", + "analyzer": "Prometheus Advisor", + "message": "Node ip-10-0-2-18 memory utilization consistently above 85% - risk of OOM kills", + "status": "open", + "severity": "high", + "analysisType": "reliability", + "source": "prometheus", + "firstObserved": "2026-03-26T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z" + }, + { + "id": "ana-011", + "configID": "cfg-eks-001", + "analyzer": "Trivy", + "message": "Resolved: CVE-2025-9999 in nginx ingress controller patched in v1.10.1", + "status": "resolved", + "severity": "high", + "analysisType": "security", + "source": "trivy-operator", + "firstObserved": "2026-02-01T09:00:00Z", + "lastObserved": "2026-03-15T09:00:00Z" + } + ], + "relationships": [ + { + "configID": "cfg-eks-001", + "relatedID": "cfg-vpc-001", + "relation": "RunsIn", + "direction": "outgoing" + }, + { + "configID": "cfg-eks-001", + "relatedID": "cfg-iam-001", + "relation": "ManagedBy", + "direction": "outgoing" + }, + { + "configID": "cfg-eks-001", + "relatedID": "cfg-sg-001", + "relation": "DependsOn", + "direction": "outgoing" + }, + { + "configID": "cfg-eks-001", + "relatedID": "cfg-rds-001", + "relation": "DependsOn", + "direction": "outgoing" + }, + { + "configID": "cfg-deploy-001", + "relatedID": "cfg-eks-001", + "relation": "RunsOn", + "direction": "incoming" + }, + { + "configID": "cfg-deploy-002", + "relatedID": "cfg-eks-001", + "relation": "RunsOn", + "direction": "incoming" + }, + { + "configID": "cfg-deploy-003", + "relatedID": "cfg-eks-001", + "relation": "RunsOn", + "direction": "incoming" + }, + { + "configID": "cfg-ns-001", + "relatedID": "cfg-eks-001", + "relation": "ChildOf", + "direction": "incoming" + }, + { + "configID": "cfg-node-001", + "relatedID": "cfg-eks-001", + "relation": "ChildOf", + "direction": "incoming" + }, + { + "configID": "cfg-node-002", + "relatedID": "cfg-eks-001", + "relation": "ChildOf", + "direction": "incoming" + } + ], + "relatedConfigs": [ + { + "id": "cfg-vpc-001", + "name": "prod-vpc", + "type": "AWS::EC2::VPC", + "configClass": "Network", + "status": "available", + "health": "healthy", + "labels": { + "env": "production" + } + }, + { + "id": "cfg-iam-001", + "name": "eks-cluster-role", + "type": "AWS::IAM::Role", + "configClass": "IAM", + "status": "active", + "health": "healthy" + }, + { + "id": "cfg-sg-001", + "name": "eks-cluster-sg", + "type": "AWS::EC2::SecurityGroup", + "configClass": "Network", + "status": "active", + "health": "warning", + "labels": { + "env": "production" + } + }, + { + "id": "cfg-rds-001", + "name": "mission-control-db", + "type": "AWS::RDS::Instance", + "configClass": "Database", + "status": "available", + "health": "healthy", + "labels": { + "env": "production", + "engine": "postgresql" + } + }, + { + "id": "cfg-deploy-001", + "name": "incident-commander", + "type": "Kubernetes::Deployment", + "configClass": "Deployment", + "status": "Running", + "health": "healthy", + "labels": { + "app": "incident-commander" + } + }, + { + "id": "cfg-deploy-002", + "name": "canary-checker", + "type": "Kubernetes::Deployment", + "configClass": "Deployment", + "status": "Running", + "health": "healthy", + "labels": { + "app": "canary-checker" + } + }, + { + "id": "cfg-deploy-003", + "name": "config-db", + "type": "Kubernetes::Deployment", + "configClass": "Deployment", + "status": "Running", + "health": "unhealthy", + "labels": { + "app": "config-db" + } + }, + { + "id": "cfg-ns-001", + "name": "mc", + "type": "Kubernetes::Namespace", + "configClass": "Namespace", + "status": "Active", + "health": "healthy" + }, + { + "id": "cfg-node-001", + "name": "ip-10-0-1-42", + "type": "Kubernetes::Node", + "configClass": "Node", + "status": "Ready", + "health": "healthy" + }, + { + "id": "cfg-node-002", + "name": "ip-10-0-2-18", + "type": "Kubernetes::Node", + "configClass": "Node", + "status": "Ready", + "health": "warning", + "labels": { + "instance-type": "i3.xlarge" + } + } + ], + "rbacChanges": [ + { + "id": "rbac-001", + "date": "2026-03-30T09:12:00Z", + "changeType": "PermissionAdded", + "source": "azure-entra", + "createdBy": "alice@flanksource.com", + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "permission": { + "user": "alice@flanksource.com", + "role": "db_owner", + "group": "incident-responders" + }, + "description": "PermissionAdded: user alice@flanksource.com, role db_owner, group incident-responders", + "status": "info", + "createdAt": "2026-03-30T09:12:00Z" + }, + { + "id": "rbac-002", + "date": "2026-03-29T18:40:00Z", + "changeType": "PermissionRemoved", + "source": "azure-entra", + "createdBy": "security-automation", + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "permission": { + "user": "contractor-temp", + "role": "db_datareader" + }, + "description": "PermissionRemoved: user contractor-temp, role db_datareader", + "status": "info", + "createdAt": "2026-03-29T18:40:00Z" + }, + { + "id": "rbac-006", + "date": "2026-03-29T11:15:00Z", + "changeType": "PermissionAdded", + "source": "okta", + "createdBy": "governance-bot", + "configId": "cfg-keyvault-001", + "configName": "prod-keyvault", + "configType": "Azure::KeyVault", + "permission": { + "user": "ops-auditor", + "role": "Secrets Reader" + }, + "description": "PermissionAdded: user ops-auditor, role Secrets Reader", + "status": "info", + "createdAt": "2026-03-29T11:15:00Z" + }, + { + "id": "rbac-003", + "date": "2026-03-29T16:00:00Z", + "changeType": "AccessReviewed", + "source": "access-review-job", + "createdBy": "governance-bot", + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "description": "Quarterly review completed for production database roles", + "status": "info", + "createdAt": "2026-03-29T16:00:00Z" + }, + { + "id": "rbac-004", + "date": "2026-03-28T13:05:00Z", + "changeType": "PermissionGranted", + "source": "okta", + "createdBy": "bob@flanksource.com", + "configId": "cfg-analytics-001", + "configName": "analytics-db", + "configType": "MSSQL::Database", + "description": "Granted db_ddladmin to deploy-bot on analytics-db", + "status": "info", + "createdAt": "2026-03-28T13:05:00Z" + }, + { + "id": "rbac-005", + "date": "2026-03-27T07:20:00Z", + "changeType": "PermissionAdded", + "source": "okta", + "configId": "cfg-keyvault-001", + "configName": "prod-keyvault", + "configType": "Azure::KeyVault", + "permission": { + "group": "break-glass-admins", + "role": "Secrets Officer" + }, + "description": "PermissionAdded: role Secrets Officer, group break-glass-admins", + "status": "info", + "createdAt": "2026-03-27T07:20:00Z" + } + ], + "backupChanges": [ + { + "id": "bak-001", + "date": "2026-03-30T02:00:00Z", + "changeType": "BackupStarted", + "source": "aws-backup", + "description": "Nightly snapshot started for incident-commander-db", + "status": "info", + "createdAt": "2026-03-30T02:00:00Z" + }, + { + "id": "bak-002", + "date": "2026-03-30T02:08:00Z", + "changeType": "BackupCompleted", + "source": "aws-backup", + "description": "Nightly snapshot completed for incident-commander-db (4.3 GB)", + "status": "info", + "createdAt": "2026-03-30T02:08:00Z" + }, + { + "id": "bak-003", + "date": "2026-03-29T02:01:00Z", + "changeType": "BackupFailed", + "source": "aws-backup", + "description": "Snapshot failed for incident-commander-db after storage timeout", + "status": "high", + "createdAt": "2026-03-29T02:01:00Z" + }, + { + "id": "bak-004", + "date": "2026-03-28T12:10:00Z", + "changeType": "BackupRestored", + "source": "drill-playbook", + "createdBy": "platform-oncall", + "description": "Restored staging copy from nightly snapshot for disaster recovery drill", + "status": "info", + "createdAt": "2026-03-28T12:10:00Z" + }, + { + "id": "bak-005", + "date": "2026-03-28T12:18:00Z", + "changeType": "RestoreCompleted", + "source": "drill-playbook", + "createdBy": "platform-oncall", + "description": "Restore completed and validation checks passed", + "status": "info", + "createdAt": "2026-03-28T12:18:00Z" + }, + { + "id": "bak-006", + "date": "2026-03-27T02:00:00Z", + "changeType": "BackupEnqueued", + "source": "aws-backup", + "description": "Queued backup job for archive-postgres", + "status": "info", + "createdAt": "2026-03-27T02:00:00Z" + }, + { + "id": "bak-007", + "date": "2026-03-27T10:00:00Z", + "changeType": "diff", + "source": "terraform", + "description": "This diff should be filtered out of the backup-focused renderer", + "status": "low", + "createdAt": "2026-03-27T10:00:00Z" + } + ], + "deploymentChanges": [ + { + "id": "dep-001", + "date": "2026-03-30T08:15:00Z", + "changeType": "diff", + "source": "argocd", + "createdBy": "deploy-bot", + "description": "Deployment incident-commander image updated: v1.4.199 -> v1.4.200", + "status": "low", + "createdAt": "2026-03-30T08:15:00Z" + }, + { + "id": "dep-002", + "date": "2026-03-30T07:30:00Z", + "changeType": "Pulled", + "source": "kubernetes", + "description": "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42", + "status": "info", + "createdAt": "2026-03-30T07:30:00Z" + }, + { + "id": "dep-003", + "date": "2026-03-29T22:00:00Z", + "changeType": "ScalingReplicaSet", + "source": "kubernetes", + "createdBy": "cluster-autoscaler", + "description": "Deployment incident-commander scaled from 2 to 4 replicas", + "status": "low", + "createdAt": "2026-03-29T22:00:00Z" + }, + { + "id": "dep-004", + "date": "2026-03-29T14:00:00Z", + "changeType": "PolicyUpdate", + "source": "argocd", + "createdBy": "alice@flanksource.com", + "description": "Deployment network policy updated to restrict egress to approved CIDRs", + "status": "medium", + "createdAt": "2026-03-29T14:00:00Z" + }, + { + "id": "dep-005", + "date": "2026-03-28T10:00:00Z", + "changeType": "FieldsV1", + "source": "kubernetes", + "description": "FieldsV1 payload updated during reconciliation", + "status": "info", + "createdAt": "2026-03-28T10:00:00Z" + }, + { + "id": "dep-006", + "date": "2026-03-27T09:00:00Z", + "changeType": "diff", + "source": "terraform", + "createdBy": "carol@flanksource.com", + "description": "Deployment incident-commander rollout template updated with new topology spread constraints", + "status": "medium", + "createdAt": "2026-03-27T09:00:00Z" + } + ], + "scrapers": [ + { + "id": "scr-001", + "name": "mc/aws-production", + "namespace": "mc", + "source": "KubernetesCRD", + "types": [ + "aws", + "kubernetes" + ], + "specHash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd", + "createdBy": "alice@flanksource.com", + "createdAt": "2025-06-10T09:00:00Z", + "updatedAt": "2026-03-28T14:30:00Z", + "gitops": { + "git": { + "url": "https://github.com/flanksource/mission-control-demo", + "branch": "main", + "file": "clusters/prod/scrapers/aws.yaml", + "dir": "clusters/prod/scrapers", + "link": "https://github.com/flanksource/mission-control-demo/tree/main/clusters/prod/scrapers/aws.yaml" + }, + "kustomize": { + "path": "clusters/prod/scrapers", + "file": "clusters/prod/scrapers/kustomization.yaml" + } + } + }, + { + "id": "scr-002", + "name": "mc/azure-entra", + "namespace": "mc", + "source": "KubernetesCRD", + "types": [ + "azure" + ], + "specHash": "ff00aa11bb22cc33dd44ee55ff00aa11bb22cc33dd44ee55ff00aa11bb22cc33", + "createdAt": "2025-09-01T10:00:00Z", + "updatedAt": "2026-03-30T08:00:00Z" + }, + { + "id": "scr-003", + "name": "local-file-scraper", + "source": "ConfigFile", + "types": [ + "file", + "sql" + ], + "specHash": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "createdBy": "bob@flanksource.com", + "createdAt": "2026-01-15T11:00:00Z" + } + ], + "genericChangesSection": { + "type": "changes", + "title": "Recent Infrastructure Changes", + "changes": [ + { + "id": "gen-001", + "date": "2026-03-30T10:00:00Z", + "changeType": "ConfigUpdate", + "source": "terraform", + "createdBy": "alice@flanksource.com", + "description": "Updated VPC CIDR block from 10.0.0.0/16 to 10.0.0.0/12", + "status": "medium", + "createdAt": "2026-03-30T10:00:00Z" + }, + { + "id": "gen-002", + "date": "2026-03-29T15:00:00Z", + "changeType": "TagUpdate", + "source": "aws-config", + "description": "Added cost-center tag to 14 resources in us-east-1", + "status": "info", + "createdAt": "2026-03-29T15:00:00Z" + }, + { + "id": "gen-003", + "date": "2026-03-28T09:00:00Z", + "changeType": "SecurityGroupChange", + "source": "aws-config", + "createdBy": "security-automation", + "description": "Removed unused ingress rule on sg-0abc123 (port 8080)", + "status": "low", + "createdAt": "2026-03-28T09:00:00Z" + }, + { + "id": "gen-004", + "date": "2026-03-27T14:00:00Z", + "changeType": "DNSUpdate", + "source": "route53", + "createdBy": "bob@flanksource.com", + "description": "Added CNAME record api-v2.flanksource.com -> prod-alb.us-east-1.elb.amazonaws.com", + "status": "info", + "createdAt": "2026-03-27T14:00:00Z" + } + ] + }, + "dynamicViewSection": { + "type": "view", + "title": "Cluster Nodes", + "view": { + "columns": [ + { + "name": "node", + "type": "string" + }, + { + "name": "status", + "type": "status" + }, + { + "name": "health", + "type": "health" + }, + { + "name": "cpu_used", + "type": "gauge", + "gauge": { + "thresholds": [ + { + "percent": 0, + "color": "#22C55E" + }, + { + "percent": 70, + "color": "#EAB308" + }, + { + "percent": 90, + "color": "#EF4444" + } + ] + } + }, + { + "name": "memory", + "type": "bytes" + } + ], + "rows": [ + [ + "ip-10-0-1-42", + "Ready", + "healthy", + 45.2, + 8589934592 + ], + [ + "ip-10-0-2-18", + "Ready", + "warning", + 87.3, + 14495514624 + ], + [ + "ip-10-0-3-7", + "NotReady", + "unhealthy", + 0, + 0 + ] + ] + } + }, + "dynamicConfigsSection": { + "type": "configs", + "title": "Related Databases", + "configs": [ + { + "id": "db-001", + "name": "mission-control-db", + "type": "AWS::RDS::Instance", + "status": "available", + "health": "healthy", + "labels": { + "engine": "postgresql", + "env": "production" + } + }, + { + "id": "db-002", + "name": "analytics-db", + "type": "AWS::RDS::Instance", + "status": "available", + "health": "warning", + "labels": { + "engine": "postgresql", + "env": "production" + } + }, + { + "id": "db-003", + "name": "archive-db", + "type": "AWS::RDS::Instance", + "status": "stopped", + "health": "unknown", + "labels": { + "engine": "postgresql", + "env": "staging" + } + } + ] + }, + "application": { + "id": "app-001", + "name": "Mission Control", + "type": "WebApplication", + "namespace": "mc", + "description": "Internal developer platform for Kubernetes fleet management", + "properties": [ + { + "name": "uptime", + "label": "Uptime", + "value": 99.97, + "unit": "percentage", + "order": 1 + }, + { + "name": "latency", + "label": "P99 Latency", + "value": 245, + "unit": "milliseconds", + "order": 2 + }, + { + "name": "requests", + "label": "Requests/s", + "value": 1240, + "order": 3 + }, + { + "name": "error_rate", + "label": "Error Rate", + "value": 0.03, + "unit": "percentage", + "order": 4 + } + ], + "accessControl": { + "users": [ + { + "id": "u-001", + "name": "Alice Johnson", + "email": "alice@flanksource.com", + "role": "admin", + "authType": "SSO", + "created": "2025-01-15T09:00:00Z", + "lastLogin": "2026-03-30T08:00:00Z", + "lastAccessReview": "2026-03-15T10:00:00Z" + }, + { + "id": "u-002", + "name": "Bob Smith", + "email": "bob@flanksource.com", + "role": "editor", + "authType": "SSO", + "created": "2025-06-01T09:00:00Z", + "lastLogin": "2026-03-28T14:00:00Z", + "lastAccessReview": "2026-02-01T10:00:00Z" + }, + { + "id": "u-003", + "name": "Carol Davis", + "email": "carol@flanksource.com", + "role": "viewer", + "authType": "API Key", + "created": "2026-01-10T09:00:00Z", + "lastLogin": null, + "lastAccessReview": null + } + ], + "authentication": [ + { + "name": "Azure AD SSO", + "type": "SAML", + "mfa": { + "type": "TOTP", + "enforced": "true" + }, + "properties": { + "tenant": "flanksource.onmicrosoft.com" + } + }, + { + "name": "API Key Auth", + "type": "Bearer", + "mfa": { + "type": "none", + "enforced": "false" + }, + "properties": { + "rotation": "90 days" + } + } + ] + }, + "incidents": [ + { + "id": "inc-001", + "date": "2026-03-28T03:15:00Z", + "severity": "critical", + "description": "Database connection pool exhausted causing 503 errors across all API endpoints", + "status": "resolved", + "resolvedDate": "2026-03-28T04:45:00Z" + }, + { + "id": "inc-002", + "date": "2026-03-25T14:00:00Z", + "severity": "high", + "description": "Config scraper failing to sync AWS resources due to expired IAM credentials", + "status": "resolved", + "resolvedDate": "2026-03-25T15:30:00Z" + }, + { + "id": "inc-003", + "date": "2026-03-22T09:30:00Z", + "severity": "medium", + "description": "Notification delivery delayed by 15+ minutes due to queue backlog", + "status": "resolved", + "resolvedDate": "2026-03-22T11:00:00Z" + }, + { + "id": "inc-004", + "date": "2026-03-30T06:00:00Z", + "severity": "low", + "description": "Health check dashboard showing stale data for canary-checker pods", + "status": "open" + }, + { + "id": "inc-005", + "date": "2026-03-29T20:00:00Z", + "severity": "high", + "description": "Memory leak in event processor causing gradual degradation", + "status": "open" + } + ], + "locations": [ + { + "account": "flanksource-prod", + "name": "us-east-1-primary", + "type": "EKS Cluster", + "purpose": "primary", + "region": "us-east-1", + "provider": "AWS", + "resourceCount": 142 + }, + { + "account": "flanksource-prod", + "name": "eu-west-1-backup", + "type": "EKS Cluster", + "purpose": "backup", + "region": "eu-west-1", + "provider": "AWS", + "resourceCount": 38 + }, + { + "account": "flanksource-dr", + "name": "us-west-2-dr", + "type": "EKS Cluster", + "purpose": "dr", + "region": "us-west-2", + "provider": "AWS", + "resourceCount": 15 + } + ], + "backups": [ + { + "id": "bkp-001", + "database": "mission-control-db", + "type": "snapshot", + "source": "aws-backup", + "date": "2026-03-30T02:00:00Z", + "size": "4.3 GB", + "status": "success" + }, + { + "id": "bkp-002", + "database": "mission-control-db", + "type": "snapshot", + "source": "aws-backup", + "date": "2026-03-29T02:00:00Z", + "size": "4.2 GB", + "status": "failed" + }, + { + "id": "bkp-003", + "database": "mission-control-db", + "type": "snapshot", + "source": "aws-backup", + "date": "2026-03-28T02:00:00Z", + "size": "4.1 GB", + "status": "success" + }, + { + "id": "bkp-004", + "database": "analytics-db", + "type": "logical", + "source": "pg_dump", + "date": "2026-03-30T03:00:00Z", + "size": "1.8 GB", + "status": "success" + }, + { + "id": "bkp-005", + "database": "analytics-db", + "type": "logical", + "source": "pg_dump", + "date": "2026-03-29T03:00:00Z", + "size": "1.7 GB", + "status": "in-progress" + } + ], + "restores": [ + { + "id": "rst-001", + "database": "mission-control-db", + "date": "2026-03-28T12:00:00Z", + "source": "aws-backup", + "status": "success", + "completedAt": "2026-03-28T12:18:00Z" + }, + { + "id": "rst-002", + "database": "analytics-db", + "date": "2026-03-15T09:00:00Z", + "source": "pg_dump", + "status": "success", + "completedAt": "2026-03-15T09:35:00Z" + } + ], + "findings": [ + { + "id": "find-001", + "type": "security", + "severity": "critical", + "title": "CVE-2026-0891 in libcrypto", + "description": "Critical vulnerability in OpenSSL library", + "date": "2026-03-25T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "open", + "remediation": "Upgrade base image to golang:1.23.1-alpine" + }, + { + "id": "find-002", + "type": "security", + "severity": "high", + "title": "Container running as root", + "description": "incident-commander pod runs as UID 0", + "date": "2026-03-20T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "open", + "remediation": "Add securityContext.runAsNonRoot to deployment spec" + }, + { + "id": "find-003", + "type": "compliance", + "severity": "medium", + "title": "Missing data-classification label", + "description": "Namespace mc missing required label", + "date": "2026-03-15T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "open", + "remediation": "Add label data-classification=internal to namespace" + }, + { + "id": "find-004", + "type": "compliance", + "severity": "low", + "title": "Pod disruption budget missing", + "description": "canary-checker deployment has no PDB", + "date": "2026-03-10T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "accepted" + }, + { + "id": "find-005", + "type": "reliability", + "severity": "high", + "title": "Node memory pressure", + "description": "ip-10-0-2-18 consistently above 85% memory", + "date": "2026-03-26T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "open", + "remediation": "Scale node group or add memory limits to workloads" + }, + { + "id": "find-006", + "type": "reliability", + "severity": "medium", + "title": "Single replica deployment", + "description": "config-db running with 1 replica", + "date": "2026-03-01T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "resolved" + }, + { + "id": "find-007", + "type": "performance", + "severity": "high", + "title": "API latency above threshold", + "description": "P99 latency exceeded 500ms 12 times in 7 days", + "date": "2026-03-23T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "in-progress", + "remediation": "Investigate slow queries and add connection pooling" + }, + { + "id": "find-008", + "type": "performance", + "severity": "low", + "title": "Slow config scraper sync", + "description": "AWS scraper taking >10min per cycle", + "date": "2026-03-18T09:00:00Z", + "lastObserved": "2026-03-30T09:00:00Z", + "status": "open" + } + ], + "sections": [] + }, + "rbacReport": { + "title": "prod-sql-primary", + "query": "type=MSSQL::Database AND name=prod-sql-primary", + "generatedAt": "2026-03-30T12:00:00Z", + "subject": { + "id": "cfg-sql-001", + "name": "prod-sql-primary", + "type": "MSSQL::Database", + "config_class": "Database", + "status": "Online", + "health": "healthy", + "description": "Primary SQL Server database for production workloads", + "tags": { + "env": "production", + "team": "data-platform" + } + }, + "parents": [ + { + "id": "cfg-sql-server-001", + "name": "sql-prod-east", + "type": "MSSQL::Server" + }, + { + "id": "cfg-rg-001", + "name": "rg-prod-data", + "type": "Azure::ResourceGroup" + } + ], + "summary": { + "totalUsers": 8, + "totalResources": 2, + "staleAccessCount": 2, + "overdueReviews": 1, + "directAssignments": 6, + "groupAssignments": 4 + }, + "resources": [ + { + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "configClass": "Database", + "path": "rg-prod-data.sql-prod-east.prod-sql-primary", + "status": "Online", + "health": "healthy", + "tags": { + "env": "production" + }, + "labels": { + "team": "data-platform" + }, + "users": [ + { + "userId": "u-alice", + "userName": "alice@flanksource.com", + "email": "alice@flanksource.com", + "role": "db_owner", + "roleSource": "direct", + "sourceSystem": "azure-entra", + "createdAt": "2025-01-15T09:00:00Z", + "lastSignedInAt": "2026-03-30T08:00:00Z", + "lastReviewedAt": "2026-03-15T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "userId": "u-bob", + "userName": "bob@flanksource.com", + "email": "bob@flanksource.com", + "role": "db_datareader", + "roleSource": "direct", + "sourceSystem": "azure-entra", + "createdAt": "2025-06-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-02-01T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "userId": "u-bob", + "userName": "bob@flanksource.com", + "email": "bob@flanksource.com", + "role": "db_datawriter", + "roleSource": "group:SG-DataEngineers", + "sourceSystem": "azure-entra", + "createdAt": "2025-06-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-02-01T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "userId": "u-carol", + "userName": "carol@flanksource.com", + "email": "carol@flanksource.com", + "role": "db_datareader", + "roleSource": "group:SG-Analytics", + "sourceSystem": "azure-entra", + "createdAt": "2026-01-10T09:00:00Z", + "lastSignedInAt": null, + "lastReviewedAt": null, + "isStale": true, + "isReviewOverdue": true + }, + { + "userId": "u-deploy-bot", + "userName": "deploy-bot", + "email": "deploy-bot@flanksource.com", + "role": "db_ddladmin", + "roleSource": "direct", + "sourceSystem": "azure-entra", + "createdAt": "2025-08-01T09:00:00Z", + "lastSignedInAt": "2026-03-30T06:00:00Z", + "lastReviewedAt": "2026-03-01T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "userId": "u-contractor", + "userName": "contractor-temp", + "email": "contractor@external.com", + "role": "db_datareader", + "roleSource": "direct", + "sourceSystem": "okta", + "createdAt": "2025-12-01T09:00:00Z", + "lastSignedInAt": "2025-12-15T10:00:00Z", + "lastReviewedAt": null, + "isStale": true, + "isReviewOverdue": true + } + ], + "changelog": [ + { + "configId": "cfg-sql-001", + "date": "2026-03-30T09:12:00Z", + "changeType": "PermissionGranted", + "user": "alice@flanksource.com", + "role": "db_owner", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Granted during oncall rotation" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-29T18:40:00Z", + "changeType": "PermissionRevoked", + "user": "contractor-temp", + "role": "db_datareader", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Contract ended" + } + ] + } + ], + "changelog": [ + { + "configId": "cfg-sql-001", + "date": "2026-03-30T09:12:00Z", + "changeType": "PermissionGranted", + "user": "alice@flanksource.com", + "role": "db_owner", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Granted during oncall rotation" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-29T18:40:00Z", + "changeType": "PermissionRevoked", + "user": "contractor-temp", + "role": "db_datareader", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Contract ended" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-29T16:00:00Z", + "changeType": "AccessReviewed", + "user": "governance-bot", + "role": "all", + "configName": "prod-sql-primary", + "source": "access-review-job", + "description": "Quarterly review completed" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-28T13:05:00Z", + "changeType": "PermissionGranted", + "user": "deploy-bot", + "role": "db_ddladmin", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Automated pipeline access" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-15T10:00:00Z", + "changeType": "PermissionGranted", + "user": "bob@flanksource.com", + "role": "db_datawriter", + "configName": "prod-sql-primary", + "source": "azure-entra", + "description": "Added via SG-DataEngineers group" + }, + { + "configId": "cfg-sql-001", + "date": "2026-03-01T09:00:00Z", + "changeType": "PermissionRevoked", + "user": "intern-2025", + "role": "db_datareader", + "configName": "prod-sql-primary", + "source": "okta", + "description": "Internship ended" + } + ], + "users": [ + { + "userId": "u-alice", + "userName": "alice@flanksource.com", + "email": "alice@flanksource.com", + "sourceSystem": "azure-entra", + "lastSignedInAt": "2026-03-30T08:00:00Z", + "resources": [ + { + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "configClass": "Database", + "role": "db_owner", + "roleSource": "direct", + "createdAt": "2025-01-15T09:00:00Z", + "lastSignedInAt": "2026-03-30T08:00:00Z", + "lastReviewedAt": "2026-03-15T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "configId": "cfg-sql-002", + "configName": "analytics-db", + "configType": "MSSQL::Database", + "configClass": "Database", + "role": "db_datareader", + "roleSource": "group:SG-Analytics", + "createdAt": "2025-03-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-03-15T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + } + ] + }, + { + "userId": "u-bob", + "userName": "bob@flanksource.com", + "email": "bob@flanksource.com", + "sourceSystem": "azure-entra", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "resources": [ + { + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "configClass": "Database", + "role": "db_datareader", + "roleSource": "direct", + "createdAt": "2025-06-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-02-01T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "configClass": "Database", + "role": "db_datawriter", + "roleSource": "group:SG-DataEngineers", + "createdAt": "2025-06-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-02-01T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + }, + { + "configId": "cfg-kv-001", + "configName": "prod-keyvault", + "configType": "Azure::KeyVault", + "configClass": "Security", + "role": "Secrets Reader", + "roleSource": "direct", + "createdAt": "2025-09-01T09:00:00Z", + "lastSignedInAt": "2026-03-25T10:00:00Z", + "lastReviewedAt": "2026-01-15T10:00:00Z", + "isStale": false, + "isReviewOverdue": false + } + ] + } + ] + }, + "catalogReport": { + "relationshipTree": { + "id": "cfg-eks-001", + "name": "prod-eks-cluster", + "type": "AWS::EKS::Cluster", + "edgeType": "target", + "children": [ + { + "id": "cfg-vpc-001", + "name": "prod-vpc", + "type": "AWS::EC2::VPC", + "edgeType": "parent", + "relation": "RunsIn", + "children": [ + { + "id": "cfg-subnet-001", + "name": "private-subnet-1a", + "type": "AWS::EC2::Subnet", + "edgeType": "child" + }, + { + "id": "cfg-subnet-002", + "name": "private-subnet-1b", + "type": "AWS::EC2::Subnet", + "edgeType": "child" + } + ] + }, + { + "id": "cfg-ns-001", + "name": "mc", + "type": "Kubernetes::Namespace", + "edgeType": "child", + "relation": "ChildOf", + "children": [ + { + "id": "cfg-deploy-001", + "name": "incident-commander", + "type": "Kubernetes::Deployment", + "edgeType": "child" + }, + { + "id": "cfg-deploy-002", + "name": "canary-checker", + "type": "Kubernetes::Deployment", + "edgeType": "child" + } + ] + }, + { + "id": "cfg-rds-001", + "name": "mission-control-db", + "type": "AWS::RDS::Instance", + "edgeType": "related", + "relation": "DependsOn" + } + ] + }, + "access": [ + { + "userId": "u-alice", + "userName": "Alice Johnson", + "email": "alice@flanksource.com", + "role": "admin", + "userType": "User", + "createdAt": "2025-01-15T09:00:00Z", + "lastSignedInAt": "2026-03-30T08:00:00Z", + "lastReviewedAt": "2026-03-15T10:00:00Z" + }, + { + "userId": "u-bob", + "userName": "Bob Smith", + "email": "bob@flanksource.com", + "role": "editor", + "userType": "User", + "createdAt": "2025-06-01T09:00:00Z", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "lastReviewedAt": "2026-02-01T10:00:00Z" + }, + { + "userId": "u-carol", + "userName": "Carol Davis", + "email": "carol@flanksource.com", + "role": "viewer", + "userType": "User", + "createdAt": "2026-01-10T09:00:00Z", + "lastSignedInAt": null + }, + { + "userId": "u-deploy-bot", + "userName": "deploy-bot", + "email": "deploy-bot@flanksource.com", + "role": "editor", + "userType": "ServiceAccount", + "createdAt": "2025-08-01T09:00:00Z", + "lastSignedInAt": "2026-03-30T06:00:00Z", + "lastReviewedAt": "2026-03-01T10:00:00Z" + }, + { + "userId": "u-stale", + "userName": "Former Employee", + "email": "former@flanksource.com", + "role": "viewer", + "userType": "User", + "createdAt": "2024-06-01T09:00:00Z", + "lastSignedInAt": "2025-06-15T10:00:00Z" + } + ], + "accessLogs": [ + { + "userId": "u-alice", + "userName": "Alice Johnson", + "configName": "prod-eks-cluster", + "configType": "AWS::EKS::Cluster", + "createdAt": "2026-03-30T08:15:00Z", + "mfa": true, + "count": 3, + "properties": { + "action": "describe-cluster", + "source": "kubectl" + } + }, + { + "userId": "u-bob", + "userName": "Bob Smith", + "configName": "prod-eks-cluster", + "configType": "AWS::EKS::Cluster", + "createdAt": "2026-03-29T14:30:00Z", + "mfa": true, + "count": 1 + }, + { + "userId": "u-deploy-bot", + "userName": "deploy-bot", + "configName": "prod-eks-cluster", + "configType": "AWS::EKS::Cluster", + "createdAt": "2026-03-30T06:00:00Z", + "mfa": false, + "count": 12, + "properties": { + "action": "apply", + "source": "argocd" + } + }, + { + "userId": "u-carol", + "userName": "Carol Davis", + "configName": "prod-eks-cluster", + "configType": "AWS::EKS::Cluster", + "createdAt": "2026-03-25T11:00:00Z", + "mfa": false, + "count": 1 + }, + { + "userId": "u-alice", + "userName": "Alice Johnson", + "configName": "mission-control-db", + "configType": "AWS::RDS::Instance", + "createdAt": "2026-03-30T09:00:00Z", + "mfa": true, + "count": 2, + "properties": { + "action": "connect", + "source": "psql" + } + }, + { + "userId": "u-stale", + "userName": "Former Employee", + "configName": "prod-eks-cluster", + "configType": "AWS::EKS::Cluster", + "createdAt": "2025-06-15T10:00:00Z", + "mfa": false, + "count": 1 + } + ], + "entries": [ + { + "configItem": { + "id": "cfg-deploy-001", + "name": "incident-commander", + "type": "Kubernetes::Deployment", + "status": "Running", + "health": "healthy" + }, + "changeCount": 5, + "insightCount": 3, + "accessCount": 2, + "changes": [], + "analyses": [], + "access": [], + "accessLogs": [] + }, + { + "configItem": { + "id": "cfg-deploy-002", + "name": "canary-checker", + "type": "Kubernetes::Deployment", + "status": "Running", + "health": "healthy" + }, + "changeCount": 2, + "insightCount": 1, + "accessCount": 1, + "changes": [], + "analyses": [], + "access": [], + "accessLogs": [] + }, + { + "configItem": { + "id": "cfg-deploy-003", + "name": "config-db", + "type": "Kubernetes::Deployment", + "status": "Running", + "health": "unhealthy" + }, + "changeCount": 1, + "insightCount": 2, + "accessCount": 0, + "changes": [], + "analyses": [], + "access": [], + "accessLogs": [] + } + ], + "changes": [ + { + "id": "art-001", + "configID": "cfg-deploy-001", + "configName": "incident-commander", + "configType": "Kubernetes::Deployment", + "changeType": "diff", + "severity": "medium", + "source": "argocd", + "summary": "Deployment spec updated with new resource limits", + "createdAt": "2026-03-30T08:15:00Z", + "artifacts": [ + { + "id": "a-001", + "filename": "diff-screenshot.png", + "contentType": "image/png", + "size": 45000, + "dataUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + ] + }, + { + "id": "art-002", + "configID": "cfg-deploy-001", + "configName": "incident-commander", + "configType": "Kubernetes::Deployment", + "changeType": "PolicyUpdate", + "severity": "high", + "source": "argocd", + "summary": "Network policy tightened for egress", + "createdAt": "2026-03-29T14:00:00Z", + "artifacts": [ + { + "id": "a-002", + "filename": "policy-diff.yaml", + "contentType": "text/yaml", + "size": 1200 + } + ] + } + ], + "audit": { + "buildCommit": "3b3a1a0f", + "buildVersion": "v1.47.0", + "gitStatus": " M report/catalog/report.go\n M db/rbac.go", + "options": { + "title": "Production EKS Cluster Report", + "since": "720h", + "sections": { + "changes": true, + "insights": true, + "relationships": true, + "access": true, + "accessLogs": true, + "configJSON": false + }, + "recursive": true, + "groupBy": "type", + "changeArtifacts": true, + "thresholds": { + "staleDays": 90, + "reviewOverdueDays": 180 + }, + "filters": [ + "type=AWS::EKS::Cluster", + "namespace=mc" + ] + }, + "scrapers": [], + "queries": [ + { + "name": "RBACAccess", + "args": "configIDs=1 selectors=0", + "count": 42, + "duration": 128, + "pretty": "SELECT ... FROM config_access_summary WHERE config_id IN (?)" + }, + { + "name": "GroupMembers", + "args": "configIDs=1", + "count": 5, + "duration": 34, + "pretty": "SELECT ... FROM external_user_groups eug JOIN external_groups eg ..." + } + ], + "groups": [ + { + "id": "grp-admins", + "name": "mission-control-admins", + "groupType": "group", + "members": [ + { + "userId": "u-alice", + "name": "Alice Johnson", + "email": "alice@flanksource.com", + "userType": "user", + "lastSignedInAt": "2026-03-30T08:00:00Z", + "membershipAddedAt": "2025-01-10T09:00:00Z" + }, + { + "userId": "u-bob", + "name": "Bob Smith", + "email": "bob@flanksource.com", + "userType": "user", + "lastSignedInAt": "2026-03-28T14:00:00Z", + "membershipAddedAt": "2025-06-01T09:00:00Z" + }, + { + "userId": "u-stale", + "name": "Former Employee", + "email": "former@flanksource.com", + "userType": "user", + "lastSignedInAt": "2025-06-15T10:00:00Z", + "membershipAddedAt": "2024-06-01T09:00:00Z", + "membershipDeletedAt": "2025-07-01T09:00:00Z" + } + ] + }, + { + "id": "grp-readers", + "name": "mission-control-readers", + "groupType": "group", + "members": [ + { + "userId": "u-carol", + "name": "Carol Davis", + "email": "carol@flanksource.com", + "userType": "user", + "membershipAddedAt": "2026-01-10T09:00:00Z" + }, + { + "userId": "u-deploy-bot", + "name": "deploy-bot", + "email": "deploy-bot@flanksource.com", + "userType": "service_account", + "lastSignedInAt": "2026-03-30T06:00:00Z", + "membershipAddedAt": "2025-08-01T09:00:00Z" + } + ] + } + ] + } + }, + "viewReport": { + "name": "cluster-overview", + "title": "Cluster Overview", + "icon": "AWS::EKS::Cluster", + "columns": [ + { + "name": "name", + "type": "string" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "health", + "type": "health" + }, + { + "name": "status", + "type": "status" + }, + { + "name": "cpu", + "type": "gauge", + "gauge": { + "thresholds": [ + { + "percent": 0, + "color": "#22C55E" + }, + { + "percent": 70, + "color": "#EAB308" + }, + { + "percent": 90, + "color": "#EF4444" + } + ] + } + }, + { + "name": "memory", + "type": "bytes" + }, + { + "name": "uptime", + "type": "duration" + }, + { + "name": "requests", + "type": "number", + "unit": "req/s" + }, + { + "name": "ready", + "type": "boolean" + }, + { + "name": "last_seen", + "type": "datetime" + }, + { + "name": "labels", + "type": "labels" + } + ], + "rows": [ + [ + "incident-commander", + "Deployment", + "healthy", + "Running", + 42.5, + 536870912, + 86400000000000, + 1240, + true, + "2026-03-30T08:00:00Z", + { + "app": "incident-commander", + "env": "production" + } + ], + [ + "canary-checker", + "Deployment", + "healthy", + "Running", + 18.3, + 268435456, + 172800000000000, + 450, + true, + "2026-03-30T08:00:00Z", + { + "app": "canary-checker", + "env": "production" + } + ], + [ + "config-db", + "Deployment", + "unhealthy", + "CrashLoopBackOff", + 92.1, + 1073741824, + 3600000000000, + 0, + false, + "2026-03-30T07:45:00Z", + { + "app": "config-db", + "env": "production" + } + ], + [ + "cert-manager", + "Deployment", + "healthy", + "Running", + 5.2, + 134217728, + 604800000000000, + 12, + true, + "2026-03-30T08:00:00Z", + { + "app": "cert-manager" + } + ], + [ + "nginx-ingress", + "Deployment", + "warning", + "Running", + 78.9, + 805306368, + 259200000000000, + 3200, + true, + "2026-03-30T08:00:00Z", + { + "app": "nginx-ingress", + "env": "production" + } + ] + ], + "panels": [ + { + "name": "Total Requests", + "type": "number", + "number": { + "unit": "req/s" + }, + "rows": [ + { + "value": 4902 + } + ] + }, + { + "name": "CPU Utilization", + "type": "gauge", + "gauge": { + "unit": "%", + "thresholds": [ + { + "percent": 0, + "color": "#22C55E" + }, + { + "percent": 70, + "color": "#EAB308" + }, + { + "percent": 90, + "color": "#EF4444" + } + ] + }, + "rows": [ + { + "value": 47.4 + } + ] + }, + { + "name": "Pod Distribution", + "type": "piechart", + "piechart": { + "showLabels": true, + "colors": { + "healthy": "#22C55E", + "warning": "#EAB308", + "unhealthy": "#EF4444" + } + }, + "rows": [ + { + "name": "healthy", + "value": 12 + }, + { + "name": "warning", + "value": 3 + }, + { + "name": "unhealthy", + "value": 1 + } + ] + }, + { + "name": "Memory by Service", + "type": "bargauge", + "bargauge": { + "unit": "bytes", + "max": 2147483648, + "thresholds": [ + { + "percent": 0, + "color": "#3B82F6" + }, + { + "percent": 70, + "color": "#EAB308" + }, + { + "percent": 90, + "color": "#EF4444" + } + ] + }, + "rows": [ + { + "name": "incident-commander", + "value": 536870912 + }, + { + "name": "config-db", + "value": 1073741824 + }, + { + "name": "nginx-ingress", + "value": 805306368 + }, + { + "name": "canary-checker", + "value": 268435456 + } + ] + }, + { + "name": "Cluster Status", + "type": "text", + "rows": [ + { + "value": "All critical services operational. config-db pod in CrashLoopBackOff - investigating OOM kills." + } + ] + }, + { + "name": "Recent Deployments", + "type": "table", + "rows": [ + { + "service": "incident-commander", + "version": "v1.4.200", + "deployed": "2026-03-30T08:15:00Z", + "status": "success" + }, + { + "service": "canary-checker", + "version": "v1.0.350", + "deployed": "2026-03-29T12:00:00Z", + "status": "success" + }, + { + "service": "config-db", + "version": "v2.1.0", + "deployed": "2026-03-30T07:30:00Z", + "status": "failed" + } + ] + } + ] + }, + "dynamicSections": [ + { + "type": "changes", + "title": "Permissions Added / Removed", + "changes": [ + { + "id": "rbac-001", + "date": "2026-03-30T09:12:00Z", + "changeType": "PermissionAdded", + "source": "azure-entra", + "createdBy": "alice@flanksource.com", + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "permission": { + "user": "alice@flanksource.com", + "role": "db_owner", + "group": "incident-responders" + }, + "description": "PermissionAdded: user alice@flanksource.com, role db_owner, group incident-responders", + "status": "info", + "createdAt": "2026-03-30T09:12:00Z" + }, + { + "id": "rbac-002", + "date": "2026-03-29T18:40:00Z", + "changeType": "PermissionRemoved", + "source": "azure-entra", + "createdBy": "security-automation", + "configId": "cfg-sql-001", + "configName": "prod-sql-primary", + "configType": "MSSQL::Database", + "permission": { + "user": "contractor-temp", + "role": "db_datareader" + }, + "description": "PermissionRemoved: user contractor-temp, role db_datareader", + "status": "info", + "createdAt": "2026-03-29T18:40:00Z" + }, + { + "id": "rbac-006", + "date": "2026-03-29T11:15:00Z", + "changeType": "PermissionAdded", + "source": "okta", + "createdBy": "governance-bot", + "configId": "cfg-keyvault-001", + "configName": "prod-keyvault", + "configType": "Azure::KeyVault", + "permission": { + "user": "ops-auditor", + "role": "Secrets Reader" + }, + "description": "PermissionAdded: user ops-auditor, role Secrets Reader", + "status": "info", + "createdAt": "2026-03-29T11:15:00Z" + }, + { + "id": "rbac-004", + "date": "2026-03-28T13:05:00Z", + "changeType": "PermissionGranted", + "source": "okta", + "createdBy": "bob@flanksource.com", + "configId": "cfg-analytics-001", + "configName": "analytics-db", + "configType": "MSSQL::Database", + "description": "Granted db_ddladmin to deploy-bot on analytics-db", + "status": "info", + "createdAt": "2026-03-28T13:05:00Z" + }, + { + "id": "rbac-005", + "date": "2026-03-27T07:20:00Z", + "changeType": "PermissionAdded", + "source": "okta", + "configId": "cfg-keyvault-001", + "configName": "prod-keyvault", + "configType": "Azure::KeyVault", + "permission": { + "group": "break-glass-admins", + "role": "Secrets Officer" + }, + "description": "PermissionAdded: role Secrets Officer, group break-glass-admins", + "status": "info", + "createdAt": "2026-03-27T07:20:00Z" + } + ] + }, + { + "type": "changes", + "title": "Backup Activity", + "changes": [ + { + "id": "bak-001", + "date": "2026-03-30T02:00:00Z", + "changeType": "BackupStarted", + "source": "aws-backup", + "description": "Nightly snapshot started for incident-commander-db", + "status": "info", + "createdAt": "2026-03-30T02:00:00Z" + }, + { + "id": "bak-002", + "date": "2026-03-30T02:08:00Z", + "changeType": "BackupCompleted", + "source": "aws-backup", + "description": "Nightly snapshot completed for incident-commander-db (4.3 GB)", + "status": "info", + "createdAt": "2026-03-30T02:08:00Z" + }, + { + "id": "bak-003", + "date": "2026-03-29T02:01:00Z", + "changeType": "BackupFailed", + "source": "aws-backup", + "description": "Snapshot failed for incident-commander-db after storage timeout", + "status": "high", + "createdAt": "2026-03-29T02:01:00Z" + } + ] + }, + { + "type": "changes", + "title": "Deployment Changes", + "changes": [ + { + "id": "dep-001", + "date": "2026-03-30T08:15:00Z", + "changeType": "diff", + "source": "argocd", + "createdBy": "deploy-bot", + "description": "Deployment incident-commander image updated: v1.4.199 -> v1.4.200", + "status": "low", + "createdAt": "2026-03-30T08:15:00Z" + }, + { + "id": "dep-002", + "date": "2026-03-30T07:30:00Z", + "changeType": "Pulled", + "source": "kubernetes", + "description": "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42", + "status": "info", + "createdAt": "2026-03-30T07:30:00Z" + }, + { + "id": "dep-003", + "date": "2026-03-29T22:00:00Z", + "changeType": "ScalingReplicaSet", + "source": "kubernetes", + "createdBy": "cluster-autoscaler", + "description": "Deployment incident-commander scaled from 2 to 4 replicas", + "status": "low", + "createdAt": "2026-03-29T22:00:00Z" + } + ] + } + ] +} diff --git a/report/kitchen-sink/ApplicationPage.tsx b/report/kitchen-sink/ApplicationPage.tsx new file mode 100644 index 000000000..bb2fe4e68 --- /dev/null +++ b/report/kitchen-sink/ApplicationPage.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import ApplicationDetails from '../components/ApplicationDetails.tsx'; +import AccessControlSection from '../components/AccessControlSection.tsx'; +import IncidentsSection from '../components/IncidentsSection.tsx'; +import BackupsSection from '../components/BackupsSection.tsx'; +import FindingsSection from '../components/FindingsSection.tsx'; +import LocationsSection from '../components/LocationsSection.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function ApplicationPage({ data, pageProps }: Props) { + const app = data.application; + if (!app) return null; + + return ( + +
+
+ Components used in the Application report: details, access control, incidents, backups, findings, and locations. +
+
+ + + + + + + +
+ ); +} diff --git a/report/kitchen-sink/CatalogPage.tsx b/report/kitchen-sink/CatalogPage.tsx new file mode 100644 index 000000000..307ba6c21 --- /dev/null +++ b/report/kitchen-sink/CatalogPage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import ConfigTreeSection from '../components/ConfigTreeSection.tsx'; +import CatalogAccessSection from '../components/CatalogAccessSection.tsx'; +import CatalogAccessLogsSection from '../components/CatalogAccessLogsSection.tsx'; +import CatalogList from '../components/CatalogList.tsx'; +import ArtifactAppendix from '../components/ArtifactAppendix.tsx'; +import AuditPage from '../components/AuditPage.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function CatalogPage({ data, pageProps }: Props) { + const catalog = data.catalogReport; + if (!catalog) return null; + + return ( + +
+
+ Components used in the Catalog report: config tree, access control, access logs, catalog list, artifact appendix, and the --audit page (build metadata, queries, scrapers, and group membership). +
+
+ + {catalog.relationshipTree && ( + + )} + + + + + + + {catalog.audit && ( +
+
+ The --audit page: build/options metadata, queries, scrapers, and group membership for every external group referenced by config_access on the reported configs. +
+ +
+ )} +
+ ); +} diff --git a/report/kitchen-sink/ChangesPage.tsx b/report/kitchen-sink/ChangesPage.tsx new file mode 100644 index 000000000..0176ba2ec --- /dev/null +++ b/report/kitchen-sink/ChangesPage.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import type { CatalogReportCategoryMapping } from '../catalog-report-types.ts'; +import ConfigChangesSection from '../components/ConfigChangesSection.tsx'; +import ConfigChangesExamples from '../components/ConfigChangesExamples.tsx'; +import RBACChanges from '../components/RBACChanges.tsx'; +import BackupChanges from '../components/BackupChanges.tsx'; +import DeploymentChanges from '../components/DeploymentChanges.tsx'; +import { categorizeChanges, configChangeToApplicationChange } from '../components/change-section-utils.ts'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function ChangesPage({ data, pageProps }: Props) { + const allChanges = data.changes ?? []; + const schemaExampleChanges = allChanges.filter((change) => change.source === 'schema-examples'); + const demoChanges = allChanges.filter((change) => change.source !== 'schema-examples'); + const rbacChanges = data.rbacChanges ?? []; + const backupChanges = data.backupChanges ?? []; + const deploymentChanges = data.deploymentChanges ?? []; + const categoryMappings = (data as any).categoryMappings as CatalogReportCategoryMapping[] | undefined; + const categorized = categorizeChanges(demoChanges, categoryMappings); + + return ( + + + + {schemaExampleChanges.length > 0 && ( +
+
+ Full coverage for every standalone example in the duty handwritten change-types schema. Generated via make report/kitchen-sink.json and rendered once here in schema order. +
+ +
+ )} + +
+
+ A single changes array auto-split into specialized sections using categoryMappings. RBAC, backup, and deployment changes get their own renderers; the rest falls through to ConfigChangesSection. +
+ {categorized.rbac.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {categorized.backup.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {categorized.deployment.length > 0 && ( +
+ configChangeToApplicationChange(change))} /> +
+ )} + {categorized.uncategorized.length > 0 && ( + + )} +
+ + + +
+
+ Groups permission changes by date and resource, shows config type icons in resource headers, and renders compact granted/revoked audit rows with role, principal, timestamp, and changed-by attribution. +
+ +
+ +
+
+ Backup calendar/heatmap pattern with event stream. Filters out non-backup change types. +
+ +
+ +
+
+ Highlights deployment-relevant spec, scaling, and policy changes. +
+ +
+
+ ); +} diff --git a/report/kitchen-sink/ConfigComponentsPage.tsx b/report/kitchen-sink/ConfigComponentsPage.tsx new file mode 100644 index 000000000..cf0554dcb --- /dev/null +++ b/report/kitchen-sink/ConfigComponentsPage.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import ConfigLink from '../components/ConfigLink.tsx'; +import ConfigItemCard from '../components/ConfigItemCard.tsx'; +import ScraperCard from '../components/ScraperCard.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function ConfigComponentsPage({ data, pageProps }: Props) { + const sampleConfigs = [data.configItem, ...data.relatedConfigs.slice(0, 5)]; + const scrapers = data.scrapers ?? []; + + return ( + +
+
+ Renders a config item as Icon + Name with optional health indicator. +
+
+ {sampleConfigs.map((config) => ( +
+
+ +
+
+ +
+ {config.type} +
+ ))} +
+
+ +
+
+ Renders a config item with icon, name, tags, and metadata. +
+
+ {sampleConfigs.map((config) => ( +
+ +
+ ))} +
+
+ +
+
+ Renders a scraper with type icons, source badge, spec hash, created by, dates, and GitOps provenance. +
+
+ {scrapers.map((scraper) => ( + + ))} +
+
+
+ ); +} diff --git a/report/kitchen-sink/DynamicSectionsPage.tsx b/report/kitchen-sink/DynamicSectionsPage.tsx new file mode 100644 index 000000000..9b0694c54 --- /dev/null +++ b/report/kitchen-sink/DynamicSectionsPage.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import DynamicSection from '../components/DynamicSection.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function DynamicSectionsPage({ data, pageProps }: Props) { + const dynamicSections = data.dynamicSections ?? []; + + return ( + +
+
+ DynamicSection chooses the specialized renderer from the section title and change type mix, including grouped RBAC sections with date buckets and resource icons. +
+
+ + {dynamicSections.map((section, index) => ( + + ))} + + {data.genericChangesSection && ( + <> +
+
+ When changes don't match RBAC/backup/deployment patterns, falls back to a generic table. +
+
+ + + )} + + {data.dynamicViewSection && ( + <> +
+
+ DynamicSection with type='view' renders a table with typed columns. +
+
+ + + )} + + {data.dynamicConfigsSection && ( + <> +
+
+ DynamicSection with type='configs' renders a config list table. +
+
+ + + )} +
+ ); +} diff --git a/report/kitchen-sink/InsightsAndGraphPage.tsx b/report/kitchen-sink/InsightsAndGraphPage.tsx new file mode 100644 index 000000000..bc69af297 --- /dev/null +++ b/report/kitchen-sink/InsightsAndGraphPage.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Page, Section, MatrixTable, Dot } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import ConfigInsightsSection from '../components/ConfigInsightsSection.tsx'; +import ConfigRelationshipGraph from '../components/ConfigRelationshipGraph.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function InsightsAndGraphPage({ data, pageProps }: Props) { + return ( + + + + + +
+
+ Rotated column headers using CSS-Tricks translate+rotate pattern. +
+ alice@example.com, cells: [, , , , null, null] }, + { label: bob@example.com, cells: [, , null, null, null, null] }, + { label: charlie@example.com, cells: [, null, null, null, null, ] }, + { label: deploy-bot, cells: [, , , null, null, null] }, + { label: monitoring-svc, cells: [, null, null, null, null, ] }, + ]} + /> +
+ With longer column names and more rows. +
+ design-studio-pas, cells: [null, null, , null, null, null, null] }, + { label: monitoring_ro, cells: [, null, null, null, null, null, null] }, + { label: oipa-qa-bot, cells: [null, null, , null, null, null, null] }, + { label: omasa, cells: [null, null, , null, null, null, null] }, + { label: SG-OMAR Shared Dev DB, cells: [null, , null, null, , null, null] }, + { label: SG-OMAR Shared RO, cells: [, null, null, null, null, null, ] }, + { label: svc_mission_control, cells: [, , null, null, null, null, null] }, + ]} + /> +
+
+ ); +} diff --git a/report/kitchen-sink/KitchenSinkTypes.ts b/report/kitchen-sink/KitchenSinkTypes.ts new file mode 100644 index 000000000..2feffa61e --- /dev/null +++ b/report/kitchen-sink/KitchenSinkTypes.ts @@ -0,0 +1,21 @@ +import type { ConfigReportData } from '../config-types.ts'; +import type { ApplicationChange, ApplicationSection, Application } from '../types.ts'; +import type { RBACReport } from '../rbac-types.ts'; +import type { CatalogReportData } from '../catalog-report-types.ts'; +import type { ViewReportData } from '../view-types.ts'; +import type { ScraperInfo } from '../scraper-types.ts'; + +export interface KitchenSinkData extends ConfigReportData { + rbacChanges?: ApplicationChange[]; + backupChanges?: ApplicationChange[]; + deploymentChanges?: ApplicationChange[]; + dynamicSections?: ApplicationSection[]; + genericChangesSection?: ApplicationSection; + dynamicViewSection?: ApplicationSection; + dynamicConfigsSection?: ApplicationSection; + scrapers?: ScraperInfo[]; + application?: Application; + rbacReport?: RBACReport; + catalogReport?: Partial; + viewReport?: ViewReportData; +} diff --git a/report/kitchen-sink/LayoutComponentsPage.tsx b/report/kitchen-sink/LayoutComponentsPage.tsx new file mode 100644 index 000000000..925726d1b --- /dev/null +++ b/report/kitchen-sink/LayoutComponentsPage.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import CoverPage from '../components/CoverPage.tsx'; +import PageHeaderComponent from '../components/PageHeader.tsx'; +import PageFooterComponent from '../components/PageFooter.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function LayoutComponentsPage({ data, pageProps }: Props) { + const config = data.configItem; + + return ( + +
+
+ Reusable cover page component with title, icon, breadcrumbs, subjects, tags, stats, and date range. +
+
+ +
+
+ +
+
+ Logo-based header bar with optional subtitle. +
+
+
+ +
+
+ +
+
+
+ +
+
+ Footer with generation timestamp and optional public URL. +
+
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/report/kitchen-sink/RBACPage.tsx b/report/kitchen-sink/RBACPage.tsx new file mode 100644 index 000000000..d5d1c84ea --- /dev/null +++ b/report/kitchen-sink/RBACPage.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import RBACCoverContent from '../components/RBACCoverContent.tsx'; +import RBACSummarySection from '../components/RBACSummarySection.tsx'; +import RBACMatrixSection from '../components/RBACMatrixSection.tsx'; +import RBACUserSection from '../components/RBACUserSection.tsx'; +import RBACChangelogSection from '../components/RBACChangelogSection.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function RBACPage({ data, pageProps }: Props) { + const report = data.rbacReport; + if (!report) return null; + + return ( + +
+
+ Components used in the RBAC reports: cover, summary, matrix, per-user view, and changelog. +
+
+ +
+
+ RBAC-specific cover page with subject, breadcrumbs, and summary stats. +
+
+ +
+
+ + + + {(report.resources || []).map((resource) => ( + + ))} + + {(report.users || []).map((user) => ( +
+ +
+ ))} + + +
+ ); +} diff --git a/report/kitchen-sink/ViewPage.tsx b/report/kitchen-sink/ViewPage.tsx new file mode 100644 index 000000000..351823d56 --- /dev/null +++ b/report/kitchen-sink/ViewPage.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Page, Section } from '@flanksource/facet'; +import type { KitchenSinkData } from './KitchenSinkTypes.ts'; +import ViewResultSection from '../components/ViewResultSection.tsx'; + +interface Props { + data: KitchenSinkData; + pageProps: any; +} + +export default function ViewPage({ data, pageProps }: Props) { + const viewReport = data.viewReport; + if (!viewReport) return null; + + return ( + +
+
+ Renders view data with typed columns (string, number, boolean, datetime, duration, + health, status, gauge, bytes, millicore, config_item, labels) and panels + (number, gauge, bargauge, piechart, table, text). +
+
+ +
+ ); +} diff --git a/report/mission-control.ts b/report/mission-control.ts index 46a63014f..ee1bda842 100644 --- a/report/mission-control.ts +++ b/report/mission-control.ts @@ -6,6 +6,10 @@ import type { Application } from './types.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const raw = readFileSync(resolve(__dirname, 'fixtures/mission-control.yaml'), 'utf-8'); -const data = yaml.load(raw) as Application; +export const data = yaml.load(raw) as Application; export default data; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.stdout.write(JSON.stringify(data)); +} diff --git a/report/package-lock.json b/report/package-lock.json index 2bed371a1..4f83249a7 100644 --- a/report/package-lock.json +++ b/report/package-lock.json @@ -8,8 +8,18 @@ "name": "application-report", "version": "1.0.0", "dependencies": { - "@flanksource/facet": "^0.1.10", + "@flanksource/facet": "^0.1.38", "@flanksource/icons": "^1.0.53", + "@iconify-json/carbon": "^1.2.0", + "@iconify-json/fluent": "^1.2.0", + "@iconify-json/iconoir": "^1.2.0", + "@iconify-json/lucide": "^1.2.0", + "@iconify-json/mdi": "^1.2.0", + "@iconify-json/ph": "^1.2.0", + "@iconify-json/ri": "^1.2.0", + "@iconify-json/tabler": "^1.2.0", + "@iconify-json/vscode-icons": "^1.2.45", + "@iconify/react": "^5.1.0", "js-yaml": "^4.1.0" }, "devDependencies": { @@ -20,17 +30,8 @@ "vite": ">=7.3.2" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "../../../facet": { + "extraneous": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", @@ -39,6 +40,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -55,6 +57,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -71,6 +74,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -87,6 +91,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -103,6 +108,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -119,6 +125,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -135,6 +142,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -151,6 +159,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -167,6 +176,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -183,6 +193,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -199,6 +210,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -215,6 +227,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -231,6 +244,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -247,6 +261,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -263,6 +278,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -279,6 +295,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -295,6 +312,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -311,6 +329,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -327,6 +346,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -343,6 +363,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -359,6 +380,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -375,6 +397,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -391,6 +414,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -407,6 +431,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -423,6 +448,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,6 +465,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -449,33 +476,28 @@ } }, "node_modules/@flanksource/facet": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@flanksource/facet/-/facet-0.1.10.tgz", - "integrity": "sha512-5bIjOjxWo++YF8/Cq6XTlPSBe8M4/IdJqYszJ1ejlkFIy+kmyUFgYFv3z/s862Xi20kuMg2lDixcX+OsCYR6KQ==", + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@flanksource/facet/-/facet-0.1.38.tgz", + "integrity": "sha512-mn1o+lCeG2zObz32W2MB6miA+FR8RJq9scAMmwx6qzKmW4bYy3ZnymDA3ChhpYERKD2cB+64u9pwgeVx3somiw==", "dependencies": { - "@flanksource/icons": "^1.0.41", + "@flanksource/icons": "^1.0.53", "@iconify/react": "^5.1.0", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.17", "@xyflow/react": "^12.0.0", - "chalk": "^5.6.2", "clsx": "^2.1.1", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "dagre": "^0.8.5", "dayjs": "^1.11.13", - "ora": "^8.2.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "pdf-lib": "^1.17.1", "react-icons": "^5.4.0", "shiki": "^1.0.0" }, - "bin": { - "facet": "cli/dist/cli.mjs" - }, "engines": { "node": ">=20.19" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/@flanksource/icons": { @@ -487,6 +509,87 @@ "react": "*" } }, + "node_modules/@iconify-json/carbon": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@iconify-json/carbon/-/carbon-1.2.20.tgz", + "integrity": "sha512-wqyxKEbIRdzGdfCAwQqn8iSfO6jx0m1toZAAQdx1NFjxd6iFl1YY4eKI1woWt7XOxs7s7phMW530kDD867JZGw==", + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/fluent": { + "version": "1.2.45", + "resolved": "https://registry.npmjs.org/@iconify-json/fluent/-/fluent-1.2.45.tgz", + "integrity": "sha512-gdlHc/HpvogYxocfCCg46V3A0wtAsGVBRr3FtTTmUnUyF4kaTnYe2cxdPOUj+gUBK8PZlvXqTmrQ5GTt20f4Jw==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/iconoir": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@iconify-json/iconoir/-/iconoir-1.2.10.tgz", + "integrity": "sha512-NnbdB9S5G++6wE5aEZhzpFR0HRcaZFSbJJIHOGF2axaNVKnSUs4NBW2z0uhZnM00iUkiK848Sp81EZPg52DL+w==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/lucide": { + "version": "1.2.102", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.102.tgz", + "integrity": "sha512-Dm3EEqu5NrmzyDMB2U1+8yroEj2/dB9V4KlH0m/szwwF/ofSf0cPaGTZqkd1aExXjCor+vU53ttRMCGuXf+/cg==", + "license": "ISC", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/mdi": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz", + "integrity": "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg==", + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/ph": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.2.2.tgz", + "integrity": "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/ri": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@iconify-json/ri/-/ri-1.2.10.tgz", + "integrity": "sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==", + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/tabler": { + "version": "1.2.33", + "resolved": "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.33.tgz", + "integrity": "sha512-q9nUQfE/cjIrGh5bAKHTphitAZpT0kX9SxDgZo3Sx8ofeDTsaHVdRwrn+CfKiJ5vQ1b1btqVwizXzIgz9KEPjA==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/vscode-icons": { + "version": "1.2.45", + "resolved": "https://registry.npmjs.org/@iconify-json/vscode-icons/-/vscode-icons-1.2.45.tgz", + "integrity": "sha512-ow+ueibMIq79ueM1kv6cOWgHx8jfh1XJQi2RrqMHb4HLbvIBlxpy5PCMvOJXlA68R6fBAHpWQeh6uWx7VKEVsA==", + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/react": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz", @@ -508,400 +611,373 @@ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT" }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "pako": "^1.0.6" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "pako": "^1.0.10" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@shikijs/core": { "version": "1.29.2", @@ -972,375 +1048,94 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.31.1", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "@types/d3-selection": "*" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", - "cpu": [ - "arm64" - ], + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-color": "*" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", - "cpu": [ - "x64" - ], + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-selection": "*" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", - "cpu": [ - "x64" - ], + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", - "cpu": [ - "arm" - ], + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/unist": "*" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", - "cpu": [ - "arm64" - ], + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", - "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "postcss": "^8.5.6", - "tailwindcss": "4.2.1" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" - } - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT", - "peer": true - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" + "dependencies": { + "@types/unist": "*" } }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1358,12 +1153,12 @@ "license": "ISC" }, "node_modules/@xyflow/react": { - "version": "12.10.1", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", - "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.75", + "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -1373,9 +1168,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.75", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", - "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -1390,15 +1185,26 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/argparse": { @@ -1417,18 +1223,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -1455,33 +1249,6 @@ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1501,18 +1268,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -1616,6 +1371,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -1711,9 +1467,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/dequal": { @@ -1725,15 +1481,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1747,35 +1494,17 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/emoji-regex-xs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1817,8 +1546,8 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12.0.0" }, @@ -1835,6 +1564,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1845,23 +1575,11 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-tsconfig": { "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -1870,12 +1588,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", @@ -1940,45 +1652,6 @@ "node": ">=12" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1991,310 +1664,12 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -2405,51 +1780,6 @@ ], "license": "MIT" }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/oniguruma-to-es": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", @@ -2461,39 +1791,29 @@ "regex-recursion": "^5.1.1" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -2507,6 +1827,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2523,25 +1844,12 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">=4" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, "node_modules/property-information": { @@ -2555,39 +1863,44 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.5" } }, "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/regex": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", @@ -2617,34 +1930,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2656,42 +1953,39 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/shiki": { "version": "1.29.2", @@ -2709,27 +2003,6 @@ "@types/hast": "^3.0.4" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -2740,35 +2013,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -2783,46 +2027,12 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": ">=4.0.4" @@ -2844,12 +2054,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2882,7 +2099,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unist-util-is": { @@ -2962,12 +2179,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -3000,8 +2211,8 @@ "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/report/package.json b/report/package.json index 4c6cf2c19..f5d24d9c7 100644 --- a/report/package.json +++ b/report/package.json @@ -9,8 +9,18 @@ "mission-control": "npm run pdf" }, "dependencies": { - "@flanksource/facet": "^0.1.10", + "@flanksource/facet": "^0.1.38", "@flanksource/icons": "^1.0.53", + "@iconify-json/carbon": "^1.2.0", + "@iconify-json/fluent": "^1.2.0", + "@iconify-json/iconoir": "^1.2.0", + "@iconify-json/lucide": "^1.2.0", + "@iconify-json/mdi": "^1.2.0", + "@iconify-json/ph": "^1.2.0", + "@iconify-json/ri": "^1.2.0", + "@iconify-json/tabler": "^1.2.0", + "@iconify-json/vscode-icons": "^1.2.45", + "@iconify/react": "^5.1.0", "js-yaml": "^4.1.0" }, "devDependencies": { diff --git a/report/rbac-types.ts b/report/rbac-types.ts index 56066d8b2..dc42a26cc 100644 --- a/report/rbac-types.ts +++ b/report/rbac-types.ts @@ -16,6 +16,9 @@ export interface RBACResource { configId: string; configName: string; configType: string; + configClass?: string; + parentId?: string; + path?: string; status?: string; health?: string; description?: string; @@ -62,6 +65,8 @@ export interface RBACUserResource { configId: string; configName: string; configType: string; + configClass?: string; + path?: string; role: string; roleSource: string; createdAt: string; @@ -84,10 +89,28 @@ export interface RBACUserReport { resources: RBACUserResource[]; } +export interface ConfigItem { + id: string; + name?: string; + type?: string; + config_class?: string; + status?: string; + health?: string; + description?: string; + path?: string; + parent_id?: string; + tags?: Record; + labels?: Record; + created_at?: string; + updated_at?: string; +} + export interface RBACReport { title: string; query?: string; generatedAt: string; + subject?: ConfigItem; + parents?: ConfigItem[]; resources: RBACResource[]; changelog: RBACChangeEntry[]; summary: RBACSummary; diff --git a/report/sample-findings.json b/report/sample-findings.json new file mode 100644 index 000000000..9be675c1a --- /dev/null +++ b/report/sample-findings.json @@ -0,0 +1,806 @@ +{ + "findings": [ + { + "title": "Brute force attack against sa account from 203.0.113.42", + "severity": "critical", + "platform": "sql-server", + "category": "credential-attack", + "outcome": "page-oncall", + "detection": { + "pattern": "brute-force", + "threshold": ">= 50 failures from single IP in < 1 hour" + }, + "evidence": { + "summary": "203.0.113.42 made 847 failed login attempts against 'sa' in a 23-minute window between 02:14 and 02:37 UTC", + "timeRange": { + "start": "2026-03-15T02:14:00Z", + "end": "2026-03-15T02:37:00Z", + "durationSeconds": 1380 + }, + "metrics": { "eventCount": 847, "failedCount": 847, "successCount": 0, "uniqueIPs": 1 }, + "samples": [ + { + "timestamp": "2026-03-15T02:14:03Z", + "action": "FAILED_LOGIN_GROUP", + "detail": "Login failed for user 'sa'. Reason: Password did not match.", + "succeeded": false, + "src": { + "identity": { "name": "sa", "type": "break-glass" }, + "endpoint": { "ip": "203.0.113.42" } + }, + "dst": { + "resource": { "name": "acme_KEN_app_PROD", "type": "database", "scope": "acmer-prod-mssql" } + } + }, + { + "timestamp": "2026-03-15T02:36:58Z", + "action": "FAILED_LOGIN_GROUP", + "detail": "Login failed for user 'sa'. Reason: Password did not match.", + "succeeded": false, + "src": { + "identity": { "name": "sa", "type": "break-glass" }, + "endpoint": { "ip": "203.0.113.42" } + }, + "dst": { + "resource": { "name": "acme_KEN_app_PROD", "type": "database", "scope": "acmer-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Block 203.0.113.42 at the network level and verify sa account is disabled for remote login", + "mitigations": [ + "Ensure sa account has a strong password and is disabled for network auth", + "Implement account lockout policy after 5 consecutive failures", + "Add IP-based rate limiting at the network/WAF layer" + ] + }, + "context": { + "killChainPhase": "initial-access", + "mitreTechnique": "T1110", + "compliance": ["PCI-DSS 10.2.4", "SOX 404"], + "baseline": { "normalValue": 2, "observedValue": 847, "deviationFactor": 423.5, "baselinePeriod": "previous 4 weeks" } + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["access-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditLogins/2026/03/*/*.parquet", + "query": "SELECT server_principal_name, client_ip, count(*) AS failed_count FROM AuditLogins WHERE succeeded = false GROUP BY server_principal_name, client_ip HAVING failed_count >= 50", + "connection": "connection://monitoring/sql-server", + "app": { "name": "config-db", "version": "1.4.2", "icon": "config-db" }, + "contentSha": "a3f8e2d1b4c6a9e0f7d5b3c1a8e6f4d2b0c7a5e3f1d9b7c5a3e1f0d8b6c4a2" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Credential spraying from 198.51.100.17 targeting 12 accounts", + "severity": "high", + "platform": "sql-server", + "category": "credential-attack", + "outcome": "high-ticket", + "detection": { + "pattern": "credential-spraying", + "threshold": ">= 10 distinct accounts from 1 IP" + }, + "evidence": { + "summary": "198.51.100.17 attempted login to 12 distinct accounts over 45 minutes with 1-2 attempts each, consistent with password spray pattern", + "timeRange": { + "start": "2026-03-18T14:22:00Z", + "end": "2026-03-18T15:07:00Z", + "durationSeconds": 2700 + }, + "metrics": { "eventCount": 18, "failedCount": 18, "successCount": 0, "uniqueIdentities": 12, "uniqueIPs": 1 }, + "samples": [ + { + "timestamp": "2026-03-18T14:22:00Z", + "action": "credential-spraying", + "succeeded": false, + "src": { + "identity": { "name": "jsmith", "type": "human" }, + "endpoint": { "ip": "198.51.100.17" } + } + }, + { + "timestamp": "2026-03-18T14:22:00Z", + "action": "credential-spraying", + "succeeded": false, + "src": { + "identity": { "name": "admin_dba", "type": "admin" }, + "endpoint": { "ip": "198.51.100.17" } + } + }, + { + "timestamp": "2026-03-18T14:22:00Z", + "action": "credential-spraying", + "succeeded": false, + "src": { + "identity": { "name": "svc_app_app", "type": "service-account" }, + "endpoint": { "ip": "198.51.100.17" } + } + } + ] + }, + "recommendation": { + "action": "Investigate source IP 198.51.100.17 and force password reset for all targeted accounts", + "mitigations": [ + "Enable multi-factor authentication for all database accounts", + "Review VPN/network access logs for 198.51.100.17" + ] + }, + "context": { + "killChainPhase": "initial-access", + "mitreTechnique": "T1110.003", + "compliance": ["PCI-DSS 10.2.4"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["access-logs"], + "path": "s3://acmer-app-audit/sql/parquet/AuditLogins/2026/03/*/*.parquet", + "query": "SELECT client_ip, count(DISTINCT server_principal_name) AS targeted FROM AuditLogins WHERE succeeded = false GROUP BY client_ip HAVING targeted >= 3", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "sysadmin role granted to jdoe outside change window", + "severity": "critical", + "platform": "sql-server", + "category": "privilege-escalation", + "outcome": "safety-switch", + "detection": { + "pattern": "role-membership-change", + "threshold": "Any sysadmin role grant" + }, + "evidence": { + "summary": "User admin_dba granted sysadmin role to jdoe at 22:41 UTC on a Tuesday, outside the approved change window (Wed 06:00-10:00 UTC)", + "timeRange": { + "start": "2026-03-11T22:41:00Z", + "end": "2026-03-11T22:41:00Z", + "durationSeconds": 0 + }, + "metrics": { "eventCount": 1, "permissionChanges": 1 }, + "samples": [ + { + "timestamp": "2026-03-11T22:41:12Z", + "action": "SERVER_ROLE_MEMBER_CHANGE_GROUP", + "detail": "ALTER SERVER ROLE [sysadmin] ADD MEMBER [jdoe]", + "succeeded": true, + "src": { + "identity": { "name": "admin_dba", "type": "admin", "displayName": "Database Administrator" }, + "endpoint": { "ip": "10.0.5.23" } + }, + "dst": { + "identity": { "name": "jdoe", "type": "human", "displayName": "John Doe" }, + "resource": { "name": "sysadmin", "type": "role", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Immediately revoke jdoe's sysadmin membership and investigate why it was granted outside change window", + "mitigations": [ + "Require dual-approval for sysadmin role grants", + "Implement change window enforcement via audit alerts" + ] + }, + "context": { + "killChainPhase": "privilege-escalation", + "mitreTechnique": "T1078.004", + "compliance": ["SOX 404", "PCI-DSS 10.2.5", "POPI Act"], + "relatedFindings": ["HOURS-2026-001"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["audit-logs", "roles"], + "path": "s3://acmer-app-audit/sql/parquet/AuditServer/2026/03/*/*.parquet", + "query": "SELECT * FROM AuditServer WHERE containing_group_name = 'SERVER_ROLE_MEMBER_CHANGE_GROUP' AND statement LIKE '%sysadmin%'", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Bulk data extraction by svc_reporting \u2014 2.4M rows in single query", + "severity": "high", + "platform": "sql-server", + "category": "data-exfiltration", + "outcome": "page-oncall", + "detection": { + "pattern": "bulk-data-read", + "threshold": "response_rows > 10x user baseline" + }, + "evidence": { + "summary": "Service account svc_reporting executed SELECT * FROM PolicyHolder returning 2.4M rows at 03:12 UTC. Normal daily volume is ~15K rows.", + "timeRange": { + "start": "2026-03-20T03:12:00Z", + "end": "2026-03-20T03:14:32Z", + "durationSeconds": 152 + }, + "metrics": { "eventCount": 1, "rowsAccessed": 2400000 }, + "samples": [ + { + "timestamp": "2026-03-20T03:12:05Z", + "action": "SELECT", + "detail": "SELECT [PolicyHolderID], [FirstName], [LastName], [IDNumber], [DateOfBirth], [Email], [Phone], [BankAccountNo], [BankBranchCode], [PolicyNumber], [PremiumAmount], [BeneficiaryName], [BeneficiaryID], [MedicalHistory], [ClaimStatus] FROM [dbo].[PolicyHolder] WITH (NOLOCK) WHERE [Status] = 'Active' ORDER BY [PolicyHolderID]", + "succeeded": true, + "src": { + "identity": { "name": "svc_reporting", "type": "service-account" }, + "endpoint": { "ip": "10.0.12.88" }, + "app": { "name": "ReportingETL.exe", "type": "etl-tool" } + }, + "dst": { + "resource": { "name": "PolicyHolder", "type": "database", "scope": "OMA_ZIM_app_PROD" } + } + } + ] + }, + "recommendation": { + "action": "Investigate svc_reporting activity \u2014 verify this query was from an authorized report job, not compromised credentials", + "mitigations": [ + "Restrict svc_reporting to specific columns via views instead of SELECT *", + "Implement row-level security on PolicyHolder table", + "Add query result size limits to the application connection" + ] + }, + "context": { + "killChainPhase": "collection", + "mitreTechnique": "T1530", + "compliance": ["POPI Act", "PCI-DSS 10.2.1"], + "baseline": { "normalValue": 15000, "observedValue": 2400000, "deviationFactor": 160.0, "baselinePeriod": "previous 4 weeks" } + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["audit-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditUserActivity/2026/03/*/*.parquet", + "query": "SELECT server_principal_name, response_rows, statement FROM AuditUserActivity WHERE response_rows > 10000 ORDER BY response_rows DESC", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Break-glass account acmedmin used without incident ticket", + "severity": "critical", + "platform": "sql-server", + "category": "break-glass", + "outcome": "page-oncall", + "detection": { + "pattern": "break-glass-usage", + "threshold": "Any usage of break-glass account" + }, + "evidence": { + "summary": "Break-glass account 'acmedmin' logged in 3 times from 10.0.5.23 on March 22. No corresponding incident ticket found in ServiceNow.", + "timeRange": { + "start": "2026-03-22T09:15:00Z", + "end": "2026-03-22T11:42:00Z", + "durationSeconds": 8820 + }, + "metrics": { "eventCount": 3, "successCount": 3 }, + "samples": [ + { + "timestamp": "2026-03-22T09:15:00Z", + "action": "break-glass-usage", + "succeeded": true, + "src": { + "identity": { "name": "acmedmin", "type": "break-glass" }, + "endpoint": { "ip": "10.0.5.23" } + }, + "dst": { + "resource": { "name": "OMA_KEN_app_PROD", "type": "database", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Investigate who used the acmedmin account and for what purpose. Create retroactive incident ticket.", + "mitigations": [ + "Rotate acmedmin password immediately", + "Require ServiceNow ticket ID before break-glass credentials are issued", + "Implement just-in-time access provisioning" + ] + }, + "context": { + "killChainPhase": "initial-access", + "mitreTechnique": "T1078", + "compliance": ["SOX 404", "King IV"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["access-logs", "audit-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditLogins/2026/03/*/*.parquet", + "query": "SELECT * FROM AuditLogins WHERE server_principal_name IN ('sa', 'acmedmin')", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "SQL Server audit configuration modified by admin_dba", + "severity": "critical", + "platform": "sql-server", + "category": "audit-tampering", + "outcome": "safety-switch", + "detection": { + "pattern": "audit-config-change", + "threshold": "Any audit configuration change" + }, + "evidence": { + "summary": "admin_dba modified the AuditUserActivity server audit at 01:33 UTC, changing the WHERE clause to exclude client_ip LIKE '192.168.%'", + "timeRange": { + "start": "2026-03-25T01:33:00Z", + "end": "2026-03-25T01:33:00Z", + "durationSeconds": 0 + }, + "metrics": { "eventCount": 1 }, + "samples": [ + { + "timestamp": "2026-03-25T01:33:45Z", + "action": "AUDIT_CHANGE_GROUP", + "detail": "ALTER SERVER AUDIT [AuditUserActivity] WITH (STATE = ON, QUEUE_DELAY = 1000, ON_FAILURE = CONTINUE) WHERE ([client_ip] NOT LIKE '192.168.%' AND [server_principal_name] NOT IN ('svc_monitoring', 'svc_backup', 'svc_replication'))", + "succeeded": true, + "src": { + "identity": { "name": "admin_dba", "type": "admin" }, + "endpoint": { "ip": "10.0.5.23" }, + "app": { "name": "sqlcmd", "type": "database-client" } + }, + "dst": { + "resource": { "name": "AuditUserActivity", "type": "audit-type", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Revert the audit configuration change immediately and investigate admin_dba's intent", + "mitigations": [ + "Restrict ALTER SERVER AUDIT permissions to break-glass accounts only", + "Alert on any AUDIT_CHANGE_GROUP event in real-time" + ] + }, + "context": { + "killChainPhase": "persistence", + "mitreTechnique": "T1562.002", + "compliance": ["SOX 404", "PCI-DSS 10.5"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["audit-logs", "configuration"], + "path": "s3://acme-app-audit/sql/parquet/AuditServer/2026/03/*/*.parquet", + "query": "SELECT * FROM AuditServer WHERE containing_group_name IN ('AUDIT_CHANGE_GROUP', 'TRACE_CHANGE_GROUP')", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "After-hours DDL activity by admin_dba \u2014 14 schema changes at 22:30 UTC", + "severity": "high", + "platform": "sql-server", + "category": "after-hours", + "outcome": "high-ticket", + "detection": { + "pattern": "after-hours-ddl", + "threshold": "DDL outside business hours (06:00-16:00 UTC)" + }, + "evidence": { + "summary": "admin_dba executed 14 DDL statements between 22:30 and 23:15 UTC on March 11, including ALTER TABLE and CREATE INDEX operations on production tables", + "timeRange": { + "start": "2026-03-11T22:30:00Z", + "end": "2026-03-11T23:15:00Z", + "durationSeconds": 2700 + }, + "metrics": { "eventCount": 14 }, + "samples": [ + { + "timestamp": "2026-03-11T22:30:00Z", + "action": "after-hours-ddl", + "succeeded": true, + "src": { + "identity": { "name": "admin_dba", "type": "admin", "displayName": "Database Administrator" }, + "endpoint": { "ip": "10.0.5.23" } + }, + "dst": { + "resource": { "name": "acme_KEN_app_PROD", "type": "database", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Verify DDL changes were authorized through change management process", + "mitigations": ["Enforce change windows via database triggers or alerts"] + }, + "context": { + "killChainPhase": "impact", + "compliance": ["SOX 404"], + "relatedFindings": ["PRIV-2026-001"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["audit-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditServer/2026/03/*/*.parquet", + "query": "SELECT * FROM AuditServer WHERE toHour(event_time) NOT BETWEEN 6 AND 16", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "ClusterRoleBinding granting cluster-admin created in production", + "severity": "critical", + "platform": "kubernetes", + "category": "privilege-escalation", + "outcome": "safety-switch", + "detection": { + "pattern": "cluster-admin-binding", + "threshold": "Any cluster-admin ClusterRoleBinding creation" + }, + "evidence": { + "summary": "User devops@acme.com created ClusterRoleBinding 'emergency-access' granting cluster-admin to service account 'debug-sa' in namespace 'default'", + "timeRange": { + "start": "2026-03-19T16:22:00Z", + "end": "2026-03-19T16:22:00Z", + "durationSeconds": 0 + }, + "metrics": { "eventCount": 1, "permissionChanges": 1 }, + "samples": [ + { + "timestamp": "2026-03-19T16:22:00Z", + "action": "create", + "detail": "kubectl create clusterrolebinding emergency-access --clusterrole=cluster-admin --serviceaccount=default:debug-sa", + "succeeded": true, + "src": { + "identity": { "name": "devops@acme.com", "type": "human" }, + "endpoint": { "ip": "10.0.3.45" }, + "app": { "name": "kubectl", "type": "cli" } + }, + "dst": { + "identity": { "name": "system:serviceaccount:default:debug-sa", "type": "service-account" }, + "resource": { "name": "emergency-access", "type": "clusterrolebinding", "scope": "acme-prod" } + } + } + ] + }, + "recommendation": { + "action": "Delete the emergency-access ClusterRoleBinding and investigate why cluster-admin access was needed", + "mitigations": [ + "Use namespace-scoped RoleBindings instead of ClusterRoleBindings", + "Implement OPA/Gatekeeper policy to block cluster-admin bindings" + ] + }, + "context": { + "killChainPhase": "privilege-escalation", + "mitreTechnique": "T1078.004", + "compliance": ["CIS Kubernetes 5.1.1"] + }, + "dataSource": { + "type": "k8s-audit-log", + "categories": ["audit-logs", "roles", "configuration"], + "path": "kubernetes audit log (acme-prod cluster)", + "query": "cat audit.log | jq 'select(.verb==\"create\" and .objectRef.resource==\"clusterrolebindings\")'", + "connection": "omar-prod", + "git": { + "repo": "github.com/acme/infra-k8s", + "file": "clusters/prod/audit-policy.yaml", + "lineNo": 42, + "sha": "e7f3a2b1c9d8e6f4a0b5c3d1", + "branch": "main", + "tag": "v2.1.0" + }, + "app": { "name": "mission-control", "version": "2.3.1", "icon": "mission-control" } + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Security group opened to 0.0.0.0/0 on port 1433", + "severity": "critical", + "platform": "aws", + "category": "network-exposure", + "outcome": "safety-switch", + "detection": { + "pattern": "security-group-exposure", + "threshold": "Inbound 0.0.0.0/0 on ports 22, 1433, 3306, 3389" + }, + "evidence": { + "summary": "IAM user infra-deploy added inbound rule to sg-0a1b2c3d allowing 0.0.0.0/0 on TCP/1433 (SQL Server), exposing the RDS instance to the internet", + "timeRange": { + "start": "2026-03-28T08:45:00Z", + "end": "2026-03-28T08:45:00Z", + "durationSeconds": 0 + }, + "metrics": { "eventCount": 1 }, + "samples": [ + { + "timestamp": "2026-03-28T08:45:00Z", + "action": "AuthorizeSecurityGroupIngress", + "detail": "IpPermissions: [{IpProtocol: tcp, FromPort: 1433, ToPort: 1433, IpRanges: [{CidrIp: 0.0.0.0/0}]}]", + "succeeded": true, + "src": { + "identity": { "name": "arn:aws:iam::123456789012:user/infra-deploy", "type": "service-account" }, + "endpoint": { "ip": "10.0.1.100" }, + "app": { "name": "terraform", "type": "iac-tool" } + }, + "dst": { + "resource": { "name": "sg-0a1b2c3d", "type": "security-group", "scope": "vpc-abc123 (eu-west-1)" } + } + } + ] + }, + "recommendation": { + "action": "Immediately remove the 0.0.0.0/0 ingress rule on port 1433 and restrict to VPC CIDR only", + "mitigations": [ + "Enable AWS Config rule to detect public security group rules", + "Use VPC endpoints or PrivateLink for database access" + ] + }, + "context": { + "killChainPhase": "initial-access", + "mitreTechnique": "T1190", + "compliance": ["CIS AWS 5.2", "PCI-DSS 1.3.1"] + }, + "dataSource": { + "type": "cloudtrail-athena", + "categories": ["audit-logs", "configuration"], + "path": "CloudTrail (eu-west-1)", + "query": "SELECT * FROM cloudtrail_logs WHERE eventName = 'AuthorizeSecurityGroupIngress'", + "connection": "123456789012", + "app": { "name": "aws-scraper", "version": "3.0.1", "icon": "aws" }, + "contentSha": "b7c4d2e0f8a6b3c1d9e5f2a0c8d4e1b6a3f7c0d5e9b2a4f6c8d1e3b5a7f0c2" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Privilege accumulation \u2014 23 grants with 0 revokes over 4 weeks", + "severity": "medium", + "platform": "sql-server", + "category": "privilege-accumulation", + "outcome": "low-ticket", + "detection": { + "pattern": "grant-revoke-imbalance", + "threshold": "Net positive accumulation > 4 consecutive weeks" + }, + "evidence": { + "summary": "Over the past 4 weeks, 23 GRANT operations and 0 REVOKE operations were recorded across 3 databases, indicating unchecked privilege creep", + "timeRange": { + "start": "2026-03-01T00:00:00Z", + "end": "2026-03-31T23:59:59Z", + "durationSeconds": 2678399 + }, + "metrics": { "eventCount": 23, "permissionChanges": 23, "uniqueResources": 3 }, + "samples": [ + { + "timestamp": "2026-03-01T00:00:00Z", + "action": "grant-revoke-imbalance", + "src": { + "identity": { "name": "admin_dba", "type": "admin" } + }, + "dst": { + "resource": { "name": "acme_KEN_app_PROD", "type": "database", "scope": "acme-prod-mssql" } + } + }, + { + "timestamp": "2026-03-01T00:00:00Z", + "action": "grant-revoke-imbalance", + "src": { + "identity": { "name": "lead_dev", "type": "human" } + }, + "dst": { + "resource": { "name": "acme_ZIM_app_PROD", "type": "database", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Conduct a privilege review across all three databases \u2014 identify and revoke unnecessary grants", + "mitigations": [ + "Schedule quarterly access reviews", + "Implement role-based access with defined permission sets" + ] + }, + "context": { + "killChainPhase": "privilege-escalation", + "compliance": ["SOX 404", "King IV"] + }, + "dataSource": { + "type": "file", + "categories": ["roles", "users"], + "path": "Audit Reports/Q1-2026/Q1-2026-Access-Review.xlsx", + "app": { "name": "access-reviewer", "version": "1.0.0", "icon": "file" }, + "contentSha": "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "file": { + "name": "Q1-2026-Access-Review.xlsx", + "size": "2.4 MB", + "created": "2026-03-01T08:00:00Z", + "modified": "2026-03-31T16:45:00Z", + "location": "sharepoint", + "host": "acme.sharepoint.com/sites/SecurityTeam" + } + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "Service account svc_app_app connected via SSMS from developer workstation", + "severity": "high", + "platform": "sql-server", + "category": "service-account-misuse", + "outcome": "high-ticket", + "detection": { + "pattern": "interactive-service-account", + "threshold": "Service account used with interactive tool" + }, + "evidence": { + "summary": "svc_app_app connected using SQL Server Management Studio from IP 10.0.8.77 (developer subnet) 7 times over 2 hours", + "timeRange": { + "start": "2026-03-26T13:10:00Z", + "end": "2026-03-26T15:22:00Z", + "durationSeconds": 7920 + }, + "metrics": { "eventCount": 7, "successCount": 7, "uniqueIPs": 1 }, + "samples": [ + { + "timestamp": "2026-03-26T13:10:00Z", + "action": "LOGIN", + "succeeded": true, + "src": { + "identity": { "name": "svc_app_app", "type": "service-account" }, + "endpoint": { "ip": "10.0.8.77", "type": "workstation", "network": "developer-subnet" }, + "app": { "name": "SQL Server Management Studio", "type": "database-client" } + }, + "dst": { + "endpoint": { "ip": "10.0.2.15", "type": "server", "hostname": "acme-prod-mssql" }, + "resource": { "name": "acme_KEN_app_PROD", "type": "database", "scope": "acme-prod-mssql" } + } + }, + { + "timestamp": "2026-03-26T14:05:00Z", + "action": "SELECT", + "detail": "SELECT TOP 100 * FROM [dbo].[Claims] ORDER BY ClaimDate DESC", + "succeeded": true, + "src": { + "identity": { "name": "svc_app_app", "type": "service-account" }, + "endpoint": { "ip": "10.0.8.77", "type": "workstation" }, + "app": { "name": "SQL Server Management Studio", "type": "database-client" } + }, + "dst": { + "resource": { "name": "Claims", "type": "database", "scope": "acme_KEN_app_PROD" } + } + } + ] + }, + "recommendation": { + "action": "Rotate svc_app_app credentials and investigate who on the developer subnet has access to this service account password", + "mitigations": [ + "Store service account credentials in a vault with audit trail", + "Restrict service accounts to connect only from application server IPs" + ] + }, + "context": { + "killChainPhase": "lateral-movement", + "mitreTechnique": "T1078.001", + "compliance": ["PCI-DSS 10.2.5"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["access-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditLogins/2026/03/*/*.parquet", + "query": "SELECT * FROM AuditLogins WHERE server_principal_name LIKE '%svc%' AND application_name LIKE '%Management Studio%'", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + }, + { + "title": "3-day gap in AuditUserActivity coverage \u2014 March 8-10", + "severity": "high", + "platform": "sql-server", + "category": "coverage-gap", + "outcome": "high-ticket", + "detection": { + "pattern": "audit-coverage-gap", + "threshold": "Any day with zero events" + }, + "evidence": { + "summary": "No AuditUserActivity parquet files exist for March 8, 9, and 10. AuditLogins and AuditServer have normal coverage for these dates, suggesting a selective audit failure.", + "timeRange": { + "start": "2026-03-08T00:00:00Z", + "end": "2026-03-10T23:59:59Z", + "durationSeconds": 259199 + }, + "metrics": { "eventCount": 0 }, + "samples": [ + { + "timestamp": "2026-03-08T00:00:00Z", + "action": "audit-coverage-gap", + "dst": { + "resource": { "name": "AuditUserActivity", "type": "audit-type", "scope": "acme-prod-mssql" } + } + } + ] + }, + "recommendation": { + "action": "Investigate why AuditUserActivity stopped collecting for 3 days \u2014 check SQL Server error logs and audit status", + "mitigations": [ + "Add monitoring canary for audit file freshness (existing s3-audit canary covers this)", + "Set up alerting when daily audit file count drops below threshold" + ] + }, + "context": { + "killChainPhase": "persistence", + "mitreTechnique": "T1562.002", + "compliance": ["SOX 404", "PCI-DSS 10.5"] + }, + "dataSource": { + "type": "s3-parquet", + "categories": ["audit-logs"], + "path": "s3://acme-app-audit/sql/parquet/AuditUserActivity/2026/03/*/*.parquet", + "query": "SELECT toDate(event_time) AS day, count(*) AS events FROM AuditUserActivity GROUP BY day ORDER BY day", + "connection": "connection://monitoring/sql-server" + }, + "provenance": { + "generatedAt": "2026-04-01T10:00:00Z", + "generatedBy": "audit-log-analyzer", + "version": "1.0.0", + "runId": "run-2026-04-01-001", + "model": "claude-opus-4-6" + } + } + ] +} diff --git a/report/scraper-types.ts b/report/scraper-types.ts new file mode 100644 index 000000000..ee6e4a77b --- /dev/null +++ b/report/scraper-types.ts @@ -0,0 +1,27 @@ +export interface GitOpsSource { + git: { + url: string; + branch: string; + file: string; + dir: string; + link: string; + }; + kustomize: { + path: string; + file: string; + }; +} + +export interface ScraperInfo { + id: string; + name: string; + namespace?: string; + description?: string; + source?: string; + types: string[]; + specHash: string; + createdBy?: string; + createdAt: string; + updatedAt?: string; + gitops?: GitOpsSource; +} diff --git a/report/scraper/scraper.go b/report/scraper/scraper.go new file mode 100644 index 000000000..5471f19c7 --- /dev/null +++ b/report/scraper/scraper.go @@ -0,0 +1,110 @@ +package scraper + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "sort" + "time" + + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/flanksource/incident-commander/api" +) + +var knownBackendKeys = map[string]bool{ + "kubernetes": true, + "aws": true, + "azure": true, + "gcp": true, + "file": true, + "sql": true, + "http": true, + "trivy": true, + "terraform": true, + "githubActions": true, + "slack": true, + "kubernetesFile": true, +} + +func BuildScraperInfo(ctx context.Context, scraperID uuid.UUID) (*api.ScraperInfo, error) { + var scraper models.ConfigScraper + if err := ctx.DB().Where("id = ?", scraperID).First(&scraper).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ctx.Oops().Wrapf(err, "scraper %s not found", scraperID) + } + return nil, ctx.Oops().Wrapf(err, "failed to query scraper %s", scraperID) + } + + types := parseSpecTypes(scraper.Spec) + if types == nil { + types = []string{} + } + + info := &api.ScraperInfo{ + ID: scraper.ID.String(), + Name: scraper.Name, + Namespace: scraper.Namespace, + Source: scraper.Source, + CreatedAt: scraper.CreatedAt.Format(time.RFC3339), + SpecHash: specSHA256(scraper.Spec), + Types: types, + } + + if scraper.Description != "" { + info.Description = scraper.Description + } + + if scraper.UpdatedAt != nil { + info.UpdatedAt = scraper.UpdatedAt.Format(time.RFC3339) + } + + if scraper.CreatedBy != nil { + var person models.Person + if err := ctx.DB().Where("id = ?", scraper.CreatedBy).First(&person).Error; err == nil { + info.CreatedBy = person.GetName() + } + } + + source, err := query.GetGitOpsSource(ctx, scraperID) + if err != nil { + ctx.Logger.V(3).Infof("no gitops source for scraper %s: %v", scraperID, err) + } else if source.Git.URL != "" { + info.GitOps = &source + } + + return info, nil +} + +func parseSpecTypes(spec string) []string { + if spec == "" { + return nil + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(spec), &parsed); err != nil { + return nil + } + + var types []string + for key := range parsed { + if knownBackendKeys[key] { + types = append(types, key) + } + } + sort.Strings(types) + return types +} + +func specSHA256(spec string) string { + if spec == "" { + return "" + } + h := sha256.Sum256([]byte(spec)) + return fmt.Sprintf("%x", h) +} diff --git a/report/testdata/kitchen-sink.yaml b/report/testdata/kitchen-sink.yaml new file mode 100644 index 000000000..f011ae10a --- /dev/null +++ b/report/testdata/kitchen-sink.yaml @@ -0,0 +1,1979 @@ +configItem: + id: "cfg-eks-001" + name: "prod-eks-cluster" + type: "AWS::EKS::Cluster" + configClass: "Cluster" + status: "Active" + health: "healthy" + description: "Production EKS cluster running Mission Control workloads in us-east-1" + labels: + env: "production" + team: "platform" + region: "us-east-1" + costTotal30d: 4280.50 + createdAt: "2025-03-15T09:00:00Z" + updatedAt: "2026-03-28T12:00:00Z" + +categoryMappings: + - category: rbac.granted + filter: 'changeType == "PermissionGranted" || changeType == "PermissionAdded" || changeType == "IAMRoleAdded"' + - category: rbac.revoked + filter: 'changeType == "PermissionRevoked" || changeType == "PermissionRemoved" || changeType == "IAMRoleRemoved"' + - category: backup.success + filter: 'changeType == "BackupCompleted" || changeType == "BackupSuccessful"' + - category: backup.failed + filter: 'changeType == "BackupFailed"' + - category: backup.progress + filter: 'changeType == "BackupStarted" || changeType == "BackupRunning" || changeType == "BackupEnqueued"' + - category: backup.restore + filter: 'changeType == "BackupRestored" || changeType == "RestoreCompleted"' + - category: deployment.spec + filter: 'changeType == "diff"' + - category: deployment.scale + filter: 'changeType == "ScalingReplicaSet"' + - category: deployment.policy + filter: 'changeType == "PolicyUpdate"' + +changes: + - id: "chg-001" + configID: "cfg-eks-001" + changeType: "diff" + category: "deployment.spec" + severity: "info" + source: "kubernetes" + summary: "Node pool autoscaler adjusted desired count from 3 to 5" + createdBy: "cluster-autoscaler" + createdAt: "2026-03-30T08:15:00Z" + count: 1 + + - id: "chg-002" + configID: "cfg-eks-001" + changeType: "Pulled" + severity: "info" + source: "kubernetes" + summary: "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42" + createdAt: "2026-03-30T07:30:00Z" + count: 3 + + - id: "chg-003" + configID: "cfg-eks-001" + changeType: "ScalingReplicaSet" + category: "deployment.scale" + severity: "low" + source: "kubernetes" + summary: "Deployment incident-commander scaled from 2 to 3 replicas" + externalCreatedBy: "hpa-controller" + createdAt: "2026-03-29T22:00:00Z" + + - id: "chg-004" + configID: "cfg-eks-001" + changeType: "diff" + category: "deployment.spec" + severity: "medium" + source: "terraform" + summary: "EKS cluster version upgraded from 1.28 to 1.29" + createdBy: "alice@flanksource.com" + createdAt: "2026-03-29T14:00:00Z" + + - id: "chg-005" + configID: "cfg-eks-001" + changeType: "PolicyUpdate" + category: "deployment.policy" + severity: "high" + source: "argocd" + summary: "Network policy updated: restricted egress to 10.0.0.0/8 for namespace mc" + createdBy: "bob@flanksource.com" + createdAt: "2026-03-28T16:00:00Z" + + - id: "chg-006" + configID: "cfg-eks-001" + changeType: "diff" + category: "deployment.spec" + severity: "critical" + source: "aws-config" + summary: "IAM role policy detached: eks-admin-access removed from cluster role" + createdBy: "security-automation" + createdAt: "2026-03-28T10:00:00Z" + + - id: "chg-007" + configID: "cfg-eks-001" + changeType: "FieldsV1" + severity: "info" + source: "kubernetes" + summary: "ConfigMap kube-proxy updated with new CIDR ranges" + createdAt: "2026-03-27T18:00:00Z" + count: 2 + + - id: "chg-008" + configID: "cfg-eks-001" + changeType: "diff" + severity: "low" + source: "terraform" + summary: "Added tag cost-center=platform-engineering to cluster" + createdBy: "carol@flanksource.com" + createdAt: "2026-03-27T09:00:00Z" + + - id: "chg-009" + configID: "cfg-eks-001" + changeType: "ScalingReplicaSet" + category: "deployment.scale" + severity: "info" + source: "kubernetes" + summary: "Deployment canary-checker scaled from 1 to 2 replicas" + externalCreatedBy: "hpa-controller" + createdAt: "2026-03-26T20:00:00Z" + + - id: "chg-010" + configID: "cfg-eks-001" + changeType: "diff" + category: "deployment.spec" + severity: "medium" + source: "argocd" + summary: "Helm release cert-manager upgraded from v1.13.3 to v1.14.1" + createdBy: "alice@flanksource.com" + createdAt: "2026-03-26T11:00:00Z" + + - id: "chg-011" + configID: "cfg-eks-001" + changeType: "Pulled" + severity: "info" + source: "kubernetes" + summary: "Image flanksource/canary-checker:v1.0.350 pulled" + createdAt: "2026-03-25T15:00:00Z" + count: 5 + + - id: "chg-012" + configID: "cfg-eks-001" + changeType: "PolicyUpdate" + category: "deployment.policy" + severity: "high" + source: "aws-config" + summary: "Security group sg-0abc123 ingress rule added: allow 443 from 0.0.0.0/0" + createdBy: "terraform" + createdAt: "2026-03-25T10:00:00Z" + + - id: "chg-013" + configID: "cfg-eks-001" + changeType: "diff" + category: "deployment.spec" + severity: "low" + source: "kubernetes" + summary: "PodDisruptionBudget added for incident-commander (minAvailable: 2)" + createdBy: "bob@flanksource.com" + createdAt: "2026-03-24T14:00:00Z" + + - id: "chg-014" + configID: "cfg-eks-001" + changeType: "PermissionGranted" + category: "rbac.granted" + severity: "info" + source: "okta" + summary: "Granted db_owner to alice@flanksource.com on prod-rds-01" + createdBy: "admin@flanksource.com" + createdAt: "2026-03-28T09:00:00Z" + details: + permission: + user: "alice@flanksource.com" + role: "db_owner" + + - id: "chg-015" + configID: "cfg-eks-001" + changeType: "PermissionRevoked" + category: "rbac.revoked" + severity: "info" + source: "okta" + summary: "Revoked Secrets Reader access for bob@flanksource.com on prod-eks-cluster" + createdBy: "admin@flanksource.com" + createdAt: "2026-03-27T15:00:00Z" + details: + permission: + user: "bob@flanksource.com" + role: "Secrets Reader" + + - id: "chg-016" + configID: "cfg-eks-001" + changeType: "BackupCompleted" + category: "backup.success" + severity: "info" + source: "velero" + summary: "Full cluster backup completed successfully (2.4 GiB)" + createdAt: "2026-03-29T03:00:00Z" + + - id: "chg-017" + configID: "cfg-eks-001" + changeType: "BackupFailed" + category: "backup.failed" + severity: "high" + source: "velero" + summary: "Incremental backup failed: PVC snapshot timeout after 300s" + createdAt: "2026-03-28T03:00:00Z" + + - id: "chg-018" + configID: "cfg-eks-001" + changeType: "BackupStarted" + category: "backup.progress" + severity: "info" + source: "velero" + summary: "Scheduled backup initiated for prod-eks-cluster" + createdAt: "2026-03-30T03:00:00Z" + + - id: "chg-019" + configID: "cfg-eks-001" + changeType: "UserCreated" + severity: "info" + source: "okta" + createdAt: "2026-03-24T11:30:00Z" + typedChange: + kind: "UserChange/v1" + user_name: "alice" + user_email: "alice@flanksource.com" + user_type: "human" + group_name: "platform-admins" + tenant: "production" + + - id: "chg-020" + configID: "cfg-eks-001" + changeType: "Screenshot" + severity: "info" + source: "synthetics" + createdAt: "2026-03-24T10:15:00Z" + typedChange: + kind: "Screenshot/v1" + artifact_id: "art-001" + content_type: "image/png" + width: 1440 + height: 900 + url: "https://prod-eks-cluster.example.com/login" + + - id: "chg-021" + configID: "cfg-eks-001" + changeType: "PermissionSync" + severity: "low" + source: "iam-reconciler" + createdAt: "2026-03-24T09:45:00Z" + typedChange: + kind: "PermissionChange/v1" + user_name: "jane@flanksource.com" + role_name: "cluster-admin" + scope: "namespace/mc" + + - id: "chg-022" + configID: "cfg-eks-001" + changeType: "Deployment" + severity: "info" + source: "argocd" + createdAt: "2026-03-24T09:00:00Z" + typedChange: + kind: "Deployment/v1" + previous_image: "flanksource/incident-commander:v1.4.190" + new_image: "flanksource/incident-commander:v1.4.200" + container: "incident-commander" + namespace: "mc" + strategy: "rolling" + + - id: "chg-023" + configID: "cfg-eks-001" + changeType: "Promotion" + severity: "info" + source: "release-bot" + createdAt: "2026-03-24T08:30:00Z" + typedChange: + kind: "Promotion/v1" + from_environment: "staging" + to_environment: "production" + version: "v1.4.200" + artifact: "incident-commander" + + - id: "chg-024" + configID: "cfg-eks-001" + changeType: "Approved" + severity: "info" + source: "playbooks" + createdAt: "2026-03-24T08:00:00Z" + typedChange: + kind: "Approval/v1" + playbook_id: "pb-001" + run_id: "run-approve-001" + approved_by: "ops-lead@flanksource.com" + reason: "Change window approved" + + - id: "chg-025" + configID: "cfg-eks-001" + changeType: "Rollback" + severity: "high" + source: "argocd" + createdAt: "2026-03-24T07:30:00Z" + typedChange: + kind: "Rollback/v1" + from_version: "v1.4.200" + to_version: "v1.4.190" + trigger: "health-check" + reason: "Elevated error rate" + + - id: "chg-026" + configID: "cfg-eks-001" + changeType: "BackupArchived" + severity: "info" + source: "velero" + createdAt: "2026-03-24T07:00:00Z" + typedChange: + kind: "Backup/v1" + status: "completed" + backup_type: "full" + size: "2.4 GiB" + duration: "4m12s" + target: "s3://velero-prod" + snapshot_id: "snap-019" + + - id: "chg-027" + configID: "cfg-eks-001" + changeType: "PlaybookCompleted" + severity: "info" + source: "playbooks" + createdAt: "2026-03-24T06:30:00Z" + typedChange: + kind: "PlaybookExecution/v1" + playbook_name: "Restart Incident Commander" + run_id: "pb-run-019" + status: "completed" + duration: "2m11s" + + - id: "chg-028" + configID: "cfg-eks-001" + changeType: "Scaling" + severity: "low" + source: "keda" + createdAt: "2026-03-24T06:00:00Z" + typedChange: + kind: "Scaling/v1" + from_replicas: 2 + to_replicas: 4 + resource_type: "Deployment" + trigger: "queue-depth" + + - id: "chg-029" + configID: "cfg-eks-001" + changeType: "CertificateRenewed" + severity: "info" + source: "cert-manager" + createdAt: "2026-03-24T05:30:00Z" + typedChange: + kind: "Certificate/v1" + subject: "prod-eks-cluster.internal" + issuer: "letsencrypt-prod" + not_after: "2026-06-22T00:00:00Z" + serial: "09AF23" + dns_names: "prod-eks-cluster.internal,api.prod.example.com" + + - id: "chg-030" + configID: "cfg-eks-001" + changeType: "CostChange" + severity: "medium" + source: "cost-analyzer" + createdAt: "2026-03-24T05:00:00Z" + typedChange: + kind: "CostChange/v1" + previous_cost: 4280.5 + new_cost: 4631.25 + currency: "USD" + period: "30d" + reason: "Node group scale-out" + + - id: "chg-031" + configID: "cfg-eks-001" + changeType: "PipelineRunCompleted" + severity: "info" + source: "github-actions" + createdAt: "2026-03-24T04:30:00Z" + typedChange: + kind: "PipelineRun/v1" + pipeline_name: "deploy-incident-commander" + run_number: 841 + branch: "main" + status: "completed" + duration: "9m31s" + +analyses: + - id: "ana-001" + configID: "cfg-eks-001" + analyzer: "Trivy" + message: "Container image flanksource/incident-commander:v1.4.200 has 3 high CVEs (CVE-2026-1234, CVE-2026-1235, CVE-2026-1236)" + status: "open" + severity: "high" + analysisType: "security" + source: "trivy-operator" + firstObserved: "2026-03-28T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-002" + configID: "cfg-eks-001" + analyzer: "Trivy" + message: "Base image golang:1.23-alpine has known vulnerability in libcrypto (CVE-2026-0891)" + status: "open" + severity: "critical" + analysisType: "security" + source: "trivy-operator" + firstObserved: "2026-03-25T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-003" + configID: "cfg-eks-001" + analyzer: "OPA/Gatekeeper" + message: "Pod incident-commander-7f8b9c running as root user in namespace mc" + status: "open" + severity: "medium" + analysisType: "compliance" + source: "gatekeeper" + firstObserved: "2026-03-20T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-004" + configID: "cfg-eks-001" + analyzer: "OPA/Gatekeeper" + message: "Namespace mc missing required label: data-classification" + status: "silenced" + severity: "low" + analysisType: "compliance" + source: "gatekeeper" + firstObserved: "2026-03-15T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-005" + configID: "cfg-eks-001" + analyzer: "AWS Cost Optimizer" + message: "EKS node group i3.xlarge instances are underutilized (avg CPU 18%). Consider downsizing to i3.large." + status: "open" + severity: "medium" + analysisType: "cost" + source: "aws-cost-explorer" + firstObserved: "2026-03-01T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-006" + configID: "cfg-eks-001" + analyzer: "AWS Cost Optimizer" + message: "NAT Gateway data processing charges are 40% above baseline ($320/mo). Review egress traffic patterns." + status: "open" + severity: "low" + analysisType: "cost" + source: "aws-cost-explorer" + firstObserved: "2026-03-10T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-007" + configID: "cfg-eks-001" + analyzer: "Prometheus Advisor" + message: "P99 API response latency exceeded 500ms threshold 12 times in the last 7 days" + status: "open" + severity: "high" + analysisType: "performance" + source: "prometheus" + firstObserved: "2026-03-23T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-008" + configID: "cfg-eks-001" + analyzer: "AWS Best Practices" + message: "EKS cluster running version 1.29 - version 1.30 is available with security patches" + status: "open" + severity: "info" + analysisType: "recommendation" + source: "aws-advisor" + firstObserved: "2026-03-28T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-009" + configID: "cfg-eks-001" + analyzer: "AWS Best Practices" + message: "Enable EKS control plane logging for audit, authenticator, and scheduler components" + status: "resolved" + severity: "medium" + analysisType: "reliability" + source: "aws-advisor" + firstObserved: "2026-02-15T09:00:00Z" + lastObserved: "2026-03-20T09:00:00Z" + + - id: "ana-010" + configID: "cfg-eks-001" + analyzer: "Prometheus Advisor" + message: "Node ip-10-0-2-18 memory utilization consistently above 85% - risk of OOM kills" + status: "open" + severity: "high" + analysisType: "reliability" + source: "prometheus" + firstObserved: "2026-03-26T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + + - id: "ana-011" + configID: "cfg-eks-001" + analyzer: "Trivy" + message: "Resolved: CVE-2025-9999 in nginx ingress controller patched in v1.10.1" + status: "resolved" + severity: "high" + analysisType: "security" + source: "trivy-operator" + firstObserved: "2026-02-01T09:00:00Z" + lastObserved: "2026-03-15T09:00:00Z" + +relationships: + - configID: "cfg-eks-001" + relatedID: "cfg-vpc-001" + relation: "RunsIn" + direction: "outgoing" + + - configID: "cfg-eks-001" + relatedID: "cfg-iam-001" + relation: "ManagedBy" + direction: "outgoing" + + - configID: "cfg-eks-001" + relatedID: "cfg-sg-001" + relation: "DependsOn" + direction: "outgoing" + + - configID: "cfg-eks-001" + relatedID: "cfg-rds-001" + relation: "DependsOn" + direction: "outgoing" + + - configID: "cfg-deploy-001" + relatedID: "cfg-eks-001" + relation: "RunsOn" + direction: "incoming" + + - configID: "cfg-deploy-002" + relatedID: "cfg-eks-001" + relation: "RunsOn" + direction: "incoming" + + - configID: "cfg-deploy-003" + relatedID: "cfg-eks-001" + relation: "RunsOn" + direction: "incoming" + + - configID: "cfg-ns-001" + relatedID: "cfg-eks-001" + relation: "ChildOf" + direction: "incoming" + + - configID: "cfg-node-001" + relatedID: "cfg-eks-001" + relation: "ChildOf" + direction: "incoming" + + - configID: "cfg-node-002" + relatedID: "cfg-eks-001" + relation: "ChildOf" + direction: "incoming" + +relatedConfigs: + - id: "cfg-vpc-001" + name: "prod-vpc" + type: "AWS::EC2::VPC" + configClass: "Network" + status: "available" + health: "healthy" + labels: + env: "production" + + - id: "cfg-iam-001" + name: "eks-cluster-role" + type: "AWS::IAM::Role" + configClass: "IAM" + status: "active" + health: "healthy" + + - id: "cfg-sg-001" + name: "eks-cluster-sg" + type: "AWS::EC2::SecurityGroup" + configClass: "Network" + status: "active" + health: "warning" + labels: + env: "production" + + - id: "cfg-rds-001" + name: "mission-control-db" + type: "AWS::RDS::Instance" + configClass: "Database" + status: "available" + health: "healthy" + labels: + env: "production" + engine: "postgresql" + + - id: "cfg-deploy-001" + name: "incident-commander" + type: "Kubernetes::Deployment" + configClass: "Deployment" + status: "Running" + health: "healthy" + labels: + app: "incident-commander" + + - id: "cfg-deploy-002" + name: "canary-checker" + type: "Kubernetes::Deployment" + configClass: "Deployment" + status: "Running" + health: "healthy" + labels: + app: "canary-checker" + + - id: "cfg-deploy-003" + name: "config-db" + type: "Kubernetes::Deployment" + configClass: "Deployment" + status: "Running" + health: "unhealthy" + labels: + app: "config-db" + + - id: "cfg-ns-001" + name: "mc" + type: "Kubernetes::Namespace" + configClass: "Namespace" + status: "Active" + health: "healthy" + + - id: "cfg-node-001" + name: "ip-10-0-1-42" + type: "Kubernetes::Node" + configClass: "Node" + status: "Ready" + health: "healthy" + + - id: "cfg-node-002" + name: "ip-10-0-2-18" + type: "Kubernetes::Node" + configClass: "Node" + status: "Ready" + health: "warning" + labels: + instance-type: "i3.xlarge" + +rbacChanges: + - id: "rbac-001" + date: "2026-03-30T09:12:00Z" + changeType: "PermissionAdded" + source: "azure-entra" + createdBy: "alice@flanksource.com" + configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + permission: + user: "alice@flanksource.com" + role: "db_owner" + group: "incident-responders" + description: "PermissionAdded: user alice@flanksource.com, role db_owner, group incident-responders" + status: "info" + createdAt: "2026-03-30T09:12:00Z" + + - id: "rbac-002" + date: "2026-03-29T18:40:00Z" + changeType: "PermissionRemoved" + source: "azure-entra" + createdBy: "security-automation" + configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + permission: + user: "contractor-temp" + role: "db_datareader" + description: "PermissionRemoved: user contractor-temp, role db_datareader" + status: "info" + createdAt: "2026-03-29T18:40:00Z" + + - id: "rbac-006" + date: "2026-03-29T11:15:00Z" + changeType: "PermissionAdded" + source: "okta" + createdBy: "governance-bot" + configId: "cfg-keyvault-001" + configName: "prod-keyvault" + configType: "Azure::KeyVault" + permission: + user: "ops-auditor" + role: "Secrets Reader" + description: "PermissionAdded: user ops-auditor, role Secrets Reader" + status: "info" + createdAt: "2026-03-29T11:15:00Z" + + - id: "rbac-003" + date: "2026-03-29T16:00:00Z" + changeType: "AccessReviewed" + source: "access-review-job" + createdBy: "governance-bot" + configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + description: "Quarterly review completed for production database roles" + status: "info" + createdAt: "2026-03-29T16:00:00Z" + + - id: "rbac-004" + date: "2026-03-28T13:05:00Z" + changeType: "PermissionGranted" + source: "okta" + createdBy: "bob@flanksource.com" + configId: "cfg-analytics-001" + configName: "analytics-db" + configType: "MSSQL::Database" + description: "Granted db_ddladmin to deploy-bot on analytics-db" + status: "info" + createdAt: "2026-03-28T13:05:00Z" + + - id: "rbac-005" + date: "2026-03-27T07:20:00Z" + changeType: "PermissionAdded" + source: "okta" + configId: "cfg-keyvault-001" + configName: "prod-keyvault" + configType: "Azure::KeyVault" + permission: + group: "break-glass-admins" + role: "Secrets Officer" + description: "PermissionAdded: role Secrets Officer, group break-glass-admins" + status: "info" + createdAt: "2026-03-27T07:20:00Z" + +backupChanges: + - id: "bak-001" + date: "2026-03-30T02:00:00Z" + changeType: "BackupStarted" + source: "aws-backup" + description: "Nightly snapshot started for incident-commander-db" + status: "info" + createdAt: "2026-03-30T02:00:00Z" + + - id: "bak-002" + date: "2026-03-30T02:08:00Z" + changeType: "BackupCompleted" + source: "aws-backup" + description: "Nightly snapshot completed for incident-commander-db (4.3 GB)" + status: "info" + createdAt: "2026-03-30T02:08:00Z" + + - id: "bak-003" + date: "2026-03-29T02:01:00Z" + changeType: "BackupFailed" + source: "aws-backup" + description: "Snapshot failed for incident-commander-db after storage timeout" + status: "high" + createdAt: "2026-03-29T02:01:00Z" + + - id: "bak-004" + date: "2026-03-28T12:10:00Z" + changeType: "BackupRestored" + source: "drill-playbook" + createdBy: "platform-oncall" + description: "Restored staging copy from nightly snapshot for disaster recovery drill" + status: "info" + createdAt: "2026-03-28T12:10:00Z" + + - id: "bak-005" + date: "2026-03-28T12:18:00Z" + changeType: "RestoreCompleted" + source: "drill-playbook" + createdBy: "platform-oncall" + description: "Restore completed and validation checks passed" + status: "info" + createdAt: "2026-03-28T12:18:00Z" + + - id: "bak-006" + date: "2026-03-27T02:00:00Z" + changeType: "BackupEnqueued" + source: "aws-backup" + description: "Queued backup job for archive-postgres" + status: "info" + createdAt: "2026-03-27T02:00:00Z" + + - id: "bak-007" + date: "2026-03-27T10:00:00Z" + changeType: "diff" + source: "terraform" + description: "This diff should be filtered out of the backup-focused renderer" + status: "low" + createdAt: "2026-03-27T10:00:00Z" + +deploymentChanges: + - id: "dep-001" + date: "2026-03-30T08:15:00Z" + changeType: "diff" + source: "argocd" + createdBy: "deploy-bot" + description: "Deployment incident-commander image updated: v1.4.199 -> v1.4.200" + status: "low" + createdAt: "2026-03-30T08:15:00Z" + + - id: "dep-002" + date: "2026-03-30T07:30:00Z" + changeType: "Pulled" + source: "kubernetes" + description: "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42" + status: "info" + createdAt: "2026-03-30T07:30:00Z" + + - id: "dep-003" + date: "2026-03-29T22:00:00Z" + changeType: "ScalingReplicaSet" + source: "kubernetes" + createdBy: "cluster-autoscaler" + description: "Deployment incident-commander scaled from 2 to 4 replicas" + status: "low" + createdAt: "2026-03-29T22:00:00Z" + + - id: "dep-004" + date: "2026-03-29T14:00:00Z" + changeType: "PolicyUpdate" + source: "argocd" + createdBy: "alice@flanksource.com" + description: "Deployment network policy updated to restrict egress to approved CIDRs" + status: "medium" + createdAt: "2026-03-29T14:00:00Z" + + - id: "dep-005" + date: "2026-03-28T10:00:00Z" + changeType: "FieldsV1" + source: "kubernetes" + description: "FieldsV1 payload updated during reconciliation" + status: "info" + createdAt: "2026-03-28T10:00:00Z" + + - id: "dep-006" + date: "2026-03-27T09:00:00Z" + changeType: "diff" + source: "terraform" + createdBy: "carol@flanksource.com" + description: "Deployment incident-commander rollout template updated with new topology spread constraints" + status: "medium" + createdAt: "2026-03-27T09:00:00Z" + +scrapers: + - id: "scr-001" + name: "mc/aws-production" + namespace: "mc" + source: "KubernetesCRD" + types: ["aws", "kubernetes"] + specHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd" + createdBy: "alice@flanksource.com" + createdAt: "2025-06-10T09:00:00Z" + updatedAt: "2026-03-28T14:30:00Z" + gitops: + git: + url: "https://github.com/flanksource/mission-control-demo" + branch: "main" + file: "clusters/prod/scrapers/aws.yaml" + dir: "clusters/prod/scrapers" + link: "https://github.com/flanksource/mission-control-demo/tree/main/clusters/prod/scrapers/aws.yaml" + kustomize: + path: "clusters/prod/scrapers" + file: "clusters/prod/scrapers/kustomization.yaml" + - id: "scr-002" + name: "mc/azure-entra" + namespace: "mc" + source: "KubernetesCRD" + types: ["azure"] + specHash: "ff00aa11bb22cc33dd44ee55ff00aa11bb22cc33dd44ee55ff00aa11bb22cc33" + createdAt: "2025-09-01T10:00:00Z" + updatedAt: "2026-03-30T08:00:00Z" + - id: "scr-003" + name: "local-file-scraper" + source: "ConfigFile" + types: ["file", "sql"] + specHash: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + createdBy: "bob@flanksource.com" + createdAt: "2026-01-15T11:00:00Z" + +genericChangesSection: + type: "changes" + title: "Recent Infrastructure Changes" + changes: + - id: "gen-001" + date: "2026-03-30T10:00:00Z" + changeType: "ConfigUpdate" + source: "terraform" + createdBy: "alice@flanksource.com" + description: "Updated VPC CIDR block from 10.0.0.0/16 to 10.0.0.0/12" + status: "medium" + createdAt: "2026-03-30T10:00:00Z" + - id: "gen-002" + date: "2026-03-29T15:00:00Z" + changeType: "TagUpdate" + source: "aws-config" + description: "Added cost-center tag to 14 resources in us-east-1" + status: "info" + createdAt: "2026-03-29T15:00:00Z" + - id: "gen-003" + date: "2026-03-28T09:00:00Z" + changeType: "SecurityGroupChange" + source: "aws-config" + createdBy: "security-automation" + description: "Removed unused ingress rule on sg-0abc123 (port 8080)" + status: "low" + createdAt: "2026-03-28T09:00:00Z" + - id: "gen-004" + date: "2026-03-27T14:00:00Z" + changeType: "DNSUpdate" + source: "route53" + createdBy: "bob@flanksource.com" + description: "Added CNAME record api-v2.flanksource.com -> prod-alb.us-east-1.elb.amazonaws.com" + status: "info" + createdAt: "2026-03-27T14:00:00Z" + +dynamicViewSection: + type: "view" + title: "Cluster Nodes" + view: + columns: + - name: "node" + type: "string" + - name: "status" + type: "status" + - name: "health" + type: "health" + - name: "cpu_used" + type: "gauge" + gauge: + thresholds: + - percent: 0 + color: "#22C55E" + - percent: 70 + color: "#EAB308" + - percent: 90 + color: "#EF4444" + - name: "memory" + type: "bytes" + rows: + - ["ip-10-0-1-42", "Ready", "healthy", 45.2, 8589934592] + - ["ip-10-0-2-18", "Ready", "warning", 87.3, 14495514624] + - ["ip-10-0-3-7", "NotReady", "unhealthy", 0, 0] + +dynamicConfigsSection: + type: "configs" + title: "Related Databases" + configs: + - id: "db-001" + name: "mission-control-db" + type: "AWS::RDS::Instance" + status: "available" + health: "healthy" + labels: + engine: "postgresql" + env: "production" + - id: "db-002" + name: "analytics-db" + type: "AWS::RDS::Instance" + status: "available" + health: "warning" + labels: + engine: "postgresql" + env: "production" + - id: "db-003" + name: "archive-db" + type: "AWS::RDS::Instance" + status: "stopped" + health: "unknown" + labels: + engine: "postgresql" + env: "staging" + +application: + id: "app-001" + name: "Mission Control" + type: "WebApplication" + namespace: "mc" + description: "Internal developer platform for Kubernetes fleet management" + properties: + - name: "uptime" + label: "Uptime" + value: 99.97 + unit: "percentage" + order: 1 + - name: "latency" + label: "P99 Latency" + value: 245 + unit: "milliseconds" + order: 2 + - name: "requests" + label: "Requests/s" + value: 1240 + order: 3 + - name: "error_rate" + label: "Error Rate" + value: 0.03 + unit: "percentage" + order: 4 + accessControl: + users: + - id: "u-001" + name: "Alice Johnson" + email: "alice@flanksource.com" + role: "admin" + authType: "SSO" + created: "2025-01-15T09:00:00Z" + lastLogin: "2026-03-30T08:00:00Z" + lastAccessReview: "2026-03-15T10:00:00Z" + - id: "u-002" + name: "Bob Smith" + email: "bob@flanksource.com" + role: "editor" + authType: "SSO" + created: "2025-06-01T09:00:00Z" + lastLogin: "2026-03-28T14:00:00Z" + lastAccessReview: "2026-02-01T10:00:00Z" + - id: "u-003" + name: "Carol Davis" + email: "carol@flanksource.com" + role: "viewer" + authType: "API Key" + created: "2026-01-10T09:00:00Z" + lastLogin: null + lastAccessReview: null + authentication: + - name: "Azure AD SSO" + type: "SAML" + mfa: + type: "TOTP" + enforced: "true" + properties: + tenant: "flanksource.onmicrosoft.com" + - name: "API Key Auth" + type: "Bearer" + mfa: + type: "none" + enforced: "false" + properties: + rotation: "90 days" + incidents: + - id: "inc-001" + date: "2026-03-28T03:15:00Z" + severity: "critical" + description: "Database connection pool exhausted causing 503 errors across all API endpoints" + status: "resolved" + resolvedDate: "2026-03-28T04:45:00Z" + - id: "inc-002" + date: "2026-03-25T14:00:00Z" + severity: "high" + description: "Config scraper failing to sync AWS resources due to expired IAM credentials" + status: "resolved" + resolvedDate: "2026-03-25T15:30:00Z" + - id: "inc-003" + date: "2026-03-22T09:30:00Z" + severity: "medium" + description: "Notification delivery delayed by 15+ minutes due to queue backlog" + status: "resolved" + resolvedDate: "2026-03-22T11:00:00Z" + - id: "inc-004" + date: "2026-03-30T06:00:00Z" + severity: "low" + description: "Health check dashboard showing stale data for canary-checker pods" + status: "open" + - id: "inc-005" + date: "2026-03-29T20:00:00Z" + severity: "high" + description: "Memory leak in event processor causing gradual degradation" + status: "open" + locations: + - account: "flanksource-prod" + name: "us-east-1-primary" + type: "EKS Cluster" + purpose: "primary" + region: "us-east-1" + provider: "AWS" + resourceCount: 142 + - account: "flanksource-prod" + name: "eu-west-1-backup" + type: "EKS Cluster" + purpose: "backup" + region: "eu-west-1" + provider: "AWS" + resourceCount: 38 + - account: "flanksource-dr" + name: "us-west-2-dr" + type: "EKS Cluster" + purpose: "dr" + region: "us-west-2" + provider: "AWS" + resourceCount: 15 + backups: + - id: "bkp-001" + database: "mission-control-db" + type: "snapshot" + source: "aws-backup" + date: "2026-03-30T02:00:00Z" + size: "4.3 GB" + status: "success" + - id: "bkp-002" + database: "mission-control-db" + type: "snapshot" + source: "aws-backup" + date: "2026-03-29T02:00:00Z" + size: "4.2 GB" + status: "failed" + - id: "bkp-003" + database: "mission-control-db" + type: "snapshot" + source: "aws-backup" + date: "2026-03-28T02:00:00Z" + size: "4.1 GB" + status: "success" + - id: "bkp-004" + database: "analytics-db" + type: "logical" + source: "pg_dump" + date: "2026-03-30T03:00:00Z" + size: "1.8 GB" + status: "success" + - id: "bkp-005" + database: "analytics-db" + type: "logical" + source: "pg_dump" + date: "2026-03-29T03:00:00Z" + size: "1.7 GB" + status: "in-progress" + restores: + - id: "rst-001" + database: "mission-control-db" + date: "2026-03-28T12:00:00Z" + source: "aws-backup" + status: "success" + completedAt: "2026-03-28T12:18:00Z" + - id: "rst-002" + database: "analytics-db" + date: "2026-03-15T09:00:00Z" + source: "pg_dump" + status: "success" + completedAt: "2026-03-15T09:35:00Z" + findings: + - id: "find-001" + type: "security" + severity: "critical" + title: "CVE-2026-0891 in libcrypto" + description: "Critical vulnerability in OpenSSL library" + date: "2026-03-25T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "open" + remediation: "Upgrade base image to golang:1.23.1-alpine" + - id: "find-002" + type: "security" + severity: "high" + title: "Container running as root" + description: "incident-commander pod runs as UID 0" + date: "2026-03-20T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "open" + remediation: "Add securityContext.runAsNonRoot to deployment spec" + - id: "find-003" + type: "compliance" + severity: "medium" + title: "Missing data-classification label" + description: "Namespace mc missing required label" + date: "2026-03-15T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "open" + remediation: "Add label data-classification=internal to namespace" + - id: "find-004" + type: "compliance" + severity: "low" + title: "Pod disruption budget missing" + description: "canary-checker deployment has no PDB" + date: "2026-03-10T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "accepted" + - id: "find-005" + type: "reliability" + severity: "high" + title: "Node memory pressure" + description: "ip-10-0-2-18 consistently above 85% memory" + date: "2026-03-26T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "open" + remediation: "Scale node group or add memory limits to workloads" + - id: "find-006" + type: "reliability" + severity: "medium" + title: "Single replica deployment" + description: "config-db running with 1 replica" + date: "2026-03-01T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "resolved" + - id: "find-007" + type: "performance" + severity: "high" + title: "API latency above threshold" + description: "P99 latency exceeded 500ms 12 times in 7 days" + date: "2026-03-23T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "in-progress" + remediation: "Investigate slow queries and add connection pooling" + - id: "find-008" + type: "performance" + severity: "low" + title: "Slow config scraper sync" + description: "AWS scraper taking >10min per cycle" + date: "2026-03-18T09:00:00Z" + lastObserved: "2026-03-30T09:00:00Z" + status: "open" + sections: [] + +rbacReport: + title: "prod-sql-primary" + query: "type=MSSQL::Database AND name=prod-sql-primary" + generatedAt: "2026-03-30T12:00:00Z" + subject: + id: "cfg-sql-001" + name: "prod-sql-primary" + type: "MSSQL::Database" + config_class: "Database" + status: "Online" + health: "healthy" + description: "Primary SQL Server database for production workloads" + tags: + env: "production" + team: "data-platform" + parents: + - id: "cfg-sql-server-001" + name: "sql-prod-east" + type: "MSSQL::Server" + - id: "cfg-rg-001" + name: "rg-prod-data" + type: "Azure::ResourceGroup" + summary: + totalUsers: 8 + totalResources: 2 + staleAccessCount: 2 + overdueReviews: 1 + directAssignments: 6 + groupAssignments: 4 + resources: + - configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + configClass: "Database" + path: "rg-prod-data.sql-prod-east.prod-sql-primary" + status: "Online" + health: "healthy" + tags: + env: "production" + labels: + team: "data-platform" + users: + - userId: "u-alice" + userName: "alice@flanksource.com" + email: "alice@flanksource.com" + role: "db_owner" + roleSource: "direct" + sourceSystem: "azure-entra" + createdAt: "2025-01-15T09:00:00Z" + lastSignedInAt: "2026-03-30T08:00:00Z" + lastReviewedAt: "2026-03-15T10:00:00Z" + isStale: false + isReviewOverdue: false + - userId: "u-bob" + userName: "bob@flanksource.com" + email: "bob@flanksource.com" + role: "db_datareader" + roleSource: "direct" + sourceSystem: "azure-entra" + createdAt: "2025-06-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-02-01T10:00:00Z" + isStale: false + isReviewOverdue: false + - userId: "u-bob" + userName: "bob@flanksource.com" + email: "bob@flanksource.com" + role: "db_datawriter" + roleSource: "group:SG-DataEngineers" + sourceSystem: "azure-entra" + createdAt: "2025-06-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-02-01T10:00:00Z" + isStale: false + isReviewOverdue: false + - userId: "u-carol" + userName: "carol@flanksource.com" + email: "carol@flanksource.com" + role: "db_datareader" + roleSource: "group:SG-Analytics" + sourceSystem: "azure-entra" + createdAt: "2026-01-10T09:00:00Z" + lastSignedInAt: null + lastReviewedAt: null + isStale: true + isReviewOverdue: true + - userId: "u-deploy-bot" + userName: "deploy-bot" + email: "deploy-bot@flanksource.com" + role: "db_ddladmin" + roleSource: "direct" + sourceSystem: "azure-entra" + createdAt: "2025-08-01T09:00:00Z" + lastSignedInAt: "2026-03-30T06:00:00Z" + lastReviewedAt: "2026-03-01T10:00:00Z" + isStale: false + isReviewOverdue: false + - userId: "u-contractor" + userName: "contractor-temp" + email: "contractor@external.com" + role: "db_datareader" + roleSource: "direct" + sourceSystem: "okta" + createdAt: "2025-12-01T09:00:00Z" + lastSignedInAt: "2025-12-15T10:00:00Z" + lastReviewedAt: null + isStale: true + isReviewOverdue: true + changelog: + - configId: "cfg-sql-001" + date: "2026-03-30T09:12:00Z" + changeType: "PermissionGranted" + user: "alice@flanksource.com" + role: "db_owner" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Granted during oncall rotation" + - configId: "cfg-sql-001" + date: "2026-03-29T18:40:00Z" + changeType: "PermissionRevoked" + user: "contractor-temp" + role: "db_datareader" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Contract ended" + changelog: + - configId: "cfg-sql-001" + date: "2026-03-30T09:12:00Z" + changeType: "PermissionGranted" + user: "alice@flanksource.com" + role: "db_owner" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Granted during oncall rotation" + - configId: "cfg-sql-001" + date: "2026-03-29T18:40:00Z" + changeType: "PermissionRevoked" + user: "contractor-temp" + role: "db_datareader" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Contract ended" + - configId: "cfg-sql-001" + date: "2026-03-29T16:00:00Z" + changeType: "AccessReviewed" + user: "governance-bot" + role: "all" + configName: "prod-sql-primary" + source: "access-review-job" + description: "Quarterly review completed" + - configId: "cfg-sql-001" + date: "2026-03-28T13:05:00Z" + changeType: "PermissionGranted" + user: "deploy-bot" + role: "db_ddladmin" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Automated pipeline access" + - configId: "cfg-sql-001" + date: "2026-03-15T10:00:00Z" + changeType: "PermissionGranted" + user: "bob@flanksource.com" + role: "db_datawriter" + configName: "prod-sql-primary" + source: "azure-entra" + description: "Added via SG-DataEngineers group" + - configId: "cfg-sql-001" + date: "2026-03-01T09:00:00Z" + changeType: "PermissionRevoked" + user: "intern-2025" + role: "db_datareader" + configName: "prod-sql-primary" + source: "okta" + description: "Internship ended" + users: + - userId: "u-alice" + userName: "alice@flanksource.com" + email: "alice@flanksource.com" + sourceSystem: "azure-entra" + lastSignedInAt: "2026-03-30T08:00:00Z" + resources: + - configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + configClass: "Database" + role: "db_owner" + roleSource: "direct" + createdAt: "2025-01-15T09:00:00Z" + lastSignedInAt: "2026-03-30T08:00:00Z" + lastReviewedAt: "2026-03-15T10:00:00Z" + isStale: false + isReviewOverdue: false + - configId: "cfg-sql-002" + configName: "analytics-db" + configType: "MSSQL::Database" + configClass: "Database" + role: "db_datareader" + roleSource: "group:SG-Analytics" + createdAt: "2025-03-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-03-15T10:00:00Z" + isStale: false + isReviewOverdue: false + - userId: "u-bob" + userName: "bob@flanksource.com" + email: "bob@flanksource.com" + sourceSystem: "azure-entra" + lastSignedInAt: "2026-03-28T14:00:00Z" + resources: + - configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + configClass: "Database" + role: "db_datareader" + roleSource: "direct" + createdAt: "2025-06-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-02-01T10:00:00Z" + isStale: false + isReviewOverdue: false + - configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + configClass: "Database" + role: "db_datawriter" + roleSource: "group:SG-DataEngineers" + createdAt: "2025-06-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-02-01T10:00:00Z" + isStale: false + isReviewOverdue: false + - configId: "cfg-kv-001" + configName: "prod-keyvault" + configType: "Azure::KeyVault" + configClass: "Security" + role: "Secrets Reader" + roleSource: "direct" + createdAt: "2025-09-01T09:00:00Z" + lastSignedInAt: "2026-03-25T10:00:00Z" + lastReviewedAt: "2026-01-15T10:00:00Z" + isStale: false + isReviewOverdue: false + +catalogReport: + relationshipTree: + id: "cfg-eks-001" + name: "prod-eks-cluster" + type: "AWS::EKS::Cluster" + edgeType: "target" + children: + - id: "cfg-vpc-001" + name: "prod-vpc" + type: "AWS::EC2::VPC" + edgeType: "parent" + relation: "RunsIn" + children: + - id: "cfg-subnet-001" + name: "private-subnet-1a" + type: "AWS::EC2::Subnet" + edgeType: "child" + - id: "cfg-subnet-002" + name: "private-subnet-1b" + type: "AWS::EC2::Subnet" + edgeType: "child" + - id: "cfg-ns-001" + name: "mc" + type: "Kubernetes::Namespace" + edgeType: "child" + relation: "ChildOf" + children: + - id: "cfg-deploy-001" + name: "incident-commander" + type: "Kubernetes::Deployment" + edgeType: "child" + - id: "cfg-deploy-002" + name: "canary-checker" + type: "Kubernetes::Deployment" + edgeType: "child" + - id: "cfg-rds-001" + name: "mission-control-db" + type: "AWS::RDS::Instance" + edgeType: "related" + relation: "DependsOn" + access: + - userId: "u-alice" + userName: "Alice Johnson" + email: "alice@flanksource.com" + role: "admin" + userType: "User" + createdAt: "2025-01-15T09:00:00Z" + lastSignedInAt: "2026-03-30T08:00:00Z" + lastReviewedAt: "2026-03-15T10:00:00Z" + - userId: "u-bob" + userName: "Bob Smith" + email: "bob@flanksource.com" + role: "editor" + userType: "User" + createdAt: "2025-06-01T09:00:00Z" + lastSignedInAt: "2026-03-28T14:00:00Z" + lastReviewedAt: "2026-02-01T10:00:00Z" + - userId: "u-carol" + userName: "Carol Davis" + email: "carol@flanksource.com" + role: "viewer" + userType: "User" + createdAt: "2026-01-10T09:00:00Z" + lastSignedInAt: null + - userId: "u-deploy-bot" + userName: "deploy-bot" + email: "deploy-bot@flanksource.com" + role: "editor" + userType: "ServiceAccount" + createdAt: "2025-08-01T09:00:00Z" + lastSignedInAt: "2026-03-30T06:00:00Z" + lastReviewedAt: "2026-03-01T10:00:00Z" + - userId: "u-stale" + userName: "Former Employee" + email: "former@flanksource.com" + role: "viewer" + userType: "User" + createdAt: "2024-06-01T09:00:00Z" + lastSignedInAt: "2025-06-15T10:00:00Z" + accessLogs: + - userId: "u-alice" + userName: "Alice Johnson" + configName: "prod-eks-cluster" + configType: "AWS::EKS::Cluster" + createdAt: "2026-03-30T08:15:00Z" + mfa: true + count: 3 + properties: + action: "describe-cluster" + source: "kubectl" + - userId: "u-bob" + userName: "Bob Smith" + configName: "prod-eks-cluster" + configType: "AWS::EKS::Cluster" + createdAt: "2026-03-29T14:30:00Z" + mfa: true + count: 1 + - userId: "u-deploy-bot" + userName: "deploy-bot" + configName: "prod-eks-cluster" + configType: "AWS::EKS::Cluster" + createdAt: "2026-03-30T06:00:00Z" + mfa: false + count: 12 + properties: + action: "apply" + source: "argocd" + - userId: "u-carol" + userName: "Carol Davis" + configName: "prod-eks-cluster" + configType: "AWS::EKS::Cluster" + createdAt: "2026-03-25T11:00:00Z" + mfa: false + count: 1 + - userId: "u-alice" + userName: "Alice Johnson" + configName: "mission-control-db" + configType: "AWS::RDS::Instance" + createdAt: "2026-03-30T09:00:00Z" + mfa: true + count: 2 + properties: + action: "connect" + source: "psql" + - userId: "u-stale" + userName: "Former Employee" + configName: "prod-eks-cluster" + configType: "AWS::EKS::Cluster" + createdAt: "2025-06-15T10:00:00Z" + mfa: false + count: 1 + entries: + - configItem: + id: "cfg-deploy-001" + name: "incident-commander" + type: "Kubernetes::Deployment" + status: "Running" + health: "healthy" + changeCount: 5 + insightCount: 3 + accessCount: 2 + changes: [] + analyses: [] + access: [] + accessLogs: [] + - configItem: + id: "cfg-deploy-002" + name: "canary-checker" + type: "Kubernetes::Deployment" + status: "Running" + health: "healthy" + changeCount: 2 + insightCount: 1 + accessCount: 1 + changes: [] + analyses: [] + access: [] + accessLogs: [] + - configItem: + id: "cfg-deploy-003" + name: "config-db" + type: "Kubernetes::Deployment" + status: "Running" + health: "unhealthy" + changeCount: 1 + insightCount: 2 + accessCount: 0 + changes: [] + analyses: [] + access: [] + accessLogs: [] + changes: + - id: "art-001" + configID: "cfg-deploy-001" + configName: "incident-commander" + configType: "Kubernetes::Deployment" + changeType: "diff" + severity: "medium" + source: "argocd" + summary: "Deployment spec updated with new resource limits" + createdAt: "2026-03-30T08:15:00Z" + artifacts: + - id: "a-001" + filename: "diff-screenshot.png" + contentType: "image/png" + size: 45000 + dataUri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + - id: "art-002" + configID: "cfg-deploy-001" + configName: "incident-commander" + configType: "Kubernetes::Deployment" + changeType: "PolicyUpdate" + severity: "high" + source: "argocd" + summary: "Network policy tightened for egress" + createdAt: "2026-03-29T14:00:00Z" + artifacts: + - id: "a-002" + filename: "policy-diff.yaml" + contentType: "text/yaml" + size: 1200 + audit: + buildCommit: "3b3a1a0f" + buildVersion: "v1.47.0" + gitStatus: " M report/catalog/report.go\n M db/rbac.go" + options: + title: "Production EKS Cluster Report" + since: "720h" + sections: + changes: true + insights: true + relationships: true + access: true + accessLogs: true + configJSON: false + recursive: true + groupBy: "type" + changeArtifacts: true + thresholds: + staleDays: 90 + reviewOverdueDays: 180 + filters: + - "type=AWS::EKS::Cluster" + - "namespace=mc" + scrapers: [] + queries: + - name: "RBACAccess" + args: "configIDs=1 selectors=0" + count: 42 + duration: 128 + pretty: "SELECT ... FROM config_access_summary WHERE config_id IN (?)" + - name: "GroupMembers" + args: "configIDs=1" + count: 5 + duration: 34 + pretty: "SELECT ... FROM external_user_groups eug JOIN external_groups eg ..." + groups: + - id: "grp-admins" + name: "mission-control-admins" + groupType: "group" + members: + - userId: "u-alice" + name: "Alice Johnson" + email: "alice@flanksource.com" + userType: "user" + lastSignedInAt: "2026-03-30T08:00:00Z" + membershipAddedAt: "2025-01-10T09:00:00Z" + - userId: "u-bob" + name: "Bob Smith" + email: "bob@flanksource.com" + userType: "user" + lastSignedInAt: "2026-03-28T14:00:00Z" + membershipAddedAt: "2025-06-01T09:00:00Z" + - userId: "u-stale" + name: "Former Employee" + email: "former@flanksource.com" + userType: "user" + lastSignedInAt: "2025-06-15T10:00:00Z" + membershipAddedAt: "2024-06-01T09:00:00Z" + membershipDeletedAt: "2025-07-01T09:00:00Z" + - id: "grp-readers" + name: "mission-control-readers" + groupType: "group" + members: + - userId: "u-carol" + name: "Carol Davis" + email: "carol@flanksource.com" + userType: "user" + membershipAddedAt: "2026-01-10T09:00:00Z" + - userId: "u-deploy-bot" + name: "deploy-bot" + email: "deploy-bot@flanksource.com" + userType: "service_account" + lastSignedInAt: "2026-03-30T06:00:00Z" + membershipAddedAt: "2025-08-01T09:00:00Z" + +viewReport: + name: "cluster-overview" + title: "Cluster Overview" + icon: "AWS::EKS::Cluster" + columns: + - name: "name" + type: "string" + - name: "type" + type: "string" + - name: "health" + type: "health" + - name: "status" + type: "status" + - name: "cpu" + type: "gauge" + gauge: + thresholds: + - percent: 0 + color: "#22C55E" + - percent: 70 + color: "#EAB308" + - percent: 90 + color: "#EF4444" + - name: "memory" + type: "bytes" + - name: "uptime" + type: "duration" + - name: "requests" + type: "number" + unit: "req/s" + - name: "ready" + type: "boolean" + - name: "last_seen" + type: "datetime" + - name: "labels" + type: "labels" + rows: + - ["incident-commander", "Deployment", "healthy", "Running", 42.5, 536870912, 86400000000000, 1240, true, "2026-03-30T08:00:00Z", {"app": "incident-commander", "env": "production"}] + - ["canary-checker", "Deployment", "healthy", "Running", 18.3, 268435456, 172800000000000, 450, true, "2026-03-30T08:00:00Z", {"app": "canary-checker", "env": "production"}] + - ["config-db", "Deployment", "unhealthy", "CrashLoopBackOff", 92.1, 1073741824, 3600000000000, 0, false, "2026-03-30T07:45:00Z", {"app": "config-db", "env": "production"}] + - ["cert-manager", "Deployment", "healthy", "Running", 5.2, 134217728, 604800000000000, 12, true, "2026-03-30T08:00:00Z", {"app": "cert-manager"}] + - ["nginx-ingress", "Deployment", "warning", "Running", 78.9, 805306368, 259200000000000, 3200, true, "2026-03-30T08:00:00Z", {"app": "nginx-ingress", "env": "production"}] + panels: + - name: "Total Requests" + type: "number" + number: + unit: "req/s" + rows: + - value: 4902 + - name: "CPU Utilization" + type: "gauge" + gauge: + unit: "%" + thresholds: + - percent: 0 + color: "#22C55E" + - percent: 70 + color: "#EAB308" + - percent: 90 + color: "#EF4444" + rows: + - value: 47.4 + - name: "Pod Distribution" + type: "piechart" + piechart: + showLabels: true + colors: + healthy: "#22C55E" + warning: "#EAB308" + unhealthy: "#EF4444" + rows: + - name: "healthy" + value: 12 + - name: "warning" + value: 3 + - name: "unhealthy" + value: 1 + - name: "Memory by Service" + type: "bargauge" + bargauge: + unit: "bytes" + max: 2147483648 + thresholds: + - percent: 0 + color: "#3B82F6" + - percent: 70 + color: "#EAB308" + - percent: 90 + color: "#EF4444" + rows: + - name: "incident-commander" + value: 536870912 + - name: "config-db" + value: 1073741824 + - name: "nginx-ingress" + value: 805306368 + - name: "canary-checker" + value: 268435456 + - name: "Cluster Status" + type: "text" + rows: + - value: "All critical services operational. config-db pod in CrashLoopBackOff - investigating OOM kills." + - name: "Recent Deployments" + type: "table" + rows: + - service: "incident-commander" + version: "v1.4.200" + deployed: "2026-03-30T08:15:00Z" + status: "success" + - service: "canary-checker" + version: "v1.0.350" + deployed: "2026-03-29T12:00:00Z" + status: "success" + - service: "config-db" + version: "v2.1.0" + deployed: "2026-03-30T07:30:00Z" + status: "failed" + +dynamicSections: + - type: "changes" + title: "Permissions Added / Removed" + changes: + - id: "rbac-001" + date: "2026-03-30T09:12:00Z" + changeType: "PermissionAdded" + source: "azure-entra" + createdBy: "alice@flanksource.com" + configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + permission: + user: "alice@flanksource.com" + role: "db_owner" + group: "incident-responders" + description: "PermissionAdded: user alice@flanksource.com, role db_owner, group incident-responders" + status: "info" + createdAt: "2026-03-30T09:12:00Z" + - id: "rbac-002" + date: "2026-03-29T18:40:00Z" + changeType: "PermissionRemoved" + source: "azure-entra" + createdBy: "security-automation" + configId: "cfg-sql-001" + configName: "prod-sql-primary" + configType: "MSSQL::Database" + permission: + user: "contractor-temp" + role: "db_datareader" + description: "PermissionRemoved: user contractor-temp, role db_datareader" + status: "info" + createdAt: "2026-03-29T18:40:00Z" + - id: "rbac-006" + date: "2026-03-29T11:15:00Z" + changeType: "PermissionAdded" + source: "okta" + createdBy: "governance-bot" + configId: "cfg-keyvault-001" + configName: "prod-keyvault" + configType: "Azure::KeyVault" + permission: + user: "ops-auditor" + role: "Secrets Reader" + description: "PermissionAdded: user ops-auditor, role Secrets Reader" + status: "info" + createdAt: "2026-03-29T11:15:00Z" + - id: "rbac-004" + date: "2026-03-28T13:05:00Z" + changeType: "PermissionGranted" + source: "okta" + createdBy: "bob@flanksource.com" + configId: "cfg-analytics-001" + configName: "analytics-db" + configType: "MSSQL::Database" + description: "Granted db_ddladmin to deploy-bot on analytics-db" + status: "info" + createdAt: "2026-03-28T13:05:00Z" + - id: "rbac-005" + date: "2026-03-27T07:20:00Z" + changeType: "PermissionAdded" + source: "okta" + configId: "cfg-keyvault-001" + configName: "prod-keyvault" + configType: "Azure::KeyVault" + permission: + group: "break-glass-admins" + role: "Secrets Officer" + description: "PermissionAdded: role Secrets Officer, group break-glass-admins" + status: "info" + createdAt: "2026-03-27T07:20:00Z" + + - type: "changes" + title: "Backup Activity" + changes: + - id: "bak-001" + date: "2026-03-30T02:00:00Z" + changeType: "BackupStarted" + source: "aws-backup" + description: "Nightly snapshot started for incident-commander-db" + status: "info" + createdAt: "2026-03-30T02:00:00Z" + - id: "bak-002" + date: "2026-03-30T02:08:00Z" + changeType: "BackupCompleted" + source: "aws-backup" + description: "Nightly snapshot completed for incident-commander-db (4.3 GB)" + status: "info" + createdAt: "2026-03-30T02:08:00Z" + - id: "bak-003" + date: "2026-03-29T02:01:00Z" + changeType: "BackupFailed" + source: "aws-backup" + description: "Snapshot failed for incident-commander-db after storage timeout" + status: "high" + createdAt: "2026-03-29T02:01:00Z" + + - type: "changes" + title: "Deployment Changes" + changes: + - id: "dep-001" + date: "2026-03-30T08:15:00Z" + changeType: "diff" + source: "argocd" + createdBy: "deploy-bot" + description: "Deployment incident-commander image updated: v1.4.199 -> v1.4.200" + status: "low" + createdAt: "2026-03-30T08:15:00Z" + - id: "dep-002" + date: "2026-03-30T07:30:00Z" + changeType: "Pulled" + source: "kubernetes" + description: "Image flanksource/incident-commander:v1.4.200 pulled on node ip-10-0-1-42" + status: "info" + createdAt: "2026-03-30T07:30:00Z" + - id: "dep-003" + date: "2026-03-29T22:00:00Z" + changeType: "ScalingReplicaSet" + source: "kubernetes" + createdBy: "cluster-autoscaler" + description: "Deployment incident-commander scaled from 2 to 4 replicas" + status: "low" + createdAt: "2026-03-29T22:00:00Z" diff --git a/report/types.ts b/report/types.ts index 1bd9a1f1e..423e659c7 100644 --- a/report/types.ts +++ b/report/types.ts @@ -127,12 +127,23 @@ export interface ApplicationViewData { columnOptions?: Record; } +export interface ApplicationPermissionChange { + user?: string; + role?: string; + group?: string; +} + export interface ApplicationChange { id: string; date: string; changeType?: string; + category?: string; source?: string; createdBy?: string; + configId?: string; + configName?: string; + configType?: string; + permission?: ApplicationPermissionChange; description: string; status: string; createdAt: string; diff --git a/report/view-types.ts b/report/view-types.ts index 52626ea04..460f4ebab 100644 --- a/report/view-types.ts +++ b/report/view-types.ts @@ -101,9 +101,12 @@ export interface PanelResult { gauge?: GaugeConfig & { unit?: string }; bargauge?: BarGaugeConfig; timeseries?: TimeseriesConfig; + heatmap?: { mode?: string }; rows: Record[]; } +export type HeatmapVariant = 'calendar' | 'compact'; + export interface ViewSectionResult { title: string; icon?: string; diff --git a/views/render_facet.go b/views/render_facet.go index 6568d23c1..c3b437a8c 100644 --- a/views/render_facet.go +++ b/views/render_facet.go @@ -1,20 +1,8 @@ package views import ( - "archive/tar" - "bytes" - "compress/gzip" - "encoding/json" "fmt" - "io" - "io/fs" - "mime/multipart" - "os" - "os/exec" - "path/filepath" - "sync" - commonshttp "github.com/flanksource/commons/http" "github.com/flanksource/duty/connection" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" @@ -32,10 +20,8 @@ type facetViewSectionResult struct { View *facetViewPayload `json:"view,omitempty"` } -// facetViewPayload is used only for facet-html/facet-pdf rendering. -// api.ViewResult keeps Rows as json:"-" for regular API responses, but the facet -// report TSX reads table data from `rows`, so we inject Rows here without changing -// the public API shape. SectionResults is also wrapped so nested viewRef tables keep rows. +// facetViewPayload injects Rows into the JSON for facet rendering. +// api.ViewResult keeps Rows as json:"-" for regular API responses. type facetViewPayload struct { *api.ViewResult Rows []view.Row `json:"rows,omitempty"` @@ -106,6 +92,38 @@ func RenderMultiFacetPDF(ctx context.Context, multi *api.MultiViewResult, opts * return renderFacetWithData(ctx, newFacetMultiViewPayload(multi), "pdf", opts) } +const viewEntryFile = "ViewReport.tsx" + +func renderViewWithFacet(ctx context.Context, result *api.ViewResult, format string, opts *v1.FacetOptions) ([]byte, error) { + if result == nil { + return nil, fmt.Errorf("view result must not be nil") + } + return renderFacetWithData(ctx, newFacetViewPayload(result), format, opts) +} + +func renderFacetWithData(ctx context.Context, data any, format string, opts *v1.FacetOptions) ([]byte, error) { + if data == nil { + return nil, fmt.Errorf("data must not be nil") + } + + baseURL, token, timestampURL, err := resolveFacetConnection(ctx, opts) + if err != nil { + return nil, err + } + + if baseURL != "" { + return report.RenderHTTP(ctx, baseURL, token, data, format, viewEntryFile, report.RenderHTTPOptions{ + TimestampURL: timestampURL, + }) + } + + result, err := report.RenderCLI(data, format, viewEntryFile) + if err != nil { + return nil, err + } + return result.Data, nil +} + func resolveFacetConnection(ctx context.Context, opts *v1.FacetOptions) (baseURL, token, timestampURL string, err error) { if opts == nil { return "", "", "", nil @@ -137,248 +155,3 @@ func resolveFacetConnection(ctx context.Context, opts *v1.FacetOptions) (baseURL return baseURL, token, timestampURL, nil } - -func buildReportArchive() ([]byte, error) { - var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gw) - - err := fs.WalkDir(report.FS, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - data, err := report.FS.ReadFile(path) - if err != nil { - return err - } - if err := tw.WriteHeader(&tar.Header{ - Name: path, - Size: int64(len(data)), - Mode: 0600, - }); err != nil { - return err - } - _, err = tw.Write(data) - return err - }) - if err != nil { - return nil, err - } - - if err := tw.Close(); err != nil { - return nil, err - } - if err := gw.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func renderFacetHTTP(ctx context.Context, baseURL, token string, data any, format string, opts *v1.FacetOptions) ([]byte, error) { - archive, err := buildReportArchive() - if err != nil { - return nil, fmt.Errorf("build report archive: %w", err) - } - - dataJSON, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("marshal data: %w", err) - } - - optionsJSON, err := json.Marshal(map[string]any{ - "format": format, - "entryFile": "ViewReport.tsx", - }) - if err != nil { - return nil, fmt.Errorf("marshal options: %w", err) - } - - var body bytes.Buffer - mw := multipart.NewWriter(&body) - - fw, err := mw.CreateFormFile("archive", "report.tar.gz") - if err != nil { - return nil, fmt.Errorf("create archive form field: %w", err) - } - if _, err := fw.Write(archive); err != nil { - return nil, fmt.Errorf("write archive field: %w", err) - } - - if err := mw.WriteField("data", string(dataJSON)); err != nil { - return nil, fmt.Errorf("write data field: %w", err) - } - - if err := mw.WriteField("options", string(optionsJSON)); err != nil { - return nil, fmt.Errorf("write options field: %w", err) - } - - if err := mw.Close(); err != nil { - return nil, fmt.Errorf("close multipart writer: %w", err) - } - - client := commonshttp.NewClient().BaseURL(baseURL) - if token != "" { - client = client.Header("X-API-Key", token) - } - - response, err := client.R(ctx). - Header("Content-Type", mw.FormDataContentType()). - Post("/render", &body) - if err != nil { - return nil, fmt.Errorf("facet render request failed: %w", err) - } - if !response.IsOK() { - errBody, _ := response.AsString() - return nil, fmt.Errorf("facet render failed (status %d): %s", response.StatusCode, errBody) - } - - if format == "html" { - return io.ReadAll(response.Body) - } - - renderResult, err := response.AsJSON() - if err != nil { - return nil, fmt.Errorf("failed to parse render response: %w", err) - } - resultURL, _ := renderResult["url"].(string) - if resultURL == "" { - return nil, fmt.Errorf("render response missing 'url' field") - } - - pdfResponse, err := client.R(ctx).Get(resultURL) - if err != nil { - return nil, fmt.Errorf("failed to fetch rendered result: %w", err) - } - if !pdfResponse.IsOK() { - errBody, _ := pdfResponse.AsString() - return nil, fmt.Errorf("result fetch failed (status %d): %s", pdfResponse.StatusCode, errBody) - } - - return io.ReadAll(pdfResponse.Body) -} - -func renderFacetWithData(ctx context.Context, data any, format string, opts *v1.FacetOptions) ([]byte, error) { - if data == nil { - return nil, fmt.Errorf("data must not be nil") - } - - baseURL, token, _, err := resolveFacetConnection(ctx, opts) - if err != nil { - return nil, err - } - if baseURL != "" { - return renderFacetHTTP(ctx, baseURL, token, data, format, opts) - } - - return renderFacetCLI(ctx, data, format) -} - -func renderViewWithFacet(ctx context.Context, result *api.ViewResult, format string, opts *v1.FacetOptions) ([]byte, error) { - if result == nil { - return nil, fmt.Errorf("view result must not be nil") - } - - payload := newFacetViewPayload(result) - - baseURL, token, _, err := resolveFacetConnection(ctx, opts) - if err != nil { - return nil, err - } - if baseURL != "" { - return renderFacetHTTP(ctx, baseURL, token, payload, format, opts) - } - - return renderFacetCLI(ctx, payload, format) -} - -func renderFacetCLI(ctx context.Context, data any, format string) ([]byte, error) { - facetBin, err := exec.LookPath("facet") - if err != nil { - return nil, fmt.Errorf("facet not found on PATH: install with 'npm install -g @flanksource/facet'") - } - - srcDir, err := viewFacetSrcDir() - if err != nil { - return nil, fmt.Errorf("prepare facet src dir: %w", err) - } - - dataJSON, err := json.MarshalIndent(data, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal data: %w", err) - } - - dataFile, err := os.CreateTemp("", "facet-view-data-*.json") - if err != nil { - return nil, fmt.Errorf("create data temp file: %w", err) - } - defer os.Remove(dataFile.Name()) - - if _, err := dataFile.Write(dataJSON); err != nil { - return nil, fmt.Errorf("write data file: %w", err) - } - dataFile.Close() - - outFile, err := os.CreateTemp("", "facet-view-output-*."+format) - if err != nil { - return nil, fmt.Errorf("create output temp file: %w", err) - } - outFile.Close() - defer os.Remove(outFile.Name()) - - ctx.Logger.V(3).Infof("facet binary: %s, data size: %dKB", facetBin, len(dataJSON)/1024) - - var stderr bytes.Buffer - cmd := exec.Command(facetBin, format, "ViewReport.tsx", "-d", dataFile.Name(), "-o", outFile.Name()) - cmd.Dir = srcDir - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("facet %s failed: %w\n%s", format, err, stderr.String()) - } - - result, err := os.ReadFile(outFile.Name()) - if err != nil { - return nil, fmt.Errorf("read facet output: %w", err) - } - - ctx.Logger.V(3).Infof("Facet rendered %dKB of %s", len(result)/1024, format) - return result, nil -} - -var viewFacetSrcDir = sync.OnceValues(func() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - cacheDir = os.TempDir() - } - dir := filepath.Join(cacheDir, "incident-commander", "facet-report") - - if err := os.MkdirAll(dir, 0750); err != nil { - return "", fmt.Errorf("create cache dir: %w", err) - } - - if err := viewExtractReportFiles(dir); err != nil { - return "", err - } - - return dir, nil -}) - -func viewExtractReportFiles(destDir string) error { - return fs.WalkDir(report.FS, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if path == "." { - return nil - } - dest := filepath.Join(destDir, path) - if d.IsDir() { - return os.MkdirAll(dest, 0750) - } - data, err := report.FS.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(dest, data, 0600) - }) -} diff --git a/views/render_facet_test.go b/views/render_facet_test.go index 8fa7ee8c3..b098f5a10 100644 --- a/views/render_facet_test.go +++ b/views/render_facet_test.go @@ -9,13 +9,13 @@ import ( ginkgo "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - v1 "github.com/flanksource/incident-commander/api/v1" + "github.com/flanksource/incident-commander/report" ) // maxMultipartMemory is the max memory used when parsing multipart form data in tests (32MB). const maxMultipartMemory = 32 << 20 -var _ = ginkgo.Describe("renderFacetHTTP", func() { +var _ = ginkgo.Describe("RenderHTTP", func() { var ( server *httptest.Server pdfBytes = []byte("%PDF-1.4 test content") @@ -66,14 +66,7 @@ var _ = ginkgo.Describe("renderFacetHTTP", func() { }) ginkgo.It("fetches PDF via two-step render+download", func() { - opts := &v1.FacetOptions{ - URL: server.URL, - PDFOptions: &v1.FacetPDFOptions{ - PageSize: "A4", - }, - } - - result, err := renderFacetHTTP(DefaultContext, server.URL, "test-token", map[string]string{"key": "value"}, "pdf", opts) + result, err := report.RenderHTTP(DefaultContext, server.URL, "test-token", map[string]string{"key": "value"}, "pdf", "ViewReport.tsx") Expect(err).ToNot(HaveOccurred()) Expect(result).To(Equal(pdfBytes)) }) @@ -92,7 +85,7 @@ var _ = ginkgo.Describe("renderFacetHTTP", func() { })) defer htmlServer.Close() - result, err := renderFacetHTTP(DefaultContext, htmlServer.URL, "", map[string]string{"key": "value"}, "html", nil) + result, err := report.RenderHTTP(DefaultContext, htmlServer.URL, "", map[string]string{"key": "value"}, "html", "ViewReport.tsx") Expect(err).ToNot(HaveOccurred()) Expect(result).To(Equal(htmlBytes)) }) From 5aadc486c2fb4294ddb424311445cdd7d269b8ab Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Apr 2026 15:49:14 +0300 Subject: [PATCH 2/5] build(makefile): refactor build and CI targets, add gavel config and report generation - Add .gavel.yaml configuration for pre-commit build step - Update ci-test target to build binary and remove parallel flag - Add static dependency to dev target - Simplify tool installation commands by removing $(LOCALBIN)/ prefix - Add report/kitchen-sink.json build target for TypeScript report generation - Expand .gitignore with report artifacts and playwright config These changes improve build consistency and add support for report generation in the build pipeline. --- .gavel.yaml | 2 ++ .gitignore | 8 ++++++++ Makefile | 16 ++++++++++------ 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .gavel.yaml diff --git a/.gavel.yaml b/.gavel.yaml new file mode 100644 index 000000000..78e419d45 --- /dev/null +++ b/.gavel.yaml @@ -0,0 +1,2 @@ +pre: + - run: make build diff --git a/.gitignore b/.gitignore index c51c006bd..cc7af077d 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,11 @@ specs/ **/*.pem **/*.key auth/oidc/static/tailwind.min.js +**/*.pem +**/*.key +report/*.png +report/*.pdf +*.pdf +*.png +.playwright-mcp/ +out.* diff --git a/Makefile b/Makefile index e5f809fa6..92e60e42e 100644 --- a/Makefile +++ b/Makefile @@ -58,8 +58,9 @@ test: --succinct --label-filter='!ignore_local' .PHONY: ci-test -ci-test: - ginkgo -r -p --skip-package=tests/e2e --keep-going --junit-report junit-report.xml --github-output --output-dir test-reports --succinct +ci-test: $(TAILWIND_JS) $(LOCALBIN) + go build -o ./.bin/$(NAME) main.go + ginkgo -r --skip-package=tests/e2e --keep-going --junit-report junit-report.xml --github-output --output-dir test-reports --succinct .PHONY: e2e e2e: $(TAILWIND_JS) @@ -149,7 +150,7 @@ build: static go build -o ./.bin/$(NAME) -ldflags "-X \"main.version=$(VERSION_TAG) built at $(DATE)\"" main.go .PHONY: dev -dev: +dev: static # Disabling CGO because of slow build times in apple silicon (just experimenting) CGO_ENABLED=0 go build -v -o ./.bin/$(NAME) -gcflags="all=-N -l" main.go @@ -188,15 +189,15 @@ ginkgo: .PHONY: controller-gen controller-gen: install-deps $(LOCALBIN) - $(LOCALBIN)/deps install controller-gen@$(CONTROLLER_TOOLS_VERSION) --bin-dir $(LOCALBIN) + deps install controller-gen@$(CONTROLLER_TOOLS_VERSION) --bin-dir $(LOCALBIN) .PHONY: golangci-lint golangci-lint: install-deps $(LOCALBIN) - $(LOCALBIN)/deps install golangci/golangci-lint@v$(GOLANGCI_LINT_VERSION) --bin-dir $(LOCALBIN) + deps install golangci/golangci-lint@v$(GOLANGCI_LINT_VERSION) --bin-dir $(LOCALBIN) .PHONY: kustomize kustomize: install-deps $(LOCALBIN) - $(LOCALBIN)/deps install kubernetes-sigs/kustomize@$(KUSTOMIZE_VERSION) --bin-dir $(LOCALBIN) + deps install kubernetes-sigs/kustomize@$(KUSTOMIZE_VERSION) --bin-dir $(LOCALBIN) .PHONY: docs\:mcp docs\:mcp: ## Generate MCP tools reference documentation @@ -204,6 +205,9 @@ docs\:mcp: ## Generate MCP tools reference documentation go run ./hack/gen-mcp-docs > docs/mcp-tools.md @echo "Generated docs/mcp-tools.md" +report/kitchen-sink.json: report/build-kitchen-sink.ts report/testdata/kitchen-sink.yaml + cd report && ./node_modules/.bin/tsx build-kitchen-sink.ts + .PHONY: lint lint: golangci-lint $(GOLANGCI_LINT) run ./... From 9e675809ee596494cc09f88aa98a729f21d652f2 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Apr 2026 15:52:21 +0300 Subject: [PATCH 3/5] feat(api,db,application): Add access and access logs UI sections to applications and views Introduce new section types for displaying access control information and access logs in applications. Add AccessItem and AccessLogItem types to represent access data, along with AccessUIFilters and AccessLogsUIFilters for filtering. Implement GetAccessForUIRef and GetAccessLogsForUIRef database functions to query access and audit log data with support for filtering by search, config types, roles, user types, and time ranges. Add PDF rendering options to ViewSpec with FacetRenderOptions abstraction. Update all generated deepcopy functions and CRD schemas to support the new types. Add BuildCommit global variable for build metadata tracking. --- api/application.go | 50 ++++- api/global.go | 1 + api/v1/view_types.go | 6 +- api/v1/zz_generated.deepcopy.go | 31 ++- api/view.go | 24 +- api/zz_generated.deepcopy.go | 40 ++++ application/application.go | 26 +++ ...-control.flanksource.com_applications.yaml | 26 +++ ...ion-control.flanksource.com_playbooks.yaml | 29 ++- ...mission-control.flanksource.com_views.yaml | 58 ++++- config/schemas/application.schema.json | 48 ++++ config/schemas/view.schema.json | 101 ++++++++- db/applications.go | 208 ++++++++++++++++++ 13 files changed, 609 insertions(+), 39 deletions(-) diff --git a/api/application.go b/api/application.go index 460e3a4bc..2d9b309cc 100644 --- a/api/application.go +++ b/api/application.go @@ -8,21 +8,24 @@ import ( ) const ( - SectionTypeView = "view" - SectionTypeChanges = "changes" - SectionTypeConfigs = "configs" + SectionTypeView = "view" + SectionTypeChanges = "changes" + SectionTypeConfigs = "configs" + SectionTypeAccess = "access" + SectionTypeAccessLogs = "accessLogs" ) // ApplicationSection is a typed section in an application response. -// The Type field is one of "view", "changes", or "configs". // Only the field matching the type is populated. type ApplicationSection struct { - Type string `json:"type"` - Title string `json:"title"` - Icon string `json:"icon,omitempty"` - View *ApplicationViewData `json:"view,omitempty"` - Changes []ApplicationChange `json:"changes,omitempty"` - Configs []ApplicationConfigItem `json:"configs,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` + View *ApplicationViewData `json:"view,omitempty"` + Changes []ApplicationChange `json:"changes,omitempty"` + Configs []ApplicationConfigItem `json:"configs,omitempty"` + Access []AccessItem `json:"access,omitempty"` + AccessLogs []AccessLogItem `json:"accessLogs,omitempty"` } // ApplicationViewData holds the data-only fields from a resolved ViewRef section. @@ -47,6 +50,33 @@ type ApplicationConfigItem struct { Labels map[string]string `json:"labels,omitempty"` } +type AccessItem struct { + ConfigID string `json:"configId"` + ConfigName string `json:"configName"` + ConfigType string `json:"configType"` + UserID string `json:"userId"` + UserName string `json:"userName"` + Email string `json:"email"` + Role string `json:"role"` + UserType string `json:"userType"` + CreatedAt time.Time `json:"createdAt"` + LastSignedInAt *time.Time `json:"lastSignedInAt,omitempty"` + LastReviewedAt *time.Time `json:"lastReviewedAt,omitempty"` + IsStale bool `json:"isStale"` +} + +type AccessLogItem struct { + ConfigID string `json:"configId"` + ConfigName string `json:"configName"` + ConfigType string `json:"configType"` + UserID string `json:"userId"` + UserName string `json:"userName"` + CreatedAt time.Time `json:"createdAt"` + MFA bool `json:"mfa"` + Count int `json:"count"` + Properties map[string]string `json:"properties,omitempty"` +} + // Application is the schema that UI uses. type Application struct { ApplicationDetail `json:",inline"` diff --git a/api/global.go b/api/global.go index 7dd480f16..3eb8a3d25 100644 --- a/api/global.go +++ b/api/global.go @@ -6,6 +6,7 @@ import ( var ( BuildVersion string + BuildCommit string SystemUserID *uuid.UUID CanaryCheckerPath string diff --git a/api/v1/view_types.go b/api/v1/view_types.go index 45f2b5018..19bd500bf 100644 --- a/api/v1/view_types.go +++ b/api/v1/view_types.go @@ -111,8 +111,10 @@ type ViewSpec struct { // Include other views in the view Sections []api.ViewSection `json:"sections,omitempty" yaml:"sections,omitempty"` - // MCP defines metadata for MCP tool registration, controlling how - // this view appears to LLM clients (Claude, Gemini, Codex). + PDF *FacetOptions `json:"pdf,omitempty" yaml:"pdf,omitempty"` + + // MCP metadata for tool registration with LLM clients. + //+kubebuilder:validation:Optional MCP MCPMetadata `json:"mcp,omitempty" yaml:"mcp,omitempty"` } diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 7dd081748..94f2b0253 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -1596,11 +1596,7 @@ func (in *ExecAction) DeepCopy() *ExecAction { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FacetOptions) DeepCopyInto(out *FacetOptions) { *out = *in - if in.PDFOptions != nil { - in, out := &in.PDFOptions, &out.PDFOptions - *out = new(FacetPDFOptions) - (*in).DeepCopyInto(*out) - } + in.FacetRenderOptions.DeepCopyInto(&out.FacetRenderOptions) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FacetOptions. @@ -1668,6 +1664,26 @@ func (in *FacetPDFOptions) DeepCopy() *FacetPDFOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FacetRenderOptions) DeepCopyInto(out *FacetRenderOptions) { + *out = *in + if in.FacetPDFOptions != nil { + in, out := &in.FacetPDFOptions, &out.FacetPDFOptions + *out = new(FacetPDFOptions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FacetRenderOptions. +func (in *FacetRenderOptions) DeepCopy() *FacetRenderOptions { + if in == nil { + return nil + } + out := new(FacetRenderOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GCPConnection) DeepCopyInto(out *GCPConnection) { *out = *in @@ -3926,6 +3942,11 @@ func (in *ViewSpec) DeepCopyInto(out *ViewSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.PDF != nil { + in, out := &in.PDF, &out.PDF + *out = new(FacetOptions) + (*in).DeepCopyInto(*out) + } in.MCP.DeepCopyInto(&out.MCP) } diff --git a/api/view.go b/api/view.go index 80a4c44bc..6e2c60039 100644 --- a/api/view.go +++ b/api/view.go @@ -66,8 +66,10 @@ type ViewSection struct { // +kubebuilder:object:generate=true // UIRef references a native Flanksource UI component (changes or configs) type UIRef struct { - Changes *ChangesUIFilters `json:"changes,omitempty"` - Configs *ConfigsUIFilters `json:"configs,omitempty"` + Changes *ChangesUIFilters `json:"changes,omitempty"` + Configs *ConfigsUIFilters `json:"configs,omitempty"` + Access *AccessUIFilters `json:"access,omitempty"` + AccessLogs *AccessLogsUIFilters `json:"accessLogs,omitempty"` } // +kubebuilder:object:generate=true @@ -98,6 +100,24 @@ type ConfigsUIFilters struct { Health string `json:"health,omitempty"` // e.g. "-healthy,warning" } +// +kubebuilder:object:generate=true +type AccessUIFilters struct { + Search string `json:"search,omitempty"` // maps to ResourceSelector.Search for scoping configs + ConfigTypes string `json:"configTypes,omitempty"` // e.g. "AWS::IAM::Role,-Kubernetes::Pod" + Role string `json:"role,omitempty"` // e.g. "Owner,-Reader" + UserType string `json:"userType,omitempty"` // e.g. "Member,-Guest" + Stale string `json:"stale,omitempty"` // duration threshold, e.g. "2160h" (90 days) +} + +// +kubebuilder:object:generate=true +type AccessLogsUIFilters struct { + Search string `json:"search,omitempty"` // maps to ResourceSelector.Search for scoping configs + ConfigTypes string `json:"configTypes,omitempty"` // e.g. "AWS::IAM::Role" + From string `json:"from,omitempty"` // e.g. "720h" (30 days) + To string `json:"to,omitempty"` + MFA string `json:"mfa,omitempty"` // "true" or "false" +} + type SerializedView struct { SerializedSection `json:",inline"` Namespace string `json:"namespace,omitempty"` diff --git a/api/zz_generated.deepcopy.go b/api/zz_generated.deepcopy.go index e623fb80d..7727e26b1 100644 --- a/api/zz_generated.deepcopy.go +++ b/api/zz_generated.deepcopy.go @@ -9,6 +9,36 @@ import ( timex "time" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessLogsUIFilters) DeepCopyInto(out *AccessLogsUIFilters) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessLogsUIFilters. +func (in *AccessLogsUIFilters) DeepCopy() *AccessLogsUIFilters { + if in == nil { + return nil + } + out := new(AccessLogsUIFilters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AccessUIFilters) DeepCopyInto(out *AccessUIFilters) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AccessUIFilters. +func (in *AccessUIFilters) DeepCopy() *AccessUIFilters { + if in == nil { + return nil + } + out := new(AccessUIFilters) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AutoClose) DeepCopyInto(out *AutoClose) { *out = *in @@ -630,6 +660,16 @@ func (in *UIRef) DeepCopyInto(out *UIRef) { *out = new(ConfigsUIFilters) **out = **in } + if in.Access != nil { + in, out := &in.Access, &out.Access + *out = new(AccessUIFilters) + **out = **in + } + if in.AccessLogs != nil { + in, out := &in.AccessLogs, &out.AccessLogs + *out = new(AccessLogsUIFilters) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UIRef. diff --git a/application/application.go b/application/application.go index a55a31230..ae5a56368 100644 --- a/application/application.go +++ b/application/application.go @@ -184,6 +184,32 @@ func buildSection(ctx context.Context, section api.ViewSection) (api.Application return appSection, nil } + if section.UIRef.Access != nil { + appSection.Type = api.SectionTypeAccess + access, err := db.GetAccessForUIRef(ctx, section.UIRef.Access) + if err != nil { + return appSection, ctx.Oops().Errorf("failed to get access for section %q: %w", section.Title, err) + } + if access == nil { + access = []api.AccessItem{} + } + appSection.Access = access + return appSection, nil + } + + if section.UIRef.AccessLogs != nil { + appSection.Type = api.SectionTypeAccessLogs + logs, err := db.GetAccessLogsForUIRef(ctx, section.UIRef.AccessLogs) + if err != nil { + return appSection, ctx.Oops().Errorf("failed to get access logs for section %q: %w", section.Title, err) + } + if logs == nil { + logs = []api.AccessLogItem{} + } + appSection.AccessLogs = logs + return appSection, nil + } + return appSection, nil } diff --git a/config/crds/mission-control.flanksource.com_applications.yaml b/config/crds/mission-control.flanksource.com_applications.yaml index 551c8ae8b..790273f09 100644 --- a/config/crds/mission-control.flanksource.com_applications.yaml +++ b/config/crds/mission-control.flanksource.com_applications.yaml @@ -448,6 +448,32 @@ spec: description: UIRef references a native Flanksource UI component (changes or configs) properties: + access: + properties: + configTypes: + type: string + role: + type: string + search: + type: string + stale: + type: string + userType: + type: string + type: object + accessLogs: + properties: + configTypes: + type: string + from: + type: string + mfa: + type: string + search: + type: string + to: + type: string + type: object changes: description: |- ChangesUIFilters defines filters for the native Changes UI component. diff --git a/config/crds/mission-control.flanksource.com_playbooks.yaml b/config/crds/mission-control.flanksource.com_playbooks.yaml index 2ba338356..a3beb0a80 100644 --- a/config/crds/mission-control.flanksource.com_playbooks.yaml +++ b/config/crds/mission-control.flanksource.com_playbooks.yaml @@ -4255,24 +4255,21 @@ spec: type: string header: type: string - pdfOptions: + landscape: + type: boolean + margins: properties: - landscape: - type: boolean - margins: - properties: - bottom: - type: integer - left: - type: integer - right: - type: integer - top: - type: integer - type: object - pageSize: - type: string + bottom: + type: integer + left: + type: integer + right: + type: integer + top: + type: integer type: object + pageSize: + type: string timestampUrl: type: string url: diff --git a/config/crds/mission-control.flanksource.com_views.yaml b/config/crds/mission-control.flanksource.com_views.yaml index 6a9d73c4d..3c3b2b5bc 100644 --- a/config/crds/mission-control.flanksource.com_views.yaml +++ b/config/crds/mission-control.flanksource.com_views.yaml @@ -386,9 +386,7 @@ spec: type: object x-kubernetes-preserve-unknown-fields: true mcp: - description: |- - MCP defines metadata for MCP tool registration, controlling how - this view appears to LLM clients (Claude, Gemini, Codex). + description: MCP metadata for tool registration with LLM clients. properties: description: description: Description provides additional context for LLMs @@ -613,6 +611,34 @@ spec: - message: heatmap config not allowed for this type rule: 'self.type!=''heatmap'' ? !has(self.heatmap) : true' type: array + pdf: + properties: + connection: + type: string + footer: + type: string + header: + type: string + landscape: + type: boolean + margins: + properties: + bottom: + type: integer + left: + type: integer + right: + type: integer + top: + type: integer + type: object + pageSize: + type: string + timestampUrl: + type: string + url: + type: string + type: object queries: additionalProperties: properties: @@ -2153,6 +2179,32 @@ spec: description: UIRef references a native Flanksource UI component (changes or configs) properties: + access: + properties: + configTypes: + type: string + role: + type: string + search: + type: string + stale: + type: string + userType: + type: string + type: object + accessLogs: + properties: + configTypes: + type: string + from: + type: string + mfa: + type: string + search: + type: string + to: + type: string + type: object changes: description: |- ChangesUIFilters defines filters for the native Changes UI component. diff --git a/config/schemas/application.schema.json b/config/schemas/application.schema.json index 5a2f175df..1ac09bd18 100644 --- a/config/schemas/application.schema.json +++ b/config/schemas/application.schema.json @@ -3,6 +3,48 @@ "$id": "https://github.com/flanksource/incident-commander/api/v1/application", "$ref": "#/$defs/Application", "$defs": { + "AccessLogsUIFilters": { + "properties": { + "search": { + "type": "string" + }, + "configTypes": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "mfa": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "AccessUIFilters": { + "properties": { + "search": { + "type": "string" + }, + "configTypes": { + "type": "string" + }, + "role": { + "type": "string" + }, + "userType": { + "type": "string" + }, + "stale": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "Application": { "properties": { "kind": { @@ -582,6 +624,12 @@ }, "configs": { "$ref": "#/$defs/ConfigsUIFilters" + }, + "access": { + "$ref": "#/$defs/AccessUIFilters" + }, + "accessLogs": { + "$ref": "#/$defs/AccessLogsUIFilters" } }, "additionalProperties": false, diff --git a/config/schemas/view.schema.json b/config/schemas/view.schema.json index 913860ee8..ce95859a8 100644 --- a/config/schemas/view.schema.json +++ b/config/schemas/view.schema.json @@ -36,6 +36,48 @@ "additionalProperties": false, "type": "object" }, + "AccessLogsUIFilters": { + "properties": { + "search": { + "type": "string" + }, + "configTypes": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "mfa": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "AccessUIFilters": { + "properties": { + "search": { + "type": "string" + }, + "configTypes": { + "type": "string" + }, + "role": { + "type": "string" + }, + "userType": { + "type": "string" + }, + "stale": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "BadgeConfig": { "properties": { "color": { @@ -323,6 +365,54 @@ "additionalProperties": false, "type": "object" }, + "FacetOptions": { + "properties": { + "connection": { + "type": "string" + }, + "url": { + "type": "string" + }, + "pageSize": { + "type": "string" + }, + "landscape": { + "type": "boolean" + }, + "margins": { + "$ref": "#/$defs/FacetPDFMargins" + }, + "header": { + "type": "string" + }, + "footer": { + "type": "string" + }, + "timestampUrl": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "FacetPDFMargins": { + "properties": { + "top": { + "type": "integer" + }, + "bottom": { + "type": "integer" + }, + "left": { + "type": "integer" + }, + "right": { + "type": "integer" + } + }, + "additionalProperties": false, + "type": "object" + }, "FieldsV1": { "properties": {}, "additionalProperties": false, @@ -1012,6 +1102,12 @@ }, "configs": { "$ref": "#/$defs/ConfigsUIFilters" + }, + "access": { + "$ref": "#/$defs/AccessUIFilters" + }, + "accessLogs": { + "$ref": "#/$defs/AccessLogsUIFilters" } }, "additionalProperties": false, @@ -1266,9 +1362,12 @@ "type": "array", "description": "Include other views in the view" }, + "pdf": { + "$ref": "#/$defs/FacetOptions" + }, "mcp": { "$ref": "#/$defs/MCPMetadata", - "description": "MCP defines metadata for MCP tool registration, controlling how\nthis view appears to LLM clients (Claude, Gemini, Codex)." + "description": "MCP metadata for tool registration with LLM clients." } }, "additionalProperties": false, diff --git a/db/applications.go b/db/applications.go index f22106d16..bbcb595af 100644 --- a/db/applications.go +++ b/db/applications.go @@ -8,10 +8,12 @@ import ( "strings" "time" + "github.com/flanksource/commons/duration" "github.com/flanksource/duty" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" + "github.com/flanksource/duty/types" "github.com/google/uuid" "github.com/samber/lo" "go.opentelemetry.io/otel/trace" @@ -519,3 +521,209 @@ func parseIncludeExcludeList(s string) (included, excluded []string) { } return } + +func GetAccessForUIRef(ctx context.Context, filters *api.AccessUIFilters) ([]api.AccessItem, error) { + if filters == nil { + filters = &api.AccessUIFilters{} + } + + q := ctx.DB(). + Table("config_access_summary"). + Select(`config_access_summary.config_id, + config_access_summary.config_name, + config_access_summary.config_type, + config_access_summary.external_user_id, + config_access_summary."user", + config_access_summary.email, + config_access_summary.role, + config_access_summary.user_type, + config_access_summary.created_at, + config_access_summary.last_signed_in_at, + config_access_summary.last_reviewed_at`). + Order(`config_access_summary.config_name, config_access_summary."user"`) + + if filters.Search != "" { + configIDs, err := query.FindConfigIDsByResourceSelector(ctx, 0, types.ResourceSelector{Search: filters.Search}) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to resolve config selector") + } + if len(configIDs) == 0 { + return nil, nil + } + q = q.Where("config_access_summary.config_id IN (?)", configIDs) + } + + if filters.ConfigTypes != "" { + included, excluded := parseIncludeExcludeList(filters.ConfigTypes) + if len(included) > 0 { + q = q.Where("config_access_summary.config_type IN (?)", included) + } + if len(excluded) > 0 { + q = q.Where("config_access_summary.config_type NOT IN (?)", excluded) + } + } + + if filters.Role != "" { + included, excluded := parseIncludeExcludeList(filters.Role) + if len(included) > 0 { + q = q.Where("config_access_summary.role IN (?)", included) + } + if len(excluded) > 0 { + q = q.Where("config_access_summary.role NOT IN (?)", excluded) + } + } + + if filters.UserType != "" { + included, excluded := parseIncludeExcludeList(filters.UserType) + if len(included) > 0 { + q = q.Where("config_access_summary.user_type IN (?)", included) + } + if len(excluded) > 0 { + q = q.Where("config_access_summary.user_type NOT IN (?)", excluded) + } + } + + var rows []RBACAccessRow + if err := q.Find(&rows).Error; err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to query access") + } + + var staleCutoff time.Time + if filters.Stale != "" { + d, err := duration.ParseDuration(filters.Stale) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "invalid stale filter %q", filters.Stale) + } + staleCutoff = time.Now().Add(-time.Duration(d)) + } + + items := make([]api.AccessItem, 0, len(rows)) + for _, r := range rows { + isStale := !staleCutoff.IsZero() && (r.LastSignedInAt == nil || r.LastSignedInAt.Before(staleCutoff)) + if !staleCutoff.IsZero() && !isStale { + continue + } + items = append(items, api.AccessItem{ + ConfigID: r.ConfigID.String(), + ConfigName: r.ConfigName, + ConfigType: r.ConfigType, + UserID: r.UserID.String(), + UserName: r.UserName, + Email: r.Email, + Role: r.Role, + UserType: r.UserType, + CreatedAt: r.CreatedAt, + LastSignedInAt: r.LastSignedInAt, + LastReviewedAt: r.LastReviewedAt, + IsStale: isStale, + }) + } + + return items, nil +} + +func GetAccessLogsForUIRef(ctx context.Context, filters *api.AccessLogsUIFilters) ([]api.AccessLogItem, error) { + if filters == nil { + filters = &api.AccessLogsUIFilters{} + } + + q := ctx.DB(). + Table("config_access_logs"). + Select(`config_access_logs.config_id, + config_items.name AS config_name, + config_items.type AS config_type, + config_access_logs.external_user_id, + external_users.name AS user_name, + config_access_logs.created_at, + config_access_logs.mfa, + config_access_logs.count, + config_access_logs.properties`). + Joins("LEFT JOIN config_items ON config_items.id = config_access_logs.config_id"). + Joins("LEFT JOIN external_users ON external_users.id = config_access_logs.external_user_id"). + Order("config_access_logs.created_at DESC") + + if filters.Search != "" { + configIDs, err := query.FindConfigIDsByResourceSelector(ctx, 0, types.ResourceSelector{Search: filters.Search}) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to resolve config selector") + } + if len(configIDs) == 0 { + return nil, nil + } + q = q.Where("config_access_logs.config_id IN (?)", configIDs) + } + + if filters.ConfigTypes != "" { + included, excluded := parseIncludeExcludeList(filters.ConfigTypes) + if len(included) > 0 { + q = q.Where("config_items.type IN (?)", included) + } + if len(excluded) > 0 { + q = q.Where("config_items.type NOT IN (?)", excluded) + } + } + + if filters.From != "" { + d, err := duration.ParseDuration(filters.From) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "invalid from filter %q", filters.From) + } + q = q.Where("config_access_logs.created_at >= ?", time.Now().Add(-time.Duration(d))) + } + + if filters.To != "" { + d, err := duration.ParseDuration(filters.To) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "invalid to filter %q", filters.To) + } + q = q.Where("config_access_logs.created_at <= ?", time.Now().Add(-time.Duration(d))) + } + + switch filters.MFA { + case "true": + q = q.Where("config_access_logs.mfa = true") + case "false": + q = q.Where("config_access_logs.mfa = false") + } + + type accessLogRow struct { + ConfigID uuid.UUID `gorm:"column:config_id"` + ConfigName string `gorm:"column:config_name"` + ConfigType string `gorm:"column:config_type"` + ExternalUserID uuid.UUID `gorm:"column:external_user_id"` + UserName string `gorm:"column:user_name"` + CreatedAt time.Time `gorm:"column:created_at"` + MFA bool `gorm:"column:mfa"` + Count *int `gorm:"column:count"` + Properties types.JSONMap `gorm:"column:properties"` + } + + var rows []accessLogRow + if err := q.Scan(&rows).Error; err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to query access logs") + } + + items := make([]api.AccessLogItem, len(rows)) + for i, r := range rows { + var props map[string]string + if r.Properties != nil { + props = make(map[string]string, len(r.Properties)) + for k, v := range r.Properties { + props[k] = fmt.Sprintf("%v", v) + } + } + items[i] = api.AccessLogItem{ + ConfigID: r.ConfigID.String(), + ConfigName: r.ConfigName, + ConfigType: r.ConfigType, + UserID: r.ExternalUserID.String(), + UserName: r.UserName, + CreatedAt: r.CreatedAt, + MFA: r.MFA, + Count: lo.FromPtr(r.Count), + Properties: props, + } + } + + return items, nil +} From d2e2949cf68f4478c444f2094921004e8aab2306 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Apr 2026 15:52:46 +0300 Subject: [PATCH 4/5] feat(sdk): detect and handle HTML responses from misconfigured backends Add detection for HTML responses on JSON endpoints to help users identify when their backend URL points to the frontend instead of the /api endpoint. Changes: - Introduce ErrHTMLResponse error for when server returns HTML instead of JSON - Add decodeJSON() helper that checks Content-Type header and response body for HTML indicators - Add looksLikeHTML() to detect HTML by content-type or body prefix - Update GetConnection() and TestConnection() to use decodeJSON() - Add Accept: application/json header to all requests - Add comprehensive test suite covering HTML detection scenarios This improves the user experience by providing clear error messages when misconfiguration occurs. --- sdk/client.go | 39 +++++++++++++++++++++++-- sdk/client_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 sdk/client_test.go diff --git a/sdk/client.go b/sdk/client.go index 661ac0988..5b2abf292 100644 --- a/sdk/client.go +++ b/sdk/client.go @@ -2,13 +2,21 @@ package sdk import ( "context" + "encoding/json" + "errors" "fmt" "net/url" + "strings" "github.com/flanksource/commons/http" "github.com/flanksource/duty/models" ) +// ErrHTMLResponse is returned when the server responded with HTML on a JSON +// endpoint — typically because the configured server URL points at the +// user-facing frontend rather than the /api backend. +var ErrHTMLResponse = errors.New("server returned HTML instead of JSON (is the backend at /api?)") + type Client struct { *http.Client } @@ -18,11 +26,35 @@ func New(serverURL, token string) *Client { Client: http.NewClient(). BaseURL(serverURL). Header("Authorization", "Bearer "+token). + Header("Accept", "application/json"). Header("Content-Type", "application/json"). UserAgent("mission-control-cli"), } } +// decodeJSON parses a response body as JSON, returning ErrHTMLResponse if the +// body looks like HTML (frontend served instead of backend JSON). +func decodeJSON(r *http.Response, out any) error { + body, err := r.AsString() + if err != nil { + return err + } + if looksLikeHTML(r.Header.Get("Content-Type"), body) { + return ErrHTMLResponse + } + if err := json.Unmarshal([]byte(body), out); err != nil { + return fmt.Errorf("failed to decode JSON response: %w", err) + } + return nil +} + +func looksLikeHTML(contentType, body string) bool { + if strings.Contains(strings.ToLower(contentType), "text/html") { + return true + } + return strings.HasPrefix(strings.TrimLeft(body, " \t\r\n"), "<") +} + func (c *Client) GetConnection(name, namespace string) (*models.Connection, error) { var connections []models.Connection r, err := c.R(context.Background()). @@ -37,7 +69,7 @@ func (c *Client) GetConnection(name, namespace string) (*models.Connection, erro if !r.IsOK() { return nil, fmt.Errorf("server returned %d", r.StatusCode) } - if err := r.Into(&connections); err != nil { + if err := decodeJSON(r, &connections); err != nil { return nil, err } if len(connections) == 0 { @@ -74,9 +106,12 @@ func (c *Client) TestConnection(id string) (*TestResult, error) { } if !r.IsOK() { body, _ := r.AsString() + if looksLikeHTML(r.Header.Get("Content-Type"), body) { + return nil, ErrHTMLResponse + } return nil, fmt.Errorf("test failed (%d): %s", r.StatusCode, body) } - if err := r.Into(&result); err != nil { + if err := decodeJSON(r, &result); err != nil { return &result, err } return &result, nil diff --git a/sdk/client_test.go b/sdk/client_test.go new file mode 100644 index 000000000..d7a03515e --- /dev/null +++ b/sdk/client_test.go @@ -0,0 +1,73 @@ +package sdk + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSDK(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "SDK") +} + +var _ = ginkgo.Describe("GetConnection HTML detection", func() { + ginkgo.It("returns ErrHTMLResponse when server returns HTML with 200 OK", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`Frontend`)) + })) + defer server.Close() + + client := New(server.URL, "fake-token") + _, err := client.GetConnection("any", "default") + Expect(errors.Is(err, ErrHTMLResponse)).To(BeTrue(), "got: %v", err) + }) + + ginkgo.It("returns ErrHTMLResponse when body starts with '<' even without HTML content-type", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`no content type header`)) + })) + defer server.Close() + + client := New(server.URL, "fake-token") + _, err := client.GetConnection("any", "default") + Expect(errors.Is(err, ErrHTMLResponse)).To(BeTrue(), "got: %v", err) + }) + + ginkgo.It("decodes JSON successfully when server returns valid JSON", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[{"name":"azure-bearer","namespace":"monitoring","type":"http"}]`)) + })) + defer server.Close() + + client := New(server.URL, "fake-token") + conn, err := client.GetConnection("azure-bearer", "monitoring") + Expect(err).ToNot(HaveOccurred()) + Expect(conn).ToNot(BeNil()) + Expect(conn.Name).To(Equal("azure-bearer")) + }) +}) + +var _ = ginkgo.Describe("TestConnection HTML detection", func() { + ginkgo.It("returns ErrHTMLResponse on HTML error page (405 from frontend proxy)", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`405`)) + })) + defer server.Close() + + client := New(server.URL, "fake-token") + _, err := client.TestConnection("00000000-0000-0000-0000-000000000000") + Expect(errors.Is(err, ErrHTMLResponse)).To(BeTrue(), "got: %v", err) + }) +}) From 8d61cff1133fc0ab3deff5bba655ee4f4a8d7446 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 17 Apr 2026 16:58:10 +0300 Subject: [PATCH 5/5] refactor(artifacts,auth,connection,db,cmd): refactor artifact download and OIDC flows with improved error handling Major refactoring across multiple components: **artifacts/controllers.go:** - Refactored ListArtifacts to use switch statement for path-based routing - Added support for config_change artifact listing - Simplified DownloadArtifact to use ctx.Blobs() instead of connection-based file access - Improved error messages and removed unnecessary intermediate variables **auth/middleware.go & auth/oidc/models.go:** - Renamed api.FrontendURL to api.PublicURL for consistency - Replaced custom StringList type with pq.StringArray - Removed unnecessary database/sql/driver import **auth/oidcclient/oidcclient.go:** - Changed RandomBase64 to panic on crypto/rand failure instead of returning error - Simplified function signature from (string, error) to string **cmd/connection_browser.go:** - Added loginWaitResult struct for better result handling - Implemented interactive token selection with huh library - Added signal handling for strict mode (required tokens) - Improved timeout and auto-detection logic - Added displayCapturedState function for cleaner output - Enhanced user prompts and feedback messages **connection/jwt.go:** - Added ScopeCount() method to JWT - Fixed audience parsing to handle both string and array formats **db/rbac.go:** - Added QueryLogSummary() method to RBACAccessRow - Refactored GetRBACAccess with query logging and recursive config expansion - Added GetRBACAccessByConfigIDs convenience function - Added GetGroupMembersForConfigs for audit reporting - Removed applyAccessSelectors in favor of inline logic Refs #123 --- artifacts/controllers.go | 58 ++-- auth/middleware.go | 6 +- auth/oidc/models.go | 62 ++--- auth/oidc/storage.go | 5 +- auth/oidc_validate.go | 2 +- auth/oidcclient/oidcclient.go | 6 +- cmd/connection_browser.go | 305 +++++++++++++++++--- connection/jwt.go | 11 + db/rbac.go | 133 +++++++-- go.sum | 445 ++++++++++++++++++++++++++++++ main.go | 1 + mcp/access.go | 2 +- tests/e2e/oidc/oidc_login_test.go | 6 +- tests/e2e/oidc/suite_test.go | 2 +- 14 files changed, 895 insertions(+), 149 deletions(-) diff --git a/artifacts/controllers.go b/artifacts/controllers.go index bf29057b5..aaf876102 100644 --- a/artifacts/controllers.go +++ b/artifacts/controllers.go @@ -6,10 +6,8 @@ import ( "strings" "time" - "github.com/flanksource/artifacts" "github.com/flanksource/commons/logger" "github.com/flanksource/duty/api" - pkgConnection "github.com/flanksource/duty/connection" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" @@ -17,7 +15,6 @@ import ( "github.com/labstack/echo/v4" "github.com/flanksource/duty/rbac/policy" - "github.com/flanksource/incident-commander/db" echoSrv "github.com/flanksource/incident-commander/echo" "github.com/flanksource/incident-commander/rbac" ) @@ -32,6 +29,7 @@ func RegisterRoutes(e *echo.Echo) { g := e.Group(fmt.Sprintf("/%s", "artifacts"), rbac.Authorization(policy.ObjectArtifact, policy.ActionRead)) g.GET("/list/check/:id/:check_time", ListArtifacts) g.GET("/list/playbook_run/:id", ListArtifacts) + g.GET("/list/config_change/:id", ListArtifacts) g.GET("/download/:id", DownloadArtifact) } @@ -45,19 +43,24 @@ func ListArtifacts(c echo.Context) error { return api.WriteError(c, api.Errorf(api.EINVALID, "invalid id(%s). must be a uuid. %v", _id, err)) } - _checkTime := c.Param("check_time") - checkTime, err := time.Parse(time.RFC3339, _checkTime) - if err != nil { - return api.WriteError(c, api.Errorf(api.EINVALID, "invalid check_time(%s). must be in RFC3339", _checkTime)) - } - var artifacts []models.Artifact - if strings.Contains(c.Path(), "/list/check/") { + switch { + case strings.Contains(c.Path(), "/list/check/"): + _checkTime := c.Param("check_time") + checkTime, err := time.Parse(time.RFC3339, _checkTime) + if err != nil { + return api.WriteError(c, api.Errorf(api.EINVALID, "invalid check_time(%s). must be in RFC3339", _checkTime)) + } artifacts, err = query.ArtifactsByCheck(ctx, id, checkTime) if err != nil { return api.WriteError(c, err) } - } else { + case strings.Contains(c.Path(), "/list/config_change/"): + artifacts, err = query.ArtifactsByConfigChange(ctx, id) + if err != nil { + return api.WriteError(c, err) + } + default: artifacts, err = query.ArtifactsByPlaybookRun(ctx, id) if err != nil { return api.WriteError(c, err) @@ -70,38 +73,29 @@ func ListArtifacts(c echo.Context) error { func DownloadArtifact(c echo.Context) error { ctx := c.Request().Context().(context.Context) - _id := c.Param("id") - artifactID, err := uuid.Parse(_id) + artifactID, err := uuid.Parse(c.Param("id")) if err != nil { - return api.WriteError(c, api.Errorf(api.EINVALID, "invalid id(%s). must be a uuid. %v", _id, err)) + return api.WriteError(c, api.Errorf(api.EINVALID, "invalid id: %v", err)) } - artifact, err := db.FindArtifact(ctx, artifactID) + blobs, err := ctx.Blobs() if err != nil { return api.WriteError(c, err) - } else if artifact == nil { - return api.WriteError(c, api.Errorf(api.ENOTFOUND, "artifact(%s) was not found", artifactID)) } + defer blobs.Close() - conn, err := pkgConnection.Get(ctx, artifact.ConnectionID.String()) + data, err := blobs.Read(artifactID) if err != nil { return api.WriteError(c, err) - } else if conn == nil { - return api.WriteError(c, api.Errorf(api.ENOTFOUND, "artifact's connection was not found")) } + defer data.Content.Close() - // TODO: Pool connection to the underlying filesystem - fs, err := artifacts.GetFSForConnection(ctx, *conn) - if err != nil { - return api.WriteError(c, err) + c.Response().Header().Set("Content-Type", data.ContentType) + if data.ContentLength > 0 { + c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", data.ContentLength)) } - defer fs.Close() - - file, err := fs.Read(ctx, artifact.Path) - if err != nil { - return api.WriteError(c, err) + if filename := c.QueryParam("filename"); filename != "" { + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) } - defer file.Close() - - return c.Stream(http.StatusOK, artifact.ContentType, file) + return c.Stream(http.StatusOK, data.ContentType, data.Content) } diff --git a/auth/middleware.go b/auth/middleware.go index 158e78dcb..89d4be27b 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -72,7 +72,7 @@ var skipAuthPathsExact = []string{ "/keys", "/revoke", "/device_authorization", - "/endsession", + "/end_session", // --end:: Standard OIDC endpoints } @@ -128,10 +128,10 @@ func Middleware(ctx context.Context, e *echo.Echo) error { if OIDCEnabled { kratosChecker := NewKratosCredentialChecker(kratosMiddleware) - if err := oidc.MountRoutes(e, ctx, api.FrontendURL, OIDCSigningKeyPath, kratosChecker, LookupKratosPersonByUsername); err != nil { + if err := oidc.MountRoutes(e, ctx, api.PublicURL, OIDCSigningKeyPath, kratosChecker, LookupKratosPersonByUsername); err != nil { return fmt.Errorf("failed to mount OIDC routes: %w", err) } - logger.Infof("OIDC provider enabled at %s (Kratos auth)", api.FrontendURL) + logger.Infof("OIDC provider enabled at %s (Kratos auth)", api.PublicURL) } case Clerk: diff --git a/auth/oidc/models.go b/auth/oidc/models.go index 7be575b1b..626600a2b 100644 --- a/auth/oidc/models.go +++ b/auth/oidc/models.go @@ -1,7 +1,6 @@ package oidc import ( - "database/sql/driver" "time" "github.com/lib/pq" @@ -11,34 +10,23 @@ import ( const ClientID = "mc-cli" -// StringList is a PostgreSQL text[] compatible type. -type StringList pq.StringArray - -func (s StringList) Value() (driver.Value, error) { - return pq.StringArray(s).Value() -} - -func (s *StringList) Scan(src any) error { - return (*pq.StringArray)(s).Scan(src) -} - // AuthRequest implements op.AuthRequest backed by the oidc_auth_requests table. type AuthRequest struct { - ID string `gorm:"primaryKey;column:id"` - ClientID string `gorm:"column:client_id;not null"` - RedirectURI string `gorm:"column:redirect_uri;not null"` - Scopes StringList `gorm:"column:scopes;type:text[]"` - State string `gorm:"column:state"` - Nonce string `gorm:"column:nonce"` - ResponseType string `gorm:"column:response_type;not null"` - CodeChallenge string `gorm:"column:code_challenge"` - CodeChallengeMethod string `gorm:"column:code_challenge_method"` - Subject string `gorm:"column:subject"` - AuthTime *time.Time `gorm:"column:auth_time"` - Code *string `gorm:"column:code"` - IsDone bool `gorm:"column:done;default:false"` - CreatedAt time.Time `gorm:"column:created_at"` - ExpiresAt time.Time `gorm:"column:expires_at"` + ID string `gorm:"primaryKey;column:id"` + ClientID string `gorm:"column:client_id;not null"` + RedirectURI string `gorm:"column:redirect_uri;not null"` + Scopes pq.StringArray `gorm:"column:scopes;type:text[]"` + State string `gorm:"column:state"` + Nonce string `gorm:"column:nonce"` + ResponseType string `gorm:"column:response_type;not null"` + CodeChallenge string `gorm:"column:code_challenge"` + CodeChallengeMethod string `gorm:"column:code_challenge_method"` + Subject string `gorm:"column:subject"` + AuthTime *time.Time `gorm:"column:auth_time"` + Code *string `gorm:"column:code"` + IsDone bool `gorm:"column:done;default:false"` + CreatedAt time.Time `gorm:"column:created_at"` + ExpiresAt time.Time `gorm:"column:expires_at"` } func (AuthRequest) TableName() string { return "oidc_auth_requests" } @@ -76,15 +64,15 @@ func (a *AuthRequest) Done() bool { return a.IsDone } // RefreshToken is backed by the oidc_refresh_tokens table. type RefreshToken struct { - ID string `gorm:"primaryKey;column:id"` - Token string `gorm:"column:token;not null;uniqueIndex"` - ClientID string `gorm:"column:client_id;not null"` - Subject string `gorm:"column:subject;not null"` - Scopes StringList `gorm:"column:scopes;type:text[]"` - AuthTime time.Time `gorm:"column:auth_time;not null"` - RotationID string `gorm:"column:rotation_id;not null"` - CreatedAt time.Time `gorm:"column:created_at"` - ExpiresAt time.Time `gorm:"column:expires_at"` + ID string `gorm:"primaryKey;column:id"` + Token string `gorm:"column:token;not null;uniqueIndex"` + ClientID string `gorm:"column:client_id;not null"` + Subject string `gorm:"column:subject;not null"` + Scopes pq.StringArray `gorm:"column:scopes;type:text[]"` + AuthTime time.Time `gorm:"column:auth_time;not null"` + RotationID string `gorm:"column:rotation_id;not null"` + CreatedAt time.Time `gorm:"column:created_at"` + ExpiresAt time.Time `gorm:"column:expires_at"` } func (RefreshToken) TableName() string { return "oidc_refresh_tokens" } @@ -96,7 +84,7 @@ func (r *RefreshToken) GetClientID() string { return r.ClientID } func (r *RefreshToken) GetScopes() []string { return []string(r.Scopes) } func (r *RefreshToken) GetSubject() string { return r.Subject } func (r *RefreshToken) SetCurrentScopes(scopes []string) { - r.Scopes = StringList(scopes) + r.Scopes = pq.StringArray(scopes) } // PublicKey is backed by the oidc_public_keys table. diff --git a/auth/oidc/storage.go b/auth/oidc/storage.go index 476fefbf3..4ec904762 100644 --- a/auth/oidc/storage.go +++ b/auth/oidc/storage.go @@ -16,6 +16,7 @@ import ( "github.com/flanksource/duty/models" "github.com/go-jose/go-jose/v4" "github.com/google/uuid" + "github.com/lib/pq" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "gorm.io/gorm" @@ -61,7 +62,7 @@ func (s *Storage) CreateAuthRequest(_ gocontext.Context, req *oidc.AuthRequest, ID: uuid.New().String(), ClientID: req.ClientID, RedirectURI: req.RedirectURI, - Scopes: StringList(req.Scopes), + Scopes: pq.StringArray(req.Scopes), State: req.State, Nonce: req.Nonce, ResponseType: string(req.ResponseType), @@ -135,7 +136,7 @@ func (s *Storage) CreateAccessAndRefreshTokens(_ gocontext.Context, req op.Token Token: hashToken(rawRefreshToken), ClientID: clientID, Subject: req.GetSubject(), - Scopes: StringList(req.GetScopes()), + Scopes: pq.StringArray(req.GetScopes()), AuthTime: now, RotationID: rotationID, CreatedAt: now, diff --git a/auth/oidc_validate.go b/auth/oidc_validate.go index 3ca910140..499761d89 100644 --- a/auth/oidc_validate.go +++ b/auth/oidc_validate.go @@ -35,7 +35,7 @@ func authenticateOIDCToken(c echo.Context, tokenStr string) (bool, error) { return false, nil } - issuer := strings.TrimRight(api.FrontendURL, "/") + issuer := strings.TrimRight(api.PublicURL, "/") var lastErr error for _, pub := range keys { diff --git a/auth/oidcclient/oidcclient.go b/auth/oidcclient/oidcclient.go index 733c7ac13..2a7daef13 100644 --- a/auth/oidcclient/oidcclient.go +++ b/auth/oidcclient/oidcclient.go @@ -62,12 +62,12 @@ func GeneratePKCE() (verifier, challenge string, err error) { return } -func RandomBase64(n int) (string, error) { +func RandomBase64(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { - return "", err + panic("crypto/rand failed: " + err.Error()) } - return base64.RawURLEncoding.EncodeToString(b), nil + return base64.RawURLEncoding.EncodeToString(b) } func ExchangeCode(tokenEndpoint, code, redirectURI, verifier string) (*Tokens, error) { diff --git a/cmd/connection_browser.go b/cmd/connection_browser.go index 61dfa7a2d..9c9ce776d 100644 --- a/cmd/connection_browser.go +++ b/cmd/connection_browser.go @@ -7,12 +7,14 @@ import ( "fmt" "net/url" "os" + "os/signal" "sort" "strings" "time" gocontext "context" + "github.com/charmbracelet/huh" "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/page" @@ -32,6 +34,13 @@ import ( "github.com/flanksource/incident-commander/connection" ) +type loginWaitResult struct { + AutoDetected bool + Interrupted bool + TimedOut bool + Tokens map[string]string +} + type browserLoginFlags struct { Name string Namespace string @@ -39,6 +48,7 @@ type browserLoginFlags struct { Domains []string WaitForURL string Timeout time.Duration + Wait time.Duration Cookies bool Session bool Bearer bool @@ -112,6 +122,7 @@ func init() { connectionLoginCmd.PersistentFlags().StringSliceVar(&browserFlags.Domains, "domains", nil, "Domains to capture cookies from") connectionLoginCmd.PersistentFlags().StringVar(&browserFlags.WaitForURL, "wait-for-url", "", "Auto-detect login completion when URL matches this pattern") connectionLoginCmd.PersistentFlags().DurationVar(&browserFlags.Timeout, "timeout", 5*time.Minute, "Timeout for browser login") + connectionLoginCmd.PersistentFlags().DurationVar(&browserFlags.Wait, "wait", 5*time.Minute, "How long to auto-detect a valid bearer token before prompting. Use 0 to disable auto-detect and prompt immediately.") connectionLoginCmd.PersistentFlags().BoolVar(&browserFlags.Cookies, "cookies", false, "Capture cookies") connectionLoginCmd.PersistentFlags().BoolVar(&browserFlags.Session, "session", false, "Capture sessionStorage (MSAL token cache)") connectionLoginCmd.PersistentFlags().BoolVar(&browserFlags.Bearer, "bearer", false, "Extract bearer tokens from MSAL session cache") @@ -166,10 +177,16 @@ func runBrowserLogin(cmd *cobra.Command, args []string) error { browserFlags.Cookies = true } - data, err := launchBrowserAndCapture(cmd.Context(), browserFlags) + data, cleanup, err := launchBrowserAndCapture(cmd.Context(), browserFlags) + if cleanup != nil { + defer cleanup() + } if err != nil { return err } + if data == nil { + return nil + } if browserFlags.RequireBearerAud != "" || browserFlags.RequireBearerScope != "" { if !hasRequiredToken(data.BearerTokens, browserFlags) { @@ -180,7 +197,7 @@ func runBrowserLogin(cmd *cobra.Command, args []string) error { return saveConnection(cmd, browserFlags, data) } -func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*browserSessionData, error) { +func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*browserSessionData, func(), error) { userDataDir := ProfileDir(flags.Namespace, flags.Name) opts := append(chromedp.DefaultExecAllocatorOptions[:], @@ -190,33 +207,49 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b ) allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...) - defer allocCancel() - browserCtx, browserCancel := chromedp.NewContext(allocCtx) - defer browserCancel() + + cleanup := func() { + done := make(chan struct{}) + go func() { + browserCancel() + allocCancel() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + fmt.Fprintln(os.Stderr, "Warning: browser teardown timed out after 10s") + } + } if err := chromedp.Run(browserCtx, chromedp.Navigate(flags.URL)); err != nil { - return nil, fmt.Errorf("failed to navigate to %s: %w", flags.URL, err) + return nil, cleanup, fmt.Errorf("failed to navigate to %s: %w", flags.URL, err) } fmt.Fprintf(os.Stderr, "Browser opened at %s\n", flags.URL) - if flags.Bearer { - fmt.Fprintln(os.Stderr, "Please log in. Will auto-detect when a valid token is available (or press Enter to skip).") - } else { + strict := flags.RequireBearerAud != "" || flags.RequireBearerScope != "" + switch { + case strict: + waiting := requiredTokenLabel(flags) + fmt.Fprintf(os.Stderr, "Please log in. Waiting for %s token. Enter/Ctrl+C will not exit — press twice to quit.\n", waiting) + case flags.Bearer && flags.Wait > 0: + fmt.Fprintln(os.Stderr, "Please log in. Will auto-detect when a valid token is available. Press Enter or Ctrl+C to pick a token manually.") + case flags.Bearer: + fmt.Fprintln(os.Stderr, "Please log in. Press Enter or Ctrl+C when done to pick a token.") + default: fmt.Fprintln(os.Stderr, "Please log in. Press Enter when done.") } autoSelectAccountPicker(browserCtx) - waitForLoginComplete(browserCtx, flags) + result := waitForLoginComplete(browserCtx, flags) data := &browserSessionData{} - verbose := clicky.Flags.LevelCount - if flags.Cookies { cookies, err := extractCookies(browserCtx, flags.Domains) if err != nil { - return nil, err + return nil, cleanup, err } data.Cookies = cookies } @@ -224,16 +257,42 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b if flags.Session || flags.Bearer { session, err := extractSessionStorage(browserCtx) if err != nil { - return nil, err + return nil, cleanup, err } data.SessionStorage = session if flags.Bearer { - data.BearerTokens = extractBearerTokens(session) + tokens := extractBearerTokens(session) + switch { + case result.AutoDetected, strict: + data.BearerTokens = tokens + default: + if result.Tokens == nil { + result.Tokens = map[string]string{} + } + for aud, tok := range tokens { + result.Tokens[aud] = tok + } + picked, abort, err := pickBearerToken(result) + if err != nil { + return nil, cleanup, err + } + if abort { + fmt.Fprintln(os.Stderr, "Aborted by user.") + return nil, cleanup, nil + } + data.BearerTokens = picked + } } } - // Build and display session state summary + displayCapturedState(data, flags) + + return data, cleanup, nil +} + +func displayCapturedState(data *browserSessionData, flags browserLoginFlags) { + verbose := clicky.Flags.LevelCount var cookies connection.Cookies for _, c := range data.Cookies { cookies = append(cookies, connection.Cookie{ @@ -244,11 +303,12 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b }) } state := connection.NewPlaywrightSessionState(cookies, data.SessionStorage, nil, flags.URL) - if verbose >= 2 { + switch { + case verbose >= 2: fmt.Fprintln(os.Stderr, state.PrettyFull().ANSI()) - } else if verbose >= 1 { + case verbose >= 1: fmt.Fprintln(os.Stderr, state.Pretty().ANSI()) - } else { + default: selectedAud, _ := selectBearerToken(data.BearerTokens, flags.RequireBearerAud, flags.RequireBearerScope) for _, aud := range sortedAudiences(data.BearerTokens) { if jwt := connection.DecodeJWT(data.BearerTokens[aud]); jwt != nil { @@ -260,8 +320,61 @@ func launchBrowserAndCapture(ctx gocontext.Context, flags browserLoginFlags) (*b } } } +} + +func pickBearerToken(result loginWaitResult) (map[string]string, bool, error) { + if len(result.Tokens) == 0 { + var saveAnyway bool + prompt := huh.NewConfirm(). + Title("No bearer tokens detected"). + Description("Save the connection with cookies and session only?"). + Affirmative("Save"). + Negative("Abort"). + Value(&saveAnyway) + if err := prompt.Run(); err != nil { + return nil, false, err + } + if !saveAnyway { + return nil, true, nil + } + return nil, false, nil + } + + auds := make([]string, 0, len(result.Tokens)) + for aud := range result.Tokens { + auds = append(auds, aud) + } + sort.Strings(auds) + + const abortValue = "" + options := make([]huh.Option[string], 0, len(auds)+1) + for _, aud := range auds { + label := aud + if jwt := connection.DecodeJWT(result.Tokens[aud]); jwt != nil { + remaining := time.Until(jwt.ExpiresAt).Round(time.Second) + if remaining > 0 { + label = fmt.Sprintf("%s (expires in %s)", aud, remaining) + } else { + label = fmt.Sprintf("%s (expired %s ago)", aud, (-remaining).Round(time.Second)) + } + } + options = append(options, huh.NewOption(label, aud)) + } + options = append(options, huh.NewOption("Abort", abortValue)) + + var chosen string + if err := huh.NewSelect[string](). + Title("Select bearer token to save"). + Options(options...). + Value(&chosen). + Run(); err != nil { + return nil, false, err + } + if chosen == abortValue { + return nil, true, nil + } - return data, nil + return map[string]string{chosen: result.Tokens[chosen]}, false, nil } func autoSelectAccountPicker(browserCtx gocontext.Context) { @@ -288,18 +401,72 @@ func autoSelectAccountPicker(browserCtx gocontext.Context) { }() } -func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) { +func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) loginWaitResult { + result := loginWaitResult{Tokens: map[string]string{}} + strict := flags.RequireBearerAud != "" || flags.RequireBearerScope != "" + + waitCtx, cancelWait := gocontext.WithCancel(browserCtx) + defer cancelWait() + doneCh := make(chan struct{}, 3) + stdinCh := make(chan struct{}, 1) + bearerCh := make(chan map[string]string, 1) - go func() { - reader := bufio.NewReader(os.Stdin) - _, _ = reader.ReadString('\n') + trySend := func(ch chan<- struct{}) { select { - case doneCh <- struct{}{}: + case ch <- struct{}{}: default: } + } + + reminder := func() { + auds := sortedAudiences(extractBearerTokens(currentSession(browserCtx))) + have := "none" + if len(auds) > 0 { + have = strings.Join(auds, ", ") + } + fmt.Fprintf(os.Stderr, "Still waiting for %s token (have: %s)\n", requiredTokenLabel(flags), have) + } + + go func() { + reader := bufio.NewReader(os.Stdin) + for { + if _, err := reader.ReadString('\n'); err != nil { + return + } + if strict { + reminder() + continue + } + trySend(stdinCh) + trySend(doneCh) + return + } }() + if strict { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + go func() { + select { + case <-sigCh: + reminder() + fmt.Fprintln(os.Stderr, "Press Ctrl+C again to quit.") + signal.Stop(sigCh) + case <-waitCtx.Done(): + return + } + }() + } + + var sigCtx gocontext.Context + if !strict { + var stopSignal gocontext.CancelFunc + sigCtx, stopSignal = signal.NotifyContext(waitCtx, os.Interrupt) + defer stopSignal() + } + if flags.WaitForURL != "" { go func() { ticker := time.NewTicker(time.Second) @@ -311,18 +478,18 @@ func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) if err := chromedp.Run(browserCtx, chromedp.Location(¤tURL)); err == nil { if strings.Contains(currentURL, flags.WaitForURL) { time.Sleep(2 * time.Second) - doneCh <- struct{}{} + trySend(doneCh) return } } - case <-browserCtx.Done(): + case <-waitCtx.Done(): return } } }() } - if flags.Bearer { + if flags.Bearer && (strict || flags.Wait > 0) { go func() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -334,10 +501,11 @@ func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) if err != nil { continue } + tokens := extractBearerTokens(session) validAuds := make([]string, 0) var matched string var matchedJWT *connection.JWT - for aud, token := range extractBearerTokens(session) { + for aud, token := range tokens { jwt := connection.DecodeJWT(token) if jwt == nil || time.Until(jwt.ExpiresAt) <= 0 { continue @@ -355,33 +523,94 @@ func waitForLoginComplete(browserCtx gocontext.Context, flags browserLoginFlags) } if matched != "" { fmt.Fprintf(os.Stderr, "Found valid token for %s (scopes=%d, expires in %s)\n", matched, matchedJWT.ScopeCount(), time.Until(matchedJWT.ExpiresAt).Round(time.Second)) - doneCh <- struct{}{} + select { + case bearerCh <- tokens: + default: + } + trySend(doneCh) return } if (flags.RequireBearerAud != "" || flags.RequireBearerScope != "") && len(validAuds) > 0 { sort.Strings(validAuds) summary := strings.Join(validAuds, ", ") if summary != lastReported { - waiting := flags.RequireBearerAud - if flags.RequireBearerScope != "" { - waiting += " with scope " + flags.RequireBearerScope - } - fmt.Fprintf(os.Stderr, "Waiting for %s token (have: %s)\n", waiting, summary) + fmt.Fprintf(os.Stderr, "Waiting for %s token (have: %s)\n", requiredTokenLabel(flags), summary) lastReported = summary } } - case <-browserCtx.Done(): + case <-waitCtx.Done(): return } } }() } + waitTimeout := maybeTimeout(flags.Wait) + + if strict { + select { + case <-doneCh: + result.AutoDetected = true + select { + case tokens := <-bearerCh: + result.Tokens = tokens + default: + } + case <-waitTimeout: + result.TimedOut = true + fmt.Fprintf(os.Stderr, "Timed out waiting for %s token.\n", requiredTokenLabel(flags)) + } + return result + } + select { case <-doneCh: - case <-time.After(flags.Timeout): - fmt.Fprintln(os.Stderr, "Login timed out") + select { + case tokens := <-bearerCh: + result.AutoDetected = true + result.Tokens = tokens + case <-stdinCh: + result.Interrupted = true + default: + result.AutoDetected = true + } + case <-sigCtx.Done(): + if waitCtx.Err() == nil { + result.Interrupted = true + fmt.Fprintln(os.Stderr, "\nInterrupted — switching to manual token selection.") + } + case <-waitTimeout: + result.TimedOut = true + fmt.Fprintln(os.Stderr, "Auto-detect timed out — switching to manual token selection.") + } + + return result +} + +func requiredTokenLabel(flags browserLoginFlags) string { + label := flags.RequireBearerAud + if label == "" { + label = "bearer" + } + if flags.RequireBearerScope != "" { + label += " with scope " + flags.RequireBearerScope + } + return label +} + +func currentSession(browserCtx gocontext.Context) map[string]string { + session, err := extractSessionStorage(browserCtx) + if err != nil { + return nil + } + return session +} + +func maybeTimeout(d time.Duration) <-chan time.Time { + if d <= 0 { + return nil } + return time.After(d) } func extractCookies(browserCtx gocontext.Context, domains []string) ([]*network.Cookie, error) { diff --git a/connection/jwt.go b/connection/jwt.go index 308ea502f..a8473667e 100644 --- a/connection/jwt.go +++ b/connection/jwt.go @@ -53,6 +53,13 @@ func (j JWT) Pretty() api.Text { return t } +func (j JWT) ScopeCount() int { + if j.Scopes == "" { + return 0 + } + return len(strings.Fields(j.Scopes)) +} + func (j JWT) PrettyFull() api.Text { t := j.Pretty() if j.Raw != "" { @@ -85,6 +92,10 @@ func DecodeJWT(token string) *JWT { j := &JWT{Raw: token} if v, ok := claims["aud"].(string); ok { j.Audience = v + } else if arr, ok := claims["aud"].([]any); ok && len(arr) > 0 { + if s, ok := arr[0].(string); ok { + j.Audience = s + } } if v, ok := claims["sub"].(string); ok { j.Subject = v diff --git a/db/rbac.go b/db/rbac.go index a1827b7f2..756c28821 100644 --- a/db/rbac.go +++ b/db/rbac.go @@ -5,9 +5,9 @@ import ( "time" "github.com/flanksource/duty/context" + dutyQuery "github.com/flanksource/duty/query" "github.com/flanksource/duty/types" "github.com/google/uuid" - "gorm.io/gorm" "github.com/flanksource/incident-commander/api" ) @@ -27,6 +27,10 @@ type RBACAccessRow struct { LastReviewedAt *time.Time `gorm:"column:last_reviewed_at"` } +func (r RBACAccessRow) QueryLogSummary() string { + return r.ConfigType +} + func (r RBACAccessRow) RoleSource() string { if r.GroupName != nil && *r.GroupName != "" { return fmt.Sprintf("group:%s", *r.GroupName) @@ -34,8 +38,15 @@ func (r RBACAccessRow) RoleSource() string { return "direct" } -func GetRBACAccess(ctx context.Context, selectors []types.ResourceSelector) ([]RBACAccessRow, error) { - query := ctx.DB(). +func GetRBACAccessByConfigIDs(ctx context.Context, configIDs []uuid.UUID) ([]RBACAccessRow, error) { + return GetRBACAccess(ctx, nil, false, configIDs...) +} + +func GetRBACAccess(ctx context.Context, selectors []types.ResourceSelector, recursive bool, configIDs ...uuid.UUID) (results []RBACAccessRow, err error) { + timer := dutyQuery.NewQueryLogger(ctx).Start("RBACAccess").Arg("configIDs", len(configIDs)).Arg("selectors", len(selectors)) + defer timer.End(&err) + + q := ctx.DB(). Table("config_access_summary"). Select(`config_access_summary.config_id, config_access_summary.config_name, @@ -51,40 +62,108 @@ func GetRBACAccess(ctx context.Context, selectors []types.ResourceSelector) ([]R config_access_summary.last_reviewed_at`). Joins("LEFT JOIN external_groups ON config_access_summary.external_group_id = external_groups.id") - query = applyAccessSelectors(query, selectors) + if len(selectors) > 0 { + resolved, err := dutyQuery.FindConfigIDsByResourceSelector(ctx, 0, selectors...) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to resolve config selectors") + } + if len(resolved) == 0 { + return nil, nil + } + if recursive { + resolved, err = ExpandConfigChildren(ctx, resolved) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to expand children") + } + } + configIDs = append(configIDs, resolved...) + } + + if len(configIDs) > 0 { + q = q.Where("config_access_summary.config_id IN (?)", configIDs) + } - var rows []RBACAccessRow - if err := query. + if err = q. Order("config_access_summary.config_name, config_access_summary.\"user\""). - Find(&rows).Error; err != nil { + Find(&results).Error; err != nil { return nil, ctx.Oops().Wrapf(err, "failed to query RBAC access") } + timer.Results(results) + return results, nil +} - return rows, nil +func ExpandConfigChildren(ctx context.Context, ids []uuid.UUID) ([]uuid.UUID, error) { + return dutyQuery.ExpandConfigChildren(ctx, ids) } -func applyAccessSelectors(query *gorm.DB, selectors []types.ResourceSelector) *gorm.DB { - if len(selectors) == 0 { - return query - } +// GroupMemberRow represents one (group, user) pair — the membership of an +// external user in an external group. Used by the catalog report audit page +// to enumerate who is in each group that grants access to reported configs. +type GroupMemberRow struct { + GroupID uuid.UUID `gorm:"column:external_group_id"` + GroupName string `gorm:"column:group_name"` + GroupType string `gorm:"column:group_type"` + UserID uuid.UUID `gorm:"column:external_user_id"` + UserName string `gorm:"column:user_name"` + Email string `gorm:"column:email"` + UserType string `gorm:"column:user_type"` + LastSignedInAt *time.Time `gorm:"column:last_signed_in_at"` + MembershipAddedAt time.Time `gorm:"column:membership_created_at"` + MembershipDeletedAt *time.Time `gorm:"column:membership_deleted_at"` +} - for _, s := range selectors { - if s.ID != "" { - query = query.Where("config_access_summary.config_id = ?", s.ID) - } - if len(s.Types) > 0 { - query = query.Where("config_access_summary.config_type IN (?)", s.Types) - } - if s.Name != "" { - query = query.Where("config_access_summary.config_name ILIKE ?", s.Name) - } - if s.Search != "" { - pattern := "%" + s.Search + "%" - query = query.Where("(config_access_summary.config_name ILIKE ? OR config_access_summary.config_type ILIKE ?)", pattern, pattern) - } +func (r GroupMemberRow) QueryLogSummary() string { + return r.GroupName +} + +// GetGroupMembersForConfigs returns the members of every external group that +// is referenced by an active config_access row on any of the given configs. +// Both active and soft-deleted group memberships are returned so that audit +// reviewers can see users who were recently removed from a group. +func GetGroupMembersForConfigs(ctx context.Context, configIDs []uuid.UUID) (results []GroupMemberRow, err error) { + timer := dutyQuery.NewQueryLogger(ctx).Start("GroupMembers").Arg("configIDs", len(configIDs)) + defer timer.End(&err) + + if len(configIDs) == 0 { + return nil, nil } - return query + sql := ` + SELECT + eg.id AS external_group_id, + eg.name AS group_name, + eg.group_type AS group_type, + eu.id AS external_user_id, + eu.name AS user_name, + COALESCE(eu.email, '') AS email, + eu.user_type AS user_type, + last_sign_in.last_signed_in_at AS last_signed_in_at, + eug.created_at AS membership_created_at, + eug.deleted_at AS membership_deleted_at + FROM external_user_groups eug + JOIN external_groups eg ON eug.external_group_id = eg.id + JOIN external_users eu ON eug.external_user_id = eu.id + LEFT JOIN ( + SELECT external_user_id, MAX(created_at) AS last_signed_in_at + FROM config_access_logs + GROUP BY external_user_id + ) last_sign_in ON last_sign_in.external_user_id = eu.id + WHERE eug.external_group_id IN ( + SELECT DISTINCT external_group_id + FROM config_access + WHERE config_id IN (?) + AND external_group_id IS NOT NULL + AND deleted_at IS NULL + ) + ORDER BY eg.name ASC, + (eug.deleted_at IS NOT NULL) ASC, + eu.name ASC` + + if err = ctx.DB().Raw(sql, configIDs).Scan(&results).Error; err != nil { + return nil, ctx.Oops().Wrapf(err, "failed to query group members for configs") + } + timer.Results(results) + return results, nil } func GetRBACChangelog(ctx context.Context, configIDs []uuid.UUID, since time.Time) ([]api.RBACChangeEntry, error) { diff --git a/go.sum b/go.sum index 35a4589d5..bac4d7a3f 100644 --- a/go.sum +++ b/go.sum @@ -19,37 +19,147 @@ cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOY cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/accessapproval v1.8.8/go.mod h1:RFwPY9JDKseP4gJrX1BlAVsP5O6kI8NdGlTmaeDefmk= +cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= +cloud.google.com/go/ai v0.7.0/go.mod h1:7ozuEcraovh4ABsPbrec3o4LmFl9HigNI3D5haxYeQo= +cloud.google.com/go/aiplatform v1.120.0/go.mod h1:6mDthfmy0oS1EQhVFdijoxkVdI2+HIZkpuGTBpedeCg= +cloud.google.com/go/alloydb v1.14.0/go.mod h1:OTBY1HoL0Z8PsHoMMVhkaUPKyY8oP7hzIAe/Dna6UHk= +cloud.google.com/go/alloydbconn v1.13.2/go.mod h1:0wlYQAOr2XuvxYsvNNVckmG2v17WVUKzMD+gmTOibSU= +cloud.google.com/go/analytics v0.30.1/go.mod h1:V/FnINU5kMOsttZnKPnXfKi6clJUHTEXUKQjHxcNK8A= +cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= +cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U= +cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= +cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= +cloud.google.com/go/area120 v0.10.0/go.mod h1:Xg3fKl4xU3UVai9wsI1FXwNU8wSCDYT7dFZfwJKViAM= +cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc= +cloud.google.com/go/asset v1.22.1/go.mod h1:NlvWwmca7CX6BIBEdRNxOocH6DowmBghAAHucOHuHng= +cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= +cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= +cloud.google.com/go/batch v1.14.0/go.mod h1:oeQveyG6NDS/ks2ilOP4LzKRmuIaI7GLe0CkR7WF6pk= +cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.76.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM= +cloud.google.com/go/bigtable v1.42.0/go.mod h1:oZ30nofVB6/UYGg7lBwGLWSea7NZUvw/WvBBgLY07xU= +cloud.google.com/go/billing v1.21.0/go.mod h1:ZGairB3EVnb3i09E2SxFxo50p5unPaMTuo1jh6jW9js= +cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= +cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= +cloud.google.com/go/channel v1.21.0/go.mod h1:8v3TwHtgLmFxTpL2U+e10CLFOQN8u/Vr9RhYcJUS3y8= +cloud.google.com/go/cloudbuild v1.25.0/go.mod h1:lCu+T6IPkobPo2Nw+vCE7wuaAl9HbXLzdPx/tcF+oWo= +cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= cloud.google.com/go/cloudsqlconn v1.20.2 h1:r1BFbgxKA7h0jY13pGk8wBueUeLhqF27e5Hyaxl8Ua8= cloud.google.com/go/cloudsqlconn v1.20.2/go.mod h1:cGBrxU+pKs1NppBkecFC+rKn9B5GnEdlz7XrHbuwn7E= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= +cloud.google.com/go/container v1.45.0/go.mod h1:eB6jUfJLjne9VsTDGcH7mnj6JyZK+KOUIA6KZnYE/ds= +cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= +cloud.google.com/go/containeranalysis v0.14.2/go.mod h1:FjppROiUtP9cyMegdWdY/TsBSGc6kqh1GjA2NOJXXL8= +cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= +cloud.google.com/go/dataflow v0.11.1/go.mod h1:3s6y/h5Qz7uuxTmKJKBifkYZ3zs63jS+6VGtSu8Cf7Y= +cloud.google.com/go/dataform v0.12.1/go.mod h1:atGS8ReRjfNDUQib0X/o/7Gi2bqHI2G7/J86LKiGimE= +cloud.google.com/go/dataform v0.13.0/go.mod h1:U3fqrPY5jAcFh1a8rQb4a+PQ7zKlc5qfgotFZ+luKPo= +cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= +cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= +cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= +cloud.google.com/go/dataproc/v2 v2.15.0/go.mod h1:tSdkodShfzrrUNPDVEL6MdH9/mIEvp/Z9s9PBdbsZg8= +cloud.google.com/go/dataproc/v2 v2.16.0/go.mod h1:HlzFg8k1SK+bJN3Zsy2z5g6OZS1D4DYiDUgJtF0gJnE= +cloud.google.com/go/dataqna v0.9.8/go.mod h1:2lHKmGPOqzzuqCc5NI0+Xrd5om4ulxGwPpLB4AnFgpA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastore v1.22.0/go.mod h1:aopSX+Whx0lHspWWBj+AjWt68/zjYsPfDe3LjWtqZg8= +cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= +cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= +cloud.google.com/go/dialogflow v1.76.0/go.mod h1:mdLkMmSCghfcP85X9dFBlirC1OssS65KE5hrrSz2GXY= +cloud.google.com/go/dlp v1.28.0/go.mod h1:C3od1fIK8lf7Kr62aU1Uh0z4OL5Z8s3do3znAiEupAw= +cloud.google.com/go/documentai v1.42.0/go.mod h1:CABOUzRNOuvb/QwJS2LS80Hpqbu3UW2afyRKTYuW7bo= +cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= +cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= +cloud.google.com/go/errorreporting v0.4.0/go.mod h1:dZGEhqzdHZSRxxWLVjC3Ue5CVaROzvP58D9rU6zbBfw= +cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs= +cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8= +cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= +cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= +cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o= +cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= +cloud.google.com/go/gkemulticloud v1.6.0/go.mod h1:bGpd4o/Z5Z/XFlaojkgdVisHRwb+fLJvUPzsmV0I9ok= +cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U= cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU= +cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= +cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U= cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= +cloud.google.com/go/language v1.14.6/go.mod h1:7y3J9OexQsfkWNGCxhT+7lb64pa60e12ZCoWDOHxJ1M= +cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= cloud.google.com/go/logging v1.16.0 h1:MMNgYRvZ/pEwiNSkcoJTKWfAbAJDqCqAMJiarZx+/CI= cloud.google.com/go/logging v1.16.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/maps v1.29.0/go.mod h1:FNATcM5ziB2TDE2IVWH4f/yeXc+SbUk1X+bmKjR8HEA= +cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw= +cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= +cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/networkconnectivity v1.19.1/go.mod h1:Q5v6uNNNz8BP232uuXM66XgWML9m379xhwv58Y+8Kb0= +cloud.google.com/go/networkconnectivity v1.21.0/go.mod h1:XC1UJ+tqBsLWz73dqrMc7kUvdTv0FIxtDGv6YntTBO0= +cloud.google.com/go/networkmanagement v1.21.0/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU= +cloud.google.com/go/networkmanagement v1.23.0/go.mod h1:QTYCWp5UxUnU280SqF7AX/mf6NhsqKblmLeCALQmx5c= +cloud.google.com/go/networksecurity v0.11.0/go.mod h1:JLgDsg4tOyJ3eMO8lypjqMftbfd60SJ+P7T+DUmWBsM= +cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= +cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= +cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= +cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0= +cloud.google.com/go/osconfig v1.15.1/go.mod h1:NegylQQl0+5m+I+4Ey/g3HGeQxKkncQ1q+Il4DZ8PME= +cloud.google.com/go/osconfig v1.16.0/go.mod h1:PRmLgZ1loD1hGaqnTBww1nETbqcqAvmTQOLYiIZ7Nvk= +cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY= +cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE= +cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= +cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.3.0/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.21.0/go.mod h1:HxQYqZC2/zl2CvKN7jJEv71vEdDi1GMGNUiZxnpiuVI= +cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= +cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= +cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= +cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.25.1/go.mod h1:J75G8pd+DH0SHueL9IJw7Y5d2VhTsjFsk+F1t9f8jXc= +cloud.google.com/go/retail v1.26.0/go.mod h1:gMfh6s174Mvy1rK4g50J9TH5sRim8px+Krml25kdrqo= +cloud.google.com/go/run v1.14.0/go.mod h1:KStBOpjX7m47Yi1xStWSkvJcCqLr+PMUkz6p3po5/VA= +cloud.google.com/go/run v1.15.0/go.mod h1:rgFHMdAopLl++57vzeqA+a1o2x0/ILZnEacRD6nC0EA= +cloud.google.com/go/scheduler v1.11.8/go.mod h1:bNKU7/f04eoM6iKQpwVLvFNBgGyJNS87RiFN73mIPik= +cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= +cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= +cloud.google.com/go/servicedirectory v1.12.7/go.mod h1:gOtN+qbuCMH6tj2dqlDY3qQL7w3V0+nkWaZElnJK8Ps= +cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= +cloud.google.com/go/spanner v1.87.0/go.mod h1:tcj735Y2aqphB6/l+X5MmwG4NnV+X1NJIbFSZGaHYXw= +cloud.google.com/go/spanner v1.88.0/go.mod h1:MzulBwuuYwQUVdkZXBBFapmXee3N+sQrj2T/yup6uEE= +cloud.google.com/go/speech v1.29.0/go.mod h1:wtUmIS/h0ZYU6cPA9klcyST3f6i2FdnvNDqENjrRDds= +cloud.google.com/go/speech v1.30.0/go.mod h1:F2+NJujR8uzDLd6bwy5kgtVycxvEq06nzvzz5eQ/gMo= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= @@ -57,10 +167,29 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= +cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= +cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= +cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= +cloud.google.com/go/vertexai v0.12.0/go.mod h1:8u+d0TsvBfAAd2x5R6GMgbYhsLgo3J7lmP4bR8g2ig8= +cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= +cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= +cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= +cloud.google.com/go/vmmigration v1.10.0/go.mod h1:LDztCWEb+RwS1bPg4Xzt0fcJS9kVrFxa3ejhH7OW9vg= +cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= +cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= +cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= +cloud.google.com/go/websecurityscanner v1.7.7/go.mod h1:ng/PzARaus3Bj4Os4LpUnyYHsbtJky1HbBDmz148v1o= +cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= code.gitea.io/sdk/gitea v0.14.0 h1:m4J352I3p9+bmJUfS+g0odeQzBY/5OXP91Gv6D4fnJ0= code.gitea.io/sdk/gitea v0.14.0/go.mod h1:89WiyOX1KEcvjP66sRHdu0RafojGo60bT9UqW17VbWs= +codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= +codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= +codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -68,6 +197,10 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fortio.org/safecast v1.2.0 h1:ckQJNenMJHycqPsi/QrzA4EUX5WQkyd+hGO4mxt/a8w= fortio.org/safecast v1.2.0/go.mod h1:xZmcPk3vi4kuUFf+tq4SvnlVdwViqf6ZSZl91Jr9Jdg= +git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AssemblyAI/assemblyai-go-sdk v1.3.0/go.mod h1:H0naZbvpIW49cDA5ZZ/gggeXqi7ojSGB1mqshRk6kNE= +github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= @@ -88,6 +221,10 @@ github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2 github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.9.1/go.mod h1:NydgUaroiShkgOcb+X6OUdS3RalWBrvDNtOyFHJtsZY= +github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.10.0/go.mod h1:IAN3Z0DMtehoxoQQnfqg1891z1P7GNoDryKtFcAyMBI= +github.com/Azure/azure-sdk-for-go/sdk/monitor/azquery v1.2.0/go.mod h1:oI5SPI1vpNJYfP9MPWXthq7jDfh9xTAuQVBKPOu7DPo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= @@ -98,6 +235,12 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= +github.com/Azure/go-amqp v1.5.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= @@ -106,25 +249,39 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Code-Hex/go-generics-cache v1.3.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.37.8/go.mod h1:exon/I6I+5u/ab7AHmGh0eCXGoYZO5cjqA3wHJlYFFQ= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.37.10/go.mod h1:gij9WLu9mdiAFCM2EB+fwnbrVvc7cLr/klV1eEcFwbQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.29.0/go.mod h1:rKOFVIPbNs2wZeh7ZeQ0D9p/XLgbNiTr5m7x6KuAshk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0/go.mod h1:4BcvJy7WxY8X2eX49z2VO1ByhO+CcQK8lKPCH/QlZvo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.53.0/go.mod h1:dtCRwgvytbGKWdlrjMOg9geBoRwRpCYWIOM/JhVsDIc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.54.0/go.mod h1:8W5IW/jylevlBQKSWkh5ZMP2oy7yT9Pnfug6Y6W/9D8= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/HdrHistogram/hdrhistogram-go v1.2.0/go.mod h1:CiIeGiHSd06zjX+FypuEJ5EQ07KKtxZ+8J6hszwVQig= +github.com/IBM/sarama v1.46.3/go.mod h1:GTUYiF9DMOZVe3FwyGT+dtSPceGFIgA+sPc5u6CBwko= +github.com/IBM/watsonx-go v1.0.0/go.mod h1:8lzvpe/158JkrzvcoIcIj6OdNty5iC9co5nQHfkhRtM= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= @@ -134,17 +291,23 @@ github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4Oe github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsonmerge v1.0.0 h1:2e0nqnadoGUP8rAvcA0hkQelZreVO5X3BHomT2XMrAk= github.com/RaveNoX/go-jsonmerge v1.0.0/go.mod h1:qYM/NA77LhO4h51JJM7Z+xBU3ovqrNIACZe+SkSNVFo= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= +github.com/Snawoot/go-http-digest-auth-client v1.1.3/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= github.com/TomOnTime/utfutil v1.0.0 h1:/0Ivgo2OjXJxo8i7zgvs7ewSFZMLwCRGm3P5Umowb90= github.com/TomOnTime/utfutil v1.0.0/go.mod h1:l9lZmOniizVSuIliSkEf87qivMRlSNzbdBFKjuLRg1c= +github.com/Venafi/vcert/v5 v5.12.2/go.mod h1:x3l0pB0q0E6wuhPe7nzfkUEwwraK7amnBWQ4LtT1bbw= github.com/WinterYukky/gorm-extra-clause-plugin v0.4.0 h1:e4gYsN9tNzoBMYKYBaGwwZpSljJhW231+1cBlYwv8YQ= github.com/WinterYukky/gorm-extra-clause-plugin v0.4.0/go.mod h1:jNWq8AymgsVev9Kq6mke0b3o3yzY6bTSwjMDfTvZPPM= +github.com/XSAM/otelsql v0.39.0/go.mod h1:uMOXLUX+wkuAuP0AR3B45NXX7E9lJS2mERa8gqdU8R0= +github.com/XSAM/otelsql v0.40.0/go.mod h1:/7F+1XKt3/sTlYtwKtkHQ5Gzoom+EerXmD1VdnTqfB4= github.com/adityathebe/go-strip-markdown/v2 v2.0.1 h1:/Dxr9Rnn6h8VIwh2rqpYTUyoN4Hx4SXeEOjrz+JUO6I= github.com/adityathebe/go-strip-markdown/v2 v2.0.1/go.mod h1:Ze3XxKLEV5u8VWBaiAALVKOIA7uLZghVIUvQrICHFV0= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -155,12 +318,14 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.0.0/go.mod h1:Bf6hnZkloZnfL4I/gFGnMMMdMHiu/ERnSOWtFgnodDk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE= github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -169,23 +334,34 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/amikos-tech/chroma-go v0.1.4/go.mod h1:sT6uXOo/L5S/Q0v9jpYtoR1iOM68hUE2itWw8sOwLHY= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.44.263/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= @@ -197,9 +373,12 @@ github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYn github.com/aws/aws-sdk-go-v2/credentials v1.13.24/go.mod h1:jYPYi99wUOPIFi0rhiOvXeSEReVOzBqFNOX5bXYoG2o= github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.26/go.mod h1:P5lKM3+laQ9v0KAOLhxOkClj4UbBwXJ2QcQc2sKSOYo= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.8.26/go.mod h1:qEScmjwld3lw08e6CIWbPIgfcCKBdw2htqqBtSOSINQ= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.14/go.mod h1:jyoemRAktfCyZR9bTb5gT3kn/Vj2KwYDm0Pev5TsmEQ= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.6 h1:xuOfOJR0SPBrHhzAXZ5c+8i1KyJ+aUVJ2cl8DT16qH4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.6/go.mod h1:rUVOV4y5upo55JxPss99p9FaN9BvqUjFgE/N54tvLuE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= @@ -213,14 +392,21 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/bedrockagent v1.40.0/go.mod h1:WlMBqEPeaBywfaXoMAfpitHvwezq555o8waYL3cCPqo= +github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime v1.41.0/go.mod h1:Kek1IWlEDT1bp8kO+soWZh37Cb13LppHUTbMiJunna0= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.24.3/go.mod h1:PKGlRhLmSZuA6iCbRD1oZKrTJHdm6NWwWBvHxfDNHTA= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0 h1:+/lmB/+i2oqkzbmlQxsW0kr/+wmJgmyiEF9VDJicX34= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.68.0/go.mod h1:PobeppEnIjw4pcgjFryNDZCTH7AiqZw0yb5r98Gvf9c= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.2/go.mod h1:bz4cZH7uK5fLxQbj7hL4MFDL+pjReC9en/nM2Wfwxsk= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.6/go.mod h1:r2DJVcbGPv7oJGoPICCQJ+4ci5oSGjdXtdscnJIQBfk= github.com/aws/aws-sdk-go-v2/service/eks v1.82.0 h1:AvBDUgHffBd4AErnQY6sB9u5vY/9Z0Ll5VmzzMraxW0= github.com/aws/aws-sdk-go-v2/service/eks v1.82.0/go.mod h1:xdUh6tdF9A8hc+PE84kmHbF/zsVPNiKnc6oLgulq1Eo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.14/go.mod h1:yLon9pByjyB6JZq5IAmwnjE3ObIhD0QibfRWH7tUhLU= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= @@ -228,10 +414,15 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWUR github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/kms v1.49.5 h1:DKibav4XF66XSeaXcrn9GlWGHos6D/vJ4r7jsK7z5CE= github.com/aws/aws-sdk-go-v2/service/kms v1.49.5/go.mod h1:1SdcmEGUEQE1mrU2sIgeHtcMSxHuybhPvuEPANzIDfI= +github.com/aws/aws-sdk-go-v2/service/route53 v1.58.4/go.mod h1:xNLZLn4SusktBQ5moqUOgiDKGz3a7vHwF4W0KD+WBPc= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.2/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.11/go.mod h1:hdZDKzao0PBfJJygT7T92x2uVcWc/htqlhrjFIjnHDM= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.25/go.mod h1:eDstEbM0OEnBUnNQxIA7j74Jy61cCU1S4EMlCtdMwzs= +github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4/go.mod h1:+nlWvcgDPQ56mChEBzTC0puAMck+4onOFaHg5cE+Lgg= github.com/aws/aws-sdk-go-v2/service/sso v1.12.10/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= @@ -248,12 +439,16 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -268,6 +463,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo= @@ -279,6 +476,8 @@ github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaD github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -295,6 +494,7 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -325,12 +525,16 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/cilium/ebpf v0.11.0/go.mod h1:WE7CZAnqOL2RouJ4f1uyNhqr2P4CCvXFIqdRDUgWsVs= github.com/clarkmcc/gorm-sqlite v0.0.0-20240426202654-00ed082c0311 h1://GDWpsQ8pSg8u8SCavanukwPu5yE0Rz3uu7CuFVfFc= github.com/clarkmcc/gorm-sqlite v0.0.0-20240426202654-00ed082c0311/go.mod h1:HrR53jwmQF7sTyNxEJ3rqfx9sRVnaTUqIo1nXn0KRho= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= @@ -340,10 +544,23 @@ github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94 h1:kkHPnzHm5Ln7WA0XYjr github.com/cncf/xds/go v0.0.0-20260121142036-a486691bba94/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/cohere-ai/tokenizer v1.1.2/go.mod h1:9MNFPd9j1fuiEK3ua2HSCUxxcrfGMlSqpa93livg/C0= +github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -357,24 +574,35 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/deepmap/oapi-codegen/v2 v2.1.0/go.mod h1:R1wL226vc5VmCNJUvMyYr3hJMm5reyv25j952zAVXZ8= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/digitalocean/godo v1.165.1/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/eko/gocache/lib/v4 v4.2.3 h1:s78TFqEGAH3SbzP4N40D755JYT/aaGFKEPrsUtC1chU= github.com/eko/gocache/lib/v4 v4.2.3/go.mod h1:Zus8mwmaPu1VYOzfomb+Dvx2wV7fT5jDRbHYtQM6MEY= github.com/eko/gocache/store/go_cache/v4 v4.2.4 h1:toHpoIi4HhuXYv1bFOh5FiEQhpli4sWoSAN74j3/MXw= github.com/eko/gocache/store/go_cache/v4 v4.2.4/go.mod h1:oZcTjIjtHiCKCFS5KfxFrcmHFJKJd3wCNwuYeqWBuhI= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= @@ -419,6 +647,7 @@ github.com/flanksource/clicky v1.21.4 h1:gAstrcx5MlqvnHqPGRhRDwEU7fxj3OyUV0B3vf4 github.com/flanksource/clicky v1.21.4/go.mod h1:0kdw18BwGjlGiW2aZhY0K1LWW04z+WipzNgvguwKt9A= github.com/flanksource/commons v1.50.2 h1:P3xLuIwc/GaNqDX1NSZrvm1ktX52FvQjpPYoGRgz+ko= github.com/flanksource/commons v1.50.2/go.mod h1:m+so9LQqb04hkRlV6iza3BMPIDu6EpC5W2izP4MK3Bw= +github.com/flanksource/commons-db v0.1.1/go.mod h1:d627xQrhgKSZ/DLBSmrF64LxnuamTvTWTGg+5oyJJpw= github.com/flanksource/commons-test v0.1.13 h1:DLb3q1a7d+BpfxZDy2bdY8ZA5z1+UTYFEO9DYd15bw4= github.com/flanksource/commons-test v0.1.13/go.mod h1:T0zLA9F55jlaOhtvjj1Ot7QZQhtn2baAuflT+27ueG8= github.com/flanksource/deps v1.0.28 h1:mm7l7WjzLbkj2aFrgnlMaRizp+j+0x22TvtwXzRlFtE= @@ -433,12 +662,14 @@ github.com/flanksource/kopper v1.0.21 h1:hbmwbEYcZp1zMfdtjsiGQJfAqp/sjY47gWypp2B github.com/flanksource/kopper v1.0.21/go.mod h1:1xAGxHUkaS8DcXQ1dRsTMriTXEUbqz+VvLYknbvWVRg= github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSxYrqb4X8yI= github.com/flanksource/kubectl-neat v1.0.4/go.mod h1:Un/Voyh3cmiZNKQrW/TkAl28nAA7vwnwDGVjRErKjOw= +github.com/flanksource/maroto/v2 v2.4.2/go.mod h1:2ox4ZhXbIY+1fyJwuXAca0S/05soOlFSWqyqCnSPtO4= github.com/flanksource/sandbox-runtime v1.0.2 h1:dEaVyUpeaCAn3GOiWcNDrPguaey1Gx9eTsPAc27W3Eg= github.com/flanksource/sandbox-runtime v1.0.2/go.mod h1:x5Ywc1MrP3lTgmj0VUCeFJzrDZ3mBhBV4Gdc/ioV76M= github.com/fluxcd/gitkit v0.6.0 h1:iNg5LTx6ePo+Pl0ZwqHTAkhbUHxGVSY3YCxCdw7VIFg= github.com/fluxcd/gitkit v0.6.0/go.mod h1:svOHuKi0fO9HoawdK4HfHAJJseZDHHjk7I3ihnCIqNo= github.com/fluxcd/pkg/gittestserver v0.26.0 h1:+RZrCzFRsE+d5WaqAoqaPCEgcgv/jZp6+f7DS0+Ynb8= github.com/fluxcd/pkg/gittestserver v0.26.0/go.mod h1:7fybYb0yej1fFNiF1ohs0Jr0XzyaZQ/cRh3AFEoCtuc= +github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -447,9 +678,12 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gage-technologies/mistral-go v1.1.0/go.mod h1:tF++Xt7U975GcLlzhrjSQb8l/x+PrriO9QEdsgm9l28= github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= +github.com/getsentry/sentry-go v0.30.0/go.mod h1:WU9B9/1/sHDqeV8T+3VwwbjeR5MSXs/6aqG3mqZrezA= +github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -458,8 +692,11 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -473,6 +710,7 @@ github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQq github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -484,6 +722,7 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -498,12 +737,18 @@ github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/inflect v0.21.5 h1:M2RCq6PPS3YbIaL7CXosGL3BbzAcmfBAT0nC3YfesZA= github.com/go-openapi/inflect v0.21.5/go.mod h1:GypUyi6bU880NYurWaEH2CmH84zFDNd+EhhmzroHmB4= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.24.2/go.mod h1:AKurw9fNre+h3ELZfk6ILsfvPN+bvvlaU/M9q/r9hpk= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= @@ -534,6 +779,8 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= @@ -541,6 +788,7 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -555,16 +803,21 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/goccmack/gocc v1.0.2/go.mod h1:LXX2tFVUggS/Zgx/ICPOr3MLyusuM7EcbfkPvNsjdO8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -577,7 +830,9 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -612,6 +867,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -620,6 +877,9 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/generative-ai-go v0.15.1/go.mod h1:AAucpWZjXsDKhQYWvCYuP6d0yB1kX998pJlOW1rAesw= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -637,8 +897,10 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= @@ -669,6 +931,7 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmI github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -681,10 +944,14 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= @@ -693,14 +960,30 @@ github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6 github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -711,19 +994,27 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/sdk v0.20.0/go.mod h1:xEjAt/n/2lHBAkYiRPRmvf1d5B6HlisPh2pELlRCosk= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20250417193237-f615e6bd150b/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15/go.mod h1:Tmbz8uw5I/I6NvVpEGuhzlElCGS5hPoXJkt7l+ul6LE= github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= @@ -798,8 +1089,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jenkins-x/go-scm v1.15.21 h1:5ptUERtkN7m/n9VYCmLNurpT8UBjHm+pHgFElyNObOw= github.com/jenkins-x/go-scm v1.15.21/go.mod h1:ZjvrCiwfE9WghwjVSLP7+6kZFFblBqUSaAeHpmuNwM0= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -809,6 +1102,8 @@ github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -824,6 +1119,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= @@ -878,6 +1175,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= @@ -903,21 +1202,40 @@ github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSY github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/metaphorsystems/metaphor-go v0.0.0-20230816231421-43794c04824e/go.mod h1:mDz8kHE7x6Ja95drCQ2T1vLyPRc/t69Cf3wau91E3QU= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM= github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/milvus-io/milvus-proto/go-api/v2 v2.6.1-0.20250819024338-07695f709619/go.mod h1:/6UT4zZl6awVeXLeE7UGDWZvXj3IWkRsh3mqsn0DiAs= +github.com/milvus-io/milvus-sdk-go/v2 v2.4.0/go.mod h1:8IKyxVV+kd+RADMuMpo8GXnTDq5ZxrSSWpe9nJieboQ= +github.com/milvus-io/milvus/client/v2 v2.6.0/go.mod h1:5ppFKT61Fh5Z1MkAhK7+nLnlh9C+ENBe/dpgFBH0te0= +github.com/milvus-io/milvus/pkg/v2 v2.0.0-20250319085209-5a6b4e56d59e/go.mod h1:37AWzxVs2NS4QUJrkcbeLUwi+4Av0h5mEdjLI62EANU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -928,6 +1246,7 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -945,10 +1264,23 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/nlpodyssey/cybertron v0.2.1/go.mod h1:Vg9PeB8EkOTAgSKQ68B3hhKUGmB6Vs734dBdCyE4SVM= +github.com/nlpodyssey/gopickle v0.2.0/go.mod h1:YIUwjJ2O7+vnBsxUN+MHAAI3N+adqEGiw+nDpwW95bY= +github.com/nlpodyssey/gotokenizers v0.2.0/go.mod h1:SBLbuSQhpni9M7U+Ie6O46TXYN73T2Cuw/4eeYHYJ+s= +github.com/nlpodyssey/spago v1.1.0/go.mod h1:jDWGZwrB4B61U6Tf3/+MVlWOtNsk3EUA7G13UDHlnjQ= +github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -959,25 +1291,38 @@ github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opensearch-project/opensearch-go v1.1.0/go.mod h1:+6/XHCuTH+fwsMJikZEWsucZ4eZMma3zNSeLrTtVGbo= github.com/opensearch-project/opensearch-go/v2 v2.3.0 h1:nQIEMr+A92CkhHrZgUhcfsrZjibvB3APXf2a1VwCmMQ= github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785 h1:J1//5K/6QF10cZ59zLcVNFGmBfiSrH8Cho/lNrViK9s= github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/ory/client-go v1.22.35 h1:mlQuEmg10kKbxIHc8ga75zE6h0xM6u2ZHAoMmIEYnHA= github.com/ory/client-go v1.22.35/go.mod h1:G1f+5+m/PJVvl40bsRn0QuyVIcXe7EHiWeM7iWpIDjw= +github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pgvector/pgvector-go v0.1.1/go.mod h1:wLJgD/ODkdtd2LJK4l6evHXTuG+8PxymYAVomKHOWac= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pinecone-io/go-pinecone v0.4.1/go.mod h1:KwWSueZFx9zccC+thBk13+LDiOgii8cff9bliUI4tQs= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= @@ -1039,8 +1384,10 @@ github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/redis/rueidis v1.0.34/go.mod h1:g8nPmgR4C68N3abFiOc/gUOSEKw3Tom6/teYMehg4RE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= @@ -1055,6 +1402,7 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= @@ -1066,7 +1414,11 @@ github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= @@ -1082,6 +1434,8 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= @@ -1105,16 +1459,23 @@ github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28Jjd github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/slack-go/slack v0.22.0 h1:jaUTiGoyhCl7xC/PuVh05BfM1ifVBsQQUKgsr5TLg5k= github.com/slack-go/slack v0.22.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -1135,6 +1496,19 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/chroma v0.37.0/go.mod h1:IWJavzQy7rxM40OqOgSN5iyckgAw21wDyE+NhSctatk= +github.com/testcontainers/testcontainers-go/modules/mariadb v0.38.0/go.mod h1:26mrWngnaRhxmgy942aVfUihLnihbIGsuIds6gGBnIE= +github.com/testcontainers/testcontainers-go/modules/milvus v0.37.0/go.mod h1:bCdLqxjPKax120BMl4aO/A0gs9+4FeJkLBVf9WpjFoQ= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.37.0/go.mod h1:e9/4dGJfSZW59/kXGf/ksrEvA+BqP/daax0Usp2cpsM= +github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc= +github.com/testcontainers/testcontainers-go/modules/opensearch v0.37.0/go.mod h1:2jEljlB96QHSHF7Vo9S8zEDisPPrfsddzSvsCR1ihNQ= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/testcontainers/testcontainers-go/modules/redis v0.37.0/go.mod h1:Abu9g/25Qv+FkYVx3U4Voaynou1c+7D0HIhaQJXvk6E= +github.com/testcontainers/testcontainers-go/modules/weaviate v0.37.0/go.mod h1:VdjCqOCJGzlGLS2p4NdLjN5rqN3/53mle+Gb+irCbOE= github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU= github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -1160,8 +1534,11 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc= github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= @@ -1177,12 +1554,20 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/weaviate/weaviate v1.29.0/go.mod h1:UsnbM1Kmm5Om+UPU6DTo421SDeMD8SqCJqsBs/nwgcI= +github.com/weaviate/weaviate-go-client/v5 v5.0.2/go.mod h1:CwZehIL4s3VfkzTu12Wy8VAUtELRtQFUt2ZniBF/lQM= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -1194,6 +1579,8 @@ github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= @@ -1202,8 +1589,10 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1219,6 +1608,8 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.2.0 h1:GDyL4+e/Qe/S0B7YaecMLbVvAR/Mp21CXMOSiCTOi1M= github.com/zclconf/go-cty-yaml v1.2.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= @@ -1228,6 +1619,22 @@ github.com/zitadel/oidc/v3 v3.47.3 h1:Dz7XzjTf+SJLg8BjjeABCmYZOqXOReLJdWbWGVHOZQ github.com/zitadel/oidc/v3 v3.47.3/go.mod h1:XxFh0666HRXycyrKmono+3gY0RACpYJLgy4r/+kliKY= github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= +gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181/go.mod h1:dzYhVIwWCtzPAa4QP98wfB9+mzt33MSmM8wsKiMi2ow= +gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82/go.mod h1:Gn+LZmCrhPECMD3SOKlE+BOHwhOYD9j7WT9NUtkCrC8= +gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a/go.mod h1:LaSIs30YPGs1H5jwGgPhLzc8vkNc/k0rDX/fEZqiU/M= +gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84/go.mod h1:IJZ+fdMvbW2qW6htJx7sLJ04FEs4Ldl/MDsJtMKywfw= +gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f/go.mod h1:Tiuhl+njh/JIg0uS/sOJVYi0x2HEa5rc1OAaVsb5tAs= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v2 v2.305.5/go.mod h1:zQjKllfqfBVyVStbt4FaosoX2iYd8fV/GRy/PbowgP4= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/raft/v3 v3.5.5/go.mod h1:76TA48q03g1y1VpTue92jZLr9lIHKUNcYdZOOGyx8rI= +go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.mongodb.org/mongo-driver/v2 v2.0.0/go.mod h1:nSjmNq4JUstE8IRZKTktLgMHM4F1fccL6HGX1yh+8RA= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -1237,6 +1644,8 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/aws/ec2 v1.37.0/go.mod h1:gs3y8jvJscW5D+FzrZvJZEsGj+xlMCF0S1x4R6ktiNo= +go.opentelemetry.io/contrib/detectors/aws/ec2 v1.38.0/go.mod h1:AqLDNPbKVFwdXy2/Xu2EYElVHO7ghhbEhKCCWymjpMI= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho v0.68.0 h1:7N94HrYgVc2tng6xEjmbycupxteYLll7lPlEi/UK5ok= @@ -1245,10 +1654,13 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/propagators/aws v1.38.0/go.mod h1:wXqc9NTGcXapBExHBDVLEZlByu6quiQL8w7Tjgv8TCg= go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A= go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= @@ -1269,12 +1681,14 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.starlark.net v0.0.0-20230302034142-4b1e35fe2254/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -1284,6 +1698,7 @@ go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+ go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= @@ -1296,6 +1711,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gocloud.dev v0.45.0 h1:WknIK8IbRdmynDvara3Q7G6wQhmEiOGwpgJufbM39sY= gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= +gocloud.dev/pubsub/kafkapubsub v0.45.0/go.mod h1:6E/m0jSgxRRcrrzYiGhoVCr/M2fUUPqY935cIzNDgKI= +gocloud.dev/pubsub/natspubsub v0.45.0/go.mod h1:WU5SMDWuF7CCr2U8UpbrEONYYFOmvigCAvTpOOpUvYE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= @@ -1522,6 +1939,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1620,6 +2039,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1632,6 +2053,8 @@ gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0 gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c/go.mod h1:fy6Otjqbk477ELp8IXTpw1cObQtLbRCBVonY+bTTfcM= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -1657,6 +2080,7 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genai v1.54.0 h1:ZQCa70WMTJDI11FdqWCzGvZ5PanpcpfoO6jl/lrSnGU= google.golang.org/genai v1.54.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -1692,6 +2116,8 @@ google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgn google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260120174246-409b4a993575/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -1709,6 +2135,7 @@ google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1741,6 +2168,9 @@ gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaD gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/readline.v1 v1.0.0-20160726135117-62c6fe619375/go.mod h1:lNEQeAhU009zbRxng+XOj5ITVgY24WcbNnQopyfKoYQ= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -1792,18 +2222,27 @@ k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpAp k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/code-generator v0.35.2/go.mod h1:id4XLCm0yAQq5nlvyfAKibMOKnMjzlesAwGw6kM3Adc= +k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kms v0.35.2/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kube-aggregator v0.34.1/go.mod h1:RU8j+5ERfp0h+gIvWtxRPfsa5nK7rboDm8RST8BJfYQ= k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM= k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= @@ -1830,9 +2269,13 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/goversion v1.2.0/go.mod h1:Eih9y/uIBS3ulggl7KNJ09xGSLcuNaLgmvvqa07sgfo= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= @@ -1841,7 +2284,9 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/main.go b/main.go index 5b7b56d5c..7a2301e40 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ func main() { } api.BuildVersion = version + api.BuildCommit = commit cmd.Root.AddCommand(&cobra.Command{ Use: "version", diff --git a/mcp/access.go b/mcp/access.go index b496bd0a3..7fd3a3d0c 100644 --- a/mcp/access.go +++ b/mcp/access.go @@ -37,7 +37,7 @@ func searchCatalogAccessMappingHandler(goctx gocontext.Context, req mcp.CallTool var rows []db.RBACAccessRow err = auth.WithRLS(ctx, func(rlsCtx context.Context) error { - rows, err = db.GetRBACAccess(rlsCtx, []types.ResourceSelector{{Search: q}}) + rows, err = db.GetRBACAccess(rlsCtx, []types.ResourceSelector{{Search: q}}, false) return err }) if err != nil { diff --git a/tests/e2e/oidc/oidc_login_test.go b/tests/e2e/oidc/oidc_login_test.go index 8d0f906bc..dd7be6e83 100644 --- a/tests/e2e/oidc/oidc_login_test.go +++ b/tests/e2e/oidc/oidc_login_test.go @@ -22,10 +22,8 @@ var _ = ginkgo.Describe("OIDC Browser Login Flow", ginkgo.Label("slow"), ginkgo. verifier, challenge, err := oidcclient.GeneratePKCE() Expect(err).ToNot(HaveOccurred()) - state, err := oidcclient.RandomBase64(16) - Expect(err).ToNot(HaveOccurred()) - nonce, err := oidcclient.RandomBase64(16) - Expect(err).ToNot(HaveOccurred()) + state := oidcclient.RandomBase64(16) + nonce := oidcclient.RandomBase64(16) endpoints, err = oidcclient.Discover(serverURL + "/.well-known/openid-configuration") Expect(err).ToNot(HaveOccurred()) diff --git a/tests/e2e/oidc/suite_test.go b/tests/e2e/oidc/suite_test.go index e123c3494..0b0c78fcb 100644 --- a/tests/e2e/oidc/suite_test.go +++ b/tests/e2e/oidc/suite_test.go @@ -72,7 +72,7 @@ var _ = ginkgo.BeforeSuite(func() { "--auth", "basic", "--htpasswd-file", htpasswdPath, "--oidc", - "--frontend-url", serverURL, + "--public-endpoint", serverURL, "--httpPort", strconv.Itoa(serverPort), "--disable-postgrest", "--postgrest-uri", "",