Operator guide · research & coordinated disclosure

Using Fulcrum and GitHub (gh CLI) to research vulnerabilities and deliver coordinated disclosure

A practical, end-to-end runbook for the operator who wants to take a vendor advisory, run the Fulcrum seven-step pipeline against it, and deliver the validated finding to a coordinated-disclosure terminal — vendor PSIRT, HackerOne, Bugcrowd, or CERT/CC — with a clean audit trail kept in GitHub repos and tracked via the gh CLI.

Conventions Green-tagged blocks are runnable today (real gh / gpg / curl commands against real services). Gold-tagged blocks are notional Fulcrum CLI — the design target, not yet shipping. The two are interleaved so an operator can start the workflow today using the real commands, and substitute the Fulcrum command once it ships.

0 / 7Prerequisites

Install and authenticate the tools that the rest of the guide assumes are present. Everything in this section is runnable today.

Local tooling

runnablemacOS / Linux — install & auth
# GitHub CLI
$ brew install gh          # or apt / dnf / scoop per https://cli.github.com
$ gh auth login           # interactive; pick HTTPS + browser auth
$ gh auth status          # confirms the token has repo + read:org scopes

# PGP for vendor-PSIRT submissions
$ brew install gnupg
$ gpg --full-generate-key  # RSA 4096, no expiry; capture the fingerprint
$ gpg --armor --export bo@trenchwork.org > operator-pubkey.asc

# Research toolchain (varies by target; representative)
$ brew install ghidra afl-fuzz radare2 binutils
$ pipx install pwntools

Service accounts & tokens

1 / 7Set up a case-file vault on GitHub

The vault is one private repo per finding, holding the patched/vulnerable artifacts, the diff, the PoC, the advisory draft, and every transmission receipt. This is the operator’s authoritative record — if a vendor disputes the timeline or wants to verify reproducibility, you produce this repo. It is never made public; the public artifact is the eventual coordinated advisory, not the working tree.

Create the vault repo

runnablegh CLI · one repo per finding
# Substitute your handle and a stable finding slug.
$ gh repo create trenchwork-private/finding-2026-cisco-xe-cmp \
       --private \
       --description "Cisco IOS XE CMP variant — internal case file" \
       --gitignore Python

$ gh repo clone trenchwork-private/finding-2026-cisco-xe-cmp
$ cd finding-2026-cisco-xe-cmp

Canonical directory layout

The layout below is what the fulcrum CLI will scaffold once it ships. Until then, create it by hand — every directory has a clear purpose in the disclosure trail:

runnablescaffold the case file
$ mkdir -p artifacts/{patched,vulnerable} diff fuzz poc advisory transmissions audit
$ touch README.md advisory/draft.md

# Commit the empty scaffold so later forensics have a clean tree-zero.
$ git add . && git commit -m "scaffold: empty case file"
$ git push -u origin main

Track the finding with a GitHub Issue

One issue per finding, labels for the lifecycle state. The issue is where you and (later) the vendor exchange status — the comments form a tamper-evident timeline because GitHub keeps an audit log of every edit.

runnablegh CLI · lifecycle labels + opening issue
# Define the lifecycle labels once per vault (S0–S7 matches /disclosure).
$ for s in S0-validated S1-submitting S2-submitted S3-acknowledged \
              S4-triaging S5-fix-in-progress S6-fixed S7-published SX-disputed; do
    gh label create "$s" --color FBB726 --description "lifecycle: $s"
  done

$ gh issue create --title "finding-2026-cisco-xe-cmp" \
                  --label S0-validated \
                  --body "$(cat advisory/draft.md)"

Once Fulcrum ships, the same setup is one command. Both forms (notional + manual) produce the same on-disk layout, so you can move between them without a migration step.

notionaltarget Fulcrum CLI — not yet shipping
$ fulcrum case init finding-2026-cisco-xe-cmp \
                  --vendor cisco \
                  --target "IOS XE 17.x — CMP path" \
                  --github-org trenchwork-private
# Creates private repo, scaffolds the layout above, opens issue, applies S0 label.

2 / 7Acquire the target (patched + vulnerable pair)

Every variant-research run starts from a pair of artifacts: the version mentioned in the vendor advisory as fixed, and the immediately-preceding vulnerable build. The pair is what the diff stage feeds on, and the comparison is what makes the variant hunt tractable.

Find advisories with GitHub search

Many vendor advisories are referenced from GitHub Security Advisories (GHSA) and from CVE databases mirrored to GitHub. Use gh search to pull candidate advisories without leaving the terminal:

runnablegh CLI · find advisories & reference code
# Search GitHub-published security advisories by CVE.
$ gh api "/advisories?cve_id=CVE-2023-20198" | jq .

