Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1729,6 +1729,11 @@ func main() {
// must be registered BEFORE /assets/:id, or "statistics" is parsed as an
// asset UUID and the route answers 400 for a request that is perfectly valid.
protected.Get("/assets/statistics", assetRead, assetHandler.GetAssetStatistics)
// #906 — the inventory's vulnerability columns, per asset. Its own guard:
// reading assets does not grant reading their vulnerabilities.
assetExposureHandler := handlers.NewAssetExposureHandler(
vulnapp.NewAssetExposureUseCase(repository.NewGormVulnerabilityRepository(database.DB)))
protected.Get("/assets/exposure", middleware.RequirePermission("vulnerabilities:read"), assetExposureHandler.List)
// #861 — CSV import of the inventory: every row or none, one transaction,
// and the plan cap checked against the whole file.
assetImportHandler := handlers.NewAssetImportHandler(
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/application/vulnerability/asset_exposure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package vulnerability

import (
"context"
"sort"

"github.com/google/uuid"

"github.com/opendefender/openrisk/internal/domain"
)

// ExposureReader counts a tenant's vulnerabilities per asset in one query.
type ExposureReader interface {
ExposureByAsset(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]domain.AssetExposure, error)
}

// AssetExposureUseCase serves the inventory's vulnerability columns (#906):
// open findings, open KEV findings and last detection, per asset. It is its
// own read, guarded by vulnerabilities:read, so a member who may read assets
// but not vulnerabilities does not learn them through the inventory.
type AssetExposureUseCase struct {
reader ExposureReader
}

// NewAssetExposureUseCase builds the use case.
func NewAssetExposureUseCase(r ExposureReader) *AssetExposureUseCase {
return &AssetExposureUseCase{reader: r}
}

// Execute returns one entry per asset that has, or had, a finding, ordered by
// asset id so the answer is stable. An asset absent from the list has none.
func (uc *AssetExposureUseCase) Execute(ctx context.Context, tenantID uuid.UUID) ([]domain.AssetExposure, error) {
if tenantID == uuid.Nil {
return nil, domain.NewForbiddenError("no organization in the session")
}
byAsset, err := uc.reader.ExposureByAsset(ctx, tenantID)
if err != nil {
return nil, err
}
out := make([]domain.AssetExposure, 0, len(byAsset))
for _, e := range byAsset {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].AssetID.String() < out[j].AssetID.String() })
return out, nil
}
65 changes: 65 additions & 0 deletions backend/internal/application/vulnerability/asset_exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package vulnerability

import (
"context"
"errors"
"testing"

"github.com/google/uuid"

"github.com/opendefender/openrisk/internal/domain"
)

type fakeAssetExposure struct {
byTenant map[uuid.UUID]map[uuid.UUID]domain.AssetExposure
asked uuid.UUID
}

func (f *fakeAssetExposure) ExposureByAsset(_ context.Context, tenantID uuid.UUID) (map[uuid.UUID]domain.AssetExposure, error) {
f.asked = tenantID
return f.byTenant[tenantID], nil
}

func TestAssetExposure_Success(t *testing.T) {
tenant, a, b := uuid.New(), uuid.New(), uuid.New()
f := &fakeAssetExposure{byTenant: map[uuid.UUID]map[uuid.UUID]domain.AssetExposure{
tenant: {
a: {AssetID: a, OpenVulnerabilities: 2, KEVOpen: 1},
b: {AssetID: b, OpenVulnerabilities: 0},
},
}}
got, err := NewAssetExposureUseCase(f).Execute(context.Background(), tenant)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || f.asked != tenant {
t.Fatalf("got %d entries, asked tenant %v", len(got), f.asked)
}
if got[0].AssetID.String() > got[1].AssetID.String() {
t.Error("entries are not in a stable order")
}
}

// An inventory with no finding is an empty list, not an error.
func TestAssetExposure_NotFound(t *testing.T) {
got, err := NewAssetExposureUseCase(&fakeAssetExposure{}).Execute(context.Background(), uuid.New())
if err != nil || len(got) != 0 {
t.Fatalf("got %v, %v", got, err)
}
}

func TestAssetExposure_Unauthorized(t *testing.T) {
f := &fakeAssetExposure{}
_, err := NewAssetExposureUseCase(f).Execute(context.Background(), uuid.Nil)
if !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("a session without an organization must be refused, got %v", err)
}
if f.asked != uuid.Nil {
t.Error("the reader was queried without a tenant")
}
}
11 changes: 11 additions & 0 deletions backend/internal/domain/vulnerability.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,14 @@ func (v *Vulnerability) BeforeSave(_ *gorm.DB) error {
v.SLADueAt = VulnSLADue(v.Severity, v.KEV, v.FirstSeen)
return nil
}

