diff --git a/README.md b/README.md index 1b71c48..31532e4 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ workflow built for triage—not another folder full of uncorrelated text files. testing for authorized network engagements. - **Designed for long scans:** resumable tasks, persistent data, monitoring, live logs, reports and resource-aware scheduling. +- **Program-aware projects:** browse public HackerOne, Bugcrowd, Intigriti and + YesWeHack programs, filter their declared scope, import only selected assets + and review scope changes before Reconner expands a scan. - **Works on phones:** the operations dashboard, navigation, scan activity and update center adapt to mobile screens and touch input. @@ -159,6 +162,39 @@ Requests also share a bounded per-host gate so independently scheduled modules do not multiply into accidental WAF pressure. Different targets can still make progress in parallel within the configured CPU and memory budget. +## Bug bounty catalog and Projects + +The **Bounty programs** menu normalizes public HackerOne, Bugcrowd, Intigriti +and YesWeHack programs into one local catalog. It supports search and server-side pagination plus +filters for provider, live status, bounty/VDP, declared in-scope asset count, +wildcards, asset type, reward, industry, Safe Harbor and start/update order. +The cache refreshes every six hours; an administrator can also request a +background refresh from the dashboard. Provider indexes are cached first; +structured scope is fetched lazily when a program is opened/imported, while +programs linked to monitored Projects refresh every six hours. This keeps memory, +bandwidth and provider load bounded. A provider outage leaves the last good +catalog available and retries with backoff. + +Opening a program loads its current structured scope. Select the assets you want +and create a Project, or create a Project manually and mix domains, wildcards, +exact URLs/pages, JavaScript files, APIs, IPs and CIDRs. Exact page and JS assets +are seeded directly into their relevant analysis pipeline instead of being +reduced to a hostname. + +Program scope remains controlled by the operator: + +- newly published upstream assets become pending scope events and are never + scanned before explicit approval; +- removed, private or submission-ineligible assets are suspended immediately, + while their findings and history remain intact; +- modified scope instructions or eligibility generate a review event and a + dashboard notification; +- monitoring records normalized page/HTTP/security/JavaScript diffs, then + schedules only the relevant verification modules when something changes. + +The catalog is a convenience cache, not legal authorization. Always verify the +official program brief, exclusions and rules of engagement before scanning. + ## Web reconnaissance and DAST Reconner includes: diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 07d3ef5..583add8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "reconner-frontend", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "reconner-frontend", - "version": "1.1.0", + "version": "1.3.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 8312108..d741809 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "reconner-frontend", - "version": "1.1.0", + "version": "1.3.0", "type": "module", "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ecba5a2..f176381 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import TargetDetail from './pages/TargetDetail' import Findings from './pages/Findings' import Tasks from './pages/Tasks' import System from './pages/System' +import BountyPrograms from './pages/BountyPrograms' import { Spinner } from './components/ui' function AuthGuard({ children }: { children: React.ReactNode }) { @@ -45,6 +46,7 @@ export default function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 547d010..fd0cf16 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -31,6 +31,7 @@ const Icons = { ), web: I('M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.8 5.7 3.8 9S14.5 18.5 12 21c-2.5-2.5-3.8-5.7-3.8-9S9.5 5.5 12 3z'), network: I('M6 4h12M6 4v4M18 4v4M4 8h16v4H4V8zM8 16h8M8 16v4M16 16v4M6 12v4M18 12v4'), + bounty: I('M8 4h8v3a4 4 0 0 1-8 0V4zM6 5H4v2a4 4 0 0 0 4 4M18 5h2v2a4 4 0 0 1-4 4M12 11v5M8 20h8M9 16h6v4H9z'), findings: I('M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z'), tasks: I('M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01'), system: I('M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'), @@ -64,7 +65,8 @@ export const Sidebar = () => { const groups: NavGroup[] = [ { label: 'Command center', items: [{ to: '/', label: 'Overview', Icon: Icons.dashboard }] }, { label: 'Operations', items: [ - { to: '/targets', label: 'Targets', Icon: Icons.targets, badge: targetsN > 0 ? { text: String(targetsN) } : null }, + { to: '/bounty-programs', label: 'Bounty programs', Icon: Icons.bounty }, + { to: '/targets', label: 'Projects', Icon: Icons.targets, badge: targetsN > 0 ? { text: String(targetsN) } : null }, { to: '/findings', label: 'Findings', Icon: Icons.findings }, { to: '/tasks', label: 'Scan activity', Icon: Icons.tasks, badge: runningN > 0 ? { text: String(runningN), live: true } : null }, ] }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 468dbbc..a20f1f7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,6 +3,7 @@ import type { Parameter, DirectoryFinding, BackupFinding, OpenRedirectFinding, NucleiFinding, VulnFinding, MonitoringChange, Task, TaskLog, DashboardStats, AttackPath, NetworkService, IngramCamera, Asset, + BountyProgram, BountySyncState, BountyScopeEvent, } from '../types' const BASE = '/api' @@ -82,9 +83,9 @@ export const targets = { req('/targets', { method: 'POST', body: JSON.stringify(data) }), networkServices: (id: string) => req(`/targets/${id}/network-services`), assets: (id: string) => req(`/targets/${id}/assets`), - addAsset: (id: string, value: string, name: string) => - req(`/targets/${id}/assets`, { method: 'POST', body: JSON.stringify({ value, name }) }), - updateAsset: (id: string, aid: string, data: { name?: string; value?: string }) => + addAsset: (id: string, value: string, name: string, assetType?: string) => + req(`/targets/${id}/assets`, { method: 'POST', body: JSON.stringify({ value, name, asset_type: assetType }) }), + updateAsset: (id: string, aid: string, data: { name?: string; value?: string; asset_type?: string }) => req(`/targets/${id}/assets/${aid}`, { method: 'PATCH', body: JSON.stringify(data) }), deleteAsset: (id: string, aid: string) => req(`/targets/${id}/assets/${aid}`, { method: 'DELETE' }), @@ -134,6 +135,28 @@ export const targets = { }, } +export interface BountyProgramList { + programs: BountyProgram[] + total: number + page: number + limit: number +} + +export const bounty = { + list: (params?: Record) => { + const q = params ? '?' + new URLSearchParams(params).toString() : '' + return req(`/bounty/programs${q}`) + }, + get: (id: string) => req(`/bounty/programs/${id}`), + status: () => req('/bounty/status'), + sync: () => req<{ status: string }>('/bounty/sync', { method: 'POST' }), + createProject: (id: string, body: { name?: string; description?: string; priority?: string; notes?: string; asset_ids: string[]; monitor_enabled: boolean; monitor_interval_hours: number }) => + req<{ id: string; url: string }>(`/bounty/programs/${id}/projects`, { method: 'POST', body: JSON.stringify(body) }), + events: (targetId: string) => req(`/targets/${targetId}/bounty-events`), + resolveEvent: (targetId: string, eventId: string, decision: 'approve' | 'reject') => + req<{ status: string }>(`/targets/${targetId}/bounty-events/${eventId}`, { method: 'POST', body: JSON.stringify({ decision }) }), +} + export const findings = { subdomains: (id: string, p?: Record) => req(`/targets/${id}/subdomains${p ? '?'+new URLSearchParams(p) : ''}`), httpServices: (id: string) => req(`/targets/${id}/http-services`), diff --git a/frontend/src/pages/BountyPrograms.tsx b/frontend/src/pages/BountyPrograms.tsx new file mode 100644 index 0000000..5188eed --- /dev/null +++ b/frontend/src/pages/BountyPrograms.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { bounty as bountyApi } from '../lib/api' +import { Badge, Button, EmptyState, Input, Modal, Skeleton } from '../components/ui' +import { useAuthStore } from '../store/auth' +import { useUIStore } from '../store/ui' +import { cn, timeAgo } from '../lib/utils' +import type { BountyAsset, BountyProgram, BountySyncState } from '../types' + +type Filters = { + search: string; provider: string; bounties: string; wildcard: string; assetType: string + minAssets: string; maxAssets: string; minReward: string; programType: string + safeHarbor: string; industry: string; status: string; sort: string +} + +const initialFilters: Filters = { + search: '', provider: '', bounties: '', wildcard: '', assetType: '', minAssets: '', maxAssets: '', minReward: '', + programType: '', safeHarbor: '', industry: '', status: 'live', sort: 'newest', +} + +const providerLabel = (p: string) => ({ hackerone: 'HackerOne', bugcrowd: 'Bugcrowd', intigriti: 'Intigriti', yeswehack: 'YesWeHack' }[p] || p) +const money = (cents: number, currency = 'USD') => cents > 0 + ? new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 }).format(cents / 100) + : '—' + +const providerTone = (p: string) => ({ + hackerone: 'border-orange-400/30 bg-orange-400/10 text-orange-300', + bugcrowd: 'border-red-400/30 bg-red-400/10 text-red-300', + intigriti: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300', + yeswehack: 'border-violet-400/30 bg-violet-400/10 text-violet-300', +}[p] || 'border-white/20 bg-white/5 text-text-secondary') + +export default function BountyPrograms() { + const navigate = useNavigate() + const { user } = useAuthStore() + const { addToast } = useUIStore() + const [filters, setFilters] = useState(initialFilters) + const [programs, setPrograms] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [loading, setLoading] = useState(true) + const [statuses, setStatuses] = useState([]) + const [syncing, setSyncing] = useState(false) + const [detail, setDetail] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [assetSearch, setAssetSearch] = useState('') + const [paidOnly, setPaidOnly] = useState(false) + const [assetLimit, setAssetLimit] = useState(100) + const [projectName, setProjectName] = useState('') + const [monitor, setMonitor] = useState(true) + const [monitorHours, setMonitorHours] = useState(12) + const [creating, setCreating] = useState(false) + + const query = useMemo(() => { + const q: Record = { page: String(page), limit: '30', sort: filters.sort } + if (filters.search.trim()) q.search = filters.search.trim() + if (filters.provider) q.provider = filters.provider + if (filters.bounties) q.bounties = filters.bounties + if (filters.wildcard) q.wildcard = filters.wildcard + if (filters.assetType) q.asset_type = filters.assetType + if (filters.minAssets) q.min_assets = filters.minAssets + if (filters.maxAssets) q.max_assets = filters.maxAssets + if (filters.minReward) q.min_reward_cents = String(Number(filters.minReward) * 100) + if (filters.programType) q.type = filters.programType + if (filters.safeHarbor) q.safe_harbor = filters.safeHarbor + if (filters.industry.trim()) q.industry = filters.industry.trim() + if (filters.status) q.status = filters.status + return q + }, [filters, page]) + + const load = async () => { + setLoading(true) + try { + const result = await bountyApi.list(query) + setPrograms(result.programs || []); setTotal(result.total || 0) + } catch (e) { addToast('error', e instanceof Error ? e.message : 'Could not load bounty catalog') } + finally { setLoading(false) } + } + + const loadStatus = () => bountyApi.status().then(setStatuses).catch(() => {}) + useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t) }, [query]) + useEffect(() => { + loadStatus(); const timer = setInterval(() => { + bountyApi.status().then(next => { setStatuses(next); if (next.some(s => s.status === 'running')) load() }).catch(() => {}) + }, 15000) + return () => clearInterval(timer) + }, [query]) + useEffect(() => { setPage(1) }, [filters]) + + const openProgram = async (p: BountyProgram) => { + setDetailLoading(true); setDetail(p); setAssetSearch(''); setPaidOnly(false); setAssetLimit(100); setSelected(new Set()); setProjectName(p.name) + try { + const full = await bountyApi.get(p.id); setDetail(full) + setSelected(new Set((full.assets || []).filter(a => a.active && a.in_scope && a.eligible_submission).map(a => a.id))) + load() + } catch (e) { addToast('error', e instanceof Error ? e.message : 'Could not fetch current program scope'); setDetail(null) } + finally { setDetailLoading(false) } + } + + const triggerSync = async () => { + setSyncing(true) + try { await bountyApi.sync(); addToast('info', 'Catalog refresh started in the background'); setTimeout(loadStatus, 800) } + catch (e) { addToast('error', e instanceof Error ? e.message : 'Could not start catalog refresh') } + finally { setSyncing(false) } + } + + const matchedAssets = (detail?.assets || []).filter(a => { + if (!a.active || !a.in_scope || !a.eligible_submission) return false + if (paidOnly && !a.eligible_bounty) return false + const q = assetSearch.toLowerCase().trim() + return !q || a.identifier.toLowerCase().includes(q) || a.asset_type.toLowerCase().includes(q) || (a.instruction || '').toLowerCase().includes(q) + }) + const visibleAssets = matchedAssets.slice(0, assetLimit) + useEffect(() => { setAssetLimit(100) }, [assetSearch, paidOnly, detail?.id]) + const visibleSelected = visibleAssets.length > 0 && visibleAssets.every(a => selected.has(a.id)) + const toggleAsset = (id: string) => setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) + const toggleVisible = () => setSelected(prev => { + const n = new Set(prev); if (visibleSelected) visibleAssets.forEach(a => n.delete(a.id)); else visibleAssets.forEach(a => n.add(a.id)); return n + }) + + const createProject = async () => { + if (!detail || selected.size === 0) return + setCreating(true) + try { + const created = await bountyApi.createProject(detail.id, { + name: projectName.trim() || detail.name, description: `Imported from ${providerLabel(detail.provider)} public program`, + priority: 'medium', notes: `Program: ${detail.url}`, asset_ids: [...selected], monitor_enabled: monitor, monitor_interval_hours: monitorHours, + }) + addToast('success', `Project created with ${selected.size} approved assets`); navigate(created.url) + } catch (e) { addToast('error', e instanceof Error ? e.message : 'Could not create project') } + finally { setCreating(false) } + } + + const pages = Math.max(1, Math.ceil(total / 30)) + const running = statuses.some(s => s.status === 'running') + const knownPrograms = statuses.reduce((n, s) => n + (s.program_count || 0), 0) + const filtersActive = filters.search !== '' || filters.provider !== '' || filters.bounties !== '' || filters.wildcard !== '' || + filters.assetType !== '' || filters.minAssets !== '' || filters.maxAssets !== '' || filters.minReward !== '' || filters.programType !== '' || + filters.safeHarbor !== '' || filters.industry !== '' || filters.status !== 'live' || filters.sort !== 'newest' + + return ( +
+
+
+
+

