Fixing a vulnerability in private
Test an embargoed security fix on your real CI before disclosure, keep exploit notes out of what goes public, and publish on disclosure day.
This page is for maintainers of a public GitHub repo who got a private vulnerability report and want the fix tested on their real CI before anyone outside sees it. It doesn’t replace GitHub’s security advisories. Keep the advisory for talking to the reporter, for the CVE and for the announcement. Use Ghostbranch for the code and the CI.
The examples use a public repo called you/project and a feature called fix-path-join.
The gap
Section titled “The gap”A draft security advisory on GitHub can have a temporary private fork, where you and the reporter work on the fix. GitHub’s docs say what it can’t do: “To keep information about vulnerabilities secure, integrations, including CI, cannot access temporary private forks”, and “status checks do not run on pull requests in temporary private forks”.
GitHub had a roadmap item to run Actions there, github/roadmap#627. It was closed as not planned on 2024-11-20.
So the fix that most needs testing gets no CI. With Ghostbranch, the fix lives on a branch in your private shadow repo, which is never a GitHub fork. The gate runs your public repo’s own workflows on exactly what would be published.
Before you start: run CI on pull requests
Section titled “Before you start: run CI on pull requests”The smallest trigger that works with the gate:
on: pull_request:This has to be on public main, since the shadow repo mirrors it. In the rehearsal behind this page, the CI ran on push, and the shadow repo ran it on the raw feature commits twice before the gate ever started. It also saves Actions minutes. Setting up CI covers secrets, repo guards and the audit.
Fix it in private
Section titled “Fix it in private”This assumes your repo is already set up with ghostbranch init (Set up a repo). To keep the fix apart from your other private work, give the advisory its own shadow repo first.
1. Start the feature
Section titled “1. Start the feature”ghostbranch syncghostbranch feature start fix-path-joinfeature start creates the branch in the shadow repo and switches your checkout to it. If your checkout has uncommitted or untracked files, it only fetches; then commit or stash them and git switch fix-path-join. A feature in detail lists every case.
2. Write the fix and a regression test
Section titled “2. Write the fix and a regression test”Commit the fix and a test that fails without it. Keep the test in the published tree, not inside an internal section: the gate strips internal sections before CI, so a test there wouldn’t run.
In preserve mode, the default, each commit message goes public as written. Write it as you’d want it read on disclosure day, such as fix: reject .. in path join. To write the public message later instead, publish in squash or curated mode (History modes).
3. Keep exploit notes in internal sections
Section titled “3. Keep exploit notes in internal sections”Notes about how the bug is reached, or the reporter’s payload, can sit next to the code between marker lines. The gate cuts them out before CI and before publishing:
# BEGIN-INTERNAL# exploit: a "../" in the name escaped the base folder (payload in the advisory)# END-INTERNALThe rule for a marker line:
- It is the token alone,
BEGIN-INTERNALorEND-INTERNAL, in any case. - It may have whitespace and at most one comment wrapper:
//,#,--,;,%, or a pair such as/* */,<!-- -->or{/* */}. - Anything else on the line after the token, a begin inside another section, an end without a begin, or a begin without an end stops the gate with
MARKER_INVALID. Ghostbranch never guesses where a section ends.
The marker lines and everything between them are cut, whole lines. Internal sections has the rest.
4. Push and gate
Section titled “4. Push and gate”git push shadow fix-path-joinghostbranch gate fix-path-joinThe gate rebases the fix onto public main, strips it, scans what would be published, and runs your CI on a draft pull request in the shadow repo. In the rehearsal it took about a minute and a half, with a 9-second CI job:
Check the draft pull request’s diff in the shadow repo: it’s the tree that would go public. In the rehearsal, the internal note was gone from it, leaving a blank gap where the marker lines had been, and the regression tests ran in the CI log.
5. If the gate finds a leak
Section titled “5. If the gate finds a leak”A security fix often comes with test vectors that look like secrets: a PEM key, a JWT, a token in an auth test. The leak scan treats them as secrets. In the rehearsal, a fake GitHub token in a test fixture stopped the gate before anything was pushed, and before CI used a minute:
The value is never printed, only the rule and the place. You can rewrite the fixture so the value is built when the test runs, or a person can waive the finding at their console. An agent can’t. The same fake token inside an internal section raised no finding, because the scan reads the stripped tree. When the gate finds a leak walks through every way out.
6. Review what will go public
Section titled “6. Review what will go public”ghostbranch promote fix-path-join --title "Fix path traversal in join" --planIt reuses a fresh green gate. If there isn’t one, it runs the gate first, CI included. It saves a plan and publishes nothing:
The terminal shows only the count. The plan file lists each commit, its author, the branch, and the pull request’s title and body, which defaults to one line per commit subject. Search it for anything that shouldn’t go public, such as the advisory’s codename or the word INTERNAL. In the rehearsal, neither the note nor the fake token was in it. apply shows the commits, files and pull request again on your console before it asks for the code.
You can plan days ahead. Then stop here until disclosure day.
What stays private
Section titled “What stays private”- The branch. It lives in the shadow repo only. The push guard refuses to push its unpublished commits to the public repo, under any branch name.
- Internal sections and
.ghostbranch/. They are cut from what CI tests and from what is published. They are still in the shadow repo’s history. - Everything else in the shadow repo. The gate branch, the draft pull request and the plans’ records stay there. Nothing reaches the public repo except through
apply, which a person approves.
Disclosure day
Section titled “Disclosure day”The usual order is to merge the fix, release it, and publish the advisory, as close together as you can. With Ghostbranch:
- Gate again if public
mainmoved. A gate is fresh only for the publicmain, feature and settings it ran on. If any changed,promote --planruns the gate again, and you wait for CI. Plan again after that. - Apply. A person runs
ghostbranch apply <id>at their own terminal and types the code it shows. It pushes a branch to your public repo and opens a normal pull request againstmain. If anything moved since the plan, it refuses withPLAN_STALEand publishes nothing. A feature in detail shows the Archive or Keep question it asks. - Merge right away, with a merge commit or a rebase. “Squash and merge” undoes preserve’s commits. The CI that matters has already passed on this exact tree.
- Release. Tag and publish the release as you usually do.
- Publish the advisory. Add the public pull request or commit to its references, and credit the reporter there.
After the merge, run ghostbranch sync. It brings the merge into the shadow repo and closes the feature (Merging and closing).
Credit the reporter
Section titled “Credit the reporter”By default, promote.author_email is noreply: every published commit carries the publishing account’s GitHub noreply identity. A commit the reporter wrote in the shadow repo goes public under your name, not theirs.
Two settings in .ghostbranch.yml keep their name (Configuration):
keeppublishes each commit’s own author, as recorded in the commit, email included. Check with the reporter that they want that email public.mappublishes the identity you list for each author email inpromote.author_map. An author who isn’t listed stops the publish withAUTHOR_UNMAPPED.
Or keep the default and add a trailer to the commit message. Preserve publishes messages as written:
fix: reject .. in path join
Co-Authored-By: Reporter Name <[email protected]>In squash mode every commit goes public as the publisher, so put the trailer in the -m message. Settings are part of what a gate is fresh for, so change them before the gate you plan to publish from. History modes has more on authors.
One shadow per advisory
Section titled “One shadow per advisory”You may want the reporter to help, or to check the fix. GitHub has no per-branch read access, so a collaborator on your shadow repo sees all of it: every private feature, and the overlay branch with your settings and private workflows. Ghostbranch doesn’t manage or check who can read the shadow repo.
To show the reporter only this fix, give the advisory its own pair, in a second workspace, from its own clone of the public repo:
git clone https://github.com/you/project project-advisorycd project-advisoryghostbranch --workspace advisory init you/project --shadow you/project-advisory-shadow--workspace advisorykeeps the new pair apart from your usual one. Add it to every command for this pair, or setGHOSTBRANCH_WORKSPACE=advisoryin that terminal.--shadowtakes the fullowner/name. A bare name is refused withBAD_FLAG.- One clone per workspace. The push guard in a clone is bound to one workspace, and installing it for the other would replace it. Let
initinstall the guard in the new clone, and leave your usual clone as it is.
Then invite the reporter to you/project-advisory-shadow only, in its settings on GitHub. Read access lets them see the fix, and write access lets them push to it.
This was tried with two shadow repos of one public repo, each in its own workspace and clone. Each guard covered its own pair, and the new shadow repo held only its own branches and the public ones it mirrors. But the first gate in that brand-new shadow repo saw no workflow run at all and failed with NO_CI, and the cause isn’t known yet. So before you rely on a new pair, gate a small harmless change in it and check that a workflow run starts.
Limits
Section titled “Limits”- Publishing is always a public pull request. There’s no direct merge, so there’s a short window between
applyand the merge. - Only the default branch. A feature is based on the public repo’s default branch (
main,masteror another name) and published to it. Backports to release branches are still by hand. - Shadow access is all or nothing within a pair. Use a separate pair per advisory, as above.
- Release and tag workflows aren’t tested. The gate runs workflows on its pull request. Ghostbranch doesn’t tag or release.
- Shadow CI uses Actions minutes. The shadow repo is private, so GitHub bills its minutes. Each gate runs your CI once, and a disclosure-day re-gate runs it again.
- Secret-shaped test vectors trip the scan, and only a person can waive a finding, at their console, on that machine. Values that end in
EXAMPLEpass. - The advisory is yours. Ghostbranch doesn’t read or change advisories, request CVEs or credit reporters there.
Clean up
Section titled “Clean up”- After the merge:
ghostbranch synccloses the feature and deletes its branches in the shadow repo. - If you stop without publishing:
ghostbranch feature abandon fix-path-joinhides the feature and deletes nothing;--undobrings it back. The gate’s draft pull request in the shadow repo stays open, so close it on GitHub. - If the scan found a real secret, rotate it. It’s in the shadow repo’s history.
- An advisory’s pair: once its fix is merged and synced, a person runs
ghostbranch --workspace advisory remove you/project, which asks for a code. Then remove the guard withghostbranch guard uninstallin that clone, delete the clone, and deleteyou/project-advisory-shadowon GitHub. Ghostbranch never deletes a repo. Removing a repo has the details.
Errors you may see
Section titled “Errors you may see”LEAK_FOUNDexit 3The leak scan found something in what would be published.
Remove the content in details.findings from the feature and gate again. A finding whose commits are listed may be only in older commits: publish with --mode squash or --mode curated so they never ship, or rewrite those commits on the feature branch, force-push it to the shadow repo, and gate again. If a person judges a finding in details.waivable a false positive, they run the waivers add command in next. Agents never waive findings or edit waivers.json.
MARKER_INVALIDexit 3A file has BEGIN-INTERNAL/END-INTERNAL markers that cannot be stripped safely.
- unmatched_begin: a begin marker with no end
- unmatched_end: an end marker with no begin
- nested: a begin marker inside an open section
- malformed: a line that looks like a marker but is not exactly one (more text, a doubled prefix, or a multi-line comment)
- unsupported_encoding: markers in a UTF-16 or UTF-32 file, or in a file read as binary
Fix the marker lines at details.problems on the feature branch, push it to the shadow repo, and gate again. When details.commit names an older commit and the final tree no longer has the problem, publish with --mode squash or --mode curated so it never ships, or rewrite that commit on the feature branch and force-push it to the shadow repo. Never delete only the marker lines: the internal lines would then be published.
NO_CIexit 3No CI checks appeared on the gate candidate (error.details.reason).
- no_workflow: the candidate has no workflow under .github/workflows that runs on the PR (error.details.no_workflows is true; the gate waited gate.ci_appear_timeout_no_workflows), and no CI app posts checks
- actions_disabled_or_untriggered: Actions is disabled in the shadow repo, or the PR doesn't match the workflows' pull_request filters
- no reason: the gate candidate couldn't be read, so which of the two holds is unknown
For no_workflow, add CI to the public repo, or, if CI is an external app, install it on the shadow repo. Otherwise enable Actions in the shadow repo or add a pull_request trigger. Then gate again.
PLAN_STALEexit 6The repos changed since the plan was made.
Make a new plan with ghostbranch promote <feature> --plan and apply that.
AUTHOR_UNMAPPEDexit 1promote.author_email is map, and some commit authors are not in promote.author_map. Commits feature pull brought in from the public PR keep their authors and are never unmapped.
A person adds the emails in error.details.emails to promote.author_map in .ghostbranch.yml on the overlay branch, or switches author_email to noreply.
This needs a person.