# Search for repos mirroring a vendor’s firmware-image catalog.
$ gh search repos "ios xe firmware mirror" --limit 10

# Search code for patterns that look like the patched sink.
$ gh search code "snprintf nonce CMP" --language c --limit 20

Store the pair under artifacts/

Once you have both builds (vendor portal download, archived community mirror, or a Cisco CCO account), drop them into the vault and commit:

runnablegit LFS for large binaries
$ git lfs install && git lfs track "artifacts/**/*.bin"
$ cp ~/Downloads/cat9k_iosxe.17.09.04a.SPA.bin artifacts/patched/
$ cp ~/Downloads/cat9k_iosxe.17.09.03.SPA.bin  artifacts/vulnerable/
$ sha512sum artifacts/**/*.bin > artifacts/SHA512SUMS
$ git add artifacts/ .gitattributes
$ git commit -m "acquire: IOS XE 17.09.03 (vuln) and 17.09.04a (patched)"
$ git push
notionaltarget Fulcrum CLI
$ fulcrum acquire --advisory "https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-iosxe-webui-privesc-..."
# Resolves the advisory, fetches both image versions, computes hashes, commits.

3 / 7Run the seven-step research pipeline

The pipeline detail lives on the Fulcrum page. The condensed operator view is below: every step writes its output to the vault, and every step opens or updates a GitHub Issue tracking that step.

Step 1 — Diff (Ghidra headless)

runnableGhidra analyzeHeadless
$ analyzeHeadless ./diff vault -import artifacts/patched/cat9k_iosxe.17.09.04a.SPA.bin \
                  -postScript BinDiffExport.java
$ analyzeHeadless ./diff vault -import artifacts/vulnerable/cat9k_iosxe.17.09.03.SPA.bin \
                  -postScript BinDiffExport.java
$ bindiff --primary diff/patched.BinExport --secondary diff/vulnerable.BinExport > diff/report.html

Step 2 — Variant hunt

From the diff, you have a sink (the patched function) and a bug class. The variant hunt is: where else in the binary does this same pattern appear? gh search code across vendor open-source mirrors plus radare2 / Ghidra over the in-tree binary handle most cases.

Step 3 — Fuzz the variants (AFL++)

runnableAFL++ campaign
$ afl-fuzz -i fuzz/seeds -o fuzz/findings -- ./harness/cmp_handler @@
# Run until you have a saturating corpus or one of the queue/crashes hits.

Step 4 — Triage (gdb / pwndbg)

For each crash, walk back to a reachability conclusion: pre-auth-remote? post-auth-remote? local-low-priv? The reachability context is what determines whether this is a coordinated-disclosure target at all, and how urgent the SLA is.

Step 5 — PoC (pwntools)

Minimal, deterministic, vendor-lab-runnable. The PoC is what convinces the vendor; it is also what the rulebook hashes and pins to the audit log row. Commit it to poc/ and tag the commit:

runnabletag the PoC commit
$ git add poc/
$ git commit -m "poc: minimal repro for CMP variant — runs in vendor lab"
$ git tag -s v1.0.0-poc -m "PoC submitted to PSIRT"      # GPG-signed tag
$ git push --follow-tags

Step 6 — Draft the advisory

The advisory at advisory/draft.md is what the vendor and (eventually) the public sees. Include: title, affected versions, primitive (rce / lpe / info-disclosure / auth-bypass), reachability context, CVSS v3.1 + v4.0 vectors, CWE-ID, technical description, PoC reference, suggested fix.

4 / 7Pick a disclosure terminal

The same decision tree that the routing logic automates. Walk it by hand if you’re not using the Fulcrum CLI yet:

Decision tree

  1. More than one vendor affected, or the bug is in a protocol/standard?cert-cc
  2. One vendor, and they require PSIRT-direct submission (Cisco, Apple, Oracle, MSRC for kernel)? → psirt
  3. Vendor runs a bounty program on HackerOne? → hackerone
  4. Vendor runs a bounty program on Bugcrowd? → bugcrowd
  5. Vendor has a mature PSIRT (published address, PGP key, SLA)? → psirt
  6. None of the above — no working channel? → cert-cc (CERT/CC is the coordinator of last resort)

Mature PSIRTs to know by heart: Cisco, Microsoft (MSRC), Apple, Oracle, SAP, Siemens ProductCERT, Adobe, Mozilla, GitHub Security Lab, Atlassian, F5, Citrix, Juniper. Major HackerOne programs: GitHub, Slack, Shopify, Uber, U.S. DoD H1 program. Major Bugcrowd programs: Tesla, Mastercard, Western Union.

