mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +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
483 lines
14 KiB
Python
483 lines
14 KiB
Python
"""Bug bounty program vocabulary: platforms, scope asset types and what reNgine can scan."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import ipaddress
|
|
import re
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
import validators
|
|
|
|
from shared.enums.target import TargetType
|
|
from shared.utils.text import strip_control
|
|
from shared.utils.validation import normalize_target_value, validate_target
|
|
|
|
|
|
class BountyPlatform(Enum):
|
|
HACKERONE = "hackerone"
|
|
BUGCROWD = "bugcrowd"
|
|
INTIGRITI = "intigriti"
|
|
YESWEHACK = "yeswehack"
|
|
|
|
|
|
class ProgramSource(Enum):
|
|
"""Where the row came from. An API row is authoritative over a feed row."""
|
|
|
|
API = "api"
|
|
FEED = "feed"
|
|
|
|
|
|
class ProgramState(Enum):
|
|
PUBLIC = "public"
|
|
PRIVATE = "private"
|
|
|
|
|
|
class SubmissionState(Enum):
|
|
OPEN = "open"
|
|
PAUSED = "paused"
|
|
CLOSED = "closed"
|
|
# a feed that does not report it must not be made to guess
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
class ScopeState(Enum):
|
|
IN_SCOPE = "in_scope"
|
|
OUT_OF_SCOPE = "out_of_scope"
|
|
|
|
|
|
class BountyEvent(Enum):
|
|
PROGRAM_ADDED = "program_added"
|
|
PROGRAM_WENT_PUBLIC = "program_went_public"
|
|
SUBMISSIONS_OPENED = "submissions_opened"
|
|
SUBMISSIONS_CLOSED = "submissions_closed"
|
|
BOUNTIES_STARTED = "bounties_started"
|
|
SCOPE_ADDED = "scope_added"
|
|
SCOPE_REMOVED = "scope_removed"
|
|
WENT_OUT_OF_SCOPE = "went_out_of_scope"
|
|
CAME_INTO_SCOPE = "came_into_scope"
|
|
|
|
|
|
class AssetGroup(Enum):
|
|
NETWORK = "network"
|
|
MOBILE = "mobile"
|
|
CODE = "code"
|
|
OTHER = "other"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlatformSpec:
|
|
key: str
|
|
label: str
|
|
url: str
|
|
supports_private: bool
|
|
note: str
|
|
tag: str
|
|
tag_color: str
|
|
source: str
|
|
|
|
|
|
PLATFORMS: tuple[PlatformSpec, ...] = (
|
|
PlatformSpec(
|
|
key=BountyPlatform.HACKERONE.value,
|
|
label="HackerOne",
|
|
url="https://hackerone.com",
|
|
supports_private=True,
|
|
note="Public and private programs your API token can see",
|
|
tag="hackerone",
|
|
tag_color="#0EA5E9",
|
|
source=ProgramSource.API.value,
|
|
),
|
|
PlatformSpec(
|
|
key=BountyPlatform.BUGCROWD.value,
|
|
label="Bugcrowd",
|
|
url="https://bugcrowd.com",
|
|
supports_private=False,
|
|
note="Public engagements from the Bounty Targets feed",
|
|
tag="bugcrowd",
|
|
tag_color="#F97316",
|
|
source=ProgramSource.FEED.value,
|
|
),
|
|
PlatformSpec(
|
|
key=BountyPlatform.INTIGRITI.value,
|
|
label="Intigriti",
|
|
url="https://app.intigriti.com",
|
|
supports_private=False,
|
|
note="Public programs from the Bounty Targets feed",
|
|
tag="intigriti",
|
|
tag_color="#8B5CF6",
|
|
source=ProgramSource.FEED.value,
|
|
),
|
|
PlatformSpec(
|
|
key=BountyPlatform.YESWEHACK.value,
|
|
label="YesWeHack",
|
|
url="https://yeswehack.com",
|
|
supports_private=False,
|
|
note="Public programs from the Bounty Targets feed",
|
|
tag="yeswehack",
|
|
tag_color="#10B981",
|
|
source=ProgramSource.FEED.value,
|
|
),
|
|
)
|
|
|
|
SOURCE_LABELS: dict[str, str] = {
|
|
ProgramSource.API.value: "Platform API",
|
|
ProgramSource.FEED.value: "Bounty Targets feed",
|
|
}
|
|
|
|
PLATFORMS_BY_KEY: dict[str, PlatformSpec] = {p.key: p for p in PLATFORMS}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssetTypeSpec:
|
|
key: str
|
|
label: str
|
|
group: AssetGroup
|
|
target_type: TargetType | None
|
|
icon: str
|
|
note: str = ""
|
|
|
|
@property
|
|
def targetable(self) -> bool:
|
|
return self.target_type is not None
|
|
|
|
|
|
# every asset type a program can declare; target_type is None for what no scan can reach
|
|
ASSET_TYPES: tuple[AssetTypeSpec, ...] = (
|
|
AssetTypeSpec("DOMAIN", "Domain", AssetGroup.NETWORK, TargetType.DOMAIN, "globe"),
|
|
AssetTypeSpec(
|
|
"WILDCARD",
|
|
"Wildcard",
|
|
AssetGroup.NETWORK,
|
|
TargetType.DOMAIN,
|
|
"asterisk",
|
|
"Added as the apex domain",
|
|
),
|
|
AssetTypeSpec("URL", "URL", AssetGroup.NETWORK, TargetType.URL, "link"),
|
|
AssetTypeSpec(
|
|
"IP_ADDRESS", "IP address", AssetGroup.NETWORK, TargetType.IP, "server"
|
|
),
|
|
AssetTypeSpec(
|
|
"CIDR", "CIDR range", AssetGroup.NETWORK, TargetType.IP_RANGE, "network"
|
|
),
|
|
AssetTypeSpec(
|
|
"OTHER",
|
|
"Other",
|
|
AssetGroup.OTHER,
|
|
None,
|
|
"shapes",
|
|
"Free text; ASNs and hostnames are detected and can be added",
|
|
),
|
|
AssetTypeSpec(
|
|
"APPLE_STORE_APP_ID", "iOS App Store", AssetGroup.MOBILE, None, "smartphone"
|
|
),
|
|
AssetTypeSpec("TESTFLIGHT", "TestFlight", AssetGroup.MOBILE, None, "smartphone"),
|
|
AssetTypeSpec("OTHER_IPA", "iOS .ipa", AssetGroup.MOBILE, None, "smartphone"),
|
|
AssetTypeSpec(
|
|
"GOOGLE_PLAY_APP_ID", "Google Play", AssetGroup.MOBILE, None, "smartphone"
|
|
),
|
|
AssetTypeSpec("OTHER_APK", "Android .apk", AssetGroup.MOBILE, None, "smartphone"),
|
|
AssetTypeSpec(
|
|
"WINDOWS_APP_STORE_APP_ID",
|
|
"Microsoft Store",
|
|
AssetGroup.MOBILE,
|
|
None,
|
|
"app-window",
|
|
),
|
|
AssetTypeSpec("SOURCE_CODE", "Source code", AssetGroup.CODE, None, "file-code"),
|
|
AssetTypeSpec(
|
|
"DOWNLOADABLE_EXECUTABLES", "Executable", AssetGroup.CODE, None, "binary"
|
|
),
|
|
AssetTypeSpec("AI_MODEL", "AI model", AssetGroup.OTHER, None, "brain"),
|
|
AssetTypeSpec("HARDWARE", "Hardware / IoT", AssetGroup.OTHER, None, "cpu"),
|
|
AssetTypeSpec(
|
|
"SMART_CONTRACT", "Smart contract", AssetGroup.OTHER, None, "file-signature"
|
|
),
|
|
)
|
|
|
|
ASSET_TYPES_BY_KEY: dict[str, AssetTypeSpec] = {a.key: a for a in ASSET_TYPES}
|
|
|
|
UNKNOWN_ASSET_TYPE = AssetTypeSpec(
|
|
"UNKNOWN", "Unrecognised", AssetGroup.OTHER, None, "circle-help"
|
|
)
|
|
|
|
# asset types worth running through validate_target; OTHER carries ASNs and hostnames
|
|
IMPORTABLE_TYPES: frozenset[str] = frozenset(
|
|
{a.key for a in ASSET_TYPES if a.targetable} | {"OTHER"}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EventSpec:
|
|
kind: str
|
|
label: str
|
|
description: str
|
|
icon: str
|
|
tone: str
|
|
# a change you can act on now ranks above one that is merely news
|
|
actionable: bool
|
|
|
|
|
|
EVENTS: tuple[EventSpec, ...] = (
|
|
EventSpec(
|
|
BountyEvent.PROGRAM_ADDED.value,
|
|
"New program",
|
|
"A program appeared in your library",
|
|
"sparkles",
|
|
"info",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.SCOPE_ADDED.value,
|
|
"Scope added",
|
|
"The program put a new asset in scope",
|
|
"plus",
|
|
"info",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.CAME_INTO_SCOPE.value,
|
|
"Now in scope",
|
|
"An asset the program excluded is now testable",
|
|
"circle-check",
|
|
"info",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.WENT_OUT_OF_SCOPE.value,
|
|
"Now out of scope",
|
|
"Stop testing this asset",
|
|
"octagon-alert",
|
|
"warning",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.SCOPE_REMOVED.value,
|
|
"Scope removed",
|
|
"The program no longer lists this asset",
|
|
"minus",
|
|
"warning",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.SUBMISSIONS_OPENED.value,
|
|
"Accepting reports",
|
|
"The program reopened for submissions",
|
|
"door-open",
|
|
"info",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.BOUNTIES_STARTED.value,
|
|
"Now pays bounties",
|
|
"The program moved from VDP to paying",
|
|
"banknote",
|
|
"info",
|
|
actionable=True,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.PROGRAM_WENT_PUBLIC.value,
|
|
"Went public",
|
|
"A private program opened to everyone",
|
|
"globe",
|
|
"muted",
|
|
actionable=False,
|
|
),
|
|
EventSpec(
|
|
BountyEvent.SUBMISSIONS_CLOSED.value,
|
|
"Stopped accepting",
|
|
"The program paused or closed submissions",
|
|
"door-closed",
|
|
"muted",
|
|
actionable=False,
|
|
),
|
|
)
|
|
|
|
EVENTS_BY_KIND: dict[str, EventSpec] = {e.kind: e for e in EVENTS}
|
|
|
|
# the changes worth waking someone for; the rest live in the feed
|
|
ALERT_EVENTS: frozenset[str] = frozenset(
|
|
{
|
|
BountyEvent.PROGRAM_ADDED.value,
|
|
BountyEvent.SCOPE_ADDED.value,
|
|
BountyEvent.CAME_INTO_SCOPE.value,
|
|
BountyEvent.WENT_OUT_OF_SCOPE.value,
|
|
BountyEvent.SUBMISSIONS_OPENED.value,
|
|
BountyEvent.BOUNTIES_STARTED.value,
|
|
}
|
|
)
|
|
|
|
|
|
# the kinds that may raise an alert; the rest only ever appear in the feed
|
|
NOTIFIABLE_EVENTS: tuple[str, ...] = (
|
|
BountyEvent.PROGRAM_ADDED.value,
|
|
BountyEvent.SCOPE_ADDED.value,
|
|
BountyEvent.CAME_INTO_SCOPE.value,
|
|
BountyEvent.WENT_OUT_OF_SCOPE.value,
|
|
BountyEvent.SUBMISSIONS_OPENED.value,
|
|
BountyEvent.BOUNTIES_STARTED.value,
|
|
)
|
|
|
|
|
|
class SyncInterval(Enum):
|
|
OFF = "off"
|
|
SIX_HOURS = "six_hours"
|
|
DAILY = "daily"
|
|
WEEKLY = "weekly"
|
|
|
|
|
|
SYNC_INTERVAL_HOURS: dict[str, int] = {
|
|
SyncInterval.SIX_HOURS.value: 6,
|
|
SyncInterval.DAILY.value: 24,
|
|
SyncInterval.WEEKLY.value: 24 * 7,
|
|
}
|
|
|
|
DEFAULT_FEED_INTERVAL = SyncInterval.SIX_HOURS.value
|
|
|
|
DEFAULT_SYNC_INTERVAL = SyncInterval.DAILY.value
|
|
DEFAULT_NOTIFY = True
|
|
DEFAULT_NOTIFY_EVENTS: tuple[str, ...] = NOTIFIABLE_EVENTS
|
|
|
|
|
|
def notify_events(settings: dict | None) -> set[str]:
|
|
"""Which change kinds may alert, falling back to every notifiable kind."""
|
|
stored = (settings or {}).get("notify_events")
|
|
if not isinstance(stored, list):
|
|
return set(DEFAULT_NOTIFY_EVENTS)
|
|
return {k for k in stored if k in NOTIFIABLE_EVENTS}
|
|
|
|
|
|
def notify_enabled(settings: dict | None) -> bool:
|
|
value = (settings or {}).get("notify")
|
|
return DEFAULT_NOTIFY if value is None else bool(value)
|
|
|
|
|
|
MAX_TAGS_PER_IMPORT = 10
|
|
# bounty_events.detail is varchar(500); an instruction is capped far higher
|
|
MAX_EVENT_DETAIL = 500
|
|
# bounty_scopes.target_value and targets.target_value are both varchar(500)
|
|
MAX_TARGET_VALUE = 500
|
|
|
|
MAX_SEVERITIES: tuple[str, ...] = ("critical", "high", "medium", "low", "none")
|
|
|
|
_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*://", re.I)
|
|
_HOSTNAME_TYPES = frozenset({TargetType.DOMAIN, TargetType.URL})
|
|
_IPV6_HOST = re.compile(r"^\[([^\]]+)\]")
|
|
|
|
|
|
def event_spec(kind: str) -> EventSpec:
|
|
return EVENTS_BY_KIND.get(
|
|
kind, EventSpec(kind, kind, "", "circle-help", "muted", actionable=False)
|
|
)
|
|
|
|
|
|
def asset_type_spec(key: str | None) -> AssetTypeSpec:
|
|
return ASSET_TYPES_BY_KEY.get((key or "").upper(), UNKNOWN_ASSET_TYPE)
|
|
|
|
|
|
def scope_state(eligible_for_submission: bool | None) -> ScopeState:
|
|
return ScopeState.IN_SCOPE if eligible_for_submission else ScopeState.OUT_OF_SCOPE
|
|
|
|
|
|
# measured 2026-09-07: the hacker API returns public_mode and soft_launched;
|
|
# soft_launched is an invite-only program, so anything but public_mode is private
|
|
PUBLIC_STATE = "public_mode"
|
|
|
|
RAW_STATE_LABELS: dict[str, str] = {
|
|
"public_mode": "Public",
|
|
"soft_launched": "Soft launched",
|
|
"private_mode": "Private",
|
|
}
|
|
|
|
|
|
def program_state(raw: str | None) -> ProgramState:
|
|
return ProgramState.PUBLIC if raw == PUBLIC_STATE else ProgramState.PRIVATE
|
|
|
|
|
|
def raw_state_label(raw: str | None) -> str:
|
|
value = (raw or "").strip()
|
|
return RAW_STATE_LABELS.get(
|
|
value, value.replace("_", " ").capitalize() or "Unknown"
|
|
)
|
|
|
|
|
|
def submission_state(raw: str | None) -> SubmissionState:
|
|
value = (raw or "").lower()
|
|
if value == "open":
|
|
return SubmissionState.OPEN
|
|
if value == "paused":
|
|
return SubmissionState.PAUSED
|
|
return SubmissionState.CLOSED
|
|
|
|
|
|
def normalize_identifier(asset_type: str | None, identifier: str) -> str | None:
|
|
"""The scannable value behind a scope entry, or None when there is not one."""
|
|
# Postgres refuses NUL and a scope value is stored, so scrub at the source
|
|
value = strip_control(identifier or "").strip()
|
|
if not value:
|
|
return None
|
|
spec = asset_type_spec(asset_type)
|
|
if spec.key not in IMPORTABLE_TYPES:
|
|
return None
|
|
|
|
# a scope list writes https://*.example.com for a wildcard
|
|
stripped = _SCHEME.sub("", value).strip()
|
|
host = stripped.split("/")[0]
|
|
if host.startswith("*."):
|
|
# platforms file wildcards under whatever type they like, so the value
|
|
# decides this, not the declared type
|
|
value = host
|
|
elif spec.key == "URL":
|
|
pass
|
|
elif spec.key == "CIDR":
|
|
# a CIDR's slash is its prefix length, never a path
|
|
value = stripped
|
|
else:
|
|
value = host
|
|
value = normalize_target_value(value)
|
|
# a residual wildcard (*.example.*, *-faq.example.com) names no single asset
|
|
if not value or "*" in value or len(value) > MAX_TARGET_VALUE:
|
|
return None
|
|
return value
|
|
|
|
|
|
def _public_host(value: str) -> bool:
|
|
"""A program's scope is internet-facing, so a bundle id is not a hostname.
|
|
|
|
validators accepts com.etoro.wallet and io.flutter.plugins as domains until
|
|
consider_tld is on. It is deliberately not on in validate_target, because a
|
|
hand-added internal target like traefik.default is legitimate there.
|
|
"""
|
|
authority = _SCHEME.sub("", value).split("/")[0].split("?")[0]
|
|
bracketed = _IPV6_HOST.match(authority)
|
|
host = bracketed.group(1) if bracketed else authority.rsplit(":", 1)[0]
|
|
with contextlib.suppress(ValueError):
|
|
ipaddress.ip_address(host)
|
|
return True
|
|
return bool(validators.domain(host, consider_tld=True))
|
|
|
|
|
|
def _canonical(value: str, target_type: TargetType) -> str:
|
|
"""The casing reNgine already stores for this target type."""
|
|
if target_type is TargetType.ASN:
|
|
return value.upper()
|
|
if target_type is TargetType.URL:
|
|
return value
|
|
return value.lower()
|
|
|
|
|
|
def target_for_scope(
|
|
asset_type: str | None, identifier: str
|
|
) -> tuple[str, TargetType] | None:
|
|
"""Normalize a scope entry, then let validate_target decide whether it is a target."""
|
|
value = normalize_identifier(asset_type, identifier)
|
|
if not value:
|
|
return None
|
|
target_type = validate_target(value)
|
|
if not target_type:
|
|
return None
|
|
if target_type in _HOSTNAME_TYPES and not _public_host(value):
|
|
return None
|
|
return _canonical(value, target_type), target_type
|