From ebabd3e2725a9165cc006f05843c692dc394b544 Mon Sep 17 00:00:00 2001 From: Nguyen Manh <0xmanhnv@gmail.com> Date: Mon, 11 May 2026 10:24:13 +0000 Subject: [PATCH 1/5] db(migration): 000167 composite indexes for blast-radius queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two partial composite indexes that power the new blast-radius reverse-lookup endpoints: - asset_components(tenant_id, component_id) WHERE component_id IS NOT NULL Powers GET /components/{id}/assets — "which assets in tenant X use component Y?". Existing idx_asset_components_tenant only covered tenant_id alone; PG would still scan all of the tenant's components after that. Bad on tenants with >100k SBOM rows. - findings(tenant_id, vulnerability_id) WHERE vulnerability_id IS NOT NULL Powers GET /vulnerabilities/{id}/affected-assets — "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 (Log4Shell, etc.) appears across many tenants. The component-CVE direction is already covered by migration 000166. Cannot use CREATE INDEX CONCURRENTLY here — golang-migrate wraps each file in a transaction (see 000165 history for prior incident). --- .../000167_blast_radius_indexes.down.sql | 4 +++ migrations/000167_blast_radius_indexes.up.sql | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 migrations/000167_blast_radius_indexes.down.sql create mode 100644 migrations/000167_blast_radius_indexes.up.sql diff --git a/migrations/000167_blast_radius_indexes.down.sql b/migrations/000167_blast_radius_indexes.down.sql new file mode 100644 index 0000000..331ca33 --- /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 0000000..62aad2b --- /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; From 8cba3b273c84789137bb1a41390e562e52a7e1c4 Mon Sep 17 00:00:00 2001 From: Nguyen Manh <0xmanhnv@gmail.com> Date: Mon, 11 May 2026 10:24:39 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(blast-radius):=20component=E2=86=94CVE?= =?UTF-8?q?=E2=86=94asset=20reverse-lookup=20endpoints=20+=20Active=20CVEs?= =?UTF-8?q?=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five new tenant-scoped read endpoints that close the CTEM blast-radius loop. They aggregate the existing findings × assets × components data into the questions a SOC analyst actually asks ("what is affected, by what, where") rather than forcing them to navigate findings one-by-one. Endpoints (all GET, paginated, tenant-scoped via JWT): /components/{id}/assets — which tenant assets use this component? Optional at_risk_only= true filter (assets with at least one open finding for this comp). /components/{id}/vulnerabilities — which CVEs affect this component (within tenant)? One row per CVE, affected_assets_count rolled up. /vulnerabilities/active — distinct CVEs currently impacting tenant assets (forward catalog view); filters by severity, KEV, min CVSS/EPSS, exploit_available. /vulnerabilities/active/stats — tenant-wide aggregate counts for the Active CVEs page header (8 counts in 1 query via FILTER). /vulnerabilities/{id}/affected-assets — which tenant assets are affected by this CVE? Aggregates findings /vulnerabilities/cve/{cveId}/affected-assets GROUP BY asset_id; default include_resolved=false. Domain DTOs (pkg/domain/component, pkg/domain/vulnerability): ComponentAssetUsage, ComponentVulnerability, VulnerabilityAffectedAsset, ActiveCVE, ActiveCVEStats, ActiveCVEFilter Repository methods are added to the existing Repository interfaces and implemented in postgres. Each list query uses CTE + aggregate FILTER so GROUP BY happens in one round-trip; no N+1. Routing notes: - Vulnerability blast-radius routes are registered under the existing /api/v1/vulnerabilities Group (chi forbids two Group blocks on the same mount path) and apply tenantOverlayMiddlewares() per-route to upgrade them from the global-catalog group's auth-only chain to full tenant-scoped middlewares (RequireTenant + activeMembership + CSRF + readRateLimit). Helper added to routes/routes.go. - /components/{id}/{assets,vulnerabilities} sit under the existing components Group which is already tenant-scoped; literal paths are registered before /{id} so they win path matching. Mock updates (10 test files): the new methods on FindingRepository and component.Repository interfaces propagate to all implementing mocks. --- internal/app/asset/component.go | 44 ++ internal/app/finding/vulnerability_service.go | 98 ++++- internal/app/finding_service.go | 1 + .../app/ingest/processor_components_test.go | 8 + .../app/ingest/processor_findings_test.go | 9 + .../infra/http/handler/component_handler.go | 102 +++++ .../http/handler/vulnerability_handler.go | 196 ++++++++- internal/infra/http/routes/assets.go | 4 + internal/infra/http/routes/exposure.go | 21 +- internal/infra/http/routes/routes.go | 27 ++ .../infra/postgres/component_repository.go | 217 ++++++++++ internal/infra/postgres/finding_repository.go | 397 ++++++++++++++++-- pkg/domain/component/entity.go | 54 +++ pkg/domain/component/repository.go | 30 ++ pkg/domain/vulnerability/entity.go | 75 ++++ pkg/domain/vulnerability/repository.go | 33 ++ tests/unit/branch_lifecycle_test.go | 9 + tests/unit/component_service_test.go | 8 + tests/unit/data_scope_test.go | 12 + tests/unit/finding_approval_service_test.go | 9 + tests/unit/finding_lifecycle_activity_test.go | 9 + tests/unit/pentest_service_test.go | 10 + tests/unit/vulnerability_service_test.go | 12 + tests/unit/workflow_action_handlers_test.go | 10 + 24 files changed, 1352 insertions(+), 43 deletions(-) diff --git a/internal/app/asset/component.go b/internal/app/asset/component.go index c3ad648..209af04 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/finding/vulnerability_service.go b/internal/app/finding/vulnerability_service.go index 9ce46f8..3c8e958 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 12ea85e..cadb69b 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 6d4e119..0776802 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 2a4e302..c67c70d 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/infra/http/handler/component_handler.go b/internal/infra/http/handler/component_handler.go index c7119ce..5b35977 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/vulnerability_handler.go b/internal/infra/http/handler/vulnerability_handler.go index f8bb7aa..894d95f 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/routes/assets.go b/internal/infra/http/routes/assets.go index e3cdd18..1580917 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 c974651..6ce698b 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 9443905..29ee4e9 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/postgres/component_repository.go b/internal/infra/postgres/component_repository.go index 208487c..077e67a 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 6598bd9..9d4f0d3 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/pkg/domain/component/entity.go b/pkg/domain/component/entity.go index a55b7e3..1b5253b 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 5a771ae..b41b5b6 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 88a640f..c1b1564 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 cb7eab8..61ce4da 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/tests/unit/branch_lifecycle_test.go b/tests/unit/branch_lifecycle_test.go index 34e098a..6c28cd5 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 dff696e..faa19fb 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 41cb1c3..4864c05 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 157f017..6346347 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 18be341..186de5b 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 88f7f56..e187da5 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/vulnerability_service_test.go b/tests/unit/vulnerability_service_test.go index 735dde0..02093e6 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 093123f..71cf06b 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 } From b03c31194b57dd28fc4354efe0a5ff15335bc8b2 Mon Sep 17 00:00:00 2001 From: Nguyen Manh <0xmanhnv@gmail.com> Date: Mon, 11 May 2026 10:25:03 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(security):=20close=204=20audit=20findin?= =?UTF-8?q?gs=20=E2=80=94=20tenant=20scoping,=20rotation,=20trusted=20prox?= =?UTF-8?q?ies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch fix for the security tasks raised by /ultrareview: S-1 — AssetGroup tenant scoping (IDOR) Repository.Update() and Delete() now require tenantID and emit WHERE tenant_id = ? in SQL. Previously a member of tenant A could mutate or delete tenant B's group by guessing the UUID. Service + handler + bulk-delete callers updated; mocks rewired. S-2 — Branch handler tenant validation (IDOR) Branch endpoints accept a {repository_id} URL segment but never verified the repo belongs to the caller's tenant. New helper ensureRepoOwnedByTenant() runs at the top of every branch handler (List/Get/Create/Update/Delete/SetDefault/GetDefault/Compare). It delegates to AssetService.GetAsset which is already tenant-scoped. On miss: 404 (never 403, to avoid leaking existence). Wired via SetAssetService at server boot. S-3-rotate — ExchangeToken refresh rotation /auth/exchange now rotates the refresh token (MarkUsed + new token in same family) matching the pattern used by Refresh and CreateFirstTeam. Without rotation a stolen refresh token stayed valid for the full window; with rotation, theft is detected on the next legitimate use (token-already-used). Handler persists the new token via httpOnly cookie; body intentionally omits it (S-3). S-4 — Trusted-proxy guard in getClientIP Centralizes IP attribution behind httpsec.ClientIP() with a TrustedProxySet (CIDR allowlist) wired from config.Server.TrustedProxies. When the list is empty: only r.RemoteAddr is honored (correct for direct-Internet deployments). With CIDRs (K8s pod range, LB subnet): X-Forwarded-For and X-Real-IP are honored only from peers in the range. Closes the spoof-via-XFF abuse on rate limiting and audit logs that was possible with the previous trust-everything path. --- cmd/server/handlers.go | 7 +- internal/app/asset/group.go | 19 +-- internal/app/auth/service.go | 62 ++++++++-- internal/config/config.go | 19 ++- .../infra/http/handler/asset_group_handler.go | 4 +- internal/infra/http/handler/branch_handler.go | 65 +++++++++- .../infra/http/handler/local_auth_handler.go | 74 +++++++----- internal/infra/http/middleware/ratelimit.go | 44 ++++--- .../infra/http/middleware/unified_auth.go | 93 ++++++-------- internal/infra/http/server.go | 12 ++ .../infra/postgres/asset_group_repository.go | 36 +++--- pkg/domain/assetgroup/repository.go | 8 +- pkg/httpsec/clientip.go | 113 ++++++++++++++++++ tests/unit/asset_group_service_test.go | 14 +-- tests/unit/scan_service_test.go | 6 +- 15 files changed, 408 insertions(+), 168 deletions(-) create mode 100644 pkg/httpsec/clientip.go diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go index 2cc5eac..09309bc 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/group.go b/internal/app/asset/group.go index 2de9a03..963ce9a 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 9121828..4166d94 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/config/config.go b/internal/config/config.go index 5aef017..8e3db3b 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 c47e3c2..81f0600 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 5f43ce4..d7c92c8 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/local_auth_handler.go b/internal/infra/http/handler/local_auth_handler.go index 4dd09ee..a56fc6d 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/middleware/ratelimit.go b/internal/infra/http/middleware/ratelimit.go index a5dd794..aa690ee 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 374b0be..d72e30e 100644 --- a/internal/infra/http/middleware/unified_auth.go +++ b/internal/infra/http/middleware/unified_auth.go @@ -56,9 +56,18 @@ 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). Use extractTokenWithQueryParam below for +// the few SSE routes only — never on the global UnifiedAuth path. func extractToken(r *http.Request) string { // 1. Try Authorization header first (standard API auth) authHeader := r.Header.Get("Authorization") @@ -69,22 +78,26 @@ 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 "" } +// extractTokenWithQueryParam is the SSE-only variant that ALSO accepts a +// `?token=` query parameter. Use this ONLY on EventSource routes — never +// register it on a route group that includes mutating endpoints. +func extractTokenWithQueryParam(r *http.Request) string { + if t := extractToken(r); t != "" { + return t + } + return r.URL.Query().Get("token") +} + // UnifiedAuth creates an authentication middleware that supports both local and OIDC authentication. // The middleware tries to validate tokens based on the configured auth provider: // - "local": Only validates local JWT tokens @@ -538,50 +551,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/server.go b/internal/infra/http/server.go index 9951e48..f720c9a 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 a0d0d8e..55ed0da 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/pkg/domain/assetgroup/repository.go b/pkg/domain/assetgroup/repository.go index 745cd28..a227373 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/httpsec/clientip.go b/pkg/httpsec/clientip.go new file mode 100644 index 0000000..c3ac3f8 --- /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 280e6db..19c9418 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/scan_service_test.go b/tests/unit/scan_service_test.go index 6b95b61..7aaeed9 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 } From 4320d8e02ef6a5294570fca92142d42e3f973876 Mon Sep 17 00:00:00 2001 From: Nguyen Manh <0xmanhnv@gmail.com> Date: Mon, 11 May 2026 10:25:44 +0000 Subject: [PATCH 4/5] chore(seed): demo data for blast-radius UI (50 CVEs / 6 assets / ~85 components / ~80 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL seed file that populates a single tenant (matched by name/slug LIKE '%org%') with realistic data so the new blast-radius views have something to render in dev and demos. Layout: - 50 vulnerabilities (real CVEs from 2021-2024 mix: KEV, Spring4Shell, Log4Shell, XZ backdoor, regreSSHion, plus per-ecosystem npm/pypi/ maven/go/nuget findings) - 6 assets covering the common asset types (web app, api, service, mobile, repository, kubernetes_cluster) - ~85 global components in components(purl, name, version, ecosystem) - License junction rows so /components/licenses page shows distribution - ~80 asset_components rows (duplicated reasonably across assets so blast-radius reverse lookups have something to aggregate) - ~38 findings linking asset × component × CVE - Final UPDATE sweeps asset_components.{vulnerability_count, has_known_vulnerabilities, highest_severity} from the new findings and refreshes components.vulnerability_count globally Idempotent: every INSERT uses ON CONFLICT DO NOTHING; safe to re-run. Run: psql ... -f migrations/seed/seed_components_demo.sql --- migrations/seed/seed_components_demo.sql | 878 +++++++++++++++++++++++ 1 file changed, 878 insertions(+) create mode 100644 migrations/seed/seed_components_demo.sql diff --git a/migrations/seed/seed_components_demo.sql b/migrations/seed/seed_components_demo.sql new file mode 100644 index 0000000..aa93a38 --- /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 From 1b2aa4f736189018deb83aa99099c67381c831b1 Mon Sep 17 00:00:00 2001 From: Nguyen Manh <0xmanhnv@gmail.com> Date: Mon, 11 May 2026 10:42:42 +0000 Subject: [PATCH 5/5] fix(lint): drop dead extractTokenWithQueryParam + correct doc-comment subject Two staticcheck failures in middleware/ that broke CI on the feat/blast-radius-views branch: unified_auth.go (U1000) extractTokenWithQueryParam was added by S-5 as the SSE-only escape hatch when removing query-param fallback from extractToken. The codebase has since migrated all streaming endpoints to WebSocket (which forwards cookies during the upgrade handshake) so the helper has zero callers and stays unused. Removed it; updated the surrounding comments so the rationale for not reintroducing query-param auth is preserved without referencing a function that no longer exists. Also corrected UnifiedAuth's doc-comment which still listed query-param as extraction step #2. bodylimit.go (ST1020) Comment block on HandleBodyLimitError started with "BodyLimitHandler" (the type that was renamed to a function). Brought the godoc subject in line with the actual symbol name. No behavior change. Build + staticcheck clean. --- internal/infra/http/middleware/bodylimit.go | 2 +- .../infra/http/middleware/unified_auth.go | 20 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/internal/infra/http/middleware/bodylimit.go b/internal/infra/http/middleware/bodylimit.go index 3524fcd..ddb10fa 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/unified_auth.go b/internal/infra/http/middleware/unified_auth.go index d72e30e..ab90f04 100644 --- a/internal/infra/http/middleware/unified_auth.go +++ b/internal/infra/http/middleware/unified_auth.go @@ -66,8 +66,10 @@ const DefaultAccessTokenCookieName = "auth_token" // - 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). Use extractTokenWithQueryParam below for -// the few SSE routes only — never on the global UnifiedAuth path. +// 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") @@ -88,25 +90,15 @@ func extractToken(r *http.Request) string { return "" } -// extractTokenWithQueryParam is the SSE-only variant that ALSO accepts a -// `?token=` query parameter. Use this ONLY on EventSource routes — never -// register it on a route group that includes mutating endpoints. -func extractTokenWithQueryParam(r *http.Request) string { - if t := extractToken(r); t != "" { - return t - } - return r.URL.Query().Get("token") -} - // UnifiedAuth creates an authentication middleware that supports both local and OIDC authentication. // The middleware tries to validate tokens based on the configured auth provider: // - "local": Only validates local JWT tokens // - "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) {