mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-01 22:24:53 +02:00
feat(whats-new): activity lanes, expandable events and whole-run counts
The summary bar draws one lane per event type over the last 30 days, 14 on a phone: a dot per day sized by its count, findings coloured by the worst severity. Hover shows the day, a click or a drag picks the days, the current period is tinted and Caught up is a dashed line. Every event starts collapsed and expands in place. A rescan lists its new critical and high findings, each opening its sheet. A bounty event lists its rows with their actions. Hover is a background, never an underline. The period now picks whole runs, so a run's count is always what its link opens. Days follow the viewer's time zone across the day filter, the lanes and the headings. The badge counts events, a triaged finding also hides its cluster replays, and a scan still running is dated by its newest finding. Claude-Session: https://claude.ai/code/session_015EWyHNJRpxkvhtapY3wNGx
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +12,7 @@ from app.services.instance_settings import InstanceSettingsService
|
||||
from app.services.whats_new import WhatsNewService
|
||||
from shared.definitions.mode_features import CAP_BOUNTY_PROGRAMS, has_capability
|
||||
from shared.definitions.whats_new import (
|
||||
DEFAULT_ZONE,
|
||||
KIND_ORDER,
|
||||
MAX_TEXT_FILTER,
|
||||
NEW_WINDOWS,
|
||||
@@ -32,6 +34,16 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
RINGS = {r.value for r in ProgramRing}
|
||||
|
||||
|
||||
def _zone(tz: str) -> None:
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown time zone {tz}.",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _bounty(session: AsyncSession) -> bool:
|
||||
settings = await InstanceSettingsService(session).get_or_create()
|
||||
return has_capability(settings.mode, CAP_BOUNTY_PROGRAMS)
|
||||
@@ -53,7 +65,9 @@ async def whats_new(
|
||||
kinds: Annotated[str | None, Query(max_length=160)] = None,
|
||||
ring: Annotated[str, Query(max_length=16)] = ProgramRing.ENGAGED.value,
|
||||
q: Annotated[str | None, Query(max_length=MAX_TEXT_FILTER)] = None,
|
||||
tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE,
|
||||
) -> NewFeed:
|
||||
_zone(tz)
|
||||
if window is not None and window not in NEW_WINDOWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
@@ -88,6 +102,7 @@ async def whats_new(
|
||||
kinds=wanted,
|
||||
ring=ring,
|
||||
q=q,
|
||||
tz=tz,
|
||||
bounty=await _bounty(session),
|
||||
)
|
||||
|
||||
@@ -103,7 +118,9 @@ async def whats_new_visual(
|
||||
day_to: date | None = None,
|
||||
target_id: UUID | None = None,
|
||||
q: Annotated[str | None, Query(max_length=MAX_TEXT_FILTER)] = None,
|
||||
tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE,
|
||||
) -> VisualFeed:
|
||||
_zone(tz)
|
||||
if window is not None and window not in NEW_WINDOWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
@@ -123,6 +140,7 @@ async def whats_new_visual(
|
||||
day_to=day_to,
|
||||
target_id=target_id,
|
||||
q=q,
|
||||
tz=tz,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,9 +150,11 @@ async def whats_new_unseen(
|
||||
service: ServiceDep,
|
||||
session: SessionDep,
|
||||
project_id: Annotated[UUID, Query(description="Project ID")],
|
||||
tz: Annotated[str, Query(max_length=64)] = DEFAULT_ZONE,
|
||||
) -> NewUnseen:
|
||||
_zone(tz)
|
||||
count = await service.unseen(
|
||||
project_id, current_user.id, bounty=await _bounty(session)
|
||||
project_id, current_user.id, bounty=await _bounty(session), tz=tz
|
||||
)
|
||||
return NewUnseen(count=count, since=await service.mark(current_user.id, project_id))
|
||||
|
||||
|
||||
+137
-114
@@ -3,17 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Uuid,
|
||||
case,
|
||||
cast,
|
||||
column,
|
||||
exists,
|
||||
func,
|
||||
literal,
|
||||
not_,
|
||||
or_,
|
||||
select,
|
||||
@@ -32,26 +33,28 @@ from shared.definitions.bounty_programs import (
|
||||
)
|
||||
from shared.definitions.vulnerabilities import (
|
||||
SEVERITY_RANK,
|
||||
SUPPRESSED_STATES,
|
||||
)
|
||||
from shared.definitions.watch import ARRIVED_STATES, CT_SOURCE, WatchHostState
|
||||
from shared.definitions.whats_new import (
|
||||
ALERT_SEVERITIES,
|
||||
BOUNTY_ROWS_PER_SECTION,
|
||||
DEFAULT_NEW_WINDOW,
|
||||
DEFAULT_ZONE,
|
||||
ENGAGED_EVENTS,
|
||||
EVENT_KIND,
|
||||
EVIDENCE_FINDINGS,
|
||||
GONE_KINDS,
|
||||
GRID_DAYS,
|
||||
GROUP_LIMIT,
|
||||
KIND_ORDER,
|
||||
NEW_WINDOWS,
|
||||
PROGRAM_EVENTS,
|
||||
SCOPE_SOURCE,
|
||||
SOURCE_LABELS,
|
||||
TERMS_KINDS,
|
||||
VISUAL_DISTANCE,
|
||||
VISUAL_FIELDS,
|
||||
VISUAL_LIMIT,
|
||||
WATCH_SOURCE,
|
||||
Fact,
|
||||
NewBasis,
|
||||
NewKind,
|
||||
@@ -65,7 +68,7 @@ from shared.models.bounty_program import BountyEventRow, BountyProgram, BountySc
|
||||
from shared.models.scan import Scan
|
||||
from shared.models.subdomain import Subdomain
|
||||
from shared.models.target import Target, TargetOrganization
|
||||
from shared.models.vulnerability import Vulnerability, VulnerabilityTriage
|
||||
from shared.models.vulnerability import Vulnerability
|
||||
from shared.models.watch import ProgramWatch, UserMark, WatchHost
|
||||
from shared.models.whats_new import (
|
||||
NewDay,
|
||||
@@ -78,28 +81,11 @@ from shared.models.whats_new import (
|
||||
VisualFeed,
|
||||
VisualPair,
|
||||
)
|
||||
from shared.services.asset_query import vuln_suppressed
|
||||
from shared.services.asset_query.tokens import token
|
||||
from shared.services.scan_scope import census_only
|
||||
from shared.utils.datetime import utc_now
|
||||
|
||||
TERMS_KINDS = frozenset({NewKind.BOUNTY_TABLE.value, NewKind.RULES.value})
|
||||
WATCH_SOURCE = "watch"
|
||||
SCOPE_SOURCE = "scope"
|
||||
|
||||
|
||||
def _severity_rank():
|
||||
return case(SEVERITY_RANK, value=Vulnerability.severity, else_=len(SEVERITY_RANK))
|
||||
|
||||
|
||||
def _suppressed():
|
||||
return exists(
|
||||
select(1).where(
|
||||
VulnerabilityTriage.target_id == Vulnerability.target_id,
|
||||
VulnerabilityTriage.fingerprint == Vulnerability.fingerprint,
|
||||
VulnerabilityTriage.state.in_(SUPPRESSED_STATES),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _seen_earlier():
|
||||
earlier = aliased(Vulnerability)
|
||||
@@ -113,25 +99,20 @@ def _seen_earlier():
|
||||
)
|
||||
|
||||
|
||||
def _new_conds(baseline: list[UUID], q: str | None) -> list:
|
||||
base = [
|
||||
def _new_conds(baseline: list[UUID]) -> list:
|
||||
return [
|
||||
Vulnerability.scan_id.in_(baseline),
|
||||
Vulnerability.severity.in_(ALERT_SEVERITIES),
|
||||
not_(_seen_earlier()),
|
||||
not_(_suppressed()),
|
||||
not_(vuln_suppressed(tuple(baseline))),
|
||||
]
|
||||
if q:
|
||||
base.append(
|
||||
_text_match(
|
||||
(
|
||||
Vulnerability.template_name,
|
||||
Vulnerability.template_id,
|
||||
Vulnerability.host,
|
||||
),
|
||||
q,
|
||||
)
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
def _finding_text(q: str):
|
||||
return _text_match(
|
||||
(Vulnerability.template_name, Vulnerability.template_id, Vulnerability.host),
|
||||
q,
|
||||
)
|
||||
|
||||
|
||||
def _text_match(columns, q: str):
|
||||
@@ -159,8 +140,16 @@ def _visual_value(value):
|
||||
return value or None
|
||||
|
||||
|
||||
def _day_start(d: date) -> datetime:
|
||||
return datetime.combine(d, time.min, tzinfo=UTC)
|
||||
def _day_start(d: date, zone: ZoneInfo) -> datetime:
|
||||
return datetime.combine(d, time.min, tzinfo=zone)
|
||||
|
||||
|
||||
def _day_of(at: datetime, zone: ZoneInfo) -> str:
|
||||
return at.astimezone(zone).date().isoformat()
|
||||
|
||||
|
||||
def _local_date(column, zone: ZoneInfo):
|
||||
return func.date(func.timezone(literal(zone.key, literal_execute=True), column))
|
||||
|
||||
|
||||
class _Check:
|
||||
@@ -170,6 +159,7 @@ class _Check:
|
||||
self.kev = 0
|
||||
self.severities: dict[str, int] = defaultdict(int)
|
||||
self.runs: dict[UUID, int] = defaultdict(int)
|
||||
self.kev_runs: dict[UUID, int] = defaultdict(int)
|
||||
|
||||
def rank(self):
|
||||
worst = min(
|
||||
@@ -180,12 +170,12 @@ class _Check:
|
||||
|
||||
|
||||
class _Groups:
|
||||
def __init__(self):
|
||||
def __init__(self, zone: ZoneInfo):
|
||||
self.zone = zone
|
||||
self.by_id: dict[str, NewGroup] = {}
|
||||
self.counts: dict[str, int] = dict.fromkeys(KIND_ORDER, 0)
|
||||
self.facts: dict[str, dict[str, int]] = defaultdict(dict)
|
||||
self.daily: dict[str, dict[str, int]] = defaultdict(dict)
|
||||
self.first_runs = 0
|
||||
self.visual = 0
|
||||
self.checks: dict[str, _Check] = {}
|
||||
|
||||
@@ -268,22 +258,37 @@ class WhatsNewService:
|
||||
bounty: bool = True,
|
||||
rows: bool = True,
|
||||
grid: bool = True,
|
||||
visual: bool = True,
|
||||
tz: str = DEFAULT_ZONE,
|
||||
) -> NewFeed:
|
||||
now = utc_now()
|
||||
zone = ZoneInfo(tz)
|
||||
marked_at = await self.mark(user_id, project_id)
|
||||
basis, cutoff, until, window = self._period(
|
||||
now, marked_at, since, window, day_from, day_to
|
||||
now, marked_at, since, window, day_from, day_to, zone
|
||||
)
|
||||
|
||||
grid_start = _day_start(now.date() - timedelta(days=GRID_DAYS - 1))
|
||||
grid_start = _day_start(
|
||||
now.astimezone(zone).date() - timedelta(days=GRID_DAYS - 1), zone
|
||||
)
|
||||
range_start = min(cutoff, grid_start) if grid else cutoff
|
||||
wanted = set(kinds) if kinds else set(KIND_ORDER)
|
||||
q = (q or "").strip() or None
|
||||
|
||||
program = await self._program(platform, handle) if bounty else None
|
||||
out = _Groups(zone)
|
||||
if platform and handle and program is None:
|
||||
return NewFeed(
|
||||
since=cutoff,
|
||||
until=until,
|
||||
basis=basis,
|
||||
marked_at=marked_at,
|
||||
window=window if basis == NewBasis.WINDOW.value else None,
|
||||
counts=out.counts,
|
||||
daily=self._days(grid_start, now, out.daily, zone) if grid else [],
|
||||
)
|
||||
target_ids = await self._target_ids(project_id, target_id, program)
|
||||
target_value = await self._target_value(project_id, target_id)
|
||||
out = _Groups()
|
||||
|
||||
await self._runs(
|
||||
out,
|
||||
@@ -297,7 +302,10 @@ class WhatsNewService:
|
||||
q,
|
||||
rows,
|
||||
)
|
||||
out.visual = await self._visual_count(project_id, cutoff, until, target_ids, q)
|
||||
if visual:
|
||||
out.visual = await self._visual_count(
|
||||
project_id, cutoff, until, target_ids, q
|
||||
)
|
||||
if bounty:
|
||||
await self._bounty(
|
||||
out,
|
||||
@@ -330,20 +338,20 @@ class WhatsNewService:
|
||||
window=window if basis == NewBasis.WINDOW.value else None,
|
||||
counts=out.counts,
|
||||
facts=dict(out.facts),
|
||||
daily=self._days(grid_start, now, out.daily) if grid else [],
|
||||
daily=self._days(grid_start, now, out.daily, zone) if grid else [],
|
||||
groups=groups[:GROUP_LIMIT],
|
||||
events=len(groups),
|
||||
truncated=truncated,
|
||||
first_runs=out.first_runs,
|
||||
visual=out.visual,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _period(now, marked_at, since, window, day_from, day_to):
|
||||
def _period(now, marked_at, since, window, day_from, day_to, zone: ZoneInfo):
|
||||
until: datetime | None = None
|
||||
if day_from is not None:
|
||||
basis = NewBasis.DAYS.value
|
||||
cutoff = _day_start(day_from)
|
||||
until = _day_start((day_to or day_from) + timedelta(days=1))
|
||||
cutoff = _day_start(day_from, zone)
|
||||
until = _day_start((day_to or day_from) + timedelta(days=1), zone)
|
||||
elif since is not None:
|
||||
basis, cutoff = NewBasis.MARK.value, since
|
||||
elif window:
|
||||
@@ -355,16 +363,18 @@ class WhatsNewService:
|
||||
basis, cutoff = NewBasis.WINDOW.value, now - NEW_WINDOWS[window]
|
||||
return basis, cutoff, until, window
|
||||
|
||||
async def unseen(self, project_id: UUID, user_id: UUID, *, bounty: bool) -> int:
|
||||
async def unseen(
|
||||
self, project_id: UUID, user_id: UUID, *, bounty: bool, tz: str = DEFAULT_ZONE
|
||||
) -> int:
|
||||
feed = await self.feed(
|
||||
project_id, user_id, bounty=bounty, rows=False, grid=False
|
||||
project_id, user_id, bounty=bounty, grid=False, visual=False, tz=tz
|
||||
)
|
||||
return sum(feed.counts.get(k, 0) for k in KIND_ORDER if k not in GONE_KINDS)
|
||||
return feed.events
|
||||
|
||||
@staticmethod
|
||||
def _days(start: datetime, now: datetime, daily) -> list[NewDay]:
|
||||
day = start.date()
|
||||
end = now.date()
|
||||
def _days(start: datetime, now: datetime, daily, zone: ZoneInfo) -> list[NewDay]:
|
||||
day = start.astimezone(zone).date()
|
||||
end = now.astimezone(zone).date()
|
||||
out: list[NewDay] = []
|
||||
while day <= end:
|
||||
key = day.isoformat()
|
||||
@@ -483,28 +493,11 @@ class WhatsNewService:
|
||||
}
|
||||
if not scans:
|
||||
return
|
||||
holding, baseline = await self._baseline_scans(scans)
|
||||
for sid in holding - set(baseline):
|
||||
at = scans[sid].started_at or scans[sid].created_at
|
||||
if at >= since and (until is None or at < until):
|
||||
out.first_runs += 1
|
||||
baseline = await self._baseline_scans(scans)
|
||||
if not baseline:
|
||||
return
|
||||
kind = NewKind.FINDING.value
|
||||
base = _new_conds(baseline, q)
|
||||
|
||||
if grid_start is not None:
|
||||
daily = await self.session.execute(
|
||||
select(func.date(Vulnerability.discovered_at), func.count())
|
||||
.where(*base, Vulnerability.discovered_at >= grid_start)
|
||||
.group_by(func.date(Vulnerability.discovered_at))
|
||||
)
|
||||
for d, n in daily.all():
|
||||
out.day(kind, d, n)
|
||||
|
||||
window = [*base, Vulnerability.discovered_at >= since]
|
||||
if until is not None:
|
||||
window.append(Vulnerability.discovered_at < until)
|
||||
base = _new_conds(baseline)
|
||||
stmt = (
|
||||
select(
|
||||
Vulnerability.scan_id,
|
||||
@@ -513,23 +506,37 @@ class WhatsNewService:
|
||||
func.max(Vulnerability.template_name),
|
||||
func.count(),
|
||||
func.count().filter(Vulnerability.is_kev),
|
||||
func.max(Vulnerability.discovered_at),
|
||||
)
|
||||
.where(*window)
|
||||
.where(*base)
|
||||
.group_by(
|
||||
Vulnerability.scan_id,
|
||||
Vulnerability.template_id,
|
||||
Vulnerability.severity,
|
||||
)
|
||||
)
|
||||
found = (await self.session.execute(stmt)).all()
|
||||
if not found:
|
||||
by_run: dict[UUID, list] = defaultdict(list)
|
||||
for row in (await self.session.execute(stmt)).all():
|
||||
by_run[row[0]].append(row)
|
||||
if q and by_run:
|
||||
matched = set(
|
||||
(
|
||||
await self.session.execute(
|
||||
select(Vulnerability.scan_id)
|
||||
.where(*base, _finding_text(q))
|
||||
.distinct()
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
by_run = {sid: r for sid, r in by_run.items() if sid in matched}
|
||||
if not by_run:
|
||||
return
|
||||
targets = {
|
||||
t.id: t
|
||||
for t in (
|
||||
await self.session.execute(
|
||||
select(Target).where(
|
||||
Target.id.in_({scans[r[0]].target_id for r in found})
|
||||
Target.id.in_({scans[sid].target_id for sid in by_run})
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -537,25 +544,38 @@ class WhatsNewService:
|
||||
.all()
|
||||
}
|
||||
show = rows and kind in wanted
|
||||
for sid, template_id, severity, name, n, kev in found:
|
||||
for sid, found in by_run.items():
|
||||
scan = scans[sid]
|
||||
out.count(kind, n)
|
||||
out.fact(kind, severity, n)
|
||||
out.fact(kind, Fact.KEV.value, kev)
|
||||
if not show:
|
||||
at = scan.completed_at or max(r[6] for r in found)
|
||||
total = sum(r[4] for r in found)
|
||||
if grid_start is not None and at >= grid_start:
|
||||
day = _day_of(at, out.zone)
|
||||
out.day(kind, day, total)
|
||||
for r in found:
|
||||
out.day(r[2], day, r[4])
|
||||
if at < since or (until is not None and at >= until):
|
||||
continue
|
||||
g = self._run_group(out, scan, targets[scan.target_id])
|
||||
g.severities[severity] = g.severities.get(severity, 0) + n
|
||||
c = out.check(template_id)
|
||||
c.name = name
|
||||
c.kev += kev
|
||||
c.severities[severity] += n
|
||||
c.runs[sid] += n
|
||||
out.count(kind, total)
|
||||
g = (
|
||||
self._run_group(out, scan, targets[scan.target_id], at)
|
||||
if show
|
||||
else None
|
||||
)
|
||||
for _, template_id, severity, name, n, kev, _last in found:
|
||||
out.fact(kind, severity, n)
|
||||
out.fact(kind, Fact.KEV.value, kev)
|
||||
if g is None:
|
||||
continue
|
||||
g.severities[severity] = g.severities.get(severity, 0) + n
|
||||
c = out.check(template_id)
|
||||
c.name = name
|
||||
c.kev += kev
|
||||
c.kev_runs[sid] += kev
|
||||
c.severities[severity] += n
|
||||
c.runs[sid] += n
|
||||
if g is not None:
|
||||
out.section(g, kind, total, [])
|
||||
if show:
|
||||
for g in out.by_id.values():
|
||||
if g.scan_id is not None:
|
||||
total = sum(g.severities.values())
|
||||
out.section(g, kind, total, [])
|
||||
await self._previous_runs(out, scans)
|
||||
|
||||
async def _visual_count(self, project_id, since, until, target_ids, q) -> int:
|
||||
@@ -636,11 +656,12 @@ class WhatsNewService:
|
||||
target_id: UUID | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = VISUAL_LIMIT,
|
||||
tz: str = DEFAULT_ZONE,
|
||||
) -> VisualFeed:
|
||||
now = utc_now()
|
||||
marked_at = await self.mark(user_id, project_id)
|
||||
basis, cutoff, until, window = self._period(
|
||||
now, marked_at, since, window, day_from, day_to
|
||||
now, marked_at, since, window, day_from, day_to, ZoneInfo(tz)
|
||||
)
|
||||
q = (q or "").strip() or None
|
||||
target_ids = [target_id] if target_id is not None else None
|
||||
@@ -719,9 +740,7 @@ class WhatsNewService:
|
||||
feed.total = len(feed.pairs)
|
||||
return feed
|
||||
|
||||
async def _baseline_scans(
|
||||
self, scans: dict[UUID, Scan]
|
||||
) -> tuple[set[UUID], list[UUID]]:
|
||||
async def _baseline_scans(self, scans: dict[UUID, Scan]) -> list[UUID]:
|
||||
"""Scans holding findings, and those of them with an earlier scan that held findings."""
|
||||
firsts = (
|
||||
await self.session.execute(
|
||||
@@ -731,7 +750,7 @@ class WhatsNewService:
|
||||
)
|
||||
).all()
|
||||
if not firsts:
|
||||
return set(), []
|
||||
return []
|
||||
table = values(
|
||||
column("id", Uuid),
|
||||
column("target_id", Uuid),
|
||||
@@ -750,7 +769,7 @@ class WhatsNewService:
|
||||
)
|
||||
)
|
||||
)
|
||||
return {sid for sid, _ in firsts}, [r[0] for r in rows.all()]
|
||||
return [r[0] for r in rows.all()]
|
||||
|
||||
@staticmethod
|
||||
def _evidence(out: _Groups, groups: list[NewGroup]) -> None:
|
||||
@@ -774,14 +793,15 @@ class WhatsNewService:
|
||||
key=lambda sev: SEVERITY_RANK.get(sev, len(SEVERITY_RANK)),
|
||||
default=None,
|
||||
),
|
||||
kev=c.kev,
|
||||
kev=c.kev_runs[g.scan_id],
|
||||
query=f"is:new {token('template', '=', c.template_id)}",
|
||||
)
|
||||
for c in picked
|
||||
]
|
||||
|
||||
def _run_group(self, out: _Groups, scan: Scan, target: Target) -> NewGroup:
|
||||
at = scan.completed_at or scan.started_at or scan.created_at
|
||||
def _run_group(
|
||||
self, out: _Groups, scan: Scan, target: Target, at: datetime
|
||||
) -> NewGroup:
|
||||
g = out.group(
|
||||
f"run:{scan.id}",
|
||||
NewSubject(
|
||||
@@ -939,11 +959,14 @@ class WhatsNewService:
|
||||
daily = await self.session.execute(
|
||||
select(
|
||||
BountyEventRow.kind,
|
||||
func.date(BountyEventRow.created_at),
|
||||
_local_date(BountyEventRow.created_at, out.zone),
|
||||
func.count(),
|
||||
)
|
||||
.where(*base, BountyEventRow.created_at >= grid_start)
|
||||
.group_by(BountyEventRow.kind, func.date(BountyEventRow.created_at))
|
||||
.group_by(
|
||||
BountyEventRow.kind,
|
||||
_local_date(BountyEventRow.created_at, out.zone),
|
||||
)
|
||||
)
|
||||
for kind, d, n in daily.all():
|
||||
out.day(EVENT_KIND[kind], d, n)
|
||||
@@ -994,7 +1017,7 @@ class WhatsNewService:
|
||||
if kind not in wanted:
|
||||
continue
|
||||
watch_id = watches.get(e.program_id)
|
||||
day = e.created_at.date().isoformat()
|
||||
day = _day_of(e.created_at, out.zone)
|
||||
if kind == NewKind.PROGRAM.value:
|
||||
gid = f"library:programs:{day}"
|
||||
subjects[gid] = NewSubject(
|
||||
@@ -1174,11 +1197,11 @@ class WhatsNewService:
|
||||
|
||||
if grid_start is not None:
|
||||
daily = await self.session.execute(
|
||||
select(func.date(WatchHost.first_seen_at), func.count())
|
||||
select(_local_date(WatchHost.first_seen_at, out.zone), func.count())
|
||||
.select_from(WatchHost)
|
||||
.join(*join)
|
||||
.where(*base, WatchHost.first_seen_at >= grid_start)
|
||||
.group_by(func.date(WatchHost.first_seen_at))
|
||||
.group_by(_local_date(WatchHost.first_seen_at, out.zone))
|
||||
)
|
||||
for d, n in daily.all():
|
||||
out.day(kind, d, n)
|
||||
@@ -1214,7 +1237,7 @@ class WhatsNewService:
|
||||
subjects: dict[str, NewSubject] = {}
|
||||
latest: dict[str, datetime] = {}
|
||||
for h, w, p, t in (await self.session.execute(stmt)).all():
|
||||
gid = f"program:{p.id}:{h.first_seen_at.date().isoformat()}"
|
||||
gid = f"program:{p.id}:{_day_of(h.first_seen_at, out.zone)}"
|
||||
subjects[gid] = NewSubject(
|
||||
kind=SubjectKind.PROGRAM.value,
|
||||
id=str(p.id),
|
||||
@@ -1284,9 +1307,9 @@ class WhatsNewService:
|
||||
|
||||
if grid_start is not None:
|
||||
daily = await self.session.execute(
|
||||
select(func.date(Target.created_at), func.count())
|
||||
select(_local_date(Target.created_at, out.zone), func.count())
|
||||
.where(*base, Target.created_at >= grid_start)
|
||||
.group_by(func.date(Target.created_at))
|
||||
.group_by(_local_date(Target.created_at, out.zone))
|
||||
)
|
||||
for d, n in daily.all():
|
||||
out.day(kind, d, n)
|
||||
@@ -1361,7 +1384,7 @@ class WhatsNewService:
|
||||
)
|
||||
by_day: dict[str, list[NewItem]] = defaultdict(list)
|
||||
for item in items:
|
||||
by_day[item.at.date().isoformat()].append(item)
|
||||
by_day[_day_of(item.at, out.zone)].append(item)
|
||||
for day, listed in by_day.items():
|
||||
g = out.group(
|
||||
f"targets:{day}",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
VisualFeed,
|
||||
VisualParams
|
||||
} from '$lib/types/whats-new';
|
||||
import { viewerZone } from '$lib/utilities/dates';
|
||||
import { api } from './client';
|
||||
|
||||
function query(params: Record<string, unknown>): string {
|
||||
@@ -19,13 +20,19 @@ function query(params: Record<string, unknown>): string {
|
||||
|
||||
export const whatsNewApi = {
|
||||
feed(projectId: string, params: NewFeedParams = {}): Promise<NewFeed> {
|
||||
return api.get<NewFeed>(`/whats-new${query({ project_id: projectId, ...params })}`);
|
||||
return api.get<NewFeed>(
|
||||
`/whats-new${query({ project_id: projectId, tz: viewerZone(), ...params })}`
|
||||
);
|
||||
},
|
||||
visual(projectId: string, params: VisualParams = {}): Promise<VisualFeed> {
|
||||
return api.get<VisualFeed>(`/whats-new/visual${query({ project_id: projectId, ...params })}`);
|
||||
return api.get<VisualFeed>(
|
||||
`/whats-new/visual${query({ project_id: projectId, tz: viewerZone(), ...params })}`
|
||||
);
|
||||
},
|
||||
unseen(projectId: string): Promise<NewUnseen> {
|
||||
return api.get<NewUnseen>(`/whats-new/unseen${query({ project_id: projectId })}`);
|
||||
return api.get<NewUnseen>(
|
||||
`/whats-new/unseen${query({ project_id: projectId, tz: viewerZone() })}`
|
||||
);
|
||||
},
|
||||
caughtUp(projectId: string): Promise<NewMark> {
|
||||
return api.post<NewMark>(`/whats-new/seen${query({ project_id: projectId })}`, {});
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import Hint from '$lib/components/hint.svelte';
|
||||
import { GRID_STEPS, KIND_NOUN, type NewKindKey } from '$lib/config/whats-new';
|
||||
import type { NewDay } from '$lib/types/whats-new';
|
||||
|
||||
interface Props {
|
||||
days: NewDay[];
|
||||
kinds: NewKindKey[];
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
onPick: (from: string | null, to: string | null) => void;
|
||||
}
|
||||
|
||||
let { days, kinds, from, to, onPick }: Props = $props();
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const WEEKDAYS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
const OPACITY = ['', '0.3', '0.55', '0.8', '1'];
|
||||
const utc = (date: string) => new Date(`${date}T12:00:00Z`);
|
||||
const weekday = (date: string) => (utc(date).getUTCDay() + 6) % 7;
|
||||
const total = (d: NewDay) => kinds.reduce((n, k) => n + (d.counts[k] ?? 0), 0);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let anchor = $state<string | null>(null);
|
||||
let hover = $state<string | null>(null);
|
||||
let moved = $state(false);
|
||||
|
||||
let cells = $derived.by(() => {
|
||||
if (!days.length) return [] as { day: NewDay; col: number; row: number }[];
|
||||
const first = utc(days[0].date);
|
||||
const offset = weekday(days[0].date);
|
||||
return days.map((day) => {
|
||||
const index = Math.round((utc(day.date).getTime() - first.getTime()) / DAY_MS) + offset;
|
||||
return { day, col: Math.floor(index / 7), row: index % 7 };
|
||||
});
|
||||
});
|
||||
let columns = $derived(cells.length ? cells[cells.length - 1].col + 1 : 0);
|
||||
let max = $derived(Math.max(0, ...days.map(total)));
|
||||
let months = $derived.by(() => {
|
||||
const byCol = new SvelteMap<number, string>();
|
||||
let last = '';
|
||||
for (const cell of cells) {
|
||||
const label = utc(cell.day.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
if (label !== last) {
|
||||
byCol.set(cell.col, label);
|
||||
last = label;
|
||||
}
|
||||
}
|
||||
return [...byCol.entries()].map(([col, label]) => ({ col, label }));
|
||||
});
|
||||
let range = $derived.by<[string, string] | null>(() => {
|
||||
if (anchor && hover && moved) return anchor <= hover ? [anchor, hover] : [hover, anchor];
|
||||
if (from) return [from, to ?? from];
|
||||
return null;
|
||||
});
|
||||
|
||||
function step(n: number): number {
|
||||
if (n <= 0 || max <= 0) return 0;
|
||||
return Math.max(1, Math.ceil((n / max) * GRID_STEPS));
|
||||
}
|
||||
function inRange(date: string): boolean {
|
||||
return !!range && date >= range[0] && date <= range[1];
|
||||
}
|
||||
function text(day: NewDay): string {
|
||||
const label = utc(day.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const parts = kinds
|
||||
.filter((k) => day.counts[k])
|
||||
.map((k) => `${day.counts[k].toLocaleString()} ${KIND_NOUN[k][day.counts[k] === 1 ? 0 : 1]}`);
|
||||
return parts.length ? `${label} · ${parts.join(' · ')}` : `${label} · Nothing new`;
|
||||
}
|
||||
|
||||
function down(date: string, e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
anchor = date;
|
||||
hover = date;
|
||||
moved = false;
|
||||
}
|
||||
function enter(date: string) {
|
||||
if (!anchor) return;
|
||||
hover = date;
|
||||
if (date !== anchor) moved = true;
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
if (!anchor) return;
|
||||
const a = anchor;
|
||||
const h = hover ?? a;
|
||||
const wasRange = moved;
|
||||
anchor = null;
|
||||
hover = null;
|
||||
moved = false;
|
||||
if (wasRange) {
|
||||
const [lo, hi] = a <= h ? [a, h] : [h, a];
|
||||
onPick(lo, hi === lo ? null : hi);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && from) {
|
||||
const [lo, hi] = from <= a ? [from, a] : [a, from];
|
||||
onPick(lo, hi === lo ? null : hi);
|
||||
return;
|
||||
}
|
||||
if (from === a && !to) onPick(null, null);
|
||||
else onPick(a, null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerup={up} />
|
||||
|
||||
<div class="flex select-none flex-col gap-1">
|
||||
<div
|
||||
class="ml-8 grid text-2xs text-muted-foreground"
|
||||
style="grid-template-columns: repeat({columns}, 0.75rem); column-gap: 0.1875rem"
|
||||
>
|
||||
{#each months as m (m.col)}
|
||||
<span style="grid-column: {m.col + 1}">{m.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="grid w-6 text-2xs text-muted-foreground"
|
||||
style="grid-template-rows: repeat(7, 0.75rem); row-gap: 0.1875rem"
|
||||
>
|
||||
{#each WEEKDAYS as w, i (i)}
|
||||
<span class="leading-3">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div
|
||||
class="grid touch-none"
|
||||
style="grid-template-columns: repeat({columns}, 0.75rem); grid-template-rows: repeat(7, 0.75rem); gap: 0.1875rem; grid-auto-flow: column"
|
||||
role="grid"
|
||||
aria-label="Days"
|
||||
>
|
||||
{#each cells as cell (cell.day.date)}
|
||||
{@const n = total(cell.day)}
|
||||
{@const s = step(n)}
|
||||
{@const on = inRange(cell.day.date)}
|
||||
<Hint text={text(cell.day)}>
|
||||
{#snippet child(props)}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="size-3 rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring {s ===
|
||||
0
|
||||
? 'bg-muted/60'
|
||||
: ''} {on ? 'ring-2 ring-ring ring-offset-1 ring-offset-background' : ''} {cell.day
|
||||
.date === today && !on
|
||||
? 'ring-1 ring-border'
|
||||
: ''}"
|
||||
style={s ? `background: var(--series); opacity: ${OPACITY[s]}` : ''}
|
||||
aria-label={text(cell.day)}
|
||||
aria-pressed={on}
|
||||
onpointerdown={(e) => down(cell.day.date, e)}
|
||||
onpointerenter={() => enter(cell.day.date)}
|
||||
></button>
|
||||
{/snippet}
|
||||
</Hint>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { KIND_LABELS, NewKind, type NewKindKey, type SignalKey } from '$lib/config/whats-new';
|
||||
import { Severity, SEVERITY_LABELS } from '$lib/config/vulnerabilities';
|
||||
import type { NewDay } from '$lib/types/whats-new';
|
||||
|
||||
interface Props {
|
||||
days: NewDay[];
|
||||
kinds: NewKindKey[];
|
||||
counts: Record<string, number>;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
markedAt: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
active: SignalKey | null;
|
||||
onPick: (from: string | null, to: string | null) => void;
|
||||
onSignal: (signal: SignalKey | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
days,
|
||||
kinds,
|
||||
counts,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
markedAt,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onPick,
|
||||
onSignal
|
||||
}: Props = $props();
|
||||
|
||||
const LANE = 30;
|
||||
const AXIS = 20;
|
||||
const WIDE = 560;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
let width = $state(0);
|
||||
let span = $derived(width >= WIDE ? 30 : 14);
|
||||
let shown = $derived(days.slice(-span));
|
||||
let lanes = $derived(kinds.filter((k) => shown.some((d) => (d.counts[k] ?? 0) > 0)));
|
||||
let cw = $derived(shown.length ? width / shown.length : 0);
|
||||
let height = $derived(lanes.length * LANE + AXIS);
|
||||
let peaks = $derived(
|
||||
Object.fromEntries(
|
||||
lanes.map((k) => [k, Math.max(1, ...shown.map((d) => d.counts[k] ?? 0))])
|
||||
) as Record<string, number>
|
||||
);
|
||||
|
||||
let hover = $state<number | null>(null);
|
||||
let anchor = $state<number | null>(null);
|
||||
let dragging = $state(false);
|
||||
|
||||
const cx = (i: number) => cw * (i + 0.5);
|
||||
const today = () => new Date().toLocaleDateString('en-CA');
|
||||
|
||||
function position(iso: string | null): number | null {
|
||||
if (!iso || !shown.length) return null;
|
||||
const at = new Date(iso);
|
||||
const key = at.toLocaleDateString('en-CA');
|
||||
const index = shown.findIndex((d) => d.date === key);
|
||||
if (index < 0) return key < shown[0].date ? 0 : null;
|
||||
const midnight = new Date(`${key}T00:00:00`).getTime();
|
||||
return cw * (index + Math.min(1, (at.getTime() - midnight) / DAY_MS));
|
||||
}
|
||||
let periodX = $derived(position(periodStart));
|
||||
let periodEndX = $derived(periodEnd ? position(periodEnd) : width);
|
||||
let markX = $derived(position(markedAt));
|
||||
|
||||
let range = $derived.by<[number, number] | null>(() => {
|
||||
if (dragging && anchor !== null && hover !== null) {
|
||||
return anchor <= hover ? [anchor, hover] : [hover, anchor];
|
||||
}
|
||||
if (!from) return null;
|
||||
const a = shown.findIndex((d) => d.date === from);
|
||||
const b = shown.findIndex((d) => d.date === (to ?? from));
|
||||
if (a < 0 && b < 0) return null;
|
||||
return [Math.max(0, a), b < 0 ? shown.length - 1 : b];
|
||||
});
|
||||
|
||||
function radius(kind: string, n: number): number {
|
||||
if (!n) return 0;
|
||||
return 3.5 + 6.5 * Math.sqrt(n / peaks[kind]);
|
||||
}
|
||||
function fill(kind: string, day: NewDay): string {
|
||||
if (kind !== NewKind.FINDING) return 'var(--series)';
|
||||
return (day.counts[Severity.CRITICAL] ?? 0) > 0 ? 'var(--sev-critical)' : 'var(--sev-high)';
|
||||
}
|
||||
function dayAt(e: PointerEvent): number {
|
||||
const box = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||
const i = Math.floor((e.clientX - box.left) / cw);
|
||||
return Math.max(0, Math.min(shown.length - 1, i));
|
||||
}
|
||||
function down(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
(e.currentTarget as SVGElement).setPointerCapture(e.pointerId);
|
||||
anchor = dayAt(e);
|
||||
hover = anchor;
|
||||
dragging = true;
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
hover = dayAt(e);
|
||||
}
|
||||
function up() {
|
||||
if (!dragging || anchor === null || hover === null) return;
|
||||
const [lo, hi] = anchor <= hover ? [anchor, hover] : [hover, anchor];
|
||||
dragging = false;
|
||||
anchor = null;
|
||||
const a = shown[lo].date;
|
||||
const b = shown[hi].date;
|
||||
if (lo === hi && from === a && !to) onPick(null, null);
|
||||
else onPick(a, lo === hi ? null : b);
|
||||
}
|
||||
function label(date: string, long = false): string {
|
||||
if (date === today()) return 'Today';
|
||||
return new Date(`${date}T12:00:00`).toLocaleDateString('en-US', {
|
||||
...(long ? { weekday: 'short' } : {}),
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
let ticks = $derived(
|
||||
shown
|
||||
.map((d, i) => ({ d, i }))
|
||||
.filter(({ i }) => (shown.length - 1 - i) % (span === 30 ? 7 : 4) === 0)
|
||||
);
|
||||
let hovered = $derived(hover !== null ? shown[hover] : null);
|
||||
let tipRows = $derived.by(() => {
|
||||
const day = hovered;
|
||||
if (!day) return [];
|
||||
const rows: { key: string; label: string; n: number; color: string }[] = [];
|
||||
for (const kind of lanes) {
|
||||
if (kind === NewKind.FINDING) {
|
||||
for (const sev of [Severity.CRITICAL, Severity.HIGH]) {
|
||||
const n = day.counts[sev] ?? 0;
|
||||
if (n)
|
||||
rows.push({ key: sev, label: SEVERITY_LABELS[sev], n, color: `var(--sev-${sev})` });
|
||||
}
|
||||
} else if (day.counts[kind]) {
|
||||
rows.push({
|
||||
key: kind,
|
||||
label: KIND_LABELS[kind],
|
||||
n: day.counts[kind],
|
||||
color: 'var(--series)'
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
let tipLeft = $derived(hover === null ? 0 : Math.max(0, Math.min(width - 188, cx(hover) - 94)));
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-1 gap-3">
|
||||
<div class="flex w-24 shrink-0 flex-col sm:w-28" style="padding-bottom: {AXIS}px">
|
||||
{#each lanes as kind (kind)}
|
||||
{@const on = active === kind}
|
||||
<button
|
||||
type="button"
|
||||
class="-ml-1.5 flex items-center justify-between gap-2 rounded-md px-1.5 text-left text-xs transition-colors {on
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}"
|
||||
style="height: {LANE}px"
|
||||
aria-pressed={on}
|
||||
onclick={() => onSignal(on ? null : kind)}
|
||||
>
|
||||
<span>{KIND_LABELS[kind]}</span>
|
||||
<span class="font-mono font-medium tabular-nums {counts[kind] ? 'text-foreground' : ''}"
|
||||
>{(counts[kind] ?? 0).toLocaleString()}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="relative min-w-0 flex-1" bind:clientWidth={width}>
|
||||
{#if lanes.length === 0}
|
||||
<div
|
||||
class="flex items-center justify-center rounded-lg border border-dashed text-xs text-muted-foreground"
|
||||
style="height: {LANE * 2}px"
|
||||
>
|
||||
No events in {span} days
|
||||
</div>
|
||||
{:else if width > 0}
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
class="block touch-none select-none"
|
||||
role="img"
|
||||
aria-label="Events per day, last {span} days"
|
||||
onpointerdown={down}
|
||||
onpointermove={move}
|
||||
onpointerup={up}
|
||||
onpointerleave={() => {
|
||||
if (!dragging) hover = null;
|
||||
}}
|
||||
>
|
||||
{#if periodX !== null && !range}
|
||||
<rect
|
||||
x={periodX}
|
||||
y="0"
|
||||
width={Math.max(0, (periodEndX ?? width) - periodX)}
|
||||
height={lanes.length * LANE}
|
||||
rx="6"
|
||||
style="fill: var(--primary); opacity: 0.06"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each lanes as kind, li (kind)}
|
||||
<rect
|
||||
x="0"
|
||||
y={li * LANE + 7}
|
||||
{width}
|
||||
height={LANE - 14}
|
||||
rx={(LANE - 14) / 2}
|
||||
style="fill: var(--muted); opacity: 0.7"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if range}
|
||||
<rect
|
||||
x={range[0] * cw}
|
||||
y="0"
|
||||
width={(range[1] - range[0] + 1) * cw}
|
||||
height={lanes.length * LANE}
|
||||
rx="6"
|
||||
style="fill: var(--foreground); opacity: 0.07"
|
||||
/>
|
||||
<rect
|
||||
x={range[0] * cw + 0.5}
|
||||
y="0.5"
|
||||
width={(range[1] - range[0] + 1) * cw - 1}
|
||||
height={lanes.length * LANE - 1}
|
||||
rx="6"
|
||||
style="fill: none; stroke: var(--foreground); stroke-opacity: 0.35"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hover !== null}
|
||||
<line
|
||||
x1={cx(hover)}
|
||||
x2={cx(hover)}
|
||||
y1="0"
|
||||
y2={lanes.length * LANE}
|
||||
style="stroke: var(--foreground); stroke-opacity: 0.25"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if markX !== null}
|
||||
<line
|
||||
x1={markX}
|
||||
x2={markX}
|
||||
y1="-2"
|
||||
y2={lanes.length * LANE + 2}
|
||||
style="stroke: var(--muted-foreground)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 3"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each lanes as kind, li (kind)}
|
||||
{#each shown as day, i (day.date)}
|
||||
{@const n = day.counts[kind] ?? 0}
|
||||
{#if n}
|
||||
<circle
|
||||
cx={cx(i)}
|
||||
cy={li * LANE + LANE / 2}
|
||||
r={radius(kind, n) + (hover === i ? 1.5 : 0)}
|
||||
style="fill: {fill(
|
||||
kind,
|
||||
day
|
||||
)}; stroke: var(--card); stroke-width: 2; opacity: {active &&
|
||||
active !== kind &&
|
||||
!(kind === NewKind.FINDING && (active === 'critical' || active === 'high'))
|
||||
? 0.3
|
||||
: kind === NewKind.FINDING
|
||||
? 1
|
||||
: 0.75}; transition: r 120ms ease-out"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#each ticks as { d, i } (d.date)}
|
||||
<text
|
||||
x={cx(i)}
|
||||
y={lanes.length * LANE + 14}
|
||||
text-anchor="middle"
|
||||
class="text-2xs"
|
||||
style="fill: var(--muted-foreground)">{label(d.date)}</text
|
||||
>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
{#if hovered && hover !== null && !dragging}
|
||||
<div
|
||||
class="pointer-events-none absolute z-20 w-[11.75rem] rounded-lg border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||
style="left: {tipLeft}px; top: {lanes.length * LANE + 6}px"
|
||||
>
|
||||
<div class="mb-1 font-medium">{label(hovered.date, true)}</div>
|
||||
{#each tipRows as row (row.key)}
|
||||
<div class="flex items-center justify-between gap-2 py-0.5">
|
||||
<span class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span class="size-2 rounded-full" style="background: {row.color}"></span>
|
||||
{row.label}
|
||||
</span>
|
||||
<span class="font-mono tabular-nums">{row.n.toLocaleString()}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground">Nothing new</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Award from '@lucide/svelte/icons/award';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Library from '@lucide/svelte/icons/library';
|
||||
import Target from '@lucide/svelte/icons/target';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import LoadingButton from '$lib/components/loading-button.svelte';
|
||||
import ItemRow from './item-row.svelte';
|
||||
import RunFindings from './run-findings.svelte';
|
||||
import SevCounts from './sev-counts.svelte';
|
||||
import { ROUTES } from '$lib/config/routes';
|
||||
import { SURFACE, SurfaceDimension } from '$lib/config/surface';
|
||||
import { getTargetTypeIcon } from '$lib/config/icons';
|
||||
import { SEVERITY_CHIP, SEVERITY_LABELS } from '$lib/config/vulnerabilities';
|
||||
import {
|
||||
KIND_NOUN,
|
||||
NEW_FINDINGS_QUERY,
|
||||
@@ -23,10 +24,12 @@
|
||||
import { bountyVocabulary } from '$lib/stores/bounty-vocabulary.svelte';
|
||||
import type { ScanStatus } from '$lib/types/scan';
|
||||
import type { TargetType } from '$lib/types/target';
|
||||
import type { VulnerabilityRead } from '$lib/utilities/vulns';
|
||||
import type { NewGroup, NewItem } from '$lib/types/whats-new';
|
||||
|
||||
interface Props {
|
||||
group: NewGroup;
|
||||
projectId: string;
|
||||
index: number;
|
||||
cursor: boolean;
|
||||
unseen: boolean;
|
||||
@@ -37,6 +40,7 @@
|
||||
onToggle: () => void;
|
||||
onPick: (index: number) => void;
|
||||
onCompare: (current: string, baseline: string) => void;
|
||||
onFinding: (vuln: VulnerabilityRead, scanId: string) => void;
|
||||
onAddTargets: (items: NewItem[]) => void;
|
||||
onOpen: (item: NewItem) => void;
|
||||
onCheck: (item: NewItem, shift: boolean) => void;
|
||||
@@ -49,6 +53,7 @@
|
||||
|
||||
let {
|
||||
group,
|
||||
projectId,
|
||||
index,
|
||||
cursor,
|
||||
unseen,
|
||||
@@ -59,6 +64,7 @@
|
||||
onToggle,
|
||||
onPick,
|
||||
onCompare,
|
||||
onFinding,
|
||||
onAddTargets,
|
||||
onOpen,
|
||||
onCheck,
|
||||
@@ -69,15 +75,14 @@
|
||||
onRemoveTarget
|
||||
}: Props = $props();
|
||||
|
||||
const PREVIEW = 3;
|
||||
const SUMMARY_NAMES = 3;
|
||||
const SHEET_KINDS = new Set<string>([NewKind.CERT_HOST]);
|
||||
const VULNS = SURFACE[SurfaceDimension.VULNERABILITIES];
|
||||
|
||||
let subject = $derived(group.subject);
|
||||
let isRun = $derived(subject.kind === SubjectKind.RUN && !!group.scan_id);
|
||||
let items = $derived(group.sections.flatMap((s) => s.items));
|
||||
let shown = $derived(expanded ? items : items.slice(0, PREVIEW));
|
||||
let hiddenItems = $derived(group.sections.reduce((n, s) => n + s.total, 0) - shown.length);
|
||||
let total = $derived(group.sections.reduce((n, s) => n + s.total, 0));
|
||||
let addable = $derived(
|
||||
items.filter((i) => i.kind === NewKind.SCOPE && i.importable && !i.target_exists)
|
||||
);
|
||||
@@ -117,10 +122,23 @@
|
||||
}
|
||||
return subject.label;
|
||||
});
|
||||
let summary = $derived.by(() => {
|
||||
if (isRun) {
|
||||
const names = group.evidence.map((e) => e.label);
|
||||
const more = names.length + group.more - SUMMARY_NAMES;
|
||||
return [
|
||||
...names.slice(0, SUMMARY_NAMES),
|
||||
...(more > 0 ? [`${more} more ${more === 1 ? 'check' : 'checks'}`] : [])
|
||||
].join(' · ');
|
||||
}
|
||||
if (subject.kind === SubjectKind.PROGRAM) {
|
||||
return group.sections.map((s) => sentence(s.kind, s.total)).join(' · ');
|
||||
}
|
||||
const names = items.slice(0, SUMMARY_NAMES).map((i) => i.value);
|
||||
const more = total - names.length;
|
||||
return [...names, ...(more > 0 ? [`${more.toLocaleString()} more`] : [])].join(' · ');
|
||||
});
|
||||
|
||||
function vulnsHref(query: string): string {
|
||||
return ROUTES.scanTab(group.scan_id ?? '', VULNS.tab, { [VULNS.queryParam]: query });
|
||||
}
|
||||
function sentence(kind: string, n: number): string {
|
||||
if (kind === NewKind.SCOPE) return `${n} ${n === 1 ? 'asset' : 'assets'} added to scope`;
|
||||
if (kind === NewKind.OUT_OF_SCOPE) return `${n} ${n === 1 ? 'asset' : 'assets'} left scope`;
|
||||
@@ -136,15 +154,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
|
||||
<li
|
||||
class="grid grid-cols-[3rem_1rem_minmax(0,1fr)] gap-x-3 px-4 transition-colors hover:bg-muted/20 sm:grid-cols-[3rem_1rem_minmax(0,1fr)_auto] {cursor
|
||||
class="group/ev grid grid-cols-[1rem_minmax(0,1fr)] gap-x-3 px-4 transition-colors sm:grid-cols-[3rem_1rem_minmax(0,1fr)_auto] {cursor
|
||||
? 'bg-muted/40'
|
||||
: ''}"
|
||||
: expanded
|
||||
? 'bg-muted/20'
|
||||
: 'hover:bg-muted/30'}"
|
||||
data-event-row={index}
|
||||
onclick={() => onPick(index)}
|
||||
>
|
||||
<span class="flex h-6 items-center pt-3 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
<span
|
||||
class="hidden h-6 items-center pt-3 font-mono text-xs text-muted-foreground tabular-nums sm:flex"
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
|
||||
@@ -159,88 +179,59 @@
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1.5 py-3">
|
||||
<div class="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
{#if isRun}
|
||||
<span class="text-muted-foreground">Rescan of</span>
|
||||
<a
|
||||
href={ROUTES.target(subject.target_id ?? '')}
|
||||
class="font-mono font-medium wrap-anywhere hover:underline"
|
||||
onclick={stop}>{subject.label}</a
|
||||
>
|
||||
<a
|
||||
href={vulnsHref(NEW_FINDINGS_QUERY)}
|
||||
class="inline-flex items-center gap-1.5 hover:underline"
|
||||
onclick={stop}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-2 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-col gap-1 rounded-sm text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => {
|
||||
onPick(index);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<span class="flex min-h-6 min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<ChevronRight
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform {expanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="font-mono text-xs text-muted-foreground tabular-nums sm:hidden">{time}</span>
|
||||
{#if isRun}
|
||||
<span class="text-muted-foreground">Rescan of</span>
|
||||
<span class="font-mono font-medium wrap-anywhere">{subject.label}</span>
|
||||
<SevCounts severities={group.severities} labelled />
|
||||
<span class="text-muted-foreground">new {found === 1 ? 'finding' : 'findings'}</span>
|
||||
</a>
|
||||
{#if status}<span class="text-xs text-warning">Run {status}</span>{/if}
|
||||
{:else}
|
||||
<span class="font-medium wrap-anywhere">{headline}</span>
|
||||
{#if subject.platform}
|
||||
<Badge variant="outline">{bountyVocabulary.label(subject.platform)}</Badge>
|
||||
{#if status}<span class="text-xs text-muted-foreground">Run {status}</span>{/if}
|
||||
{:else}
|
||||
<span class="font-medium wrap-anywhere">{headline}</span>
|
||||
{#if subject.platform}
|
||||
<Badge variant="outline">{bountyVocabulary.label(subject.platform)}</Badge>
|
||||
{/if}
|
||||
{#if subject.watched}<Badge variant="info">Watched</Badge>{/if}
|
||||
{/if}
|
||||
{#if subject.watched}<Badge variant="info">Watched</Badge>{/if}
|
||||
</span>
|
||||
{#if summary && !expanded}
|
||||
<span class="pl-[1.375rem] text-xs text-muted-foreground wrap-anywhere">{summary}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if isRun}
|
||||
{#if group.evidence.length}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each group.evidence as e (e.query)}
|
||||
<li>
|
||||
<a
|
||||
href={vulnsHref(e.query)}
|
||||
class="flex min-w-0 items-center gap-2 text-xs hover:underline"
|
||||
onclick={stop}
|
||||
>
|
||||
{#if e.severity && SEVERITY_CHIP[e.severity]}
|
||||
<span
|
||||
class="inline-flex h-5 w-16 shrink-0 items-center justify-center rounded px-1 text-2xs font-medium {SEVERITY_CHIP[
|
||||
e.severity
|
||||
].chip}">{SEVERITY_LABELS[e.severity]}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="min-w-0 wrap-anywhere">{e.label}</span>
|
||||
{#if e.kev}<Badge variant="destructive">KEV</Badge>{/if}
|
||||
<span class="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
{e.count.toLocaleString()}
|
||||
{e.count === 1 ? 'instance' : 'instances'}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if group.more}
|
||||
<a
|
||||
href={vulnsHref(NEW_FINDINGS_QUERY)}
|
||||
class="w-fit text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
onclick={stop}
|
||||
>
|
||||
{group.more} more {group.more === 1 ? 'check' : 'checks'}
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if subject.kind === SubjectKind.PROGRAM}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{group.sections.map((s) => sentence(s.kind, s.total)).join(' · ')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if shown.length}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div class="divide-y divide-border/50 rounded-md border bg-card" onclick={stop}>
|
||||
{#each shown as item (item.id)}
|
||||
{#if expanded}
|
||||
{#if isRun && group.scan_id}
|
||||
<RunFindings
|
||||
{projectId}
|
||||
scanId={group.scan_id}
|
||||
count={found}
|
||||
onOpen={(v) => onFinding(v, group.scan_id ?? '')}
|
||||
/>
|
||||
{:else if items.length}
|
||||
<div class="divide-y divide-border/50 overflow-clip rounded-md border bg-card">
|
||||
{#each items as item (item.id)}
|
||||
<ItemRow
|
||||
{item}
|
||||
index={-1}
|
||||
checked={isChecked(item.id)}
|
||||
selectable={SELECTABLE_KINDS.has(item.kind)}
|
||||
busy={isBusy(item.id)}
|
||||
showTime={false}
|
||||
sheet={SHEET_KINDS.has(item.kind) && !!item.scan_id}
|
||||
{onOpen}
|
||||
{onCheck}
|
||||
@@ -252,35 +243,31 @@
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hiddenItems > 0 || (expanded && items.length > PREVIEW)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-fit text-xs text-muted-foreground hover:text-foreground"
|
||||
onclick={(e) => {
|
||||
stop(e);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Show less' : `${hiddenItems.toLocaleString()} more`}
|
||||
</button>
|
||||
{#if total > items.length}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{items.length} of {total.toLocaleString()} shown
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="col-start-3 flex items-start gap-1 pb-3 sm:col-start-auto sm:justify-end sm:pt-3 sm:pb-0"
|
||||
class="col-start-2 flex items-start gap-1 pb-3 transition-opacity sm:col-start-auto sm:justify-end sm:pt-3 sm:pb-0 sm:opacity-0 sm:group-hover/ev:opacity-100 sm:focus-within:opacity-100 {cursor ||
|
||||
expanded
|
||||
? 'sm:opacity-100'
|
||||
: ''}"
|
||||
onclick={stop}
|
||||
>
|
||||
{#if isRun}
|
||||
{#if isRun && group.scan_id}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
href={vulnsHref(NEW_FINDINGS_QUERY)}
|
||||
href={ROUTES.scanTab(group.scan_id, VULNS.tab, { [VULNS.queryParam]: NEW_FINDINGS_QUERY })}
|
||||
>
|
||||
Triage
|
||||
Open in scan
|
||||
</Button>
|
||||
{#if group.previous_scan_id}
|
||||
<Button
|
||||
|
||||
@@ -11,23 +11,17 @@
|
||||
import Hint from '$lib/components/hint.svelte';
|
||||
import LoadingButton from '$lib/components/loading-button.svelte';
|
||||
import ScreenshotThumb from '$lib/components/scans/results/screenshot-thumb.svelte';
|
||||
import SeverityMark from '$lib/components/scans/results/vulnerabilities/severity-mark.svelte';
|
||||
import { ROUTES } from '$lib/config/routes';
|
||||
import { NewKind, TERMS_KINDS } from '$lib/config/whats-new';
|
||||
import { rowHref } from '$lib/utilities/whats-new';
|
||||
import { relativeTime } from '$lib/utilities/dates';
|
||||
import type { NewItem } from '$lib/types/whats-new';
|
||||
|
||||
interface Props {
|
||||
item: NewItem;
|
||||
index: number;
|
||||
cursor?: boolean;
|
||||
checked?: boolean;
|
||||
selectable?: boolean;
|
||||
busy?: boolean;
|
||||
showTime?: boolean;
|
||||
sheet?: boolean;
|
||||
onPick?: (index: number) => void;
|
||||
onOpen?: (item: NewItem) => void;
|
||||
onCheck?: (item: NewItem, shift: boolean) => void;
|
||||
onAddTarget?: (item: NewItem) => void;
|
||||
@@ -39,14 +33,10 @@
|
||||
|
||||
let {
|
||||
item,
|
||||
index,
|
||||
cursor = false,
|
||||
checked = false,
|
||||
selectable = false,
|
||||
busy = false,
|
||||
showTime = true,
|
||||
sheet = false,
|
||||
onPick,
|
||||
onOpen,
|
||||
onCheck,
|
||||
onAddTarget,
|
||||
@@ -74,13 +64,8 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
||||
<div
|
||||
data-new-row={index}
|
||||
onclick={() => onPick?.(index)}
|
||||
class="group/row grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-1.5 transition-colors hover:bg-muted/50 {cursor
|
||||
? 'bg-muted/50'
|
||||
: ''} max-sm:grid-cols-[1.25rem_minmax(0,1fr)]"
|
||||
class="group/row grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-1.5 transition-colors hover:bg-muted/50 max-sm:grid-cols-[1.25rem_minmax(0,1fr)]"
|
||||
>
|
||||
<div class="flex h-5 items-center">
|
||||
{#if selectable}
|
||||
@@ -104,14 +89,12 @@
|
||||
class="h-7 w-11 shrink-0"
|
||||
preview
|
||||
/>
|
||||
{:else if item.kind === NewKind.FINDING}
|
||||
<SeverityMark severity={item.severity ?? ''} class="w-20 shrink-0" />
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-0.5">
|
||||
{#if sheet}
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 text-left text-sm wrap-anywhere hover:underline {mono
|
||||
class="min-w-0 text-left text-sm wrap-anywhere hover:text-primary {mono
|
||||
? 'font-mono text-sm'
|
||||
: 'font-medium'}"
|
||||
onclick={(e) => {
|
||||
@@ -133,7 +116,7 @@
|
||||
{:else if link}
|
||||
<a
|
||||
href={link}
|
||||
class="inline-flex min-w-0 items-center gap-1 text-sm wrap-anywhere hover:underline {mono
|
||||
class="inline-flex min-w-0 items-center gap-1 text-sm wrap-anywhere hover:text-primary {mono
|
||||
? 'font-mono text-sm'
|
||||
: 'font-medium'}"
|
||||
>
|
||||
@@ -148,11 +131,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if item.kind === NewKind.FINDING}
|
||||
{#if item.is_kev}<Badge variant="destructive">KEV</Badge>{/if}
|
||||
{#if item.detail}<span class="font-mono text-xs text-muted-foreground">{item.detail}</span
|
||||
>{/if}
|
||||
{:else if TERMS_KINDS.has(item.kind)}
|
||||
{#if TERMS_KINDS.has(item.kind)}
|
||||
{#if item.asset_type}<span class="text-xs text-muted-foreground">{item.asset_type}</span
|
||||
>{/if}
|
||||
{#if item.detail}<span class="text-xs wrap-anywhere">{item.detail}</span>{/if}
|
||||
@@ -181,7 +160,7 @@
|
||||
{#if item.watch_id}<Badge variant="info">Watched</Badge>{/if}
|
||||
{:else if item.kind === NewKind.TARGET}
|
||||
{#if item.detail && programHref}
|
||||
<a href={programHref} class="text-xs text-muted-foreground hover:underline"
|
||||
<a href={programHref} class="text-xs text-muted-foreground hover:text-foreground"
|
||||
>{item.detail}</a
|
||||
>
|
||||
{/if}
|
||||
@@ -192,9 +171,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-0.5 opacity-40 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 max-sm:col-start-2 max-sm:justify-start max-sm:opacity-100 {cursor
|
||||
? 'opacity-100'
|
||||
: ''}"
|
||||
class="flex items-center justify-end gap-0.5 opacity-40 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100 max-sm:col-start-2 max-sm:justify-start max-sm:opacity-100"
|
||||
>
|
||||
{#if mono}
|
||||
<CopyButton
|
||||
@@ -277,7 +254,7 @@
|
||||
</Hint>
|
||||
{:else if item.kind === NewKind.TARGET}
|
||||
<Button
|
||||
size={item.scanned ? 'sm' : 'sm'}
|
||||
size="sm"
|
||||
variant={item.scanned ? 'ghost' : 'default'}
|
||||
class="h-7 px-2.5 text-xs"
|
||||
onclick={() => onScan?.(item)}
|
||||
@@ -298,10 +275,5 @@
|
||||
</LoadingButton>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showTime}
|
||||
<span class="pl-2 text-2xs text-muted-foreground tabular-nums whitespace-nowrap"
|
||||
>{relativeTime(item.at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,127 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import * as ScrollArea from '$lib/components/ui/scroll-area';
|
||||
import ActivityGrid from './activity-grid.svelte';
|
||||
import ActivityLanes from './activity-lanes.svelte';
|
||||
import { SEVERITY_CHIP, SEVERITY_LABELS, Severity } from '$lib/config/vulnerabilities';
|
||||
import {
|
||||
Fact,
|
||||
KIND_LABELS,
|
||||
NewKind,
|
||||
Signal,
|
||||
type NewKindKey,
|
||||
type SignalKey
|
||||
} from '$lib/config/whats-new';
|
||||
import { Fact, NewKind, Signal, type NewKindKey, type SignalKey } from '$lib/config/whats-new';
|
||||
import type { NewFeed } from '$lib/types/whats-new';
|
||||
|
||||
interface Props {
|
||||
feed: NewFeed | null;
|
||||
kinds: readonly string[];
|
||||
since: string;
|
||||
kinds: NewKindKey[];
|
||||
period: string;
|
||||
active: SignalKey | null;
|
||||
gridKinds: NewKindKey[];
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
onSignal: (signal: SignalKey | null) => void;
|
||||
onPick: (from: string | null, to: string | null) => void;
|
||||
}
|
||||
|
||||
let { feed, kinds, since, active, gridKinds, from, to, onSignal, onPick }: Props = $props();
|
||||
let { feed, kinds, period, active, from, to, onSignal, onPick }: Props = $props();
|
||||
|
||||
const FINDINGS = [
|
||||
{ signal: Signal.CRITICAL, sev: Severity.CRITICAL },
|
||||
{ signal: Signal.HIGH, sev: Severity.HIGH }
|
||||
];
|
||||
|
||||
let counts = $derived(feed?.counts ?? {});
|
||||
let facts = $derived(feed?.facts ?? {});
|
||||
function fact(kind: string, key: string): number {
|
||||
return facts[kind]?.[key] ?? 0;
|
||||
}
|
||||
function shows(kind: string): boolean {
|
||||
return kinds.includes(kind);
|
||||
}
|
||||
const BOUNTY_STATS: NewKindKey[] = [
|
||||
NewKind.PROGRAM,
|
||||
NewKind.SCOPE,
|
||||
NewKind.OUT_OF_SCOPE,
|
||||
NewKind.BOUNTY_TABLE,
|
||||
NewKind.RULES,
|
||||
NewKind.CERT_HOST,
|
||||
NewKind.TARGET
|
||||
];
|
||||
let bountyStats = $derived(
|
||||
BOUNTY_STATS.filter((k) => shows(k) && (counts[k] ?? 0) > 0).map((kind) => ({
|
||||
kind,
|
||||
sub:
|
||||
kind === NewKind.SCOPE && fact(kind, Fact.NOT_TARGET)
|
||||
? `${fact(kind, Fact.NOT_TARGET).toLocaleString()} not targets`
|
||||
: kind === NewKind.CERT_HOST && fact(kind, Fact.ANSWERING)
|
||||
? `${fact(kind, Fact.ANSWERING).toLocaleString()} answering`
|
||||
: kind === NewKind.TARGET && fact(kind, Fact.NOT_SCANNED)
|
||||
? `${fact(kind, Fact.NOT_SCANNED).toLocaleString()} not scanned`
|
||||
: ''
|
||||
}))
|
||||
);
|
||||
let facts = $derived(feed?.facts[NewKind.FINDING] ?? {});
|
||||
let kev = $derived(facts[Fact.KEV] ?? 0);
|
||||
</script>
|
||||
|
||||
{#snippet stat(label: string, signal: SignalKey | null, n: number, sub = '', prefix = '')}
|
||||
{@const on = signal !== null && active === signal}
|
||||
<button
|
||||
type="button"
|
||||
class="group/s flex flex-col items-start gap-0.5 rounded-md text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-pressed={on}
|
||||
disabled={signal === null}
|
||||
onclick={() => onSignal(on ? null : signal)}
|
||||
>
|
||||
<span class="text-2xs tracking-wide text-muted-foreground uppercase">{label}</span>
|
||||
<span
|
||||
class="font-mono text-2xl font-semibold tabular-nums underline-offset-4 {signal
|
||||
? 'group-hover/s:underline'
|
||||
: ''} {n ? 'text-foreground' : 'text-muted-foreground/60'} {on
|
||||
? 'underline decoration-2'
|
||||
: ''}"
|
||||
>
|
||||
{n ? prefix : ''}{n.toLocaleString()}
|
||||
</span>
|
||||
{#if sub}<span class="text-2xs text-muted-foreground">{sub}</span>{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if !feed}
|
||||
<div class="flex flex-wrap items-start gap-x-8 gap-y-4 border-b px-4 py-4" aria-busy="true">
|
||||
{#each ['w-14', 'w-24', 'w-28', 'w-16', 'w-20'] as w (w)}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Skeleton class="h-3 {w}" />
|
||||
<Skeleton class="h-7 w-12" />
|
||||
<Skeleton class="h-3 w-20" />
|
||||
</div>
|
||||
{/each}
|
||||
<Skeleton class="h-[7.5rem] w-full max-w-[42rem] rounded-md lg:ml-auto lg:w-[36rem]" />
|
||||
<div class="flex w-56 flex-col gap-2">
|
||||
<Skeleton class="h-3 w-32" />
|
||||
<Skeleton class="h-9 w-24" />
|
||||
<div class="flex gap-1.5"><Skeleton class="h-8 w-24" /><Skeleton class="h-8 w-20" /></div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2 pt-1">
|
||||
{#each { length: 3 } as _, i (i)}
|
||||
<Skeleton class="h-4 w-full rounded-full" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-start gap-x-8 gap-y-4 border-b px-4 py-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Since</span>
|
||||
<span class="text-lg leading-8 font-semibold">{since}</span>
|
||||
</div>
|
||||
|
||||
{#if shows(NewKind.FINDING)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-fit text-left text-2xs tracking-wide text-muted-foreground uppercase hover:text-foreground {active ===
|
||||
NewKind.FINDING
|
||||
? 'text-foreground underline decoration-2 underline-offset-4'
|
||||
: ''}"
|
||||
aria-pressed={active === NewKind.FINDING}
|
||||
onclick={() => onSignal(active === NewKind.FINDING ? null : NewKind.FINDING)}
|
||||
>
|
||||
New findings
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex flex-col gap-4 border-b px-4 py-4 md:flex-row md:items-start md:gap-8">
|
||||
<div class="flex shrink-0 flex-col gap-2 md:w-56">
|
||||
<span class="text-2xs tracking-wide text-muted-foreground uppercase">{period}</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-mono text-4xl leading-none font-semibold tabular-nums">
|
||||
{feed.events.toLocaleString()}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{feed.events === 1 ? 'event' : 'events'}
|
||||
</span>
|
||||
</div>
|
||||
{#if kinds.includes(NewKind.FINDING)}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each FINDINGS as f (f.signal)}
|
||||
{@const on = active === f.signal}
|
||||
{@const n = fact(NewKind.FINDING, f.signal)}
|
||||
{@const n = facts[f.signal] ?? 0}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 font-mono text-sm font-semibold tabular-nums transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none {SEVERITY_CHIP[
|
||||
@@ -136,27 +71,24 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if fact(NewKind.FINDING, Fact.KEV)}
|
||||
<span class="text-2xs text-muted-foreground">
|
||||
{fact(NewKind.FINDING, Fact.KEV).toLocaleString()} known exploited
|
||||
</span>
|
||||
{#if kev}
|
||||
<span class="text-2xs text-muted-foreground">{kev.toLocaleString()} known exploited</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each bountyStats as b (b.kind)}
|
||||
{@render stat(KIND_LABELS[b.kind], b.kind, counts[b.kind] ?? 0, b.sub)}
|
||||
{/each}
|
||||
|
||||
{#if feed.daily.length}
|
||||
<div class="flex max-w-full min-w-0 flex-col gap-1 lg:ml-auto">
|
||||
<span class="text-2xs tracking-wide text-muted-foreground uppercase">Last 13 weeks</span>
|
||||
<ScrollArea.Root orientation="horizontal" class="max-w-full">
|
||||
<div class="pb-2">
|
||||
<ActivityGrid days={feed.daily} kinds={gridKinds} {from} {to} {onPick} />
|
||||
</div>
|
||||
</ScrollArea.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<ActivityLanes
|
||||
days={feed.daily}
|
||||
{kinds}
|
||||
counts={feed.counts}
|
||||
periodStart={from ? null : feed.since}
|
||||
periodEnd={feed.until}
|
||||
markedAt={feed.marked_at}
|
||||
{from}
|
||||
{to}
|
||||
{active}
|
||||
{onPick}
|
||||
{onSignal}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import SeverityMark from '$lib/components/scans/results/vulnerabilities/severity-mark.svelte';
|
||||
import { vulnerabilitiesApi } from '$lib/api/vulnerabilities';
|
||||
import { findingsFilter } from '$lib/components/scans/history/findings';
|
||||
import { NEW_FINDINGS_QUERY } from '$lib/config/whats-new';
|
||||
import { relativeTime } from '$lib/utilities/dates';
|
||||
import type { VulnerabilityRead } from '$lib/utilities/vulns';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
scanId: string;
|
||||
count: number;
|
||||
onOpen: (vuln: VulnerabilityRead) => void;
|
||||
}
|
||||
|
||||
let { projectId, scanId, count, onOpen }: Props = $props();
|
||||
|
||||
const LIMIT = 50;
|
||||
let items = $state<VulnerabilityRead[] | null>(null);
|
||||
let total = $state(0);
|
||||
let failed = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const scan = scanId;
|
||||
items = null;
|
||||
failed = false;
|
||||
vulnerabilitiesApi
|
||||
.search(projectId, scan, { ...findingsFilter([], LIMIT), q: NEW_FINDINGS_QUERY })
|
||||
.then((res) => {
|
||||
if (scan !== scanId) return;
|
||||
items = res.items;
|
||||
total = res.total;
|
||||
})
|
||||
.catch(() => (failed = true));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if failed}
|
||||
<p class="text-xs text-muted-foreground">Findings not loaded.</p>
|
||||
{:else if items === null}
|
||||
<div class="flex flex-col gap-1.5" aria-busy="true">
|
||||
{#each { length: Math.min(count, 4) } as _, i (i)}
|
||||
<Skeleton class="h-8 w-full rounded-md" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border/50 overflow-clip rounded-md border bg-card">
|
||||
{#each items as v (v.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none"
|
||||
onclick={() => onOpen(v)}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<SeverityMark severity={v.severity} class="hidden w-20 shrink-0 sm:flex" />
|
||||
<SeverityMark severity={v.severity} showLabel={false} class="shrink-0 sm:hidden" />
|
||||
<span class="min-w-0 flex-1 text-sm wrap-anywhere">{v.template_name}</span>
|
||||
{#if v.is_kev}<Badge variant="destructive">KEV</Badge>{/if}
|
||||
</span>
|
||||
<span class="flex min-w-0 items-baseline gap-2 pl-4 sm:pl-[5.5rem]">
|
||||
<span class="min-w-0 flex-1 font-mono text-xs text-muted-foreground wrap-anywhere">
|
||||
<span class="sm:hidden">{v.host || v.matched_at}</span>
|
||||
<span class="hidden sm:inline">{v.matched_at || v.host}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-2xs text-muted-foreground tabular-nums">
|
||||
{relativeTime(v.discovered_at)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if total > items.length}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{items.length} of {total.toLocaleString()} shown
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -125,7 +125,7 @@
|
||||
<Dialog.Title class="font-mono text-sm font-medium">{pair.host}</Dialog.Title>
|
||||
<a
|
||||
href={ROUTES.target(pair.target_id)}
|
||||
class="text-xs text-muted-foreground hover:underline">{pair.target_value}</a
|
||||
class="text-xs text-muted-foreground hover:text-foreground">{pair.target_value}</a
|
||||
>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">Distance {pair.distance}</span>
|
||||
{#if pair.silent}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="flex items-center gap-2 border-b px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left font-mono text-sm hover:underline"
|
||||
class="min-w-0 flex-1 truncate text-left font-mono text-sm hover:text-primary"
|
||||
onclick={() => onOpen(pair)}
|
||||
>
|
||||
{pair.host}
|
||||
@@ -69,7 +69,7 @@
|
||||
/>
|
||||
<a
|
||||
href={ROUTES.target(pair.target_id)}
|
||||
class="truncate text-2xs text-muted-foreground hover:underline"
|
||||
class="truncate text-2xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{pair.target_value}
|
||||
</a>
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
// mirrors shared/definitions/whats_new.py
|
||||
import Bug from '@lucide/svelte/icons/bug';
|
||||
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||
import Award from '@lucide/svelte/icons/award';
|
||||
import Radar from '@lucide/svelte/icons/radar';
|
||||
import Target from '@lucide/svelte/icons/target';
|
||||
import CircleMinus from '@lucide/svelte/icons/circle-minus';
|
||||
import Banknote from '@lucide/svelte/icons/banknote';
|
||||
import ScrollText from '@lucide/svelte/icons/scroll-text';
|
||||
import type { IconComponent } from './icons';
|
||||
import { SurfaceDimension } from './surface';
|
||||
import { Severity } from './vulnerabilities';
|
||||
import type { ScanStatus } from '$lib/types/scan';
|
||||
@@ -48,17 +39,6 @@ export const KIND_NOUN: Record<NewKindKey, [string, string]> = {
|
||||
[NewKind.TARGET]: ['target', 'targets']
|
||||
};
|
||||
|
||||
export const KIND_ICONS: Record<NewKindKey, IconComponent> = {
|
||||
[NewKind.FINDING]: Bug,
|
||||
[NewKind.PROGRAM]: Award,
|
||||
[NewKind.SCOPE]: ShieldCheck,
|
||||
[NewKind.OUT_OF_SCOPE]: CircleMinus,
|
||||
[NewKind.BOUNTY_TABLE]: Banknote,
|
||||
[NewKind.RULES]: ScrollText,
|
||||
[NewKind.CERT_HOST]: Radar,
|
||||
[NewKind.TARGET]: Target
|
||||
};
|
||||
|
||||
export const SCAN_KINDS: ReadonlySet<string> = new Set([NewKind.FINDING]);
|
||||
export const BOUNTY_KINDS: ReadonlySet<string> = new Set(
|
||||
KIND_ORDER.filter((k) => !SCAN_KINDS.has(k))
|
||||
@@ -160,12 +140,10 @@ export const VISUAL_FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
export const VISUAL_MAX_DISTANCE = 64;
|
||||
|
||||
export const GRID_STEPS = 4;
|
||||
|
||||
export const NEW_KEYS: [string, string][] = [
|
||||
['j / k', 'Move between events'],
|
||||
['o', 'Show or hide the rows of an event'],
|
||||
['Enter', 'Open the run or program'],
|
||||
['Enter or o', 'Expand or collapse the event'],
|
||||
['g', 'Go to the run or program'],
|
||||
['s', 'Scan the target'],
|
||||
['1 2', 'Switch tab'],
|
||||
['/', 'Filter'],
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface NewItem {
|
||||
source: string | null;
|
||||
source_label: string | null;
|
||||
screenshot_path: string | null;
|
||||
severity: string | null;
|
||||
is_kev: boolean;
|
||||
sensitive: boolean;
|
||||
asset_type: string | null;
|
||||
query: string | null;
|
||||
target_id: string | null;
|
||||
@@ -93,8 +90,8 @@ export interface NewFeed {
|
||||
facts: Record<string, Record<string, number>>;
|
||||
daily: NewDay[];
|
||||
groups: NewGroup[];
|
||||
events: number;
|
||||
truncated: boolean;
|
||||
first_runs: number;
|
||||
visual: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,3 +206,11 @@ export function getColorsForTimestamp(
|
||||
): FreshnessColors {
|
||||
return getFreshnessColors(getFreshnessLevel(timestamp, thresholds));
|
||||
}
|
||||
|
||||
export function viewerZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
import { liveScans } from '$lib/stores/live-scans.svelte';
|
||||
import { subdomainsApi } from '$lib/api/subdomains';
|
||||
import WebAssetDetailSheet from '$lib/components/scans/results/web-asset-detail-sheet.svelte';
|
||||
import VulnerabilityDetailSheet from '$lib/components/scans/results/vulnerability-detail-sheet.svelte';
|
||||
import type { VulnerabilityRead } from '$lib/utilities/vulns';
|
||||
import { compileQuery, emptyQuery, exactToken } from '$lib/utilities/scan-insights';
|
||||
import { SURFACE } from '$lib/config/surface';
|
||||
import { ROUTES, routeLabels } from '$lib/config/routes';
|
||||
@@ -57,7 +59,6 @@
|
||||
import { SurfaceDimension } from '$lib/config/surface';
|
||||
import {
|
||||
BOUNTY_KINDS,
|
||||
GONE_KINDS,
|
||||
KIND_ORDER,
|
||||
NEW_KEYS,
|
||||
NEW_TABS,
|
||||
@@ -156,6 +157,7 @@
|
||||
let addingAll = $state<string | null>(null);
|
||||
let lastChecked = $state<string | null>(null);
|
||||
let sheetSub = $state<SubdomainRead | null>(null);
|
||||
let sheetVuln = $state<VulnerabilityRead | null>(null);
|
||||
let sheetScan = $state('');
|
||||
let sheetOpen = $state(false);
|
||||
let opening = $state<string | null>(null);
|
||||
@@ -169,19 +171,12 @@
|
||||
KIND_ORDER.filter((k) => (bounty || !BOUNTY_KINDS.has(k)) && SOURCE_KINDS[sourceOn].has(k))
|
||||
);
|
||||
let wantedKinds = $derived<string[] | null>(sourceOn === NewSource.ALL ? null : visibleKinds);
|
||||
let gridKinds = $derived<NewKindKey[]>(visibleKinds.filter((k) => !GONE_KINDS.has(k)));
|
||||
|
||||
const splitProgram = (v: string): [string, string] => {
|
||||
const i = v.indexOf(':');
|
||||
return i < 0 ? ['', ''] : [v.slice(0, i), v.slice(i + 1)];
|
||||
};
|
||||
|
||||
let total = $derived(
|
||||
feed
|
||||
? visibleKinds.reduce((n, k) => (GONE_KINDS.has(k) ? n : n + (feed?.counts[k] ?? 0)), 0)
|
||||
: 0
|
||||
);
|
||||
|
||||
const dayLabel = (d: string) =>
|
||||
new Date(`${d}T12:00:00Z`).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
@@ -209,14 +204,13 @@
|
||||
if (feed.basis === NewBasis.MARK) return 'since caught up';
|
||||
return feed.window ? `in the last ${WINDOW_WORDS[feed.window] ?? feed.window}` : '';
|
||||
});
|
||||
let stripPeriod = $derived.by(() => {
|
||||
if (!feed) return '';
|
||||
if (feed.basis === NewBasis.DAYS) return sinceLabel;
|
||||
if (feed.basis === NewBasis.MARK) return `Since caught up · ${sinceLabel}`;
|
||||
return feed.window ? `Last ${WINDOW_WORDS[feed.window] ?? feed.window}` : '';
|
||||
});
|
||||
let emptyTitle = $derived(feed ? `Nothing new ${periodLabel}` : '');
|
||||
let emptyDescription = $derived(
|
||||
feed?.first_runs
|
||||
? feed.first_runs === 1
|
||||
? 'One first scan set a baseline. New rows appear from the next run of that target.'
|
||||
: `${feed.first_runs} first scans set a baseline. New rows appear from the next run of each target.`
|
||||
: undefined
|
||||
);
|
||||
let filtered = $derived(
|
||||
!!(targetId || program || qApplied || signal || sourceOn !== NewSource.ALL || dayFrom)
|
||||
);
|
||||
@@ -277,7 +271,11 @@
|
||||
});
|
||||
|
||||
let rowCount = $derived(tab === NewTab.TIMELINE ? entries.length : 0);
|
||||
let briefItems = $derived(entries.flatMap((e) => e.group.sections.flatMap((s) => s.items)));
|
||||
let briefItems = $derived(
|
||||
entries
|
||||
.filter((e) => expanded.has(e.id))
|
||||
.flatMap((e) => e.group.sections.flatMap((s) => s.items))
|
||||
);
|
||||
|
||||
// ---------- pickers ----------
|
||||
|
||||
@@ -339,13 +337,15 @@
|
||||
remove: () => pickDays(null, null)
|
||||
});
|
||||
}
|
||||
if (signal) {
|
||||
if (signal && tab !== NewTab.VISUAL) {
|
||||
out.push({ key: 'signal', label: SIGNAL_LABELS[signal], remove: () => (signal = null) });
|
||||
}
|
||||
if (targetId) {
|
||||
out.push({ key: 'target', label: targetLabel || 'Target', remove: () => (targetId = '') });
|
||||
}
|
||||
if (program) out.push({ key: 'program', label: programLabel, remove: () => (program = '') });
|
||||
if (program && tab !== NewTab.VISUAL) {
|
||||
out.push({ key: 'program', label: programLabel, remove: () => (program = '') });
|
||||
}
|
||||
if (qApplied) {
|
||||
out.push({
|
||||
key: 'q',
|
||||
@@ -485,6 +485,7 @@
|
||||
expanded.clear();
|
||||
cursor = -1;
|
||||
}
|
||||
selection.clear();
|
||||
syncUrl();
|
||||
void load();
|
||||
if (onVisual) void loadVisual();
|
||||
@@ -497,6 +498,7 @@
|
||||
untrack(() => {
|
||||
syncUrl();
|
||||
cursor = -1;
|
||||
selection.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -707,6 +709,7 @@
|
||||
sheetOpen = open;
|
||||
if (!open) {
|
||||
sheetSub = null;
|
||||
sheetVuln = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,6 +755,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openFinding(vuln: VulnerabilityRead, scanId: string) {
|
||||
sheetScan = scanId;
|
||||
sheetVuln = vuln;
|
||||
sheetOpen = true;
|
||||
}
|
||||
|
||||
async function openPair(pair: VisualPair) {
|
||||
if (!projectId || opening) return;
|
||||
opening = pair.id;
|
||||
@@ -841,6 +850,8 @@
|
||||
shortcutsOpen = true;
|
||||
return;
|
||||
}
|
||||
const control = !!t?.closest('a, button, [role=button], [role=checkbox]');
|
||||
if (control && (e.key === 'Enter' || e.key === ' ')) return;
|
||||
const tabIndex = ['1', '2'].indexOf(e.key);
|
||||
if (tabIndex >= 0 && NEW_TABS[tabIndex]) {
|
||||
setTab(NEW_TABS[tabIndex].key);
|
||||
@@ -879,6 +890,8 @@
|
||||
toggle(entry.id);
|
||||
} else if (e.key === 'Enter' && entry) {
|
||||
e.preventDefault();
|
||||
toggle(entry.id);
|
||||
} else if (e.key === 'g' && entry) {
|
||||
void goto(eventHref(entry.group));
|
||||
} else if (e.key === 's' && entry?.group.subject.target_id && entry.group.scan_id) {
|
||||
launchFor = entry.group.subject.target_id;
|
||||
@@ -897,13 +910,12 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="sr-only">{routeLabels['whats-new']}</h1>
|
||||
|
||||
<Card.Root class="gap-0 overflow-hidden py-0">
|
||||
<Card.Root class="gap-0 overflow-clip py-0">
|
||||
<NewStrip
|
||||
{feed}
|
||||
kinds={visibleKinds}
|
||||
since={sinceLabel}
|
||||
period={stripPeriod}
|
||||
active={signal}
|
||||
{gridKinds}
|
||||
from={dayFrom}
|
||||
to={dayTo}
|
||||
onSignal={(s) => (signal = s)}
|
||||
@@ -916,7 +928,7 @@
|
||||
value={tab}
|
||||
counts={feed
|
||||
? {
|
||||
[NewTab.TIMELINE]: total,
|
||||
[NewTab.TIMELINE]: feed.events,
|
||||
[NewTab.VISUAL]: feed.visual
|
||||
}
|
||||
: null}
|
||||
@@ -1122,58 +1134,62 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if entries.length === 0}
|
||||
<EmptyState icon={Sparkles} title={emptyTitle} description={emptyDescription} />
|
||||
<EmptyState icon={Sparkles} title={emptyTitle} />
|
||||
{:else}
|
||||
<div class="flex flex-col pb-2 transition-opacity {loading ? 'opacity-60' : ''}">
|
||||
{#each days as day (day.key)}
|
||||
<h2
|
||||
class="sticky top-0 z-10 border-b bg-card/95 px-4 py-2 text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase backdrop-blur"
|
||||
>
|
||||
{day.label}
|
||||
</h2>
|
||||
<ol>
|
||||
{#each day.rows as { entry, index } (entry.id)}
|
||||
{#if index === markIndex}
|
||||
<li
|
||||
class="grid grid-cols-[3rem_1rem_minmax(0,1fr)] items-center gap-x-3 px-4 py-1.5"
|
||||
>
|
||||
<span></span>
|
||||
<span class="flex justify-center"
|
||||
><span class="h-4 border-l border-dashed border-muted-foreground/60"
|
||||
></span></span
|
||||
<section>
|
||||
<h2
|
||||
class="sticky top-0 z-10 border-b bg-card/95 px-4 py-2 text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase backdrop-blur"
|
||||
>
|
||||
{day.label}
|
||||
</h2>
|
||||
<ol>
|
||||
{#each day.rows as { entry, index } (entry.id)}
|
||||
{#if index === markIndex}
|
||||
<li
|
||||
class="grid grid-cols-[1rem_minmax(0,1fr)] sm:grid-cols-[3rem_1rem_minmax(0,1fr)] items-center gap-x-3 px-4 py-1.5"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-2xs text-muted-foreground">
|
||||
<span class="h-px flex-1 border-t border-dashed border-muted-foreground/40"
|
||||
></span>
|
||||
Caught up {markLabel}
|
||||
<span class="h-px flex-1 border-t border-dashed border-muted-foreground/40"
|
||||
></span>
|
||||
</span>
|
||||
</li>
|
||||
{/if}
|
||||
<EventRow
|
||||
group={entry.group}
|
||||
{index}
|
||||
cursor={cursor === index}
|
||||
unseen={markedAt === null || new Date(entry.group.at).getTime() > markedAt}
|
||||
expanded={expanded.has(entry.id)}
|
||||
isChecked={(id) => selection.has(id)}
|
||||
isBusy={(id) => busy.has(id)}
|
||||
addingAll={addingAll === entry.id}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onPick={(i) => (cursor = i)}
|
||||
onCompare={(current, baseline) => (runCompare = { current, baseline })}
|
||||
onAddTargets={(items) => addAll(entry.id, items)}
|
||||
onOpen={(item) => openRow(item)}
|
||||
onCheck={check}
|
||||
onAddTarget={(item) => addTargets([item])}
|
||||
onWatch={(item) => (watchFor = item)}
|
||||
onMute={(item) => muteHosts([item])}
|
||||
onScan={scan}
|
||||
onRemoveTarget={(item) => (removeFor = item)}
|
||||
/>
|
||||
{/each}
|
||||
</ol>
|
||||
<span class="hidden sm:block"></span>
|
||||
<span class="flex justify-center"
|
||||
><span class="h-4 border-l border-dashed border-muted-foreground/60"
|
||||
></span></span
|
||||
>
|
||||
<span class="flex items-center gap-2 text-2xs text-muted-foreground">
|
||||
<span class="h-px flex-1 border-t border-dashed border-muted-foreground/40"
|
||||
></span>
|
||||
Caught up {markLabel}
|
||||
<span class="h-px flex-1 border-t border-dashed border-muted-foreground/40"
|
||||
></span>
|
||||
</span>
|
||||
</li>
|
||||
{/if}
|
||||
<EventRow
|
||||
group={entry.group}
|
||||
{projectId}
|
||||
{index}
|
||||
cursor={cursor === index}
|
||||
unseen={markedAt === null || new Date(entry.group.at).getTime() > markedAt}
|
||||
expanded={expanded.has(entry.id)}
|
||||
isChecked={(id) => selection.has(id)}
|
||||
isBusy={(id) => busy.has(id)}
|
||||
addingAll={addingAll === entry.id}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onPick={(i) => (cursor = i)}
|
||||
onCompare={(current, baseline) => (runCompare = { current, baseline })}
|
||||
onFinding={openFinding}
|
||||
onAddTargets={(items) => addAll(entry.id, items)}
|
||||
onOpen={(item) => openRow(item)}
|
||||
onCheck={check}
|
||||
onAddTarget={(item) => addTargets([item])}
|
||||
onWatch={(item) => (watchFor = item)}
|
||||
onMute={(item) => muteHosts([item])}
|
||||
onScan={scan}
|
||||
onRemoveTarget={(item) => (removeFor = item)}
|
||||
/>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/each}
|
||||
{#if feed.truncated}
|
||||
<p class="px-4 py-3 text-xs text-muted-foreground">
|
||||
@@ -1270,6 +1286,15 @@
|
||||
scanId={sheetScan}
|
||||
onFilter={(dsl) => scanTab(SurfaceDimension.WEB_ASSETS, dsl)}
|
||||
/>
|
||||
<VulnerabilityDetailSheet
|
||||
vuln={sheetVuln}
|
||||
{projectId}
|
||||
scanId={sheetScan}
|
||||
open={sheetOpen && sheetVuln !== null}
|
||||
onOpenChange={closeSheet}
|
||||
onFilter={(dsl) => scanTab(SurfaceDimension.VULNERABILITIES, dsl)}
|
||||
onHost={(dsl) => scanTab(SurfaceDimension.WEB_ASSETS, dsl)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -317,7 +317,7 @@ EVENTS: tuple[EventSpec, ...] = (
|
||||
EventSpec(
|
||||
BountyEvent.PAYOUT_CHANGED.value,
|
||||
"Payout changed",
|
||||
"The program changed its bounty range",
|
||||
"Minimum and maximum payout",
|
||||
"banknote",
|
||||
"info",
|
||||
actionable=False,
|
||||
@@ -341,7 +341,7 @@ EVENTS: tuple[EventSpec, ...] = (
|
||||
EventSpec(
|
||||
BountyEvent.ASSET_RULES_CHANGED.value,
|
||||
"Asset rules changed",
|
||||
"The program rewrote an asset's instructions",
|
||||
"Testing instructions on one asset",
|
||||
"file-pen",
|
||||
"info",
|
||||
actionable=False,
|
||||
|
||||
@@ -43,6 +43,10 @@ KIND_DIMENSION: dict[str, str] = {
|
||||
NewKind.FINDING.value: SurfaceDimension.VULNERABILITIES.value,
|
||||
}
|
||||
|
||||
TERMS_KINDS: frozenset[str] = frozenset(
|
||||
{NewKind.BOUNTY_TABLE.value, NewKind.RULES.value}
|
||||
)
|
||||
|
||||
ALERT_SEVERITIES: tuple[str, ...] = (Severity.CRITICAL.value, Severity.HIGH.value)
|
||||
|
||||
|
||||
@@ -67,7 +71,6 @@ class ProgramRing(StrEnum):
|
||||
class NewTone(StrEnum):
|
||||
NEW = "new"
|
||||
HOT = "hot"
|
||||
NEUTRAL = "neutral"
|
||||
|
||||
|
||||
NEW_WINDOWS: dict[str, timedelta] = {
|
||||
@@ -81,6 +84,9 @@ BOUNTY_ROWS_PER_SECTION = 50
|
||||
GROUP_LIMIT = 80
|
||||
EVIDENCE_FINDINGS = 4
|
||||
MAX_TEXT_FILTER = 200
|
||||
DEFAULT_ZONE = "UTC"
|
||||
WATCH_SOURCE = "watch"
|
||||
SCOPE_SOURCE = "scope"
|
||||
|
||||
SOURCE_LABELS: dict[str, str] = {
|
||||
"ct_log": "Certificate log",
|
||||
|
||||
@@ -22,9 +22,6 @@ class NewItem(BaseModel):
|
||||
source: str | None = None
|
||||
source_label: str | None = None
|
||||
screenshot_path: str | None = None
|
||||
severity: str | None = None
|
||||
is_kev: bool = False
|
||||
sensitive: bool = False
|
||||
asset_type: str | None = None
|
||||
query: str | None = None
|
||||
target_id: uuid.UUID | None = None
|
||||
@@ -103,8 +100,8 @@ class NewFeed(BaseModel):
|
||||
facts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
daily: list[NewDay] = Field(default_factory=list)
|
||||
groups: list[NewGroup] = Field(default_factory=list)
|
||||
events: int = 0
|
||||
truncated: bool = False
|
||||
first_runs: int = 0
|
||||
visual: int = 0
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ async def test_a_rescan_reports_new_critical_and_high_findings(estate, now):
|
||||
]
|
||||
today = now.date().isoformat()
|
||||
assert [d.counts for d in out.daily if d.date == today] == [
|
||||
{NewKind.FINDING.value: 2}
|
||||
{NewKind.FINDING.value: 2, "critical": 1, "high": 1}
|
||||
]
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ async def test_a_first_scan_is_not_an_event(estate, now):
|
||||
|
||||
assert out.groups == []
|
||||
assert out.counts[NewKind.FINDING.value] == 0
|
||||
assert out.first_runs == 1
|
||||
|
||||
|
||||
async def test_a_rescan_with_only_lower_severities_is_not_an_event(estate, now):
|
||||
@@ -132,14 +131,79 @@ async def test_a_day_range_bounds_the_feed(estate, now):
|
||||
assert [g.scan_id for g in out.groups] == [estate.scans["mid"]]
|
||||
|
||||
|
||||
async def test_text_filter_narrows_findings(estate, now):
|
||||
async def test_text_filter_picks_runs_and_keeps_their_whole_count(estate, now):
|
||||
await _rescan(estate, now, [("solr-rce", "critical"), ("info-leak", "high")])
|
||||
|
||||
out = await _service(estate).feed(
|
||||
service = _service(estate)
|
||||
hit = await service.feed(
|
||||
estate.project_id, estate.user_id, since=now - timedelta(hours=1), q="solr"
|
||||
)
|
||||
miss = await service.feed(
|
||||
estate.project_id, estate.user_id, since=now - timedelta(hours=1), q="zimbra"
|
||||
)
|
||||
|
||||
assert out.counts[NewKind.FINDING.value] == 1
|
||||
assert hit.counts[NewKind.FINDING.value] == 2
|
||||
assert miss.groups == []
|
||||
|
||||
|
||||
async def test_a_period_starting_mid_run_counts_the_whole_run(estate, now):
|
||||
await estate.scan("example.com", "older", at=now - timedelta(days=2))
|
||||
await estate.vulns("older", [("old-check", "low")], at=now - timedelta(days=2))
|
||||
await estate.scan("example.com", "fresh", at=now)
|
||||
await estate.vulns(
|
||||
"fresh", [("early-rce", "critical")], at=now - timedelta(hours=3)
|
||||
)
|
||||
await estate.vulns("fresh", [("late-rce", "high")], at=now)
|
||||
|
||||
out = await _service(estate).feed(
|
||||
estate.project_id, estate.user_id, since=now - timedelta(hours=1)
|
||||
)
|
||||
|
||||
assert out.groups[0].severities == {"critical": 1, "high": 1}
|
||||
assert out.events == 1
|
||||
|
||||
|
||||
async def test_bounty_days_follow_the_viewer_zone(estate, now):
|
||||
program = _program("acme", "Acme")
|
||||
estate.session.add(program)
|
||||
await estate.session.flush()
|
||||
late = now.replace(hour=17, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
||||
for at in (late, late + timedelta(hours=2)):
|
||||
estate.session.add(
|
||||
BountyEventRow(
|
||||
platform=program.platform,
|
||||
program_id=program.id,
|
||||
handle=program.handle,
|
||||
program_name=program.name,
|
||||
kind=BountyEvent.PROGRAM_ADDED.value,
|
||||
created_at=at,
|
||||
)
|
||||
)
|
||||
await estate.session.flush()
|
||||
|
||||
service = _service(estate)
|
||||
utc = await service.feed(estate.project_id, estate.user_id, window="7d")
|
||||
nepal = await service.feed(
|
||||
estate.project_id, estate.user_id, window="7d", tz="Asia/Kathmandu"
|
||||
)
|
||||
|
||||
assert len(utc.groups) == 1
|
||||
assert len(nepal.groups) == 2
|
||||
|
||||
|
||||
async def test_an_unknown_program_filter_shows_nothing(estate, now):
|
||||
await _rescan(estate, now, [("solr-rce", "critical")])
|
||||
|
||||
out = await _service(estate).feed(
|
||||
estate.project_id,
|
||||
estate.user_id,
|
||||
since=now - timedelta(hours=1),
|
||||
platform="hackerone",
|
||||
handle="missing",
|
||||
)
|
||||
|
||||
assert out.groups == []
|
||||
assert out.counts[NewKind.FINDING.value] == 0
|
||||
|
||||
|
||||
async def test_a_kind_filter_leaves_no_empty_run(estate, now):
|
||||
|
||||
Reference in New Issue
Block a user