// AssetExposure is one asset's vulnerability picture for the inventory (#906):
// unresolved findings, how many are known-exploited (KEV), and when a scan last
// reported anything on it. LastDetectedAt is nil when no scan ever did; it is
// not a "last scanned" date, since a scan that finds nothing leaves no row.
type AssetExposure struct {
AssetID uuid.UUID `json:"asset_id"`
OpenVulnerabilities int `json:"open_vulnerabilities"`
KEVOpen int `json:"kev_open"`
LastDetectedAt *time.Time `json:"last_detected_at"`
}
31 changes: 31 additions & 0 deletions backend/internal/handler/asset_exposure_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package handler

import (
"github.com/gofiber/fiber/v2"

"github.com/opendefender/openrisk/internal/application/vulnerability"
)

// AssetExposureHandler serves GET /assets/exposure (#906).
type AssetExposureHandler struct {
uc *vulnerability.AssetExposureUseCase
}

// NewAssetExposureHandler builds the handler.
func NewAssetExposureHandler(uc *vulnerability.AssetExposureUseCase) *AssetExposureHandler {
return &AssetExposureHandler{uc: uc}
}

// List returns open vulnerabilities, open KEV and last detection per asset.
func (h *AssetExposureHandler) List(c *fiber.Ctx) error {
out, err := h.uc.Execute(c.UserContext(), tenantID(c))
if err != nil {
return writeAppError(c, err)
}
return c.JSON(fiber.Map{"items": out})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 (see LICENSE).

package repository

import (
"context"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"

"github.com/opendefender/openrisk/internal/domain"
)

func seedAssetVuln(t *testing.T, db *gorm.DB, tenant, asset uuid.UUID, kev bool, status domain.VulnStatus, seen time.Time) {
t.Helper()
a := asset
require.NoError(t, db.Create(&domain.Vulnerability{
ID: uuid.New(), TenantID: tenant, AssetID: &a,
CVEID: "CVE-2026-" + uuid.NewString()[:5], Title: "finding",
Severity: domain.VulnSeverity("high"), Status: status, KEV: kev, LastSeen: seen,
}).Error)
}

// The inventory's vulnerability columns (#906): unresolved findings, unresolved
// KEV findings and the last detection per asset, for one tenant only.
func TestExposureByAsset_Success(t *testing.T) {
repo, db := setupVulnStatsRepo(t)
tenantA, tenantB := uuid.New(), uuid.New()
core, clean := uuid.New(), uuid.New()
t1 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)

seedAssetVuln(t, db, tenantA, core, true, domain.VulnStatusOpen, t1)
seedAssetVuln(t, db, tenantA, core, false, domain.VulnStatusTriaged, t1)
seedAssetVuln(t, db, tenantA, core, true, domain.VulnStatusRemediated, t2)
seedAssetVuln(t, db, tenantA, clean, false, domain.VulnStatusFalsePositive, t1)
seedAssetVuln(t, db, tenantB, core, true, domain.VulnStatusOpen, t2)

got, err := repo.ExposureByAsset(context.Background(), tenantA)
require.NoError(t, err)
require.Len(t, got, 2)

c := got[core]
assert.Equal(t, 2, c.OpenVulnerabilities, "remediated findings are not open")
assert.Equal(t, 1, c.KEVOpen, "a remediated KEV flaw no longer counts")
require.NotNil(t, c.LastDetectedAt)
assert.True(t, c.LastDetectedAt.Equal(t2), "last detection includes resolved findings, got %v", c.LastDetectedAt)

assert.Equal(t, 0, got[clean].OpenVulnerabilities)
assert.Equal(t, 0, got[clean].KEVOpen)
}

