Skip to content

fix(github): fail closed on every non-success response and malformed payload - #7

Merged
LuanTrindade95 merged 7 commits into
mainfrom
fix/github-client-fail-closed
Sep 20, 2026
Merged

LuanTrindade95 merged 7 commits into
mainfrom
fix/github-client-fail-closed

Conversation

@LuanTrindade95

Copy link
Copy Markdown
Owner

Closes remediation item C2 (audit finding F-006).

Problem

HttpGitHubClient::request() only mapped 403 to rate limit and 404 to repository not found. Every other failure — 401, 409, 422, 429, 5xx, connection errors — came back as an ordinary Response, and markdownFiles() consumed it with json(''tree'', []). A server error, a non-JSON body or a body without tree became an empty list, so ingestion finished success with zero documents, indistinguishable from a repository that legitimately has no docs. Invalid base64 was persisted as an empty document, and a truncated: true tree was treated as complete.

A second fail-open path, not described in F-006, is closed here too: IngestionService::run() only caught the two existing exceptions, so any other one escaped the method and left the IngestionRun stuck in running, with no finished_at and no log.

Change

App\Exceptions\GitHubClientException is the abstract base for every GitHub failure and exposes failureCode(). The client now fails closed on every non-success status and validates the payload; run() catches the whole hierarchy and ends the run through fail().

Category Code Covers
Rate limit git_hub_rate_limit_exception 403 with rate-limit header, 429
Auth / permission github_authentication_failed 401, 403 without that header
Repo or ref missing git_hub_repository_not_found_exception 404
Conflict / validation github_validation_failed 409, 422
Transient github_transient_error any status >= 500, connection, timeout
Malformed payload github_malformed_response non-JSON, no tree, invalid base64, unsupported encoding
Truncated tree github_tree_truncated truncated: true

A 403 is classified by the rate-limit header rather than the status alone, so a permission denial is no longer misreported as rate limiting. Transient matches a range, not a list, so unseen 5xx stay covered.

Compatibility

App\Contracts\GitHubClient, FixtureGitHubClient, the IngestionService::fail() signature and the IngestionRun.log entry shape are all unchanged — downstream consumers read the taxonomy through the existing code key with no migration. The two legacy codes keep their historical shape because a test asserts one literally. The 304/ETag path of ADR-11 returns before any error check and is covered by a regression test. No retry or backoff: that remains Priority 8.

Validation

  • Pest 50 passed (233 assertions), Pint 122 files, run against a rebuilt image.
  • Independent adversarial audit: APPROVED. All 16 scenarios rebuilt with an own Http::fake harness driving IngestionService::run() end to end, reading status and log from the database. No error scenario finished success; no two distinct categories shared a code.
  • 502, missing from the implementation report, is covered — confirmed empirically.
  • Token leak check with a fictitious token across IngestionRun.log, storage/logs, exception messages and the diff: no occurrence of the token, Bearer or Authorization.
  • Test count 33 to 50, no test removed, no skip, no weakened assertion.

Worth knowing for future work: the backend service has no bind mount, so a gate run straight after an edit can execute stale image code. Rebuild before trusting it.

Taxonomy and scope trade-offs recorded in ADR-27; the general rule in BRAIN-007.

🤖 Generated with Claude Code

LuanTrindade95 and others added 7 commits September 20, 2026 00:15
…rrors

GitHubRateLimitException and GitHubRepositoryNotFoundException now extend
a common GitHubClientException base with a failureCode() contract, and
gain siblings for authentication/permission, validation/conflict,
transient, malformed-response and truncated-tree failures (F-006).
Historical codes for the two existing exceptions are preserved via the
default Str::snake(class_basename()) fallback; only the new categories
get explicit, clean literals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…esponse

request() now maps every GitHub API outcome to an explicit domain
exception instead of letting anything besides 403/404 fall through as a
normal Response: 401 and 403-without-rate-limit-signal become
authentication failures, 429 and 403-with-signal become rate limiting,
409/422 become validation failures, 5xx and connection failures become
transient errors, and any other non-2xx/304 status is treated as
malformed. markdownFiles() validates the tree payload is actually an
array before use and fails closed with a dedicated code when GitHub
reports the tree as truncated, instead of silently processing a partial
listing. readMarkdownFile() uses strict base64 decoding and fails closed
instead of turning invalid content or an unsupported encoding into a
silently empty document. The 304/ETag conditional-request path is
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
run() previously caught only GitHubRateLimitException and
GitHubRepositoryNotFoundException. Any other GitHub client failure (e.g.
the unsupported-encoding case) escaped run() entirely, leaving the
IngestionRun stuck in "running" with no finished_at and no log entry.
Catching the common GitHubClientException base and using its
failureCode() ensures every category introduced for F-006 ends the run
explicitly as "failed" with a stable, logged code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tions

Adds GitHubClientFailClosedTest with one case per F-006 category (401,
403 with/without rate-limit signal, 429, 409/422, 5xx, connection
failure, non-JSON body, missing tree, truncated tree, invalid base64,
unsupported encoding), each asserting the concrete exception type and
its failureCode(). Also regression-tests that the 304/ETag path still
returns unchanged content, and that a transient GitHub failure ends the
ingestion run as "failed" with a logged code instead of leaving it stuck
running. Replaces the uncommitted GitHubFailClosedReproTest scaffold,
which only dumped output and asserted toBeString() without proving any
behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Declared but never called; its docblock also claimed an unverified
"before" column. Residue from the F-006 fail-closed test authoring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Record ADR-27 with the failure-code taxonomy and the scope trade-offs,
add BRAIN-007 on external integrations failing closed, mark F-006 as
mitigated in the system audit, update the canonical state, drop the
delivered backlog item, and leave the handoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LuanTrindade95
LuanTrindade95 merged commit 25bbb3c into main Sep 20, 2026
4 checks passed
@LuanTrindade95
LuanTrindade95 deleted the fix/github-client-fail-closed branch September 20, 2026 13:22
LuanTrindade95 added a commit that referenced this pull request Sep 20, 2026
…sanitization

Renumbers the sanitization ADR to ADR-28 because main took ADR-26 and
ADR-27, keeps both remediation phases in the canonical state, and drops
the two backlog items completed by this branch and by PR #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant