Mode A · disclosure-pinned delivery · full technical detail

How Fulcrum systematically delivers to PSIRT, HackerOne, Bugcrowd, and CERT/CC

A validated finding from stages 1–6 of the Fulcrum research pipeline exits through exactly one of four coordinated-disclosure terminals. This page is the implementation reference for that exit path: the per-terminal adapters, the routing logic that picks one, the pre-submission machinery (CVE pre-allocation, CVSS, CWE), the lifecycle state machine, the SLA timers, the audit-log shape, the schema-enforced invariants, and the hardening tests that catch any drift.

Status Proposed design, not operational. Adapter contracts and the disclosure-routing rulebook are scaffolded in source; the integrated delivery pipeline is not yet runnable end-to-end. The schemas and control-flow on this page are what the build targets, and what the hardening tests at test/v1.7-disclosure-delivery.test.ts will pin once the implementation lands.

The four terminals

Each terminal is a distinct submission protocol with its own authentication, payload shape, and acknowledgment model. Fulcrum implements one DisclosureAdapter per terminal behind a shared interface, so the rest of the pipeline does not need to know which channel it is talking to.

Adapter A · default for single-vendor bugs

Vendor PSIRT

Transport
PGP-encrypted email to the vendor’s published PSIRT address; vendor portal submission where one exists (Cisco PSIRT, MSRC, Apple Product Security, Oracle, SAP, Siemens ProductCERT).
Auth
PGP key fingerprint pinned in src/disclosure/keyring/<vendor>.asc; portal submissions via vendor-issued credential, never embedded in source.
Payload
RFC-5322 message with application/pgp-encrypted body; structured advisory (title, CVE-ID-request, affected versions, CVSS 3.1+4.0 vectors, CWE, technical description, minimal PoC, suggested fix).
Ack model
Free-text reply; case ID parsed from the vendor’s acknowledgment subject line via per-vendor regex in src/disclosure/adapters/psirt/<vendor>.parser.ts.

Adapter B · vendor on HackerOne

HackerOne

Transport
REST API at api.hackerone.com/v1; POST /hackers/reports from a researcher-account API token.
Auth
HTTP Basic with token from $H1_API_USERNAME / $H1_API_TOKEN in the environment; tokens never written to git or logs.
Payload
JSON-API document with team_handle, title, vulnerability_information, severity_rating (CVSS 3.1), weakness_id (HackerOne’s CWE table), structured attachments for PoC artifacts.
Ack model
Synchronous — response body returns a report_id; subsequent state polled via GET /reports/{id}.

Adapter C · vendor on Bugcrowd

Bugcrowd

Transport
REST API at api.bugcrowd.com; researcher-side submissions endpoint, or program-defined tracker integration where Bugcrowd is the broker for a vendor’s self-hosted intake.
Auth
Token from $BUGCROWD_API_TOKEN; per-program target identifier resolved from the program’s policy document at submission time.
Payload
JSON document with VRT taxonomy node, severity (P1–P5 mapped from CVSS), affected target, reproduction steps, attachments.
Ack model
Synchronous — response returns a submission UUID; state polled via the submission resource.

Adapter D · multi-party / no-PSIRT / protocol-class bugs

CERT/CC (VINCE)

Transport
VINCE (Vulnerability Information and Coordination Environment) at kb.cert.org/vince/: web case-creation, plus PGP-signed email to the CERT/CC report alias as fallback.
Auth
VINCE account credential; case-room invites issued by CERT/CC for the operator and the named coordinating vendors.
Payload
Structured case (technical description, affected products list, suggested disclosure date, vendor contacts, CVSS); CERT/CC assigns the VU# identifier.
Ack model
Asynchronous — VU# arrives in the case-room and by email; downstream state tracked through VINCE.

A fifth terminal, public-90day, exists in the schema as a fallback when none of the above acknowledges within the SLA. It is not a separate channel — it is the rulebook’s name for "publish under Project Zero’s 90-day policy after documented good-faith contact." See the SLA table below.

Terminal-selection logic

When a finding completes stage 6 (vendor-confirmable PoC), the orchestrator picks exactly one terminal. The selection is deterministic given the finding metadata and the program directory at src/disclosure/programs/ — not an LLM judgement call.

routing src/disclosure/route.ts · pick_terminal()
function pick_terminal(finding):
    vendors = resolve_affected_vendors(finding.target)

    # Multi-vendor / protocol-class bugs route to CERT/CC for coordination.
    if len(vendors) > 1 or finding.target.kind == "protocol":
        return DisclosureTerminal.cert_cc

    vendor = vendors[0]
    program = load_program(vendor)        # programs/<vendor>.json

    # Vendor-declared preference wins (some PSIRTs require PSIRT-direct).
    if program.preferred_channel == "psirt":
        return DisclosureTerminal.psirt

    # Bounty-program presence:
    if program.hackerone_handle:
        return DisclosureTerminal.hackerone
    if program.bugcrowd_handle:
        return DisclosureTerminal.bugcrowd

    # Mature PSIRT (Cisco, Microsoft, Apple, Oracle, SAP, Siemens, ...):
    if program.psirt_pgp_fingerprint:
        return DisclosureTerminal.psirt

    # No vendor channel at all → CERT/CC as the coordinator of last resort.
    return DisclosureTerminal.cert_cc

The public-90day terminal is not selectable here. It is reached only by SLA-expiry escalation from one of the four primaries (see Lifecycle state machine below), never by direct routing.

The Finding schema delta — adding bugcrowd

The Fulcrum page documents the existing Finding schema with disclosure_terminal enum [psirt, hackerone, cert-cc, public-90day]. This page adds bugcrowd as a fifth value. The schema diff lands as part of the disclosure-delivery implementation:

schema diff src/contracts/finding.schema.json
"disclosure_terminal": {
   "enum": ["psirt", "hackerone",
+            "bugcrowd",
             "cert-cc", "public-90day"]
}

Adding a sixth terminal is a coordinated change — new enum value + new DisclosureAdapter implementation + new entry in the program-directory schema + new hardening tests asserting all four invariants below hold for the new adapter. The structural property that "exit only through one of the named terminals" stays intact regardless of how many terminals exist.

The shared DisclosureAdapter interface

Every adapter implements the same four operations. The orchestrator’s lifecycle code calls only this interface; per-terminal logic lives entirely inside each adapter.

interface src/disclosure/adapter.ts
interface DisclosureAdapter {
  // 1. Render a Finding into the terminal-specific payload.
  render(finding: Finding, context: ProgramContext): Submission;

  // 2. Transmit. Idempotent on (finding_id, terminal) — re-runs return the prior receipt.
  submit(submission: Submission): Promise<SubmissionReceipt>;

  // 3. Poll downstream state. Maps terminal-native state to LifecycleState.
  poll(receipt: SubmissionReceipt): Promise<LifecycleState>;

  // 4. Publish the coordinated advisory once the vendor confirms a fix
  //    OR the 90-day SLA expires (terminal == psirt only — others publish themselves).
  publish(receipt: SubmissionReceipt, advisory: PublicAdvisory): Promise<PublishReceipt>;
}

Per-adapter implementations

Adapter A · Vendor PSIRT (PGP email + portal)

The PSIRT adapter is the most heterogeneous: each major vendor has its own intake convention. Fulcrum holds a per-vendor descriptor at src/disclosure/programs/<vendor>.json capturing the address, fingerprint, parser regex for acknowledgments, advisory-format quirks, and any preferred-channel override.

descriptor src/disclosure/programs/cisco.json (representative)
{
  "vendor_id":           "cisco",
  "preferred_channel":   "psirt",
  "psirt_email":         "psirt@cisco.com",
  "psirt_pgp_fingerprint": "<hex, pinned, rotated only by signed update>",
  "psirt_pgp_key_path":  "src/disclosure/keyring/cisco.asc",
  "portal_url":          "https://sec.cloudapps.cisco.com/security/center/psirtreport/....",
  "cve_cna":             "cisco",
  "advisory_template":   "src/disclosure/templates/cisco-advisory.md.tmpl",
  "ack_subject_regex":   "PSIRT-\\d{4}-\\d{6}",
  "sla": {
     "acknowledge_days": 3,
     "triage_days":      14,
     "disclosure_days":  90
  }
}

Submission flow: the advisory is rendered from advisory_template against the Finding, PGP-encrypted to psirt_pgp_fingerprint using a libgpg-compatible binding, then sent through an SMTP submission account dedicated to disclosure (no shared mailbox). The same advisory is also posted to the vendor portal when one exists, with a back-reference to the email message-ID so the vendor can de-duplicate.

Adapter B · HackerOne

The HackerOne adapter renders the Finding into HackerOne’s JSON-API report document and POSTs it against the researcher account’s API token. The team handle is resolved from the program descriptor.

