Summary
The merge-guard read floor (is_dangerous_command in pact-plugin/hooks/shared/merge_guard_common.py, called by merge_guard_pre.py) gates curl/wget GitHub-API ref-mutation commands using patterns that require the literal substring api in the command. A ref-deleting curl/wget to a host whose URL does not contain api therefore evaluates is_dangerous = False and is not gated.
curl -X DELETE <self-hosted-or-$VAR-host>/.../git/refs/heads/y → is_dangerous = False (BYPASS)
The canonical forms always contain api and remain correctly gated:
gh api ... (literal api)
https://api.github.com/...
- GHE
https://<host>/api/v3/...
The gap is curl/wget to a non-api host (a self-hosted endpoint, an IP, or a $VAR-interpolated host) carrying a mutating method on a git/refs/... path.
Severity
MEDIUM. A real ref-deletion (branch/tag delete) bypass of the read floor. Not reachable via the canonical gh api / api.github.com forms (always contain api); requires a non-api host URL.
Scope / provenance
Fix direction
Gate the read-side curl/wget API ref-mutation form structurally rather than on the literal api substring:
- HTTP tool present (
curl / wget / http/https clients), AND
- a
git/refs/... path fragment, AND
- a mutating method (
-X|--request|--method in {DELETE, POST, PATCH, PUT}, or an implicit-write data flag).
This mirrors the structural test already used for the gh api form and removes the host-substring dependency. Apply symmetrically so mint and read stay equal.
Acceptance
curl -X DELETE <non-api-host>/.../git/refs/heads/y → is_dangerous = True (gated).
- All currently-gated
api-bearing forms stay gated (no regression).
curl -X GET .../git/refs/... (read) stays ungated.
Anti-drift: mint==read parity canary (added scope)
When the fix tightens the read floor so the non-api curl/wget ref-mutation form evaluates is_dangerous = True, the mint side needs no change — detect_command_operation_type already classifies it, so the mint will gate-pass and correctly mint a branch-delete token (mint == read, both gated). The risk is silent drift between the read-side patterns and the mint classifier over time.
Add a mint==read parity canary to the fix's test set: assert that the read floor (is_dangerous_command) and the mint classifier (detect_command_operation_type) agree on the non-api curl/wget ref-mutation form — so a later edit to one cannot diverge from the other. This is the same anti-drift discipline the GAP1 is_dangerous-gate SSOT enforces (one predicate, both consumers).
(Reproduced + corroborated by the security-engineer and architect during PR #1060 v3 verification: under the current is_dangerous-gate, mint == read holds — non-api curl → is_dangerous=False → no mint — so this is purely a read-floor coverage task, not a mint regression.)
Summary
The merge-guard read floor (
is_dangerous_commandinpact-plugin/hooks/shared/merge_guard_common.py, called bymerge_guard_pre.py) gates curl/wget GitHub-API ref-mutation commands using patterns that require the literal substringapiin the command. A ref-deleting curl/wget to a host whose URL does not containapitherefore evaluatesis_dangerous = Falseand is not gated.The canonical forms always contain
apiand remain correctly gated:gh api ...(literalapi)https://api.github.com/...https://<host>/api/v3/...The gap is curl/wget to a non-
apihost (a self-hosted endpoint, an IP, or a$VAR-interpolated host) carrying a mutating method on agit/refs/...path.Severity
MEDIUM. A real ref-deletion (branch/tag delete) bypass of the read floor. Not reachable via the canonical
gh api/api.github.comforms (always containapi); requires a non-apihost URL.Scope / provenance
is_dangerous_commandpredicate (mint ⊆ read by construction), so the mint never under-recognizes relative to the read floor — this is purely a read-floor coverage gap, symmetric on both sides.Fix direction
Gate the read-side curl/wget API ref-mutation form structurally rather than on the literal
apisubstring:curl/wget/http/httpsclients), ANDgit/refs/...path fragment, AND-X|--request|--methodin{DELETE, POST, PATCH, PUT}, or an implicit-write data flag).This mirrors the structural test already used for the
gh apiform and removes the host-substring dependency. Apply symmetrically so mint and read stay equal.Acceptance
curl -X DELETE <non-api-host>/.../git/refs/heads/y→is_dangerous = True(gated).api-bearing forms stay gated (no regression).curl -X GET .../git/refs/...(read) stays ungated.Anti-drift: mint==read parity canary (added scope)
When the fix tightens the read floor so the non-
apicurl/wget ref-mutation form evaluatesis_dangerous = True, the mint side needs no change —detect_command_operation_typealready classifies it, so the mint will gate-pass and correctly mint a branch-delete token (mint == read, both gated). The risk is silent drift between the read-side patterns and the mint classifier over time.Add a mint==read parity canary to the fix's test set: assert that the read floor (
is_dangerous_command) and the mint classifier (detect_command_operation_type) agree on the non-apicurl/wget ref-mutation form — so a later edit to one cannot diverge from the other. This is the same anti-drift discipline the GAP1is_dangerous-gate SSOT enforces (one predicate, both consumers).(Reproduced + corroborated by the security-engineer and architect during PR #1060 v3 verification: under the current
is_dangerous-gate, mint == read holds — non-apicurl →is_dangerous=False→ no mint — so this is purely a read-floor coverage task, not a mint regression.)