diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go index 2cc5eac4..09309bcc 100644 --- a/cmd/server/handlers.go +++ b/cmd/server/handlers.go @@ -139,7 +139,12 @@ func NewHandlers(deps *HandlerDeps) routes.Handlers { // Dashboard & Branch Dashboard: handler.NewDashboardHandler(svc.Dashboard, log), - Branch: handler.NewBranchHandler(svc.Branch, v, log), + Branch: func() *handler.BranchHandler { + h := handler.NewBranchHandler(svc.Branch, v, log) + // S-2: wire AssetService so branch handler can verify repo ownership. + h.SetAssetService(svc.Asset) + return h + }(), // Integration Integration: handler.NewIntegrationHandler(svc.Integration, v, log), diff --git a/internal/app/asset/component.go b/internal/app/asset/component.go index c3ad6483..209af043 100644 --- a/internal/app/asset/component.go +++ b/internal/app/asset/component.go @@ -358,6 +358,50 @@ func (s *ComponentService) DeleteAssetComponents(ctx context.Context, assetID st return nil } +// ListVulnerabilitiesByComponent returns the CVEs affecting a global component within a tenant. +// Powers the "Vulnerabilities" tab on the component detail sheet. +func (s *ComponentService) ListVulnerabilitiesByComponent( + ctx context.Context, + tenantID, componentID string, + includeResolved bool, + page, perPage int, +) (pagination.Result[componentdom.ComponentVulnerability], error) { + parsedTenantID, err := shared.IDFromString(tenantID) + if err != nil { + return pagination.Result[componentdom.ComponentVulnerability]{}, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + parsedComponentID, err := shared.IDFromString(componentID) + if err != nil { + return pagination.Result[componentdom.ComponentVulnerability]{}, fmt.Errorf("%w: invalid component id format", shared.ErrValidation) + } + p := pagination.New(page, perPage) + return s.repo.ListVulnerabilities(ctx, parsedTenantID, parsedComponentID, includeResolved, p) +} + +// ListAssetUsageByComponent retrieves the assets within a tenant that use a given global component. +// Used by the "Used By Assets" blast-radius panel on the component detail sheet. +// +// When atRiskOnly is true, only assets with at least one open finding for this +// component are returned (matches the "at risk only" toggle in the UI). +func (s *ComponentService) ListAssetUsageByComponent( + ctx context.Context, + tenantID, componentID string, + atRiskOnly bool, + page, perPage int, +) (pagination.Result[componentdom.ComponentAssetUsage], error) { + parsedTenantID, err := shared.IDFromString(tenantID) + if err != nil { + return pagination.Result[componentdom.ComponentAssetUsage]{}, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + parsedComponentID, err := shared.IDFromString(componentID) + if err != nil { + return pagination.Result[componentdom.ComponentAssetUsage]{}, fmt.Errorf("%w: invalid component id format", shared.ErrValidation) + } + + p := pagination.New(page, perPage) + return s.repo.ListAssetUsage(ctx, parsedTenantID, parsedComponentID, atRiskOnly, p) +} + // GetLicenseStats retrieves license statistics for a tenant. func (s *ComponentService) GetLicenseStats(ctx context.Context, tenantID string) ([]componentdom.LicenseStats, error) { parsedTenantID, err := shared.IDFromString(tenantID) diff --git a/internal/app/asset/group.go b/internal/app/asset/group.go index 2de9a031..963ce9a8 100644 --- a/internal/app/asset/group.go +++ b/internal/app/asset/group.go @@ -227,7 +227,7 @@ func (s *AssetGroupService) UpdateAssetGroup(ctx context.Context, tenantIDStr st group.SetTags(input.Tags) } - if err := s.repo.Update(ctx, group); err != nil { + if err := s.repo.Update(ctx, tenantID, group); err != nil { return nil, err } @@ -235,12 +235,17 @@ func (s *AssetGroupService) UpdateAssetGroup(ctx context.Context, tenantIDStr st return group, nil } -// DeleteAssetGroup deletes an asset group. -func (s *AssetGroupService) DeleteAssetGroup(ctx context.Context, id shared.ID) error { - if err := s.repo.Delete(ctx, id); err != nil { +// DeleteAssetGroup deletes an asset group within the given tenant scope. +// Security: tenantID required so the SQL DELETE is tenant-scoped (S-1 audit). +func (s *AssetGroupService) DeleteAssetGroup(ctx context.Context, tenantIDStr string, id shared.ID) error { + tenantID, err := shared.IDFromString(tenantIDStr) + if err != nil { + return fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + if err := s.repo.Delete(ctx, tenantID, id); err != nil { return err } - s.logger.Info("asset group deleted", "id", id) + s.logger.Info("asset group deleted", "id", id, "tenant_id", tenantIDStr) return nil } @@ -447,7 +452,7 @@ func (s *AssetGroupService) BulkUpdateAssetGroups(ctx context.Context, tenantID } // BulkDeleteAssetGroups deletes multiple asset groups. -func (s *AssetGroupService) BulkDeleteAssetGroups(ctx context.Context, groupIDs []string) (int, error) { +func (s *AssetGroupService) BulkDeleteAssetGroups(ctx context.Context, tenantIDStr string, groupIDs []string) (int, error) { deleted := 0 for _, idStr := range groupIDs { id, err := shared.IDFromString(idStr) @@ -455,7 +460,7 @@ func (s *AssetGroupService) BulkDeleteAssetGroups(ctx context.Context, groupIDs continue } - if err := s.DeleteAssetGroup(ctx, id); err != nil { + if err := s.DeleteAssetGroup(ctx, tenantIDStr, id); err != nil { s.logger.Warn("bulk delete failed for group", "id", idStr, "error", err) continue } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 91218280..4166d949 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -649,12 +649,18 @@ type ExchangeTokenInput struct { } // ExchangeTokenResult represents the result of token exchange. +// +// RefreshToken is the NEW refresh token issued during rotation (S-3-rotate). +// Caller is expected to overwrite the old refresh_token cookie with this value +// — failing to do so means the next ExchangeToken call will fail (the old +// token is now marked used). type ExchangeTokenResult struct { - AccessToken string - TenantID string - TenantSlug string - Role string - ExpiresAt time.Time + AccessToken string + RefreshToken string + TenantID string + TenantSlug string + Role string + ExpiresAt time.Time } // ExchangeToken exchanges a global refresh token for a tenant-scoped access token. @@ -753,6 +759,41 @@ func (s *AuthService) ExchangeToken(ctx context.Context, input ExchangeTokenInpu s.logger.Error("failed to update session activity", "error", err) } + // S-3-rotate: rotate refresh token (mark old as used + issue new in same family). + // Matches the pattern used in CreateFirstTeam (line ~1300) and Refresh. + // Without rotation, a stolen refresh token remains valid for the full window; + // with rotation, theft is detected on the next legitimate use (token-already-used). + if err := storedToken.MarkUsed(); err != nil { + s.logger.Error("failed to mark refresh token as used", "error", err) + } else { + if err := s.refreshTokenRepo.Update(ctx, storedToken); err != nil { + s.logger.Error("failed to update refresh token", "error", err) + } + } + + newRefreshTokenStr, _, err := s.tokenGenerator.GenerateGlobalRefreshToken( + u.ID().String(), + u.Email(), + u.Name(), + sess.ID().String(), + ) + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + newRefreshToken, err := sessiondom.NewRefreshTokenInFamily( + u.ID(), + sess.ID(), + newRefreshTokenStr, + storedToken.Family(), + s.config.RefreshTokenDuration, + ) + if err != nil { + return nil, fmt.Errorf("failed to create refresh token: %w", err) + } + if err := s.refreshTokenRepo.Create(ctx, newRefreshToken); err != nil { + return nil, fmt.Errorf("failed to save refresh token: %w", err) + } + s.logger.Debug("token exchanged", "user_id", u.ID().String(), "tenant_id", input.TenantID, @@ -760,11 +801,12 @@ func (s *AuthService) ExchangeToken(ctx context.Context, input ExchangeTokenInpu ) return &ExchangeTokenResult{ - AccessToken: accessToken.AccessToken, - TenantID: accessToken.TenantID, - TenantSlug: accessToken.TenantSlug, - Role: accessToken.Role, - ExpiresAt: accessToken.ExpiresAt, + AccessToken: accessToken.AccessToken, + RefreshToken: newRefreshTokenStr, + TenantID: accessToken.TenantID, + TenantSlug: accessToken.TenantSlug, + Role: accessToken.Role, + ExpiresAt: accessToken.ExpiresAt, }, nil } diff --git a/internal/app/finding/vulnerability_service.go b/internal/app/finding/vulnerability_service.go index 9ce46f87..3c8e9587 100644 --- a/internal/app/finding/vulnerability_service.go +++ b/internal/app/finding/vulnerability_service.go @@ -4,11 +4,12 @@ import ( "context" "database/sql" "fmt" + "strings" + "time" + "github.com/openctemio/api/internal/app/activity" "github.com/openctemio/api/internal/app/aitriage" "github.com/openctemio/api/internal/app/integration" - "strings" - "time" "github.com/openctemio/api/internal/app/assignment" "github.com/openctemio/api/internal/app/outbox" @@ -195,6 +196,99 @@ func (s *VulnerabilityService) GetVulnerabilityByCVE(ctx context.Context, cveID return s.vulnRepo.GetByCVE(ctx, cveID) } +// ListAffectedAssets returns the assets in the tenant affected by a CVE +// (blast-radius reverse lookup). Powers the "Affected Assets" panel on the +// vulnerability detail sheet. +// +// includeResolved controls whether assets affected only by closed findings +// (resolved/false_positive/accepted) are included. Default is false (open only). +func (s *VulnerabilityService) ListAffectedAssets( + ctx context.Context, + tenantID, vulnID string, + includeResolved bool, + page, perPage int, +) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + parsedTenant, err := shared.IDFromString(tenantID) + if err != nil { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, + fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + parsedVuln, err := shared.IDFromString(vulnID) + if err != nil { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, + fmt.Errorf("%w: invalid vulnerability id format", shared.ErrValidation) + } + + p := pagination.New(page, perPage) + return s.findingRepo.ListAffectedAssetsByVulnerabilityID(ctx, parsedTenant, parsedVuln, includeResolved, p) +} + +// GetActiveCVEStats returns aggregate counts (total, by-severity, KEV, exploit) +// for the tenant's active CVEs. Powers the stat-card row above the Active CVEs +// table. +func (s *VulnerabilityService) GetActiveCVEStats( + ctx context.Context, + tenantID string, + includeResolved bool, +) (*vulnerability.ActiveCVEStats, error) { + parsedTenant, err := shared.IDFromString(tenantID) + if err != nil { + return nil, fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + return s.findingRepo.GetActiveCVEStats(ctx, parsedTenant, includeResolved) +} + +// ListActiveCVEsInput captures the query options for ListActiveCVEs. +type ListActiveCVEsInput struct { + TenantID string + IncludeResolved bool + SeverityIn []string + KEVOnly bool + MinCVSS *float64 + MinEPSS *float64 + ExploitAvailable *bool + Page int + PerPage int +} + +// ListActiveCVEs returns CVEs currently impacting tenant assets (Active CVEs view). +// Distinct from the global CVE catalog — scoped to findings within tenant. +func (s *VulnerabilityService) ListActiveCVEs( + ctx context.Context, + input ListActiveCVEsInput, +) (pagination.Result[vulnerability.ActiveCVE], error) { + parsedTenant, err := shared.IDFromString(input.TenantID) + if err != nil { + return pagination.Result[vulnerability.ActiveCVE]{}, + fmt.Errorf("%w: invalid tenant id format", shared.ErrValidation) + } + filter := vulnerability.ActiveCVEFilter{ + IncludeResolved: input.IncludeResolved, + SeverityIn: input.SeverityIn, + KEVOnly: input.KEVOnly, + MinCVSS: input.MinCVSS, + MinEPSS: input.MinEPSS, + ExploitAvailable: input.ExploitAvailable, + } + p := pagination.New(input.Page, input.PerPage) + return s.findingRepo.ListActiveCVEsByTenant(ctx, parsedTenant, filter, p) +} + +// ListAffectedAssetsByCVE is a convenience wrapper that resolves CVE-2024-XXXX +// to the global Vulnerability ID then delegates. +func (s *VulnerabilityService) ListAffectedAssetsByCVE( + ctx context.Context, + tenantID, cveID string, + includeResolved bool, + page, perPage int, +) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + v, err := s.vulnRepo.GetByCVE(ctx, cveID) + if err != nil { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, err + } + return s.ListAffectedAssets(ctx, tenantID, v.ID().String(), includeResolved, page, perPage) +} + // UpdateVulnerabilityInput represents the input for updating a vulnerability. type UpdateVulnerabilityInput struct { Title *string `validate:"omitempty,min=1,max=500"` diff --git a/internal/app/finding_service.go b/internal/app/finding_service.go index 12ea85ed..cadb69bc 100644 --- a/internal/app/finding_service.go +++ b/internal/app/finding_service.go @@ -49,6 +49,7 @@ type ( KEVRepository = finding.KEVRepository ListFindingsInput = finding.ListFindingsInput ListVulnerabilitiesInput = finding.ListVulnerabilitiesInput + ListActiveCVEsInput = finding.ListActiveCVEsInput PriorityAuditEntry = finding.PriorityAuditEntry PriorityAuditRepository = finding.PriorityAuditRepository PriorityChangeEvent = finding.PriorityChangeEvent diff --git a/internal/app/ingest/processor_components_test.go b/internal/app/ingest/processor_components_test.go index 6d4e119e..07768025 100644 --- a/internal/app/ingest/processor_components_test.go +++ b/internal/app/ingest/processor_components_test.go @@ -122,6 +122,14 @@ func (m *MockComponentRepository) GetLicenseStats(ctx context.Context, tenantID return nil, nil } +func (m *MockComponentRepository) ListAssetUsage(_ context.Context, _ shared.ID, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentAssetUsage], error) { + return pagination.NewResult([]component.ComponentAssetUsage{}, 0, page), nil +} + +func (m *MockComponentRepository) ListVulnerabilities(_ context.Context, _, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentVulnerability], error) { + return pagination.NewResult([]component.ComponentVulnerability{}, 0, page), nil +} + func TestComponentProcessor_ProcessBatch_WithLicenses(t *testing.T) { // Setup mockRepo := new(MockComponentRepository) diff --git a/internal/app/ingest/processor_findings_test.go b/internal/app/ingest/processor_findings_test.go index 2a4e3029..c67c70d4 100644 --- a/internal/app/ingest/processor_findings_test.go +++ b/internal/app/ingest/processor_findings_test.go @@ -1190,6 +1190,15 @@ func (s *stubFindingRepository) ListByVulnerabilityID(_ context.Context, _, _ sh func (s *stubFindingRepository) ListByComponentID(_ context.Context, _, _ shared.ID, _ vulnerability.FindingListOptions, _ pagination.Pagination) (pagination.Result[*vulnerability.Finding], error) { return pagination.Result[*vulnerability.Finding]{}, nil } +func (s *stubFindingRepository) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (s *stubFindingRepository) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (s *stubFindingRepository) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} func (s *stubFindingRepository) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return 0, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 5aef017e..8e3db3bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config loads and validates application configuration from environment variables. package config import ( @@ -95,6 +96,13 @@ type ServerConfig struct { MaxBodySize int64 SessionTimeoutMinutes int // Session timeout in minutes (0 = disabled, default: 30) MaxConcurrentRequests int // Maximum concurrent requests (default: 1000) + // TrustedProxies is the list of CIDR ranges (or bare IPs) whose + // X-Real-IP / X-Forwarded-For headers will be honored. Requests from + // peers outside this list have their forwarding headers ignored — + // preventing IP spoofing of rate-limit and audit-log keys (S-4). + // Empty list = treat the API as directly Internet-facing. + // Configure via SERVER_TRUSTED_PROXIES (comma-separated CIDRs). + TrustedProxies []string } // GRPCConfig holds gRPC server configuration. @@ -515,6 +523,7 @@ func Load() (*Config, error) { MaxBodySize: getEnvInt64("SERVER_MAX_BODY_SIZE", 10<<20), // 10MB default SessionTimeoutMinutes: getEnvInt("SESSION_TIMEOUT_MINUTES", 30), // 30 minutes default MaxConcurrentRequests: getEnvInt("MAX_CONCURRENT_REQUESTS", 1000), // 1000 concurrent requests default + TrustedProxies: getEnvSlice("SERVER_TRUSTED_PROXIES", nil), }, GRPC: GRPCConfig{ Port: getEnvInt("GRPC_PORT", 9090), @@ -795,12 +804,12 @@ func (c *Config) validateEncryption() error { // Auto-detect format if not specified if format == "" { - switch { - case keyLen == 32: + switch keyLen { + case 32: format = "raw" - case keyLen == 64: + case 64: format = "hex" - case keyLen == 44: + case 44: format = "base64" default: return fmt.Errorf("APP_ENCRYPTION_KEY has invalid length %d (expected 32 raw, 64 hex, or 44 base64)", keyLen) @@ -1145,7 +1154,7 @@ func getEnvSlice(key string, defaultValue []string) []string { func splitAndTrim(s, sep string) []string { parts := make([]string, 0) - for _, p := range strings.Split(s, sep) { + for p := range strings.SplitSeq(s, sep) { trimmed := strings.TrimSpace(p) if trimmed != "" { parts = append(parts, trimmed) diff --git a/internal/infra/http/handler/asset_group_handler.go b/internal/infra/http/handler/asset_group_handler.go index c47e3c23..81f0600b 100644 --- a/internal/infra/http/handler/asset_group_handler.go +++ b/internal/infra/http/handler/asset_group_handler.go @@ -424,7 +424,7 @@ func (h *AssetGroupHandler) Delete(w http.ResponseWriter, r *http.Request) { return } - if err := h.service.DeleteAssetGroup(r.Context(), id); err != nil { + if err := h.service.DeleteAssetGroup(r.Context(), middleware.MustGetTenantID(r.Context()), id); err != nil { h.handleServiceError(w, err) return } @@ -761,7 +761,7 @@ func (h *AssetGroupHandler) BulkDelete(w http.ResponseWriter, r *http.Request) { return } - deleted, err := h.service.BulkDeleteAssetGroups(r.Context(), req.GroupIDs) + deleted, err := h.service.BulkDeleteAssetGroups(r.Context(), middleware.MustGetTenantID(r.Context()), req.GroupIDs) if err != nil { h.handleServiceError(w, err) return diff --git a/internal/infra/http/handler/branch_handler.go b/internal/infra/http/handler/branch_handler.go index 5f43ce41..d7c92c8b 100644 --- a/internal/infra/http/handler/branch_handler.go +++ b/internal/infra/http/handler/branch_handler.go @@ -17,9 +17,10 @@ import ( // BranchHandler handles branch-related HTTP requests. type BranchHandler struct { - service *app.BranchService - validator *validator.Validator - logger *logger.Logger + service *app.BranchService + assetService *app.AssetService // for tenant ownership validation (S-2) + validator *validator.Validator + logger *logger.Logger } // NewBranchHandler creates a new branch handler. @@ -31,6 +32,40 @@ func NewBranchHandler(svc *app.BranchService, v *validator.Validator, log *logge } } +// SetAssetService wires the asset service used for repository ownership checks +// (S-2: branch handler must verify the URL repo belongs to caller's tenant). +func (h *BranchHandler) SetAssetService(svc *app.AssetService) { + h.assetService = svc +} + +// ensureRepoOwnedByTenant verifies that the repository referenced in the URL +// belongs to the caller's tenant. Returns true if valid; on failure writes +// 404 (treat as not-found to avoid leaking existence) and returns false. +// +// Security rationale (S-2 audit): without this check, a member of tenant A +// could mutate branches of tenant B's repository by guessing its UUID. We use +// the asset service (repository assets are stored in `assets` table with +// asset_type='repository') because it already enforces tenant scoping in SQL. +func (h *BranchHandler) ensureRepoOwnedByTenant(w http.ResponseWriter, r *http.Request, repoIDStr string) bool { + if h.assetService == nil { + // Service not wired (test environment): be conservative and allow. + return true + } + tenantID := middleware.MustGetTenantID(r.Context()) + if _, err := shared.IDFromString(repoIDStr); err != nil { + apierror.BadRequest("Invalid repository ID").WriteJSON(w) + return false + } + if _, err := h.assetService.GetAsset(r.Context(), tenantID, repoIDStr); err != nil { + // Asset service returns shared.ErrNotFound when the repo doesn't exist + // OR isn't owned by this tenant. Either way the answer is 404 — never + // 403 (would leak that the resource exists in another tenant). + apierror.NotFound("Repository").WriteJSON(w) + return false + } + return true +} + // BranchResponse represents a branch in API responses. type BranchResponse struct { ID string `json:"id"` @@ -175,6 +210,9 @@ func (h *BranchHandler) List(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID is required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } query := r.URL.Query() input := app.ListBranchesInput{ @@ -238,6 +276,9 @@ func (h *BranchHandler) Create(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID is required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } var req CreateBranchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -289,6 +330,9 @@ func (h *BranchHandler) Get(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID and Branch ID are required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } b, err := h.service.GetBranch(r.Context(), branchID) if err != nil { @@ -328,6 +372,9 @@ func (h *BranchHandler) Update(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID and Branch ID are required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } var req UpdateBranchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -381,6 +428,9 @@ func (h *BranchHandler) Delete(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID and Branch ID are required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } if err := h.service.DeleteBranch(r.Context(), branchID, repositoryID); err != nil { h.handleServiceError(w, err) @@ -409,6 +459,9 @@ func (h *BranchHandler) SetDefault(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID and Branch ID are required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } b, err := h.service.SetDefaultBranch(r.Context(), branchID, repositoryID) if err != nil { @@ -438,6 +491,9 @@ func (h *BranchHandler) GetDefault(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID is required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } b, err := h.service.GetDefaultBranch(r.Context(), repositoryID) if err != nil { @@ -459,6 +515,9 @@ func (h *BranchHandler) Compare(w http.ResponseWriter, r *http.Request) { apierror.BadRequest("Repository ID is required").WriteJSON(w) return } + if !h.ensureRepoOwnedByTenant(w, r, repositoryID) { + return + } baseBranch := r.URL.Query().Get("base") compareBranch := r.URL.Query().Get("compare") diff --git a/internal/infra/http/handler/component_handler.go b/internal/infra/http/handler/component_handler.go index c7119ceb..5b359774 100644 --- a/internal/infra/http/handler/component_handler.go +++ b/internal/infra/http/handler/component_handler.go @@ -650,6 +650,108 @@ func toAssetComponentResponse(d *component.AssetDependency) ComponentResponse { } } +// ListVulnerabilities handles GET /api/v1/components/{id}/vulnerabilities +// @Summary List CVEs that affect a component +// @Description Returns CVEs affecting a global component within the current tenant. +// One row per CVE with affected_assets_count rolled up. +// @Tags Components +// @Produce json +// @Security BearerAuth +// @Param id path string true "Global component ID" +// @Param include_resolved query bool false "Include CVEs only seen in closed findings" +// @Param page query int false "Page number" default(1) +// @Param per_page query int false "Items per page" default(20) +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /components/{id}/vulnerabilities [get] +func (h *ComponentHandler) ListVulnerabilities(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + + componentID := r.PathValue("id") + if componentID == "" { + apierror.BadRequest("Component ID is required").WriteJSON(w) + return + } + + query := r.URL.Query() + includeResolved := parseQueryBool(query.Get("include_resolved")) + page := parseQueryInt(query.Get("page"), 1) + perPage := parseQueryIntBounded(query.Get("per_page"), 20, 1, MaxPerPage) + + result, err := h.service.ListVulnerabilitiesByComponent(r.Context(), tenantID, componentID, + includeResolved != nil && *includeResolved, page, perPage) + if err != nil { + h.handleServiceError(w, err) + return + } + + response := ListResponse[component.ComponentVulnerability]{ + Data: result.Data, + Total: result.Total, + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + Links: NewPaginationLinks(r, result.Page, result.PerPage, result.TotalPages), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + +// ListAssets handles GET /api/v1/components/{id}/assets +// @Summary List assets that use a component +// @Description Returns the assets in the current tenant that use the given global component +// +// (blast-radius reverse lookup). +// +// @Tags Components +// @Produce json +// @Security BearerAuth +// @Param id path string true "Global component ID" +// @Param at_risk_only query bool false "Only return assets with open findings for this component" +// @Param page query int false "Page number" default(1) +// @Param per_page query int false "Items per page" default(20) +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /components/{id}/assets [get] +func (h *ComponentHandler) ListAssets(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + + componentID := r.PathValue("id") + if componentID == "" { + apierror.BadRequest("Component ID is required").WriteJSON(w) + return + } + + query := r.URL.Query() + atRiskOnly := parseQueryBool(query.Get("at_risk_only")) + page := parseQueryInt(query.Get("page"), 1) + perPage := parseQueryIntBounded(query.Get("per_page"), 20, 1, MaxPerPage) + + result, err := h.service.ListAssetUsageByComponent(r.Context(), tenantID, componentID, + atRiskOnly != nil && *atRiskOnly, page, perPage) + if err != nil { + h.handleServiceError(w, err) + return + } + + response := ListResponse[component.ComponentAssetUsage]{ + Data: result.Data, + Total: result.Total, + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + Links: NewPaginationLinks(r, result.Page, result.PerPage, result.TotalPages), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + // ListByAsset handles GET /api/v1/assets/{id}/components // @Summary List asset components // @Description Retrieves all components for an asset diff --git a/internal/infra/http/handler/local_auth_handler.go b/internal/infra/http/handler/local_auth_handler.go index 4dd09eec..a56fc6de 100644 --- a/internal/infra/http/handler/local_auth_handler.go +++ b/internal/infra/http/handler/local_auth_handler.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "net/http" - "strings" + "time" "github.com/openctemio/api/internal/app" "github.com/openctemio/api/internal/config" @@ -12,6 +12,7 @@ import ( "github.com/openctemio/api/pkg/apierror" "github.com/openctemio/api/pkg/domain/session" "github.com/openctemio/api/pkg/domain/shared" + "github.com/openctemio/api/pkg/httpsec" "github.com/openctemio/api/pkg/logger" "github.com/openctemio/api/pkg/password" "github.com/openctemio/api/pkg/validator" @@ -289,10 +290,14 @@ func (h *LocalAuthHandler) Login(w http.ResponseWriter, r *http.Request) { } middleware.SetCSRFTokenCookie(w, csrfToken, h.csrfConfig) + // SECURITY (S-3): Do NOT include refresh_token in the response body. + // It is set as an httpOnly cookie above (SetRefreshTokenCookie) which is + // the only place a browser-bound client should read it from. Returning + // it in the body lets any XSS / browser extension / analytics middleware + // capture the long-lived credential, enabling persistent ATO. resp := LoginResponse{ - RefreshToken: result.RefreshToken, // Also in body for backward compatibility - TokenType: "Bearer", - ExpiresIn: expiresIn, + TokenType: "Bearer", + ExpiresIn: expiresIn, User: UserInfo{ ID: result.User.ID().String(), Email: result.User.Email(), @@ -412,6 +417,15 @@ func (h *LocalAuthHandler) ExchangeToken(w http.ResponseWriter, r *http.Request) expiresIn := int64(h.authConfig.AccessTokenDuration.Seconds()) + // S-3-rotate: ExchangeToken now rotates the refresh token. Persist the + // NEW refresh token to the httpOnly cookie so the next call works. + // Body intentionally OMITS the refresh token — it lives in cookie only + // (matches S-3 hardening: never echo refresh tokens in JSON bodies). + if result.RefreshToken != "" { + refreshExpiresAt := time.Now().Add(h.authConfig.RefreshTokenDuration) + SetRefreshTokenCookie(w, result.RefreshToken, refreshExpiresAt, h.cookieConfig) + } + resp := ExchangeTokenResponse{ AccessToken: result.AccessToken, TokenType: "Bearer", @@ -508,14 +522,15 @@ func (h *LocalAuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) } middleware.SetCSRFTokenCookie(w, csrfToken, h.csrfConfig) + // SECURITY (S-3): omit refresh_token from response body — set in httpOnly + // cookie above. Browser clients never need it in JS. resp := RefreshTokenResponse{ - AccessToken: result.AccessToken, - RefreshToken: result.RefreshToken, // Also in body for backward compatibility - TokenType: "Bearer", - ExpiresIn: expiresIn, - TenantID: result.TenantID, - TenantSlug: result.TenantSlug, - Role: result.Role, + AccessToken: result.AccessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + TenantID: result.TenantID, + TenantSlug: result.TenantSlug, + Role: result.Role, } w.Header().Set("Content-Type", "application/json") @@ -1155,27 +1170,24 @@ func (h *LocalAuthHandler) handleAuthError(w http.ResponseWriter, err error) { } // getClientIP extracts the client IP address from the request. +// +// SECURITY (S-4): Forwarding headers are honored only when the immediate +// TCP peer sits in the trusted-proxy CIDR allowlist. Without this guard +// attackers could spoof X-Forwarded-For to attribute brute-force / abuse +// attempts to fake IPs in the audit log. +// +// trustedProxiesForAuth is set during server bootstrap. If nil (tests, +// direct-Internet deployments) only r.RemoteAddr is honored. func getClientIP(r *http.Request) string { - // Check X-Forwarded-For header first (for proxied requests) - xff := r.Header.Get("X-Forwarded-For") - if xff != "" { - // Take the first IP in the list - if idx := strings.Index(xff, ","); idx != -1 { - return strings.TrimSpace(xff[:idx]) - } - return strings.TrimSpace(xff) - } + return httpsec.ClientIP(r, trustedProxiesForAuth) +} - // Check X-Real-IP header - xri := r.Header.Get("X-Real-IP") - if xri != "" { - return strings.TrimSpace(xri) - } +// trustedProxiesForAuth is the package-level proxy allowlist used by +// auth-handler audit code. Wired once at startup via SetAuthTrustedProxies. +var trustedProxiesForAuth *httpsec.TrustedProxySet //nolint:gochecknoglobals // set once at startup - // Fall back to RemoteAddr - ip := r.RemoteAddr - if idx := strings.LastIndex(ip, ":"); idx != -1 { - return ip[:idx] - } - return ip +// SetAuthTrustedProxies configures the trusted-proxy set used by the +// auth handler's IP attribution. Call once during server bootstrap. +func SetAuthTrustedProxies(set *httpsec.TrustedProxySet) { + trustedProxiesForAuth = set } diff --git a/internal/infra/http/handler/vulnerability_handler.go b/internal/infra/http/handler/vulnerability_handler.go index f8bb7aa4..894d95f3 100644 --- a/internal/infra/http/handler/vulnerability_handler.go +++ b/internal/infra/http/handler/vulnerability_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "time" "github.com/openctemio/api/internal/app" @@ -378,10 +379,10 @@ type FindingResponse struct { HostedViewerURI string `json:"hosted_viewer_uri,omitempty"` // Threat Intel Enrichment (RFC-004) - EPSSScore *float64 `json:"epss_score,omitempty"` - EPSSPercentile *float64 `json:"epss_percentile,omitempty"` - IsInKEV bool `json:"is_in_kev,omitempty"` - KEVDueDate *string `json:"kev_due_date,omitempty"` + EPSSScore *float64 `json:"epss_score,omitempty"` + EPSSPercentile *float64 `json:"epss_percentile,omitempty"` + IsInKEV bool `json:"is_in_kev,omitempty"` + KEVDueDate *string `json:"kev_due_date,omitempty"` // Priority Classification (RFC-004) PriorityClass *string `json:"priority_class,omitempty"` @@ -1250,6 +1251,188 @@ func (h *VulnerabilityHandler) GetVulnerabilityByCVE(w http.ResponseWriter, r *h _ = json.NewEncoder(w).Encode(toVulnerabilityResponse(v)) } +// GetActiveCVEStats handles GET /api/v1/vulnerabilities/active/stats +// @Summary Aggregate stats for CVEs currently impacting the tenant +// @Description Counts (total, by severity, KEV, exploit-available) for the +// Active CVEs view. Renders the stat-card row above the table. +// @Tags Vulnerabilities +// @Produce json +// @Security BearerAuth +// @Param include_resolved query bool false "Include CVEs only seen in closed findings" +// @Success 200 {object} vulnerability.ActiveCVEStats +// @Router /vulnerabilities/active/stats [get] +func (h *VulnerabilityHandler) GetActiveCVEStats(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + includeResolved := parseQueryBool(r.URL.Query().Get("include_resolved")) + + stats, err := h.service.GetActiveCVEStats(r.Context(), tenantID, + includeResolved != nil && *includeResolved) + if err != nil { + h.handleServiceError(w, err, "Vulnerability") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(stats) +} + +// ListActiveCVEs handles GET /api/v1/vulnerabilities/active +// @Summary List CVEs currently impacting the tenant +// @Description Distinct CVEs that have at least one finding (default: open) on +// an asset in the current tenant. The "Active CVEs" view, distinct +// from the global CVE catalog at GET /vulnerabilities. +// @Tags Vulnerabilities +// @Produce json +// @Security BearerAuth +// @Param include_resolved query bool false "Include CVEs only seen in closed findings" +// @Param severities query string false "Comma-separated severities (critical,high,...)" +// @Param kev_only query bool false "Only return CISA KEV-listed CVEs" +// @Param min_cvss query number false "Minimum CVSS score" +// @Param min_epss query number false "Minimum EPSS score (0-1)" +// @Param exploit_available query bool false "Only CVEs with public exploit" +// @Param page query int false "Page number" default(1) +// @Param per_page query int false "Items per page" default(20) +// @Success 200 {object} map[string]interface{} +// @Router /vulnerabilities/active [get] +func (h *VulnerabilityHandler) ListActiveCVEs(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + + q := r.URL.Query() + includeResolved := parseQueryBool(q.Get("include_resolved")) + kevOnly := parseQueryBool(q.Get("kev_only")) + exploitAvailable := parseQueryBool(q.Get("exploit_available")) + + input := app.ListActiveCVEsInput{ + TenantID: tenantID, + IncludeResolved: includeResolved != nil && *includeResolved, + SeverityIn: parseQueryArray(q.Get("severities")), + KEVOnly: kevOnly != nil && *kevOnly, + ExploitAvailable: exploitAvailable, + Page: parseQueryInt(q.Get("page"), 1), + PerPage: parseQueryIntBounded(q.Get("per_page"), 20, 1, MaxPerPage), + } + if v := q.Get("min_cvss"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + input.MinCVSS = &f + } + } + if v := q.Get("min_epss"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + input.MinEPSS = &f + } + } + + result, err := h.service.ListActiveCVEs(r.Context(), input) + if err != nil { + h.handleServiceError(w, err, "Vulnerability") + return + } + + response := ListResponse[vulnerability.ActiveCVE]{ + Data: result.Data, + Total: result.Total, + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + Links: NewPaginationLinks(r, result.Page, result.PerPage, result.TotalPages), + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + +// ListAffectedAssets handles GET /api/v1/vulnerabilities/{id}/affected-assets +// @Summary List assets affected by a CVE (blast-radius) +// @Description Returns the assets in the current tenant affected by this CVE, +// +// aggregated from findings. +// +// @Tags Vulnerabilities +// @Produce json +// @Security BearerAuth +// @Param id path string true "Vulnerability ID" +// @Param include_resolved query bool false "Include assets affected only by closed findings" +// @Param page query int false "Page number" default(1) +// @Param per_page query int false "Items per page" default(20) +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /vulnerabilities/{id}/affected-assets [get] +func (h *VulnerabilityHandler) ListAffectedAssets(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + + vulnID := r.PathValue("id") + if vulnID == "" { + apierror.BadRequest("Vulnerability ID is required").WriteJSON(w) + return + } + + query := r.URL.Query() + includeResolved := parseQueryBool(query.Get("include_resolved")) + + page := parseQueryInt(query.Get("page"), 1) + perPage := parseQueryIntBounded(query.Get("per_page"), 20, 1, MaxPerPage) + + result, err := h.service.ListAffectedAssets(r.Context(), tenantID, vulnID, + includeResolved != nil && *includeResolved, page, perPage) + if err != nil { + h.handleServiceError(w, err, "Vulnerability") + return + } + + response := ListResponse[vulnerability.VulnerabilityAffectedAsset]{ + Data: result.Data, + Total: result.Total, + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + Links: NewPaginationLinks(r, result.Page, result.PerPage, result.TotalPages), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + +// ListAffectedAssetsByCVE handles GET /api/v1/vulnerabilities/cve/{cveId}/affected-assets +// Same as ListAffectedAssets but takes a CVE string identifier instead of UUID. +func (h *VulnerabilityHandler) ListAffectedAssetsByCVE(w http.ResponseWriter, r *http.Request) { + tenantID := middleware.MustGetTenantID(r.Context()) + + cveID := r.PathValue("cveId") + if cveID == "" { + apierror.BadRequest("CVE ID is required").WriteJSON(w) + return + } + + query := r.URL.Query() + includeResolved := parseQueryBool(query.Get("include_resolved")) + + page := parseQueryInt(query.Get("page"), 1) + perPage := parseQueryIntBounded(query.Get("per_page"), 20, 1, MaxPerPage) + + result, err := h.service.ListAffectedAssetsByCVE(r.Context(), tenantID, cveID, + includeResolved != nil && *includeResolved, page, perPage) + if err != nil { + h.handleServiceError(w, err, "Vulnerability") + return + } + + response := ListResponse[vulnerability.VulnerabilityAffectedAsset]{ + Data: result.Data, + Total: result.Total, + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + Links: NewPaginationLinks(r, result.Page, result.PerPage, result.TotalPages), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + // UpdateVulnerability handles PUT /api/v1/vulnerabilities/{id} // @Summary Update vulnerability // @Description Updates a vulnerability @@ -2580,8 +2763,8 @@ func (h *VulnerabilityHandler) CancelApproval(w http.ResponseWriter, r *http.Req userID := middleware.GetUserID(r.Context()) approval, err := h.service.CancelApproval(r.Context(), app.CancelApprovalInput{ - TenantID: tenantID, - ApprovalID: approvalID, + TenantID: tenantID, + ApprovalID: approvalID, CanceledBy: userID, }) if err != nil { @@ -2618,4 +2801,3 @@ func (h *VulnerabilityHandler) ListPendingApprovals(w http.ResponseWriter, r *ht "per_page": result.PerPage, }) } - diff --git a/internal/infra/http/middleware/bodylimit.go b/internal/infra/http/middleware/bodylimit.go index 3524fcd9..ddb10fa1 100644 --- a/internal/infra/http/middleware/bodylimit.go +++ b/internal/infra/http/middleware/bodylimit.go @@ -36,7 +36,7 @@ func BodyLimit(maxBytes int64) func(http.Handler) http.Handler { } } -// BodyLimitHandler is an error handler for body limit exceeded. +// HandleBodyLimitError is an error handler for body limit exceeded. // Use this in your error handling middleware to catch http.MaxBytesError. func HandleBodyLimitError(w http.ResponseWriter, _ *http.Request) { apierror.New(http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", diff --git a/internal/infra/http/middleware/ratelimit.go b/internal/infra/http/middleware/ratelimit.go index a5dd7947..aa690ee6 100644 --- a/internal/infra/http/middleware/ratelimit.go +++ b/internal/infra/http/middleware/ratelimit.go @@ -4,7 +4,6 @@ import ( "math" "net/http" "strconv" - "strings" "sync" "time" @@ -13,6 +12,7 @@ import ( "github.com/openctemio/api/internal/config" redisinfra "github.com/openctemio/api/internal/infra/redis" "github.com/openctemio/api/pkg/apierror" + "github.com/openctemio/api/pkg/httpsec" "github.com/openctemio/api/pkg/logger" ) @@ -178,31 +178,29 @@ func RateLimit(cfg *config.RateLimitConfig, log *logger.Logger) func(http.Handle } // getClientIP extracts the real client IP from the request. -// Note: In production behind a trusted proxy, configure your proxy -// to set X-Real-IP or the rightmost X-Forwarded-For IP. +// +// SECURITY (S-4): Forwarding headers (X-Real-IP, X-Forwarded-For) are +// honored ONLY when the immediate TCP peer (r.RemoteAddr) is in the +// configured trusted-proxy CIDR allowlist. Without this guard, attackers +// could spoof IPs to defeat the per-IP rate limit and corrupt audit +// logging on login / password-reset flows. +// +// trustedProxies is package-level state populated once at startup via +// SetTrustedProxies. When unset (e.g. tests, no SERVER_TRUSTED_PROXIES), +// only r.RemoteAddr is trusted — which is correct for direct-Internet +// deployments. func getClientIP(r *http.Request) string { - // Check X-Real-IP header (typically set by nginx) - if xrip := r.Header.Get("X-Real-IP"); xrip != "" { - return strings.TrimSpace(xrip) - } + return httpsec.ClientIP(r, trustedProxies) +} - // Check X-Forwarded-For header - // Warning: This can be spoofed if not behind a trusted proxy - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - // Take the first IP in the list (client IP) - if idx := strings.Index(xff, ","); idx != -1 { - return strings.TrimSpace(xff[:idx]) - } - return strings.TrimSpace(xff) - } +// trustedProxies is wired by cmd/server during init. It can stay nil for +// tests / direct-Internet deployments — ClientIP falls back to r.RemoteAddr. +var trustedProxies *httpsec.TrustedProxySet //nolint:gochecknoglobals // set once at startup - // Fall back to RemoteAddr - // Remove port if present - ip := r.RemoteAddr - if idx := strings.LastIndex(ip, ":"); idx != -1 { - return ip[:idx] - } - return ip +// SetTrustedProxies configures the package-level trusted-proxy set used by +// rate-limit and audit-log paths. Call once during server bootstrap. +func SetTrustedProxies(set *httpsec.TrustedProxySet) { + trustedProxies = set } // DistributedRateLimitConfig configures the distributed rate limit middleware. diff --git a/internal/infra/http/middleware/unified_auth.go b/internal/infra/http/middleware/unified_auth.go index 374b0be0..ab90f049 100644 --- a/internal/infra/http/middleware/unified_auth.go +++ b/internal/infra/http/middleware/unified_auth.go @@ -56,9 +56,20 @@ type UnifiedAuthConfig struct { const DefaultAccessTokenCookieName = "auth_token" // extractToken extracts the JWT token from the request. -// Priority: Authorization header > Cookie > query parameter "token" -// Cookie-based auth is preferred for WebSocket (browser sends cookies automatically). -// Query parameter is needed for SSE/EventSource which cannot send custom headers. +// Priority: Authorization header > httpOnly cookie. +// +// SECURITY (S-5): The query-parameter fallback (`?token=`) was REMOVED from +// the default extractor because tokens leak via: +// - nginx/CDN access logs +// - browser history & autocomplete +// - Referer headers sent to 3rd-party domains +// - paste-into-Slack social engineering ("here's the URL" with token in it) +// +// SSE/EventSource genuinely needs query-param auth (browsers don't allow +// custom headers on EventSource), but the codebase has migrated all +// streaming endpoints to WebSocket (which DOES forward cookies during the +// upgrade handshake). If SSE is ever reintroduced, add a dedicated extractor +// next to its route — never reintroduce a query-param fallback here. func extractToken(r *http.Request) string { // 1. Try Authorization header first (standard API auth) authHeader := r.Header.Get("Authorization") @@ -69,19 +80,13 @@ func extractToken(r *http.Request) string { } } - // 2. Try httpOnly cookie (for WebSocket connections) - // Browser automatically sends cookies during WebSocket upgrade request - // This eliminates the need for frontend to expose token via query param + // 2. Try httpOnly cookie (for WebSocket connections + cookie-based SPA) + // Browser automatically sends cookies during WebSocket upgrade request, + // eliminating any need for frontend to expose token via query param. if cookie, err := r.Cookie(DefaultAccessTokenCookieName); err == nil && cookie.Value != "" { return cookie.Value } - // 3. Fallback to query parameter for SSE/EventSource - // Note: Query param auth is less secure (logged in URLs), only use for SSE - if token := r.URL.Query().Get("token"); token != "" { - return token - } - return "" } @@ -91,9 +96,9 @@ func extractToken(r *http.Request) string { // - "oidc": Only validates Keycloak/OIDC tokens // - "hybrid": Tries local first, then falls back to OIDC // -// Token extraction order: +// Token extraction order (see extractToken): // 1. Authorization header (Bearer ) -// 2. Query parameter "token" (for SSE/EventSource which can't send headers) +// 2. httpOnly cookie (auth_token) — used by WebSocket upgrade and cookie SPA func UnifiedAuth(cfg UnifiedAuthConfig) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -538,50 +543,12 @@ func RequirePlatformAdmin() func(http.Handler) http.Handler { } } -// OptionalUnifiedAuth creates an optional authentication middleware. -// It extracts claims if present but doesn't require authentication. -func OptionalUnifiedAuth(cfg UnifiedAuthConfig) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - next.ServeHTTP(w, r) - return - } - - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - next.ServeHTTP(w, r) - return - } - - tokenString := parts[1] - if tokenString == "" { - next.ServeHTTP(w, r) - return - } - - var ctx context.Context - var err error - - switch cfg.Provider { - case config.AuthProviderLocal: - ctx, err = validateLocalToken(r.Context(), tokenString, cfg.LocalValidator) - case config.AuthProviderOIDC: - ctx, err = validateOIDCToken(r.Context(), tokenString, cfg.OIDCValidator, cfg.Logger) - case config.AuthProviderHybrid: - ctx, err = validateLocalToken(r.Context(), tokenString, cfg.LocalValidator) - if err != nil && cfg.OIDCValidator != nil { - ctx, err = validateOIDCToken(r.Context(), tokenString, cfg.OIDCValidator, cfg.Logger) - } - } - - if err == nil && ctx != nil { - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - - next.ServeHTTP(w, r) - }) - } -} +// REMOVED (S-8): OptionalUnifiedAuth. +// The middleware silently passed through requests when a Bearer token was +// present but invalid (next.ServeHTTP without auth context). Future code that +// mounts it could be tricked: attacker sends `Authorization: Bearer junk` and +// reaches an unauthenticated handler that assumes claims-or-nothing. +// Confirmed zero callers via repo-wide grep before removal. +// If an SSE-style "auth optional" pattern is needed later, build a new +// middleware that returns 401 when a token is present-but-invalid (only skip +// auth on the missing-header case). diff --git a/internal/infra/http/routes/assets.go b/internal/infra/http/routes/assets.go index e3cdd18c..15809171 100644 --- a/internal/infra/http/routes/assets.go +++ b/internal/infra/http/routes/assets.go @@ -113,6 +113,10 @@ func registerComponentRoutes( // Read operations r.GET("/", h.List, middleware.Require(permission.ComponentsRead)) + // Reverse lookup: assets that use a given global component (blast-radius) + r.GET("/{id}/assets", h.ListAssets, middleware.Require(permission.ComponentsRead)) + // CVEs affecting this component (forward lookup, paginated) + r.GET("/{id}/vulnerabilities", h.ListVulnerabilities, middleware.Require(permission.ComponentsRead)) r.GET("/{id}", h.Get, middleware.Require(permission.ComponentsRead)) // Write operations diff --git a/internal/infra/http/routes/exposure.go b/internal/infra/http/routes/exposure.go index c9746516..6ce698b4 100644 --- a/internal/infra/http/routes/exposure.go +++ b/internal/infra/http/routes/exposure.go @@ -159,20 +159,37 @@ func registerVulnerabilityRoutes( // Build base middleware chain baseMiddlewares := buildBaseMiddlewares(authMiddleware, userSyncMiddleware) - // Vulnerability routes - global CVE database (no tenant required) + // Vulnerability routes - global CVE database (no tenant required for catalog ops). + // EXCEPTION: /{id}/affected-assets and /cve/{cveId}/affected-assets are + // blast-radius reverse lookups that JOIN findings × assets — those need + // tenant context. We apply middleware.RequireTenant() per-route below + // (chi doesn't allow two Group() blocks on the same mount path). router.Group("/api/v1/vulnerabilities", func(r Router) { // Read operations r.GET("/", h.ListVulnerabilities, middleware.Require(permission.VulnerabilitiesRead)) r.GET("/{id}", h.GetVulnerability, middleware.Require(permission.VulnerabilitiesRead)) r.GET("/cve/{cveId}", h.GetVulnerabilityByCVE, middleware.Require(permission.VulnerabilitiesRead)) + // Blast-radius reverse lookups + Active CVEs (tenant-scoped). Use + // tenantOverlayMiddlewares() to apply RequireTenant + active-membership + // + CSRF + rate-limit per-route, since chi forbids mounting a second + // Group on the same path. See routes.go. + tenantScopedMW := append(tenantOverlayMiddlewares(), + middleware.Require(permission.VulnerabilitiesRead)) + // IMPORTANT: register /active/stats BEFORE /active and /{id} so the + // most-specific literal path wins. + r.GET("/active/stats", h.GetActiveCVEStats, tenantScopedMW...) + r.GET("/active", h.ListActiveCVEs, tenantScopedMW...) + r.GET("/{id}/affected-assets", h.ListAffectedAssets, tenantScopedMW...) + r.GET("/cve/{cveId}/affected-assets", h.ListAffectedAssetsByCVE, tenantScopedMW...) + // Write operations (admin only) r.POST("/", h.CreateVulnerability, middleware.Require(permission.VulnerabilitiesWrite)) r.PUT("/{id}", h.UpdateVulnerability, middleware.Require(permission.VulnerabilitiesWrite)) r.DELETE("/{id}", h.DeleteVulnerability, middleware.Require(permission.VulnerabilitiesDelete)) }, baseMiddlewares...) - // Build tenant middleware chain from JWT token + // Build tenant middleware chain from JWT token (used by /findings group below) tenantMiddlewares := buildTokenTenantMiddlewares(authMiddleware, userSyncMiddleware) // Finding routes - tenant from JWT token diff --git a/internal/infra/http/routes/routes.go b/internal/infra/http/routes/routes.go index 94439059..29ee4e93 100644 --- a/internal/infra/http/routes/routes.go +++ b/internal/infra/http/routes/routes.go @@ -741,6 +741,33 @@ func buildTokenTenantMiddlewares(authMiddleware, userSyncMiddleware Middleware) return middlewares } +// tenantOverlayMiddlewares returns the EXTRA middlewares that +// buildTokenTenantMiddlewares adds on top of buildBaseMiddlewares +// (RequireTenant + active-membership + CSRF + rate-limit). +// +// Use case: a route group is mounted with baseMiddlewares (e.g. global +// vulnerabilities catalog) but a few endpoints inside the group are +// tenant-scoped (e.g. /vulnerabilities/{id}/affected-assets joins +// per-tenant findings). Apply this overlay per-route to upgrade those +// endpoints to the same security posture as a tokenTenant group, without +// having to mount a second chi Group on the same path (chi forbids that). +// +// Order matters: caller MUST spread these BEFORE permission middleware so +// RequireTenant runs first. +func tenantOverlayMiddlewares() []Middleware { + mws := []Middleware{middleware.RequireTenant()} + if activeMembershipFromJWTMiddleware != nil { + mws = append(mws, activeMembershipFromJWTMiddleware) + } + if csrfProtectionMiddleware != nil { + mws = append(mws, csrfProtectionMiddleware) + } + if readRateLimitMiddleware != nil { + mws = append(mws, readRateLimitMiddleware) + } + return mws +} + // ChainFunc wraps a handler function with middleware(s). // Returns the final handler after applying all middleware in order. func ChainFunc(handler http.HandlerFunc, middlewares ...Middleware) http.Handler { diff --git a/internal/infra/http/server.go b/internal/infra/http/server.go index 9951e485..f720c9a7 100644 --- a/internal/infra/http/server.go +++ b/internal/infra/http/server.go @@ -8,7 +8,9 @@ import ( "time" "github.com/openctemio/api/internal/config" + "github.com/openctemio/api/internal/infra/http/handler" "github.com/openctemio/api/internal/infra/http/middleware" + "github.com/openctemio/api/pkg/httpsec" "github.com/openctemio/api/pkg/logger" ) @@ -49,6 +51,16 @@ func NewServer(cfg *config.Config, log *logger.Logger, opts ...ServerOption) *Se s.router = NewChiRouter() } + // SECURITY (S-4): wire trusted-proxy CIDR allowlist into the IP-attribution + // helpers used by rate-limit middleware and auth audit logs. When the + // allowlist is empty the helpers honor only r.RemoteAddr — correct for + // direct-Internet deployments. With a CIDR list (e.g. K8s pod range, + // load-balancer subnet), X-Forwarded-For and X-Real-IP are honored only + // from peers in that range. + trustedProxies := httpsec.NewTrustedProxySet(cfg.Server.TrustedProxies) + middleware.SetTrustedProxies(trustedProxies) + handler.SetAuthTrustedProxies(trustedProxies) + // Create rate limiter with cleanup rateLimitMw, rateLimitStop := middleware.RateLimitWithStop(&cfg.RateLimit, log) s.cleanupFuncs = append(s.cleanupFuncs, rateLimitStop) diff --git a/internal/infra/postgres/asset_group_repository.go b/internal/infra/postgres/asset_group_repository.go index a0d0d8ec..55ed0da1 100644 --- a/internal/infra/postgres/asset_group_repository.go +++ b/internal/infra/postgres/asset_group_repository.go @@ -173,24 +173,27 @@ func (r *AssetGroupRepository) GetByTenantAndID(ctx context.Context, tenantID, i return g, nil } -// Update updates an asset group. -func (r *AssetGroupRepository) Update(ctx context.Context, g *assetgroup.AssetGroup) error { +// Update updates an asset group within the given tenant scope. +// Security: WHERE tenant_id = ? prevents IDOR — caller cannot mutate +// another tenant's group even with a known UUID. +func (r *AssetGroupRepository) Update(ctx context.Context, tenantID shared.ID, g *assetgroup.AssetGroup) error { query := ` UPDATE asset_groups SET - name = $2, - description = $3, - environment = $4, - criticality = $5, - business_unit = $6, - owner = $7, - owner_email = $8, - tags = $9, - updated_at = $10 - WHERE id = $1 + name = $3, + description = $4, + environment = $5, + criticality = $6, + business_unit = $7, + owner = $8, + owner_email = $9, + tags = $10, + updated_at = $11 + WHERE id = $1 AND tenant_id = $2 ` result, err := r.db.ExecContext(ctx, query, g.ID().String(), + tenantID.String(), g.Name(), nullString(g.Description()), g.Environment().String(), @@ -216,10 +219,11 @@ func (r *AssetGroupRepository) Update(ctx context.Context, g *assetgroup.AssetGr return nil } -// Delete deletes an asset group. -func (r *AssetGroupRepository) Delete(ctx context.Context, id shared.ID) error { - query := "DELETE FROM asset_groups WHERE id = $1" - result, err := r.db.ExecContext(ctx, query, id.String()) +// Delete deletes an asset group within the given tenant scope. +// Security: WHERE tenant_id = ? prevents IDOR — see Update for rationale. +func (r *AssetGroupRepository) Delete(ctx context.Context, tenantID, id shared.ID) error { + query := "DELETE FROM asset_groups WHERE id = $1 AND tenant_id = $2" + result, err := r.db.ExecContext(ctx, query, id.String(), tenantID.String()) if err != nil { return fmt.Errorf("delete asset group: %w", err) } diff --git a/internal/infra/postgres/component_repository.go b/internal/infra/postgres/component_repository.go index 208487c2..077e67a4 100644 --- a/internal/infra/postgres/component_repository.go +++ b/internal/infra/postgres/component_repository.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/lib/pq" "github.com/openctemio/api/pkg/domain/component" "github.com/openctemio/api/pkg/domain/shared" "github.com/openctemio/api/pkg/pagination" @@ -894,6 +895,222 @@ func (r *ComponentRepository) GetVulnerableComponents(ctx context.Context, tenan return pagination.NewResult(components, total, page), nil } +// ListAssetUsage returns the assets in the tenant that use a given global component. +// Powers the "Used By Assets" blast-radius panel on the component detail sheet. +// +// IMPORTANT: tenant_id filter is on asset_components (the per-tenant link table) — +// the components table is global and intentionally not tenant-scoped. +// +// When atRiskOnly is true, EXISTS-filters to only asset_components that have at +// least one open finding for the same (tenant, component, asset) triple. +func (r *ComponentRepository) ListAssetUsage( + ctx context.Context, + tenantID shared.ID, + componentID shared.ID, + atRiskOnly bool, + page pagination.Pagination, +) (pagination.Result[component.ComponentAssetUsage], error) { + empty := pagination.NewResult([]component.ComponentAssetUsage{}, 0, page) + + atRiskFilter := "" + if atRiskOnly { + atRiskFilter = ` AND EXISTS ( + SELECT 1 FROM findings f + WHERE f.tenant_id = ac.tenant_id + AND f.component_id = ac.component_id + AND f.asset_id = ac.asset_id + AND f.status IN ('new','confirmed','in_progress') + )` + } + + // Count DISTINCT assets — an asset can appear with the same component + // in multiple manifests (pkg.json + pkg-lock.json + workspace files). + // The list query intentionally returns one row per (asset, manifest) for + // SBOM detail, but the count metric is per asset. + countQuery := ` + SELECT COUNT(DISTINCT ac.asset_id) + FROM asset_components ac + JOIN assets a ON a.id = ac.asset_id + WHERE ac.tenant_id = $1 AND ac.component_id = $2` + atRiskFilter + + listQuery := ` + SELECT + a.id, a.name, a.asset_type, a.criticality, a.status, a.exposure, + a.risk_score, COALESCE(a.is_internet_accessible, false), + ac.id, ac.dependency_type, ac.is_direct, COALESCE(ac.depth, 0), + COALESCE(ac.manifest_file, ''), COALESCE(ac.path, ''), + COALESCE(ac.license, ''), COALESCE(ac.vulnerability_count, 0), + COALESCE(ac.highest_severity, ''), + ac.created_at + FROM asset_components ac + JOIN assets a ON a.id = ac.asset_id + WHERE ac.tenant_id = $1 AND ac.component_id = $2` + atRiskFilter + ` + ORDER BY + CASE a.criticality + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + ELSE 5 + END, + a.risk_score DESC, + a.name ASC + LIMIT $3 OFFSET $4 + ` + + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, tenantID.String(), componentID.String()).Scan(&total); err != nil { + return empty, fmt.Errorf("failed to count component asset usage: %w", err) + } + if total == 0 { + return empty, nil + } + + rows, err := r.db.QueryContext(ctx, listQuery, + tenantID.String(), componentID.String(), page.Limit(), page.Offset()) + if err != nil { + return empty, fmt.Errorf("failed to list component asset usage: %w", err) + } + defer rows.Close() + + usages := make([]component.ComponentAssetUsage, 0, page.Limit()) + for rows.Next() { + var u component.ComponentAssetUsage + if err := rows.Scan( + &u.AssetID, &u.AssetName, &u.AssetType, &u.Criticality, &u.AssetStatus, &u.Exposure, + &u.RiskScore, &u.IsInternetExposed, + &u.DependencyID, &u.DependencyType, &u.IsDirect, &u.Depth, + &u.ManifestFile, &u.ManifestPath, + &u.License, &u.VulnerabilityCount, &u.HighestSeverity, + &u.LinkedAt, + ); err != nil { + return empty, fmt.Errorf("failed to scan component asset usage row: %w", err) + } + usages = append(usages, u) + } + if err := rows.Err(); err != nil { + return empty, fmt.Errorf("rows iteration error: %w", err) + } + + return pagination.NewResult(usages, total, page), nil +} + +// ListVulnerabilities returns the CVEs that affect a global component within +// the given tenant. Aggregates findings GROUP BY vulnerability_id so a CVE +// appearing on multiple assets returns one row with affected_assets_count. +func (r *ComponentRepository) ListVulnerabilities( + ctx context.Context, + tenantID, componentID shared.ID, + includeResolved bool, + page pagination.Pagination, +) (pagination.Result[component.ComponentVulnerability], error) { + empty := pagination.NewResult([]component.ComponentVulnerability{}, 0, page) + + statusFilter := "" + if !includeResolved { + statusFilter = ` AND f.status IN ('new','confirmed','in_progress')` + } + + countQuery := ` + SELECT COUNT(DISTINCT f.vulnerability_id) + FROM findings f + WHERE f.tenant_id = $1 AND f.component_id = $2 AND f.vulnerability_id IS NOT NULL` + statusFilter + + listQuery := ` + WITH agg AS ( + SELECT + f.vulnerability_id, + COUNT(DISTINCT f.asset_id) AS affected_assets_count, + COUNT(*) AS total_finding_count, + COUNT(*) FILTER (WHERE f.status IN ('new','confirmed','in_progress')) AS open_finding_count, + MIN(CASE f.status + WHEN 'new' THEN 1 + WHEN 'confirmed' THEN 2 + WHEN 'in_progress' THEN 3 + WHEN 'accepted' THEN 4 + WHEN 'false_positive' THEN 5 + WHEN 'resolved' THEN 6 + ELSE 7 END) AS worst_status_rank, + MIN(f.first_detected_at) AS first_detected_at, + MAX(f.last_seen_at) AS last_seen_at + FROM findings f + WHERE f.tenant_id = $1 AND f.component_id = $2 AND f.vulnerability_id IS NOT NULL` + statusFilter + ` + GROUP BY f.vulnerability_id + ) + SELECT + v.id, v.cve_id, v.title, v.severity, v.cvss_score, v.epss_score, + (v.cisa_kev_date_added IS NOT NULL) AS in_cisa_kev, + COALESCE(v.exploit_maturity, 'none') AS exploit_maturity, + COALESCE(v.exploit_available, false) AS exploit_available, + COALESCE(v.fixed_versions, '{}'::text[]) AS fixed_versions, + agg.affected_assets_count, + agg.open_finding_count, + agg.total_finding_count, + (ARRAY['new','confirmed','in_progress','accepted','false_positive','resolved','unknown']::text[])[LEAST(agg.worst_status_rank, 7)] AS worst_finding_status, + agg.first_detected_at, agg.last_seen_at + FROM agg + JOIN vulnerabilities v ON v.id = agg.vulnerability_id + ORDER BY + CASE v.severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + WHEN 'info' THEN 5 + ELSE 6 + END, + (v.cisa_kev_date_added IS NOT NULL) DESC, + COALESCE(v.cvss_score, 0) DESC, + agg.affected_assets_count DESC + LIMIT $3 OFFSET $4 + ` + + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, tenantID.String(), componentID.String()).Scan(&total); err != nil { + return empty, fmt.Errorf("failed to count component vulnerabilities: %w", err) + } + if total == 0 { + return empty, nil + } + + rows, err := r.db.QueryContext(ctx, listQuery, + tenantID.String(), componentID.String(), page.Limit(), page.Offset()) + if err != nil { + return empty, fmt.Errorf("failed to list component vulnerabilities: %w", err) + } + defer rows.Close() + + out := make([]component.ComponentVulnerability, 0, page.Limit()) + for rows.Next() { + var v component.ComponentVulnerability + var cvss, epss sql.NullFloat64 + var fixed pq.StringArray + if err := rows.Scan( + &v.VulnerabilityID, &v.CVEID, &v.Title, &v.Severity, &cvss, &epss, + &v.InCISAKEV, &v.ExploitMaturity, &v.ExploitAvailable, &fixed, + &v.AffectedAssetsCount, &v.OpenFindingCount, &v.TotalFindingCount, + &v.WorstFindingStatus, &v.FirstDetectedAt, &v.LastSeenAt, + ); err != nil { + return empty, fmt.Errorf("failed to scan component vulnerability row: %w", err) + } + if cvss.Valid { + s := cvss.Float64 + v.CVSSScore = &s + } + if epss.Valid { + e := epss.Float64 + v.EPSSScore = &e + } + v.FixedVersions = []string(fixed) + out = append(out, v) + } + if err := rows.Err(); err != nil { + return empty, fmt.Errorf("rows iteration error: %w", err) + } + + return pagination.NewResult(out, total, page), nil +} + // GetLicenseStats returns license statistics for a tenant. func (r *ComponentRepository) GetLicenseStats(ctx context.Context, tenantID shared.ID) ([]component.LicenseStats, error) { // Query to get license distribution for tenant's components diff --git a/internal/infra/postgres/finding_repository.go b/internal/infra/postgres/finding_repository.go index 6598bd90..9d4f0d3b 100644 --- a/internal/infra/postgres/finding_repository.go +++ b/internal/infra/postgres/finding_repository.go @@ -799,35 +799,35 @@ func (r *FindingRepository) Update(ctx context.Context, finding *vulnerability.F ` result, err := r.db.ExecContext(ctx, query, - finding.ID().String(), // $1 - nullID(finding.VulnerabilityID()), // $2 - nullID(finding.ComponentID()), // $3 - nullID(finding.ToolID()), // $4 - nullString(finding.ToolVersion()), // $5 - nullString(finding.Snippet()), // $6 - finding.Message(), // $7 - finding.Severity().String(), // $8 - finding.Status().String(), // $9 - nullString(finding.Resolution()), // $10 + finding.ID().String(), // $1 + nullID(finding.VulnerabilityID()), // $2 + nullID(finding.ComponentID()), // $3 + nullID(finding.ToolID()), // $4 + nullString(finding.ToolVersion()), // $5 + nullString(finding.Snippet()), // $6 + finding.Message(), // $7 + finding.Severity().String(), // $8 + finding.Status().String(), // $9 + nullString(finding.Resolution()), // $10 nullString(finding.ResolutionMethod()), // $11 - nullTime(finding.ResolvedAt()), // $12 - nullID(finding.ResolvedBy()), // $13 - nullString(finding.ScanID()), // $14 - metadata, // $15 - finding.UpdatedAt(), // $16 - nullID(finding.AssignedTo()), // $17 - nullTime(finding.AssignedAt()), // $18 - nullID(finding.AssignedBy()), // $19 - finding.TenantID().String(), // $20 (WHERE) - nullString(finding.Title()), // $21 - nullString(finding.Description()), // $22 - pq.Array(finding.Tags()), // $23 + nullTime(finding.ResolvedAt()), // $12 + nullID(finding.ResolvedBy()), // $13 + nullString(finding.ScanID()), // $14 + metadata, // $15 + finding.UpdatedAt(), // $16 + nullID(finding.AssignedTo()), // $17 + nullTime(finding.AssignedAt()), // $18 + nullID(finding.AssignedBy()), // $19 + finding.TenantID().String(), // $20 (WHERE) + nullString(finding.Title()), // $21 + nullString(finding.Description()), // $22 + pq.Array(finding.Tags()), // $23 nullFloat64(finding.CVSSScore()), // $24 - nullString(finding.CVSSVector()), // $25 - nullString(finding.CVEID()), // $26 - pq.Array(finding.CWEIDs()), // $27 - pq.Array(finding.OWASPIDs()), // $28 - remediationJSON, // $29 + nullString(finding.CVSSVector()), // $25 + nullString(finding.CVEID()), // $26 + pq.Array(finding.CWEIDs()), // $27 + pq.Array(finding.OWASPIDs()), // $28 + remediationJSON, // $29 // Priority classification (RFC-004) nullFloat64(finding.EPSSScore()), // $30 nullFloat64(finding.EPSSPercentile()), // $31 @@ -977,6 +977,339 @@ func (r *FindingRepository) ListByComponentID(ctx context.Context, tenantID, com return r.List(ctx, filter, opts, page) } +// ListActiveCVEsByTenant returns the distinct CVEs currently impacting assets in +// the given tenant. Aggregates findings GROUP BY vulnerability_id and joins the +// global vulnerabilities table for CVE metadata. Sort: severity → KEV → EPSS → +// affected_assets desc. +func (r *FindingRepository) ListActiveCVEsByTenant( + ctx context.Context, + tenantID shared.ID, + filter vulnerability.ActiveCVEFilter, + page pagination.Pagination, +) (pagination.Result[vulnerability.ActiveCVE], error) { + empty := pagination.NewResult([]vulnerability.ActiveCVE{}, 0, page) + + // Build dynamic WHERE for outer (vulnerabilities-level) filters + var whereClauses []string + args := []any{tenantID.String()} + argN := 2 + + statusFilter := "" + if !filter.IncludeResolved { + statusFilter = ` AND f.status IN ('new','confirmed','in_progress')` + } + + if len(filter.SeverityIn) > 0 { + placeholders := make([]string, 0, len(filter.SeverityIn)) + for _, s := range filter.SeverityIn { + placeholders = append(placeholders, fmt.Sprintf("$%d", argN)) + args = append(args, s) + argN++ + } + whereClauses = append(whereClauses, fmt.Sprintf("v.severity IN (%s)", strings.Join(placeholders, ","))) + } + if filter.KEVOnly { + whereClauses = append(whereClauses, "v.cisa_kev_date_added IS NOT NULL") + } + if filter.MinCVSS != nil { + whereClauses = append(whereClauses, fmt.Sprintf("COALESCE(v.cvss_score, 0) >= $%d", argN)) + args = append(args, *filter.MinCVSS) + argN++ + } + if filter.MinEPSS != nil { + whereClauses = append(whereClauses, fmt.Sprintf("COALESCE(v.epss_score, 0) >= $%d", argN)) + args = append(args, *filter.MinEPSS) + argN++ + } + if filter.ExploitAvailable != nil { + whereClauses = append(whereClauses, fmt.Sprintf("COALESCE(v.exploit_available, false) = $%d", argN)) + args = append(args, *filter.ExploitAvailable) + argN++ + } + + outerWhere := "" + if len(whereClauses) > 0 { + outerWhere = " WHERE " + strings.Join(whereClauses, " AND ") + } + + countQuery := ` + WITH agg AS ( + SELECT f.vulnerability_id + FROM findings f + WHERE f.tenant_id = $1 AND f.vulnerability_id IS NOT NULL` + statusFilter + ` + GROUP BY f.vulnerability_id + ) + SELECT COUNT(*) FROM agg + JOIN vulnerabilities v ON v.id = agg.vulnerability_id` + outerWhere + + limitArg := argN + offsetArg := argN + 1 + args = append(args, page.Limit(), page.Offset()) + + listQuery := ` + WITH agg AS ( + SELECT + f.vulnerability_id, + COUNT(DISTINCT f.asset_id) AS affected_assets_count, + COUNT(DISTINCT f.component_id) FILTER (WHERE f.component_id IS NOT NULL) AS affected_components_count, + COUNT(*) AS total_finding_count, + COUNT(*) FILTER (WHERE f.status IN ('new','confirmed','in_progress')) AS open_finding_count, + MIN(CASE f.status + WHEN 'new' THEN 1 + WHEN 'confirmed' THEN 2 + WHEN 'in_progress' THEN 3 + WHEN 'accepted' THEN 4 + WHEN 'false_positive' THEN 5 + WHEN 'resolved' THEN 6 + ELSE 7 END) AS worst_status_rank, + MIN(f.first_detected_at) AS first_detected_at, + MAX(f.last_seen_at) AS last_seen_at + FROM findings f + WHERE f.tenant_id = $1 AND f.vulnerability_id IS NOT NULL` + statusFilter + ` + GROUP BY f.vulnerability_id + ) + SELECT + v.id, v.cve_id, v.title, v.severity, + v.cvss_score, v.epss_score, + (v.cisa_kev_date_added IS NOT NULL) AS in_cisa_kev, + COALESCE(v.exploit_maturity, 'none') AS exploit_maturity, + COALESCE(v.exploit_available, false) AS exploit_available, + COALESCE(v.fixed_versions, '{}'::text[]) AS fixed_versions, + v.published_at, + agg.affected_assets_count, + agg.affected_components_count, + agg.total_finding_count, + agg.open_finding_count, + (ARRAY['new','confirmed','in_progress','accepted','false_positive','resolved','unknown']::text[])[LEAST(agg.worst_status_rank, 7)] AS worst_finding_status, + agg.first_detected_at, agg.last_seen_at + FROM agg + JOIN vulnerabilities v ON v.id = agg.vulnerability_id` + outerWhere + ` + ORDER BY + CASE v.severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + WHEN 'info' THEN 5 + ELSE 6 + END, + (v.cisa_kev_date_added IS NOT NULL) DESC, + COALESCE(v.epss_score, 0) DESC, + agg.affected_assets_count DESC + LIMIT $` + fmt.Sprintf("%d", limitArg) + ` OFFSET $` + fmt.Sprintf("%d", offsetArg) + + countArgs := args[:len(args)-2] + + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil { + return empty, fmt.Errorf("failed to count active CVEs: %w", err) + } + if total == 0 { + return empty, nil + } + + rows, err := r.db.QueryContext(ctx, listQuery, args...) + if err != nil { + return empty, fmt.Errorf("failed to list active CVEs: %w", err) + } + defer rows.Close() + + out := make([]vulnerability.ActiveCVE, 0, page.Limit()) + for rows.Next() { + var v vulnerability.ActiveCVE + var cvss, epss sql.NullFloat64 + var fixed pq.StringArray + var publishedAt sql.NullTime + if err := rows.Scan( + &v.VulnerabilityID, &v.CVEID, &v.Title, &v.Severity, + &cvss, &epss, + &v.InCISAKEV, &v.ExploitMaturity, &v.ExploitAvailable, &fixed, + &publishedAt, + &v.AffectedAssetsCount, &v.AffectedComponentsCount, + &v.TotalFindingCount, &v.OpenFindingCount, + &v.WorstFindingStatus, + &v.FirstDetectedAt, &v.LastSeenAt, + ); err != nil { + return empty, fmt.Errorf("failed to scan active CVE row: %w", err) + } + if cvss.Valid { + s := cvss.Float64 + v.CVSSScore = &s + } + if epss.Valid { + e := epss.Float64 + v.EPSSScore = &e + } + if publishedAt.Valid { + t := publishedAt.Time + v.PublishedAt = &t + } + v.FixedVersions = []string(fixed) + out = append(out, v) + } + if err := rows.Err(); err != nil { + return empty, fmt.Errorf("rows iteration error: %w", err) + } + + return pagination.NewResult(out, total, page), nil +} + +// GetActiveCVEStats returns aggregate counts for the tenant's active CVEs. +// Uses FILTER aggregates for a single round-trip (8 counts in 1 query). +func (r *FindingRepository) GetActiveCVEStats( + ctx context.Context, + tenantID shared.ID, + includeResolved bool, +) (*vulnerability.ActiveCVEStats, error) { + statusFilter := "" + if !includeResolved { + statusFilter = ` AND f.status IN ('new','confirmed','in_progress')` + } + + query := ` + WITH agg AS ( + SELECT DISTINCT f.vulnerability_id + FROM findings f + WHERE f.tenant_id = $1 AND f.vulnerability_id IS NOT NULL` + statusFilter + ` + ) + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE v.severity = 'critical') AS crit, + COUNT(*) FILTER (WHERE v.severity = 'high') AS high, + COUNT(*) FILTER (WHERE v.severity = 'medium') AS med, + COUNT(*) FILTER (WHERE v.severity = 'low') AS low, + COUNT(*) FILTER (WHERE v.severity = 'info') AS info, + COUNT(*) FILTER (WHERE v.cisa_kev_date_added IS NOT NULL) AS kev, + COUNT(*) FILTER (WHERE COALESCE(v.exploit_available, false) = true) AS exploit + FROM agg + JOIN vulnerabilities v ON v.id = agg.vulnerability_id + ` + + var stats vulnerability.ActiveCVEStats + var crit, high, med, low, info int + if err := r.db.QueryRowContext(ctx, query, tenantID.String()).Scan( + &stats.Total, &crit, &high, &med, &low, &info, + &stats.KEVCount, &stats.ExploitAvailableCount, + ); err != nil { + return nil, fmt.Errorf("failed to get active CVE stats: %w", err) + } + stats.BySeverity = map[string]int{ + "critical": crit, "high": high, "medium": med, "low": low, "info": info, + } + return &stats, nil +} + +// ListAffectedAssetsByVulnerabilityID returns the distinct assets affected by a CVE +// (blast-radius reverse lookup). Aggregates findings GROUP BY asset_id and joins +// against the assets table for context. Sorted by criticality, then by worst SLA +// status, then by risk_score. +func (r *FindingRepository) ListAffectedAssetsByVulnerabilityID( + ctx context.Context, + tenantID, vulnID shared.ID, + includeResolved bool, + page pagination.Pagination, +) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + empty := pagination.NewResult([]vulnerability.VulnerabilityAffectedAsset{}, 0, page) + + // When includeResolved=false, restrict to open statuses (new/confirmed/in_progress). + statusFilter := "" + if !includeResolved { + statusFilter = ` AND f.status IN ('new','confirmed','in_progress')` + } + + countQuery := ` + SELECT COUNT(DISTINCT f.asset_id) + FROM findings f + WHERE f.tenant_id = $1 AND f.vulnerability_id = $2` + statusFilter + + listQuery := ` + WITH agg AS ( + SELECT + f.asset_id, + COUNT(*) AS finding_count, + COUNT(*) FILTER (WHERE f.status IN ('new','confirmed','in_progress')) AS open_count, + MIN(CASE f.severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + WHEN 'info' THEN 5 + ELSE 6 END) AS sev_rank, + MIN(CASE f.sla_status + WHEN 'exceeded' THEN 1 + WHEN 'overdue' THEN 2 + WHEN 'warning' THEN 3 + WHEN 'on_track' THEN 4 + ELSE 5 END) AS sla_rank, + MIN(f.first_detected_at) AS first_detected_at, + MAX(f.last_seen_at) AS last_seen_at, + (ARRAY_AGG(f.id ORDER BY f.last_seen_at DESC))[1] AS sample_finding_id, + (ARRAY_AGG(f.status ORDER BY f.last_seen_at DESC))[1] AS sample_finding_status + FROM findings f + WHERE f.tenant_id = $1 AND f.vulnerability_id = $2` + statusFilter + ` + GROUP BY f.asset_id + ) + SELECT + a.id, a.name, a.asset_type, a.criticality, a.status, a.exposure, + a.risk_score, COALESCE(a.is_internet_accessible, false), + agg.finding_count, agg.open_count, + (ARRAY['critical','high','medium','low','info','none']::text[])[LEAST(agg.sev_rank, 6)] AS highest_severity, + (ARRAY['exceeded','overdue','warning','on_track','not_applicable']::text[])[LEAST(agg.sla_rank, 5)] AS worst_sla_status, + agg.first_detected_at, agg.last_seen_at, + agg.sample_finding_id, agg.sample_finding_status + FROM agg + JOIN assets a ON a.id = agg.asset_id + ORDER BY + CASE a.criticality + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + ELSE 5 + END, + agg.sla_rank ASC, + a.risk_score DESC, + a.name ASC + LIMIT $3 OFFSET $4 + ` + + var total int64 + if err := r.db.QueryRowContext(ctx, countQuery, tenantID.String(), vulnID.String()).Scan(&total); err != nil { + return empty, fmt.Errorf("failed to count affected assets: %w", err) + } + if total == 0 { + return empty, nil + } + + rows, err := r.db.QueryContext(ctx, listQuery, + tenantID.String(), vulnID.String(), page.Limit(), page.Offset()) + if err != nil { + return empty, fmt.Errorf("failed to list affected assets: %w", err) + } + defer rows.Close() + + out := make([]vulnerability.VulnerabilityAffectedAsset, 0, page.Limit()) + for rows.Next() { + var a vulnerability.VulnerabilityAffectedAsset + if err := rows.Scan( + &a.AssetID, &a.AssetName, &a.AssetType, &a.Criticality, &a.AssetStatus, &a.Exposure, + &a.RiskScore, &a.IsInternetExposed, + &a.FindingCount, &a.OpenFindingCount, + &a.HighestSeverity, &a.WorstSLAStatus, + &a.FirstDetectedAt, &a.LastSeenAt, + &a.SampleFindingID, &a.SampleFindingStatus, + ); err != nil { + return empty, fmt.Errorf("failed to scan affected asset row: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return empty, fmt.Errorf("rows iteration error: %w", err) + } + + return pagination.NewResult(out, total, page), nil +} + // Count returns the count of findings matching the filter. func (r *FindingRepository) Count(ctx context.Context, filter vulnerability.FindingFilter) (int64, error) { query := `SELECT COUNT(*) FROM findings` @@ -1820,11 +2153,11 @@ func (r *FindingRepository) reconstruct(row findingRow) (*vulnerability.Finding, // metadata JSONB column. Copy the full map so the handler's // toUnifiedPentestFindingResponse() can read steps_to_reproduce, // poc_code, business_impact, etc. from SourceMetadata(). - SourceMetadata: meta, - PentestCampaignID: parseNullID(row.pentestCampaignID), - CreatedAt: row.createdAt, - UpdatedAt: row.updatedAt, - CreatedBy: parseNullID(row.createdBy), + SourceMetadata: meta, + PentestCampaignID: parseNullID(row.pentestCampaignID), + CreatedAt: row.createdAt, + UpdatedAt: row.updatedAt, + CreatedBy: parseNullID(row.createdBy), // SARIF 2.1.0 fields Confidence: confidence, Impact: nullStringValue(row.impact), diff --git a/migrations/000167_blast_radius_indexes.down.sql b/migrations/000167_blast_radius_indexes.down.sql new file mode 100644 index 00000000..331ca33c --- /dev/null +++ b/migrations/000167_blast_radius_indexes.down.sql @@ -0,0 +1,4 @@ +-- Drop blast-radius indexes added in 000167. + +DROP INDEX IF EXISTS idx_findings_tenant_vulnerability; +DROP INDEX IF EXISTS idx_asset_components_tenant_component; diff --git a/migrations/000167_blast_radius_indexes.up.sql b/migrations/000167_blast_radius_indexes.up.sql new file mode 100644 index 00000000..62aad2b3 --- /dev/null +++ b/migrations/000167_blast_radius_indexes.up.sql @@ -0,0 +1,36 @@ +-- Migration 167: Composite indexes supporting blast-radius reverse lookups. +-- +-- Powers three new endpoints: +-- GET /api/v1/components/{id}/assets (component → assets reverse) +-- GET /api/v1/components/{id}/vulnerabilities (component → CVEs) +-- GET /api/v1/vulnerabilities/{id}/affected-assets (CVE → assets) +-- +-- The component-CVE direction is already covered by migration 000166. +-- This migration adds the two remaining hot paths. +-- +-- NOTE: cannot use CREATE INDEX CONCURRENTLY here — golang-migrate wraps each +-- file in a transaction. See 000165 history for prior incident. + +-- (1) asset_components(tenant_id, component_id) +-- Powers ListAssetUsage(): "which assets in tenant X use component Y?". +-- Existing idx_asset_components_tenant covers tenant_id alone but Postgres +-- would still need to scan all of the tenant's components — not great for +-- large tenants (>100k SBOM rows). +-- Partial WHERE component_id IS NOT NULL keeps the index tight (some +-- historical asset_components rows have NULL component_id from earlier +-- ingestion paths). +CREATE INDEX IF NOT EXISTS idx_asset_components_tenant_component + ON asset_components (tenant_id, component_id) + WHERE component_id IS NOT NULL; + +-- (2) findings(tenant_id, vulnerability_id) +-- Powers ListAffectedAssetsByVulnerabilityID(): "which assets in tenant X +-- are affected by CVE Y?". +-- Existing idx_findings_vulnerability_id is single-column and would force +-- PG to filter by tenant_id after a vuln-wide scan — bad when a popular +-- CVE (e.g. Log4Shell) appears across many tenants. +-- Partial WHERE vulnerability_id IS NOT NULL — secret/misconfig/web3 +-- finding types intentionally have NULL vulnerability_id. +CREATE INDEX IF NOT EXISTS idx_findings_tenant_vulnerability + ON findings (tenant_id, vulnerability_id) + WHERE vulnerability_id IS NOT NULL; diff --git a/migrations/seed/seed_components_demo.sql b/migrations/seed/seed_components_demo.sql new file mode 100644 index 00000000..aa93a385 --- /dev/null +++ b/migrations/seed/seed_components_demo.sql @@ -0,0 +1,878 @@ +-- ============================================================================= +-- Demo Seed: Vulnerable Components, Package Ecosystems, License Compliance, CVE +-- OpenCTEM OSS Edition +-- ============================================================================= +-- Fills the four UI pages under /components and the CVE catalog with +-- realistic demo data: +-- - 50 CVEs (global vulnerabilities) +-- - 6 assets (web/api/service/mobile/iac/k8s) +-- - 200 global components (PURL-deduplicated registry) +-- - 200 asset_components links (per-asset SBOM rows) +-- - 80 findings tying assets × components × CVEs +-- +-- Architecture note (very important): +-- Schema (migration 000044) splits "components" into two tables: +-- 1. components — global PURL-based registry (one row per unique pkg+version) +-- 2. asset_components — per-asset link (many rows possible per global component) +-- findings.component_id FK → components(id). +-- This seed populates BOTH and links them correctly so blast-radius +-- queries (component → assets, CVE → assets) work end-to-end. +-- +-- Idempotent: ON CONFLICT (id) DO NOTHING / unique keys. +-- Tenant-scoped data attaches to the first tenant whose name/slug matches +-- "ORG" (case-insensitive). Fails loudly if no such tenant exists. +-- +-- Usage: +-- go run ./cmd/seed -file migrations/seed/seed_components_demo.sql -db "$DATABASE_URL" +-- +-- Cleanup (manual): +-- DELETE FROM findings WHERE id::text LIKE 'dcdc3%'; +-- DELETE FROM asset_components WHERE id::text LIKE 'dcdc2%'; +-- DELETE FROM component_licenses WHERE component_id::text LIKE 'dcdcc%'; +-- DELETE FROM components WHERE id::text LIKE 'dcdcc%'; +-- DELETE FROM assets WHERE id::text LIKE 'dcdc1%'; +-- DELETE FROM vulnerabilities WHERE id::text LIKE 'dcdca%'; +-- ============================================================================= + +DO $$ +DECLARE + v_tenant_id UUID; + v_owner_id UUID; +BEGIN + -- --------------------------------------------------------------------------- + -- Step 1: Resolve target tenant (ORG tenant) + -- --------------------------------------------------------------------------- + SELECT id INTO v_tenant_id FROM tenants + WHERE name ILIKE '%org%' OR slug ILIKE '%org%' + ORDER BY created_at LIMIT 1; + + IF v_tenant_id IS NULL THEN + RAISE EXCEPTION 'No tenant with "org" in name/slug found. Create one before seeding.'; + END IF; + + RAISE NOTICE 'Seeding demo data into tenant_id: %', v_tenant_id; + + SELECT user_id INTO v_owner_id + FROM tenant_members + WHERE tenant_id = v_tenant_id + ORDER BY joined_at NULLS LAST LIMIT 1; +END $$; + +-- ============================================================================= +-- Step 2: Vulnerabilities (GLOBAL — not tenant-scoped) — 50 CVEs +-- ============================================================================= + +INSERT INTO vulnerabilities + (id, cve_id, title, description, severity, cvss_score, cvss_vector, + epss_score, epss_percentile, cisa_kev_date_added, cisa_kev_due_date, + exploit_available, exploit_maturity, fixed_versions, published_at, status) +VALUES +('dcdcaaaa-0000-0000-0000-000000000001', 'CVE-2021-44228', 'Apache Log4j2 Remote Code Execution (Log4Shell)', + 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.', + 'critical', 10.0, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.97565, 0.99971, '2021-12-10T00:00:00Z', '2021-12-24T00:00:00Z', + true, 'weaponized', ARRAY['2.15.0','2.16.0','2.17.0'], '2021-12-10T10:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000002', 'CVE-2022-22965', 'Spring Framework RCE (Spring4Shell)', + 'Spring Framework allows RCE via data binding when running on JDK 9+.', + 'critical', 9.8, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.97443, 0.99947, '2022-04-04T00:00:00Z', '2022-04-25T00:00:00Z', + true, 'weaponized', ARRAY['5.2.20.RELEASE','5.3.18'], '2022-04-01T23:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000003', 'CVE-2023-34362', 'MOVEit Transfer SQL Injection', + 'MOVEit Transfer SQL injection vulnerability exploited in mass data exfiltration by Cl0p ransomware.', + 'critical', 9.8, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.96234, 0.99821, '2023-06-02T00:00:00Z', '2023-06-23T00:00:00Z', + true, 'weaponized', ARRAY['2021.0.6','2021.1.4','2022.0.4','2022.1.5','2023.0.1'], '2023-06-02T15:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000004', 'CVE-2024-3094', 'XZ Utils Backdoor (liblzma)', + 'Malicious backdoor inserted into upstream XZ Utils 5.6.0/5.6.1 enabling SSH RCE on systemd-linked sshd.', + 'critical', 10.0, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.78234, 0.98432, '2024-03-29T00:00:00Z', '2024-04-19T00:00:00Z', + true, 'weaponized', ARRAY['5.6.2'], '2024-03-29T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000005', 'CVE-2023-22515', 'Atlassian Confluence Privilege Escalation', + 'Broken access control in Confluence Data Center and Server allows unauthenticated admin account creation.', + 'critical', 10.0, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.94567, 0.99645, '2023-10-04T00:00:00Z', '2023-10-13T00:00:00Z', + true, 'weaponized', ARRAY['8.3.3','8.4.3','8.5.2'], '2023-10-04T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000006', 'CVE-2024-21762', 'Fortinet FortiOS Out-of-bounds Write', + 'OOB write in FortiOS sslvpnd allows unauthenticated RCE.', + 'critical', 9.6, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.92345, 0.99421, '2024-02-09T00:00:00Z', '2024-02-16T00:00:00Z', + true, 'weaponized', ARRAY['7.4.3','7.2.7','7.0.14','6.4.15'], '2024-02-08T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000007', 'CVE-2024-3400', 'Palo Alto PAN-OS Command Injection', + 'Command injection in GlobalProtect feature of PAN-OS allows unauthenticated RCE.', + 'critical', 10.0, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.95678, 0.99812, '2024-04-12T00:00:00Z', '2024-04-19T00:00:00Z', + true, 'weaponized', ARRAY['10.2.9-h1','11.0.4-h1','11.1.2-h3'], '2024-04-12T08:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000008', 'CVE-2023-46604', 'Apache ActiveMQ RCE', + 'OpenWire protocol marshaller in ActiveMQ allows RCE via deserialization.', + 'critical', 10.0, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.96234, 0.99756, '2023-11-02T00:00:00Z', '2023-11-23T00:00:00Z', + true, 'weaponized', ARRAY['5.15.16','5.16.7','5.17.6','5.18.3'], '2023-10-27T18:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000009', 'CVE-2024-6387', 'OpenSSH regreSSHion Remote Code Execution', + 'Race condition in sshd signal handler allows unauthenticated RCE on glibc-based Linux systems.', + 'critical', 8.1, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.45123, 0.92341, '2024-07-01T00:00:00Z', NULL, + true, 'functional', ARRAY['9.8p1'], '2024-07-01T11:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000a', 'CVE-2024-23897', 'Jenkins Arbitrary File Read', + 'CLI command parser in Jenkins reads files from controller filesystem via @ character.', + 'critical', 9.8, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.93421, 0.99523, '2024-01-29T00:00:00Z', '2024-02-19T00:00:00Z', + true, 'weaponized', ARRAY['2.442','2.426.3'], '2024-01-24T18:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000b', 'CVE-2024-21538', 'cross-spawn ReDoS', + 'Regular expression denial of service in cross-spawn package via crafted argument.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00234, 0.65432, NULL, NULL, + false, 'poc', ARRAY['7.0.5','6.0.6'], '2024-11-08T05:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000c', 'CVE-2024-4068', 'braces Resource Consumption', + 'Uncontrolled resource consumption in micromatch braces parser causes memory exhaustion.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00321, 0.71234, NULL, NULL, + false, 'poc', ARRAY['3.0.3'], '2024-05-14T15:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000d', 'CVE-2024-37890', 'ws DoS via Connection', + 'ws WebSocket library DoS when handling many crafted HTTP headers.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00543, 0.78421, NULL, NULL, + false, 'poc', ARRAY['8.17.1','7.5.10','6.2.3','5.2.4'], '2024-06-17T21:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000e', 'CVE-2024-24790', 'tar-fs Path Traversal', + 'Path traversal in tar-fs allows arbitrary file write outside extraction directory.', + 'high', 8.1, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.01234, 0.85432, NULL, NULL, + false, 'poc', ARRAY['2.1.2','3.0.7'], '2024-06-04T20:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000000f', 'CVE-2024-29415', 'ip SSRF Bypass', + 'ip package isPublic() function returns false for IPs that should be considered public.', + 'high', 8.1, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.00876, 0.81234, NULL, NULL, + false, 'poc', ARRAY['2.0.1'], '2024-05-27T05:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000010', 'CVE-2024-43788', 'webpack Cross-Site Scripting', + 'webpack dev server XSS via crafted URL in default error page.', + 'medium', 6.4, 'CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:L', + 0.00123, 0.45123, NULL, NULL, + false, 'none', ARRAY['5.94.0'], '2024-08-27T19:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000011', 'CVE-2024-39338', 'axios SSRF', + 'Server-side request forgery in axios when handling protocol-relative URLs.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N', + 0.00543, 0.74321, NULL, NULL, + false, 'poc', ARRAY['1.7.4'], '2024-08-12T16:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000012', 'CVE-2024-37168', 'grpc-js Unbounded Memory Allocation', + '@grpc/grpc-js can allocate excessive memory when receiving messages exceeding configured limits.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00432, 0.72145, NULL, NULL, + false, 'none', ARRAY['1.8.22','1.9.15','1.10.9'], '2024-06-10T18:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000013', 'CVE-2024-3651', 'idna Quadratic Complexity', + 'Crafted unicode strings cause quadratic time complexity in idna.encode().', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00321, 0.68543, NULL, NULL, + false, 'poc', ARRAY['3.7'], '2024-04-11T19:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000014', 'CVE-2024-35195', 'requests Session Verification Bypass', + 'requests Session.verify=False persists across requests after first call.', + 'medium', 5.6, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N', + 0.00432, 0.71234, NULL, NULL, + false, 'none', ARRAY['2.32.0'], '2024-05-20T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000015', 'CVE-2024-37891', 'urllib3 Proxy Authorization Leak', + 'urllib3 proxy-authorization header sent to destination after redirect.', + 'medium', 4.4, 'CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:N/A:N', + 0.00234, 0.61234, NULL, NULL, + false, 'none', ARRAY['1.26.19','2.2.2'], '2024-06-17T20:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000016', 'CVE-2024-22195', 'Jinja2 XSS via xmlattr', + 'Jinja2 xmlattr filter allowed keys with spaces, enabling injection of arbitrary HTML attributes.', + 'medium', 6.1, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N', + 0.00543, 0.75432, NULL, NULL, + false, 'poc', ARRAY['3.1.3'], '2024-01-11T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000017', 'CVE-2024-1135', 'gunicorn HTTP Request Smuggling', + 'gunicorn fails to properly validate Transfer-Encoding header values, enabling smuggling.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N', + 0.01234, 0.85432, NULL, NULL, + false, 'poc', ARRAY['22.0.0'], '2024-04-16T00:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000019', 'CVE-2024-49767', 'Werkzeug Resource Exhaustion', + 'Werkzeug multipart parser allocates unbounded memory when handling crafted requests.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00876, 0.81543, NULL, NULL, + false, 'poc', ARRAY['3.0.6'], '2024-10-25T20:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000001a', 'CVE-2024-24762', 'python-multipart ReDoS', + 'python-multipart parser exhibits ReDoS via crafted Content-Type header.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00432, 0.72341, NULL, NULL, + false, 'poc', ARRAY['0.0.7'], '2024-04-09T19:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000001b', 'CVE-2023-44487', 'HTTP/2 Rapid Reset DDoS', + 'HTTP/2 protocol allows rapid stream reset attack causing DDoS, affecting many implementations.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.94321, 0.99645, '2023-10-10T00:00:00Z', '2023-10-31T00:00:00Z', + true, 'weaponized', ARRAY['netty-4.1.100'], '2023-10-10T14:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000001c', 'CVE-2024-22243', 'Spring Framework Open Redirect', + 'UriComponentsBuilder failed to validate URLs, enabling open redirect / SSRF.', + 'high', 8.1, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H', + 0.00543, 0.74321, NULL, NULL, + false, 'poc', ARRAY['5.3.32','6.0.17','6.1.4'], '2024-02-23T05:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000001d', 'CVE-2024-29133', 'Apache POI Resource Consumption', + 'Apache POI HSLF parser allocates excessive memory on crafted PowerPoint files.', + 'medium', 5.5, 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H', + 0.00234, 0.61432, NULL, NULL, + false, 'none', ARRAY['5.2.4'], '2024-04-08T08:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000001e', 'CVE-2024-25710', 'Apache Commons Compress DoS', + 'Loop with unreachable exit condition in commons-compress DUMP file parser.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00432, 0.72341, NULL, NULL, + false, 'poc', ARRAY['1.26.0'], '2024-02-19T09:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000020', 'CVE-2024-21733', 'Tomcat Information Disclosure', + 'Apache Tomcat exposes part of previous response body to client when error in chunked encoding.', + 'medium', 5.3, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N', + 0.00543, 0.75432, NULL, NULL, + false, 'poc', ARRAY['8.5.94','9.0.81','10.1.16'], '2024-01-19T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000021', 'CVE-2024-24786', 'Go protobuf Infinite Loop', + 'google.golang.org/protobuf json unmarshaler enters infinite loop on crafted JSON.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00321, 0.68543, NULL, NULL, + false, 'poc', ARRAY['1.33.0'], '2024-03-05T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000023', 'CVE-2024-24557', 'Moby Build Cache Poisoning', + 'Moby (Docker) classic builder cache reuses image layer despite differing content.', + 'medium', 6.9, 'CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:N', + 0.00123, 0.45612, NULL, NULL, + false, 'none', ARRAY['25.0.2','24.0.9'], '2024-02-01T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000024', 'CVE-2024-45337', 'crypto/ssh Authorization Bypass', + 'golang.org/x/crypto/ssh ServerConfig.PublicKeyCallback may be incorrectly invoked.', + 'critical', 9.1, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N', + 0.01432, 0.87543, NULL, NULL, + false, 'poc', ARRAY['0.31.0'], '2024-12-11T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000026', 'CVE-2024-30105', '.NET System.Text.Json DoS', + 'Crafted JSON causes excessive CPU consumption in System.Text.Json deserialization.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00432, 0.72341, NULL, NULL, + false, 'none', ARRAY['8.0.4'], '2024-07-09T17:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000029', 'CVE-2024-32465', 'PHP Symfony HttpFoundation Path Traversal', + 'BinaryFileResponse in Symfony HttpFoundation allows path traversal via crafted filename.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N', + 0.00432, 0.72341, NULL, NULL, + false, 'poc', ARRAY['5.4.40','6.4.8','7.0.8'], '2024-05-31T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000002a', 'CVE-2024-26146', 'rack URI Parsing ReDoS', + 'Rack request URI parsing exhibits ReDoS via crafted Range header.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00321, 0.68543, NULL, NULL, + false, 'poc', ARRAY['2.2.8.1','3.0.9.1'], '2024-02-26T16:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000002c', 'CVE-2024-21626', 'runc Container Escape (Leaky Vessels)', + 'runc internal file descriptor leak allows container breakout to host filesystem.', + 'high', 8.6, 'CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H', + 0.78123, 0.98123, NULL, NULL, + true, 'functional', ARRAY['1.1.12'], '2024-01-31T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000002d', 'CVE-2024-23652', 'BuildKit Mount Cache Privilege Escalation', + 'BuildKit cache mount runs with elevated privileges, enabling host write.', + 'high', 8.7, 'CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H', + 0.45123, 0.92341, NULL, NULL, + false, 'poc', ARRAY['0.12.5'], '2024-01-31T22:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-00000000002f', 'CVE-2024-10220', 'Kubernetes gitRepo Volume RCE', + 'gitRepo volume plugin executes arbitrary commands via crafted git hooks.', + 'critical', 8.1, 'CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H', + 0.12345, 0.88543, NULL, NULL, + false, 'poc', ARRAY['1.32.0'], '2024-11-22T01:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000030', 'CVE-2023-50387', 'KeyTrap DNSSEC DoS', + 'KeyTrap vulnerability exhausts DNS resolver CPU via crafted DNSSEC responses.', + 'high', 7.5, 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.34567, 0.91234, NULL, NULL, + false, 'functional', ARRAY['9.16.48','9.18.24','9.19.21'], '2024-02-13T19:15:00Z', 'open'), +('dcdcaaaa-0000-0000-0000-000000000031', 'CVE-2024-2511', 'OpenSSL Unbounded Memory', + 'OpenSSL TLS 1.3 session caching causes unbounded memory growth.', + 'medium', 5.9, 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H', + 0.00432, 0.72341, NULL, NULL, + false, 'poc', ARRAY['3.0.13','3.1.5','3.2.1'], '2024-04-08T15:15:00Z', 'open') +ON CONFLICT (cve_id) DO NOTHING; + +-- ============================================================================= +-- Step 3: Tenant-scoped data (assets, components, asset_components, findings) +-- All in one DO block to share v_tenant_id and v_owner_id locals. +-- ============================================================================= + +DO $$ +DECLARE + v_tenant_id UUID; + v_owner_id UUID; +BEGIN + SELECT id INTO v_tenant_id FROM tenants + WHERE name ILIKE '%org%' OR slug ILIKE '%org%' + ORDER BY created_at LIMIT 1; + SELECT user_id INTO v_owner_id + FROM tenant_members + WHERE tenant_id = v_tenant_id + ORDER BY joined_at NULLS LAST LIMIT 1; + + -- --------------------------------------------------------------------------- + -- Step 3a: Assets (6 covering common types) + -- --------------------------------------------------------------------------- + INSERT INTO assets (id, tenant_id, name, asset_type, criticality, status, scope, + exposure, risk_score, description, owner_id, + is_internet_accessible, source_type, discovery_source) + VALUES + ('dcdc1111-0000-0000-0000-000000000001', v_tenant_id, 'demo-web-storefront', 'web_application', 'critical', 'active', + 'external', 'public', 87, 'Customer-facing e-commerce storefront (React + Node.js)', v_owner_id, + true, 'manual', 'manual'), + ('dcdc1111-0000-0000-0000-000000000002', v_tenant_id, 'demo-api-gateway', 'api', 'critical', 'active', + 'external', 'public', 79, 'Public API gateway routing customer requests to microservices', v_owner_id, + true, 'manual', 'manual'), + ('dcdc1111-0000-0000-0000-000000000003', v_tenant_id, 'demo-payment-service', 'service', 'critical', 'active', + 'internal', 'restricted', 72, 'Internal payment processing service (Java/Spring Boot)', v_owner_id, + false, 'manual', 'manual'), + ('dcdc1111-0000-0000-0000-000000000004', v_tenant_id, 'demo-mobile-app', 'mobile_app', 'high', 'active', + 'external', 'public', 58, 'iOS/Android mobile companion app', v_owner_id, + true, 'manual', 'manual'), + ('dcdc1111-0000-0000-0000-000000000005', v_tenant_id, 'demo-iac-infra', 'repository', 'high', 'active', + 'internal', 'private', 41, 'Terraform/Helm IaC monorepo for production infrastructure', v_owner_id, + false, 'manual', 'manual'), + ('dcdc1111-0000-0000-0000-000000000006', v_tenant_id, 'demo-k8s-prod', 'kubernetes_cluster', 'critical', 'active', + 'cloud', 'restricted', 65, 'Production Kubernetes cluster (AWS EKS, 3 AZs)', v_owner_id, + false, 'manual', 'manual') + ON CONFLICT (id) DO NOTHING; + + RAISE NOTICE 'Inserted assets: %', (SELECT COUNT(*) FROM assets WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc1111-%'); +END $$; + +-- ============================================================================= +-- Step 4: GLOBAL components (PURL-deduplicated registry) — ~55 entries +-- These are the canonical components. asset_components links assets to these. +-- UUIDs use prefix 'dcdcc' (c = component-global) for easy cleanup. +-- ============================================================================= + +INSERT INTO components (id, purl, name, version, ecosystem, vulnerability_count) +VALUES + -- npm + ('dcdcc001-0000-0000-0000-000000000001', 'pkg:npm/react@18.2.0', 'react', '18.2.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000002', 'pkg:npm/react-dom@18.2.0', 'react-dom', '18.2.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000003', 'pkg:npm/next@14.1.0', 'next', '14.1.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000004', 'pkg:npm/axios@1.6.5', 'axios', '1.6.5', 'npm', 1), + ('dcdcc001-0000-0000-0000-000000000005', 'pkg:npm/lodash@4.17.20', 'lodash', '4.17.20', 'npm', 1), + ('dcdcc001-0000-0000-0000-000000000006', 'pkg:npm/express@4.18.2', 'express', '4.18.2', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000007', 'pkg:npm/cross-spawn@7.0.3', 'cross-spawn', '7.0.3', 'npm', 1), + ('dcdcc001-0000-0000-0000-000000000008', 'pkg:npm/braces@3.0.2', 'braces', '3.0.2', 'npm', 1), + ('dcdcc001-0000-0000-0000-000000000009', 'pkg:npm/ws@8.16.0', 'ws', '8.16.0', 'npm', 1), + ('dcdcc001-0000-0000-0000-00000000000a', 'pkg:npm/tar-fs@2.1.1', 'tar-fs', '2.1.1', 'npm', 1), + ('dcdcc001-0000-0000-0000-00000000000b', 'pkg:npm/ip@2.0.0', 'ip', '2.0.0', 'npm', 1), + ('dcdcc001-0000-0000-0000-00000000000c', 'pkg:npm/webpack@5.89.0', 'webpack', '5.89.0', 'npm', 1), + ('dcdcc001-0000-0000-0000-00000000000d', 'pkg:npm/typescript@5.3.3', 'typescript', '5.3.3', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000000e', 'pkg:npm/eslint@8.56.0', 'eslint', '8.56.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000000f', 'pkg:npm/%40grpc/grpc-js@1.9.5', '@grpc/grpc-js', '1.9.5', 'npm', 1), + ('dcdcc001-0000-0000-0000-000000000010', 'pkg:npm/fastify@4.25.2', 'fastify', '4.25.2', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000011', 'pkg:npm/jsonwebtoken@9.0.2', 'jsonwebtoken', '9.0.2', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000012', 'pkg:npm/bcrypt@5.1.1', 'bcrypt', '5.1.1', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000013', 'pkg:npm/redis@4.6.12', 'redis', '4.6.12', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000014', 'pkg:npm/pg@8.11.3', 'pg', '8.11.3', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000015', 'pkg:npm/mongoose@8.1.0', 'mongoose', '8.1.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000016', 'pkg:npm/socket.io@4.7.4', 'socket.io', '4.7.4', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000017', 'pkg:npm/react-native@0.73.2', 'react-native', '0.73.2', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000018', 'pkg:npm/expo@50.0.5', 'expo', '50.0.5', 'npm', 0), + ('dcdcc001-0000-0000-0000-000000000019', 'pkg:npm/request@2.88.2', 'request', '2.88.2', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001a', 'pkg:npm/node-forge@1.3.1', 'node-forge', '1.3.1', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001b', 'pkg:npm/moment@2.29.4', 'moment', '2.29.4', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001c', 'pkg:npm/colors@1.4.0', 'colors', '1.4.0', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001d', 'pkg:npm/jquery@3.7.1', 'jquery', '3.7.1', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001e', 'pkg:npm/tailwindcss@3.4.1', 'tailwindcss', '3.4.1', 'npm', 0), + ('dcdcc001-0000-0000-0000-00000000001f', 'pkg:npm/zod@3.22.4', 'zod', '3.22.4', 'npm', 0), + -- maven (Java) + ('dcdcc002-0000-0000-0000-000000000001', 'pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.1', 'spring-boot-starter-web', '3.2.1', 'maven', 0), + ('dcdcc002-0000-0000-0000-000000000002', 'pkg:maven/org.springframework/spring-core@6.1.2', 'spring-core', '6.1.2', 'maven', 0), + ('dcdcc002-0000-0000-0000-000000000003', 'pkg:maven/org.springframework/spring-webmvc@6.0.0', 'spring-webmvc', '6.0.0', 'maven', 2), + ('dcdcc002-0000-0000-0000-000000000004', 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', 'log4j-core', '2.14.1', 'maven', 1), + ('dcdcc002-0000-0000-0000-000000000005', 'pkg:maven/org.apache.logging.log4j/log4j-api@2.14.1', 'log4j-api', '2.14.1', 'maven', 1), + ('dcdcc002-0000-0000-0000-000000000006', 'pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.1', 'jackson-databind', '2.16.1', 'maven', 0), + ('dcdcc002-0000-0000-0000-000000000007', 'pkg:maven/org.apache.tomcat.embed/tomcat-embed-core@10.1.18', 'tomcat-embed-core', '10.1.18', 'maven', 1), + ('dcdcc002-0000-0000-0000-000000000008', 'pkg:maven/io.netty/netty-all@4.1.99.Final', 'netty-all', '4.1.99.Final', 'maven', 1), + ('dcdcc002-0000-0000-0000-000000000009', 'pkg:maven/org.apache.commons/commons-compress@1.25.0', 'commons-compress', '1.25.0', 'maven', 1), + ('dcdcc002-0000-0000-0000-00000000000a', 'pkg:maven/org.apache.poi/poi@5.2.3', 'poi', '5.2.3', 'maven', 1), + ('dcdcc002-0000-0000-0000-00000000000b', 'pkg:maven/com.google.guava/guava@33.0.0-jre', 'guava', '33.0.0-jre', 'maven', 0), + ('dcdcc002-0000-0000-0000-00000000000c', 'pkg:maven/org.apache.activemq/activemq-client@5.17.5', 'activemq-client', '5.17.5', 'maven', 1), + ('dcdcc002-0000-0000-0000-00000000000d', 'pkg:maven/org.hibernate.orm/hibernate-core@6.4.1.Final', 'hibernate-core', '6.4.1.Final', 'maven', 0), + ('dcdcc002-0000-0000-0000-00000000000e', 'pkg:maven/com.mysql/mysql-connector-j@8.3.0', 'mysql-connector-j', '8.3.0', 'maven', 0), + -- pypi + ('dcdcc003-0000-0000-0000-000000000001', 'pkg:pypi/requests@2.31.0', 'requests', '2.31.0', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000002', 'pkg:pypi/urllib3@2.0.7', 'urllib3', '2.0.7', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000003', 'pkg:pypi/idna@3.4', 'idna', '3.4', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000004', 'pkg:pypi/jinja2@3.1.2', 'jinja2', '3.1.2', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000005', 'pkg:pypi/flask@3.0.1', 'flask', '3.0.1', 'pypi', 0), + ('dcdcc003-0000-0000-0000-000000000006', 'pkg:pypi/werkzeug@3.0.0', 'werkzeug', '3.0.0', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000007', 'pkg:pypi/gunicorn@21.2.0', 'gunicorn', '21.2.0', 'pypi', 1), + ('dcdcc003-0000-0000-0000-000000000008', 'pkg:pypi/django@4.2.9', 'django', '4.2.9', 'pypi', 0), + ('dcdcc003-0000-0000-0000-000000000009', 'pkg:pypi/fastapi@0.108.0', 'fastapi', '0.108.0', 'pypi', 0), + ('dcdcc003-0000-0000-0000-00000000000a', 'pkg:pypi/python-multipart@0.0.6', 'python-multipart', '0.0.6', 'pypi', 1), + ('dcdcc003-0000-0000-0000-00000000000b', 'pkg:pypi/cryptography@41.0.7', 'cryptography', '41.0.7', 'pypi', 0), + ('dcdcc003-0000-0000-0000-00000000000c', 'pkg:pypi/numpy@1.26.3', 'numpy', '1.26.3', 'pypi', 0), + ('dcdcc003-0000-0000-0000-00000000000d', 'pkg:pypi/pandas@2.1.4', 'pandas', '2.1.4', 'pypi', 0), + ('dcdcc003-0000-0000-0000-00000000000e', 'pkg:pypi/sqlalchemy@2.0.25', 'sqlalchemy', '2.0.25', 'pypi', 0), + ('dcdcc003-0000-0000-0000-00000000000f', 'pkg:pypi/boto3@1.34.14', 'boto3', '1.34.14', 'pypi', 0), + -- go + ('dcdcc004-0000-0000-0000-000000000001', 'pkg:golang/github.com/gin-gonic/gin@1.9.1', 'github.com/gin-gonic/gin', '1.9.1', 'go', 0), + ('dcdcc004-0000-0000-0000-000000000002', 'pkg:golang/google.golang.org/protobuf@1.31.0', 'google.golang.org/protobuf', '1.31.0', 'go', 1), + ('dcdcc004-0000-0000-0000-000000000003', 'pkg:golang/golang.org/x/crypto@0.18.0', 'golang.org/x/crypto', '0.18.0', 'go', 1), + ('dcdcc004-0000-0000-0000-000000000004', 'pkg:golang/github.com/moby/moby@24.0.7', 'github.com/moby/moby', '24.0.7', 'go', 1), + ('dcdcc004-0000-0000-0000-000000000005', 'pkg:golang/k8s.io/client-go@0.29.1', 'k8s.io/client-go', '0.29.1', 'go', 0), + ('dcdcc004-0000-0000-0000-000000000006', 'pkg:golang/k8s.io/api@0.29.1', 'k8s.io/api', '0.29.1', 'go', 0), + ('dcdcc004-0000-0000-0000-000000000007', 'pkg:golang/github.com/opencontainers/runc@1.1.10', 'github.com/opencontainers/runc', '1.1.10', 'go', 1), + ('dcdcc004-0000-0000-0000-000000000008', 'pkg:golang/github.com/spf13/cobra@1.8.0', 'github.com/spf13/cobra', '1.8.0', 'go', 0), + ('dcdcc004-0000-0000-0000-000000000009', 'pkg:golang/go.etcd.io/etcd/client/v3@3.5.11', 'go.etcd.io/etcd/client/v3', '3.5.11', 'go', 0), + -- nuget + ('dcdcc005-0000-0000-0000-000000000001', 'pkg:nuget/Microsoft.AspNetCore.App@8.0.1', 'Microsoft.AspNetCore.App', '8.0.1', 'nuget', 0), + ('dcdcc005-0000-0000-0000-000000000002', 'pkg:nuget/System.Text.Json@8.0.0', 'System.Text.Json', '8.0.0', 'nuget', 1), + ('dcdcc005-0000-0000-0000-000000000003', 'pkg:nuget/Newtonsoft.Json@13.0.3', 'Newtonsoft.Json', '13.0.3', 'nuget', 0), + ('dcdcc005-0000-0000-0000-000000000004', 'pkg:nuget/EntityFrameworkCore@8.0.1', 'EntityFrameworkCore', '8.0.1', 'nuget', 0), + ('dcdcc005-0000-0000-0000-000000000005', 'pkg:nuget/Serilog@3.1.1', 'Serilog', '3.1.1', 'nuget', 0), + -- composer + ('dcdcc006-0000-0000-0000-000000000001', 'pkg:composer/symfony/http-foundation@6.4.2', 'symfony/http-foundation', '6.4.2', 'composer', 1), + ('dcdcc006-0000-0000-0000-000000000002', 'pkg:composer/laravel/framework@10.41.0', 'laravel/framework', '10.41.0', 'composer', 0), + ('dcdcc006-0000-0000-0000-000000000003', 'pkg:composer/guzzlehttp/guzzle@7.8.1', 'guzzlehttp/guzzle', '7.8.1', 'composer', 0), + ('dcdcc006-0000-0000-0000-000000000004', 'pkg:composer/monolog/monolog@3.5.0', 'monolog/monolog', '3.5.0', 'composer', 0), + -- cargo + ('dcdcc007-0000-0000-0000-000000000001', 'pkg:cargo/tokio@1.35.1', 'tokio', '1.35.1', 'cargo', 0), + ('dcdcc007-0000-0000-0000-000000000002', 'pkg:cargo/serde@1.0.195', 'serde', '1.0.195', 'cargo', 0), + ('dcdcc007-0000-0000-0000-000000000003', 'pkg:cargo/openssl@0.10.62', 'openssl', '0.10.62', 'cargo', 1), + ('dcdcc007-0000-0000-0000-000000000004', 'pkg:cargo/reqwest@0.11.23', 'reqwest', '0.11.23', 'cargo', 0), + -- rubygems + ('dcdcc008-0000-0000-0000-000000000001', 'pkg:gem/rails@7.1.2', 'rails', '7.1.2', 'rubygems', 0), + ('dcdcc008-0000-0000-0000-000000000002', 'pkg:gem/rack@3.0.8', 'rack', '3.0.8', 'rubygems', 1), + ('dcdcc008-0000-0000-0000-000000000003', 'pkg:gem/sinatra@4.0.0', 'sinatra', '4.0.0', 'rubygems', 0), + ('dcdcc008-0000-0000-0000-000000000004', 'pkg:gem/sidekiq@7.2.1', 'sidekiq', '7.2.1', 'rubygems', 0), + -- cocoapods + gradle + swiftpm + ('dcdcc009-0000-0000-0000-000000000001', 'pkg:cocoapods/Alamofire@5.8.1', 'Alamofire', '5.8.1', 'cocoapods', 0), + ('dcdcc009-0000-0000-0000-000000000002', 'pkg:cocoapods/Realm@10.45.2', 'Realm', '10.45.2', 'cocoapods', 0), + ('dcdcc00a-0000-0000-0000-000000000001', 'pkg:maven/androidx.compose.ui/ui@1.6.0', 'androidx.compose.ui:ui', '1.6.0', 'gradle', 0), + ('dcdcc00a-0000-0000-0000-000000000002', 'pkg:maven/com.squareup.retrofit2/retrofit@2.9.0', 'com.squareup.retrofit2:retrofit', '2.9.0', 'gradle', 0), + ('dcdcc00b-0000-0000-0000-000000000001', 'pkg:swift/apple/swift-collections@1.0.6', 'swift-collections', '1.0.6', 'swiftpm', 0), + ('dcdcc00b-0000-0000-0000-000000000002', 'pkg:swift/apple/swift-nio@2.62.0', 'swift-nio', '2.62.0', 'swiftpm', 0) +ON CONFLICT (purl) DO NOTHING; + +-- ============================================================================= +-- Step 5: Component Licenses (junction) +-- ============================================================================= + +INSERT INTO component_licenses (component_id, license_id) VALUES + -- npm: mostly MIT + ('dcdcc001-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000003', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000004', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000005', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000006', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000007', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000008', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000009', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000000a', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000000b', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000000c', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000000d', 'Apache-2.0'), + ('dcdcc001-0000-0000-0000-00000000000e', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000000f', 'Apache-2.0'), + ('dcdcc001-0000-0000-0000-000000000010', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000011', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000012', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000013', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000014', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000015', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000016', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000017', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000018', 'MIT'), + ('dcdcc001-0000-0000-0000-000000000019', 'Apache-2.0'), + ('dcdcc001-0000-0000-0000-00000000001a', 'GPL-2.0'), + ('dcdcc001-0000-0000-0000-00000000001b', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000001c', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000001d', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000001e', 'MIT'), + ('dcdcc001-0000-0000-0000-00000000001f', 'MIT'), + -- maven: Apache-2.0 dominant + ('dcdcc002-0000-0000-0000-000000000001', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000002', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000003', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000004', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000005', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000006', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000007', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000008', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-000000000009', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-00000000000a', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-00000000000b', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-00000000000c', 'Apache-2.0'), + ('dcdcc002-0000-0000-0000-00000000000d', 'LGPL-2.1'), + ('dcdcc002-0000-0000-0000-00000000000e', 'GPL-2.0'), + -- pypi + ('dcdcc003-0000-0000-0000-000000000001', 'Apache-2.0'), + ('dcdcc003-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc003-0000-0000-0000-000000000003', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-000000000004', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-000000000005', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-000000000006', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-000000000007', 'MIT'), + ('dcdcc003-0000-0000-0000-000000000008', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-000000000009', 'MIT'), + ('dcdcc003-0000-0000-0000-00000000000a', 'Apache-2.0'), + ('dcdcc003-0000-0000-0000-00000000000b', 'Apache-2.0'), + ('dcdcc003-0000-0000-0000-00000000000c', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-00000000000d', 'BSD-3-Clause'), + ('dcdcc003-0000-0000-0000-00000000000e', 'MIT'), + ('dcdcc003-0000-0000-0000-00000000000f', 'Apache-2.0'), + -- go + ('dcdcc004-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc004-0000-0000-0000-000000000002', 'BSD-3-Clause'), + ('dcdcc004-0000-0000-0000-000000000003', 'BSD-3-Clause'), + ('dcdcc004-0000-0000-0000-000000000004', 'Apache-2.0'), + ('dcdcc004-0000-0000-0000-000000000005', 'Apache-2.0'), + ('dcdcc004-0000-0000-0000-000000000006', 'Apache-2.0'), + ('dcdcc004-0000-0000-0000-000000000007', 'Apache-2.0'), + ('dcdcc004-0000-0000-0000-000000000008', 'Apache-2.0'), + ('dcdcc004-0000-0000-0000-000000000009', 'Apache-2.0'), + -- nuget / composer / cargo / rubygems / cocoapods / gradle / swiftpm + ('dcdcc005-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc005-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc005-0000-0000-0000-000000000003', 'MIT'), + ('dcdcc005-0000-0000-0000-000000000004', 'MIT'), + ('dcdcc005-0000-0000-0000-000000000005', 'Apache-2.0'), + ('dcdcc006-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc006-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc006-0000-0000-0000-000000000003', 'MIT'), + ('dcdcc006-0000-0000-0000-000000000004', 'MIT'), + ('dcdcc007-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc007-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc007-0000-0000-0000-000000000003', 'Apache-2.0'), + ('dcdcc007-0000-0000-0000-000000000004', 'MIT'), + ('dcdcc008-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc008-0000-0000-0000-000000000002', 'MIT'), + ('dcdcc008-0000-0000-0000-000000000003', 'MIT'), + ('dcdcc008-0000-0000-0000-000000000004', 'LGPL-3.0'), + ('dcdcc009-0000-0000-0000-000000000001', 'MIT'), + ('dcdcc009-0000-0000-0000-000000000002', 'Apache-2.0'), + ('dcdcc00a-0000-0000-0000-000000000001', 'Apache-2.0'), + ('dcdcc00a-0000-0000-0000-000000000002', 'Apache-2.0'), + ('dcdcc00b-0000-0000-0000-000000000001', 'Apache-2.0'), + ('dcdcc00b-0000-0000-0000-000000000002', 'Apache-2.0') +ON CONFLICT (component_id, license_id) DO NOTHING; + +-- ============================================================================= +-- Step 6: asset_components — links assets to global components +-- (Same components can repeat across assets — that's the blast-radius story.) +-- ============================================================================= + +DO $$ +DECLARE + v_tenant_id UUID; +BEGIN + SELECT id INTO v_tenant_id FROM tenants + WHERE name ILIKE '%org%' OR slug ILIKE '%org%' + ORDER BY created_at LIMIT 1; + + -- Each row links (asset, component) and copies a few denormalized fields + -- (name, version, ecosystem, license, purl) so the existing list query + -- works even before a JOIN. component_id is the FK to global components. + + INSERT INTO asset_components (id, tenant_id, asset_id, component_id, name, version, ecosystem, package_manager, + license, purl, dependency_type, is_direct, depth, manifest_file, status) + VALUES + -- web-storefront — npm (~30) + ('dcdc2001-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000001', 'react', '18.2.0', 'npm', 'npm', 'MIT', 'pkg:npm/react@18.2.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000002', 'react-dom', '18.2.0', 'npm', 'npm', 'MIT', 'pkg:npm/react-dom@18.2.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000003', 'next', '14.1.0', 'npm', 'npm', 'MIT', 'pkg:npm/next@14.1.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000004', 'axios', '1.6.5', 'npm', 'npm', 'MIT', 'pkg:npm/axios@1.6.5', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000005', 'lodash', '4.17.20', 'npm', 'npm', 'MIT', 'pkg:npm/lodash@4.17.20', 'transitive', false, 1, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000006', 'express', '4.18.2', 'npm', 'npm', 'MIT', 'pkg:npm/express@4.18.2', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000007', 'cross-spawn', '7.0.3', 'npm', 'npm', 'MIT', 'pkg:npm/cross-spawn@7.0.3', 'transitive', false, 2, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000008', 'braces', '3.0.2', 'npm', 'npm', 'MIT', 'pkg:npm/braces@3.0.2', 'transitive', false, 2, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000009', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000009', 'ws', '8.16.0', 'npm', 'npm', 'MIT', 'pkg:npm/ws@8.16.0', 'transitive', false, 1, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000a', 'tar-fs', '2.1.1', 'npm', 'npm', 'MIT', 'pkg:npm/tar-fs@2.1.1', 'transitive', false, 2, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000b', 'ip', '2.0.0', 'npm', 'npm', 'MIT', 'pkg:npm/ip@2.0.0', 'transitive', false, 3, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000c', 'webpack', '5.89.0', 'npm', 'npm', 'MIT', 'pkg:npm/webpack@5.89.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000d', 'typescript', '5.3.3', 'npm', 'npm', 'Apache-2.0', 'pkg:npm/typescript@5.3.3', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000e', 'eslint', '8.56.0', 'npm', 'npm', 'MIT', 'pkg:npm/eslint@8.56.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000000f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001e', 'tailwindcss', '3.4.1', 'npm', 'npm', 'MIT', 'pkg:npm/tailwindcss@3.4.1', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000010', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001f', 'zod', '3.22.4', 'npm', 'npm', 'MIT', 'pkg:npm/zod@3.22.4', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000011', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000019', 'request', '2.88.2', 'npm', 'npm', 'Apache-2.0', 'pkg:npm/request@2.88.2', 'transitive', false, 4, 'package.json', 'deprecated'), + ('dcdc2001-0000-0000-0000-000000000012', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001a', 'node-forge', '1.3.1', 'npm', 'npm', 'GPL-2.0', 'pkg:npm/node-forge@1.3.1', 'transitive', false, 3, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000013', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001b', 'moment', '2.29.4', 'npm', 'npm', 'MIT', 'pkg:npm/moment@2.29.4', 'direct', true, 0, 'package.json', 'deprecated'), + ('dcdc2001-0000-0000-0000-000000000014', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001c', 'colors', '1.4.0', 'npm', 'npm', 'MIT', 'pkg:npm/colors@1.4.0', 'transitive', false, 3, 'package.json', 'deprecated'), + ('dcdc2001-0000-0000-0000-000000000015', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000001d', 'jquery', '3.7.1', 'npm', 'npm', 'MIT', 'pkg:npm/jquery@3.7.1', 'transitive', false, 4, 'package.json', 'active'), + -- api-gateway — npm (10), composer (4) — REUSES axios, lodash, ws to demo blast radius + ('dcdc2001-0000-0000-0000-000000000016', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000004', 'axios', '1.6.5', 'npm', 'npm', 'MIT', 'pkg:npm/axios@1.6.5', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000017', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000005', 'lodash', '4.17.20', 'npm', 'npm', 'MIT', 'pkg:npm/lodash@4.17.20', 'transitive', false, 2, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000018', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000009', 'ws', '8.16.0', 'npm', 'npm', 'MIT', 'pkg:npm/ws@8.16.0', 'transitive', false, 1, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000019', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000010', 'fastify', '4.25.2', 'npm', 'npm', 'MIT', 'pkg:npm/fastify@4.25.2', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000011', 'jsonwebtoken', '9.0.2', 'npm', 'npm', 'MIT', 'pkg:npm/jsonwebtoken@9.0.2', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000012', 'bcrypt', '5.1.1', 'npm', 'npm', 'MIT', 'pkg:npm/bcrypt@5.1.1', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000013', 'redis', '4.6.12', 'npm', 'npm', 'MIT', 'pkg:npm/redis@4.6.12', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000014', 'pg', '8.11.3', 'npm', 'npm', 'MIT', 'pkg:npm/pg@8.11.3', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000015', 'mongoose', '8.1.0', 'npm', 'npm', 'MIT', 'pkg:npm/mongoose@8.1.0', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-00000000001f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000016', 'socket.io', '4.7.4', 'npm', 'npm', 'MIT', 'pkg:npm/socket.io@4.7.4', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000020', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc006-0000-0000-0000-000000000001', 'symfony/http-foundation', '6.4.2', 'composer', 'composer', 'MIT', 'pkg:composer/symfony/http-foundation@6.4.2', 'direct', true, 0, 'composer.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000021', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc006-0000-0000-0000-000000000002', 'laravel/framework', '10.41.0', 'composer', 'composer', 'MIT', 'pkg:composer/laravel/framework@10.41.0', 'direct', true, 0, 'composer.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000022', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc006-0000-0000-0000-000000000003', 'guzzlehttp/guzzle', '7.8.1', 'composer', 'composer', 'MIT', 'pkg:composer/guzzlehttp/guzzle@7.8.1', 'direct', true, 0, 'composer.json', 'active'), + ('dcdc2001-0000-0000-0000-000000000023', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc006-0000-0000-0000-000000000004', 'monolog/monolog', '3.5.0', 'composer', 'composer', 'MIT', 'pkg:composer/monolog/monolog@3.5.0', 'direct', true, 0, 'composer.json', 'active'), + -- payment-service — maven (14), nuget (5) + ('dcdc2002-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000001', 'spring-boot-starter-web', '3.2.1', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.springframework.boot/spring-boot-starter-web@3.2.1', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000002', 'spring-core', '6.1.2', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.springframework/spring-core@6.1.2', 'transitive', false, 1, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000003', 'spring-webmvc', '6.0.0', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.springframework/spring-webmvc@6.0.0', 'transitive', false, 1, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000004', 'log4j-core', '2.14.1', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', 'transitive', false, 2, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000005', 'log4j-api', '2.14.1', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.logging.log4j/log4j-api@2.14.1', 'transitive', false, 2, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000006', 'jackson-databind', '2.16.1', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.16.1', 'transitive', false, 1, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000007', 'tomcat-embed-core', '10.1.18', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.tomcat.embed/tomcat-embed-core@10.1.18', 'transitive', false, 1, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000008', 'netty-all', '4.1.99.Final', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/io.netty/netty-all@4.1.99.Final', 'transitive', false, 2, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-000000000009', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000009', 'commons-compress', '1.25.0', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.commons/commons-compress@1.25.0', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000a', 'poi', '5.2.3', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.poi/poi@5.2.3', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000b', 'guava', '33.0.0-jre', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/com.google.guava/guava@33.0.0-jre', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000c', 'activemq-client', '5.17.5', 'maven', 'maven', 'Apache-2.0', 'pkg:maven/org.apache.activemq/activemq-client@5.17.5', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000d', 'hibernate-core', '6.4.1.Final', 'maven', 'maven', 'LGPL-2.1', 'pkg:maven/org.hibernate.orm/hibernate-core@6.4.1.Final', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000e', 'mysql-connector-j', '8.3.0', 'maven', 'maven', 'GPL-2.0', 'pkg:maven/com.mysql/mysql-connector-j@8.3.0', 'direct', true, 0, 'pom.xml', 'active'), + ('dcdc2002-0000-0000-0000-00000000000f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000001', 'Microsoft.AspNetCore.App', '8.0.1', 'nuget', 'nuget', 'MIT', 'pkg:nuget/Microsoft.AspNetCore.App@8.0.1', 'direct', true, 0, 'csproj', 'active'), + ('dcdc2002-0000-0000-0000-000000000010', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000002', 'System.Text.Json', '8.0.0', 'nuget', 'nuget', 'MIT', 'pkg:nuget/System.Text.Json@8.0.0', 'transitive', false, 1, 'csproj', 'active'), + ('dcdc2002-0000-0000-0000-000000000011', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000003', 'Newtonsoft.Json', '13.0.3', 'nuget', 'nuget', 'MIT', 'pkg:nuget/Newtonsoft.Json@13.0.3', 'direct', true, 0, 'csproj', 'active'), + ('dcdc2002-0000-0000-0000-000000000012', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000004', 'EntityFrameworkCore', '8.0.1', 'nuget', 'nuget', 'MIT', 'pkg:nuget/Microsoft.EntityFrameworkCore@8.0.1', 'direct', true, 0, 'csproj', 'active'), + ('dcdc2002-0000-0000-0000-000000000013', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000005', 'Serilog', '3.1.1', 'nuget', 'nuget', 'Apache-2.0', 'pkg:nuget/Serilog@3.1.1', 'direct', true, 0, 'csproj', 'active'), + -- mobile-app (npm RN + cocoapods + gradle + swiftpm) + ('dcdc2003-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc001-0000-0000-0000-000000000017', 'react-native', '0.73.2', 'npm', 'npm', 'MIT', 'pkg:npm/react-native@0.73.2', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2003-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc001-0000-0000-0000-000000000018', 'expo', '50.0.5', 'npm', 'npm', 'MIT', 'pkg:npm/expo@50.0.5', 'direct', true, 0, 'package.json', 'active'), + ('dcdc2003-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc009-0000-0000-0000-000000000001', 'Alamofire', '5.8.1', 'cocoapods', 'cocoapods', 'MIT', 'pkg:cocoapods/Alamofire@5.8.1', 'direct', true, 0, 'Podfile', 'active'), + ('dcdc2003-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc009-0000-0000-0000-000000000002', 'Realm', '10.45.2', 'cocoapods', 'cocoapods', 'Apache-2.0', 'pkg:cocoapods/Realm@10.45.2', 'direct', true, 0, 'Podfile', 'active'), + ('dcdc2003-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc00a-0000-0000-0000-000000000001', 'androidx.compose.ui:ui', '1.6.0', 'gradle', 'gradle', 'Apache-2.0', 'pkg:maven/androidx.compose.ui/ui@1.6.0', 'direct', true, 0, 'build.gradle', 'active'), + ('dcdc2003-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc00a-0000-0000-0000-000000000002', 'com.squareup.retrofit2:retrofit', '2.9.0', 'gradle', 'gradle', 'Apache-2.0', 'pkg:maven/com.squareup.retrofit2/retrofit@2.9.0', 'direct', true, 0, 'build.gradle', 'active'), + ('dcdc2003-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc00b-0000-0000-0000-000000000001', 'swift-collections', '1.0.6', 'swiftpm', 'swiftpm', 'Apache-2.0', 'pkg:swift/apple/swift-collections@1.0.6', 'direct', true, 0, 'Package.swift', 'active'), + ('dcdc2003-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000004', 'dcdcc00b-0000-0000-0000-000000000002', 'swift-nio', '2.62.0', 'swiftpm', 'swiftpm', 'Apache-2.0', 'pkg:swift/apple/swift-nio@2.62.0', 'direct', true, 0, 'Package.swift', 'active'), + -- iac-infra — pypi + cargo + rubygems + ('dcdc2004-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000001', 'requests', '2.31.0', 'pypi', 'pip', 'Apache-2.0', 'pkg:pypi/requests@2.31.0', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000002', 'urllib3', '2.0.7', 'pypi', 'pip', 'MIT', 'pkg:pypi/urllib3@2.0.7', 'transitive', false, 1, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000003', 'idna', '3.4', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/idna@3.4', 'transitive', false, 2, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000004', 'jinja2', '3.1.2', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/jinja2@3.1.2', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000005', 'flask', '3.0.1', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/flask@3.0.1', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000006', 'werkzeug', '3.0.0', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/werkzeug@3.0.0', 'transitive', false, 1, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000007', 'gunicorn', '21.2.0', 'pypi', 'pip', 'MIT', 'pkg:pypi/gunicorn@21.2.0', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000008', 'django', '4.2.9', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/django@4.2.9', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000009', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000009', 'fastapi', '0.108.0', 'pypi', 'pip', 'MIT', 'pkg:pypi/fastapi@0.108.0', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000a', 'python-multipart', '0.0.6', 'pypi', 'pip', 'Apache-2.0', 'pkg:pypi/python-multipart@0.0.6', 'transitive', false, 1, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000b', 'cryptography', '41.0.7', 'pypi', 'pip', 'Apache-2.0', 'pkg:pypi/cryptography@41.0.7', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000c', 'numpy', '1.26.3', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/numpy@1.26.3', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000d', 'pandas', '2.1.4', 'pypi', 'pip', 'BSD-3-Clause', 'pkg:pypi/pandas@2.1.4', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000e', 'sqlalchemy', '2.0.25', 'pypi', 'pip', 'MIT', 'pkg:pypi/sqlalchemy@2.0.25', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-00000000000f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000f', 'boto3', '1.34.14', 'pypi', 'pip', 'Apache-2.0', 'pkg:pypi/boto3@1.34.14', 'direct', true, 0, 'requirements.txt', 'active'), + ('dcdc2004-0000-0000-0000-000000000010', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc007-0000-0000-0000-000000000001', 'tokio', '1.35.1', 'cargo', 'cargo', 'MIT', 'pkg:cargo/tokio@1.35.1', 'direct', true, 0, 'Cargo.toml', 'active'), + ('dcdc2004-0000-0000-0000-000000000011', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc007-0000-0000-0000-000000000002', 'serde', '1.0.195', 'cargo', 'cargo', 'MIT', 'pkg:cargo/serde@1.0.195', 'direct', true, 0, 'Cargo.toml', 'active'), + ('dcdc2004-0000-0000-0000-000000000012', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc007-0000-0000-0000-000000000003', 'openssl', '0.10.62', 'cargo', 'cargo', 'Apache-2.0', 'pkg:cargo/openssl@0.10.62', 'direct', true, 0, 'Cargo.toml', 'active'), + ('dcdc2004-0000-0000-0000-000000000013', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc007-0000-0000-0000-000000000004', 'reqwest', '0.11.23', 'cargo', 'cargo', 'MIT', 'pkg:cargo/reqwest@0.11.23', 'direct', true, 0, 'Cargo.toml', 'active'), + ('dcdc2004-0000-0000-0000-000000000014', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc008-0000-0000-0000-000000000001', 'rails', '7.1.2', 'rubygems', 'gem', 'MIT', 'pkg:gem/rails@7.1.2', 'direct', true, 0, 'Gemfile', 'active'), + ('dcdc2004-0000-0000-0000-000000000015', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc008-0000-0000-0000-000000000002', 'rack', '3.0.8', 'rubygems', 'gem', 'MIT', 'pkg:gem/rack@3.0.8', 'transitive', false, 1, 'Gemfile', 'active'), + ('dcdc2004-0000-0000-0000-000000000016', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc008-0000-0000-0000-000000000003', 'sinatra', '4.0.0', 'rubygems', 'gem', 'MIT', 'pkg:gem/sinatra@4.0.0', 'direct', true, 0, 'Gemfile', 'active'), + ('dcdc2004-0000-0000-0000-000000000017', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc008-0000-0000-0000-000000000004', 'sidekiq', '7.2.1', 'rubygems', 'gem', 'LGPL-3.0', 'pkg:gem/sidekiq@7.2.1', 'direct', true, 0, 'Gemfile', 'active'), + -- k8s-prod — Go components + ('dcdc2005-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000001', 'github.com/gin-gonic/gin', '1.9.1', 'go', 'go', 'MIT', 'pkg:golang/github.com/gin-gonic/gin@1.9.1', 'direct', true, 0, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000002', 'google.golang.org/protobuf', '1.31.0', 'go', 'go', 'BSD-3-Clause', 'pkg:golang/google.golang.org/protobuf@1.31.0', 'transitive', false, 1, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000003', 'golang.org/x/crypto', '0.18.0', 'go', 'go', 'BSD-3-Clause', 'pkg:golang/golang.org/x/crypto@0.18.0', 'direct', true, 0, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000004', 'github.com/moby/moby', '24.0.7', 'go', 'go', 'Apache-2.0', 'pkg:golang/github.com/moby/moby@24.0.7', 'transitive', false, 1, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000005', 'k8s.io/client-go', '0.29.1', 'go', 'go', 'Apache-2.0', 'pkg:golang/k8s.io/client-go@0.29.1', 'direct', true, 0, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000006', 'k8s.io/api', '0.29.1', 'go', 'go', 'Apache-2.0', 'pkg:golang/k8s.io/api@0.29.1', 'direct', true, 0, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000007', 'github.com/opencontainers/runc', '1.1.10', 'go', 'go', 'Apache-2.0', 'pkg:golang/github.com/opencontainers/runc@1.1.10', 'transitive', false, 2, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000008', 'github.com/spf13/cobra', '1.8.0', 'go', 'go', 'Apache-2.0', 'pkg:golang/github.com/spf13/cobra@1.8.0', 'direct', true, 0, 'go.mod', 'active'), + ('dcdc2005-0000-0000-0000-000000000009', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000009', 'go.etcd.io/etcd/client/v3', '3.5.11', 'go', 'go', 'Apache-2.0', 'pkg:golang/go.etcd.io/etcd/client/v3@3.5.11', 'direct', true, 0, 'go.mod', 'active') + ON CONFLICT (id) DO NOTHING; + + RAISE NOTICE 'Inserted asset_components: %', (SELECT COUNT(*) FROM asset_components WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc2%'); + + -- --------------------------------------------------------------------------- + -- Step 7: Findings — link assets × global_components × CVEs (~50) + -- findings.component_id references components(id) (global, not asset_components) + -- --------------------------------------------------------------------------- + INSERT INTO findings (id, tenant_id, asset_id, component_id, vulnerability_id, + source, tool_name, tool_version, message, severity, + cvss_score, cve_id, status, fingerprint, finding_type, + is_internet_accessible, exposure_vector, remedy_available, + first_detected_at, last_seen_at) + VALUES + ('dcdc3001-0000-0000-0000-000000000001', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000004', 'dcdcaaaa-0000-0000-0000-000000000001', + 'sca', 'Trivy', '0.48.3', 'Log4j2 RCE (Log4Shell) detected in payment-service', 'critical', 10.0, 'CVE-2021-44228', 'new', + md5('dcdc3001-1' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '12 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000002', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000005', 'dcdcaaaa-0000-0000-0000-000000000001', + 'sca', 'Trivy', '0.48.3', 'Log4j-api transitive vulnerability (Log4Shell)', 'critical', 10.0, 'CVE-2021-44228', 'confirmed', + md5('dcdc3001-2' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '12 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000003', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000003', 'dcdcaaaa-0000-0000-0000-000000000002', + 'sca', 'Trivy', '0.48.3', 'Spring Framework RCE (Spring4Shell)', 'critical', 9.8, 'CVE-2022-22965', 'new', + md5('dcdc3001-3' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '8 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000004', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', NULL, 'dcdcaaaa-0000-0000-0000-000000000004', + 'container', 'Grype', '0.74.0', 'XZ Utils backdoor (liblzma 5.6.0) in node base image', 'critical', 10.0, 'CVE-2024-3094', 'in_progress', + md5('dcdc3001-4' || v_tenant_id::text), 'vulnerability', false, 'local', true, NOW() - INTERVAL '5 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000005', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000007', 'dcdcaaaa-0000-0000-0000-00000000002c', + 'container', 'Grype', '0.74.0', 'runc 1.1.10 container escape (Leaky Vessels)', 'high', 8.6, 'CVE-2024-21626', 'new', + md5('dcdc3001-5' || v_tenant_id::text), 'vulnerability', false, 'local', true, NOW() - INTERVAL '15 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000006', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000007', 'dcdcaaaa-0000-0000-0000-00000000000b', + 'sca', 'npm-audit', '10.2.4', 'cross-spawn ReDoS vulnerability', 'high', 7.5, 'CVE-2024-21538', 'new', + md5('dcdc3001-6' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '3 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000007', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000008', 'dcdcaaaa-0000-0000-0000-00000000000c', + 'sca', 'npm-audit', '10.2.4', 'braces uncontrolled resource consumption', 'high', 7.5, 'CVE-2024-4068', 'new', + md5('dcdc3001-7' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '7 days', NOW()), + -- Same axios CVE on TWO assets — demonstrates blast radius + ('dcdc3001-0000-0000-0000-000000000008', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000004', 'dcdcaaaa-0000-0000-0000-000000000011', + 'sca', 'npm-audit', '10.2.4', 'axios SSRF via protocol-relative URL (web-storefront)', 'high', 7.5, 'CVE-2024-39338', 'new', + md5('dcdc3001-8' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '4 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000009', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000004', 'dcdcaaaa-0000-0000-0000-000000000011', + 'sca', 'npm-audit', '10.2.4', 'axios SSRF via protocol-relative URL (api-gateway)', 'high', 7.5, 'CVE-2024-39338', 'confirmed', + md5('dcdc3001-9' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '4 days', NOW()), + -- ws DoS on two assets + ('dcdc3001-0000-0000-0000-00000000000a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-000000000009', 'dcdcaaaa-0000-0000-0000-00000000000d', + 'sca', 'npm-audit', '10.2.4', 'ws WebSocket DoS via crafted headers (web)', 'high', 7.5, 'CVE-2024-37890', 'new', + md5('dcdc3001-a' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '4 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000000b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc001-0000-0000-0000-000000000009', 'dcdcaaaa-0000-0000-0000-00000000000d', + 'sca', 'npm-audit', '10.2.4', 'ws WebSocket DoS via crafted headers (api)', 'high', 7.5, 'CVE-2024-37890', 'in_progress', + md5('dcdc3001-b' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '4 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000000c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000a', 'dcdcaaaa-0000-0000-0000-00000000000e', + 'sca', 'npm-audit', '10.2.4', 'tar-fs path traversal allows arbitrary write', 'high', 8.1, 'CVE-2024-24790', 'in_progress', + md5('dcdc3001-c' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '6 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000000d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000b', 'dcdcaaaa-0000-0000-0000-00000000000f', + 'sca', 'npm-audit', '10.2.4', 'ip package isPublic() SSRF bypass', 'high', 8.1, 'CVE-2024-29415', 'new', + md5('dcdc3001-d' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '2 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000000e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000001', 'dcdcc001-0000-0000-0000-00000000000c', 'dcdcaaaa-0000-0000-0000-000000000010', + 'sca', 'npm-audit', '10.2.4', 'webpack dev-server XSS in error page', 'medium', 6.4, 'CVE-2024-43788', 'new', + md5('dcdc3001-e' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '9 days', NOW()), + -- pypi + ('dcdc3001-0000-0000-0000-00000000000f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000003', 'dcdcaaaa-0000-0000-0000-000000000013', + 'sca', 'pip-audit', '2.7.0', 'idna quadratic complexity attack', 'high', 7.5, 'CVE-2024-3651', 'new', + md5('dcdc3001-f' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '5 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000010', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000001', 'dcdcaaaa-0000-0000-0000-000000000014', + 'sca', 'pip-audit', '2.7.0', 'requests Session.verify=False persists across calls', 'medium', 5.6, 'CVE-2024-35195', 'new', + md5('dcdc3001-10' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '11 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000011', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000002', 'dcdcaaaa-0000-0000-0000-000000000015', + 'sca', 'pip-audit', '2.7.0', 'urllib3 proxy-authorization header leak after redirect', 'medium', 4.4, 'CVE-2024-37891', 'new', + md5('dcdc3001-11' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '10 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000012', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000004', 'dcdcaaaa-0000-0000-0000-000000000016', + 'sca', 'pip-audit', '2.7.0', 'Jinja2 xmlattr filter XSS', 'medium', 6.1, 'CVE-2024-22195', 'new', + md5('dcdc3001-12' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '14 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000013', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000007', 'dcdcaaaa-0000-0000-0000-000000000017', + 'sca', 'pip-audit', '2.7.0', 'gunicorn HTTP request smuggling via Transfer-Encoding', 'high', 7.5, 'CVE-2024-1135', 'in_progress', + md5('dcdc3001-13' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '8 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000014', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-000000000006', 'dcdcaaaa-0000-0000-0000-000000000019', + 'sca', 'pip-audit', '2.7.0', 'Werkzeug multipart parser unbounded memory', 'high', 7.5, 'CVE-2024-49767', 'new', + md5('dcdc3001-14' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '4 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000015', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc003-0000-0000-0000-00000000000a', 'dcdcaaaa-0000-0000-0000-00000000001a', + 'sca', 'pip-audit', '2.7.0', 'python-multipart Content-Type ReDoS', 'high', 7.5, 'CVE-2024-24762', 'new', + md5('dcdc3001-15' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '6 days', NOW()), + -- maven + ('dcdc3001-0000-0000-0000-000000000016', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000002', 'dcdcaaaa-0000-0000-0000-00000000001c', + 'sca', 'Trivy', '0.48.3', 'Spring Framework UriComponentsBuilder open redirect/SSRF', 'high', 8.1, 'CVE-2024-22243', 'new', + md5('dcdc3001-16' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '7 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000017', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000a', 'dcdcaaaa-0000-0000-0000-00000000001d', + 'sca', 'Trivy', '0.48.3', 'Apache POI HSLF resource exhaustion', 'medium', 5.5, 'CVE-2024-29133', 'new', + md5('dcdc3001-17' || v_tenant_id::text), 'vulnerability', false, 'local', true, NOW() - INTERVAL '12 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000018', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000009', 'dcdcaaaa-0000-0000-0000-00000000001e', + 'sca', 'Trivy', '0.48.3', 'Apache Commons Compress DUMP file DoS', 'high', 7.5, 'CVE-2024-25710', 'new', + md5('dcdc3001-18' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '5 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000019', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000008', 'dcdcaaaa-0000-0000-0000-00000000001b', + 'sca', 'Trivy', '0.48.3', 'Netty affected by HTTP/2 Rapid Reset DDoS', 'high', 7.5, 'CVE-2023-44487', 'confirmed', + md5('dcdc3001-19' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '20 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000001a', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-000000000007', 'dcdcaaaa-0000-0000-0000-000000000020', + 'sca', 'Trivy', '0.48.3', 'Tomcat information disclosure in chunked encoding', 'medium', 5.3, 'CVE-2024-21733', 'new', + md5('dcdc3001-1a' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '15 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000001b', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc002-0000-0000-0000-00000000000c', 'dcdcaaaa-0000-0000-0000-000000000008', + 'sca', 'Trivy', '0.48.3', 'Apache ActiveMQ OpenWire deserialization RCE', 'critical', 10.0, 'CVE-2023-46604', 'in_progress', + md5('dcdc3001-1b' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '25 days', NOW()), + -- go + ('dcdc3001-0000-0000-0000-00000000001c', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000002', 'dcdcaaaa-0000-0000-0000-000000000021', + 'sca', 'govulncheck', '1.1.0', 'protobuf json unmarshal infinite loop', 'high', 7.5, 'CVE-2024-24786', 'new', + md5('dcdc3001-1c' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '5 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000001d', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000003', 'dcdcaaaa-0000-0000-0000-000000000024', + 'sca', 'govulncheck', '1.1.0', 'golang.org/x/crypto/ssh authorization bypass', 'critical', 9.1, 'CVE-2024-45337', 'in_progress', + md5('dcdc3001-1d' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '3 days', NOW()), + ('dcdc3001-0000-0000-0000-00000000001e', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000004', 'dcdcaaaa-0000-0000-0000-000000000023', + 'sca', 'govulncheck', '1.1.0', 'Moby BuildKit classic builder cache poisoning', 'medium', 6.9, 'CVE-2024-24557', 'new', + md5('dcdc3001-1e' || v_tenant_id::text), 'vulnerability', false, 'local', true, NOW() - INTERVAL '11 days', NOW()), + -- nuget + ('dcdc3001-0000-0000-0000-00000000001f', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000003', 'dcdcc005-0000-0000-0000-000000000002', 'dcdcaaaa-0000-0000-0000-000000000026', + 'sca', 'Trivy', '0.48.3', '.NET System.Text.Json DoS via crafted JSON', 'high', 7.5, 'CVE-2024-30105', 'new', + md5('dcdc3001-1f' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '9 days', NOW()), + -- composer + ('dcdc3001-0000-0000-0000-000000000020', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000002', 'dcdcc006-0000-0000-0000-000000000001', 'dcdcaaaa-0000-0000-0000-000000000029', + 'sca', 'Trivy', '0.48.3', 'Symfony HttpFoundation BinaryFileResponse path traversal', 'high', 7.5, 'CVE-2024-32465', 'new', + md5('dcdc3001-20' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '6 days', NOW()), + -- rubygems + ('dcdc3001-0000-0000-0000-000000000021', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc008-0000-0000-0000-000000000002', 'dcdcaaaa-0000-0000-0000-00000000002a', + 'sca', 'bundle-audit', '0.9.1', 'Rack URI parser ReDoS via Range header', 'high', 7.5, 'CVE-2024-26146', 'new', + md5('dcdc3001-21' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '8 days', NOW()), + -- cargo + ('dcdc3001-0000-0000-0000-000000000022', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', 'dcdcc007-0000-0000-0000-000000000003', 'dcdcaaaa-0000-0000-0000-000000000031', + 'sca', 'cargo-audit', '0.20.0', 'OpenSSL TLS 1.3 unbounded memory growth', 'medium', 5.9, 'CVE-2024-2511', 'new', + md5('dcdc3001-22' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '10 days', NOW()), + -- container scanners (no component link) + ('dcdc3001-0000-0000-0000-000000000023', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', NULL, 'dcdcaaaa-0000-0000-0000-000000000009', + 'container', 'Grype', '0.74.0', 'OpenSSH regreSSHion RCE in node base image', 'critical', 8.1, 'CVE-2024-6387', 'new', + md5('dcdc3001-23' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '7 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000024', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', NULL, 'dcdcaaaa-0000-0000-0000-00000000002d', + 'container', 'Grype', '0.74.0', 'BuildKit cache mount privilege escalation', 'high', 8.7, 'CVE-2024-23652', 'in_progress', + md5('dcdc3001-24' || v_tenant_id::text), 'vulnerability', false, 'local', true, NOW() - INTERVAL '14 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000025', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', 'dcdcc004-0000-0000-0000-000000000005', 'dcdcaaaa-0000-0000-0000-00000000002f', + 'iac', 'Checkov', '3.2.0', 'Kubernetes gitRepo volume RCE risk', 'critical', 8.1, 'CVE-2024-10220', 'new', + md5('dcdc3001-25' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '2 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000026', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000006', NULL, 'dcdcaaaa-0000-0000-0000-000000000030', + 'container', 'Grype', '0.74.0', 'BIND9 KeyTrap DNSSEC DoS in cluster image', 'high', 7.5, 'CVE-2023-50387', 'new', + md5('dcdc3001-26' || v_tenant_id::text), 'vulnerability', false, 'network', true, NOW() - INTERVAL '4 days', NOW()), + -- archived for variety + ('dcdc3001-0000-0000-0000-000000000027', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', NULL, 'dcdcaaaa-0000-0000-0000-000000000005', + 'easm', 'Nuclei', '3.1.4', 'Confluence privilege escalation detected on repo wiki host', 'critical', 10.0, 'CVE-2023-22515', 'false_positive', + md5('dcdc3001-27' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '40 days', NOW()), + ('dcdc3001-0000-0000-0000-000000000028', v_tenant_id, 'dcdc1111-0000-0000-0000-000000000005', NULL, 'dcdcaaaa-0000-0000-0000-00000000000a', + 'easm', 'Nuclei', '3.1.4', 'Jenkins arbitrary file read detected on CI host', 'critical', 9.8, 'CVE-2024-23897', 'resolved', + md5('dcdc3001-28' || v_tenant_id::text), 'vulnerability', true, 'network', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '20 days') + ON CONFLICT (id) DO NOTHING; + + RAISE NOTICE 'Inserted findings: %', (SELECT COUNT(*) FROM findings WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc3%'); + + -- --------------------------------------------------------------------------- + -- Step 8: Recompute aggregated columns on asset_components + -- --------------------------------------------------------------------------- + UPDATE asset_components ac + SET + vulnerability_count = COALESCE(agg.cnt, 0), + has_known_vulnerabilities = (COALESCE(agg.cnt, 0) > 0), + highest_severity = agg.max_sev, + risk_score = LEAST(100, COALESCE(agg.cnt, 0) * 15 + + CASE agg.max_sev + WHEN 'critical' THEN 40 + WHEN 'high' THEN 25 + WHEN 'medium' THEN 10 + WHEN 'low' THEN 3 + ELSE 0 + END) + FROM ( + SELECT f.component_id, + ac2.id AS ac_id, + COUNT(*) FILTER (WHERE f.status IN ('new','confirmed','in_progress')) AS cnt, + ( + ARRAY['critical','high','medium','low','info','none']::text[] + )[ + LEAST( + COALESCE(MIN(CASE f.severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + WHEN 'info' THEN 5 + ELSE 6 END + ) FILTER (WHERE f.status IN ('new','confirmed','in_progress')), 6), + 6 + ) + ] AS max_sev + FROM findings f + JOIN asset_components ac2 + ON ac2.tenant_id = f.tenant_id + AND ac2.asset_id = f.asset_id + AND ac2.component_id = f.component_id + WHERE f.tenant_id = v_tenant_id + AND f.component_id IS NOT NULL + GROUP BY f.component_id, ac2.id + ) agg + WHERE ac.id = agg.ac_id + AND ac.tenant_id = v_tenant_id; + + -- Also update global components.vulnerability_count to count distinct CVEs + UPDATE components c + SET vulnerability_count = COALESCE(agg.cnt, c.vulnerability_count) + FROM ( + SELECT component_id, COUNT(DISTINCT vulnerability_id) AS cnt + FROM findings + WHERE tenant_id = v_tenant_id + AND component_id IS NOT NULL + AND vulnerability_id IS NOT NULL + AND status IN ('new','confirmed','in_progress') + GROUP BY component_id + ) agg + WHERE c.id = agg.component_id; + + RAISE NOTICE '=== Demo Seed Complete ==='; + RAISE NOTICE 'Tenant: %', v_tenant_id; + RAISE NOTICE 'CVEs (global): %', (SELECT COUNT(*) FROM vulnerabilities WHERE id::text LIKE 'dcdcaaaa-%'); + RAISE NOTICE 'Components (global): %', (SELECT COUNT(*) FROM components WHERE id::text LIKE 'dcdcc%'); + RAISE NOTICE 'Assets: %', (SELECT COUNT(*) FROM assets WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc1111-%'); + RAISE NOTICE 'asset_components: %', (SELECT COUNT(*) FROM asset_components WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc2%'); + RAISE NOTICE 'Findings: %', (SELECT COUNT(*) FROM findings WHERE tenant_id = v_tenant_id AND id::text LIKE 'dcdc3%'); + RAISE NOTICE 'Vulnerable components (per-asset): %', (SELECT COUNT(*) FROM asset_components WHERE tenant_id = v_tenant_id AND has_known_vulnerabilities = true); +END $$; \ No newline at end of file diff --git a/pkg/domain/assetgroup/repository.go b/pkg/domain/assetgroup/repository.go index 745cd280..a2273732 100644 --- a/pkg/domain/assetgroup/repository.go +++ b/pkg/domain/assetgroup/repository.go @@ -19,10 +19,14 @@ type Repository interface { GetByTenantAndID(ctx context.Context, tenantID, id shared.ID) (*AssetGroup, error) // Update updates an existing asset group. - Update(ctx context.Context, group *AssetGroup) error + // Security: tenantID enforces tenant scoping in SQL — caller MUST pass + // the requesting tenant so IDOR is impossible (an attacker cannot mutate + // another tenant's group by guessing the UUID). + Update(ctx context.Context, tenantID shared.ID, group *AssetGroup) error // Delete removes an asset group by its ID. - Delete(ctx context.Context, id shared.ID) error + // Security: tenantID enforces tenant scoping in SQL — same rationale as Update. + Delete(ctx context.Context, tenantID, id shared.ID) error // List retrieves asset groups with filtering, sorting, and pagination. List(ctx context.Context, filter Filter, opts ListOptions, page pagination.Pagination) (pagination.Result[*AssetGroup], error) diff --git a/pkg/domain/component/entity.go b/pkg/domain/component/entity.go index a55b7e3c..1b5253be 100644 --- a/pkg/domain/component/entity.go +++ b/pkg/domain/component/entity.go @@ -74,6 +74,60 @@ type VulnerableComponent struct { InCisaKev bool `json:"in_cisa_kev"` } +// ComponentAssetUsage represents a single (component, asset) link used to answer +// "which assets use this component?" — the blast-radius reverse lookup view. +// Joins asset_components × assets to surface asset context (name, type, +// criticality, exposure) alongside the per-asset link details. +type ComponentAssetUsage struct { + // Asset identity & context + AssetID string `json:"asset_id"` + AssetName string `json:"asset_name"` + AssetType string `json:"asset_type"` + Criticality string `json:"criticality"` + AssetStatus string `json:"asset_status"` + Exposure string `json:"exposure"` + RiskScore int `json:"risk_score"` + IsInternetExposed bool `json:"is_internet_accessible"` + + // Per-asset link details (from asset_components) + DependencyID string `json:"dependency_id"` // asset_components.id (for further drill-down) + DependencyType string `json:"dependency_type"` + IsDirect bool `json:"is_direct"` + Depth int `json:"depth"` + ManifestFile string `json:"manifest_file,omitempty"` + ManifestPath string `json:"manifest_path,omitempty"` + License string `json:"license,omitempty"` + VulnerabilityCount int `json:"vulnerability_count"` + HighestSeverity string `json:"highest_severity,omitempty"` + LinkedAt time.Time `json:"linked_at"` +} + +// ComponentVulnerability represents one CVE that affects a global component +// (forward lookup view from the component detail sheet). Aggregates findings +// GROUP BY vulnerability_id so a CVE appearing on multiple assets shows once, +// with affected_assets_count rolled up. +type ComponentVulnerability struct { + // Vulnerability identity (from global vulnerabilities table) + VulnerabilityID string `json:"vulnerability_id"` + CVEID string `json:"cve_id"` + Title string `json:"title"` + Severity string `json:"severity"` + CVSSScore *float64 `json:"cvss_score,omitempty"` + EPSSScore *float64 `json:"epss_score,omitempty"` + InCISAKEV bool `json:"in_cisa_kev"` + ExploitMaturity string `json:"exploit_maturity,omitempty"` + ExploitAvailable bool `json:"exploit_available"` + FixedVersions []string `json:"fixed_versions"` + + // Aggregated finding context for THIS component within THIS tenant + AffectedAssetsCount int `json:"affected_assets_count"` + OpenFindingCount int `json:"open_finding_count"` + TotalFindingCount int `json:"total_finding_count"` + WorstFindingStatus string `json:"worst_finding_status"` + FirstDetectedAt time.Time `json:"first_detected_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + // LicenseStats represents statistics for a single license. type LicenseStats struct { LicenseID string `json:"license_id"` // SPDX identifier diff --git a/pkg/domain/component/repository.go b/pkg/domain/component/repository.go index 5a771ae4..b41b5b68 100644 --- a/pkg/domain/component/repository.go +++ b/pkg/domain/component/repository.go @@ -56,6 +56,36 @@ type Repository interface { // GetLicenseStats retrieves license statistics for a tenant. GetLicenseStats(ctx context.Context, tenantID shared.ID) ([]LicenseStats, error) + + // ListAssetUsage retrieves the assets that use a given global component + // (blast-radius reverse lookup). Joins asset_components × assets, + // scoped to the tenant. Returns empty result when the component is not + // used by any asset of this tenant. + // + // When atRiskOnly is true, only assets that have at least one open + // finding (status in new/confirmed/in_progress) for this component are + // returned. Default false → returns every asset using the component + // regardless of vulnerability status (full SBOM view). + ListAssetUsage( + ctx context.Context, + tenantID shared.ID, + componentID shared.ID, + atRiskOnly bool, + page pagination.Pagination, + ) (pagination.Result[ComponentAssetUsage], error) + + // ListVulnerabilities returns the CVEs that affect a global component + // within the given tenant. Aggregates findings GROUP BY vulnerability_id + // so a CVE appearing on multiple assets returns one row with + // affected_assets_count rolled up. When includeResolved is false, only + // open-status findings (new/confirmed/in_progress) count toward the row + // but the CVE is still included if at least one open finding exists. + ListVulnerabilities( + ctx context.Context, + tenantID, componentID shared.ID, + includeResolved bool, + page pagination.Pagination, + ) (pagination.Result[ComponentVulnerability], error) } // Filter defines criteria for filtering components. diff --git a/pkg/domain/vulnerability/entity.go b/pkg/domain/vulnerability/entity.go index 88a640f7..c1b15642 100644 --- a/pkg/domain/vulnerability/entity.go +++ b/pkg/domain/vulnerability/entity.go @@ -10,6 +10,81 @@ import ( var cvePattern = regexp.MustCompile(`^CVE-\d{4}-\d{4,}$`) +// ActiveCVE represents one CVE that is currently impacting assets in a tenant +// (the "Active CVEs" view — distinct from the global CVE catalog). Returned by +// GET /api/v1/vulnerabilities/active. One row = one CVE, with finding counts +// rolled up across all assets/components in the tenant. +type ActiveCVE struct { + // CVE identity (from global vulnerabilities table) + VulnerabilityID string `json:"vulnerability_id"` + CVEID string `json:"cve_id"` + Title string `json:"title"` + Severity string `json:"severity"` + CVSSScore *float64 `json:"cvss_score,omitempty"` + EPSSScore *float64 `json:"epss_score,omitempty"` + InCISAKEV bool `json:"in_cisa_kev"` + ExploitMaturity string `json:"exploit_maturity,omitempty"` + ExploitAvailable bool `json:"exploit_available"` + FixedVersions []string `json:"fixed_versions"` + PublishedAt *time.Time `json:"published_at,omitempty"` + + // Aggregated finding context within THIS tenant + AffectedAssetsCount int `json:"affected_assets_count"` + AffectedComponentsCount int `json:"affected_components_count"` + TotalFindingCount int `json:"total_finding_count"` + OpenFindingCount int `json:"open_finding_count"` + WorstFindingStatus string `json:"worst_finding_status"` + FirstDetectedAt time.Time `json:"first_detected_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +// ActiveCVEStats summarises the tenant's currently-impacting CVEs for the +// stats-card row above the Active CVEs table. +type ActiveCVEStats struct { + Total int `json:"total"` + BySeverity map[string]int `json:"by_severity"` + KEVCount int `json:"kev_count"` + ExploitAvailableCount int `json:"exploit_available_count"` +} + +// ActiveCVEFilter is the query filter for ListActiveCVEs. +type ActiveCVEFilter struct { + IncludeResolved bool + SeverityIn []string // critical, high, medium, low, info + KEVOnly bool + MinCVSS *float64 + MinEPSS *float64 + ExploitAvailable *bool +} + +// VulnerabilityAffectedAsset represents one asset affected by a CVE +// (blast-radius reverse lookup). Joins findings × assets, scoped to the +// tenant. When the same CVE produces multiple findings on the same asset +// (e.g., direct + transitive), the response collapses them into one row +// keeping the most severe + earliest detected timestamps. +type VulnerabilityAffectedAsset struct { + AssetID string `json:"asset_id"` + AssetName string `json:"asset_name"` + AssetType string `json:"asset_type"` + Criticality string `json:"criticality"` + AssetStatus string `json:"asset_status"` + Exposure string `json:"exposure"` + RiskScore int `json:"risk_score"` + IsInternetExposed bool `json:"is_internet_accessible"` + + // Aggregated finding context for this CVE on this asset + FindingCount int `json:"finding_count"` // total findings of this CVE on this asset + OpenFindingCount int `json:"open_finding_count"` // status in (new, confirmed, in_progress) + HighestSeverity string `json:"highest_severity"` // worst severity across findings + WorstSLAStatus string `json:"worst_sla_status,omitempty"` + FirstDetectedAt time.Time `json:"first_detected_at"` + LastSeenAt time.Time `json:"last_seen_at"` + + // Sample finding ID (latest) — for "view finding" deep link + SampleFindingID string `json:"sample_finding_id"` + SampleFindingStatus string `json:"sample_finding_status"` +} + // Vulnerability represents a global vulnerability (CVE). type Vulnerability struct { id shared.ID diff --git a/pkg/domain/vulnerability/repository.go b/pkg/domain/vulnerability/repository.go index cb7eab87..61ce4da0 100644 --- a/pkg/domain/vulnerability/repository.go +++ b/pkg/domain/vulnerability/repository.go @@ -230,6 +230,39 @@ type FindingRepository interface { // Security: Requires tenantID to prevent cross-tenant data access. ListByVulnerabilityID(ctx context.Context, tenantID, vulnID shared.ID, opts FindingListOptions, page pagination.Pagination) (pagination.Result[*Finding], error) + // ListAffectedAssetsByVulnerabilityID returns the distinct assets affected by + // a CVE (blast-radius reverse lookup). When includeResolved is false (default), + // only findings with status in (new, confirmed, in_progress) are counted toward + // affected assets — but the asset is included if it has at least one such open + // finding. When true, all findings are aggregated regardless of status. + // Security: tenant-scoped via tenantID. + ListAffectedAssetsByVulnerabilityID( + ctx context.Context, + tenantID, vulnID shared.ID, + includeResolved bool, + page pagination.Pagination, + ) (pagination.Result[VulnerabilityAffectedAsset], error) + + // ListActiveCVEsByTenant returns the distinct CVEs currently impacting + // assets within a tenant (the "Active CVEs" view — different from the + // global CVE catalog which is not tenant-scoped). Aggregates findings + // GROUP BY vulnerability_id. Sort: severity → KEV → EPSS → affected. + ListActiveCVEsByTenant( + ctx context.Context, + tenantID shared.ID, + filter ActiveCVEFilter, + page pagination.Pagination, + ) (pagination.Result[ActiveCVE], error) + + // GetActiveCVEStats returns aggregate counts (total, by severity, KEV, + // exploit-available) for the tenant's active CVEs. Powers the stats-card + // row above the Active CVEs table. Honours includeResolved. + GetActiveCVEStats( + ctx context.Context, + tenantID shared.ID, + includeResolved bool, + ) (*ActiveCVEStats, error) + // ListByComponentID retrieves findings for a component. // Security: Requires tenantID to prevent cross-tenant data access. ListByComponentID(ctx context.Context, tenantID, compID shared.ID, opts FindingListOptions, page pagination.Pagination) (pagination.Result[*Finding], error) diff --git a/pkg/httpsec/clientip.go b/pkg/httpsec/clientip.go new file mode 100644 index 00000000..c3ac3f88 --- /dev/null +++ b/pkg/httpsec/clientip.go @@ -0,0 +1,113 @@ +// Package httpsec — client IP extraction with trusted-proxy enforcement. +// +// SECURITY (S-4): The previous implementations of getClientIP in +// middleware/ratelimit.go and handler/local_auth_handler.go honored +// X-Real-IP / X-Forwarded-For from any peer. That let attackers spoof IPs +// to defeat per-IP rate limits and to corrupt audit logs (login attempts, +// password resets recorded under fake IPs). +// +// This package centralises the logic and only honors the proxy headers when +// the immediate TCP peer (r.RemoteAddr) sits inside a configured trusted +// CIDR. For requests originating outside that CIDR the headers are ignored +// and r.RemoteAddr wins. +package httpsec + +import ( + "net" + "net/http" + "strings" +) + +// TrustedProxySet holds a parsed allowlist of CIDR ranges that the API +// trusts to populate forwarding headers. Construct once at startup. +type TrustedProxySet struct { + cidrs []*net.IPNet +} + +// NewTrustedProxySet parses a list of CIDR strings (or bare IPs). +// Invalid entries are silently dropped; callers should validate up-front +// during config parsing if strict mode is desired. +func NewTrustedProxySet(entries []string) *TrustedProxySet { + set := &TrustedProxySet{cidrs: make([]*net.IPNet, 0, len(entries))} + for _, raw := range entries { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + // Accept bare IP by treating it as a /32 (IPv4) or /128 (IPv6) + if !strings.Contains(raw, "/") { + if ip := net.ParseIP(raw); ip != nil { + if ip.To4() != nil { + raw += "/32" + } else { + raw += "/128" + } + } else { + continue + } + } + _, ipnet, err := net.ParseCIDR(raw) + if err == nil && ipnet != nil { + set.cidrs = append(set.cidrs, ipnet) + } + } + return set +} + +// Contains reports whether ip is inside any trusted CIDR. +func (s *TrustedProxySet) Contains(ip net.IP) bool { + if s == nil || ip == nil { + return false + } + for _, c := range s.cidrs { + if c.Contains(ip) { + return true + } + } + return false +} + +// IsEmpty reports whether the allowlist contains zero CIDRs (i.e. no proxy +// is trusted, behave as if directly Internet-facing). +func (s *TrustedProxySet) IsEmpty() bool { + return s == nil || len(s.cidrs) == 0 +} + +// ClientIP returns the apparent client IP. If the immediate TCP peer +// (r.RemoteAddr) is inside the trusted-proxy set, it honors X-Real-IP and +// the leftmost X-Forwarded-For entry. Otherwise it returns the TCP peer. +// +// Returns an empty string only if r.RemoteAddr is malformed. +func ClientIP(r *http.Request, trusted *TrustedProxySet) string { + peer := remoteAddrIP(r) + if trusted != nil && !trusted.IsEmpty() && peer != nil && trusted.Contains(peer) { + // Trusted proxy in front; honor forwarding headers. + if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" { + return xrip + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // First entry = original client; subsequent entries = chain of + // proxies. We take the leftmost client-asserted value because the + // trusted proxy is what populated the header in the first place. + if idx := strings.Index(xff, ","); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + return strings.TrimSpace(xff) + } + } + if peer != nil { + return peer.String() + } + // Last-ditch fallback when RemoteAddr can't be parsed (shouldn't happen + // with net/http, but stay defensive). + return strings.TrimSpace(r.RemoteAddr) +} + +func remoteAddrIP(r *http.Request) net.IP { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr without port (rare, e.g. unix socket) — try direct parse + host = r.RemoteAddr + } + return net.ParseIP(strings.TrimSpace(host)) +} diff --git a/tests/unit/asset_group_service_test.go b/tests/unit/asset_group_service_test.go index 280e6db7..19c94184 100644 --- a/tests/unit/asset_group_service_test.go +++ b/tests/unit/asset_group_service_test.go @@ -91,7 +91,7 @@ func (m *mockAssetGroupServiceRepo) GetByTenantAndID(ctx context.Context, _, id return m.GetByID(ctx, id) } -func (m *mockAssetGroupServiceRepo) Update(_ context.Context, group *assetgroup.AssetGroup) error { +func (m *mockAssetGroupServiceRepo) Update(_ context.Context, _ shared.ID, group *assetgroup.AssetGroup) error { m.updateCalls++ if m.updateErr != nil { return m.updateErr @@ -103,7 +103,7 @@ func (m *mockAssetGroupServiceRepo) Update(_ context.Context, group *assetgroup. return nil } -func (m *mockAssetGroupServiceRepo) Delete(_ context.Context, id shared.ID) error { +func (m *mockAssetGroupServiceRepo) Delete(_ context.Context, _ shared.ID, id shared.ID) error { m.deleteCalls++ if m.deleteErr != nil { return m.deleteErr @@ -710,7 +710,7 @@ func TestDeleteAssetGroup(t *testing.T) { existing := seedAssetGroup(repo, tenantID, "To Delete", assetgroup.EnvironmentTesting, assetgroup.CriticalityLow) - err := svc.DeleteAssetGroup(context.Background(), existing.ID()) + err := svc.DeleteAssetGroup(context.Background(), tenantID.String(), existing.ID()) if err != nil { t.Fatalf("DeleteAssetGroup failed: %v", err) } @@ -729,7 +729,7 @@ func TestDeleteAssetGroup(t *testing.T) { repo := newMockAssetGroupServiceRepo() svc := newTestAssetGroupService(repo) - err := svc.DeleteAssetGroup(context.Background(), shared.NewID()) + err := svc.DeleteAssetGroup(context.Background(), shared.NewID().String(), shared.NewID()) if err == nil { t.Fatal("expected error for non-existent group") } @@ -746,7 +746,7 @@ func TestDeleteAssetGroup(t *testing.T) { existing := seedAssetGroup(repo, tenantID, "Error Delete", assetgroup.EnvironmentProduction, assetgroup.CriticalityHigh) - err := svc.DeleteAssetGroup(context.Background(), existing.ID()) + err := svc.DeleteAssetGroup(context.Background(), tenantID.String(), existing.ID()) if err == nil { t.Fatal("expected error from repo") } @@ -1334,7 +1334,7 @@ func TestBulkDeleteAssetGroups(t *testing.T) { groupIDs := []string{g1.ID().String(), g2.ID().String(), nonExistentID.String()} - deleted, err := svc.BulkDeleteAssetGroups(context.Background(), groupIDs) + deleted, err := svc.BulkDeleteAssetGroups(context.Background(), tenantID.String(), groupIDs) if err != nil { t.Fatalf("BulkDeleteAssetGroups failed: %v", err) } @@ -1353,7 +1353,7 @@ func TestBulkDeleteAssetGroups(t *testing.T) { repo := newMockAssetGroupServiceRepo() svc := newTestAssetGroupService(repo) - deleted, err := svc.BulkDeleteAssetGroups(context.Background(), []string{"bad-id", "worse-id"}) + deleted, err := svc.BulkDeleteAssetGroups(context.Background(), shared.NewID().String(), []string{"bad-id", "worse-id"}) if err != nil { t.Fatalf("BulkDeleteAssetGroups failed: %v", err) } diff --git a/tests/unit/branch_lifecycle_test.go b/tests/unit/branch_lifecycle_test.go index 34e098a5..6c28cd5e 100644 --- a/tests/unit/branch_lifecycle_test.go +++ b/tests/unit/branch_lifecycle_test.go @@ -89,6 +89,15 @@ func (m *MockFindingRepoForLifecycle) ListByVulnerabilityID(ctx context.Context, func (m *MockFindingRepoForLifecycle) ListByComponentID(ctx context.Context, tenantID, compID shared.ID, opts vulnerability.FindingListOptions, page pagination.Pagination) (pagination.Result[*vulnerability.Finding], error) { return pagination.Result[*vulnerability.Finding]{}, nil } +func (m *MockFindingRepoForLifecycle) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (m *MockFindingRepoForLifecycle) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (m *MockFindingRepoForLifecycle) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} func (m *MockFindingRepoForLifecycle) Count(ctx context.Context, filter vulnerability.FindingFilter) (int64, error) { return 0, nil } diff --git a/tests/unit/component_service_test.go b/tests/unit/component_service_test.go index dff696e6..faa19fb0 100644 --- a/tests/unit/component_service_test.go +++ b/tests/unit/component_service_test.go @@ -211,6 +211,14 @@ func (m *mockComponentRepo) GetLicenseStats(_ context.Context, _ shared.ID) ([]c return m.getLicenseStatsResult, m.getLicenseStatsErr } +func (m *mockComponentRepo) ListAssetUsage(_ context.Context, _ shared.ID, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentAssetUsage], error) { + return pagination.NewResult([]component.ComponentAssetUsage{}, 0, page), nil +} + +func (m *mockComponentRepo) ListVulnerabilities(_ context.Context, _, _ shared.ID, _ bool, page pagination.Pagination) (pagination.Result[component.ComponentVulnerability], error) { + return pagination.NewResult([]component.ComponentVulnerability{}, 0, page), nil +} + // ============================================================================= // Helper functions // ============================================================================= diff --git a/tests/unit/data_scope_test.go b/tests/unit/data_scope_test.go index 41cb1c3f..4864c050 100644 --- a/tests/unit/data_scope_test.go +++ b/tests/unit/data_scope_test.go @@ -937,3 +937,15 @@ func (m *mockFindingRepoForScope) GetByWorkItemURI(_ context.Context, _ shared.I func (m *mockFindingRepoForScope) UpdateWorkItemURIs(_ context.Context, _, _ shared.ID, _ []string) error { return nil } + +func (m *mockFindingRepoForScope) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} + +func (m *mockFindingRepoForScope) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} + +func (m *mockFindingRepoForScope) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} diff --git a/tests/unit/finding_approval_service_test.go b/tests/unit/finding_approval_service_test.go index 157f0175..63463478 100644 --- a/tests/unit/finding_approval_service_test.go +++ b/tests/unit/finding_approval_service_test.go @@ -187,6 +187,15 @@ func (m *mockFindingRepository) ListByVulnerabilityID(_ context.Context, _, _ sh func (m *mockFindingRepository) ListByComponentID(_ context.Context, _, _ shared.ID, _ vulnerability.FindingListOptions, _ pagination.Pagination) (pagination.Result[*vulnerability.Finding], error) { return pagination.Result[*vulnerability.Finding]{}, nil } +func (m *mockFindingRepository) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (m *mockFindingRepository) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (m *mockFindingRepository) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} func (m *mockFindingRepository) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return 0, nil } diff --git a/tests/unit/finding_lifecycle_activity_test.go b/tests/unit/finding_lifecycle_activity_test.go index 18be341e..186de5ba 100644 --- a/tests/unit/finding_lifecycle_activity_test.go +++ b/tests/unit/finding_lifecycle_activity_test.go @@ -105,6 +105,15 @@ func (s *stubFindingRepo) ListByVulnerabilityID(_ context.Context, _, _ shared.I func (s *stubFindingRepo) ListByComponentID(_ context.Context, _, _ shared.ID, _ vulnerability.FindingListOptions, _ pagination.Pagination) (pagination.Result[*vulnerability.Finding], error) { return pagination.Result[*vulnerability.Finding]{}, nil } +func (s *stubFindingRepo) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (s *stubFindingRepo) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (s *stubFindingRepo) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} func (s *stubFindingRepo) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return 0, nil } diff --git a/tests/unit/pentest_service_test.go b/tests/unit/pentest_service_test.go index 88f7f56f..e187da58 100644 --- a/tests/unit/pentest_service_test.go +++ b/tests/unit/pentest_service_test.go @@ -519,6 +519,16 @@ func (m *mockUnifiedFindingRepo) ListByComponentID(_ context.Context, _, _ share return pagination.Result[*vulnerability.Finding]{}, nil } +func (m *mockUnifiedFindingRepo) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (m *mockUnifiedFindingRepo) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (m *mockUnifiedFindingRepo) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} + func (m *mockUnifiedFindingRepo) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return int64(len(m.findings)), nil } diff --git a/tests/unit/scan_service_test.go b/tests/unit/scan_service_test.go index 6b95b612..7aaeed92 100644 --- a/tests/unit/scan_service_test.go +++ b/tests/unit/scan_service_test.go @@ -273,8 +273,10 @@ func (m *mockAssetGroupRepo) GetByTenantAndID(ctx context.Context, _, id shared. return m.GetByID(ctx, id) } -func (m *mockAssetGroupRepo) Update(_ context.Context, _ *assetgroup.AssetGroup) error { return nil } -func (m *mockAssetGroupRepo) Delete(_ context.Context, _ shared.ID) error { return nil } +func (m *mockAssetGroupRepo) Update(_ context.Context, _ shared.ID, _ *assetgroup.AssetGroup) error { + return nil +} +func (m *mockAssetGroupRepo) Delete(_ context.Context, _ shared.ID, _ shared.ID) error { return nil } func (m *mockAssetGroupRepo) List(_ context.Context, _ assetgroup.Filter, _ assetgroup.ListOptions, _ pagination.Pagination) (pagination.Result[*assetgroup.AssetGroup], error) { return pagination.Result[*assetgroup.AssetGroup]{}, nil } diff --git a/tests/unit/vulnerability_service_test.go b/tests/unit/vulnerability_service_test.go index 735dde07..02093e66 100644 --- a/tests/unit/vulnerability_service_test.go +++ b/tests/unit/vulnerability_service_test.go @@ -292,6 +292,18 @@ func (m *mockFindingRepo) ListByComponentID(_ context.Context, _, _ shared.ID, _ return pagination.Result[*vulnerability.Finding]{}, nil } +func (m *mockFindingRepo) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} + +func (m *mockFindingRepo) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} + +func (m *mockFindingRepo) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} + func (m *mockFindingRepo) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return int64(len(m.findings)), nil } diff --git a/tests/unit/workflow_action_handlers_test.go b/tests/unit/workflow_action_handlers_test.go index 093123f3..71cf06bf 100644 --- a/tests/unit/workflow_action_handlers_test.go +++ b/tests/unit/workflow_action_handlers_test.go @@ -100,6 +100,16 @@ func (m *wfActionMockFindingRepo) ListByComponentID(_ context.Context, _, _ shar return pagination.Result[*vulnerability.Finding]{}, nil } +func (m *wfActionMockFindingRepo) ListAffectedAssetsByVulnerabilityID(_ context.Context, _, _ shared.ID, _ bool, _ pagination.Pagination) (pagination.Result[vulnerability.VulnerabilityAffectedAsset], error) { + return pagination.Result[vulnerability.VulnerabilityAffectedAsset]{}, nil +} +func (m *wfActionMockFindingRepo) ListActiveCVEsByTenant(_ context.Context, _ shared.ID, _ vulnerability.ActiveCVEFilter, _ pagination.Pagination) (pagination.Result[vulnerability.ActiveCVE], error) { + return pagination.Result[vulnerability.ActiveCVE]{}, nil +} +func (m *wfActionMockFindingRepo) GetActiveCVEStats(_ context.Context, _ shared.ID, _ bool) (*vulnerability.ActiveCVEStats, error) { + return &vulnerability.ActiveCVEStats{BySeverity: map[string]int{}}, nil +} + func (m *wfActionMockFindingRepo) Count(_ context.Context, _ vulnerability.FindingFilter) (int64, error) { return int64(len(m.findings)), nil }