adapter src/disclosure/adapters/hackerone.ts · submit()
async function submit(s: Submission): Promise<SubmissionReceipt> {
  const body = {
    "data": {
      "type": "report",
      "attributes": {
        "team_handle":            s.program.hackerone_handle,
        "title":                  s.title,
        "vulnerability_information": s.markdown_body,
        "impact":                 s.impact_paragraph,
        "severity_rating":        cvss_to_h1_severity(s.cvss_v31),
        "weakness_id":            cwe_to_h1_weakness(s.cwe_id),
        "structured_scope_id":    s.asset_scope_id,
      }
    }
  };

  const r = await h1_client.post("/v1/hackers/reports", body, {
    idempotency_key: "finding:" + s.finding_id,  // dedupes accidental re-submits
  });

  return {
    terminal:        "hackerone",
    external_id:     r.data.id,
    external_url:    "https://hackerone.com/reports/" + r.data.id,
    submitted_at:    now(),
    payload_sha512:  hash(body),
  };
}

The PoC artifacts are uploaded as report attachments through the multipart POST /reports/{id}/attachments route after the report itself is created, since the HackerOne API does not accept multipart bodies on the initial submission.

Adapter C · Bugcrowd

Bugcrowd’s submission model centers on the Vulnerability Rating Taxonomy (VRT). The adapter maps the Finding’s CWE + primitive to a VRT node, picks the P1–P5 severity from the CVSS base score, and POSTs the document to the program’s submission endpoint.

adapter src/disclosure/adapters/bugcrowd.ts · render() + submit()
function render(f: Finding, ctx: ProgramContext): Submission {
  return {
    target:        ctx.bugcrowd_target_id,
    vrt:           map_to_vrt(f.cwe_id, f.primitive),    // e.g. "server_security_misconfiguration.unsafe_cross_origin_resource_sharing"
    severity:      cvss_to_p_band(f.cvss_v31.base_score), // 9.0+ → P1, 7.0+ → P2, etc.
    title:         f.title,
    description:   f.markdown_body,
    reproduction:  f.repro_steps,
    attachments:   f.artifacts.map(to_bugcrowd_attachment),
  };
}

async function submit(s: Submission): Promise<SubmissionReceipt> {
  const r = await bc_client.post(
    "/submissions",
    s,
    { idempotency_key: "finding:" + s.finding_id },
  );
  return {
    terminal:       "bugcrowd",
    external_id:    r.uuid,
    external_url:   "https://bugcrowd.com/submissions/" + r.uuid,
    submitted_at:   now(),
    payload_sha512: hash(s),
  };
}

Adapter D · CERT/CC VINCE

CERT/CC is the coordinator for multi-vendor bugs, protocol-class bugs, and any case where the affected vendor has no usable PSIRT. The adapter creates a VINCE case, attaches the technical writeup, and lists the vendor contacts to be invited to the case-room. The VU# assignment is asynchronous.

adapter src/disclosure/adapters/cert-cc.ts · submit()
async function submit(s: Submission): Promise<SubmissionReceipt> {
  // Primary: VINCE web case-create (authenticated session).
  const case_id = await vince_client.create_case({
    title:              s.title,
    technical:          s.markdown_body,
    affected_products:  s.affected_products,
    vendor_contacts:    s.vendor_contacts,    // invited to the case-room
    proposed_disclosure_date: now() + days(90),
    cvss:               s.cvss_v31,
  });

  // Fallback: PGP-signed email to cert@cert.org with the same body
  // — captured in the audit log even when the VINCE call succeeds,
  // so the case-room can be reconstructed if VINCE state is ever lost.
  await send_pgp_signed_email(
    "cert@cert.org",
    s.title,
    s.markdown_body,
    { signing_key: operator_pgp_key },
  );

  return {
    terminal:       "cert-cc",
    external_id:    case_id,
    external_url:   "https://kb.cert.org/vince/comm/" + case_id,
    submitted_at:   now(),
    payload_sha512: hash(s),
  };
}

The VU# identifier appears on the case asynchronously when CERT/CC’s coordination team triages. The poll loop watches for it; until it arrives, the case is referenced by the VINCE-internal case ID.

Pre-submission machinery (shared by all adapters)

Before any adapter’s render() is called, every Finding passes through the same preparation pipeline:

Lifecycle state machine

A Finding moves through a fixed lifecycle. Transitions are persisted to Firestore at findings/{finding_id}/lifecycle and audit-logged. The state machine is the single source of truth for "what should happen next."

S0validatedStage 6 complete; ready for terminal routing
S1submittingAdapter.submit() in-flight
S2submittedExternal ID received; SLA timer started
S3acknowledgedVendor/program returned a case ID
S4triagingVendor confirmed reproduction
S5fix-in-progressVendor accepted; fix dated or in development
S6fixedVendor advisory published; CVE assigned
S7publishedTrenchwork advisory published (post-embargo or 90d)
SXdisputedVendor disputes; escalation path engaged

Transitions are driven by adapter poll() returns and SLA-timer fires. The escalation table:

SLA gateDefault windowOn expiry
Acknowledgment3 days from S2Auto-nudge (PGP email to PSIRT, comment on H1/Bugcrowd report, VINCE case-room ping). After 7d: open CERT/CC case as fallback.
Triage14 days from S3Operator notified; SLA clock continues toward disclosure.
Disclosure90 days from S2Transition to public-90day — Trenchwork publishes the advisory at /research with the technical writeup. Vendor is given a final 7-day countdown notice.
Actively-exploited grace0 days — immediateIf the finding is observed in-the-wild during the embargo, the 90-day timer is shortened per Project Zero’s 7-day-active-exploit policy and the vendor is notified of the accelerated timeline.

Audit log shape

Every transition writes one append-only Firestore row at audit_log/{ts}-{finding_id}-{action}. The fields are fixed; no free-form payload is permitted in the audit row (the payload itself is hash-pinned and stored separately under findings/{finding_id}/submissions/).

audit row src/disclosure/audit.ts · DisclosureAuditRow
interface DisclosureAuditRow {
  ts:                  string;   // ISO-8601, server-stamped
  finding_id:          string;
  action:              "route" | "submit.start" | "submit.complete"
                       | "poll" | "transition" | "publish"
                       | "sla.nudge" | "sla.escalate";
  terminal:            DisclosureTerminal;
  from_state:          LifecycleState | null;
  to_state:            LifecycleState | null;
  payload_sha512:      string | null;  // hash of the on-wire payload, not the payload itself
  external_id:         string | null;
  external_url:        string | null;
  operator_uid:        string;            // allowlist-pinned
  run_id:              string;            // ties row to the Fulcrum pipeline run
}

The Firestore security rules at firestore.rules deny any UPDATE or DELETE on audit_log/*. Once a row lands, it is permanent; tampering attempts return permission-denied server-side and are themselves logged.

Schema-enforced invariants

These are not runtime policies that the orchestrator can ignore — they are schema-validation failures that prevent the disclosure pipeline from starting at all.

Disclosure-delivery invariants

  • The disclosure_terminal field is required on every Finding entering S1; values outside the enum (currently psirt | hackerone | bugcrowd | cert-cc | public-90day) fail validation at the schema layer before any adapter is touched.
  • Adapter submit() calls outside the program-descriptor’s declared endpoint set raise DeliveryEndpointViolation. PGP messages to fingerprints not pinned in src/disclosure/keyring/ fail at encryption time. HTTPS calls to hosts not in the program descriptor fail at the HTTP client layer.
  • API tokens are read from environment variables on every call; the source tree contains zero token strings — gitleaks in pre-push hooks blocks commits that introduce one.
  • Idempotency keys are mandatory on all adapter submit() calls. Re-running a Finding through the pipeline produces the prior SubmissionReceipt instead of a duplicate submission.
  • SLA timers cannot be shortened by the operator below the program descriptor’s minimums — only lengthened (e.g. extending embargo at the vendor’s explicit request). Shortening below minimum requires a signed override row in the audit log, captured as a distinct action.
  • The audit-log row for submit.start is written before the network call. If the network call fails, the row records the attempt; a duplicate-key constraint prevents two submit.start rows for the same (finding_id, terminal) pair.
  • Public publication (S7) is gated by either vendor-fix confirmation (S6) or 90-day SLA expiry. No code path transitions S2 → S7 directly.

Hardening tests

The disclosure-delivery surface is covered by test/v1.7-disclosure-delivery.test.ts. The contract mirrors the rest of this repo’s hardening discipline: every invariant has both a behavioural assertion (call the function with a violating input and observe the failure) and a source-string assertion (expect(src).toMatch(...)) so a refactor that drops the check fails CI.

Per the repo’s CLAUDE.md discipline: every hardening pass that closes a disclosure-delivery issue ships a test that fails before the fix and passes after, in this file, organised under a describe() block keyed to the GitHub issue number.

What this page does not cover

Mode B (procurement-side, contract-scoped engagement delivery) uses a different terminal — the customer-controlled mTLS endpoint declared in the EngagementContract — and a different set of controls (EAR ECCN 4D004 end-user attestation, dual-signed contract, engagement-scoped audit row). The Mode B delivery path is documented in full on the Fulcrum page under Stages 8 and 9. The two delivery paths share the audit-log shape and the schema-enforcement style; they do not share endpoints or adapters.

Broker channels (initial-access brokers, exploit auctions, undeclared third-party recipients) are not a delivery option in either mode. The DisclosureTerminal enum makes them schema-invalid; the engagement-contract’s endpoint pin makes them invalid in Mode B. This is the structural property that the hardening tests pin in place.