5 / 7Deliver to the terminal

Four walkthroughs — one per terminal — with both the real commands you can run today and the Fulcrum form once it ships.

Terminal A · vendor PSIRT (PGP)

Delivery to Cisco PSIRT (representative)

Cisco accepts vulnerability reports at psirt@cisco.com with PGP encryption. The fingerprint and key are published on their PSIRT policy page. Confirm both before importing.

Import & verify the vendor key

runnablegpg · verify fingerprint against vendor page
$ curl -O https://www.cisco.com/web/siaa/psirt/psirt-pgp-key.asc
$ gpg --import psirt-pgp-key.asc
$ gpg --fingerprint psirt@cisco.com
# Compare hex against the fingerprint printed on Cisco's policy page.
# Pin the verified fingerprint in your vault:
$ echo "<fingerprint>" > audit/cisco-pgp-fingerprint.pin

Encrypt & send the advisory

runnablegpg + mail client
$ gpg --encrypt --sign \
        -r psirt@cisco.com \
        -u bo@trenchwork.org \
        --output transmissions/cisco-001.asc \
        advisory/draft.md

# Send via your normal mail client; subject line includes a slug
# Cisco's autoresponder will parse and assign a PSIRT-YYYY-NNNNNN ID.
$ mail -s "Coordinated disclosure: IOS XE CMP variant" \
        -a transmissions/cisco-001.asc psirt@cisco.com < /dev/null

$ git add transmissions/cisco-001.asc audit/cisco-pgp-fingerprint.pin
$ git commit -m "transmit: cisco-001 — encrypted advisory sent to psirt@cisco.com"

Record the case ID when Cisco replies

runnablegh CLI · transition the issue
$ gh issue edit 3 --remove-label S2-submitted --add-label S3-acknowledged
$ gh issue comment 3 --body "Cisco acknowledged as PSIRT-2026-019412 on 2026-05-16."

For other PSIRTs the shape is the same; what changes is the address, the fingerprint, and (for MSRC) that the submission goes through a web portal rather than email. The vault structure, GitHub Issue tracking, and audit record do not change.

notionaltarget Fulcrum CLI
$ fulcrum disclose --terminal psirt --vendor cisco --finding finding-2026-cisco-xe-cmp
# Renders advisory, encrypts to the pinned fingerprint, sends, writes audit row,
# transitions the GitHub Issue label, watches for the ack regex on inbound mail.
Terminal B · HackerOne

Delivery via the HackerOne API

HackerOne accepts report submissions through its public API at api.hackerone.com/v1/hackers/reports. You need the program’s team_handle (visible on its public-program page) and your researcher-account API token.

Resolve the team handle & verify scope

runnablecurl + jq · confirm program is in scope
$ curl -s -u "$H1_API_USERNAME:$H1_API_TOKEN" \
        https://api.hackerone.com/v1/hackers/programs/<team_handle> \
        | jq '.data.attributes | {handle, state, submission_state, policy}'

Submit the report

runnablecurl · POST /v1/hackers/reports
$ jq -n --arg title "$(head -1 advisory/draft.md)" \
            --arg team   "<team_handle>" \
            --rawfile body advisory/draft.md \
            --rawfile impact advisory/impact.md \
       '{data: {type: "report", attributes: {team_handle: $team, title: $title,
          vulnerability_information: $body, impact: $impact,
          severity_rating: "high", weakness_id: 0}}}' \
       > transmissions/h1-001.json

$ curl -s -u "$H1_API_USERNAME:$H1_API_TOKEN" \
        -X POST https://api.hackerone.com/v1/hackers/reports \
        -H "Content-Type: application/json" \
        -d @transmissions/h1-001.json \
        | tee transmissions/h1-001.response.json

# Capture the new report ID into the GitHub Issue trail.
$ REPORT_ID=$(jq -r '.data.id' transmissions/h1-001.response.json)
$ gh issue comment 3 --body "H1 report ID: ${REPORT_ID} — https://hackerone.com/reports/${REPORT_ID}"
$ gh issue edit 3 --remove-label S1-submitting --add-label S2-submitted

Attach PoC artifacts

HackerOne does not accept multipart bodies on the initial submission. Upload artifacts in a follow-up call:

runnablecurl · multipart attachment
$ curl -s -u "$H1_API_USERNAME:$H1_API_TOKEN" \
        -X POST https://api.hackerone.com/v1/hackers/reports/${REPORT_ID}/attachments \
        -F "file[]=@poc/repro.py" \
        -F "file[]=@poc/crash.bin"