Bug bounty programs

+ {running && syncing official catalogs} +
+

Public HackerOne, Bugcrowd, Intigriti and YesWeHack programs in one searchable catalog. Scope details load only when needed; import only the assets you choose.

+
+
+ {knownPrograms || total} cached programs + {user?.role === 'admin' && } +
+
+ + {statuses.some(s => s.status === 'error') && ( +
+ A provider refresh is retrying with backoff. Cached programs remain available. {statuses.filter(s => s.status === 'error').map(s => `${providerLabel(s.provider)}: ${s.last_error}`).join(' · ')} +
+ )} + +
+
+ setFilters(f => ({ ...f, search: e.target.value }))} className="xl:col-span-2" /> + setFilters(f => ({ ...f, sort: v }))} options={[['newest','Newest programs'],['updated','Recently updated'],['assets_desc','Most in-scope assets'],['wildcards','Most wildcards'],['reward_desc','Highest max reward'],['name','Name A–Z']]} /> +
+
+ setFilters(f => ({ ...f, wildcard: v }))} options={[['','Any scope'],['true','Has wildcard'],['false','No wildcard']]} /> + setFilters(f => ({ ...f, programType: v }))} options={[['','Any program'],['bug_bounty','Bug bounty'],['vdp','VDP']]} /> + setFilters(f => ({ ...f, status: v }))} options={[['live','Live only'],['','Any status'],['unavailable','Unavailable']]} /> + setFilters(f => ({ ...f, minAssets: e.target.value }))} /> + setFilters(f => ({ ...f, maxAssets: e.target.value }))} /> + setFilters(f => ({ ...f, minReward: e.target.value }))} /> + setFilters(f => ({ ...f, industry: e.target.value }))} /> +
+ {filtersActive && } +
+ + {loading ? ( +
{Array.from({ length: 9 }).map((_,i)=>
)}
+ ) : programs.length === 0 ? ( +
Start refresh : undefined}/>
+ ) : ( +
+ {programs.map(p => openProgram(p)} />)} +
+ )} + + {pages > 1 &&
Page {page} of {pages} · {total} programs
} + + setDetail(null)} title={detail?.name || 'Program'} width="xl"> + {detailLoading ?
: detail && ( +
+
+
+
{providerLabel(detail.provider)}{detail.offers_bounties ? bounty : VDP}{detail.wildcard_count > 0 && {detail.wildcard_count} wildcard}{detail.safe_harbor && detail.safe_harbor !== 'none' && {detail.safe_harbor} safe harbor}
+

{detail.description}

+
+ Open official program ↗ +
+
{[[detail.in_scope_count,'In scope'],[detail.asset_count,'All declared'],[money(detail.max_reward_cents,detail.currency),'Max reward'],[detail.started_at ? timeAgo(detail.started_at) : '—','Started']].map(([v,l])=>
{v}
{l}
)}
+
+
+

Choose project assets ({selected.size} selected)

+ setAssetSearch(e.target.value)} /> +
+
+ {visibleAssets.map(a => toggleAsset(a.id)} />)} + {visibleAssets.length === 0 &&

No submission-eligible assets match.

} + {visibleAssets.length < matchedAssets.length &&
} +
+
+
+ +
Scope monitoring
{monitor && }
+
+
Upstream additions are never scanned automatically. Reconner creates a pending scope event for approval. Assets removed or made ineligible are suspended immediately and remain in history.
+
+
+ )} +
+
+ ) +} + +function Select({ value, onChange, options }: { value: string; onChange: (v:string)=>void; options: [string,string][] }) { + return +} + +function ProgramCard({ program:p, onOpen }: { program:BountyProgram; onOpen:()=>void }) { + return +} + +function Metric({value,label}:{value:string;label:string}){return
{value}
{label}
} + +function AssetRow({asset:a,checked,onToggle}:{asset:BountyAsset;checked:boolean;onToggle:()=>void}){ + return +} diff --git a/frontend/src/pages/TargetDetail.tsx b/frontend/src/pages/TargetDetail.tsx index d9d04f4..bc29be2 100644 --- a/frontend/src/pages/TargetDetail.tsx +++ b/frontend/src/pages/TargetDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { targets as targetsApi, findings as findingsApi, tasks as tasksApi } from '../lib/api' +import { targets as targetsApi, findings as findingsApi, tasks as tasksApi, bounty as bountyApi } from '../lib/api' import { useUIStore } from '../store/ui' import { Badge, Button, Spinner, Empty, CopyButton, ErrorBoundary, SkeletonRows } from '../components/ui' import { ScanModal } from '../components/targets/ScanModal' @@ -10,9 +10,12 @@ import { ws } from '../lib/websocket' import { timeAgo, statusCodeColor, truncate, cn } from '../lib/utils' import type { Target, Subdomain, HTTPService, JSFile, JSFinding, Parameter, - DirectoryFinding, BackupFinding, OpenRedirectFinding, NucleiFinding, VulnFinding, MonitoringChange, AttackPath, Task, IngramCamera, Asset + DirectoryFinding, BackupFinding, OpenRedirectFinding, NucleiFinding, VulnFinding, MonitoringChange, AttackPath, Task, IngramCamera, Asset, BountyScopeEvent } from '../types' +const isScannableProjectAsset = (asset: Asset) => + ['domain', 'wildcard', 'url', 'page', 'js', 'api', 'ip', 'cidr'].includes(asset.asset_type || 'domain') + // Findings information architecture — a logical hierarchy instead of a flat row // of unrelated siblings. Assets = the discovered surface; Vulnerabilities = the // working area (Confirmed vs Needs-Review first, then by check type); Activity = @@ -265,7 +268,10 @@ export default function TargetDetail() { const [assets, setAssets] = useState([]) const [scanAsset, setScanAsset] = useState(null) const [newAsset, setNewAsset] = useState('') + const [newAssetType, setNewAssetType] = useState('auto') const [assetBusy, setAssetBusy] = useState(false) + const [scopeEvents, setScopeEvents] = useState([]) + const [eventBusy, setEventBusy] = useState(null) const [evidence, setEvidence] = useState<{ id: string; url: string; type: string } | null>(null) const [isScanning, setIsScanning] = useState(false) const [monitorSaving, setMonitorSaving] = useState(false) @@ -284,6 +290,7 @@ export default function TargetDetail() { }).catch(() => navigate('/targets')).finally(() => setLoading(false)) targetsApi.graph(id).then(g => setPaths(g.attack_paths || [])).catch(() => {}) loadAssets() + bountyApi.events(id).then(e => setScopeEvents(e || [])).catch(() => {}) }, [id]) const loadAssets = () => { if (id) targetsApi.assets(id).then(a => setAssets(a || [])).catch(() => {}) } @@ -291,10 +298,22 @@ export default function TargetDetail() { const v = newAsset.trim() if (!v || !id) return setAssetBusy(true) - try { await targetsApi.addAsset(id, v, ''); setNewAsset(''); loadAssets(); addToast('success', 'Asset added') } + try { await targetsApi.addAsset(id, v, '', newAssetType === 'auto' ? undefined : newAssetType); setNewAsset(''); loadAssets(); addToast('success', 'Asset added') } catch (e) { addToast('error', e instanceof Error ? e.message : 'Failed to add asset') } finally { setAssetBusy(false) } } + + const resolveScopeEvent = async (event: BountyScopeEvent, decision: 'approve' | 'reject') => { + if (!id) return + setEventBusy(event.id) + try { + await bountyApi.resolveEvent(id, event.id, decision) + setScopeEvents(prev => prev.map(e => e.id === event.id ? { ...e, status: decision === 'approve' ? 'approved' : 'rejected' } : e)) + loadAssets() + addToast('success', decision === 'approve' ? 'Scope change approved' : 'Scope change dismissed') + } catch (e) { addToast('error', e instanceof Error ? e.message : 'Could not resolve scope change') } + finally { setEventBusy(null) } + } const removeAsset = async (a: Asset) => { if (!id) return try { await targetsApi.deleteAsset(id, a.id); loadAssets() } catch { addToast('error', 'Failed to delete asset') } @@ -506,7 +525,7 @@ export default function TargetDetail() {
- + {/* A network target's "domain" is its whole scope — up to 65k IPs. Without truncation the h1 renders the entire list and shoves the action buttons @@ -538,6 +557,23 @@ export default function TargetDetail() {
+ {scopeEvents.some(e => e.status === 'pending') && ( +
+
+

Program scope changes need review

+

Added assets stay unscanned until approved. Removed assets were suspended immediately.

+
+
+ {scopeEvents.filter(e => e.status === 'pending').map(e => ( +
+
{e.event_type}{e.identifier}

Detected {timeAgo(e.detected_at)}

+
+
+ ))} +
+
+ )} + {target.scan_status === 'failed' && lastFailedTask?.error && (
Last scan failed: {lastFailedTask.error} @@ -574,10 +610,13 @@ export default function TargetDetail() {

{assets.length}
-
+
+ setNewAsset(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addAsset() }} - placeholder="add asset — domain, IP, CIDR, range, or a mix (space/comma separated)" + placeholder="domain, full URL/page, .js file, IP or CIDR" className="flex-1 bg-surface-alt border border-border rounded px-2 py-1.5 text-xs font-mono" />
@@ -587,13 +626,16 @@ export default function TargetDetail() {
{assets.map(a => (
- {a.kind} + {a.asset_type || 'domain'} + {a.approval_status !== 'approved' && {a.approval_status}}
{a.name &&

{a.name}

}

{a.value}

- + diff --git a/frontend/src/pages/Targets.tsx b/frontend/src/pages/Targets.tsx index 0a25cf9..3e89a80 100644 --- a/frontend/src/pages/Targets.tsx +++ b/frontend/src/pages/Targets.tsx @@ -182,10 +182,10 @@ export default function Targets({ filterKind }: { filterKind?: 'web' | 'network'

- {filterKind === 'web' ? 'Web Targets' : filterKind === 'network' ? 'Network Targets' : 'Targets'} + {filterKind === 'web' ? 'Web Projects' : filterKind === 'network' ? 'Network Projects' : 'Projects'}

- {kindTargets.length} target{kindTargets.length === 1 ? '' : 's'} · domains & subdomains — web application scanning + {kindTargets.length} project{kindTargets.length === 1 ? '' : 's'} · group domains, URLs, JavaScript files, network scope, or a public bounty program

@@ -195,7 +195,7 @@ export default function Targets({ filterKind }: { filterKind?: 'web' | 'network' )} - +
@@ -319,18 +319,18 @@ export default function Targets({ filterKind }: { filterKind?: 'web' | 'network' {/* Create / Edit Modal */} { setCreateOpen(false); setEditTarget(null); setForm(emptyForm) }} - title={editTarget ? 'Edit Target' : 'Add Target'} width="md"> + title={editTarget ? 'Edit Project' : 'Create Project'} width="md">
- { setCreateOpen(false); navigate('/bounty-programs') }} className="w-full rounded-lg border border-accent/20 bg-accent/[.06] px-3 py-2 text-left text-xs text-accent hover:bg-accent/[.1]">Or choose a HackerOne / Bugcrowd program and import its current scope →} + setForm({ ...form, name: e.target.value })} />
- +