"""Value enumerations and the three state machines of the bounty domain. Kept free of imports from the rest of the domain so migrations, tests and the MCP surface can read the vocabulary without pulling in ORM models. """ from __future__ import annotations from typing import Any # ════════════════════════════════════════════════════════════════════ # Triage axis — "what did we decide about this report" # ════════════════════════════════════════════════════════════════════ STATUS_NEW = "new" """录入完成但还没派发给业务确认。""" STATUS_AWAITING_OWNER = "awaiting_owner" """已指派业务负责人,等他确认是否为真问题。""" STATUS_IN_DISCUSSION = "in_discussion" """业务负责人已回应(或正在沟通中),等安全侧定级/定论。""" STATUS_CONFIRMED = "confirmed" """安全侧确认为有效漏洞。级别已定,奖金轴与修复轴从这里开始有意义。""" STATUS_FALSE_POSITIVE = "false_positive" """确认误报。""" STATUS_OUT_OF_SCOPE = "out_of_scope" """不在收录范围(TLS 配置、SPF、社工、物理攻击等)。""" STATUS_DUPLICATE = "duplicate" """重复提交,或我们在收到之前已在处理(不发奖)。""" STATUS_CLOSED = "closed" """流程结束归档。""" STATUSES: tuple[str, ...] = ( STATUS_NEW, STATUS_AWAITING_OWNER, STATUS_IN_DISCUSSION, STATUS_CONFIRMED, STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE, STATUS_CLOSED, ) #: Statuses that represent a finished triage decision. A report in one of #: these is only reachable again through an explicit reopen. TERMINAL_STATUSES: frozenset[str] = frozenset({ STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE, STATUS_CLOSED, }) #: The one status where a bounty may be owed and a fix is expected. VALID_STATUSES: frozenset[str] = frozenset({STATUS_CONFIRMED}) #: Statuses that count as "still on someone's desk" for the open worklist. OPEN_STATUSES: frozenset[str] = frozenset({ STATUS_NEW, STATUS_AWAITING_OWNER, STATUS_IN_DISCUSSION, STATUS_CONFIRMED, }) #: Allowed triage transitions. The security operator drives all of them; #: the business owner's response does NOT set a triage status directly #: (it records ``owner_decision`` and moves the report to #: ``in_discussion`` — the final call is always the security team's). STATUS_TRANSITIONS: dict[str, frozenset[str]] = { STATUS_NEW: frozenset({ STATUS_AWAITING_OWNER, STATUS_IN_DISCUSSION, STATUS_CONFIRMED, STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE, }), STATUS_AWAITING_OWNER: frozenset({ STATUS_IN_DISCUSSION, STATUS_CONFIRMED, STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE, }), STATUS_IN_DISCUSSION: frozenset({ STATUS_AWAITING_OWNER, STATUS_CONFIRMED, STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE, }), STATUS_CONFIRMED: frozenset({ STATUS_IN_DISCUSSION, STATUS_FALSE_POSITIVE, STATUS_DUPLICATE, STATUS_CLOSED, }), STATUS_FALSE_POSITIVE: frozenset({STATUS_IN_DISCUSSION, STATUS_CLOSED}), STATUS_OUT_OF_SCOPE: frozenset({STATUS_IN_DISCUSSION, STATUS_CLOSED}), STATUS_DUPLICATE: frozenset({STATUS_IN_DISCUSSION, STATUS_CLOSED}), # Reopen lands back in discussion; closing again goes through the # normal path so the axes get re-evaluated. STATUS_CLOSED: frozenset({STATUS_IN_DISCUSSION}), } # ── Owner decision (recorded on the report, not a triage status) ── OWNER_DECISION_VALID = "valid" OWNER_DECISION_FALSE_POSITIVE = "false_positive" OWNER_DECISION_NEED_INFO = "need_info" OWNER_DECISIONS: tuple[str, ...] = ( OWNER_DECISION_VALID, OWNER_DECISION_FALSE_POSITIVE, OWNER_DECISION_NEED_INFO, ) # ════════════════════════════════════════════════════════════════════ # Remediation axis — "is the hole actually plugged" # ════════════════════════════════════════════════════════════════════ REMEDIATION_NOT_APPLICABLE = "not_applicable" REMEDIATION_PENDING = "pending" REMEDIATION_IN_PROGRESS = "in_progress" REMEDIATION_FIXED = "fixed" REMEDIATION_WONT_FIX = "wont_fix" REMEDIATION_RISK_ACCEPTED = "risk_accepted" REMEDIATION_STATUSES: tuple[str, ...] = ( REMEDIATION_NOT_APPLICABLE, REMEDIATION_PENDING, REMEDIATION_IN_PROGRESS, REMEDIATION_FIXED, REMEDIATION_WONT_FIX, REMEDIATION_RISK_ACCEPTED, ) #: Remediation states that stop the SLA clock. REMEDIATION_SETTLED: frozenset[str] = frozenset({ REMEDIATION_NOT_APPLICABLE, REMEDIATION_FIXED, REMEDIATION_WONT_FIX, REMEDIATION_RISK_ACCEPTED, }) #: What the business owner is allowed to set from the confirmation page. #: ``wont_fix`` / ``risk_accepted`` are risk decisions that need the #: security team, so they are deliberately absent. OWNER_SETTABLE_REMEDIATION: frozenset[str] = frozenset({ REMEDIATION_PENDING, REMEDIATION_IN_PROGRESS, REMEDIATION_FIXED, }) # ════════════════════════════════════════════════════════════════════ # Money axis — "did the researcher get paid" # ════════════════════════════════════════════════════════════════════ PAYMENT_NOT_APPLICABLE = "not_applicable" """误报 / 超范围 / 重复 / informational —— 本来就不涉及付款。""" PAYMENT_PENDING = "pending" """已确认有效且有金额,尚未向财务发起付款(等收款信息 / 发票)。""" PAYMENT_REQUESTED = "requested" """付款中 —— 已交给财务。""" PAYMENT_PAID = "paid" """已付款。""" PAYMENT_WAIVED = "waived" """确认有效但不发奖(白帽子放弃、政策例外),金额记 0。""" PAYMENT_STATUSES: tuple[str, ...] = ( PAYMENT_NOT_APPLICABLE, PAYMENT_PENDING, PAYMENT_REQUESTED, PAYMENT_PAID, PAYMENT_WAIVED, ) #: Money that has actually left the company — the numerator of "spent". PAYMENT_SETTLED_SPENT: frozenset[str] = frozenset({PAYMENT_PAID}) #: Money committed but not yet out the door — the "in flight" bucket that #: must be reserved against the budget, otherwise a burn chart reads #: healthy right up to the moment finance pays six pending rewards. PAYMENT_COMMITTED: frozenset[str] = frozenset({ PAYMENT_PENDING, PAYMENT_REQUESTED, }) PAYMENT_TRANSITIONS: dict[str, frozenset[str]] = { PAYMENT_NOT_APPLICABLE: frozenset({PAYMENT_PENDING, PAYMENT_WAIVED}), PAYMENT_PENDING: frozenset({ PAYMENT_REQUESTED, PAYMENT_PAID, PAYMENT_WAIVED, PAYMENT_NOT_APPLICABLE, }), PAYMENT_REQUESTED: frozenset({ PAYMENT_PAID, PAYMENT_PENDING, PAYMENT_WAIVED, }), # Paid is terminal on purpose: un-paying is a finance correction, not # a Helix state flip. Reverting requires an explicit re-open of the # report, which resets the axis through ``pending``. PAYMENT_PAID: frozenset({PAYMENT_PENDING}), PAYMENT_WAIVED: frozenset({PAYMENT_PENDING, PAYMENT_NOT_APPLICABLE}), } # ════════════════════════════════════════════════════════════════════ # Report event log # ════════════════════════════════════════════════════════════════════ EVENT_CREATED = "created" EVENT_UPDATED = "updated" EVENT_AI_PARSED = "ai_parsed" EVENT_ASSIGNED = "assigned" EVENT_UNASSIGNED = "unassigned" EVENT_NUDGED = "nudged" EVENT_OWNER_RESPONDED = "owner_responded" EVENT_STATUS_CHANGED = "status_changed" EVENT_SEVERITY_SET = "severity_set" EVENT_REMEDIATION_CHANGED = "remediation_changed" EVENT_REWARD_SET = "reward_set" EVENT_PAYMENT_CHANGED = "payment_changed" EVENT_COMMENT = "comment" EVENT_REPLY_LOGGED = "reply_logged" EVENT_INBOUND_LOGGED = "inbound_logged" EVENT_REOPENED = "reopened" EVENT_CLOSED = "closed" EVENT_TYPES: tuple[str, ...] = ( EVENT_CREATED, EVENT_UPDATED, EVENT_AI_PARSED, EVENT_ASSIGNED, EVENT_UNASSIGNED, EVENT_NUDGED, EVENT_OWNER_RESPONDED, EVENT_STATUS_CHANGED, EVENT_SEVERITY_SET, EVENT_REMEDIATION_CHANGED, EVENT_REWARD_SET, EVENT_PAYMENT_CHANGED, EVENT_COMMENT, EVENT_REPLY_LOGGED, EVENT_INBOUND_LOGGED, EVENT_REOPENED, EVENT_CLOSED, ) VISIBILITY_INTERNAL = "internal" """安全团队可见。奖金、白帽子身份、付款记录一律 internal。""" VISIBILITY_SHARED = "shared" """被指派的业务负责人也可见 —— 技术沟通用。""" VISIBILITIES: tuple[str, ...] = (VISIBILITY_INTERNAL, VISIBILITY_SHARED) ACTOR_USER = "user" ACTOR_SYSTEM = "system" # ════════════════════════════════════════════════════════════════════ # Vulnerability classification # ════════════════════════════════════════════════════════════════════ #: Deliberately a short, operator-friendly list rather than full CWE — #: the value of this field is "group the report page and the attribution #: chart", not taxonomic completeness. ``other`` is always available and #: the free-text title carries the detail. VULN_TYPES: tuple[str, ...] = ( "xss", "sqli", "rce", "ssrf", "idor", "auth_bypass", "privilege_escalation", "info_disclosure", "secret_leak", "csrf", "open_redirect", "dos", "misconfiguration", "vulnerable_dependency", "supply_chain", "business_logic", "other", ) #: Intake channel. Only ``email`` exists today (a white-hat mails the #: security alias); the column exists so a future intake path — a public #: submission form, a platform like HackerOne — does not need a migration. SOURCE_EMAIL = "email" SOURCE_PLATFORM = "platform" SOURCE_INTERNAL = "internal" SOURCES: tuple[str, ...] = (SOURCE_EMAIL, SOURCE_PLATFORM, SOURCE_INTERNAL) # ════════════════════════════════════════════════════════════════════ # Email reply templates # ════════════════════════════════════════════════════════════════════ # Named REPLY_* rather than TEMPLATE_* on purpose: these are categories of # **outbound reply letter to a researcher**, not notification templates. # ``tests/test_notifications/test_template_locale_parity.py`` scans every # UPPER_SNAKE constant whose name contains TEMPLATE and asserts its value is # a registered notification template — a ``TEMPLATE_CATEGORY_ACK = "ack"`` # here reads to that guard as "the code sends a template called 'ack'". # The wire field stays ``template_categories`` (the console consumes it). REPLY_CATEGORY_ACK = "ack" REPLY_CATEGORY_OUT_OF_SCOPE = "out_of_scope" REPLY_CATEGORY_DUPLICATE = "duplicate" REPLY_CATEGORY_REWARD = "reward" REPLY_CATEGORY_OTHER = "other" REPLY_CATEGORIES: tuple[str, ...] = ( REPLY_CATEGORY_ACK, REPLY_CATEGORY_OUT_OF_SCOPE, REPLY_CATEGORY_DUPLICATE, REPLY_CATEGORY_REWARD, REPLY_CATEGORY_OTHER, ) # ════════════════════════════════════════════════════════════════════ # Platform settings keys # ════════════════════════════════════════════════════════════════════ SETTING_BUDGET = "bounty.budget" """Per-track fiscal-year budget:: {"tracks": {"product": {"default_usd": 5000, "by_fy": {"2027": 8000}}, "it": {"default_usd": 5000, "by_fy": {}}}, "currency": "USD"} ``by_fy`` overrides ``default_usd`` for one fiscal year; a missing year falls back to the default rather than to zero (a budget of 0 would make every reward look like an overrun the day the FY rolls over). The flat pre-2026-08-24 shape (``default_usd`` / ``by_fy`` at the top level) is still read as the ``product`` track so a half-migrated environment does not lose its configured ceiling. """ # ── Budget tracks ─────────────────────────────────────────────────── # # The programme is funded from two pots and they are approved by # different owners, so one combined number cannot answer "how much of MY # budget is left". Which track a report lands on is an explicit decision # on the report, defaulted to ``product`` (that is where the # overwhelming majority of findings are) and switchable at triage. BUDGET_TRACK_PRODUCT = "product" """产研 —— vulnerabilities in the products and their infrastructure.""" BUDGET_TRACK_IT = "it" """IT —— vulnerabilities in IT-owned internal systems and services.""" BUDGET_TRACKS: tuple[str, ...] = (BUDGET_TRACK_PRODUCT, BUDGET_TRACK_IT) DEFAULT_BUDGET_TRACK = BUDGET_TRACK_PRODUCT SETTING_SIGNATURE = "bounty.reply_signature" #: Who receives the desk's alerts (new researcher mail, chases, owner #: verdicts). Shape: ``{"person_ids": [...]}``. An EMPTY list means "use #: the role default" — every active holder of ``security_officer`` — so a #: fresh install notifies the desk without anyone configuring anything. SETTING_ALERT_RECIPIENTS = "bounty.alert_recipients" """``{"signer_name": "...", "signer_title": "...", "team": "...", "company": "...", "entity_block": "..."}`` — merged into every rendered reply so the operator does not retype their own signature block.""" DEFAULT_BUDGET_USD = 5000 DEFAULT_CURRENCY = "USD" #: Burn thresholds that colour the budget widget and drive the advisory #: banner. Never a hard block: refusing to record a real payout because a #: number in a config table is too small would put the ledger at odds #: with reality. BUDGET_WARN_RATIO = 0.8 BUDGET_OVER_RATIO = 1.0 # ── Bounds ────────────────────────────────────────────────────────── MAX_RAW_REPORT_CHARS = 200_000 """Pasted mail bodies are long (headers + PoC + base64 screenshots). Cap generously but do cap: this lands in a TEXT column and gets fed to the LLM.""" MAX_TITLE_CHARS = 500 MAX_COMMENT_CHARS = 20_000 MAX_REWARD_USD = 100_000 """Sanity ceiling on a single payout, well above any real tier. Guards a mistyped amount (a stray zero) from silently blowing the FY budget.""" # ── The one definition of "somebody graded this" ───────────────────── def graded_severity(report: Any) -> str | None: """The level a PERSON chose, or ``None`` when nobody has. Returns the value rather than a bool so callers that need the level get it already narrowed — the bool version left ``report.severity`` as ``str | None`` at the call site and mypy was right to complain. Since reports arrive at the lowest enabled tier (2026-08-26), the test ``severity is not None`` no longer means "somebody graded it": every report satisfies that from birth. Two kinds of caller depend on the difference and both live outside this module, which is why the rule lives here rather than being spelled out again in each of them: * **Decisions.** Confirming a finding as valid, attaching money to it, and choosing which reply letter to offer. Offering a critical finding the $0 informational letter cannot be undone once sent. * **Display outside the security desk.** For an owner or a lead the tier is a fact about how bad this is, not a workflow placeholder. Rendering the bottom of the ladder onto an ungraded finding states a fact that is false — in production 「告知」 sat on 「Unauthenticated TiCard admin API leaks 7,735 attendees' full PII」, in front of the person being asked to fix it. Everything else — routing, replying, tracking, SLA — proceeds on the default, which is the entire point of having a default. Duck-typed on purpose: the report row, the coordinator's copy, and any future projection all answer these two attributes, and requiring the ORM class would put a domain import in the cross-domain seam. """ if report.severity is None or report.severity_is_default: return None return str(report.severity) # ════════════════════════════════════════════════════════════════════ # The lifecycle — one line, derived, the only thing a human is shown # ════════════════════════════════════════════════════════════════════ # # The domain stores three axes (triage / remediation / money) plus an # ``owner_decision``. That is four things to read before you know where a # report stands, and the console made a person choose on three of them: # 「现在有处置。修复 指派多个字段 这个是不是太复杂了 … 这个应该不是这么多 # 状态 这么复杂的吧」. # # Measured against the real ledger, the sprawl is not real. 149 reports # occupy **14** distinct combinations, and ``owner_decision`` is NULL on # every single row — a field with 26 references in the backend that # nobody has ever written. The apparent complexity is the schema's, not # the work's. # # So the axes stay where they earn their keep and stop being the # interface. Money remains separate because it genuinely is a different # question, and it appears only once there is an amount. # # Derived, not stored, deliberately. A stored copy of state that is # computable from its inputs is a second source of truth, and the window # where both exist is exactly when they disagree. There is one function, # and everything — serializer, cross-domain seam, matrix, MCP, console — # reads it. STAGE_TRIAGE = "triage" """刚到,等安全台看第一眼。""" STAGE_OWNER_CHECK = "owner_check" """已(自动)指派,等业务负责人确认是不是真问题。""" STAGE_DISPUTED = "disputed" """负责人有异议,退回安全台看他的理由。""" STAGE_FIXING = "fixing" """认可了,正在修。""" STAGE_FIXED = "fixed" """洞堵上了。""" STAGE_ACCEPTED_RISK = "accepted_risk" """安全台决定不修/接受风险 —— 这是决定,不是拖延。""" STAGE_REJECTED = "rejected" """不予受理:误报 / 超范围 / 重复。""" STAGE_CLOSED = "closed" """归档。""" #: Display order. Left-to-right is the order work actually moves in, so a #: queue rendered in this order reads as a pipeline rather than a legend. LIFECYCLE_STAGES: tuple[str, ...] = ( STAGE_TRIAGE, STAGE_OWNER_CHECK, STAGE_DISPUTED, STAGE_FIXING, STAGE_FIXED, STAGE_ACCEPTED_RISK, STAGE_REJECTED, STAGE_CLOSED, ) #: Stages where somebody still owes something. Everything else is settled. LIFECYCLE_OPEN: frozenset[str] = frozenset({ STAGE_TRIAGE, STAGE_OWNER_CHECK, STAGE_DISPUTED, STAGE_FIXING, }) def lifecycle_stage(report: Any) -> str: """Where this report stands, as one value. An ORDERED predicate list, and the order is the specification — the first match wins, so the rules read as "the most settled thing that is true about this report". Two orderings were forced by real rows: * **Rejection and closure outrank everything.** A closed report that also happens to carry ``remediation = fixed`` is closed; reporting it as 「已修复」 would put an archived row back in the working set. * **An in-progress fix outranks the triage column.** Production holds a row with ``status = in_discussion`` AND ``remediation = in_progress``: the axes disagree, because somebody started fixing before the desk formally confirmed. Reading the triage column first would show 「有异议」 over an active fix, which is the opposite of what is happening. Duck-typed for the same reason as ``graded_severity``: the ORM row, the coordinator's projection and any future read model all answer these attributes, and importing the model here would put a domain import in the cross-domain seam. """ status = report.status remediation = getattr(report, "remediation_status", None) if status in (STATUS_FALSE_POSITIVE, STATUS_OUT_OF_SCOPE, STATUS_DUPLICATE): return STAGE_REJECTED if status == STATUS_CLOSED: return STAGE_CLOSED if remediation in (REMEDIATION_WONT_FIX, REMEDIATION_RISK_ACCEPTED): return STAGE_ACCEPTED_RISK if remediation == REMEDIATION_FIXED: return STAGE_FIXED if remediation == REMEDIATION_IN_PROGRESS or status == STATUS_CONFIRMED: return STAGE_FIXING if status == STATUS_IN_DISCUSSION: return STAGE_DISPUTED if status == STATUS_AWAITING_OWNER: return STAGE_OWNER_CHECK return STAGE_TRIAGE