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.
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
# 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
- HackerOne — create a researcher account at hackerone.com, then issue an API token at
Settings → API Tokens. Export asH1_API_USERNAMEandH1_API_TOKENin your shell’s secret store (1Password CLI,op;pass; or a.envrckept out of git). - Bugcrowd — researcher account at bugcrowd.com, then API token under
Account Settings → API. Export asBUGCROWD_API_TOKEN. - CERT/CC VINCE — account at kb.cert.org/vince/. VINCE uses session-cookie auth via the web UI; API access is invite-only per case.
- Vendor PSIRTs — addresses and PGP keys are vendor-published. The four most-common: Cisco
psirt@cisco.com(key), Microsoft MSRC web portal at msrc.microsoft.com/report, Appleproduct-security@apple.com(key), Oraclesecalert_us@oracle.com. - CVE-ID requests — for findings whose vendor is not a CVE CNA, request a CVE-ID from MITRE’s CNA-LR via the CVE form. Many vendor PSIRTs issue their own CVE-IDs when they accept your report; you typically only need to file directly when the vendor doesn’t.
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
# 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:
$ 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.
# 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.
$ 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:
# 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:
$ 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
$ 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)
$ 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++)
$ 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:
$ 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
- More than one vendor affected, or the bug is in a protocol/standard? →
cert-cc - One vendor, and they require PSIRT-direct submission (Cisco, Apple, Oracle, MSRC for kernel)? →
psirt - Vendor runs a bounty program on HackerOne? →
hackerone - Vendor runs a bounty program on Bugcrowd? →
bugcrowd - Vendor has a mature PSIRT (published address, PGP key, SLA)? →
psirt - 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.
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
$ 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
$ 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
$ 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.
$ 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.
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
$ 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
$ 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:
$ 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"
$ fulcrum disclose --terminal hackerone --team <handle> --finding finding-2026-cisco-xe-cmp
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
# 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
$ 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}"
$ fulcrum disclose --terminal bugcrowd --program <slug> --finding finding-2026-cisco-xe-cmp
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
- Sign in to kb.cert.org/vince/.
New report → vulnerability. Fill: title, technical description (pasteadvisory/draft.md), affected products list, vendor contacts, proposed disclosure date (today + 90 days).- 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).
$ 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
$ 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
$ 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.
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.
# 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:
- Vendor fix released. The vendor publishes their advisory; you publish yours alongside, cross-referencing their CVE. Move the issue to
S6, thenS7after your write-up is live. - 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 thepublic-90dayterminal 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.
$ 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.
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.
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.
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.
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/.
PSIRT replies with case ID VEN-2026-001923. Issue label S2 → S3-acknowledged. Comment with the case ID.
PSIRT confirms reproduction. CVSS 9.1, assigned CVE-2026-XXXX from Vendor X’s CNA. Issue label S3 → S4-triaging → S5-fix-in-progress.
Vendor confirms a fix in the next maintenance release, targeted day 84. You agree to coordinate disclosure on the day of that release.
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.
S2; combine with jq to find ones past SLA.--follow-tags on push gets them to the remote alongside commits.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.