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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions TODO-minor-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# TODO — minor findings

Recorded: 2026-10-25
Source: validation audit of commit `0b6ebe1` (per-target HTTP catch-all override)
Verdict: no functional bugs. Items below are polish/consistency, none block use.

Status: items 1–3 fixed in the follow-up commit (see git log). Items 4–5 remain
open by design / pending a decision.

## 1. List endpoints emit empty override strings (API consistency)

`GET /targets` (handleListTargets) and the `POST /targets` create response
build `models.Target` without the new catchall fields, so JSON contains:

"catchall_detection": "",
"catchall_effective": false

The detail endpoint (`GET /targets/{id}`) always returns `inherit|on|off`.
The UI reads these fields only from the detail endpoint, so there is no UI
effect today. External API clients could misread the empty string.

Fix options:
- Fill the fields in the list/create handlers (compute like handleGetTarget), or
- add `omitempty` to both JSON tags in `internal/models/models.go`.

## 2. Doc comment misattached (cosmetic)

In `internal/scanner/http_catchall.go`, the `runCatchallPass` doc comment now
sits above `catchallEnabledForTarget` because the helper was inserted between
the comment and its function. `runCatchallPass` has no doc comment of its own.

Fix: move the comment down, or give each function its own short comment.

## 3. Silent UI failure on catch-all save (UX)

`saveCatchall` in `frontend/src/pages/TargetDetail.tsx` swallows errors
(`catch { /* ignore */ }`), same pattern as `saveMonitor`. On a failed PUT the
select snaps back to the previous value with no toast.

Fix: add an error toast (the page already has `addToast`).

## 4. DB error during override lookup falls back to global (edge)

`catchallEnabledForTarget` returns the global config when the targets query
errors. An explicitly-Off target could therefore run one catch-all pass during
a transient DB error. The pass is best-effort by design; noted so the behavior
is not a surprise.

Optional fix: fail closed for explicit overrides by caching the resolved value
per scan instead of re-querying.

## 5. Turning the override Off does not unmark existing rows (by design)

Marked rows keep `catchall = 1` and stay hidden from the Hosts tab, reports,
graph, stats and wordlist learning. This is the mark-never-delete design: the
override only controls FUTURE passes.

Optional follow-up (needs a decision): a small "unmark" action or a cleanup
query (`UPDATE subdomains SET catchall = 0 WHERE target_id = ?`) exposed in
the UI.
14 changes: 12 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"diff": "^9.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.18.3",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/layout/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export const TopBar = () => {

return (
<>
<header className="app-topbar h-[76px] flex items-center justify-between gap-3 px-3 sm:px-6 xl:px-8 shrink-0">
<header className="app-topbar relative z-40 h-[76px] flex items-center justify-between gap-3 px-3 sm:px-6 xl:px-8 shrink-0">
{/* Breadcrumbs */}
<div className="flex items-center gap-2 min-w-0">
<button type="button" onClick={() => setMobileNavOpen(true)} aria-label="Open navigation"
Expand Down
120 changes: 120 additions & 0 deletions frontend/src/components/targets/ChangeDiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useState } from 'react'
import { Modal, CopyButton } from '../ui'
import { findings as findingsApi } from '../../lib/api'
import { diffLines } from 'diff'
import { cn, truncate } from '../../lib/utils'
import type { FileBodyResponse } from '../../types'

type Props = {
targetId: string
url: string
kind: 'css' | 'js'
onClose: () => void
}

// Line-level diff for a monitored stylesheet/script change (css_change /
// js_change rows). Both sides are read from the body captured at detection time
// (monitor.go's css_files/js_files) — never a live re-fetch: a live fetch would
// show today's bytes on both sides and pull a possibly third-party URL through
// a fresh GET on every drawer open.
export function ChangeDiffViewer({ targetId, url, kind, onClose }: Props) {
const [current, setCurrent] = useState<FileBodyResponse | null>(null)
const [previous, setPrevious] = useState<FileBodyResponse | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)

useEffect(() => {
let alive = true
setLoading(true)
setError('')
setCurrent(null)
setPrevious(null)
Promise.all([
findingsApi.fileBody(targetId, kind, url, 'current'),
findingsApi.fileBody(targetId, kind, url, 'old'),
])
.then(([cur, prev]) => {
if (!alive) return
setCurrent(cur)
setPrevious(prev)
setLoading(false)
})
.catch(e => {
if (!alive) return
setError(e instanceof Error ? e.message : 'Failed to load stored file bodies')
setLoading(false)
})
return () => { alive = false }
}, [targetId, kind, url])

const hunks = useMemo(() => {
if (!current || !previous) return null
return diffLines(previous.content || '', current.content || '')
}, [current, previous])

const added = (hunks || []).reduce((n, h) => n + (h.added ? (h.count || 0) : 0), 0)
const removed = (hunks || []).reduce((n, h) => n + (h.removed ? (h.count || 0) : 0), 0)
// Identical bytes on both sides but different recorded hashes ⇒ the 256 KB
// capture cap cut the tail: the change is real, it just sits past the stored
// content.
const truncated = !!previous?.captured && !!current?.captured && added === 0 && removed === 0 && previous.hash !== current.hash

return (
<Modal open onClose={onClose} width="xl"
title={`${kind === 'css' ? 'Stylesheet' : 'JavaScript'} diff — ${truncate(url, 80)}`}
footer={<span className="text-xs text-text-muted"><span className="text-severity-low">+{added.toLocaleString()}</span>{' / '}<span className="text-severity-medium">−{removed.toLocaleString()}</span> lines</span>}>
<p className="font-mono text-[11px] text-text-muted break-all mb-3">{url}</p>
{loading && <p className="text-xs text-text-muted py-8 text-center">Loading stored snapshots…</p>}
{!loading && error && <p className="text-xs text-severity-medium py-8 text-center">{error}</p>}
{!loading && !error && (
<div className="rounded-lg border border-border overflow-hidden">
{!previous?.captured || !current?.captured ? (
<p className="px-3 py-2 text-[11px] text-text-muted bg-surface-3 border-b border-border">
{!previous?.captured && !current?.captured
? 'Neither side was captured — this row predates content tracking. Recorded hashes only.'
: !previous?.captured
? 'Original content was not captured — this row predates content tracking. Current capture shown read-only.'
: 'Current content was not captured. Stored original shown read-only.'}
</p>
) : truncated ? (
<p className="px-3 py-2 text-[11px] text-severity-medium bg-surface-3 border-b border-border">
Both snapshots hold identical bytes but the recorded hashes differ — the change sits past the 256 KB capture cap.
</p>
) : null}
{previous?.captured && current?.captured ? (
<div className="max-h-[55vh] overflow-y-auto">
{(hunks || []).length === 0 && (
<p className="px-3 py-2 text-[11px] text-text-muted">No stored content on either side.</p>
)}
{(hunks || []).map((h, i) => (
<div key={i} className={cn(h.added && 'bg-severity-low/10', h.removed && 'bg-severity-medium/10')}>
{h.value.replace(/\n$/, '').split('\n').map((line, j) => (
<div key={j} className={cn('px-3 py-px font-mono text-[11px] whitespace-pre-wrap break-all', h.added ? 'text-severity-low' : h.removed ? 'text-severity-medium' : 'text-text-muted')}>
<span className="select-none opacity-60 mr-2">{h.added ? '+' : h.removed ? '−' : ' '}</span>{line}
</div>
))}
</div>
))}
</div>
) : (
<div className="max-h-[55vh] overflow-y-auto">
{((current?.captured ? current.content : previous?.content) || 'Not captured.').split('\n').map((line, j) => (
<div key={j} className="px-3 py-px font-mono text-[11px] whitespace-pre-wrap break-all text-text-muted">{line}</div>
))}
</div>
)}
<div className="flex items-center justify-between gap-3 px-3 py-2 border-t border-border bg-surface-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[10px] uppercase tracking-wider text-text-muted shrink-0">Old ({previous?.hash ? previous.hash.slice(0, 12) : '—'})</span>
{previous?.captured ? <CopyButton text={previous.content} /> : <span className="text-[10px] text-text-muted">not captured</span>}
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-[10px] uppercase tracking-wider text-text-muted shrink-0">Current ({current?.hash ? current.hash.slice(0, 12) : '—'})</span>
{current?.captured ? <CopyButton text={current.content} /> : <span className="text-[10px] text-text-muted">not captured</span>}
</div>
</div>
</div>
)}
</Modal>
)
}
14 changes: 12 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {
Target, Subdomain, HTTPService, JSFile, JSFinding,
Parameter, DirectoryFinding, AdminPanelFinding, BackupFinding, OpenRedirectFinding,
NucleiFinding, VulnFinding, MonitoringChange, Task, TaskLog, TaskPhase, DashboardStats, AttackPath,
NetworkService, IngramCamera, Asset,
NetworkService, IngramCamera, Asset, FileBodyResponse,
BountyProgram, BountySyncState, BountyScopeEvent,
} from '../types'

Expand Down Expand Up @@ -301,7 +301,14 @@ export const findings = {
req<{ triage: string }>(`/targets/${id}/findings/${fid}/triage`, {
method: 'POST', body: JSON.stringify({ triage, note }),
}),
monitoringChanges: (id: string) => req<MonitoringChange[]>(`/targets/${id}/monitoring-changes`),
// Changes are paged server-side, newest first (500 per page) — pass the
// number of rows already loaded to pull the next (older) slice.
monitoringChanges: (id: string, offset = 0) =>
req<MonitoringChange[]>(`/targets/${id}/monitoring-changes${offset > 0 ? `?offset=${offset}` : ''}`),
// Stored capture of a monitored file's body at detection time — NOT a live
// fetch. Backs the Changes-tab diff drawer (css_change / js_change rows).
fileBody: (id: string, kind: 'css' | 'js', url: string, side: 'current' | 'old' = 'current') =>
req<FileBodyResponse>(`/targets/${id}/${kind}-files/body?url=${encodeURIComponent(url)}&side=${side}`),
ingram: (id: string) => req<IngramCamera[]>(`/targets/${id}/ingram`),
// Global findings across all of the caller's targets (top-level Findings page).
all: (params?: Record<string, string>) => {
Expand Down Expand Up @@ -332,8 +339,11 @@ export interface ApiKeyState { name: string; label: string; hint: string; set: b
export interface ScanConfig {
dns_resolvers: string[]
subdomain_recursion_depth: number
subdomain_recursion_enabled: boolean
http_catchall_detection: boolean
nuclei_exclude_template_ids: string[]
nuclei_extra_template_repos: string[]
monitor_modules: string[]
default_dns_resolvers: string[]
default_nuclei_repos: string[]
}
Expand Down
Loading