fix/reliability-safety-batch: close tier-1 reliability/safety cluster on the path to v2.0#32
Merged
vedanthvdev merged 1 commit intomasterfrom Apr 22, 2026
Merged
Conversation
… on the path to v2.0
This batch closes the tier-1 reliability/safety findings from the most recent
multi-reviewer sweep — the class of issues that don't silently drop tests but
weaken the merge-gate contract and the hardening work that shipped in v1.9.19.
Four production fixes plus the review-found follow-ups applied in this PR.
1. SourceFileScanner now rejects file-level symlinks during the walk. v1.9.19
hardened the directory leg via stayInsideProjectRoot + toRealPath, which
closes `src/main/java -> /` planted by an attacker MR. The per-file leg of
the same attack — committing `src/main/java/com/x/Leak.java -> /etc/passwd`
— slid past that guard because walkFileTree still called visitFile on the
symlink and the visitor didn't check attrs.isSymbolicLink(). The two
relevant visitors (collectJavaFiles, walkFqnsUnder) now skip any symlink
unconditionally. The policy is deliberately stricter than the directory
leg: we don't do per-file toRealPath on every .java entry because the
syscall cost on the hot path isn't worth the usability gain, and the
javadoc documents that intra-repo file symlinks are not traversed.
Regression: SourceFileScannerTest#rejectsFileLevelSymlinkEscapingProjectRoot
and #scansTestFqnsIgnoresFileLevelSymlinks both fail when the guard is
reverted, covering both halves of discovery so they can never diverge on
what counts as a valid source file.
2. Per-subtree IOExceptions no longer truncate the whole walk. The default
SimpleFileVisitor.visitFileFailed rethrows, and the scanner's outer catch
swallowed the remaining walk silently — a single unreadable nested module
(permission denied, file-deleted-under-us race, filesystem loop) took
every readable sibling with it. All three walkers (collectJavaFiles,
walkFqnsUnder, findAllMatchingDirs) now override visitFileFailed to log
the unreadable entry at WARN via LogSanitizer and continue. Default-visible
surfacing is the load-bearing change: operators can now see when a scan
ran incomplete where previously they'd silently under-select tests and
have no way to correlate it with CI config drift.
3. AffectedTestsConfig.Builder#isAcceptableBaseRef now rejects control
characters at the entry gate. The REV_EXPR pattern used `@\{[^}]+\}` for
the reflog segment — `[^}]` happily matched newline, ESC, CSI, DEL. A
baseRef sourced from an attacker-controlled CI env var like
`master@{1\n\u001b[2JAffected Tests: SELECTED\u001b[m}` previously passed
validation and flowed unsanitised into log.info("Base ref: {}", …) in
AffectedTestsEngine and into three IllegalStateException messages in
GitChangeDetector — an attacker who could poison CI_BASE_REF could forge
plugin-branded status lines and defeat grep-based merge gates. The fix is
a single blanket containsControlChars check before any downstream matcher
runs (C0, DEL, and C1 all rejected), closing every regex path at once
without depending on getting each hand-crafted character class right.
Regression: AffectedTestsConfigTest#baseRefRejectsControlCharsInReflogSuffix
covers newline-in-reflog, bare ESC/DEL, and CRLF anywhere.
4. LogSanitizer coverage extended to every remaining diff-sourced log site.
v1.9.19 introduced the sanitizer and applied it at four sites; the review
found ~28 additional sites passing raw filenames, FQNs, or exception
messages straight to the logger — most at DEBUG, several at default-
visible WARN, and two inside IllegalStateException messages that Gradle
renders verbatim into the build log. All remaining sites now route through
LogSanitizer.sanitize: JavaParsers.parseOrWarn (WARN+DEBUG),
ImplementationStrategy.supertypesOf (WARN+DEBUG trace), PathToClassMapper's
eight classification DEBUG lines, UsageStrategy's seven tier-match DEBUG
lines, NamingConventionStrategy's match DEBUG line, GitChangeDetector's
per-file DEBUG line plus the IOException/GitAPIException wrappers it
throws (JGit exception messages can carry attacker-influenced pack-file
paths and ref segments), AffectedTestsEngine's FQN-has-no-file DEBUG line,
SourceFileScanner.findAllMatchingDirs's trailing DEBUG summary, and the
value echoed inside the IllegalArgumentException thrown by
AffectedTestsConfig.Builder#baseRef when validation fails (otherwise the
reject branch would reopen the same surface containsControlChars closes
on the accept branch). Even DEBUG sites are sanitised because operators
who bump the log level to chase a false-positive selection are exactly
the audience an attacker would target — the log-forgery contract has to
hold at every visibility level. Regression:
baseRefValidationErrorDoesNotEchoRawControlChars pins the newly-sanitised
exception-message path; LogSanitizerTest covers the escape table.
Two related findings are intentionally deferred to batch 6 (v1.9.22) so each
PR stays scoped and independently bisectable: #9 (ProjectIndex parse failures
should signal discovery-incomplete to the engine so the safety net can
escalate explicitly) and #11 (configurable timeout on the nested ./gradlew
invocation so a hung child test cannot pin a CI worker indefinitely).
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
Fifth of seven PRs on the path to v2.0. v1.9.20 closed the tier-1 correctness cluster (nine silent-drop bugs); this PR (v1.9.21) closes the tier-1 reliability and safety cluster from the same multi-reviewer sweep — issues that don't silently drop tests but weaken the merge-gate contract or the hardening work that shipped in v1.9.19.
Four production fixes, plus the review-found follow-ups applied before this PR opened.
The four fixes
1. File-level symlink rejection in
SourceFileScannerv1.9.19 hardened the directory leg via
stayInsideProjectRoot+toRealPath, which closessrc/main/java -> /planted by an attacker MR. The per-file leg of the same attack — committingsrc/main/java/com/x/Leak.java -> /etc/passwd— slid past becauseFiles.walkFileTreestill calledvisitFileon the symlink and the visitor didn't checkattrs.isSymbolicLink(). The file would then be parsed byJavaParser, with its contents potentially surfaced in--explainsamples or parse-failure WARN output.Both file-walk visitors in the scanner (
collectJavaFiles,walkFqnsUnder) now skip any entry flagged as a symlink unconditionally. This is deliberately stricter than the directory leg: a per-filetoRealPathon every.javaentry would add a syscall to the hot path for negligible benefit, and the usability cost of dropping intra-repo file symlinks is low (Git doesn't preserve hard links across clone either, so tooling that relies on canonical-reference aliasing already has to handle the regular-file case). The scanner javadoc documents the asymmetry so a future contributor isn't surprised.Regression:
SourceFileScannerTest#rejectsFileLevelSymlinkEscapingProjectRootand#scansTestFqnsIgnoresFileLevelSymlinksboth fail when the production guard is reverted, covering both thecollectTestFiles/collectSourceFilespath and thescanTestFqnspath so the two halves of discovery can't diverge on what counts as a valid source file.2. Per-subtree
IOExceptionno longer truncates the whole walkThe default
SimpleFileVisitor.visitFileFailedrethrows, and the scanner's outerIOExceptioncatch swallowed the remaining walk silently. A single unreadable nested module (permission denied, file-deleted-under-us race, filesystem loop) took every readable sibling with it — the operator got a generic "error searching for X" WARN and no way to know which subtree vanished or which tests were under-selected because of it.All three walkers (
collectJavaFiles,walkFqnsUnder,findAllMatchingDirs) now overridevisitFileFailedto log the unreadable entry at WARN viaLogSanitizerand continue. Default-visible surfacing is the load-bearing change: operators can now see when a scan ran incomplete, where previously they'd silently under-select tests and have no way to correlate it with CI config drift.No dedicated regression test — inducing a portable per-entry
IOExceptionrequires either root-evadablechmod 000semantics (fails on CI runners running as root) or heavyweight filesystem mocking. The fix is evident in the diff and backed by the 11 existing walk tests inSourceFileScannerTest.3.
AffectedTestsConfig.Builder#isAcceptableBaseRefrejects control characters at the entry gateThe rev-expression pattern used
@\{[^}]+\}for the reflog segment —[^}]happily matched newline, ESC, CSI, DEL. AbaseRefsourced from an attacker-controlled CI env var likepreviously passed validation and then flowed unsanitised into
log.info(\"Base ref: {}\", ...)inAffectedTestsEngineand into threeIllegalStateExceptionmessages inGitChangeDetector(which Gradle renders verbatim into the build log). An attacker who could poisonCI_BASE_REF(or any equivalent) could forge plugin-branded status lines into CI output and defeat grep-based merge gates.The fix is a single blanket
containsControlCharscheck before any of the downstream matchers run — C0 (0x00..0x1F), DEL (0x7F), and C1 (0x80..0x9F) are all rejected, closing every regex path at once without depending on getting each hand-crafted character class right.Regression:
AffectedTestsConfigTest#baseRefRejectsControlCharsInReflogSuffixcovers the newline-in-reflog shape, bare ESC/DEL, and CRLF anywhere in the input; each case fails when the entry-gate check is removed.4.
LogSanitizercoverage extended to every remaining diff-sourced log sitev1.9.19 introduced the sanitizer and applied it at four sites; the review found ~28 additional sites passing raw filenames, FQNs, or exception messages straight to the logger — most at DEBUG, several at default-visible WARN, and two inside
IllegalStateExceptionmessages that Gradle renders verbatim into the build log. An attacker-crafted filename or a poisonedJavaParserdiagnostic carrying\u001b[2J...\u001b[mcould still forge ops-visible log lines.All remaining sites now route through
LogSanitizer.sanitize:JavaParsers.parseOrWarn— WARN + DEBUGImplementationStrategy.supertypesOf— WARN + per-depth match trace (DEBUG)PathToClassMapper— eight classification DEBUG linesUsageStrategy— seven tier-match DEBUG linesNamingConventionStrategy— match DEBUG lineGitChangeDetector— per-file changed-entry DEBUG line + theIOException/GitAPIExceptionwrappers it throws (JGit exception messages carry pack-file paths and ref segments that are attacker-influenceable on an MR branch)AffectedTestsEngine— FQN-has-no-file DEBUG lineSourceFileScanner.findAllMatchingDirs— trailing DEBUG summary (brought into parity with its WARN path)AffectedTestsConfig.Builder#baseRef— the offending value echoed inside theIllegalArgumentExceptionthrown when validation fails (otherwise the reject branch would reopen the same surfacecontainsControlCharscloses on the accept branch)Even DEBUG sites are sanitised because operators who bump the log level to chase a false-positive selection are exactly the audience an attacker would target — the log-forgery contract has to hold at every visibility level, not just WARN/INFO.
Regression:
LogSanitizerTestcovers the escape table;baseRefValidationErrorDoesNotEchoRawControlCharspins the newly-sanitised exception-message path and fails on revert of theLogSanitizer.sanitizewrap.Review-applied follow-ups (before opening this PR)
A pre-merge
code-reviewerpass against the initial diff surfaced two tier-2 gaps and four tier-3 cleanups, all applied in this PR:IllegalArgumentExceptionthrown byBuilder#baseRefwas echoing the raw tainted input back into its message — same forgery surface, just the reject branch. Wrapped viaLogSanitizer.sanitize+ addedbaseRefValidationErrorDoesNotEchoRawControlChars.GitChangeDetector'sIOExceptionandGitAPIExceptioncatch-blocks concatenatede.getMessage()raw intoIllegalStateExceptionmessages. JGit's exception strings embed pack-file paths, ref names, and (onPackInvalidException/CorruptObjectException) raw object-database bytes — all attacker-influenceable. Both sites now wrape.getMessage()throughLogSanitizer.sanitize.SourceFileScannerjavadoc which over-claimed that files "should just exist as regular files." Now documents the directory-vs-file asymmetry and the cost/benefit rationale.findAllMatchingDirs's trailing DEBUG was inconsistent with the WARN five lines above it — now sanitised at parity.PathToClassMapperhas 8 sanitised sites,UsageStrategyhas 7; total across the batch is ~28 sites, not ~20 as originally stated).isAcceptableBaseRefjavadoc now explicitly notes theSHORT_SHAshort-circuit bypassesisValidRefNamebut is safe by construction (hex-only class excludes.and/).Deferred to batch 6 (v1.9.22)
Two related findings from the same review are intentionally deferred so each PR stays scoped and independently bisectable:
ProjectIndexparse failures should signal discovery-incomplete to the engine so the safety net can escalate toFULL_SUITEexplicitly. Observable behavioural change, needs its own entry../gradlewinvocation so a hung child test cannot pin a CI worker indefinitely. New config knob, needs design discussion.Test plan
./gradlew :affected-tests-core:check— greenbaseRefRejectsControlCharsInReflogSuffixfails on revert ofcontainsControlCharsrejectsFileLevelSymlinkEscapingProjectRoot+scansTestFqnsIgnoresFileLevelSymlinksfail on revert of the symlink guardbaseRefValidationErrorDoesNotEchoRawControlCharsfails on revert of theLogSanitizer.sanitizewrap in the IAE messagelog.(warn|info|debug)sites remain that embed diff-sourced input raw (manual sweep acrossaffected-tests-core/src/main/java)Path to v2.0