func TestExposureByAsset_NotFound(t *testing.T) {
repo, _ := setupVulnStatsRepo(t)
got, err := repo.ExposureByAsset(context.Background(), uuid.New())
require.NoError(t, err)
assert.Empty(t, got)
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,68 @@ func (r *GormVulnerabilityRepository) CountOpenByAsset(ctx context.Context, tena
return out, nil
}

// ExposureByAsset returns, per asset, its unresolved vulnerabilities, how many
// of those are on the CISA KEV list, and the last time any scan reported a
// finding on it (resolved ones included). One grouped query for the whole
// inventory (#906). Concrete method, off the domain port, like CountOpenByAsset.
func (r *GormVulnerabilityRepository) ExposureByAsset(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]domain.AssetExposure, error) {
closed := []domain.VulnStatus{
domain.VulnStatusRemediated,
domain.VulnStatusAccepted,
domain.VulnStatusFalsePositive,
}
type row struct {
AssetID uuid.UUID
Open int
KEVOpen int
// MAX() of a timestamp loses its column type on SQLite, so the query
// casts it to text on every database and it is parsed here.
LastDetected *string
}
var rows []row
err := r.db.WithContext(ctx).Model(&domain.Vulnerability{}).
Select(`asset_id,
COUNT(*) FILTER (WHERE status NOT IN ?) AS open,
COUNT(*) FILTER (WHERE status NOT IN ? AND kev) AS kev_open,
CAST(MAX(last_seen) AS TEXT) AS last_detected`, closed, closed).
Where("tenant_id = ? AND asset_id IS NOT NULL", tenantID).
Group("asset_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]domain.AssetExposure, len(rows))
for _, rw := range rows {
out[rw.AssetID] = domain.AssetExposure{
AssetID: rw.AssetID,
OpenVulnerabilities: rw.Open,
KEVOpen: rw.KEVOpen,
LastDetectedAt: aggregateTime(rw.LastDetected),
}
}
return out, nil
}

// aggregateTime parses a timestamp rendered as text by Postgres
// ("2026-10-06 09:00:00+00") or SQLite ("2026-10-06 09:00:00+00:00"); nil when
// absent or unreadable.
func aggregateTime(v *string) *time.Time {
if v == nil || *v == "" {
return nil
}
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999-07",
"2006-01-02 15:04:05.999999999",
} {
if p, err := time.Parse(layout, *v); err == nil {
return &p
}
}
return nil
}

func (r *GormVulnerabilityRepository) Stats(ctx context.Context, tenantID uuid.UUID) (*domain.VulnStats, error) {
stats := &domain.VulnStats{
BySeverity: map[string]int64{},
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/security/isolation/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,8 @@ var decisions = []Decision{
"the inventory list, one of the largest tenant collections in the product. ListAssetsUseCase.Search delegates to GormAssetRepository.List, which is `Where(\"tenant_id = ?\")`; repository/gorm_asset_repository_test TestAssetRepository_List_ScopedToTenant seeds both tenants and asserts each sees only its own. The category and attr.* filters are applied in memory AFTER that scoped read, so they can only narrow it"},
{"/api/v1/assets/statistics", Covered,
"repository/gorm_asset_statistics_test TestAssetStatistics_IsTenantScoped, plus TestAssetStatistics_RefusesWithoutTenant — the repository refuses uuid.Nil outright rather than emitting a predicate that matches nothing"},
{"/api/v1/assets/exposure", Covered,
"#906, the inventory's vulnerability columns. AssetExposureUseCase refuses uuid.Nil, then GormVulnerabilityRepository.ExposureByAsset groups one `Where(\"tenant_id = ? AND asset_id IS NOT NULL\")` query; repository/gorm_vulnerability_exposure_test TestExposureByAsset_Success seeds a finding on the same asset id in a second tenant and asserts it is not counted"},
{"/api/v1/saved-views", Covered,
"repository/gorm_saved_view_repository_test TestSavedViewRepo_ListVisible_IsTenantAndVisibilityScoped seeds views in two tenants and asserts the listing returns only the caller's tenant, and within it only the caller's own views plus the tenant's shared ones; the caller's tenant and user id come from the JWT (savedViewCaller), never from the query string, which carries only table_id"},
{"/api/v1/asset-dependencies", Covered,
Expand Down
41 changes: 41 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,39 @@ paths:
'404':
description: Risk not found

/assets/exposure:
get:
tags:
- Assets
summary: Vulnerability exposure per asset
description: >
For each asset with a finding: unresolved vulnerabilities, unresolved
ones on the CISA KEV list, and the last time a scan reported anything
on it (resolved findings included). An asset absent from the list has
no finding. Guarded by `vulnerabilities:read`, separately from the
inventory, so reading assets does not reveal their vulnerabilities.
`last_detected_at` is not a last-scan date: a scan that finds nothing
leaves no row.
operationId: listAssetExposure
security:
- bearerAuth: []
responses:
'200':
description: Exposure per asset
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/AssetExposure' }
'401':
description: Unauthorized
'403':
description: Missing `vulnerabilities:read`, or no organization in the session

/timeline:
get:
tags:
Expand Down Expand Up @@ -6415,6 +6448,14 @@ components:
code:
type: string
enum: [wrong_password, wrong_code, mfa_required_by_role, no_local_password, not_enrolled, too_many_attempts, rejected, internal]
AssetExposure:
type: object
required: [asset_id, open_vulnerabilities, kev_open, last_detected_at]
properties:
asset_id: { type: string, format: uuid }
open_vulnerabilities: { type: integer }
kev_open: { type: integer }
last_detected_at: { type: string, format: date-time, nullable: true }
TimelineActor:
type: object
properties:
Expand Down
Loading
Loading