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 ./... 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/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/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/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/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/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/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/basic.go b/auth/basic.go index 4d267a82a..2c65fed60 100644 --- a/auth/basic.go +++ b/auth/basic.go @@ -165,7 +165,6 @@ func basicAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { } } } - setWWWAuthenticate(c) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) } diff --git a/auth/kratos.go b/auth/kratos.go index c6859d8b5..c2d3972f9 100644 --- a/auth/kratos.go +++ b/auth/kratos.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" "strings" "time" @@ -13,7 +12,6 @@ import ( "github.com/flanksource/commons/rand" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" - incAPI "github.com/flanksource/incident-commander/api" "github.com/flanksource/incident-commander/db" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -246,40 +244,6 @@ func (k *KratosCredentialChecker) Match(ctx context.Context, user, pass string) return err } -func (k *KratosCredentialChecker) LoginRedirectURL(authRequestID string) (string, error) { - frontendURL := strings.TrimRight(incAPI.FrontendURL, "/") - if frontendURL == "" { - return "", fmt.Errorf("frontend URL is not configured") - } - - q := url.Values{} - q.Set("return_to", "/oidc/kratos/callback?auth_request_id="+authRequestID) - return frontendURL + "/login?" + q.Encode(), nil -} - -func (k *KratosCredentialChecker) CallbackSubject(c echo.Context) (string, error) { - ctx := c.Request().Context().(context.Context) - - session, err := k.middleware.validateSession(ctx, c.Request()) - if err != nil { - return "", err - } - if session.Active == nil || !*session.Active { - return "", fmt.Errorf("session is not active") - } - - subject := session.Identity.GetId() - if subject == "" { - return "", fmt.Errorf("session identity is missing") - } - - if _, err := db.GetUserByID(ctx, subject); err != nil { - return "", fmt.Errorf("failed to resolve user: %w", err) - } - - return subject, nil -} - // LookupKratosPersonByUsername finds a person by email in the Kratos identities table. func LookupKratosPersonByUsername(ctx context.Context, username string) (string, error) { var id string 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/login.go b/auth/oidc/login.go index c713cd769..ac38333b9 100644 --- a/auth/oidc/login.go +++ b/auth/oidc/login.go @@ -23,14 +23,6 @@ type CredentialChecker interface { Match(ctx context.Context, user, pass string) error } -type LoginRedirector interface { - LoginRedirectURL(authRequestID string) (string, error) -} - -type CallbackSubjectResolver interface { - CallbackSubject(c echo.Context) (string, error) -} - type PersonLookup func(ctx context.Context, user string) (personID string, err error) func NewLoginHandler(storage *Storage, provider op.OpenIDProvider, checker CredentialChecker, lookup PersonLookup, issuerURL string) *LoginHandler { @@ -48,15 +40,6 @@ func (h *LoginHandler) ShowForm(c echo.Context) error { if id == "" { return c.String(http.StatusBadRequest, "missing auth_request_id") } - - if redirector, ok := h.checker.(LoginRedirector); ok { - redirectURL, err := redirector.LoginRedirectURL(id) - if err != nil { - return c.String(http.StatusInternalServerError, "failed to build login redirect") - } - return c.Redirect(http.StatusFound, redirectURL) - } - return c.HTML(http.StatusOK, fmt.Sprintf(static.LoginHTML, html.EscapeString(id), "")) } @@ -93,28 +76,3 @@ func (h *LoginHandler) HandleSubmit(c echo.Context) error { callbackURL := op.AuthCallbackURL(h.provider)(issuerCtx, id) return c.Redirect(http.StatusFound, callbackURL) } - -func (h *LoginHandler) HandleExternalCallback(c echo.Context) error { - id := c.QueryParam("auth_request_id") - if id == "" { - return c.String(http.StatusBadRequest, "missing auth_request_id") - } - - resolver, ok := h.checker.(CallbackSubjectResolver) - if !ok { - return c.String(http.StatusNotFound, "external callback is not configured") - } - - personID, err := resolver.CallbackSubject(c) - if err != nil { - return c.String(http.StatusUnauthorized, "authorization failed") - } - - if err := h.storage.SetAuthRequestSubject(id, personID); err != nil { - return c.String(http.StatusInternalServerError, "internal error") - } - - issuerCtx := op.ContextWithIssuer(c.Request().Context(), h.issuerURL) - callbackURL := op.AuthCallbackURL(h.provider)(issuerCtx, id) - return c.Redirect(http.StatusFound, callbackURL) -} 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/routes.go b/auth/oidc/routes.go index d5f39c316..3e5777cda 100644 --- a/auth/oidc/routes.go +++ b/auth/oidc/routes.go @@ -26,9 +26,9 @@ func MountRoutes(e *echo.Echo, ctx context.Context, issuerURL, signingKeyPath st // Custom login form (not part of the standard OIDC protocol paths). e.GET("/oidc/login", loginHandler.ShowForm) e.POST("/oidc/login", loginHandler.HandleSubmit) - e.GET("/oidc/kratos/callback", loginHandler.HandleExternalCallback) - // MCP Clients need OAuth well-known discovery endpoints (not just OIDC discovery). + // RFC 9728 OAuth 2.0 Protected Resource Metadata endpoints — registered before + // the OIDC provider catch-all so they take precedence over /.well-known/*. mountOAuthRoutes(e, oidcIssuer) // Standard OIDC protocol endpoints — mounted at the root so that the issuer URL 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_test.go b/auth/oidc_test.go index 670f82eda..a6ff50365 100644 --- a/auth/oidc_test.go +++ b/auth/oidc_test.go @@ -270,9 +270,9 @@ var _ = ginkgo.Describe("OIDC", func() { // Clear the cache so it reloads from DB oidcPublicKeyCache.Flush() - savedPublicURL := api.FrontendURL - api.FrontendURL = "http://localhost:8080" - defer func() { api.FrontendURL = savedPublicURL }() + savedPublicURL := api.PublicURL + api.PublicURL = "http://localhost:8080" + defer func() { api.PublicURL = savedPublicURL }() claims := jwt.MapClaims{ "iss": "http://localhost:8080", @@ -296,9 +296,9 @@ var _ = ginkgo.Describe("OIDC", func() { Expect(err).ToNot(HaveOccurred()) oidcPublicKeyCache.Flush() - savedPublicURL := api.FrontendURL - api.FrontendURL = "http://localhost:8080" - defer func() { api.FrontendURL = savedPublicURL }() + savedPublicURL := api.PublicURL + api.PublicURL = "http://localhost:8080" + defer func() { api.PublicURL = savedPublicURL }() claims := jwt.MapClaims{ "iss": "http://localhost:8080", @@ -323,9 +323,9 @@ var _ = ginkgo.Describe("OIDC", func() { Expect(err).ToNot(HaveOccurred()) oidcPublicKeyCache.Flush() - savedPublicURL := api.FrontendURL - api.FrontendURL = "http://localhost:8080" - defer func() { api.FrontendURL = savedPublicURL }() + savedPublicURL := api.PublicURL + api.PublicURL = "http://localhost:8080" + defer func() { api.PublicURL = savedPublicURL }() claims := jwt.MapClaims{ "iss": "http://localhost:8080", @@ -350,9 +350,9 @@ var _ = ginkgo.Describe("OIDC", func() { Expect(err).ToNot(HaveOccurred()) oidcPublicKeyCache.Flush() - savedPublicURL := api.FrontendURL - api.FrontendURL = "http://localhost:8080" - defer func() { api.FrontendURL = savedPublicURL }() + savedPublicURL := api.PublicURL + api.PublicURL = "http://localhost:8080" + defer func() { api.PublicURL = savedPublicURL }() claims := jwt.MapClaims{ "iss": "http://localhost:8080", @@ -377,9 +377,9 @@ var _ = ginkgo.Describe("OIDC", func() { Expect(err).ToNot(HaveOccurred()) oidcPublicKeyCache.Flush() - savedPublicURL := api.FrontendURL - api.FrontendURL = "http://localhost:8080" - defer func() { api.FrontendURL = savedPublicURL }() + savedPublicURL := api.PublicURL + api.PublicURL = "http://localhost:8080" + defer func() { api.PublicURL = savedPublicURL }() claims := jwt.MapClaims{ "iss": "http://localhost:8080", @@ -425,45 +425,6 @@ var _ = ginkgo.Describe("OIDC", func() { Expect(rec.Code).To(Equal(http.StatusBadRequest)) }) - ginkgo.It("redirects to external login when checker supports login redirect", func() { - login := oidc.NewLoginHandler(provider.Storage, provider.OpenIDProvider, &redirectChecker{}, mockLookup, "http://localhost:8080") - e := newEchoInstance(DefaultContext) - req := httptest.NewRequest(http.MethodGet, "/oidc/login?auth_request_id=req-123", nil) - req = req.WithContext(DefaultContext.Wrap(req.Context())) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - Expect(login.ShowForm(c)).To(Succeed()) - Expect(rec.Code).To(Equal(http.StatusFound)) - Expect(rec.Header().Get("Location")).To(Equal("http://localhost:3000/login?return_to=%2Foidc%2Fkratos%2Fcallback%3Fauth_request_id%3Dreq-123")) - }) - - ginkgo.It("completes auth request from external callback", func() { - req := &oidclib.AuthRequest{ - ClientID: oidc.ClientID, - RedirectURI: "http://localhost:9999/callback", - Scopes: []string{"openid"}, - ResponseType: "code", - } - ar, err := provider.Storage.CreateAuthRequest(gocontext.TODO(), req, "") - Expect(err).ToNot(HaveOccurred()) - - login := oidc.NewLoginHandler(provider.Storage, provider.OpenIDProvider, &callbackChecker{subjectID: person.ID.String()}, mockLookup, "http://localhost:8080") - e := newEchoInstance(DefaultContext) - httpReq := httptest.NewRequest(http.MethodGet, "/oidc/kratos/callback?auth_request_id="+ar.GetID(), nil) - httpReq = httpReq.WithContext(DefaultContext.Wrap(httpReq.Context())) - rec := httptest.NewRecorder() - c := e.NewContext(httpReq, rec) - - Expect(login.HandleExternalCallback(c)).To(Succeed()) - Expect(rec.Code).To(Equal(http.StatusFound)) - Expect(rec.Header().Get("Location")).To(ContainSubstring("id=" + ar.GetID())) - - updated, err := provider.Storage.AuthRequestByID(gocontext.TODO(), ar.GetID()) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.GetSubject()).To(Equal(person.ID.String())) - }) - ginkgo.It("rejects invalid credentials", func() { login := oidc.NewLoginHandler(provider.Storage, provider.OpenIDProvider, &mockChecker{valid: false}, mockLookup, "http://localhost:8080") e := newEchoInstance(DefaultContext) @@ -593,28 +554,6 @@ func (m *mockChecker) Match(_ dutyContext.Context, _, _ string) error { return fmt.Errorf("invalid credentials") } -type redirectChecker struct{} - -func (r *redirectChecker) Match(_ dutyContext.Context, _, _ string) error { - return nil -} - -func (r *redirectChecker) LoginRedirectURL(authRequestID string) (string, error) { - return "http://localhost:3000/login?return_to=%2Foidc%2Fkratos%2Fcallback%3Fauth_request_id%3D" + authRequestID, nil -} - -type callbackChecker struct { - subjectID string -} - -func (r *callbackChecker) Match(_ dutyContext.Context, _, _ string) error { - return nil -} - -func (r *callbackChecker) CallbackSubject(_ echo.Context) (string, error) { - return r.subjectID, nil -} - var mockLookup = func(ctx dutyContext.Context, user string) (string, error) { return uuid.New().String(), nil } 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..41bfe3b0b 100644 --- a/auth/oidcclient/oidcclient.go +++ b/auth/oidcclient/oidcclient.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" @@ -35,15 +34,6 @@ func Discover(discoveryURL string) (*Discovery, error) { } defer resp.Body.Close() - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) - msg := strings.TrimSpace(string(body)) - if msg == "" { - return nil, fmt.Errorf("discovery endpoint returned %s", resp.Status) - } - return nil, fmt.Errorf("discovery endpoint returned %s: %s", resp.Status, msg) - } - var d Discovery if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { return nil, err @@ -62,12 +52,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/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/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/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/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/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/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 +} 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/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/go.sum b/go.sum index ad0f8c72e..16dd329fd 100644 --- a/go.sum +++ b/go.sum @@ -19,37 +19,137 @@ 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.113.0/go.mod h1:B8fcWtC2vSadapIQqweJrTATJe/odNDjk2uIA5kmXog= +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.9.7/go.mod h1:5nJ0yksmjOMfc4Zpk+okWfJ3A1004FvB82rfia+ZLaY= +cloud.google.com/go/artifactregistry v1.19.0/go.mod h1:UEAPCgHDFC1q+A8nnVxXHPEy9KCVOeavFBF1fEChQvU= +cloud.google.com/go/asset v1.22.0/go.mod h1:q80JP2TeWWzMCazYnrAfDf36aQKf1QiKzzpNLflJwf8= +cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= 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.72.0/go.mod h1:GUbRtmeCckOE85endLherHD9RsujY+gS7i++c1CqssQ= +cloud.google.com/go/bigtable v1.41.0/go.mod h1:JlaltP06LEFXaxQdZiarGR9tKsX/II0IkNAKMDrWspI= +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.0 h1:5EBr98dktt5QStX6jacFTECTQ4rxfY6qpIUIV9YNRqo= cloud.google.com/go/cloudsqlconn v1.20.0/go.mod h1:YCoWR0SWYTDf9npeqq8ODFN1WdGMGVC5G74+A3CXXP4= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= +cloud.google.com/go/compute v1.53.0/go.mod h1:zdogTa7daHhEtEX92+S5IARtQmi/RNVPUfoI8Jhl8Do= 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/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/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/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.21.0/go.mod h1:9l+KyAHO+YVVcdBbNQZJu8svF17Nw5sMKuFR0LYf1nY= +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.73.0/go.mod h1:vFkeDO7ishnfakWVLlbgIynQGTFJ/YaVMlYmSn5M+1o= +cloud.google.com/go/dlp v1.28.0/go.mod h1:C3od1fIK8lf7Kr62aU1Uh0z4OL5Z8s3do3znAiEupAw= +cloud.google.com/go/documentai v1.39.0/go.mod h1:KmlLO93F7GRU8dENXRxvt+7V8o7eCG6Y6WDitKbcYJs= +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.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +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.25.0 h1:gVqvGGUmz0nYCmtoxWmdc1wli2L1apgP8U4fghPGSbQ= cloud.google.com/go/kms v1.25.0/go.mod h1:XIdHkzfj0bUO3E+LvwPg+oc7s58/Ns8Nd8Sdtljihbk= +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.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/maps v1.26.0/go.mod h1:+auempdONAP8emtm48aCfNo1ZC+3CJniRA1h8J4u7bY= +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/networkmanagement v1.21.0/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU= +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/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/run v1.14.0/go.mod h1:KStBOpjX7m47Yi1xStWSkvJcCqLr+PMUkz6p3po5/VA= +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/speech v1.29.0/go.mod h1:wtUmIS/h0ZYU6cPA9klcyST3f6i2FdnvNDqENjrRDds= 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 +157,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.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58= cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +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 +187,10 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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 +211,9 @@ 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/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 +224,11 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= 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-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 +237,35 @@ 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/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/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/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +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 +275,22 @@ 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/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 +301,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 +317,33 @@ 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-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 +355,12 @@ github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o 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.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87/go.mod h1:ZeQC4gVarhdcWeM1c90DyBLaBCNhEeAbKUXwVI/byvw= 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.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.5.13/go.mod h1:RxLhhGmjEidlLTRZyk1BLMigHONURhQakw2//prq+DA= 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 +374,20 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/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.65.0 h1:3yaFbUbuLfN8n1q01wZtQtHRzUDc/jm0VvniMY0IPE8= github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.65.0/go.mod h1:PobeppEnIjw4pcgjFryNDZCTH7AiqZw0yb5r98Gvf9c= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo= github.com/aws/aws-sdk-go-v2/service/eks v1.77.0 h1:Z5mTpmbJKU7jEM7xoXI5tO4Nm0JUZSgVSFkpYuu6Ic0= github.com/aws/aws-sdk-go-v2/service/eks v1.77.0/go.mod h1:Qg678m+87sCuJhcsZojenz8mblYG+Tq86V4m3hjVz0s= 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/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 +395,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.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.7/go.mod h1:1X1NotbcGHH7PCQJ98PsExSxsJj/VWzz8MfFz43+02M= github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= +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.21/go.mod h1:t98Ssq+qtXKXl2SFtaSkuT6X42FSM//fnO6sfq5RqGM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= 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.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= @@ -248,12 +420,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 +444,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/gorm-adapter/v3 v3.39.0 h1:k15txH6vE4796MuA+LFcU8I1vMjutklyzMXfjDz7lzo= @@ -277,6 +455,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= @@ -293,6 +473,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= @@ -323,12 +504,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= @@ -338,10 +523,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= @@ -355,24 +553,34 @@ 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.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= @@ -417,6 +625,7 @@ github.com/flanksource/clicky v1.21.1 h1:Vk/q39QFCp+BEvX+mVfoeyfVP4IvLs18DlI5gMB github.com/flanksource/clicky v1.21.1/go.mod h1:Wg30x88982ejUzKqtw+Sm7UMRHTB8bnIQBXHi9s54RU= 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.24 h1:X23SZb2nxCDsS1wRiuqyvUYpA3KQxcQR9YfB8H/oTgo= @@ -431,12 +640,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.1 h1:zBzNx9GoZILo1ot4qI2wd/gqny0vejvex3xnJzsmvgE= github.com/flanksource/sandbox-runtime v1.0.1/go.mod h1:HCeOqw4QQOpvzDeN3hMdQpxIZ9yrp0/5ziXjiiOw5ec= 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.21.0 h1:2ez/cCGbGHz/Rp1IIbjqRsuTDgMmW98or3+8cSWpbHk= github.com/fluxcd/pkg/gittestserver v0.21.0/go.mod h1:KbTkLjhjHnVbepN4d3OWo6T+nQMFU+lZgrTUm3vIHgo= +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= @@ -445,9 +656,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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/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= @@ -456,8 +670,10 @@ 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/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= @@ -471,6 +687,7 @@ github.com/go-git/go-git/v5 v5.17.1/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= @@ -482,6 +699,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= @@ -496,12 +714,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= @@ -532,6 +756,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= @@ -539,6 +765,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= @@ -553,16 +780,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= @@ -575,7 +807,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= @@ -610,6 +844,7 @@ 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/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -618,6 +853,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.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +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= @@ -635,8 +873,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= @@ -665,6 +905,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= @@ -677,10 +918,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.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +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= @@ -689,14 +934,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= @@ -707,19 +968,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.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= @@ -794,8 +1063,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.16 h1:fdmMcjlA+VOpWO1lS8V7jzxIGvwgJ6Ls286FUpHoUSk= github.com/jenkins-x/go-scm v1.15.16/go.mod h1:RU3n2g3nxbIkjjm7cg7iOUh/7Wr1V+bTr/YM8qZeAr0= +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= @@ -805,6 +1076,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= @@ -820,6 +1093,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= @@ -874,6 +1149,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.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= @@ -900,21 +1177,38 @@ 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.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw= github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4= +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/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/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= @@ -925,6 +1219,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= @@ -942,10 +1237,21 @@ 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/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +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.0 h1:8xClBgMIRRJGDUC9xNe7NprP4kD2C3mQMeon3wY4KXA= github.com/ohler55/ojg v1.28.0/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= @@ -956,25 +1262,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.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 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.8 h1:v1NCqmKIKFQFYQihWUJuksvxILujvVA8HUOCAs5Lxr0= github.com/ory/client-go v1.22.8/go.mod h1:o/1hF5MKq3gyn9nUWZF/VVz35nitCzsGfIwl5SXVJ1Y= +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= @@ -1036,8 +1355,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.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +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= @@ -1053,6 +1374,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.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= +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= @@ -1064,7 +1386,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= @@ -1080,6 +1406,7 @@ 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/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= @@ -1103,16 +1430,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.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +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= @@ -1133,6 +1467,18 @@ 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/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= @@ -1158,8 +1504,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= @@ -1175,12 +1524,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= @@ -1192,6 +1549,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= @@ -1200,8 +1559,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= @@ -1217,6 +1578,9 @@ 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/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= @@ -1224,6 +1588,22 @@ github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrD github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE= 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= @@ -1233,6 +1613,7 @@ 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/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.63.0 h1:6YeICKmGrvgJ5th4+OMNpcuoB6q/Xs8gt0YCO7MUv1k= @@ -1241,10 +1622,12 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/propagators/aws v1.37.0/go.mod h1:Cy8Hk2E2iSGEbsLnPUdeigrexaAOAGIAmBFK919EQs0= go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo= go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU= 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/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.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= @@ -1265,10 +1648,13 @@ 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/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= @@ -1278,6 +1664,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= @@ -1290,6 +1677,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.44.0 h1:iVyMAqFl2r6xUy7M4mfqwlN+21UpJoEtgHEcfiLMUXs= gocloud.dev v0.44.0/go.mod h1:ZmjROXGdC/eKZLF1N+RujDlFRx3D+4Av2thREKDMVxY= +gocloud.dev/pubsub/kafkapubsub v0.44.0/go.mod h1:/gcNz6OG4HgcY+w2LXwwY4qaRMgtq+SXoPSQU2jOlcw= +gocloud.dev/pubsub/natspubsub v0.44.0/go.mod h1:PvVAGIhL14PWGwWIXX/zAK42ixr2/PKP4Q4yMiAUraQ= 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= @@ -1516,6 +1905,7 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.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/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= @@ -1614,6 +2004,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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +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= @@ -1626,6 +2018,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= @@ -1651,6 +2045,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.34.0 h1:lPRJRO+HqRX1SwFo1Xb/22nZ5MBEPUbXDl61OoDxlbY= google.golang.org/genai v1.34.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -1686,6 +2081,7 @@ google.golang.org/genproto v0.0.0-20260126211449-d11affda4bed h1:qZW022+WR7NN5TK google.golang.org/genproto v0.0.0-20260126211449-d11affda4bed/go.mod h1:SpjiK7gGN2j/djoQMxLl3QOe/J/XxNzC5M+YLecVVWU= 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/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= @@ -1703,6 +2099,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= @@ -1735,6 +2132,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= @@ -1786,18 +2186,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.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= +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.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +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.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= @@ -1824,9 +2233,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.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= @@ -1835,7 +2248,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/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 3dbddc505..c304dcc09 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/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) + }) +}) 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", "", 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)) })