The in-app feed was a second copy of the activity log with a red badge on it.
A scan published up to six notifications (started, completed, new subdomains,
new services, new vulnerabilities, coverage), every one of them already an
activity_logs row and every one of them toasted for five seconds. A nightly
twenty-target schedule left forty to a hundred and twenty unread rows.
If it does not want a human to do something, it is not a notification.
Deleted scan_started, scan_completed and scan_cancelled (both the API-side echo
of the user's own Cancel click and the finalize one). whois/ripestat
enrichment_complete became enrichment_incomplete and return None unless
something actually failed.
One digest replaces the five scan templates. scan_digest(...) -> dict | None
takes a ScanDeltas and derives its severity, title, type and deep-link tab from
the worst thing inside it, and returns None when nothing changed, so a quiet
scheduled run is silent. Severity now encodes risk rather than process:
scan_failed is warning, not error, because the bell takes its colour from the
highest unread severity and one flaky run should not read like a KEV.
Toasts are interrupts only: warning and error toast, success and info land
silently in the bell. A toast with a url finally gets a View action; it used to
require an action_label no template ever set.
notifications.project_id scopes the bell to the active project (nullable and
indexed, no FK so a transient row can never block a project delete). The list,
stats, read-all and clear-all endpoints take ?project_id=, and the store drops
SSE events from other projects.
app.tasks.notifications.cleanup runs on beat every six hours. expires_at had
been decorative since nothing ever called cleanup_expired, and the inbox modal
fetched stats.total rows in a single request.
Three bugs this uncovered:
_count_new_subdomains had no baseline gate while services and vulnerabilities
did, so a target's first scan announced its whole inventory as new: the first
gov.np run would have reported 24022 new subdomains. Baselines are now per
dimension, and a run with none reports the census instead.
The delta counts ignored census_only, so a focused rescan both counted as a
baseline and had its own rows announced. Every comparison now joins
scans bs ... scope = 'full' AND bs.started_at < :started.
Measuring the deltas took 16.8s on a 13,528-host scan: the scan-level baseline
was a correlated EXISTS re-evaluated once per row and could not use
ix_subdomains_target_name_discovered, having no name predicate. Hoisting it to
one scan-level check and rewriting the per-row test as a hash anti-join over a
WITH seen AS (SELECT DISTINCT ...) CTE gives the identical answer in 52ms,
verified against a plain EXCEPT query.
Not done, deliberately: is_read is still one global flag, so on a shared
instance one analyst's Mark all as read still applies to everyone; that needs a
notification_reads table. And notifier.wants() still governs external channels
only, the bell having no preference layer, because volume reduction was the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCPTXCr5qPESCt3LxR3329
The dialog could only choose a template. Producing a critical-and-high cut of
an existing report meant duplicating the template, opening the builder, finding
the findings section, clearing four severities, saving, returning and
generating. The decision belongs where the report is asked for.
The template is now a starting point and says so. Contents are picked in the
dialog, grouped and in reading order, each section carrying its own settings
behind a popover; the cover, contents and reference sections are included
without being asked for and the header states it. Settings a section marks as
launch fields are lifted out of the popover onto the dialog itself, so the
severities, the detail depth and the roll-up are one click from open rather
than three. Anything that departs from the template is badged and resettable,
per section and for the report as a whole, and the template is not written to.
The estimate in the rail now moves as the report is shaped, and shows what it
moved from: clearing informational and low findings reads 626 struck through
beside 20, and 28 pages struck through beside 18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
Choosing what a report contains required editing a template. That is the wrong
place for a decision made per report, and the scan launch flow already
established the alternative: a saved configuration seeds the run, the run may
depart from it, and the saved configuration is never touched.
Three declarations put sections on the same footing as stages. SectionRole
separates the content a reader asked for from the furniture around it, so a
picker can offer twenty-one choices and include the cover, the contents and the
reference matter without being asked. Section.order fixes the reading order,
which until now was however the directories happened to sort — the executive
summary came after attack paths because "a" precedes "e". Section.launch_fields
names the settings worth surfacing when the report is generated rather than
only in the builder, mirroring Stage.launch_fields; the findings section
declares its severities, its detail depth, its roll-up and its asset cap.
The estimate had to become honest for any of this to be worth showing. It
counted every finding in the scan whatever the section filtered to, so dropping
three severities moved nothing. It now counts through the same severity filter
the section applies and prices each tier separately, from the real per-severity
issue counts rather than an assumed split: 28 pages against 26 rendered for a
full template, 10 against 9 for critical and high alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
Every weakness got the same page weight. A critical with public exploit code on
eighteen hosts and "TLS Version - Detect" observed ninety-four times were both
written up with a description, an impact, an affected table, a remediation, a
request, a response, a control mapping and references. The chapter was twelve
pages of identical shape, and the findings that matter were buried in it. The
severity chart already refuses to draw all five levels proportionally for this
exact reason; the prose was still doing it.
Two knobs, both on the section so a template decides. "Full detail down to"
keeps evidence and impact at or above a severity, defaulting to medium, and
writes anything below it short — what it is, where it is, what to do, without
the proof. "Roll informational observations into a table" gives the long tail
one row each: severity, check, times seen, hosts and a first location.
The findings chapter goes from 52 pages to 29 on the same scan, and opens with
the criticals. A report with no findings is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
Severity in the app was a mixture of the status scale and the chart scale —
destructive, chart-4, warning, chart-1 — which is the one thing a status
palette must not be. Measured against the app's own surface it fails: the
worst pair, critical against medium, is deltaE 2.0 under protanopia. They are
the same colour to that reader. Critical against high is 12.3 to everyone
else, under the 15 floor.
The ramp the report prints passes both gates all-pairs on the same surface,
12.4 and 16.8, because lightness carries the rank rather than hue alone. A
screen-tuned variant was tried first and is what proved the point: compressing
the lightness range for small marks collapses red, orange and gold back
together. The spread is the mechanism.
So the app takes that ramp verbatim for light and its dark restep for dark,
and severity now means one thing on the screen and on the page. The tokens
were generated by calling shared/utils/color.py, the same code the renderer
uses, so the two cannot drift. Text takes the -ink step: the mark itself is
1.66:1 on white at medium and cannot carry a label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
The theme cards were coloured rectangles with five dots on them. Nothing in
the UI told you what any theme looked like, which made choosing one a guess
and made the whole feature feel unconsidered.
theme-preview.svelte draws a miniature page from the theme's own tokens: the
cover in its own layout, or a body page with the running head, the numbered
heading, severity tiles, ruled prose and a table in the theme's table style.
It is the theme picker in the generate dialog and the Look panel, the art on
theme and template cards, and the thumbnail on every report row. Its type is
sized in cqw against a container, so one component reads correctly at 36px on
a list row and at 260px in a rail.
The generate dialog also could not generate. Opened from Reports there is no
scan and no target, so the estimate returned 400 and the button did nothing.
It now carries its own subject picker, and Generate stays disabled until a
subject is chosen. While it was open: a real ToggleGroup for the formats,
single-line select items that do not fight the check indicator, and a rail
that shows the chosen theme beside what the document will contain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
The first cut was rejected outright, and fairly. Four things were wrong.
The running head and foot sat 1.4mm from the physical sheet: vertical-align:top
on the @top-* margin boxes pins text to the paper, not to the text block. They
now hang off the block with RUNNING_CLEAR of air on the inside edge.
Severity was five saturated hues, restated by every theme and slightly
different in each. It is now one reserved ramp defined once, light rather than
heavy, with lightness carrying the rank so it survives deuteranopia; it clears
the CVD and normal-vision floors all-pairs on white paper. A dark page picks
up the dark restep automatically, so a dark theme carries no severity block at
all. shared/utils/color.py converts through OKLCH, so a theme names one colour
and the document derives the wash a badge sits on and the ink that sits on the
wash — every badge clears 7:1, including one from a theme a user uploads.
Badges are pills, never saturated slabs, and never inline-flex, which
WeasyPrint lays out as a block so the badge stretched its whole column.
Mono is for machine values now — hostnames, evidence, hashes. Labels, table
headers and the running head are the body face, and uppercase_labels finally
does something: the CSS had hard-coded uppercase and ignored the token.
Prose fills the text column, justified and hyphenated, instead of sitting in a
narrow measure with dead space beside it. Chapters still start a new page, but
a document may now ask them to run on, and the page estimate knows the
difference: 52 pages against 52, and 48 against 48.
Two defects the redesign surfaced: the risk summary never passed the variables
its own template reads, so that severity table had always printed empty; and
the standalone HTML export had no margins at all, because @page geometry does
not apply on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
The Google Fonts css2 API answers a modern user agent with one @font-face per
script, all sharing a family and weight and separated only by unicode-range.
WeasyPrint then shapes a run against one subset file and rasterises it from
another: the PDF's text layer read back correctly while the page showed
"ΨΗΦΙΑΚΗ ΥΠΗΡΕΣΙΑ" as "N ĂN ĐỀ ẤU NẤGĂNG". Every non-Latin page title in a
report was gibberish.
Ask the API with a user agent that predates unicode-range and it answers with
one whole file per family, weight and style; subset that here with fontTools
and save it as woff2. The vendoring script now refuses to write a face whose
block still carries a unicode-range, so the split cannot come back unnoticed.
131 files and 3.3 MB become 23 files and 1.2 MB — smaller as well as correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
A design pass over the rendered documents, on the six themes, the print
stylesheet, the charts and the cover artwork. The brief was a baseline that
looked templated; the bar was a client deliverable someone would be proud to
hand over.
The structural failure was the severity chart. On a real scan, 589 informational
findings swamped 20 criticals into an invisible sliver, on the one page everyone
reads. Severity counts are now stat tiles, so the number is always legible, and
the ring and bar draw only the actionable set with the informational total
stated in the caption. That is the dataviz rule that a dominant neutral bucket
is a KPI, not a slice.
The executive page now opens with a verdict band: the gauge, the headline, the
severity tiles, then five surface figures in one row, and only then the prose at
a readable measure. Every cover carries a grade mark, so the report tells you
its answer on the front; on blueprint the reticle artwork is drawn around it.
Findings are a card with a hierarchy and a facts row where a table used to be,
control mapping is a line of text per framework rather than a wall of chips,
tables have a settled rhythm, and the section number sits in the accent.
The warning collector from the previous commit found every WeasyPrint defect
here. font-weight accepts only the hundreds, so two themes had been rendering
headings in a silent bold. content(text) drops the whitespace between an inline
span and its neighbour, so the running header read "1Executive summary" and the
bookmark with it; both now read a precomputed attribute. break-after: avoid on a
div is not honoured, so labels were orphaning from their content. A dark-paper
theme needs its background on @page as well as html, or the margins print white,
which is why noir had a white frame. word-break: break-word is invalid, and the
hostname column in the asset table was being crushed to a sliver by auto layout.
Two string edits in this pass silently missed because ruff had reformatted the
target; reading the function back after each edit is now written down.
Verified on the gov.cy scan: all 26 sections in all six themes, 57 to 60 pages
each, zero renderer warnings.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
Audited every path an image can reach the renderer by, on the principle that
uploads are the only source. Four of the five were already closed, and the one
that mattered most turned out to be closed for a reason worth recording: an SVG
logo cannot beacon or read a file, because references made from inside the SVG
go through the same fetcher, and WeasyPrint's SVG parser does not resolve XML
entities. An <image href> pointing at a listener got zero hits; an XXE entity
leaked nothing. So SVG stays. Rasterising a logo to avoid a risk that is already
closed would cost real print quality for nothing.
The gap was not a URL at all. WeasyPrint passes a PNG stream straight into the
PDF, so the output stays small, but it still decodes the image: inside the 500 KB
logo limit, a 182 KB file decodes to 64M pixels and cost a measured +594 MB of
RSS, and Pillow's 89M default only warns below twice the limit. Across eight
workers that is several gigabytes from a handful of small uploads. The render now
caps decoded pixels, which takes that same payload to +0 MB, and a dropped image
is surfaced as a report warning instead of vanishing.
Two things this exposed about the previous commit. The fetcher was built on
default_url_fetcher, deprecated in WeasyPrint 69; it is now a URLFetcher subclass
pinned to the data and file protocols. And the first attempt at the pixel cap
silently missed, because the edit was written against a function ruff had already
reformatted: the guard existed, nothing called it, and the probe reported success
by rendering the bomb. Measuring the test harness separately from the renderer is
what caught it.
cover_image was a validated field nothing rendered. It is wired now, uploaded and
embedded like the logo, so it is no longer dead configuration.
Verified after the rewrite: 11/11 on the fetcher matrix, a 124-page report with
the uploaded typeface still embedded, and screenshots still embedding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
Uploaded typefaces and instance-wide branding defaults, which together make a
report genuinely white-labellable: a client's own font, a logo set once, and a
theme that names both.
The security half came first, because the logo field was a live SSRF. It was a
free-text string rendered into an <img src>, and WeasyPrint would have fetched
whatever it pointed at from inside the worker. Nothing fetches now, at four
layers. The load-bearing one is a WeasyPrint url_fetcher that allows data: URIs
and files under three known roots and refuses everything else; it was checked
against cloud metadata, internal https, loopback, /etc/passwd, a UNC host, ftp
and protocol-relative references. Above it sit three input guards: a logo must
be embedded data of a known image type, an uploaded theme may not use @import or
a url() that is not data:, and authored markdown loses any image that is not
embedded. A new feature that lets a report name a resource goes through the
fetcher, not around it.
A typeface is bytes, never a link. The family row records files named by us, so
a hostile filename never reaches disk, and the family directory refuses anything
resolving outside itself. A face is accepted only when the bytes agree: the
signature is sniffed and then fontTools opens it and requires outlines, which is
why fonttools is now an api dependency rather than only a WeasyPrint one. A
5-byte file with a valid TTF header and a 4 KB file of padding are both refused.
The family name is banned from carrying quotes or braces because it is written
into a quoted CSS font-family string.
The standalone HTML export now embeds only the families the theme actually
names, which halves it, and no longer carries file:// references that would have
been dead outside the container.
Instance defaults fill only what a template leaves blank, so setting a logo once
never overrides a per-client deliverable that set its own.
Verified: a real TTF family uploaded and embedded in a 53-page PDF under an
uploaded theme with an uploaded logo, and every probe above refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
reNgine 2 could produce a PDF. This produces a deliverable, and it is built
the way scan engines are built: sections are plug-and-play directories under
reports/, each with a pydantic config model that IS the source of truth. That
model feeds GET /reports/catalog, the builder renders its own form from it, and
the API validates against it. Adding a section is a directory. Scan Engine is to
stages what Report Template is to sections.
WeasyPrint 69 renders it, chosen over Chromium and Typst after measuring what
each can actually do. Only real CSS Paged Media gives a table of contents with
true page numbers, running headers, a PDF outline and footnotes; SysReptor
reaches the same verdict and pipes Chromium's HTML into WeasyPrint for exactly
that reason. reNgine drops the Chromium hop because it templates in Jinja and
draws its charts as SVG in Python, so a browser buys nothing and costs 400 MB
per report. Typst was rejected because a user-uploadable theme would then have
to be Typst code rather than CSS.
A theme is data. shared/definitions/report_theme.py is the schema, six themes
ship as YAML, and a user uploads their own; there is no markup in a theme, so
there is no injection surface. Fonts are vendored so an offline instance still
renders in Latin, Greek and Cyrillic.
Everything the document claims is computed once, in reports/analysis/engine.py:
a posture score whose every deduction is named, weaknesses ranked by the signals
behind the rank rather than severity alone, attack paths that chain observations
into a route, a remediation plan ordered by risk removed, per-dimension change
against the previous run, and caveats that state what was never assessed. Two
narrators read that one brief. Without AI the report still argues its case; with
AI the prose is better and the model never sees a row, only the brief, capped and
cached per check. An AI failure falls back and the report still completes.
Generation is a job. POST /reports queues, the worker walks progress over SSE,
and four formats come out of one render: PDF, self-contained HTML, Markdown and
JSON. The estimate reads each section's own limits, so the page count it promises
is close to the one it delivers.
AI moves out of Settings into its own top-level page: connection, a model per
tier, what it may be used for, what it has cost, and the narrative cache.
Verified on a 1,379-host gov.cy scan: 124 pages in 19.5s across 19 sections,
all six themes, and a broken key still producing a complete report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
The SSE route authenticated through CurrentUser, whose session dependency is
only released when the response ends — so every open stream left one connection
idle in transaction for as long as the tab stayed open. The engine was created
with no pool arguments at all, so the ceiling was SQLAlchemy's default 5+10:
fifteen tabs and every DB endpoint blocked for pool_timeout and then 500'd.
Reproduced exactly, and both pinned backends' last query was get_current_user's
SELECT users.
StreamUser opens and closes its own short session, so a stream holds nothing
pooled — 200 concurrent streams now report checked_out 1.
The pool is configured rather than defaulted (10+20, pre-ping, 30m recycle,
fail-fast timeout) and carries an application_name, because a blank one is why
this needed a live reproduction to diagnose. idle_in_transaction_session_timeout
is the backstop: any future leak from any path is reclaimed by Postgres instead
of accumulating, and pre-ping makes the next checkout succeed on a fresh socket.
A stream also no longer outlives its token. It re-checks expiry and revocation
on each heartbeat — JWT plus Redis, no DB — and emits `event: unauthorized`, so
logout actually ends the feed and the client refreshes at once instead of
burning three failed reconnects.
Worker pools are sized per prefork child (8 x 15 was over max_connections) and
the inherited engine is disposed on fork so a child never shares a parent socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015biqVXTng75nnfKXMqm61H
- Added new definitions for dashboard windows and stale target management.
- Expanded domain definitions to include additional public second-level domains.
- Created rescan models and logic to handle asset rechecks based on previous scan results.
- Implemented new scan scopes (FULL and FOCUSED) to differentiate between complete scans and focused rescans.
- Developed asset seed stage to initiate scans from previously identified assets.
- Enhanced scan factory and resolution services to support new scanning configurations and seed assets.
- Updated existing scan models and services to accommodate new features and ensure compatibility.
- Added logging and error handling for focused rescans during scan finalization.
The wordlist field was a free-text absolute path, so the only lists anyone could
actually use were the two baked into the image. Changing that meant a bind mount or a
docker cp — a sysadmin action, not a product one — which in practice meant every
install guessed from the same list. Bruteforce quality is almost entirely wordlist
quality, so the knob was there and nobody could reach it.
Wordlists are now a library, on the same shape as the nuclei check library. Shipped
lists stay in the image and are indexed on read so the picker never starts empty;
uploads land under a `wordlists` volume shared by api and worker. A config field holds
a slug, never a path — `stages/config.py:wordlist(kind)` declares one the way
`threads()` and `rate()` declare theirs, and carries the kind the picker filters on so
a DNS stage is never offered a list of URL paths. `lookup()` still accepts the
absolute paths older engines stored, so an existing engine keeps working.
The UI is the Arsenal's second tab, which is why the Arsenal is now "what your scans
run with" rather than only scanners and their checks: upload, filter by what the words
are, preview the first 200 in the order a scan will read them, delete an upload. Every
list shows the name an engine refers to it by, because that is the thing you type into
a YAML document.
`resolve_path()` refuses anything resolving outside its origin's root and
`delete_custom()` refuses a shipped list — the guard the template library learned the
hard way. A stage that cannot find its list says so and reports PARTIAL rather than
quietly guessing from a different one; that is how the shared volume being absent was
caught during the end-to-end run.
Verified end to end: a seven-word list uploaded through the API, selected by slug,
every passive source disabled — the scan returned six hosts, all attributed to
bruteforce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
The fieldmap scrubber covers every tool record that maps through it, which is not
how endpoints and ports are written: a sitemap entry, an archive record and a
service banner go straight from a provider dataclass into a row. gov.np's URL
discovery lost its whole run to one of them.
Both inventory row builders now scrub, at the same single-writer choke point the
fieldmap uses for tools. `endpoint_inventory._changes` scrubs too — the update path
writes the same untrusted text as the insert.
The savepoint guards added for exactly this caught nothing, because psycopg raises
the NUL complaint while *binding parameters*: SQLAlchemy wraps that above DBAPIError,
so `except DatabaseError` never fired. Both guards catch StatementError now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
Discovery was passive-only — subfinder, ctfr, crtname, assetfinder and certificate
SANs — so anything absent from a public source was absent from the scan, and every
later stage inherited the miss. It now runs two more sources after the passive merge,
carrying SubdomainSource.BRUTEFORCE and PERMUTATION through the same scoping and
merge every other provider uses.
Bruteforce is `dnsx -d <apex> -w <list>` over a wordlist shipped in the repo:
4,860 labels ranked by real-world frequency, regenerated by
scripts/fetch_subdomain_wordlist.py and committed like the port registry, so an
install with no egress still has one. The word budget reads from the top of that
ranking, which is why a smaller budget is simply the first N lines. Permutation is
alterx over the names already found, seeded apex-first because a name one label from
the apex has more siblings worth guessing than a five-label one.
Two things this needed to be worth running at all:
- **A budget, not an idle watchdog.** A guessed name that does not exist produces
no output, so the stall detector that protects the resolver killed both runs at
exactly 90s the first time. Guessing is bounded by total time instead, floored at
5 names/s against a measured 9, and says so when it stops early.
- **Wildcard rejection.** The wildcard probe moved ahead of expansion: on a wildcard
zone every guess answers, and storing 5,000 fake names would be worse than not
guessing. Answers that resolve only to the wildcard address are dropped and
counted in the source's note.
Both are skipped at passive intensity and say why — the stage still touches no
target, but a wordlist run does query the target's own nameservers. Bruteforce
defaults on at 1,000 words (110s measured, 2 hits on vulnweb.com, both attributed
correctly alongside their passive sources); permutations default off, because every
variant is another DNS query.
ProviderResult gained `note` so a source can come up short without being reported as
failed. alterx reads seeds from stdin — `-l <file>` answers "no input found".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
`normalize_path` caps a path at MAX_PATH_LENGTH, but `split_path` takes the last
segment as the filename and that column is varchar(300) — a 1,400-character segment
with no slash in it failed the insert, poisoned the transaction and cost the whole
stage. gov.np hit it once the HTTP probe stopped truncating itself and handed URL
discovery 15,098 web assets instead of 2,687.
MAX_HOST_LENGTH and MAX_FILENAME_LENGTH join the caps already in
shared/definitions/endpoints.py, `split_path` applies the filename cap and
`parse_url` rejects an over-long host rather than storing a truncated one, and the
model's max_length values now read from those constants instead of repeating the
numbers. The batch insert runs in a savepoint and retries row by row, so a row
postgres refuses costs that row and lands in UpsertResult.rejected — the same rule
the HTTP probe now follows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
The savepoint retry re-attached each batch to the session and left it there, undoing
the expunge that keeps up to 128 KiB of stored response body per row from
accumulating — 26,000 rows on a large estate. The rows are detached again once the
savepoint has committed, whether or not any of them were rejected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
Running three dnsx batches at once looked like a 2.7x speedup and was the resolver
dropping answers. The same 1,000 gov.np names answered 838 and 836 across two solo
runs, and 446 when two peer batches ran alongside — a 47% loss, with the peers
degrading identically at 442 and 401. Because the loss is uniform, neither the idle
watchdog nor the peer-median retry can see it: every batch looks equally healthy and
the stage reports success. A full scan at concurrency 3 resolved 10,000 live hosts
where the sequential run resolved 11,267 from the same 13,500-name set.
`dns_batch_concurrency` stays, defaulted to 1, for anyone running resolvers of their
own; its help text now says what was measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
A gov.np HTTP probe ran 66 minutes over 25,916 host and port pairs, then lost every
row: a captured response body carried a NUL byte, postgres refuses NUL in text and in
json alike, and the failing flush rolled back the whole stage transaction. Recording
that failure then failed too — psycopg puts the offending parameters in its error
message, so the NUL travelled into `scan_activities.error` and took the failure path
down with it, leaving the activity RUNNING and the scan unable to finalize.
`shared/utils/text.py` is now the one place that names what postgres will not store.
`strip_nul` runs through every string, list and dict a tool record yields, applied in
`tools/runner/fieldmap.py` so every tool that maps a record is covered rather than
httpx alone. `strip_control` replaces the two identical local regexes in the nuclei
parser and the template indexer, and now also guards the activity log, the activity
timeline and the command recorder — the three places that write a tool's own words.
The HTTP probe's batch flush runs in a savepoint: a row postgres refuses costs that
row, retried one at a time, and the count comes back as a PARTIAL stage naming how
many responses could not be stored. Recording a stage failure is wrapped too, so a
second error while writing the first can never leave a scan running forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
Measured end to end against three targets on the default plan.
Execution order is now declared, not hand-numbered. `Stage.depends_on` names the
stages one may not start before and the registry derives `level` from it by
longest-path layering, rejecting an unknown name or a cycle. Eleven hand-assigned
barriers collapse to seven real ones: target enrichment no longer gates subdomain
discovery, and the whole post-probe fan-out (origin probe, service fingerprint,
WAF detection, URL discovery, vulnerability scan) is one parallel level instead of
five. The frontend plan solver groups by the same derived level.
Four tool invocations were wrong in ways that cost whole scans:
- naabu reads `-timeout` as a go duration, so a bare `-timeout 3000` pinned every
invocation at a fixed ~135s whatever the host and port count. `-timeout 3s`
returns the identical open-port set: 138s -> 5s on three addresses, and on a
1,610-address estate 7,428s (two 3,600s timeouts, stage failed) -> 240s.
- urlfinder has no `-t`; passing one made it exit 2, so the archive URL source had
never returned a row.
- amass v4 prints a relationship graph, not a host list, so its output was parsed
as hostnames and discarded — zero rows in every scan on record while costing the
full 360s tool budget. Parsed properly now, and out of the default source set.
- httpx's stream probe was capped at a 900s total, silently truncating a large HTTP
probe and reporting it as success. It is idle-bounded now, the way dnsx already
was, and reports PARTIAL when it is stopped.
Celery's redis visibility timeout (3600s default) sat below the 8h task limit, so any
stage running over an hour was delivered twice and the whole rest of the scan ran
again — one run duplicated its entire post-port-scan pipeline, another looped for ten
hours. It now sits above the hard limit.
Work inside stages that was serial and did not need to be: resolver batches
(dns_batch_concurrency, default 3), URL discovery providers, robots/sitemap fetches,
and wafw00f (which has no threads of its own) now run concurrently, with DB writes
kept on the stage thread. wafw00f, screenshots and the WAF stage report what they did
not cover instead of reporting a short run as complete. The cancellation flag is
polled at most every two seconds instead of once per resolved name.
Also: WAF detection and screenshots no longer load whole http_asset rows (stored
response bodies included) to write one column; tool output decodes with
errors="replace" so one non-UTF-8 byte cannot discard a run; the worker runs at
CELERY_SCAN_CONCURRENCY (8) because the widest level now holds five stages.
Measured: vulnweb.com 656s -> 218s with identical results. hamropatro.com completes
in 2,273s where it previously never finished. gov.np's port scan 7,428s -> 240s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPueZXQMbjLXMokXeA8LFP
How is the surface hosted? One three-column flow on both Overviews answers it:
resolving web assets → fronting (CDN or WAF edge · cloud provider · direct to
origin) → network (the top nine AS organisations and "Other networks"). It sits
after Vulnerabilities on the scan page and after Needs attention on the target
page, renders only when something resolves, and replaces Composition's "Hosting
networks" ranked list, which said the same thing without the fronting
relationship.
GET /subdomains/hosting-flow aggregates Subdomain LEFT JOIN a distinct-on
HttpAsset per host. Every node and link carries its query token — is:resolved,
is:cdn, is:cloud (new on the host grammar: cdn_type == 'cloud'), asn:<n>, and
"is:resolved and not (asn:… or …)" for the remainder — and the count is a
promise: on gov.cy and hamropatro every figure equals the row count its query
lands on. "Resolving" uses the grammar's own resolved predicate rather than
is_active (they differed by two), and the one expression is shared between
SELECT and GROUP BY because a second call binds a second parameter Postgres
rejects. Unresolved hosts are not a node: they have no flow and they crushed
the scale; the head line says "444 of 1,379 resolve · 26 networks" instead.
The sankey is hand-rolled (no d3-sankey; node_modules is baked into the image):
a minimum node height so a one-asset network stays legible, centred columns,
labels clipped at 30 characters with the full name in the tooltip, and a halo
so mid-column labels read over ribbons. The whole row is the hit target and
every ribbon has a wide transparent twin; nodes are Tab stops with a focus ring
and an anchored tooltip; the tooltip carries the share and the query with a Web
Assets affordance; the traced path wears a moving dash that reduced motion
turns off; the draw-in starts when the panel scrolls into view. Click pivots
into Web Assets with the query.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YK61xuAJGMhq1uniLhHX5
The same small-tweak pass the target page got, on the scan Overview. Tabs and
widgets earn their place with data: a result tab renders when its count is
above zero, or while the scan is live and a planned stage produces one of the
dimension's kinds (SurfaceSpec.kinds mirrors SURFACE_KINDS), and a hidden active
tab falls back to Overview. The vulnerability panel renders only when a
vulnerability scan ran or found something — no "No vulnerability scan ran" card
— and Needs attention only when it has tiles. Panel descriptions are gone: the
panel is the explanation.
Origin exposure moves out of its proof-strip panel into Needs attention as two
tiles — "N origins reachable outside the CDN" and "N addresses serving a
different site" — whose click opens the origin dialog on the first finding of
that kind, and the dialog steps through them ("1 of 2"). The Overview answers
what needs attention; screenshot pairs are evidence for after you decide to act.
The hero's KPI grid picks the column count with the fewest empty slots (4, then
3, then 5) and the last cell spans the remainder, so seven figures no longer
render as 3 + 3 + 1. Structure splits its third column into Endpoint kinds and
Of interest and drops cells with nothing in them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YK61xuAJGMhq1uniLhHX5
The summary rebuild was a card grid: a lot of space to see nothing, the same
fact three times, and a scan table pasted in from the history page. This is the
page rebuilt from an approved mockup as an entity page — Overview · Web Assets ·
DNS · WHOIS · BGP — where the rail is the fact sheet, the main column is the
story, and every fact has one home.
Overview: a five-pill surface strip (count plus one note — added/gone,
sensitive, critical, "so far" while live, dashed "Not scanned" — with a
sparkline over the last six completed runs once two carry a value); Needs
attention tiles ranked critical → warning from the summary's risk figures,
sensitive services and the derived checks; an activity timeline of runs on a
dotted rail whose latest chips read the summary's surface values, so the
timeline never contradicts the strip; and related targets from WHOIS
correlation and certificate names. The sticky rail opens with the globe (the
IPs-covering scan's countries, every dot a pivot), then Scan · Resolution ·
Registration · Routing, with NS, MX and registrar shown as provider name plus
vendored brand logo and a failed lookup's error carried in the group note next
to Refresh.
There is no Scans tab: "All runs" opens /scans?target=<id>, which the scan
history page now reads. DNS, WHOIS and BGP are evidence tabs sharing one
grammar (record-shell + record-group): DNS grouped by type with provider logos,
TXT purpose and private-range warnings; WHOIS as registration facts → EPP status
→ nameservers → contacts; BGP as routing facts → prefixes (family filter,
search, paging) → peers → related prefixes → abuse contacts. A tab renders only
when it has data or its lookup is still pending, so a deep link is not yanked
to Overview mid-load.
WhoisRecordSummary carries target_id so a correlated registrant or nameserver
links to its target. copy-button takes a class that overrides its size and the
org/tag popovers report their patch through onChange. Removes the enrichment/,
summary/ and scan-history components of the previous version.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YK61xuAJGMhq1uniLhHX5
dnsx drops 20–60% of answers under load and exits 0, so a subdomain stage
could store half its hosts unresolved and still read Success. Resolution now
runs in shuffled batches (dns_batch_size) with the thread count clamped to the
range the resolver actually keeps up with; a batch answering far below the
median of its peers is retried, and one that stops producing is abandoned after
dns_idle_timeout instead of the whole run waiting on a total ceiling. What the
resolver managed — batches, retries, stalls — is reported, never assumed.
StageResult gains warnings + partial. The stage runner records a new PARTIAL
activity status carrying the notes, logs the stage outcome as a warning and
emits it live. The run ribbon paints a partial stage amber with a triangle, the
stage dialog shows the note in a warning box, and activityRan() is the one "did
it run" test so progress and summaries count partial as done. reverse_dns
reports the same way when dnsx times out or is missing.
stream_json yields a StreamOutcome (return code, timed_out, record count,
stderr) instead of a bare iterator, kills on idle as well as on total time, and
a timed-out run keeps the records that landed instead of discarding them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YK61xuAJGMhq1uniLhHX5
`/targets/[id]` was a header plus eight tabs, five of which rendered empty
states written before the scan pipeline existed. It is now four real tabs —
Summary, Web Assets, Intelligence, Scans — wearing the scan pages' clothes:
the same sticky counted tab strip, the same Card + PanelHead hairline-cell
panels, and a Scans tab that is the scan history page scoped to the target.
The five result dimensions become one vocabulary. shared/definitions/surface.py
and frontend config/surface.ts name what a scan produces the way the UI names
it — Web Assets, Endpoints, Services, IPs, Vulnerabilities. The scan page's tab
strip, scanCountPills, targetAssetNoun and the activity chart now read from that
one definition, so no screen says "subdomains" or "open ports" any more.
A target's surface is per-dimension "last observed", not "the latest scan".
GET /targets/{id}/summary reports what the most recent scan that actually ran
each dimension found. A dimension nothing has run reads "Not scanned", never 0 —
"we looked and found nothing" and "we never looked" are different facts. Rows
are proof a stage ran, because a cancelled run writes what it found before it is
stopped without recording success, and counts come from the tables rather than
the scans.*_found rollup, which is wrong for those runs. Every figure equals the
row count you land on: 1,379 / 7,982 / 656 / 182 / 626 against the result tabs,
and 37 sensitive, 20 critical, 19 KEV against their filters.
POST /targets/{id}/assets is a SQL rollup of the target's web assets across
every scan — first seen, last seen, scans seen in, and All/Current/Gone tabs.
It is the one asset view a scan page structurally cannot give. A running scan
can never retire an asset, and is_new needs a baseline, the same rule the
query grammars use. A 24k-asset target pages in ~90ms.
Also fixed: the headline read "No change since the previous scan" above a cell
reading "11,436" because two different comparisons shared a screen; formatSeconds
never rolled minutes into hours, so a 12-hour scan read "720m 55s" here and on
the scan history page; and truncate on an inline span in a block table cell does
not clip, so a long page title spilled across three columns.
Removes target-subdomains, subdomain-table and scan-tab-empty. Promotes
panel-head.svelte to a shared primitive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9Gw9RvaR2m6wkZZUdfb4w
The location block gets a button that pivots to the Endpoints tab with host= and path=
tokens, so the outline opens straight to the affected endpoint. A bare host:port
location yields a host-only token instead of being read as a scheme.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KEfo6ipoWMYeHxThmTLRy
Replace the tree rail beside a flat list with one outline: host rows, folder rows and
endpoint leaves in a single grid. Kind is a glyph on the leaf, folders get a glyph for
why they matter, a folder that is only its own index renders as a leaf, and the query
bar is the tree search with matched segments marked.
Defaults are measured: hosts come from a paginated SQL rollup and load their folders
when opened, nothing is open at rest, and a search opens branches only while they fit a
40-row screen budget. Hide static is a real server filter with the count carried by the
facets so the All tab equals the rows.
The tree now carries change and judgement: new and gone counts per node against the
previous scan of the target with a Gone lens, anomaly sentences for folders that are
mostly walled with one open door or mostly fine with a few 5xx, siblings that share a
skeleton folded into one group row whose dir:[...] token lands on exactly its count,
archive-only and robots.txt markers, copy paths as a wordlist, reveal by host= path=
tokens, sticky ancestor crumbs rendered in the sticky head, type-ahead, and a verify-this-
branch action wired to the worker task.
Also: class-aware path interests (a cPanel logo is not an administrative interface),
the tilde backup rule limited to trailing tildes, endpoint_count and a paths: field on
hosts, and a null-safe static predicate.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KEfo6ipoWMYeHxThmTLRy
A Celery task that probes the unchecked, non-static URLs under one folder of a scan's
endpoints with httpx, applies the results through endpoint_inventory.verify (never
upsert, so no source is invented) and records an EndpointCoverage row whose cap_reason
names the branch. Dispatched from the api through celery_dispatch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KEfo6ipoWMYeHxThmTLRy
- Updated highlight-text component to support multiple search terms.
- Modified results-pagination component to make onPageSize optional and conditionally render PageSizeSelector.
- Integrated HostStructure component into web-asset-detail-sheet for improved asset structure visualization.
- Added HostStructureDialog to manage host structure interactions.
- Enhanced web-assets-table to handle structure-related actions and dialogs.
- Introduced new folder glyphs and updated endpoint classification logic in endpoints configuration.
- Added endpoint_count to subdomain type for better tracking of discovered endpoints.
- Implemented new endpoint filtering capabilities in the backend to support static content visibility.
- Updated models and services to accommodate new endpoint structures and classifications.
A scan no longer needs a saved engine. The launch dialog has two
configuration modes: Scan Engine (default, runs a saved engine exactly
as configured, shown as a pipeline and its YAML) and Custom Scan (pick
the capabilities to run; dependent and supporting stages are included
automatically). Targets are created on launch from typed values, the
scan context is a first-class field, and "Run again" restores the
engine or the selection that ran.
Backend
- scans.engine_id is nullable; an absent engine resolves through
AdHocEngine and the run is named after its capabilities (plan_label)
- ScanCreate/ScanBatchCreate accept target_value(s) (create or reuse via
TargetService.ensure_targets), a per-run intensity, and preview works
for a target that does not exist yet
- every stage declares consumes/produces (AssetKind), group (StageGroup)
and role (capability | support); the registry validates them and the
catalog serves them with groups and seed_produces
- stage_effects walks levels so a producer only feeds later levels and
marks an unfed consumer skipped_no_input
Frontend
- components/scans/launch/: launch-dialog, launch-state, target-picker,
plan-picker (segmented Scan Engine | Custom Scan, pipeline + YAML
Config views, capability pills with full per-stage settings),
stage-pill, stage-config-popover, launch-summary + what-runs,
launch-context-field, launch-context-form, save-engine-dialog
- utilities/launch-plan.ts resolves implied producers and following
support stages; the last engine and last custom selection persist
- quick-scan-fields offers presets and engines in one select; the
command palette offers "Scan <value>"; scan list action is "Run again"
- effect-panel drops its explanatory header; NO_CONTEXT_LABEL is
"No scan context"; launch-modal and execution-preview are removed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014erb6Jbm6v9sxPgYFBxB7E
The lightbox capped its dialog width via an unprefixed max-w-5xl, which
lost to the base Dialog.Content's sm:max-w-lg, and shrank tall
screenshots to fit an 80vh cap via object-contain. Now the dialog opens
up to md:max-w-5xl with a titled header, and the image scrolls inside
a bounded ScrollArea instead of being squeezed or overflowing the
viewport.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xo8Yn18vYa8nGhj6QnVeF
Squaring the corners while stuck was the wrong call — the radius is the
look. Reverted that, along with the scroll-listener stuck detection it
needed, and the head now sticks with md:pt-2 above the bar instead. The
wrapper's bg-background fills those 8px, so the rounded corners sit
against page colour with no rows ghosting through, and the bar reads as
floating below the tab strip rather than butted against it.
Measured on Web Assets, Services and Vulnerabilities: stuck head top 106
against a strip bottom of 107, bar top 114 — a 7px visible gap — with the
radius holding at 14px in both the resting and stuck states. Costs 8px at
rest too, where the gap was already ~66px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sticky query-bar head and the pinned actions cell were both z-10, so
document order decided and the rows — which come later — won. A row's
actions cell painted on top of the stuck search bar: a stray "..." floating
over it, and its right 60px covering the Syntax button, which is why that
label rendered clipped as "Synta". Stacking is now a ladder: rows and the
pinned cell at 10, results head at 20, scan tab strip at 30 (was 20, moved
so the head cannot cover it), app top bar at 50.
The head's sticky wrapper was also fully transparent while the bar inside
it carries rounded-t-xl, so rows scrolling underneath showed through the
14px corner notches. The wrapper now carries bg-background, and the bar
squares its top corners while stuck so it meets the tab strip flush
instead of leaving a bitten-out corner. Stuck state is measured in the
shared query bar against its own sticky offset and scroll root, so all
four tables get it; a tab whose content does not scroll never sticks and
keeps its radius.
Verified on all four tables: radius 14px at rest, 0px stuck, 14px again on
scroll back, and zero pinned cells painting at or above the head's layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vuln-row and issue-row applied pinTone to ACTIONS_PIN itself
(`class="{ACTIONS_PIN} {pin}"`) while the three other results tables put it
on the ACTIONS_BODY child. Two consequences, both only in the vulnerability
tables:
- the transition-colors added to ACTIONS_BODY never reached them, so the
actions column still snapped to full tint while the row faded over 150ms
— the flash behind the row actions.
- that class string is plain interpolation, not cn(), so the element
carried both bg-card and group-hover:bg-muted/30. The variant is more
specific, so on hover it REPLACED the opaque backdrop instead of layering
over it, and row content scrolled under the sticky column showed through.
Moving {pin} down one level fixes both and leaves all six call sites
(five rows + the header spacer) with one shape. Measured on both the Issues
and Findings views: row and body alpha now match frame by frame through the
fade, and the pin holds oklch(0.205 0.01 265) throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every results row root carries transition-colors, so its hover background
fades over 150ms. The pinned actions cell that receives pinTone carried
none, so it snapped straight to the final tint — measured at 49ms into a
hover, the row is at alpha 0.000 while the cell is already at 0.300. For
that ~180ms the actions column reads as a lighter box behind the row
actions, then vanishes as the row catches up; moving down the list flashes
it once per row, and again in reverse on leave. Both now track identically
frame by frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ACTIONS_BODY reserved 56px (sm:w-14) but every table renders two icon
buttons in it. With justify-end the surplus overflowed to the LEFT, past
the sticky cell's opaque bg-card, so hovering the leftmost action painted
its bg-accent fill over the neighbouring column and over content scrolled
underneath the pinned cell. Web Assets was worst at 10px (two 32px
buttons + a 2px gap = 66px); IPs, Services and Vulnerabilities overflowed
2px with 28px buttons.
The cell is a fixed width because list-header.svelte renders a matching
spacer, so it has to fit the widest row: one button size across all four
sibling tables (size-7, which three already used) and 60px of cell.
Measured after: 0 buttons overflow on any tab, row and header pins both
report 60px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A finding is corroborated when another check fired at the same matched_at,
names the same CVE or CWE, and neither is informational or suppressed. The
peer carries its basis, the shared identifiers and its scanner, so a second
scanner confirming the same weakness is distinguishable from a second nuclei
template. Co-location alone no longer counts: four unrelated TLS defects on
one endpoint used to vouch for each other.
Surface it the way the grammar surfaces everything else: an is:corroborated
flag, a Confirmed quick filter, two counted example leads, a Location group
dimension, and a tiebreaker in the risk sort after severity and KEV. The row
carries a confirmed badge beside new and KEV instead of trailing text on the
origin line; the detail sheet states each peer's basis and links to the other
findings at that location.
Add a btree over (scan_id, matched_at) - the equality lookup was probing the
trigram GIN index at 8.2ms a key - and disable JIT on the vulnerability query
paths, where the set-returning expansion's 100-rows-a-row estimate bought
267ms of compilation per request. On 60k findings: search 510ms to 240ms,
issues 1380ms to 440ms (830ms before the feature existed).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZKQQx39KeiDo8QE9XXzf3
Modals rendered at the darkest surface in the palette. dialog-content,
alert-dialog-content and sheet-content carried bg-background (dark 0.145)
while every panel uses bg-card (0.205) and every floating surface uses
bg-popover (0.269), so the highest-elevation surface was the darkest one,
on top of a bg-black/50 overlay. Harmless upstream, where dark --card and
--background are both 0.145; this theme elevated them and the three modal
primitives never moved. Ladder is now page < card/modal < popover, with
chart-tooltip joining the popover tier. Light mode is unchanged.
The svelteStyleCssLeakGuard recovered a Svelte component's CSS by regex,
handing the raw <style> body to the CSS pipeline and bypassing the
compiler. bits-ui hides native scrollbars with
`:global([data-scroll-area-viewport]) { scrollbar-width: none }`, so
`:global(...)` shipped literally, Chromium dropped the rule as an invalid
selector, and because bits viewports are `overflow: scroll` a 15px native
bar was reserved and painted on every ScrollArea — including ones with
zero overflow, which is why one sat under every tab strip. The guard now
compiles with svelte/compiler, which also stops library scoped styles
(svelte-sonner Toaster, layerchart Legend) leaking unscoped app-wide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scan row whose execution_config was missing target_value/target_type
raised a pydantic ValidationError inside the pagination transformer, so
one bad row returned 500 for the entire /scans page. _resolved_config
now drops the offending keys, fills the identity fields and rebuilds,
logging a warning instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wg6h3FMyybV7ZFGWrEou5
- Integrated Hint component to display tooltips for scan steps, IP addresses, service rows, and other elements across multiple Svelte components.
- Enhanced accessibility and usability by providing contextual information on hover for elements such as scan engines, private addresses, and filter options.
- Updated relevant components including live-scan-card, ip-hover-card, ip-row, origin-dialog, attention-panel, hero-panel, ranked-list, query-suggestions, service-row, group-list, web-asset-detail-sheet, asset-row, scan-filters, scan-list-item, and others.
bits-ui's scroll content is min-width:fit-content, so a row sizes to its
columns, not to the scrollport. Measured on the Services table at a 1078px
viewport: the row was 1264px and the ... cell sat at x=1497-1553, entirely
outside the visible area. And when the columns are narrower than the port, the
capped grow column leaves the slack *after* the last cell, so ... floated in
from the right instead of sitting on the edge.
The actions cell is now sticky right-0 with ml-auto, over an opaque bg-card
plate carrying the row's own tone, so it lands on the edge with free space and
stays there under scroll. Shared as ACTIONS_PIN/ACTIONS_BODY in table/columns
with rowTone/pinTone, replacing the tone ternary that was copied verbatim into
all three rows.
Measured after (header vs row, every column): drift 0, pin delta 0 on
web-assets, services and ips.
Services table, from the same review:
- sensitive is the endpoint's own colour now, not a badge beside it
- the service logo leads the row in a class-tinted 24px tile; the 6px dot it
replaces was carrying the same information invisibly
- Software gets w-56 and wraps instead of truncating, so a full version string
reads: OpenSSH_9.6p1 Ubuntu-3ubuntu13.18, MySQL 5.7.33-0ubuntu0.16.04.1
- one host prints no count; the hostname is already right there
- host chips size to the column instead of a 128px cap
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnC2xMMzV6WQnpDp5zcXoU