Files
rengine/api/app/services/bounty_program.py
T
Yogesh OjhaandClaude Opus 5.5 682a177e9b feat(bounty-hub): HackerOne reports and earnings with program cross-links
- sync the account's reports, bounties and standing from the HackerOne
  hacker API into bounty_reports, bounty_awards and bounty_accounts;
  money comes from /payments/earnings, totals stay per currency
- /bounty-hub/<platform> as a Bounty Hub child in the sidebar, in the
  /scans idiom: stat strip, severity toggles, monthly chart with drag
  range, stage tabs with filtered counts, URL filters, expandable rows,
  keyboard navigation and a by-program view
- program names open the Bounty Hub program sheet, which links back to
  the reports on that program and fetches unfetched scope on open
- hourly report sync gated on bug bounty mode and the sync interval

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:51:30 +05:30

790 lines
30 KiB
Python

from datetime import timedelta
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import Select, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col
from app.services.target import TargetService
from shared.definitions.bounty_feed import (
FEEDS_BY_PLATFORM,
SOURCE_LICENSE,
SOURCE_NAME,
SOURCE_URL,
)
from shared.definitions.bounty_programs import (
API_PLATFORMS,
DEFAULT_FEED_INTERVAL,
DEFAULT_SYNC_INTERVAL,
MAX_TAGS_PER_IMPORT,
NOTIFIABLE_EVENTS,
PLATFORMS,
PLATFORMS_BY_KEY,
SOURCE_LABELS,
SYNC_INTERVAL_HOURS,
ProgramSource,
ProgramState,
ScopeState,
SubmissionState,
SyncInterval,
asset_type_spec,
event_spec,
notify_enabled,
notify_events,
raw_state_label,
)
from shared.enums.api_key import APIProvider
from shared.models.api_key import APIKey
from shared.models.bounty_program import (
BountyEventRead,
BountyEventRow,
BountyImportRequest,
BountyImportResult,
BountyProgram,
BountyProgramDetail,
BountyProgramRead,
BountyScope,
BountyScopeRead,
BountySettingsRead,
BountySettingsUpdate,
BountyStatus,
PlatformCount,
)
from shared.models.instance_settings import InstanceSettings
from shared.models.organization import Organization, OrganizationSummary
from shared.models.tag import Tag, TagSummary, TargetTag
from shared.models.target import Target, TargetOrganization
from shared.models.watch import ProgramWatch
from shared.utils.datetime import utc_now
from shared.utils.slug import add_with_unique_slug
from shared.utils.validation import clean_name
def _platform_label(platform: str) -> str:
spec = PLATFORMS_BY_KEY.get(platform)
return spec.label if spec else platform
MAX_ORG_NAME = 100
MAX_TAG_NAME = 50
DEFAULT_TAG_COLOR = "#6B7280"
MAX_ORG_DESCRIPTION = 500
EMPTY_COUNTS = {"in_scope_count": 0, "out_of_scope_count": 0, "importable_count": 0}
def _without_counts(program) -> dict:
"""paginate() may hand back an already-coerced read."""
data = program.model_dump()
for key in (
*EMPTY_COUNTS,
"source_labels",
"follow_label",
"imported_count",
"watched",
"watch_id",
"scopes",
"unreachable",
"raw_state_label",
):
data.pop(key, None)
data["raw_state_label"] = raw_state_label(data.get("raw_state"))
spec = PLATFORMS_BY_KEY.get(data.get("platform") or "")
data["platform_label"] = spec.label if spec else (data.get("platform") or "")
data["follow_label"] = spec.follow_label if spec else ""
data["source_label"] = SOURCE_LABELS.get(data.get("source") or "", "")
sources = data.get("sources") or ([data["source"]] if data.get("source") else [])
data["sources"] = sources
data["source_labels"] = [SOURCE_LABELS.get(s, s) for s in sources]
return data
SORTS = {
"name": (col(BountyProgram.name), "asc"),
"reports": (col(BountyProgram.reports_for_user), "desc"),
"age": (col(BountyProgram.started_accepting_at), "desc"),
"payout": (col(BountyProgram.max_payout), "desc"),
"assets": (col(BountyProgram.name), "asc"),
}
class BountyProgramService:
def __init__(self, session: AsyncSession):
self.session = session
async def _connected(self) -> dict[str, dict]:
"""Every platform credential this instance holds, keyed by provider."""
wanted = [APIProvider(p.api_provider) for p in API_PLATFORMS if p.api_provider]
rows = await self.session.execute(
select(APIKey).where(
col(APIKey.provider).in_(wanted),
APIKey.is_enabled == True, # noqa: E712
)
)
return {k.provider.value: (k.key_meta or {}) for k in rows.scalars()}
async def _credentials_username(self, platform: str) -> str | None:
spec = PLATFORMS_BY_KEY.get(platform)
if not spec or not spec.api_provider:
return None
meta = (await self._connected()).get(spec.api_provider)
if meta is None:
return None
return meta.get("username") or spec.label
async def _settings(self) -> InstanceSettings | None:
rows = await self.session.execute(select(InstanceSettings).limit(1))
return rows.scalar_one_or_none()
async def events(
self, platform: str | None, *, kind: str | None, handle: str | None
) -> Select:
query = select(BountyEventRow)
if platform:
query = query.where(BountyEventRow.platform == platform)
if kind:
query = query.where(BountyEventRow.kind == kind)
if handle:
query = query.where(BountyEventRow.handle == handle)
return query.order_by(col(BountyEventRow.created_at).desc())
@staticmethod
def event_read(row: BountyEventRow) -> BountyEventRead:
spec = event_spec(row.kind)
return BountyEventRead(
id=row.id,
platform=row.platform,
handle=row.handle,
program_name=row.program_name,
kind=row.kind,
label=spec.label,
description=spec.description,
icon=spec.icon,
tone=spec.tone,
actionable=spec.actionable,
asset_type=row.asset_type,
asset_identifier=row.asset_identifier,
detail=row.detail,
created_at=row.created_at,
)
async def read_settings(self) -> BountySettingsRead:
settings = await self._settings()
interval = (
settings.bounty_sync_interval if settings else None
) or DEFAULT_SYNC_INTERVAL
stored = settings.bounty_settings if settings else {}
synced_at = settings.bounty_synced_at if settings else None
hours = SYNC_INTERVAL_HOURS.get(interval)
totals = await self.session.execute(
select(
select(func.count(BountyProgram.id)).scalar_subquery(),
select(func.count(BountyEventRow.id)).scalar_subquery(),
)
)
programs, events = totals.one()
feed_interval = (
settings.bounty_feed_interval if settings else None
) or DEFAULT_FEED_INTERVAL
feed_synced = settings.bounty_feed_synced_at if settings else None
feed_hours = SYNC_INTERVAL_HOURS.get(feed_interval)
feed_programs = await self.session.execute(
select(func.count(BountyProgram.id)).where(
BountyProgram.source == ProgramSource.FEED.value
)
)
return BountySettingsRead(
platforms=await self.platform_counts(),
sync_interval=interval,
feed_interval=feed_interval,
feed_synced_at=feed_synced,
feed_next_sync_at=(
(feed_synced + timedelta(hours=feed_hours))
if (feed_hours and feed_synced)
else None
),
feed_programs=feed_programs.scalar_one() or 0,
feed_source=SOURCE_NAME,
feed_url=SOURCE_URL,
feed_license=SOURCE_LICENSE,
notify=notify_enabled(stored),
notify_events=sorted(notify_events(stored)),
notifiable_events=list(NOTIFIABLE_EVENTS),
last_synced_at=synced_at,
next_sync_at=(
(synced_at + timedelta(hours=hours)) if (hours and synced_at) else None
),
programs=programs or 0,
events_recorded=events or 0,
)
async def write_settings(self, data: BountySettingsUpdate) -> BountySettingsRead:
settings = await self._settings()
if settings is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance settings not found",
)
if data.sync_interval is not None:
if data.sync_interval not in {i.value for i in SyncInterval}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown sync interval: {data.sync_interval}",
)
settings.bounty_sync_interval = data.sync_interval
if data.feed_interval is not None:
if data.feed_interval not in {i.value for i in SyncInterval}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown sync interval: {data.feed_interval}",
)
settings.bounty_feed_interval = data.feed_interval
stored = dict(settings.bounty_settings or {})
if data.notify is not None:
stored["notify"] = data.notify
if data.notify_events is not None:
unknown = set(data.notify_events) - set(NOTIFIABLE_EVENTS)
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown change kinds: {', '.join(sorted(unknown))}",
)
stored["notify_events"] = sorted(set(data.notify_events))
settings.bounty_settings = stored
await self.session.commit()
return await self.read_settings()
async def mark_events_seen(self) -> None:
settings = await self._settings()
if settings:
settings.bounty_events_seen_at = utc_now()
await self.session.commit()
async def platform_counts(self) -> list[PlatformCount]:
rows = await self.session.execute(
select(
BountyProgram.platform,
func.count(BountyProgram.id),
func.count(BountyProgram.id).filter(
BountyProgram.program_state == ProgramState.PRIVATE.value
),
func.count(BountyProgram.id).filter(
BountyProgram.source == ProgramSource.FEED.value
),
).group_by(BountyProgram.platform)
)
counts = {r[0]: r[1:] for r in rows.all()}
empty = (0, 0, 0)
connected = await self._connected()
return [
PlatformCount(
platform=spec.key,
label=spec.label,
source=spec.source,
programs=counts.get(spec.key, empty)[0],
private_programs=counts.get(spec.key, empty)[1],
feed_programs=counts.get(spec.key, empty)[2],
has_feed=spec.key in FEEDS_BY_PLATFORM,
api_provider=spec.api_provider,
supports_private=spec.supports_private,
credential=spec.credential,
note=spec.note,
configured=bool(spec.api_provider and spec.api_provider in connected),
tracks_reports=spec.tracks_reports,
)
for spec in PLATFORMS
]
async def source_counts(self) -> dict[str, int]:
"""Programs each source has seen, counting a program under every source."""
rows = await self.session.execute(
select(
*[
func.count(BountyProgram.id).filter(
col(BountyProgram.sources).contains([source.value])
)
for source in ProgramSource
]
)
)
return dict(zip([s.value for s in ProgramSource], rows.one(), strict=True))
async def status(self, platform: str) -> BountyStatus:
username = await self._credentials_username(platform)
settings = await self._settings()
interval = (settings.bounty_sync_interval if settings else None) or (
DEFAULT_SYNC_INTERVAL
)
synced_at = settings.bounty_synced_at if settings else None
seen_at = settings.bounty_events_seen_at if settings else None
hours = SYNC_INTERVAL_HOURS.get(interval)
next_sync = (
(synced_at + timedelta(hours=hours)) if (hours and synced_at) else None
)
unseen = await self.session.execute(
select(func.count(BountyEventRow.id)).where(
*([BountyEventRow.created_at > seen_at] if seen_at else []),
)
)
platforms = await self.platform_counts()
source_counts = await self.source_counts()
totals = await self.session.execute(
select(
func.count(BountyProgram.id),
func.count(BountyProgram.id).filter(
BountyProgram.program_state == ProgramState.PRIVATE.value
),
func.max(BountyProgram.synced_at),
)
)
total, private, last = totals.one()
return BountyStatus(
configured=bool(username),
platform=platform,
username=username,
programs=total or 0,
private_programs=private or 0,
last_synced_at=synced_at or last,
sync_interval=interval,
next_sync_at=next_sync,
unseen_events=unseen.scalar_one() or 0,
platforms=platforms,
source_counts=source_counts,
feed_interval=(settings.bounty_feed_interval if settings else None)
or DEFAULT_FEED_INTERVAL,
feed_synced_at=settings.bounty_feed_synced_at if settings else None,
)
def _filtered(
self,
platform: str | None,
*,
platforms: list[str] | None,
sources: list[str] | None,
q: str | None,
state: str | None,
submission: str | None,
bounty: bool | None,
bookmarked: bool | None,
joined: bool | None,
scope: str | None,
) -> Select:
query = select(BountyProgram)
if platform:
query = query.where(BountyProgram.platform == platform)
if platforms:
query = query.where(col(BountyProgram.platform).in_(platforms))
if sources:
query = query.where(
or_(*[col(BountyProgram.sources).contains([s]) for s in sources])
)
if q:
term = f"%{q.strip().lower()}%"
query = query.where(
or_(
func.lower(BountyProgram.name).like(term),
func.lower(BountyProgram.handle).like(term),
)
)
if state in {s.value for s in ProgramState}:
query = query.where(BountyProgram.program_state == state)
if submission in {s.value for s in SubmissionState}:
query = query.where(BountyProgram.submission_state == submission)
if bounty is not None:
query = query.where(BountyProgram.offers_bounties == bounty)
if bookmarked is not None:
query = query.where(BountyProgram.bookmarked == bookmarked)
if joined is not None:
query = query.where(BountyProgram.joined == joined)
if scope == "importable":
query = query.where(
select(BountyScope.id)
.where(
BountyScope.program_id == BountyProgram.id,
BountyScope.scope_state == ScopeState.IN_SCOPE.value,
col(BountyScope.target_value).is_not(None),
)
.exists()
)
elif scope == "none":
query = query.where(
~select(BountyScope.id)
.where(BountyScope.program_id == BountyProgram.id)
.exists()
)
return query
def list_query(self, platform: str | None, *, sort: str, **filters) -> Select:
query = self._filtered(platform, **filters)
column, default_dir = SORTS.get(sort or "age", SORTS["age"])
ordering = column.desc() if default_dir == "desc" else column.asc()
return query.order_by(ordering.nullslast(), col(BountyProgram.name).asc())
async def imported_for(
self, program_ids: list[UUID], project_id: UUID | None
) -> dict[UUID, int]:
"""How many of each program's assets the project already tracks."""
if not program_ids or not project_id:
return {}
rows = await self.session.execute(
select(
BountyScope.program_id,
func.count(func.distinct(BountyScope.target_value)),
)
.join(
Target,
(Target.target_value == BountyScope.target_value)
& (Target.project_id == project_id),
)
.where(col(BountyScope.program_id).in_(program_ids))
.group_by(BountyScope.program_id)
)
return {program_id: count or 0 for program_id, count in rows.all()}
async def counts_for(self, program_ids: list[UUID]) -> dict[UUID, dict[str, int]]:
"""Scope tallies per program."""
if not program_ids:
return {}
rows = await self.session.execute(
select(
BountyScope.program_id,
func.count(BountyScope.id),
func.count(BountyScope.id).filter(
BountyScope.scope_state == ScopeState.IN_SCOPE.value
),
func.count(BountyScope.id).filter(
col(BountyScope.target_value).is_not(None),
BountyScope.scope_state == ScopeState.IN_SCOPE.value,
),
)
.where(col(BountyScope.program_id).in_(program_ids))
.group_by(BountyScope.program_id)
)
out: dict[UUID, dict[str, int]] = {}
for program_id, total, in_scope, importable in rows.all():
out[program_id] = {
"in_scope_count": in_scope or 0,
"out_of_scope_count": (total or 0) - (in_scope or 0),
"importable_count": importable or 0,
}
return out
async def to_read(
self, programs: list[BountyProgram], project_id: UUID | None = None
) -> list[BountyProgramRead]:
ids = [p.id for p in programs]
counts = await self.counts_for(ids)
imported = await self.imported_for(ids, project_id)
watched = await self.watched_for(ids, project_id)
return [
BountyProgramRead(
**_without_counts(p),
**counts.get(p.id, EMPTY_COUNTS),
imported_count=imported.get(p.id, 0),
watched=p.id in watched,
watch_id=watched.get(p.id),
)
for p in programs
]
async def watched_for(
self, program_ids: list[UUID], project_id: UUID | None
) -> dict[UUID, UUID]:
if not project_id or not program_ids:
return {}
rows = await self.session.execute(
select(ProgramWatch.program_id, ProgramWatch.id).where(
ProgramWatch.project_id == project_id,
ProgramWatch.program_id.in_(program_ids),
)
)
return dict(rows.all())
async def get_program(self, platform: str, handle: str) -> BountyProgram:
row = await self.session.execute(
select(BountyProgram).where(
BountyProgram.platform == platform, BountyProgram.handle == handle
)
)
program = row.scalar_one_or_none()
if not program:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Program not found"
)
return program
async def detail(
self,
platform: str,
handle: str,
*,
project_id: UUID | None,
scope: str | None = None,
asset_type: str | None = None,
) -> BountyProgramDetail:
program = await self.get_program(platform, handle)
query = select(BountyScope).where(BountyScope.program_id == program.id)
if scope in {s.value for s in ScopeState}:
query = query.where(BountyScope.scope_state == scope)
if asset_type:
query = query.where(BountyScope.asset_type == asset_type.upper())
rows = (await self.session.execute(query)).scalars().all()
existing: set[str] = set()
if project_id:
values = [s.target_value for s in rows if s.target_value]
if values:
found = await self.session.execute(
select(Target.target_value).where(
Target.project_id == project_id,
col(Target.target_value).in_(values),
)
)
existing = set(found.scalars().all())
scopes = [self._scope_read(s, existing) for s in rows]
scopes.sort(
key=lambda s: (
s.scope_state != ScopeState.IN_SCOPE.value,
not s.importable,
s.asset_identifier,
)
)
counts = await self.counts_for([program.id])
unreachable: dict[str, int] = {}
for s in rows:
if s.target_value:
continue
label = asset_type_spec(s.asset_type).label
unreachable[label] = unreachable.get(label, 0) + 1
return BountyProgramDetail(
**_without_counts(program),
**counts.get(program.id, EMPTY_COUNTS),
imported_count=len(existing),
scopes=scopes,
unreachable=unreachable,
)
@staticmethod
def _scope_read(scope: BountyScope, existing: set[str]) -> BountyScopeRead:
spec = asset_type_spec(scope.asset_type)
return BountyScopeRead(
id=scope.id,
asset_type=scope.asset_type,
asset_type_label=spec.label,
asset_group=spec.group.value,
icon=spec.icon,
asset_identifier=scope.asset_identifier,
scope_state=scope.scope_state,
eligible_for_bounty=scope.eligible_for_bounty,
max_severity=scope.max_severity,
tier=scope.tier,
instruction=scope.instruction,
target_value=scope.target_value,
target_type=scope.target_type.value if scope.target_type else None,
importable=bool(scope.target_value),
already_target=bool(scope.target_value and scope.target_value in existing),
)
async def import_scopes(
self, platform: str, handle: str, request: BountyImportRequest, user_id
) -> BountyImportResult:
"""Add a program's scannable assets to a project as targets."""
program = await self.get_program(platform, handle)
query = select(BountyScope).where(
BountyScope.program_id == program.id,
col(BountyScope.target_value).is_not(None),
)
if request.scope_ids is not None:
query = query.where(col(BountyScope.id).in_(request.scope_ids))
if not request.include_out_of_scope:
query = query.where(BountyScope.scope_state == ScopeState.IN_SCOPE.value)
rows = (await self.session.execute(query)).scalars().all()
unreachable = select(BountyScope).where(
BountyScope.program_id == program.id,
col(BountyScope.target_value).is_(None),
)
if request.scope_ids is not None:
unreachable = unreachable.where(col(BountyScope.id).in_(request.scope_ids))
if not request.include_out_of_scope:
unreachable = unreachable.where(
BountyScope.scope_state == ScopeState.IN_SCOPE.value
)
skipped = sorted(
{
s.asset_identifier
for s in (await self.session.execute(unreachable)).scalars()
}
)
values = sorted({s.target_value for s in rows if s.target_value})
if not values:
return BountyImportResult(created=[], existing=[], skipped=skipped)
before = await self.session.execute(
select(Target.target_value).where(
Target.project_id == request.project_id,
col(Target.target_value).in_(values),
)
)
already = set(before.scalars().all())
targets = TargetService(self.session)
rows = await targets.ensure_targets(values, request.project_id, user_id)
organization = None
if request.group_by_program:
organization = await self._organization_for(
program, request.project_id, user_id, request.organization_name
)
await self._attach(rows, organization)
tags = await self._tags_for(request, program, user_id)
await self._attach_tags(rows, tags)
await self.session.commit()
return BountyImportResult(
created=[v for v in values if v not in already],
existing=sorted(already),
skipped=skipped,
organization=(
OrganizationSummary(
id=organization.id, name=organization.name, slug=organization.slug
)
if organization
else None
),
tags=[
TagSummary(id=t.id, name=t.name, slug=t.slug, color=t.color)
for t in tags
],
)
async def _tags_for(
self, request: BountyImportRequest, program: BountyProgram, user_id
) -> list[Tag]:
"""The project's tags for these names, created on first use."""
spec = PLATFORMS_BY_KEY.get(program.platform)
wanted: dict[str, str] = {}
for raw in request.tags[:MAX_TAGS_PER_IMPORT]:
candidate = (raw or "").strip()[:MAX_TAG_NAME]
if not candidate:
continue
wanted.setdefault(candidate.lower(), DEFAULT_TAG_COLOR)
if spec and spec.tag in wanted:
wanted[spec.tag] = spec.tag_color
if not wanted:
return []
found = await self.session.execute(
select(Tag).where(
Tag.project_id == request.project_id, col(Tag.name).in_(list(wanted))
)
)
tags = {t.name: t for t in found.scalars().all()}
for name, color in wanted.items():
if name in tags:
continue
tag = Tag(
name=name,
color=color,
project_id=request.project_id,
created_by=user_id,
)
try:
async with self.session.begin_nested():
await add_with_unique_slug(
self.session, tag, name, project_id=request.project_id
)
except IntegrityError:
again = await self.session.execute(
select(Tag).where(
Tag.project_id == request.project_id, Tag.name == name
)
)
tags[name] = again.scalar_one()
continue
tags[name] = tag
return [tags[name] for name in wanted if name in tags]
async def _attach_tags(self, targets: list[Target], tags: list[Tag]) -> None:
"""A tag already on a target must not fail the import."""
if not targets or not tags:
return
await self.session.execute(
pg_insert(TargetTag)
.values(
[{"target_id": t.id, "tag_id": tag.id} for t in targets for tag in tags]
)
.on_conflict_do_nothing()
)
async def _organization_for(
self,
program: BountyProgram,
project_id: UUID,
user_id,
override: str | None = None,
) -> Organization:
"""The project's organization for this program, created on first import."""
chosen = ((override or "").strip() or program.name)[:MAX_ORG_NAME]
try:
name = clean_name(chosen, max_len=MAX_ORG_NAME).lower()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
found = await self.session.execute(
select(Organization).where(
Organization.project_id == project_id, Organization.name == name
)
)
existing = found.scalar_one_or_none()
if existing:
return existing
organization = Organization(
name=name,
description=f"{_platform_label(program.platform)} program @{program.handle}"[
:MAX_ORG_DESCRIPTION
],
project_id=project_id,
created_by=user_id,
)
try:
async with self.session.begin_nested():
await add_with_unique_slug(
self.session, organization, name, project_id=project_id
)
except IntegrityError:
again = await self.session.execute(
select(Organization).where(
Organization.project_id == project_id, Organization.name == name
)
)
return again.scalar_one()
return organization
async def _attach(self, targets: list[Target], organization: Organization) -> None:
"""A target already under the organization must not fail the import."""
if not targets:
return
await self.session.execute(
pg_insert(TargetOrganization)
.values(
[
{"target_id": t.id, "organization_id": organization.id}
for t in targets
]
)
.on_conflict_do_nothing()
)
@staticmethod
def require_platform(platform: str) -> str:
if platform not in PLATFORMS_BY_KEY:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown platform: {platform}",
)
return platform