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:
Yogesh Ojha
2026-09-24 21:51:06 +05:30
parent 15683ea54d
commit 7aded45d03
19 changed files with 895 additions and 650 deletions
+21 -1
View File
@@ -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
View File
@@ -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}",
+10 -3
View File
@@ -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>
+2 -24
View File
@@ -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'],
+1 -4
View File
@@ -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;
}
+8
View File
@@ -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
+2 -2
View File
@@ -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,
+7 -1
View File
@@ -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",
+1 -4
View File
@@ -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
+69 -5
View File
@@ -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):