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.
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-encryptedbody; 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/reportsfrom a researcher-account API token. - Auth
- HTTP Basic with token from
$H1_API_USERNAME/$H1_API_TOKENin 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 viaGET /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.
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:
"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 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.
{
"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.
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.
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.
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:
- CVE pre-allocation. If the vendor is its own CNA, Fulcrum requests an ID from the vendor at submission time and embeds the placeholder. If the vendor is not a CNA, Fulcrum requests an ID from its CNA-LR (CNA of Last Resort, MITRE) via the CVE Services API. The CVE-ID accompanies the submission; it is never published before the embargo expires.
- CVSS v3.1 and v4.0 vectors. Both vectors are computed; the higher-severity score is used for HackerOne’s
severity_ratingand Bugcrowd’s P-band mapping. Vendors that consume only v3.1 receive only v3.1. - CWE classification. Mapped from the Finding’s
primitive+ sink-analysis output via the CWE 4.x dictionary; included in every payload regardless of terminal. - Minimal vendor-confirmable PoC. Produced in stage 6 of the Fulcrum pipeline; deterministic, no live-target dependencies, runnable in a vendor lab. Sized to under 1 MB when possible; large artifacts are attached as supplementary uploads after the initial submission.
- Operator identity attestation. Each submission carries the operator’s allowlist-pinned identity and the run’s audit-log row sha512, so a vendor that wants to verify a report’s provenance can confirm it against the Trenchwork audit collection.
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."
Transitions are driven by adapter poll() returns and SLA-timer fires. The escalation table:
| SLA gate | Default window | On expiry |
|---|---|---|
| Acknowledgment | 3 days from S2 | Auto-nudge (PGP email to PSIRT, comment on H1/Bugcrowd report, VINCE case-room ping). After 7d: open CERT/CC case as fallback. |
| Triage | 14 days from S3 | Operator notified; SLA clock continues toward disclosure. |
| Disclosure | 90 days from S2 | Transition to public-90day — Trenchwork publishes the advisory at /research with the technical writeup. Vendor is given a final 7-day countdown notice. |
| Actively-exploited grace | 0 days — immediate | If 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/).
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_terminalfield is required on every Finding entering S1; values outside the enum (currentlypsirt | 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 raiseDeliveryEndpointViolation. PGP messages to fingerprints not pinned insrc/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 priorSubmissionReceiptinstead 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.startis written before the network call. If the network call fails, the row records the attempt; a duplicate-key constraint prevents twosubmit.startrows 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.
- Schema-enum closure.
finding.schema.json’sdisclosure_terminalenum is read from disk; tests fail if values are added without the corresponding adapter + program-descriptor wiring. - Adapter interface conformance. Every file under
src/disclosure/adapters/must export an object satisfyingDisclosureAdapter; missing methods or wrong signatures fail at TypeScript compile. - PGP-fingerprint pin enforcement. Encrypting a payload to a fingerprint not present in
src/disclosure/keyring/throws; tests assert the throw and assert the source contains the check. - Endpoint pin enforcement. Submitting to a host outside the program descriptor’s allowed-host list throws; tested against a fixture program descriptor.
- Idempotency. Two
submit()calls for the same(finding_id, terminal)return the sameSubmissionReceipt; the underlying HTTP client mock asserts a single outbound call. - SLA escalation. A finding whose
S2timestamp is 91 days in the past transitions topublic-90dayon the next tick; the audit-row sequence is asserted. - Audit-log append-only. A test attempts UPDATE and DELETE against
audit_log/*via the Firestore emulator with both the operator credential and an admin credential; both fail with permission-denied.
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.