fix(clients): prevent query injection, rate-limit masking, and window drift - #34
Merged
Conversation
… drift Harden the GitHub/GitLab commit-evidence paths against silently incorrect or incomplete evidence sets, plus supporting robustness fixes. P1 GitHub search-qualifier injection: - config/resolve.go:37 validate GitHub author/org/repo against the provider charset (login [A-Za-z0-9-], repo owner/name, email token), rejecting embedded spaces/colons/quotes so input like "victim author:attacker" cannot smuggle a second qualifier. GitLab is left unvalidated because its client URL-encodes every value. - ghclient/ghclient.go:533 quote/escape free-text author/org/repo values in the search qualifier as defense in depth (matches GitLab's URL-encode safety level). P1 secondary rate limit masked as access-forbidden: - ghclient/ghclient.go:66 inspect Retry-After / X-RateLimit-Remaining / message on a 403 and classify rate-limit 403s as FATAL (abort) instead of skipping the repo with a benign "access forbidden" reason. - gitlabclient/gitlabclient.go:301 mirror the care: capture Retry-After on statusError and treat a skippable status carrying it as fatal. P1 search-scope window truncated to whole days: - ghclient/ghclient.go:543 emit full RFC3339 author-date bounds so scope=search filters at the same second precision as repos/PR/GitLab. P2 no HTTP timeout: - gitlabclient/gitlabclient.go:57 and ghclient/ghclient.go:86 each use a dedicated *http.Client with a 30s timeout instead of the shared http.DefaultClient / go-github nil default. P2 unbounded pagination: - ghclient/ghclient.go and gitlabclient/gitlabclient.go add a maxPages cap to every pagination loop, aborting with a clear error. P2 false Truncated: - ghclient/ghclient.go:136 and gitlabclient/gitlabclient.go:92 fetch one commit past MaxCommits so an exact fit (nothing dropped) no longer reports Truncated. P2 UTF-8 patch split: - internal/patch/patch.go:21 back off to the last whole-rune boundary when truncating so recorded patch text is always valid UTF-8. Dead-code cleanup: - gitlabclient/gitlabclient.go remove unread gitlabCommit.ShortID. - internal/commitclient/commitclient.go remove the provider-defaulting duplicated by config.Resolve; keep the switch default as a guard so the exported constructor never returns a nil client. Adds regression tests for every behavioral fix. Signed-off-by: Shawn Stratton <shawn.stratton@mface.net>
mfacenet
force-pushed
the
fix/evidence-integrity
branch
from
July 11, 2026 19:32
4e944fd to
37f5d55
Compare
mfacenet
marked this pull request as ready for review
July 11, 2026 19:50
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens sting’s GitHub/GitLab commit collection to avoid silently incomplete/incorrect evidence by preventing search-qualifier injection, correctly surfacing rate-limit failures, aligning time-window precision across scopes, and adding safety bounds (timeouts + pagination caps). This fits into the provider clients (ghclient/, gitlabclient/) and shared helpers (config/, internal/) that power both the CLI and MCP server.
Changes:
- Prevent GitHub search-qualifier injection via validation in
config.Resolveplus defense-in-depth escaping/quoting inghclientquery construction. - Treat rate-limit-like 403s as fatal (abort) instead of “access forbidden” skips; add per-request HTTP timeouts and max-pages guards to pagination loops.
- Fix correctness edge cases: second-precision search windows, correct
Truncateddetection by probingMaxCommits+1, and UTF-8-safe patch truncation.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| internal/patch/patch.go | Truncate patches without splitting UTF-8 runes. |
| internal/patch/patch_test.go | Adds regression coverage for UTF-8-safe truncation behavior. |
| internal/commitclient/commitclient.go | Stops defaulting empty provider in New; documents reliance on config.Resolve. |
| internal/commitclient/commitclient_test.go | Updates tests to enforce error on empty provider. |
| gitlabclient/gitlabclient.go | Adds HTTP timeout, pagination cap, improved rate-limit signaling, and correct truncation detection. |
| gitlabclient/gitlabclient_test.go | Adds/updates tests for retry-after fatal handling and truncation correctness. |
| ghclient/ghclient.go | Adds HTTP timeout, pagination cap, rate-limit-aware 403 handling, query escaping/quoting, and RFC3339 window bounds. |
| ghclient/ghclient_test.go | Updates/extends unit tests for RFC3339 bounds and qualifier escaping/quoting. |
| ghclient/hardening_test.go | Adds end-to-end regression tests for rate-limit vs permission 403 behavior and window parity. |
| ghclient/collect_test.go | Updates tests for the “probe one past MaxCommits” truncation detection semantics. |
| config/resolve.go | Validates GitHub identifiers to prevent qualifier injection before reaching the client. |
| config/resolve_test.go | Adds regression tests for GitHub validation and confirms GitLab bypasses that validation. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the GitHub/GitLab commit-evidence collection paths against silently incorrect or incomplete evidence sets. Every behavioral fix ships with a regression test that fails before the change and passes after. Scope was limited to
ghclient/,gitlabclient/,internal/patch/,internal/commitclient/, andconfig/resolve.go(+ their tests);config/config.gowas intentionally not touched.Findings addressed
P1 — GitHub search-qualifier injection
ghclient/ghclient.go:533-565concatenatedq.Author,q.Org,q.Reposunquoted into the commit-search string, so an author likevictim author:attackersmuggled a second qualifier and evenJohn Doecorrupted the query. Two layers of defense:config/resolve.go— validate GitHub author/org/repo against the provider charset (login[A-Za-z0-9-], repoowner/name, email as a whitespace/colon/quote-free token including theName <addr>form). Embedded spaces, colons, and quotes are rejected. GitLab is deliberately left unvalidated because its client URL-encodes every value.ghclient/ghclient.go(authorQualifier/buildSearchQuery/ newsearchQualifierValue) — quote and backslash-escape any qualifier value that is not a plain identifier, as defense in depth matching GitLab's URL-encode safety level.Tests:
TestResolveRejectsGitHubIdentifierInjection(feedsvictim author:attacker,John Doe,x org:secret-org, a quote),TestResolveAcceptsValidGitHubAuthors,TestResolveGitLabSkipsGitHubValidation,TestSearchQualifierValueEscaping,TestBuildSearchQueryQuotesInjection.P1 — secondary rate limits masked as access-forbidden
ghclient/ghclient.go:66treated every 403 during an org scan as a per-repo skip (access forbidden), so a secondary-rate-limit 403 (which go-github does not always map toRateLimitError) silently dropped repos. NowskipRepoReasoninspectsRetry-After/X-RateLimit-Remaining/ the message and classifies rate-limit 403s as fatal (abort), only skipping genuine permission 403s. Mirrored ingitlabclient/gitlabclient.go:301:statusErrornow capturesRetry-AfterandskipProjectReasontreats a skippable status carrying it as fatal.Tests:
TestSkipRepoReasonRateLimit403,TestCollectScopeOrgRateLimit403Aborts,TestCollectScopeOrgPermission403Skips,TestSkipProjectReasonRetryAfter.P1 — search-scope window truncated to whole days
ghclient/ghclient.go:543-551formattedsince/untilas2006-01-02, while repos, PR (inWindow), and GitLab all filter at second precision, soscope=searchandscope=reposreturned different evidence for the same sub-day window. Now emits full RFC3339author-datebounds.Tests:
TestSearchReposWindowParity(asserts the searchqqualifier and the repossince/untilcarry the same sub-day RFC3339 instants), updatedTestBuildSearchQuery.P2 — no HTTP timeout
gitlabclient.go:57usedhttp.DefaultClient;ghclient.go:86usedgithub.NewClient(nil). Each client now gets its own*http.Client{Timeout: 30s}(per-request backstop; the caller still bounds the whole scan via context). The global default client is never mutated.P2 — unbounded pagination
Every pagination loop trusted the server's next-page signal with no cap. Added a
maxPages(10000) guard to every loop in both clients; exceeding it aborts with a clear error.P2 — false Truncated
ghclient.go:136/gitlabclient.go:92setTruncatedwheneverlen(commits) >= MaxCommits, even when nothing was dropped. BothCollects now fetch one commit past the cap (MaxCommits+1) and only setTruncatedwhen strictly more thanMaxCommitscame back.Tests:
TestCollectExactMaxCommitsNotTruncated(ghclient + gitlabclient), reworkedTestCollectReposPullRequestsRespectMaxCommitsandTestCollectPaginationAndMaxCommits.P2 — UTF-8 patch split
internal/patch/patch.go:21slicedpatch[:budget]on a byte boundary, which could cut a multibyte rune. Truncation now backs off to the last whole-rune boundary so recorded evidence is always valid UTF-8.Tests:
TestConsumePatchBudgetUTF8Boundary.Dead-code cleanup
gitlabCommit.ShortIDfield (JSON still ignores the incomingshort_id).internal/commitclient/commitclient.gothat duplicatedconfig.Resolve's empty→default→github fallback. Both production callers pass an already-resolvedq.Provider.P2 doc note
Added a short code comment in
gitlabclient.listProjectCommitsdocumenting that GitLab filters/records the committed date andauthor_name/author_emailwhile the GitHub paths use the author date and login. The prose docs change is left to the docs PR.Decision needing maintainer sign-off
commitclient.Newno longer defaults an empty provider. The finding suggested removing both the duplicated defaulting and theswitchdefault branch. I removed the defaulting but kept the default branch as a guard, because dropping it would make the exported constructor silently return(nil, nil)for an unexpected/empty provider — a nil-client footgun. Consequence:New(cfg, "")now returns an error instead of defaulting (previously it fell back tocfg.DefaultProvider/github). This is safe today because both in-repo callers pass a resolved provider and the package isinternal/, but it is a behavior change to an exported constructor.TestNewUsesDefaultProviderwas repurposed intoTestNewRejectsEmptyProvider. Please confirm this is acceptable, or say the word and I'll restore the defaulting inNew.Verification
go build ./...,go vet ./...,go test ./...all pass.golangci-lint run ./...(v2.12.2): 0 issues.scripts/check-coverage.sh) passes; all touched packages stay above their thresholds.