"""HTTP surface for the bug-bounty console. Mounts under ``/api/v1/bounty``. RBAC mirrors the rest of the security lane: * ``can_view_security`` — reads (global_admin + auditor + management) * ``can_manage_security`` — every mutation (global_admin + auditor) Money and correspondence endpoints additionally live behind the ``include_sensitive=True`` serialisation path, which management's read-only view does reach: leadership seeing bounty spend is the point of the budget report. What no role reaches through THIS router is the business owner's narrow view — that is a separate self-service router (PR2) keyed on the caller being the assignee. """ from __future__ import annotations import logging import uuid from datetime import date, datetime from decimal import Decimal from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core import fiscal_year as fy from app.core.coordinators import system_registry_coordinator as registry from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import AppException, ForbiddenError, ValidationError from app.core.exporting import xlsx_export_response from app.core.idempotent_route import idempotent from app.core.rbac import can_manage_security, can_view_security from app.core.response import paginated_response, success_response from app.domains.bounty import ai as bounty_ai from app.domains.bounty import analytics, mail_intake, notify from app.domains.bounty import config_service as cfg from app.domains.bounty import constants as const from app.domains.bounty import notify as bnotify from app.domains.bounty import service as svc from app.domains.bounty.catalog import ( SUGGESTED_TEMPLATE_BY_SEVERITY, SUGGESTED_TEMPLATE_BY_STATUS, ) from app.domains.bounty.rendering import KNOWN_TOKENS from app.domains.bounty.schemas import ( AiDraftRequest, AiParseRequest, AlertRecipientsUpdateRequest, AssignRequest, BackfillLinkRequest, BackfillNoSystemRequest, BackfillSeverityRequest, BudgetTrackRequest, BudgetUpdateRequest, CommentRequest, InboundLogRequest, MailIntakeAcceptRequest, MailIntakeAttachRequest, MailIntakeIgnoreRequest, OwnerResponseRequest, PaymentRequest, RemediationRequest, ReplyLogRequest, ReplyPreviewRequest, ReportCreateRequest, ReportDeleteRequest, ReportUpdateRequest, ResearcherMergeRequest, ResearcherUpdateRequest, RewardRequest, SeverityRequest, SignatureUpdateRequest, StatusChangeRequest, TemplateCreateRequest, TemplateUpdateRequest, TierCreateRequest, TierUpdateRequest, UnassignRequest, ) from app.domains.bounty.serializers import ( mail_thread_url_from_events, serialize_attachment, serialize_budget, serialize_event, serialize_mail_intake, serialize_report, serialize_researcher, serialize_template, serialize_tier, ) from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/bounty", tags=["bounty"]) _RESOURCE = "bounty_report" def _require_view(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to view bounty data", ) def _require_manage(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to mutate bounty data", ) async def _actor(db: AsyncSession, user: AuthUser) -> IdentityPerson | None: """The acting person row. ``None`` only if the JWT outlived the person (a soft-deleted admin mid-session) — the mutation still proceeds and the event records a null actor rather than 500-ing.""" return await db.get(IdentityPerson, user.id) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict[str, Any] | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type=_RESOURCE, resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) async def _resolve_system( db: AsyncSession, system_id: uuid.UUID | None, ) -> dict[str, Any] | None: """Validate a registry link and return its display payload. A link to something that does not exist is rejected rather than stored: a dangling ``system_id`` would render as an empty owner and read as "nobody owns this", which is a different and much more alarming statement than "not linked". """ if system_id is None: return None found = await registry.describe_systems(db, [system_id]) payload = found.get(str(system_id)) if payload is None: raise ValidationError( f"system '{system_id}' is not in the internal system registry", ) return payload async def _report_payload( db: AsyncSession, report: Any, *, include_sensitive: bool = True, ) -> dict[str, Any]: researcher = await svc.get_researcher(db, report.researcher_id) tiers = await cfg.tier_map(db) systems = ( await registry.describe_systems(db, [report.system_id]) if report.system_id else {} ) original = ( await svc.get_report(db, report.duplicate_of_id) if report.duplicate_of_id else None ) # One settings read for the payload, so the FY renders the way the # rest of the console renders it ("FY27") rather than as the raw # start year. fy_start_month = await fy.get_fiscal_year_start_month(db) payload = serialize_report( report, researcher=researcher, tiers=tiers, include_sensitive=include_sensitive, fy_start_month=fy_start_month, system=systems.get(str(report.system_id)), duplicate_of=( {"id": str(original.id), "code": original.code, "title": original.title, "status": original.status} if original else None ), ) # Fetched here rather than in the serializer: it is a second query, # and a sync serializer touching a relationship on an async session is # a MissingGreenlet rather than a missing field. payload["attachments"] = [ serialize_attachment(a) for a in await svc.list_attachments(db, report.id) ] return payload # ════════════════════════════════════════════════════════════════════ # Metadata + overview # ════════════════════════════════════════════════════════════════════ @router.get("/meta") async def get_meta( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Everything the console needs to render its filters and forms in one round trip: enum vocabularies, the live severity ladder, template list, template-variable vocabulary, and the department options that actually appear on reports.""" _require_view(user) tiers = await cfg.list_tiers(db) templates = await cfg.list_templates(db) fiscal_year = await cfg.current_fy(db) return success_response( { "statuses": list(const.STATUSES), "status_transitions": { key: sorted(value) for key, value in const.STATUS_TRANSITIONS.items() }, "terminal_statuses": sorted(const.TERMINAL_STATUSES), "open_statuses": sorted(const.OPEN_STATUSES), "remediation_statuses": list(const.REMEDIATION_STATUSES), "owner_settable_remediation": sorted(const.OWNER_SETTABLE_REMEDIATION), "payment_statuses": list(const.PAYMENT_STATUSES), "payment_transitions": { key: sorted(value) for key, value in const.PAYMENT_TRANSITIONS.items() }, "owner_decisions": list(const.OWNER_DECISIONS), "vuln_types": list(const.VULN_TYPES), "sources": list(const.SOURCES), "budget_tracks": list(const.BUDGET_TRACKS), "default_budget_track": const.DEFAULT_BUDGET_TRACK, "visibilities": list(const.VISIBILITIES), "event_types": list(const.EVENT_TYPES), "template_categories": list(const.REPLY_CATEGORIES), "template_tokens": sorted(KNOWN_TOKENS), "suggested_template_by_status": dict(SUGGESTED_TEMPLATE_BY_STATUS), # A confirmed report resolves through the SEVERITY map: the # reward letter states the amount, and the tier is what # decides it. One shared "confirmed" letter is how the old # template asserted "low impact" over a critical finding. "suggested_template_by_severity": dict( SUGGESTED_TEMPLATE_BY_SEVERITY, ), "severity_tiers": [serialize_tier(t) for t in tiers], "templates": [ serialize_template(t, include_body=False) for t in templates ], "departments": await svc.distinct_departments(db), "current_fiscal_year": fiscal_year, "current_fiscal_year_label": await cfg.fy_label(db, fiscal_year), "max_reward_usd": const.MAX_REWARD_USD, "can_manage": can_manage_security(user.role_names), }, request=request, ) @router.get("/overview") async def get_overview( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) data = await svc.overview(db) data["budget"] = serialize_budget(data["budget"]) return success_response(data, request=request) # ════════════════════════════════════════════════════════════════════ # Reports # ════════════════════════════════════════════════════════════════════ def _csv(value: str | None) -> list[str] | None: if not value: return None items = [part.strip() for part in value.split(",") if part.strip()] return items or None @router.get("/reports") async def list_reports( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), status: str | None = Query(None, description="Comma-separated statuses"), severity: str | None = Query(None), remediation_status: str | None = Query(None), payment_status: str | None = Query(None), assignee_person_id: uuid.UUID | None = Query(None), researcher_id: uuid.UUID | None = Query(None), fiscal_year: int | None = Query(None), vuln_type: str | None = Query(None), system_id: uuid.UUID | None = Query(None), department: str | None = Query(None), unassigned: bool | None = Query(None), ungraded: bool | None = Query( None, description="Reports with no severity set (the matrix's " "'not graded' column drills through this).", ), never_replied: bool | None = Query( None, description="Reports the researcher has never had an answer " "to. The reply queue walks this set oldest-first.", ), overdue_only: bool = Query(False), search: str | None = Query(None, max_length=200), reported_from: datetime | None = Query(None), reported_to: datetime | None = Query(None), sort: str = Query("reported_at"), order: str = Query("desc", pattern="^(asc|desc)$"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await svc.list_reports( db, page=page, page_size=page_size, sort=sort, order=order, statuses=_csv(status), severities=_csv(severity), remediation_statuses=_csv(remediation_status), payment_statuses=_csv(payment_status), assignee_person_id=assignee_person_id, researcher_id=researcher_id, fiscal_year=fiscal_year, vuln_type=vuln_type, system_id=system_id, department=department, unassigned=unassigned, ungraded=ungraded, never_replied=never_replied, overdue_only=overdue_only, search=search, reported_from=reported_from, reported_to=reported_to, ) researchers, tiers = await svc.load_report_refs(db, rows) # One batched registry read for the whole page — the list must stay # a constant number of queries regardless of page size. Same for the # FY start month: read once, passed to every row. systems = await registry.describe_systems( db, [r.system_id for r in rows if r.system_id], ) fy_start_month = await fy.get_fiscal_year_start_month(db) items = [ serialize_report( row, researcher=researchers.get(row.researcher_id), tiers=tiers, include_sensitive=True, include_content=False, fy_start_month=fy_start_month, system=systems.get(str(row.system_id)) if row.system_id else None, ) for row in rows ] return paginated_response(items, total, page, page_size, request=request) @router.post("/reports") @idempotent("bounty.report.create") async def create_report( body: ReportCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) actor = await _actor(db, user) system = await _resolve_system(db, body.system_id) # Pot precedence: what the operator said > what the registry says the # system belongs to > the platform default. Silently defaulting a # registry-linked IT finding to 产研 would put the spend in the wrong # budget, and the budget report is the whole point of the two pots. budget_track = ( body.budget_track or (system or {}).get("budget_track") or const.DEFAULT_BUDGET_TRACK ) report = await svc.create_report( db, title=body.title, researcher_email=body.researcher_email, researcher_name=body.researcher_name, researcher_handle=body.researcher_handle, researcher_country=body.researcher_country, reported_at=body.reported_at, raw_report=body.raw_report, summary=body.summary, reproduction=body.reproduction, affected_system=body.affected_system, system_id=body.system_id, affected_urls=body.affected_urls, vuln_type=body.vuln_type, cve_id=body.cve_id, suggested_severity=body.suggested_severity, budget_track=budget_track, source=body.source, actor=actor, ) await _audit( db, request, user, action="bounty.report.create", resource_id=str(report.id), detail={"code": report.code, "researcher": body.researcher_email}, ) return success_response(await _report_payload(db, report), request=request) @router.get("/reports/by-code/{code}") async def get_report_by_code( code: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The report page's entry point. Keyed on the CODE, not the uuid: ``BB-2026-104`` is what the researcher quotes, what the reply subject line carries and what somebody pastes into chat, so it is what the URL should be. A uuid in the address bar is unshareable in the one workflow this domain is about. """ _require_view(user) report = await svc.get_report_by_code(db, code) events = await svc.list_events(db, report.id, internal=True) payload = await _report_payload(db, report) payload["events"] = [serialize_event(e) for e in events] payload["mail_thread_url"] = mail_thread_url_from_events(events) return success_response(payload, request=request) @router.get("/reports/{report_id}") async def get_report( report_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) report = await svc.get_report(db, report_id) events = await svc.list_events(db, report.id, internal=True) payload = await _report_payload(db, report) payload["events"] = [serialize_event(e) for e in events] # Computed here rather than in the serializer: the thread id lives on # the created EVENT, and events are fetched separately — reading # ``report.events`` inside a sync serializer is a lazy load on an async # session, which is a MissingGreenlet, not a missing link. payload["mail_thread_url"] = mail_thread_url_from_events(events) return success_response(payload, request=request) @router.patch("/reports/{report_id}") async def update_report( report_id: uuid.UUID, body: ReportUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) patch = body.model_dump(exclude_unset=True) if patch.get("system_id") is not None: await _resolve_system(db, patch["system_id"]) await svc.update_report(db, report, patch=patch, actor=actor) await _audit( db, request, user, action="bounty.report.update", resource_id=str(report.id), detail={"fields": sorted(patch.keys())}, ) return success_response(await _report_payload(db, report), request=request) @router.delete("/reports/{report_id}") async def delete_report( report_id: uuid.UUID, request: Request, body: ReportDeleteRequest | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.soft_delete_report( db, report, actor=actor, reason=body.reason if body else None, ) await _audit( db, request, user, action="bounty.report.delete", resource_id=str(report.id), detail={"code": report.code}, ) return success_response({"deleted": True, "id": str(report.id)}, request=request) # ── Assignment ────────────────────────────────────────────────────── @router.post("/reports/{report_id}/assign") @idempotent("bounty.report.assign", key_params=("report_id",)) async def assign_report( report_id: uuid.UUID, body: AssignRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) person = await svc.load_assignable_person(db, body.person_id) await svc.assign_report(db, report, person=person, note=body.note, actor=actor) await _audit( db, request, user, action="bounty.report.assign", resource_id=str(report.id), detail={ "assignee_person_id": str(person.id), "assignee": report.assignee_name, "notify_requested": body.notify, }, ) notified = False if body.notify: # Best-effort: a Feishu/inbox hiccup must not undo the assignment. # The console shows what actually happened rather than assuming. notified = await notify.notify_owner_assigned(db, report, owner=person) payload = await _report_payload(db, report) payload["notified"] = notified return success_response(payload, request=request) @router.post("/reports/{report_id}/unassign") async def unassign_report( report_id: uuid.UUID, body: UnassignRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.unassign_report(db, report, actor=actor, reason=body.reason) await _audit( db, request, user, action="bounty.report.unassign", resource_id=str(report.id), detail={}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/reports/{report_id}/nudge") async def nudge_report( report_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Chase the assigned owner. Throttled to once per 12h per report.""" _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.record_nudge(db, report, actor=actor) owner = await svc.load_assignable_person( db, report.assignee_person_id, ) if report.assignee_person_id else None notified = False if owner is not None: notified = await notify.notify_owner_assigned( db, report, owner=owner, reminder=True, ) await _audit( db, request, user, action="bounty.report.nudge", resource_id=str(report.id), detail={"assignee": report.assignee_name, "notified": notified}, ) payload = await _report_payload(db, report) payload["notified"] = notified return success_response(payload, request=request) # ── Triage / severity / remediation ───────────────────────────────── @router.post("/reports/{report_id}/status") @idempotent("bounty.report.status", key_params=("report_id",)) async def set_status( report_id: uuid.UUID, body: StatusChangeRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) previous = report.status await svc.set_status( db, report, target=body.status, note=body.note, actor=actor, duplicate_of_id=body.duplicate_of_id, duplicate_note=body.duplicate_note, ) await _audit( db, request, user, action="bounty.report.status", resource_id=str(report.id), detail={"from": previous, "to": body.status}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/reports/{report_id}/severity") @idempotent("bounty.report.severity", key_params=("report_id",)) async def set_severity( report_id: uuid.UUID, body: SeverityRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.set_severity( db, report, severity=body.severity, note=body.note, apply_default_reward=body.apply_default_reward, actor=actor, ) await _audit( db, request, user, action="bounty.report.severity", resource_id=str(report.id), detail={ "severity": body.severity, "apply_default_reward": body.apply_default_reward, }, ) return success_response(await _report_payload(db, report), request=request) @router.post("/reports/{report_id}/remediation") async def set_remediation( report_id: uuid.UUID, body: RemediationRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.set_remediation( db, report, target=body.remediation_status, note=body.note, fix_eta=body.fix_eta, actor=actor, ) await _audit( db, request, user, action="bounty.report.remediation", resource_id=str(report.id), detail={"to": body.remediation_status}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/reports/{report_id}/owner-response") async def record_owner_response( report_id: uuid.UUID, body: OwnerResponseRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Record the business owner's verdict *on their behalf* — for the common case where the owner answered on a call or in a chat rather than through their own page. The owner's own submission path is the self-service router (PR2); both land in the same place.""" _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.record_owner_response( db, report, decision=body.decision, note=body.note, suggested_severity=body.suggested_severity, fix_eta=body.fix_eta, remediation_status=body.remediation_status, actor=actor, ) await _audit( db, request, user, action="bounty.report.owner_response", resource_id=str(report.id), detail={"decision": body.decision, "relayed_by_security": True}, ) return success_response(await _report_payload(db, report), request=request) # ── Money ─────────────────────────────────────────────────────────── @router.post("/reports/{report_id}/reward") @idempotent("bounty.report.reward", key_params=("report_id",)) async def set_reward( report_id: uuid.UUID, body: RewardRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) await svc.set_reward( db, report, amount=Decimal(str(body.amount_usd)), note=body.note, actor=actor, ) await _audit( db, request, user, action="bounty.report.reward", resource_id=str(report.id), detail={"amount_usd": str(body.amount_usd), "fiscal_year": report.fiscal_year}, ) payload = await _report_payload(db, report) payload["budget"] = serialize_budget((await svc.overview(db))["budget"]) return success_response(payload, request=request) @router.post("/reports/{report_id}/budget-track") async def set_budget_track( report_id: uuid.UUID, body: BudgetTrackRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Move a report between the 产研 and IT funding pots.""" _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) previous = report.budget_track await svc.set_budget_track( db, report, track=body.budget_track, note=body.note, actor=actor, ) await _audit( db, request, user, action="bounty.report.budget_track", resource_id=str(report.id), detail={"from": previous, "to": body.budget_track}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/reports/{report_id}/payment") @idempotent("bounty.report.payment", key_params=("report_id",)) async def set_payment( report_id: uuid.UUID, body: PaymentRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) previous = report.payment_status await svc.set_payment_status( db, report, target=body.payment_status, reference=body.reference, note=body.note, paid_at=body.paid_at, actor=actor, ) await _audit( db, request, user, action="bounty.report.payment", resource_id=str(report.id), detail={ "from": previous, "to": body.payment_status, "amount_usd": ( str(report.reward_amount_usd) if report.reward_amount_usd is not None else None ), "reference": report.payment_reference, }, ) payload = await _report_payload(db, report) payload["budget"] = serialize_budget((await svc.overview(db))["budget"]) return success_response(payload, request=request) # ── Correspondence ────────────────────────────────────────────────── @router.get("/reports/{report_id}/events") async def list_events( report_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) report = await svc.get_report(db, report_id) return success_response( { "items": [ serialize_event(e) for e in await svc.list_events(db, report.id, internal=True) ], }, request=request, ) @router.post("/reports/{report_id}/comments") async def add_comment( report_id: uuid.UUID, body: CommentRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) event = await svc.add_comment( db, report, body=body.body, visibility=body.visibility, actor=actor, ) return success_response(serialize_event(event), request=request) @router.post("/reports/{report_id}/reply-preview") async def preview_reply( report_id: uuid.UUID, body: ReplyPreviewRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Render a template against this report. Side-effect free: nothing is logged and ``use_count`` is untouched until the operator confirms they actually sent it.""" _require_manage(user) report = await svc.get_report(db, report_id) rendered, template = await svc.preview_reply( db, report, template_key=body.template_key, amount_override=( Decimal(str(body.amount_usd)) if body.amount_usd is not None else None ), subject_override=body.subject, body_override=body.body, ) return success_response( { "template": serialize_template(template), "subject": rendered.subject, "body": rendered.body, "missing_variables": rendered.missing, "used_variables": rendered.used, }, request=request, ) @router.post("/reports/{report_id}/replies") @idempotent("bounty.report.reply", key_params=("report_id",)) async def log_reply( report_id: uuid.UUID, body: ReplyLogRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Attest that this text was sent to the researcher (Helix does not send the mail — the operator keeps the thread in their mailbox).""" _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) event = await svc.log_reply( db, report, template_key=body.template_key, subject=body.subject, body=body.body, actor=actor, ) await _audit( db, request, user, action="bounty.report.reply_logged", resource_id=str(report.id), detail={"template_key": body.template_key, "subject": body.subject}, ) payload = await _report_payload(db, report) payload["event"] = serialize_event(event) return success_response(payload, request=request) @router.post("/reports/{report_id}/inbound") async def log_inbound( report_id: uuid.UUID, body: InboundLogRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Paste a follow-up mail FROM the researcher into the thread.""" _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) event = await svc.log_inbound( db, report, body=body.body, received_at=body.received_at, actor=actor, ) # Recording an incoming message by hand is the same event as the # poller attaching one: the researcher has come back to us. Notify the # desk from here too, so this works before the poller is switched on. try: await bnotify.notify_security_of_researcher_followup( db, report, days_silent=report.days_silent if hasattr(report, "days_silent") else None, ) except Exception: # noqa: BLE001 — never lose the record over a push logger.warning("bounty: follow-up notification failed", exc_info=True) return success_response(serialize_event(event), request=request) # ════════════════════════════════════════════════════════════════════ # Researchers # ════════════════════════════════════════════════════════════════════ @router.get("/researchers") async def list_researchers( request: Request, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), search: str | None = Query(None, max_length=200), include_blocked: bool = Query(True), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) rows, total = await _list_researchers( db, page=page, page_size=page_size, search=search, include_blocked=include_blocked, ) stats = await svc.researcher_stats(db, [r.id for r in rows]) items = [ serialize_researcher(r, stats=stats.get(r.id, {})) for r in rows ] return paginated_response(items, total, page, page_size, request=request) async def _list_researchers( db: AsyncSession, *, page: int, page_size: int, search: str | None, include_blocked: bool, ): from sqlalchemy import func, or_, select from app.domains.bounty.models import BountyResearcher stmt = select(BountyResearcher) if not include_blocked: stmt = stmt.where(BountyResearcher.is_blocked.is_(False)) if search: needle = f"%{search.strip()}%" stmt = stmt.where( or_( BountyResearcher.email.like(needle), BountyResearcher.display_name.like(needle), BountyResearcher.handle.like(needle), ), ) total = ( await db.execute(select(func.count()).select_from(stmt.subquery())) ).scalar_one() rows = ( await db.execute( stmt.order_by( # MySQL orders NULLs last on DESC, which is what we want: # a researcher with no reports yet sinks to the bottom. BountyResearcher.last_report_at.desc(), BountyResearcher.email, ) .offset((page - 1) * page_size) .limit(page_size), ) ).scalars().all() return list(rows), int(total) @router.get("/researchers/{researcher_id}") async def get_researcher( researcher_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) researcher = await svc.get_researcher(db, researcher_id) stats = await svc.researcher_stats(db, [researcher.id]) rows, total = await svc.list_reports( db, page=1, page_size=100, researcher_id=researcher.id, ) researchers, tiers = await svc.load_report_refs(db, rows) fy_start_month = await fy.get_fiscal_year_start_month(db) return success_response( { **serialize_researcher(researcher, stats=stats.get(researcher.id, {})), "reports": [ serialize_report( row, researcher=researchers.get(row.researcher_id), tiers=tiers, include_sensitive=True, include_content=False, fy_start_month=fy_start_month, ) for row in rows ], "report_total": total, }, request=request, ) @router.patch("/researchers/{researcher_id}") async def update_researcher( researcher_id: uuid.UUID, body: ResearcherUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) patch = body.model_dump(exclude_unset=True) researcher = await svc.update_researcher(db, researcher_id, patch=patch) await _audit( db, request, user, action="bounty.researcher.update", resource_id=str(researcher.id), detail={"fields": sorted(patch.keys())}, ) stats = await svc.researcher_stats(db, [researcher.id]) return success_response( serialize_researcher(researcher, stats=stats.get(researcher.id, {})), request=request, ) # ════════════════════════════════════════════════════════════════════ # Configuration — severity tiers # ════════════════════════════════════════════════════════════════════ @router.get("/config/tiers") async def list_tiers( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) tiers = await cfg.list_tiers(db) return success_response( {"items": [serialize_tier(t) for t in tiers]}, request=request, ) @router.post("/config/tiers") async def create_tier( body: TierCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) tier = await cfg.create_tier( db, key=body.key, label_zh=body.label_zh, label_en=body.label_en, label_ja=body.label_ja, default_reward_usd=Decimal(str(body.default_reward_usd)), max_reward_usd=( Decimal(str(body.max_reward_usd)) if body.max_reward_usd is not None else None ), fix_sla_days=body.fix_sla_days, color=body.color, sort_order=body.sort_order, actor_id=user.id, ) await _audit( db, request, user, action="bounty.tier.create", resource_id=tier.key, detail={"default_reward_usd": str(body.default_reward_usd)}, ) return success_response(serialize_tier(tier), request=request) @router.patch("/config/tiers/{key}") async def update_tier( key: str, body: TierUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) patch = body.model_dump(exclude_unset=True) tier = await cfg.update_tier(db, key, patch=patch, actor_id=user.id) await _audit( db, request, user, action="bounty.tier.update", resource_id=key, detail={"fields": sorted(patch.keys())}, ) return success_response(serialize_tier(tier), request=request) @router.delete("/config/tiers/{key}") async def delete_tier( key: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await cfg.delete_tier(db, key) await _audit( db, request, user, action="bounty.tier.delete", resource_id=key, detail={}, ) return success_response({"deleted": True, "key": key}, request=request) @router.post("/config/tiers/restore-defaults") async def restore_tiers( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Reset the builtin ladder to shipped values. Custom tiers untouched.""" _require_manage(user) count = await cfg.restore_builtin_tiers(db, actor_id=user.id) await _audit( db, request, user, action="bounty.tier.restore_defaults", resource_id=None, detail={"restored": count}, ) tiers = await cfg.list_tiers(db) return success_response( {"restored": count, "items": [serialize_tier(t) for t in tiers]}, request=request, ) # ════════════════════════════════════════════════════════════════════ # Configuration — reply templates # ════════════════════════════════════════════════════════════════════ @router.get("/config/templates") async def list_templates( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) templates = await cfg.list_templates(db) return success_response( {"items": [serialize_template(t) for t in templates]}, request=request, ) @router.post("/config/templates") async def create_template( body: TemplateCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) template = await cfg.create_template( db, key=body.key, name_zh=body.name_zh, name_en=body.name_en, name_ja=body.name_ja, category=body.category, subject=body.subject, body=body.body, sort_order=body.sort_order, actor_id=user.id, ) await _audit( db, request, user, action="bounty.template.create", resource_id=template.key, detail={"category": body.category}, ) return success_response(serialize_template(template), request=request) @router.patch("/config/templates/{key}") async def update_template( key: str, body: TemplateUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) patch = body.model_dump(exclude_unset=True) template = await cfg.update_template(db, key, patch=patch, actor_id=user.id) await _audit( db, request, user, action="bounty.template.update", resource_id=key, detail={"fields": sorted(patch.keys())}, ) return success_response(serialize_template(template), request=request) @router.delete("/config/templates/{key}") async def delete_template( key: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await cfg.delete_template(db, key) await _audit( db, request, user, action="bounty.template.delete", resource_id=key, detail={}, ) return success_response({"deleted": True, "key": key}, request=request) @router.post("/config/templates/{key}/restore") async def restore_template( key: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) template = await cfg.restore_builtin_template(db, key, actor_id=user.id) await _audit( db, request, user, action="bounty.template.restore", resource_id=key, detail={}, ) return success_response(serialize_template(template), request=request) # ════════════════════════════════════════════════════════════════════ # Configuration — budget + signature # ════════════════════════════════════════════════════════════════════ @router.get("/config/budget") async def get_budget( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) config = await cfg.get_budget_config(db) fiscal_year = await cfg.current_fy(db) return success_response( { "tracks": [ { "track": track, "default_usd": float(values["default_usd"]), "by_fy": { str(year): float(amount) for year, amount in sorted(values["by_fy"].items()) }, "current_budget_usd": float( await cfg.budget_for_fy(db, fiscal_year, track=track), ), } for track, values in config["tracks"].items() ], "currency": config["currency"], "current_fiscal_year": fiscal_year, "current_fiscal_year_label": await cfg.fy_label(db, fiscal_year), "current_budget_usd": float(await cfg.budget_for_fy(db, fiscal_year)), "warn_ratio": const.BUDGET_WARN_RATIO, "over_ratio": const.BUDGET_OVER_RATIO, }, request=request, ) @router.put("/config/budget") async def update_budget( body: BudgetUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) patch: dict[str, dict[str, Any]] = {} for track, values in ( (const.BUDGET_TRACK_PRODUCT, body.product), (const.BUDGET_TRACK_IT, body.it), ): if values is None: continue entry: dict[str, Any] = {} if values.default_usd is not None: entry["default_usd"] = Decimal(str(values.default_usd)) if values.by_fy is not None: entry["by_fy"] = { int(year): Decimal(str(amount)) for year, amount in values.by_fy.items() } patch[track] = entry config = await cfg.update_budget_config( db, tracks=patch, actor_id=user.id, ) await _audit( db, request, user, action="bounty.budget.update", resource_id=None, detail={ "tracks": { track: { "default_usd": str(values["default_usd"]), "by_fy": {str(y): str(a) for y, a in values["by_fy"].items()}, } for track, values in config["tracks"].items() }, }, ) fiscal_year = await cfg.current_fy(db) return success_response( { "tracks": [ { "track": track, "default_usd": float(values["default_usd"]), "by_fy": { str(year): float(amount) for year, amount in sorted(values["by_fy"].items()) }, "current_budget_usd": float( await cfg.budget_for_fy(db, fiscal_year, track=track), ), } for track, values in config["tracks"].items() ], "currency": config["currency"], "current_fiscal_year": fiscal_year, "current_budget_usd": float(await cfg.budget_for_fy(db, fiscal_year)), }, request=request, ) @router.get("/config/signature") async def get_signature( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await cfg.get_signature(db), request=request) @router.put("/config/signature") async def update_signature( body: SignatureUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) payload = body.model_dump(exclude_unset=True) signature = await cfg.update_signature(db, payload, actor_id=user.id) await _audit( db, request, user, action="bounty.signature.update", resource_id=None, detail={"fields": sorted(payload.keys())}, ) return success_response(signature, request=request) @router.get("/config/alert-recipients") async def get_alert_recipients( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Who currently receives the desk's alerts, and whether that is the default. Returns the RESOLVED people, not just the stored ids: the whole point of the surface is answering "who actually gets told", and a stored id whose person has left the company answers that wrongly. ``is_default`` lets the console say so out loud instead of rendering an empty box that looks like nobody is being notified. """ _require_view(user) from app.domains.bounty import notify as bnotify configured = await cfg.get_alert_recipient_ids(db) people = await bnotify._security_operators(db) return success_response( { "is_default": not configured, "default_roles": list(bnotify._DESK_ROLES), "configured_count": len(configured), "recipients": [ { "id": str(p.id), "display_name": p.display_name, "email": p.work_email, "department": p.department, } for p in people ], }, request=request, ) @router.put("/config/alert-recipients") async def update_alert_recipients( body: AlertRecipientsUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Replace the recipient list. Empty list = back to every active 安全员.""" _require_manage(user) from app.domains.bounty import notify as bnotify saved = await cfg.update_alert_recipients( db, list(body.person_ids), actor_id=user.id, ) await _audit( db, request, user, action="bounty.alert_recipients.update", resource_id=None, detail={"count": len(saved), "is_default": not saved}, ) people = await bnotify._security_operators(db) return success_response( { "is_default": not saved, "default_roles": list(bnotify._DESK_ROLES), "configured_count": len(saved), "recipients": [ { "id": str(p.id), "display_name": p.display_name, "email": p.work_email, "department": p.department, } for p in people ], }, request=request, ) # ════════════════════════════════════════════════════════════════════ # Analytics / reports # ════════════════════════════════════════════════════════════════════ # # Mounted under ``/analytics/...``, NOT ``/reports/...``: the latter is the # report *collection* and ``/reports/{report_id}`` would swallow any # sibling literal added later. # # Read gate only (``can_view_security``, which admits ``management``) — # leadership seeing bounty spend and per-team finding counts is the point # of these screens. Nothing here mutates. @router.get("/analytics/overview") async def analytics_overview( request: Request, fiscal_year: int | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Programme health in one round trip: budget burn, intake curve, decision mix, responsiveness and the FY picker's options.""" _require_view(user) return success_response( await analytics.programme_overview(db, fiscal_year=fiscal_year), request=request, ) @router.get("/analytics/budget") async def analytics_budget( request: Request, fiscal_year: int | None = Query(None), track: str | None = Query( None, description="product | it; omit for both pots combined", ), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """FY burn — paid, committed, waived, remaining, per severity, and paid-per-month. Scoped to one funding track, or both combined.""" _require_view(user) if track is not None and track not in const.BUDGET_TRACKS: raise ValidationError( f"track must be one of {', '.join(const.BUDGET_TRACKS)}", ) return success_response( await analytics.budget_report( db, fiscal_year=fiscal_year, track=track, ), request=request, ) @router.get("/analytics/budget-tracks") async def analytics_budget_tracks( request: Request, fiscal_year: int | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Both funding pots side by side, plus the combined total. Two cards rather than one bar: the tracks have different owners and different approval paths, so "83% of product gone" and "4% of IT gone" are two separate operational facts that a combined average hides. """ _require_view(user) return success_response( await analytics.budget_by_track(db, fiscal_year=fiscal_year), request=request, ) @router.get("/analytics/attribution") async def analytics_attribution( request: Request, group_by: str = Query( analytics.GROUP_DEPARTMENT, description="department | assignee | system | vuln_type", ), since: date | None = Query(None), until: date | None = Query(None), fiscal_year: int | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """"Whose code produces the most findings" — grouped on the assignment snapshot, with validity, spend and fix performance per bucket.""" _require_view(user) if group_by not in analytics.ATTRIBUTION_GROUPS: raise ValidationError( f"group_by must be one of {', '.join(analytics.ATTRIBUTION_GROUPS)}", ) return success_response( await analytics.attribution_report( db, group_by=group_by, since=since, until=until, fiscal_year=fiscal_year, ), request=request, ) @router.get("/analytics/remediation") async def analytics_remediation( request: Request, since: date | None = Query(None), until: date | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Fix performance per severity, measured against the deadline each report was actually given.""" _require_view(user) return success_response( await analytics.remediation_report(db, since=since, until=until), request=request, ) @router.get("/analytics/researchers") async def analytics_researchers( request: Request, limit: int = Query(50, ge=1, le=500), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Researcher leaderboard: submissions, valid rate over decided reports, total paid.""" _require_view(user) return success_response( await analytics.researcher_leaderboard(db, limit=limit), request=request, ) @router.get("/analytics/awaiting-reply") async def analytics_awaiting_reply( request: Request, overdue_days: int = Query(analytics.REPLY_OVERDUE_DAYS, ge=0, le=365), limit: int = Query(100, ge=1, le=500), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Open reports the researcher has not heard back on, longest first. The programme's own responsiveness, which no volume or budget chart can show: the failure mode is silence. Separates "never answered" from "answered once then went quiet", and flags repeat submitters. """ _require_view(user) return success_response( await analytics.awaiting_our_reply( db, overdue_days=overdue_days, limit=limit, ), request=request, ) @router.post("/researchers/{researcher_id}/merge") @idempotent("bounty.researcher.merge", key_params=("researcher_id",)) async def merge_researcher( researcher_id: uuid.UUID, body: ResearcherMergeRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Record that this address is another address of ``into_id``. Reports are NOT re-pointed: a reply has to go back to the address the mail arrived on, and "which address did this come in on" is a real question in a dispute. Only the aggregates resolve. """ _require_manage(user) alias = await svc.merge_researchers( db, source_id=researcher_id, target_id=body.into_id, ) await _audit( db, request, user, action="bounty.researcher.merge", resource_id=str(researcher_id), detail={"into_id": str(body.into_id)}, ) return success_response(serialize_researcher(alias), request=request) @router.post("/researchers/{researcher_id}/unmerge") @idempotent("bounty.researcher.unmerge", key_params=("researcher_id",)) async def unmerge_researcher( researcher_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Undo a merge. An identity claim typed by a person is sometimes wrong, and a one-way door on a guess is a trap.""" _require_manage(user) row = await svc.unmerge_researcher(db, researcher_id) await _audit( db, request, user, action="bounty.researcher.unmerge", resource_id=str(researcher_id), detail={}, ) return success_response(serialize_researcher(row), request=request) @router.post("/reports/{report_id}/ai/assess") @idempotent("bounty.report.assess", key_params=("report_id",)) async def assess_report_with_ai( report_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Have the model read this report and say whether it looks real. Advisory, and re-runnable: the verdict never sets the status, the level never becomes the grade, and the reply composer never reads it. """ _require_manage(user) report = await svc.get_report(db, report_id) actor = await _actor(db, user) # The console tells us what language it is rendering in; a reading in # the other language under translated headings is worse than either. lang = (request.headers.get("accept-language") or "zh").split(",")[0] assessment = await svc.assess_with_ai(db, report, actor=actor, lang=lang) if assessment is None: raise AppException( code="BOUNTY_AI_NO_MATERIAL", message=( "这份报告在 Helix 里没有正文可读(FY27 补录只存了标题)," "或者 AI 暂时不可用。先用「打开原始邮件」看原文," "或等来信正文导入后再分析。" ), status_code=422, ) await _audit( db, request, user, action="bounty.report.ai_assess", resource_id=str(report.id), detail={"verdict": assessment["verdict"]}, ) return success_response( await _report_payload(db, report), request=request, ) @router.post("/mail-intake/backfill-bodies") @idempotent("bounty.mail_intake.backfill_bodies") async def backfill_report_bodies( request: Request, limit: int = Query(25, ge=1, le=50), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Pull the researchers' write-ups in from their mail threads, then have the AI read each one. Batched and re-runnable — call it until ``considered`` comes back 0. Needs the ``gmail.readonly`` grant; see ``docs/ops/gmail-readonly-delegation.md``. """ _require_manage(user) result = await mail_intake.backfill_bodies_and_assess(db, limit=limit) await _audit( db, request, user, action="bounty.mail_intake.backfill_bodies", resource_id=None, detail=result, ) return success_response(result, request=request) @router.get("/mail-intake") async def list_mail_intake( request: Request, limit: int = Query(50, ge=1, le=100), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mail that arrived on security@ and is waiting for a person. Oldest first. Nothing here has become a report yet — the poller only ever queues, because security@ also carries vendor pitches, internal mail and automated scan output. """ _require_view(user) rows = await mail_intake.pending(db, limit=limit) items = [] for row in rows: suggestion = await mail_intake.suggest_report_for(db, row) items.append(serialize_mail_intake( row, suggested_report=None if suggestion is None else { "id": str(suggestion.id), "code": suggestion.code, "title": suggestion.title, "status": suggestion.status, }, )) return success_response({"items": items}, request=request) @router.post("/mail-intake/{intake_id}/accept") @idempotent("bounty.mail_intake.accept", key_params=("intake_id",)) async def accept_mail_intake( intake_id: uuid.UUID, body: MailIntakeAcceptRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Turn one queued mail into a report. The human's decision. The AI's severity reading is carried over as ``suggested_severity``, never as ``severity``: grading pulls a reward tier and a fix deadline, which is a decision with money attached. """ _require_manage(user) actor = await _actor(db, user) report = await mail_intake.accept( db, intake_id, actor=actor, title=body.title, affected_system=body.affected_system, ) await _audit( db, request, user, action="bounty.mail_intake.accept", resource_id=str(intake_id), detail={"report_code": report.code}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/mail-intake/{intake_id}/attach") @idempotent("bounty.mail_intake.attach", key_params=("intake_id",)) async def attach_mail_intake( intake_id: uuid.UUID, body: MailIntakeAttachRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """This queued mail is a follow-up on a report we already have. The third option the queue was missing. Gmail thread ids are per account, so `poll` can only auto-attach a chase whose thread it ingested itself — a chase on any of the 149 imported reports arrives looking like a new finding. Without this the operator could only 「接受」 (a duplicate report for one conversation, and every count and the reward owed are computed off these rows) or 「忽略」 (drop the chase and leave the researcher waiting). """ _require_manage(user) actor = await _actor(db, user) report = await mail_intake.attach_to_report( db, intake_id, actor=actor, report_id=body.report_id, ) await _audit( db, request, user, action="bounty.mail_intake.attach", resource_id=str(intake_id), detail={"report_code": report.code}, ) return success_response(await _report_payload(db, report), request=request) @router.post("/mail-intake/{intake_id}/ignore") @idempotent("bounty.mail_intake.ignore", key_params=("intake_id",)) async def ignore_mail_intake( intake_id: uuid.UUID, body: MailIntakeIgnoreRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Not a researcher report. The row is kept so the same mail is never offered again.""" _require_manage(user) actor = await _actor(db, user) row = await mail_intake.ignore(db, intake_id, actor=actor, note=body.note) await _audit( db, request, user, action="bounty.mail_intake.ignore", resource_id=str(intake_id), detail={"note": body.note}, ) return success_response(serialize_mail_intake(row), request=request) @router.get("/analytics/matrix") async def get_matrix( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Ownership rows × stage columns, coloured by how long people waited. The landing page used to be a flat 149-row table with no hierarchy, because the data has none: 114 rows share one status, 142 share one empty severity, 84 share one hostname. Two axes — whose problem, and what is it waiting on — are the two questions actually asked, and silence duration is the only dimension here that ranks priority objectively (2 … 295 days). """ _require_view(user) return success_response(await analytics.matrix(db), request=request) @router.get("/backfill/candidates") async def backfill_candidates( request: Request, kind: str = Query("severity", pattern="^(severity|system)$"), limit: int = Query(30, ge=1, le=100), suggest: bool = Query(True, description="Ask the AI for suggestions"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Reports missing a grade or a system link, oldest first, each with an AI suggestion the operator can adopt. Suggestions come from the TITLE alone: ``raw_report`` is empty on every row because the FY27 import deliberately kept researcher correspondence out of the database. Titles here are unusually informative, which is what makes it workable. Nothing is applied. A degraded AI (unconfigured, failing) returns rows with no suggestion rather than a guess — the operator adopting them is the point, and the failure mode being avoided is the HRIS one, where a loose predicate created 26 accounts because nothing asked a person. """ _require_view(user) rows = await svc.reports_needing_backfill(db, kind=kind, limit=limit) tiers = await cfg.tier_map(db) researchers, _ = await svc.load_report_refs(db, rows) fy_start_month = await fy.get_fiscal_year_start_month(db) suggestions: dict[str, dict[str, Any]] = {} slug_by_host: dict[str, dict[str, Any]] = {} if suggest and rows: options = await registry.live_systems_for_matching(db) for opt in options: for host in opt.get("hostnames") or []: slug_by_host[host.lower()] = opt suggestions = await bounty_ai.suggest_from_titles( db, items=[{"code": r.code, "title": r.title} for r in rows], severity_keys=[k for k, t in tiers.items() if t.enabled], known_hosts=sorted({ host for opt in options for host in (opt.get("hostnames") or []) }), ) items = [] for row in rows: payload = serialize_report( row, researcher=researchers.get(row.researcher_id), tiers=tiers, include_sensitive=True, include_content=False, fy_start_month=fy_start_month, ) hint = suggestions.get(row.code) or {} # The report's OWN hostname first. 84 of the unlinked reports name # a host, and for those the match against the register is exact and # needs no model at all — asking the AI to guess a host the report # already states made the suggestion depend on the LLM echoing it # back, and produced an empty column on every row whenever the AI # was degraded or unconfigured. `.strip()` because this column was # unnormalised until 2026-08-26 and arrives by paste. stated = (row.affected_system or "").strip().lower() matched = slug_by_host.get(stated) if stated else None source = "report" if matched else None if matched is None and hint.get("host"): matched = slug_by_host.get(str(hint["host"]).strip().lower()) source = "ai" if matched else None # Prefer the level read from the WHOLE write-up over one guessed # from the title. 131 reports now carry `suggested_severity` from # the full-text assessment, and re-asking the model to guess from a # title threw that away — a strictly worse question with a stored # answer sitting right there. stored_severity = row.suggested_severity payload["suggestion"] = { "severity": stored_severity or hint.get("severity"), # So the operator can see which question produced it: the whole # report, or just its title. "severity_source": ( "write_up" if stored_severity else ("title" if hint.get("severity") else None) ), # Show whichever host produced the match, so the operator can # see what they are adopting rather than a bare system name. "host": (row.affected_system if source == "report" else hint.get("host")), "system": matched, # Which signal found it. An exact match on the report's own # hostname is not a guess and should not read like one. "match_source": source, "internal": hint.get("internal"), "confidence": hint.get("confidence"), } items.append(payload) return success_response({"items": items, "kind": kind}, request=request) @router.post("/backfill/severity") @idempotent("bounty.backfill.severity") async def backfill_severity( body: BackfillSeverityRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Adopt grades for many reports. Internal field, so bulk is allowed — unlike a reply, a wrong grade is visible in Helix and correctable.""" _require_manage(user) actor = await _actor(db, user) result = await svc.bulk_set_severity( db, report_ids=[i.report_id for i in body.items], severity_by_id={i.report_id: i.severity for i in body.items}, actor=actor, ) await _audit( db, request, user, action="bounty.backfill.severity", resource_id=None, detail=result, ) return success_response(result, request=request) @router.post("/backfill/link-system") @idempotent("bounty.backfill.link_system") async def backfill_link_system( body: BackfillLinkRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Link many reports to registry systems. Already-linked reports are skipped, so a re-run cannot re-point an earlier decision.""" _require_manage(user) actor = await _actor(db, user) for item in body.items: await _resolve_system(db, item.system_id) result = await svc.bulk_link_system( db, system_by_id={i.report_id: i.system_id for i in body.items}, actor=actor, ) await _audit( db, request, user, action="bounty.backfill.link_system", resource_id=None, detail=result, ) return success_response(result, request=request) @router.post("/backfill/no-system") @idempotent("bounty.backfill.no_system") async def backfill_no_system( body: BackfillNoSystemRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Record that no internal system applies — an open-source product finding, a CI workflow. The only way out of the "unlinked" gap that is not a link, so the operator's note lands on each report's own timeline.""" _require_manage(user) actor = await _actor(db, user) result = await svc.bulk_mark_no_system( db, report_ids=body.report_ids, actor=actor, note=body.note, ) await _audit( db, request, user, action="bounty.backfill.no_system", resource_id=None, detail=result, ) return success_response(result, request=request) @router.get("/analytics/lane") async def get_lane( request: Request, row: str = Query(..., min_length=1, max_length=200), per_stage: int = Query(6, ge=1, le=20), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """One ownership row as a left-to-right pipeline with real reports. The matrix answers "how much and how hot"; a number cannot say *which* report has been waiting 295 days. Shares the matrix's stage predicates by construction so the two views cannot disagree. """ _require_view(user) return success_response( await analytics.lane(db, row_key=row, per_stage=per_stage), request=request, ) @router.get("/analytics/data-gaps") async def get_data_gaps( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """What has to be filled in before the rest of this domain can work. Every mechanism added recently reads a column that is empty on most rows — routing reads the registry owner, the reward ladder reads severity, budget attribution reads the linked system. This endpoint exists so the console can say which column, how many reports it blocks, and where to go fix it, instead of reporting nothing wrong while 97 reports sit unroutable. """ _require_view(user) return success_response(await analytics.data_gaps(db), request=request) @router.get("/analytics/routable") async def get_routable_unassigned( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Open reports whose system has a known, active owner that nobody has been asked. Linking records who should answer; this is the queue of times we knew and did not ask.""" _require_view(user) return success_response( await analytics.routable_unassigned(db), request=request, ) @router.get("/analytics/ledger/export.xlsx") async def export_ledger( request: Request, fiscal_year: int | None = Query(None), since: date | None = Query(None), until: date | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """One row per report — the payout ledger finance and audit ask for. Includes the researcher and the amount: this is gated exactly like the console, and a payout ledger without the payee or the figure is not a ledger. Still no banking details, because Helix never stores them. """ _require_view(user) rows = await analytics.ledger_rows( db, fiscal_year=fiscal_year, since=since, until=until, ) return await xlsx_export_response( db, request, user, columns=analytics.LEDGER_COLUMNS, rows=rows, total_matched=len(rows), filename_stem="bounty-ledger", sheet_title="漏洞赏金台账", audit_action="bounty.ledger.export", audit_resource_type=_RESOURCE, filters={ "fiscal_year": fiscal_year, "since": since.isoformat() if since else None, "until": until.isoformat() if until else None, }, ) # ════════════════════════════════════════════════════════════════════ # AI assistance # ════════════════════════════════════════════════════════════════════ @router.post("/ai/parse") async def ai_parse( body: AiParseRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Structure a pasted researcher mail. Persists nothing — the operator reviews the extraction and submits the normal create call.""" _require_manage(user) tiers = await cfg.list_tiers(db, include_disabled=False) parsed = await bounty_ai.parse_report( db, raw_text=body.raw_text, severity_keys=[t.key for t in tiers], ) await _audit( db, request, user, action="bounty.ai.parse", resource_id=None, detail={ "chars": len(body.raw_text), "confidence": parsed.get("confidence"), }, ) return success_response(parsed, request=request) @router.post("/reports/{report_id}/ai/draft-reply") async def ai_draft_reply( report_id: uuid.UUID, body: AiDraftRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Draft a report-specific opening paragraph for a template reply.""" _require_manage(user) report = await svc.get_report(db, report_id) template = await cfg.get_template(db, body.template_key) tiers = await cfg.tier_map(db) tier = tiers.get(report.severity) if report.severity else None # Only non-identifying facts go to the model: no researcher email, no # payment reference, no raw mail (which may quote third parties). context = { "title": report.title, "summary": report.summary, "affected_system": report.affected_system, "vuln_type": report.vuln_type, "cve_id": report.cve_id, "severity": tier.label_en if tier else None, "status": report.status, "remediation_status": report.remediation_status, "reward_usd": ( float(report.reward_amount_usd) if report.reward_amount_usd is not None else None ), } drafted = await bounty_ai.draft_reply_intro( db, report_context=context, template_name=template.name_en, template_body=template.body, instruction=body.instruction, ) return success_response(drafted, request=request) @router.get("/reports/{report_id}/attachments/{attachment_id}/url") async def attachment_url( report_id: uuid.UUID, attachment_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """A short-lived download URL for one attachment, for the desk. Same gate as every other read on this router. The lookup is scoped to ``report_id`` so an attachment id from another report cannot be signed through this path — the URL bypasses Helix auth once minted, which is why the check belongs before the call and not inside it. """ _require_view(user) report = await svc.get_report(db, report_id) url = await svc.attachment_download_url( db, report_id=report.id, attachment_id=attachment_id, ) if url is None: raise HTTPException(status_code=404, detail="ATTACHMENT_NOT_FOUND") return success_response({"url": url}, request=request)