mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
The search box cleared itself on every keystroke. The effect that syncs the input to the applied filter both read and wrote `draft`, so it was its own dependency: typing a character reset the field, and only the last character of each debounce window ever reached the query. The read is untracked now, and typing "adobe" keeps "adobe" and returns the 2 rows the API returns. Closing a deep-linked program reopened it. The guard was cleared synchronously while goto() dropped ?program= asynchronously, so the effect re-fired with the param still present. deepLinked is now cleared only once the param has actually left the URL. Verified: the sheet stays closed and the query string empties. sync_lock could mask the real exception and strand the advisory lock. Its finally ran a statement on a possibly-poisoned transaction, which raises PendingRollbackError over the original error and skips the unlock — and because the engine is pooled, a session-level lock outlives session.close() and would make every later sync answer "already_running" with nothing logged. It rolls back first now; a deliberately poisoned transaction preserves its DataError and the next run reacquires the lock. target_value is varchar(500) and nothing capped what was written to it, so a program listing a longer URL would abort a sync the way the profile picture and the event detail already did. A resolved value that could never be stored — or imported, since targets.target_value is also 500 — is no longer offered. The per-program refresh took no lock while doing exactly the work the lock was added for: on a feed platform it redownloads the file and rewrites every program's scope. It now takes the same lock, and the endpoint is superuser like its two siblings rather than letting any user drive that repeatedly. Also: the program sheet said HackerOne in five places and linked feed programs to a hackerone.com page that does not exist; formatPayout still announced a minimum as "up to" and _organization_for still rolled back instead of using a savepoint, both because an earlier unasserted string replace silently did not apply; scope_ids=[] was read as "every scope" rather than none; and the feed hardcoded "six_hours" and the "program_added" literal instead of their definitions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZEi41ZDRWtwYDPFwoCVnu
449 lines
15 KiB
Python
449 lines
15 KiB
Python
"""HackerOne programs and their structured scopes: fetch, store, and resolve to targets.
|
|
|
|
Measured limits are 600 reads/minute, so the pacer only has to survive a burst; the
|
|
client backs off on 429 and never touches a write endpoint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import delete, select, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from shared.definitions.bounty_programs import (
|
|
DEFAULT_SYNC_INTERVAL,
|
|
MAX_EVENT_DETAIL,
|
|
SYNC_INTERVAL_HOURS,
|
|
BountyEvent,
|
|
BountyPlatform,
|
|
ScopeState,
|
|
SubmissionState,
|
|
asset_type_spec,
|
|
program_state,
|
|
scope_state,
|
|
submission_state,
|
|
target_for_scope,
|
|
)
|
|
from shared.enums.api_key import APIProvider
|
|
from shared.logging import get_logger
|
|
from shared.models.api_key import APIKey
|
|
from shared.models.bounty_program import BountyEventRow, BountyProgram, BountyScope
|
|
from shared.utils.crypto import try_decrypt
|
|
from shared.utils.datetime import utc_now
|
|
from shared.utils.text import strip_control
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
BASE_URL = "https://api.hackerone.com/v1/hackers"
|
|
TIMEOUT = 30
|
|
PAGE_SIZE = 100
|
|
MAX_PAGES = 200
|
|
HTTP_TOO_MANY = 429
|
|
HTTP_UNAUTHORIZED = 401
|
|
RETRY_AFTER_DEFAULT = 5
|
|
MAX_RETRIES = 3
|
|
MAX_INSTRUCTION = 4000
|
|
# a truncated URL is a broken link, so an absurd one is dropped instead
|
|
MAX_PICTURE_URL = 4000
|
|
|
|
|
|
# two syncs deleting the same scope rows block each other badly: a no-change
|
|
# yeswehack pass measured 0.5s alone and 120.5s against a concurrent run
|
|
SYNC_LOCK_KEY = 0x624F0001
|
|
FEED_LOCK_KEY = 0x624F0002
|
|
|
|
|
|
@contextmanager
|
|
def sync_lock(session: Session, key: int) -> Iterator[bool]:
|
|
"""Session-level advisory lock, so it survives the per-program commits."""
|
|
held = bool(
|
|
session.execute(
|
|
text("SELECT pg_try_advisory_lock(:key)"), {"key": key}
|
|
).scalar_one()
|
|
)
|
|
try:
|
|
yield held
|
|
finally:
|
|
if held:
|
|
# the body may have poisoned the transaction; unlocking on a pooled
|
|
# connection matters more than the failed work
|
|
session.rollback()
|
|
session.execute(text("SELECT pg_advisory_unlock(:key)"), {"key": key})
|
|
session.commit()
|
|
|
|
|
|
class HackerOneError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class CredentialsError(HackerOneError):
|
|
pass
|
|
|
|
|
|
def credentials(session: Session) -> tuple[str, str] | None:
|
|
"""The instance's HackerOne API username and token, or None when not configured."""
|
|
row = session.execute(
|
|
select(APIKey).where(
|
|
APIKey.provider == APIProvider.HACKERONE,
|
|
APIKey.is_enabled == True, # noqa: E712
|
|
)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
return None
|
|
username = (row.key_meta or {}).get("username")
|
|
token = try_decrypt(row.key_value) or row.key_value
|
|
if not username or not token:
|
|
return None
|
|
return str(username), str(token)
|
|
|
|
|
|
def _request(path: str, params: dict | None, auth: tuple[str, str]) -> dict:
|
|
url = f"{BASE_URL}{path}"
|
|
if params:
|
|
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
return _get(url, auth)
|
|
|
|
|
|
def _get(url: str, auth: tuple[str, str]) -> dict:
|
|
token = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
|
|
request = urllib.request.Request( # noqa: S310
|
|
url,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Authorization": f"Basic {token}",
|
|
"User-Agent": "reNgine",
|
|
},
|
|
)
|
|
for attempt in range(MAX_RETRIES):
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310
|
|
return json.loads(response.read().decode("utf-8", errors="replace"))
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == HTTP_UNAUTHORIZED:
|
|
msg = "HackerOne rejected the API username or token"
|
|
raise CredentialsError(msg) from exc
|
|
if exc.code == HTTP_TOO_MANY and attempt < MAX_RETRIES - 1:
|
|
delay = _int(exc.headers.get("Retry-After")) or RETRY_AFTER_DEFAULT
|
|
logger.info("hackerone rate limited, waiting", seconds=delay)
|
|
time.sleep(min(delay, 60))
|
|
continue
|
|
msg = f"HackerOne returned {exc.code}"
|
|
raise HackerOneError(msg) from exc
|
|
except Exception as exc:
|
|
raise HackerOneError(str(exc)) from exc
|
|
msg = "HackerOne rate limit did not clear"
|
|
raise HackerOneError(msg)
|
|
|
|
|
|
def _paged(path: str, auth: tuple[str, str]) -> list[dict]:
|
|
"""Every page of a collection, following the API's own next link."""
|
|
rows: list[dict] = []
|
|
payload = _request(path, {"page[size]": PAGE_SIZE}, auth)
|
|
for _ in range(MAX_PAGES):
|
|
rows.extend(payload.get("data") or [])
|
|
next_url = (payload.get("links") or {}).get("next")
|
|
if not next_url:
|
|
break
|
|
payload = _get(next_url, auth)
|
|
return rows
|
|
|
|
|
|
def _int(raw: Any) -> int | None:
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _float(raw: Any) -> float | None:
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _dt(raw: Any) -> datetime | None:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def fetch_programs(auth: tuple[str, str]) -> list[dict]:
|
|
"""Every program the token can see, public and private."""
|
|
return _paged("/programs", auth)
|
|
|
|
|
|
def fetch_scopes(handle: str, auth: tuple[str, str]) -> list[dict]:
|
|
"""In-scope and out-of-scope assets for one program."""
|
|
quoted = urllib.parse.quote(handle, safe="")
|
|
return _paged(f"/programs/{quoted}/structured_scopes", auth)
|
|
|
|
|
|
def verify(auth: tuple[str, str]) -> dict:
|
|
"""One cheap call down the same path a sync uses, to prove the credentials work."""
|
|
payload = _request("/programs", {"page[size]": 1}, auth)
|
|
rows = payload.get("data") or []
|
|
sample = ((rows[0] if rows else {}).get("attributes") or {}).get("handle")
|
|
return {"programs_visible": len(rows), "sample_handle": sample}
|
|
|
|
|
|
def _url(raw: Any) -> str | None:
|
|
value = str(raw or "").strip()
|
|
return value if value and len(value) <= MAX_PICTURE_URL else None
|
|
|
|
|
|
def _program_row(entry: dict) -> dict:
|
|
attrs = entry.get("attributes") or {}
|
|
handle = strip_control(str(attrs.get("handle") or ""))[:200]
|
|
return {
|
|
"platform": BountyPlatform.HACKERONE.value,
|
|
"handle": handle,
|
|
"name": strip_control(str(attrs.get("name") or handle))[:300],
|
|
"url": f"https://hackerone.com/{handle}" if handle else None,
|
|
"profile_picture": _url(attrs.get("profile_picture")),
|
|
"program_state": program_state(attrs.get("state")).value,
|
|
"raw_state": (str(attrs["state"])[:32] if attrs.get("state") else None),
|
|
"joined": bool(attrs.get("last_invitation_accepted_at_for_user")),
|
|
"joined_at": _dt(attrs.get("last_invitation_accepted_at_for_user")),
|
|
"submission_state": submission_state(attrs.get("submission_state")).value,
|
|
"offers_bounties": bool(attrs.get("offers_bounties")),
|
|
"open_scope": attrs.get("open_scope"),
|
|
"gold_standard_safe_harbor": attrs.get("gold_standard_safe_harbor"),
|
|
"currency": (str(attrs["currency"])[:16] if attrs.get("currency") else None),
|
|
"started_accepting_at": _dt(attrs.get("started_accepting_at")),
|
|
"bookmarked": bool(attrs.get("bookmarked")),
|
|
"reports_for_user": _int(attrs.get("number_of_reports_for_user")),
|
|
"earnings_for_user": _float(attrs.get("bounty_earned_for_user")),
|
|
"synced_at": utc_now(),
|
|
}
|
|
|
|
|
|
def _scope_row(program_id, entry: dict) -> dict | None:
|
|
attrs = entry.get("attributes") or {}
|
|
asset_type = str(attrs.get("asset_type") or "").upper()
|
|
identifier = strip_control(str(attrs.get("asset_identifier") or "")).strip()
|
|
if not identifier:
|
|
return None
|
|
resolved = target_for_scope(asset_type, identifier)
|
|
instruction = attrs.get("instruction")
|
|
return {
|
|
"program_id": program_id,
|
|
"asset_type": asset_type[:48] or "UNKNOWN",
|
|
"asset_identifier": identifier[:1000],
|
|
"scope_state": scope_state(attrs.get("eligible_for_submission")).value,
|
|
"eligible_for_bounty": attrs.get("eligible_for_bounty"),
|
|
"max_severity": (
|
|
str(attrs.get("max_severity"))[:16] if attrs.get("max_severity") else None
|
|
),
|
|
"instruction": (
|
|
strip_control(str(instruction))[:MAX_INSTRUCTION] if instruction else None
|
|
),
|
|
"reference": (
|
|
strip_control(str(attrs.get("reference")))[:500]
|
|
if attrs.get("reference")
|
|
else None
|
|
),
|
|
"target_value": resolved[0] if resolved else None,
|
|
"target_type": resolved[1] if resolved else None,
|
|
"synced_at": utc_now(),
|
|
}
|
|
|
|
|
|
def _event(program: BountyProgram, kind: str, **extra) -> BountyEventRow:
|
|
return BountyEventRow(
|
|
platform=program.platform,
|
|
program_id=program.id,
|
|
handle=program.handle,
|
|
program_name=program.name,
|
|
kind=kind,
|
|
**extra,
|
|
)
|
|
|
|
|
|
def _program_changes(current: BountyProgram, row: dict) -> list[str]:
|
|
"""What moved on a program the library already knew about."""
|
|
kinds: list[str] = []
|
|
if current.submission_state != row["submission_state"]:
|
|
kinds.append(
|
|
BountyEvent.SUBMISSIONS_OPENED.value
|
|
if row["submission_state"] == SubmissionState.OPEN.value
|
|
else BountyEvent.SUBMISSIONS_CLOSED.value
|
|
)
|
|
if (
|
|
current.program_state != row["program_state"]
|
|
and row["program_state"] == "public"
|
|
):
|
|
kinds.append(BountyEvent.PROGRAM_WENT_PUBLIC.value)
|
|
if row["offers_bounties"] and not current.offers_bounties:
|
|
kinds.append(BountyEvent.BOUNTIES_STARTED.value)
|
|
return kinds
|
|
|
|
|
|
def sync_programs(session: Session, auth: tuple[str, str]) -> dict[str, int]:
|
|
"""Refresh the program list; scopes are fetched per program on demand."""
|
|
started = time.monotonic()
|
|
entries = fetch_programs(auth)
|
|
existing = {
|
|
(p.platform, p.handle): p
|
|
for p in session.execute(
|
|
select(BountyProgram).where(
|
|
BountyProgram.platform == BountyPlatform.HACKERONE.value
|
|
)
|
|
).scalars()
|
|
}
|
|
# a first library sync is a baseline, not 630 new programs
|
|
baseline = bool(existing)
|
|
created = updated = 0
|
|
seen: set[str] = set()
|
|
events: list[BountyEventRow] = []
|
|
for entry in entries:
|
|
row = _program_row(entry)
|
|
if not row["handle"] or row["handle"] in seen:
|
|
continue
|
|
seen.add(row["handle"])
|
|
current = existing.get((row["platform"], row["handle"]))
|
|
if current is None:
|
|
program = BountyProgram(**row)
|
|
session.add(program)
|
|
session.flush()
|
|
created += 1
|
|
if baseline:
|
|
events.append(_event(program, BountyEvent.PROGRAM_ADDED.value))
|
|
continue
|
|
for kind in _program_changes(current, row):
|
|
events.append(_event(current, kind))
|
|
for key, value in row.items():
|
|
setattr(current, key, value)
|
|
updated += 1
|
|
session.add_all(events)
|
|
session.commit()
|
|
return {
|
|
"programs": len(seen),
|
|
"created": created,
|
|
"updated": updated,
|
|
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
"events": len(events),
|
|
}
|
|
|
|
|
|
_SCOPE_TRANSITIONS = {
|
|
(ScopeState.IN_SCOPE.value, ScopeState.OUT_OF_SCOPE.value): (
|
|
BountyEvent.WENT_OUT_OF_SCOPE.value
|
|
),
|
|
(ScopeState.OUT_OF_SCOPE.value, ScopeState.IN_SCOPE.value): (
|
|
BountyEvent.CAME_INTO_SCOPE.value
|
|
),
|
|
}
|
|
|
|
|
|
def _scope_changes(
|
|
program: BountyProgram, before: dict[tuple[str, str], str], after: dict
|
|
) -> list[BountyEventRow]:
|
|
"""Added, removed and flipped assets — never on a program's first read."""
|
|
events: list[BountyEventRow] = []
|
|
for key, row in after.items():
|
|
asset_type, identifier = key
|
|
was = before.get(key)
|
|
if was is None:
|
|
kind = BountyEvent.SCOPE_ADDED.value
|
|
else:
|
|
kind = _SCOPE_TRANSITIONS.get((was, row["scope_state"]))
|
|
if kind is None:
|
|
continue
|
|
events.append(
|
|
_event(
|
|
program,
|
|
kind,
|
|
asset_type=asset_type,
|
|
asset_identifier=identifier,
|
|
detail=(row.get("instruction") or None)
|
|
and row["instruction"][:MAX_EVENT_DETAIL],
|
|
)
|
|
)
|
|
for key in before.keys() - after.keys():
|
|
events.append(
|
|
_event(
|
|
program,
|
|
BountyEvent.SCOPE_REMOVED.value,
|
|
asset_type=key[0],
|
|
asset_identifier=key[1],
|
|
)
|
|
)
|
|
return events
|
|
|
|
|
|
def sync_scopes(session: Session, program: BountyProgram, auth: tuple[str, str]) -> int:
|
|
"""Replace one program's scope rows with what the platform reports now."""
|
|
entries = fetch_scopes(program.handle, auth)
|
|
rows = [r for r in (_scope_row(program.id, e) for e in entries) if r]
|
|
deduped: dict[tuple[str, str], dict] = {}
|
|
for row in rows:
|
|
deduped[(row["asset_type"], row["asset_identifier"])] = row
|
|
|
|
# a program read for the first time is a baseline, not a scope change
|
|
before: dict[tuple[str, str], str] = {}
|
|
if program.scopes_synced_at is not None:
|
|
before = {
|
|
(s.asset_type, s.asset_identifier): s.scope_state
|
|
for s in session.execute(
|
|
select(BountyScope).where(BountyScope.program_id == program.id)
|
|
).scalars()
|
|
}
|
|
session.add_all(_scope_changes(program, before, deduped))
|
|
|
|
# a program restates its whole scope, so the set is replaced not merged
|
|
session.execute(delete(BountyScope).where(BountyScope.program_id == program.id))
|
|
session.add_all([BountyScope(**row) for row in deduped.values()])
|
|
program.scopes_synced_at = utc_now()
|
|
session.commit()
|
|
return len(deduped)
|
|
|
|
|
|
def sync_settings(session: Session) -> tuple[str, datetime | None]:
|
|
row = session.execute(
|
|
text(
|
|
"SELECT bounty_sync_interval, bounty_synced_at FROM instance_settings LIMIT 1"
|
|
)
|
|
).first()
|
|
if not row:
|
|
return DEFAULT_SYNC_INTERVAL, None
|
|
return (row[0] or DEFAULT_SYNC_INTERVAL), row[1]
|
|
|
|
|
|
def sync_due(session: Session) -> bool:
|
|
"""Whether the schedule says to sync now. A manual refresh never asks."""
|
|
interval, last = sync_settings(session)
|
|
hours = SYNC_INTERVAL_HOURS.get(interval)
|
|
if hours is None:
|
|
return False
|
|
return last is None or (utc_now() - last) >= timedelta(hours=hours)
|
|
|
|
|
|
def mark_synced(session: Session) -> None:
|
|
session.execute(text("UPDATE instance_settings SET bounty_synced_at = now()"))
|
|
session.commit()
|
|
|
|
|
|
def unreachable_summary(scopes: list[BountyScope]) -> dict[str, int]:
|
|
"""What the program lists that no scan can reach, counted by asset type label."""
|
|
counts: dict[str, int] = {}
|
|
for scope in scopes:
|
|
if scope.target_value:
|
|
continue
|
|
label = asset_type_spec(scope.asset_type).label
|
|
counts[label] = counts.get(label, 0) + 1
|
|
return counts
|