notionaltarget Fulcrum CLI
$ fulcrum disclose --terminal hackerone --team <handle> --finding finding-2026-cisco-xe-cmp
Terminal C · Bugcrowd

Delivery via the Bugcrowd researcher API

Bugcrowd’s submission model centers on the Vulnerability Rating Taxonomy (VRT). Pick the closest VRT node, map your CVSS to a P1–P5 band, and submit. Many Bugcrowd programs also allow web-form submission through the program’s page if you prefer not to use the API.

Pick the VRT node & severity

runnablefetch the VRT & pick a node
# The full VRT JSON is open-source.
$ curl -s https://raw.githubusercontent.com/bugcrowd/vulnerability-rating-taxonomy/master/vulnerability-rating-taxonomy.json \
       > advisory/vrt.json
$ jq '.content[] | select(.category=="server_security_misconfiguration")' advisory/vrt.json
# Map CVSS → P-band: 9.0+ → P1, 7.0+ → P2, 4.0+ → P3, ...

Submit the report

runnablecurl · Bugcrowd submissions endpoint
$ curl -s -H "Authorization: Token ${BUGCROWD_API_TOKEN}" \
        -H "Accept: application/vnd.bugcrowd.v4+json" \
        -H "Content-Type: application/json" \
        -X POST https://api.bugcrowd.com/submissions \
        -d @transmissions/bugcrowd-001.json \
        | tee transmissions/bugcrowd-001.response.json

$ UUID=$(jq -r '.data.id' transmissions/bugcrowd-001.response.json)
$ gh issue comment 3 --body "Bugcrowd submission UUID: ${UUID}"
notionaltarget Fulcrum CLI
$ fulcrum disclose --terminal bugcrowd --program <slug> --finding finding-2026-cisco-xe-cmp
Terminal D · CERT/CC VINCE

Multi-party or no-PSIRT coordination via VINCE

Open a case via the VINCE web UI at kb.cert.org/vince/. CERT/CC requires that you list the affected vendors and proposed disclosure date; they invite each vendor to the case-room asynchronously. The CERT-assigned VU# identifier arrives in the case-room after triage.

Open a VINCE case

  1. Sign in to kb.cert.org/vince/.
  2. New report → vulnerability. Fill: title, technical description (paste advisory/draft.md), affected products list, vendor contacts, proposed disclosure date (today + 90 days).
  3. Attach PoC artifacts. The case page generates a per-case URL of the form https://kb.cert.org/vince/comm/<case-id>/ — record it.

Fallback: PGP-signed email

If you cannot reach the VINCE web UI, CERT/CC accepts PGP-signed email to cert@cert.org. The public key is at cisa.gov/.../PGP-OldCERT.asc (verify against their site).

runnablegpg --clearsign + mail
$ gpg --clearsign --output transmissions/cert-cc-001.asc advisory/draft.md
$ mail -s "Multi-vendor report: <short title>" cert@cert.org < transmissions/cert-cc-001.asc

Record the case in the vault

runnablegh CLI
$ gh issue comment 3 --body "VINCE case opened: https://kb.cert.org/vince/comm/<case-id>/ — VU# pending."
$ gh issue edit 3 --remove-label S1-submitting --add-label S2-submitted
notionaltarget Fulcrum CLI
$ fulcrum disclose --terminal cert-cc --vendors cisco,juniper,arista --finding finding-2026-bgp-validator

6 / 7Track the lifecycle in GitHub Issues

The labels you created in Step 1 are the lifecycle. Drive them forward as the vendor responds; the resulting issue timeline is your audit trail.

S0 validated S1 submitting S2 submitted S3 acknowledged S4 triaging S5 fix-in-progress S6 fixed S7 published SX disputed

Nudge at SLA boundaries

Vendor acknowledgments are commonly slow. The convention this repo recommends matches Project Zero’s 90-day policy: nudge at day 3 if no acknowledgment, day 14 if no triage, day 60 with a 30-day-warning, day 83 with a 7-day-warning, day 90 publish.

runnablegh CLI · one-liner SLA nudges
# Day-3 acknowledgment nudge.
$ gh issue comment 3 --body "Day-3 ack reminder sent to PSIRT (no response on initial submission)."

# Day-83: 7-day-to-publish notice.
$ gh issue comment 3 --body "7-day-to-publish notice sent to vendor: $(date -I)."

# Watch all open findings at a glance.
$ gh issue list --label S2-submitted --json number,title,createdAt \
       | jq '.[] | select((now - (.createdAt|fromdate)) > 86400*3)'

7 / 7Publish the coordinated public advisory

Two paths to publication:

  1. Vendor fix released. The vendor publishes their advisory; you publish yours alongside, cross-referencing their CVE. Move the issue to S6, then S7 after your write-up is live.
  2. 90-day SLA expired without a fix. Publish the technical writeup at /research with the timeline of contact attempts and the vendor’s response (or lack thereof). Move the issue to S7-published. This is the public-90day terminal in the disclosure rulebook.

Publish through this site

Add a section to site/public/research.html with the technical writeup; commit and push. Firebase Hosting picks up the change on the next deploy. The vault repo stays private — only the curated advisory lands on the public site.

runnableclose the issue with the public URL
$ gh issue close 3 --comment "Published: https://trenchwork.org/research#cve-2026-XXXX (vendor fix shipped 2026-08-12, CVE assigned via Cisco CNA)."
$ git tag -s v1.0.0-published -m "published as CVE-2026-XXXX"
$ git push --follow-tags

Worked end-to-end example

One finding, real shape, day-by-day. The vendor is fictional (avoiding LLM-fabricated facts about a real disclosure); the workflow is exactly what you would run.

Day 0
Advisory published by “Vendor X” mentions a patched UAF in their VPN concentrator.

Run gh search code "snprintf nonce" --repo vendorx/firmware to find the patched sink. Acquire the patched + vulnerable images, drop into the vault under artifacts/, commit. Open issue #3 with label S0-validated.

Day 1–3
Diff and variant hunt

Ghidra-headless on both images, bindiff on the resulting .BinExport files. Find three siblings of the patched sink in the same module; one of them looks reachable pre-auth from the management plane.

Day 4–9
Fuzz, triage, PoC

AFL++ campaign on the candidate sink. Crash within 14 hours; pwndbg shows controllable PC. Build a minimal pwntools PoC that runs in a Vendor-X lab VM. Tag the commit v1.0.0-poc.

Day 10
Pick terminal & submit

Vendor X has a mature PSIRT with PGP. Decision tree → psirt. Encrypt advisory to the pinned fingerprint, send. Issue label S1-submitting → S2-submitted. Audit row written under audit/.

Day 11
Acknowledged

PSIRT replies with case ID VEN-2026-001923. Issue label S2 → S3-acknowledged. Comment with the case ID.

Day 28
Triage confirmed

PSIRT confirms reproduction. CVSS 9.1, assigned CVE-2026-XXXX from Vendor X’s CNA. Issue label S3 → S4-triaging → S5-fix-in-progress.

Day 62
Fix dated

Vendor confirms a fix in the next maintenance release, targeted day 84. You agree to coordinate disclosure on the day of that release.

Day 84
Coordinated publication

Vendor publishes their advisory; you push your technical writeup to /research. Issue label S5 → S6-fixed → S7-published. Tag the vault commit v1.0.0-published. Close issue with the public URL.

gh CLI cheat sheet for the disclosure workflow

The commands that show up most often when running a disclosure case through GitHub. Everything here is real and runnable today.

gh repo create <org/name> --private
Create the per-finding case-file vault. Always private — the working tree is never the public artifact.
gh label create S3-acknowledged --color FBB726
Define a lifecycle label. Bulk-create with the loop in Step 1.
gh issue create --title "<slug>" --label S0-validated
Open the tracking issue at validation time. One issue per finding.
gh issue edit <n> --remove-label S2 --add-label S3
Transition the lifecycle when the vendor responds. Each transition is a commented event in the issue timeline.
gh issue comment <n> --body "$(cat ack.txt)"
Paste the vendor acknowledgment text into the issue. The comment is timestamped server-side — usable as evidence in a dispute.
gh issue list --label S2-submitted --json number,title,createdAt
Sweep all submissions that have not progressed past S2; combine with jq to find ones past SLA.
gh search code "<pattern>" --language c --limit 50
Variant hunt across vendor open-source mirrors. Useful for upstream-projects (Linux, OpenSSL, BoringSSL, libcurl) that vendors carry.
gh search repos "<vendor> firmware mirror"
Find third-party mirrors of vendor firmware drops — handy when the official vendor portal requires an account you don’t have.
gh api /advisories?cve_id=<cve>
Pull the GitHub Security Advisory record for a CVE — structured metadata, including affected packages and references.
git tag -s v1.0.0-poc -m "<case-id>"
GPG-signed tag at each lifecycle transition. --follow-tags on push gets them to the remote alongside commits.
gh release create v1.0.0-published --draft
Optional: cut a draft release that mirrors the public advisory text, kept private until publication day.

For the architectural detail behind why each lifecycle transition exists and how the rulebook enforces them, see /disclosure. For the Fulcrum pipeline that produces the validated findings this guide hands to a terminal, see /fulcrum.