Files
rengine/shared/models/target_asset.py
T
Yogesh OjhaandClaude Opus 5 37b3f8bacd feat(targets): the target page becomes a summary
`/targets/[id]` was a header plus eight tabs, five of which rendered empty
states written before the scan pipeline existed. It is now four real tabs —
Summary, Web Assets, Intelligence, Scans — wearing the scan pages' clothes:
the same sticky counted tab strip, the same Card + PanelHead hairline-cell
panels, and a Scans tab that is the scan history page scoped to the target.

The five result dimensions become one vocabulary. shared/definitions/surface.py
and frontend config/surface.ts name what a scan produces the way the UI names
it — Web Assets, Endpoints, Services, IPs, Vulnerabilities. The scan page's tab
strip, scanCountPills, targetAssetNoun and the activity chart now read from that
one definition, so no screen says "subdomains" or "open ports" any more.

A target's surface is per-dimension "last observed", not "the latest scan".
GET /targets/{id}/summary reports what the most recent scan that actually ran
each dimension found. A dimension nothing has run reads "Not scanned", never 0 —
"we looked and found nothing" and "we never looked" are different facts. Rows
are proof a stage ran, because a cancelled run writes what it found before it is
stopped without recording success, and counts come from the tables rather than
the scans.*_found rollup, which is wrong for those runs. Every figure equals the
row count you land on: 1,379 / 7,982 / 656 / 182 / 626 against the result tabs,
and 37 sensitive, 20 critical, 19 KEV against their filters.

POST /targets/{id}/assets is a SQL rollup of the target's web assets across
every scan — first seen, last seen, scans seen in, and All/Current/Gone tabs.
It is the one asset view a scan page structurally cannot give. A running scan
can never retire an asset, and is_new needs a baseline, the same rule the
query grammars use. A 24k-asset target pages in ~90ms.

Also fixed: the headline read "No change since the previous scan" above a cell
reading "11,436" because two different comparisons shared a screen; formatSeconds
never rolled minutes into hours, so a 12-hour scan read "720m 55s" here and on
the scan history page; and truncate on an inline span in a block table cell does
not clip, so a long page title spilled across three columns.

Removes target-subdomains, subdomain-table and scan-tab-empty. Promotes
panel-head.svelte to a shared primitive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9Gw9RvaR2m6wkZZUdfb4w
2026-09-05 14:04:35 +05:30

60 lines
1.7 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel, Field
MAX_ASSET_SEARCH = 200
ASSET_STATES: tuple[str, ...] = ("all", "current", "new", "gone")
ASSET_SORTS: tuple[str, ...] = ("name", "first_seen", "last_seen", "scans", "status")
class TargetAssetRow(BaseModel):
name: str
is_active: bool = False
is_wildcard: bool = False
resolved_ips: list[str] = Field(default_factory=list)
cname: str | None = None
sources: list[str] = Field(default_factory=list)
scan_count: int = 0
first_seen: datetime
last_seen: datetime
last_scan_id: uuid.UUID
current: bool = True
is_new: bool = False
status_code: int | None = None
title: str | None = None
webserver: str | None = None
tech: list[str] = Field(default_factory=list)
ip: str | None = None
asn_org: str | None = None
is_cdn: bool = False
cdn_name: str | None = None
screenshot_path: str | None = None
class TargetAssetFilter(BaseModel):
search: str | None = Field(default=None, max_length=MAX_ASSET_SEARCH)
state: str = Field(default="all", max_length=10)
live: bool = False
sort: str = Field(default="name", max_length=20)
order: str = Field(default="asc", max_length=4)
limit: int = Field(default=50, ge=1, le=200)
offset: int = Field(default=0, ge=0, le=100_000_000)
class TargetAssetFacets(BaseModel):
total: int = 0
current: int = 0
new: int = 0
gone: int = 0
live: int = 0
baseline: bool = False
latest_scan_id: uuid.UUID | None = None
class TargetAssetPage(BaseModel):
items: list[TargetAssetRow] = Field(default_factory=list)
total: int = 0
facets: TargetAssetFacets = Field(default_factory=TargetAssetFacets)