Compare commits
10 Commits
98479c97cf
...
issue-146
| Author | SHA1 | Date | |
|---|---|---|---|
| 430e61fdbd | |||
| b8aa63e7ba | |||
| d855064765 | |||
| 38bb01b4b4 | |||
| c96ebcc6e0 | |||
| 34ff4c5c17 | |||
| eb3770e18c | |||
| 77a7f667cb | |||
| 76b6493628 | |||
| 4dce8e4454 |
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **`validateDocmapPath`: add `EvalSymlinks` to close directory-symlink bypass** ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)): The previous implementation used `os.Lstat` which only avoids following the *final* path component. An intermediate directory symlink (e.g. `.review-bot/` committed as a symlink to a directory outside the repo) would pass the path-confinement check because the textual path appeared within the repo root. `filepath.EvalSymlinks` is now called first, resolving all symlink components before the `filepath.Rel` confinement check. In-repo symlinks whose resolved targets also reside within the repo root are now allowed; out-of-repo targets are rejected by the confinement check.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **`TestValidateDocmapPath_DirSymlinkBypass`**: verifies that a directory symlink inside the repo pointing outside cannot be used to bypass path confinement ([#150](https://gitea.weiker.me/rodin/review-bot/issues/150)).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137))
|
- **`doc-map` input** (`--doc-map` flag / `DOC_MAP_FILE` env var): Path to a YAML file mapping source path globs to governing design docs. review-bot intersects the map with changed PR paths and injects matching docs into the system prompt under a `## Design Documents` heading. ([#137](https://gitea.weiker.me/rodin/review-bot/issues/137))
|
||||||
|
|||||||
+52
-80
@@ -1,96 +1,68 @@
|
|||||||
# Dev Loop Status — 2026-05-15 09:37 UTC
|
# Dev Loop Status — 2026-05-15 11:58 UTC
|
||||||
|
|
||||||
## Summary
|
**Cron ID:** 5342ac81-4bbc-4e4c-a123-347a7788d50c
|
||||||
|
**Status:** ✅ HEALTHY — All tests passing, repo clean, ready for review & merge
|
||||||
|
|
||||||
- **Review-bot status:** ✅ MAIN BRANCH CURRENT & HEALTHY
|
## Quick Status
|
||||||
- **Coverage:** 77.1% (↑ from 70.4%) — healthy trajectory
|
|
||||||
- **Tests:** ✅ All passing
|
|
||||||
- **Active development tracks:**
|
|
||||||
- issue-143: fetch doc-map config from trusted VCS ref (ready for review)
|
|
||||||
- issue-146: reuse resolved doc-map path early (ready for review)
|
|
||||||
- issue-150: add EvalSymlinks to validateDocmapPath (ready for review)
|
|
||||||
- issue-154: refactor subprocess test helpers (ready for review)
|
|
||||||
|
|
||||||
---
|
- **Main branch:** Synced with origin/main (d855064)
|
||||||
|
- **Tests:** All passing ✅ (7 packages, 80+ test cases, race detector clean)
|
||||||
|
- **Test coverage:** **77.1%** overall
|
||||||
|
- budget: 92.0%
|
||||||
|
- review: 92.0%
|
||||||
|
- gitea: 85.2%
|
||||||
|
- github: 86.3%
|
||||||
|
- llm: 81.3%
|
||||||
|
- netutil: 85.7%
|
||||||
|
- cmd/review-bot: 54.3%
|
||||||
|
- **Working tree:** Clean (no uncommitted changes)
|
||||||
|
|
||||||
## Current State
|
## PR Status & Recommended Actions
|
||||||
|
|
||||||
### Main Branch
|
### Ready to Merge (3 PRs)
|
||||||
- **HEAD:** 1650343 (dev-loop cycle complete)
|
These have `ready` label, passing tests, and are self-reviewed. Recommend merging in order:
|
||||||
- **Status:** Clean, all tests passing, 77.1% coverage
|
|
||||||
- **Recent work:** Issue #130 fixes merged and verified complete
|
|
||||||
|
|
||||||
### Active Issue Branches (Ready for Review)
|
| Order | PR | Issue | Type | Size | Status |
|
||||||
|
|-------|----|----|------|------|--------|
|
||||||
|
| 1️⃣ | #155 | #154 | Refactor | M | ✅ Ready |
|
||||||
|
| 2️⃣ | #152 | #150 | Security | S | ✅ Ready |
|
||||||
|
| 3️⃣ | #151 | #146 | Test | S | ✅ Ready |
|
||||||
|
|
||||||
| Issue | Branch | Latest Commit | Status | Recommendation |
|
**Merge strategy:** Sequential. All currently passing; no blocking dependencies.
|
||||||
|-------|--------|---------------|--------|-----------------|
|
|
||||||
| #143 | origin/issue-143 | 3222c76 | Ready | Review feature + tests, consider for merge |
|
|
||||||
| #146 | origin/issue-146 | 9b64c60 | Ready | 2 new test cases + 1 fix, review completeness |
|
|
||||||
| #150 | origin/issue-150 | 4dce8e4 | Ready | Symlink validation, security-sensitive |
|
|
||||||
| #154 | origin/issue-154 | 2892dff | Ready | Refactor/cleanup, low-risk |
|
|
||||||
|
|
||||||
### Priority Assessment
|
### Awaiting AI-Review (2 PRs)
|
||||||
|
These have passing tests and self-review but need ai-review before marking ready:
|
||||||
|
|
||||||
**High Priority (Security/Risk):**
|
| PR | Issue | Type | Size | Notes |
|
||||||
- **#150** — EvalSymlinks for dir-symlink bypass (security fix)
|
|----|-------|------|------|-------|
|
||||||
- **#143** — Fetch doc-map from trusted VCS ref (trust boundary)
|
| #156 | #141 | Feature | M | `validate-docmap` subcommand |
|
||||||
|
| #153 | #143 | Feature | M | Fetch doc-map from VCS |
|
||||||
|
|
||||||
**Medium Priority (Feature):**
|
## Dev Loop Health
|
||||||
- **#146** — Path resolution optimization + tests
|
|
||||||
|
|
||||||
**Low Priority (Cleanup):**
|
| Metric | Status | Details |
|
||||||
- **#154** — Test refactoring
|
|--------|--------|---------|
|
||||||
|
| Main branch | ✅ Current | d855064 (2026-05-15 11:44 UTC) |
|
||||||
|
| Working tree | ✅ Clean | Ready for fetch/merge |
|
||||||
|
| Test suite | ✅ All pass | 7 packages, 80+ cases, ~2s runtime |
|
||||||
|
| Race detector | ✅ Clean | No race conditions detected |
|
||||||
|
| Coverage | ✅ 77.1% | Stable, no regressions |
|
||||||
|
| Remotes | ✅ Current | origin/main up-to-date |
|
||||||
|
|
||||||
---
|
## Recommendations
|
||||||
|
|
||||||
## Coverage Trends
|
1. **[IMMEDIATE] Merge 3 ready PRs** (#155 → #152 → #151)
|
||||||
|
- All provide foundational support for downstream features
|
||||||
|
- Safe to merge in sequence; no cross-PR dependencies
|
||||||
|
- Post-merge: dev-loop can run verification cycle
|
||||||
|
|
||||||
| Package | Current | Previous | Δ |
|
2. **Schedule AI-review for #156 and #153**
|
||||||
|---------|---------|----------|---|
|
- Both feature-complete and test-passing
|
||||||
| cmd/review-bot | TBD | 36.8% | ↑ |
|
- Waiting on code quality & design review
|
||||||
| budget | 91.8% | 91.8% | → |
|
|
||||||
| review | 91.5% | 91.5% | → |
|
|
||||||
| llm | 81.3% | 81.3% | → |
|
|
||||||
| **Total** | **77.1%** | **70.4%** | **↑6.7%** |
|
|
||||||
|
|
||||||
---
|
## Cycle Complete ✅
|
||||||
|
|
||||||
## Recommendations for Next Cycle
|
Next dev-loop cycle will:
|
||||||
|
- Verify post-merge state
|
||||||
### Immediate (This Dev-Loop)
|
- Update coverage tracking
|
||||||
1. **Checkout #150** — Review symlink fix, run security tests
|
- Monitor awaiting-review PRs for AI review status
|
||||||
2. **Checkout #143** — Review doc-map config fetching, validate error handling
|
|
||||||
3. **Decide merge order** — #150 or #143 first (dependency check)
|
|
||||||
4. **Run full integration** — After each merge to catch regressions
|
|
||||||
|
|
||||||
### Short-term (Next 1-2 cycles)
|
|
||||||
- Pull #146 into main if no blockers
|
|
||||||
- Merge #154 as low-risk cleanup
|
|
||||||
- Check for any test coverage gaps post-merge
|
|
||||||
- Monitor for regressions during next run
|
|
||||||
|
|
||||||
### Ongoing
|
|
||||||
- Continue tracking coverage trend (goal: >80%)
|
|
||||||
- Document new security fixes (issue #150)
|
|
||||||
- Review CONVENTIONS.md for consistency across new code
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worktrees
|
|
||||||
|
|
||||||
- All stale worktrees cleaned in previous cycle ✅
|
|
||||||
- Ready for new worktree setup if Aaron wants to work on next issue
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Dev-Loop Cycle
|
|
||||||
|
|
||||||
When dev-loop runs next (in ~4 hours):
|
|
||||||
1. ✅ Verify main still current
|
|
||||||
2. ✅ Re-run tests & coverage
|
|
||||||
3. ✅ Check if any PRs merged (update local branches)
|
|
||||||
4. ⚠️ Flag for human review if coverage drops or tests fail
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_Generated by dev-loop at 2026-05-15 09:37 UTC_
|
|
||||||
|
|||||||
+54
-56
@@ -880,16 +880,9 @@ func TestMainSubprocess_MissingFlags(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-name", "invalid name",
|
"--reviewer-name", "invalid name",
|
||||||
"--reviewer-token", "tok",
|
)
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
}
|
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -908,15 +901,15 @@ func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
args := baseSubprocessArgs()
|
||||||
"--gitea-url", "http://localhost",
|
// Replace the canonical --repo value with an invalid one.
|
||||||
"--repo", "invalidrepo",
|
for i, a := range args {
|
||||||
"--pr", "1",
|
if a == "--repo" && i+1 < len(args) {
|
||||||
"--reviewer-token", "tok",
|
args[i+1] = "invalidrepo"
|
||||||
"--llm-base-url", "http://localhost",
|
break
|
||||||
"--llm-api-key", "key",
|
}
|
||||||
"--llm-model", "model",
|
|
||||||
}
|
}
|
||||||
|
os.Args = args
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -935,15 +928,15 @@ func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
args := baseSubprocessArgs()
|
||||||
"--gitea-url", "http://localhost",
|
// Replace the canonical --pr value with a non-numeric string.
|
||||||
"--repo", "owner/repo",
|
for i, a := range args {
|
||||||
"--pr", "notanumber",
|
if a == "--pr" && i+1 < len(args) {
|
||||||
"--reviewer-token", "tok",
|
args[i+1] = "notanumber"
|
||||||
"--llm-base-url", "http://localhost",
|
break
|
||||||
"--llm-api-key", "key",
|
}
|
||||||
"--llm-model", "model",
|
|
||||||
}
|
}
|
||||||
|
os.Args = args
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -962,16 +955,9 @@ func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
"--llm-temperature", "5.0",
|
"--llm-temperature", "5.0",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -990,16 +976,9 @@ func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
|||||||
func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--gitea-url", "http://localhost",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "http://localhost",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "model",
|
|
||||||
"--llm-provider", "invalid-provider",
|
"--llm-provider", "invalid-provider",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1015,6 +994,25 @@ func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baseSubprocessArgs returns the base set of required flags for subprocess tests
|
||||||
|
// that need a fully-configured main() invocation. Each test appends its own
|
||||||
|
// test-specific flags on top of this base.
|
||||||
|
//
|
||||||
|
// Using a helper here means that when the set of required flags changes, only
|
||||||
|
// this function needs updating (instead of every test that passes all flags).
|
||||||
|
func baseSubprocessArgs() []string {
|
||||||
|
return []string{
|
||||||
|
"review-bot",
|
||||||
|
"--vcs-url", "https://gitea.example.com",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "https://api.example.com",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "gpt-4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS env vars that would
|
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS env vars that would
|
||||||
// interfere with testing missing-flag scenarios.
|
// interfere with testing missing-flag scenarios.
|
||||||
func cleanEnv() []string {
|
func cleanEnv() []string {
|
||||||
@@ -1389,13 +1387,14 @@ func TestFetchPatterns_MultipleRepos(t *testing.T) {
|
|||||||
func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
// Note: cannot use baseSubprocessArgs() here because --llm-base-url and
|
||||||
|
// --llm-api-key are intentionally omitted to test the missing-URL error.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--vcs-url", "https://gitea.example.com",
|
"--vcs-url", "https://gitea.example.com",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
"--llm-model", "gpt-4",
|
"--llm-model", "gpt-4",
|
||||||
// --llm-base-url and --llm-api-key intentionally omitted
|
|
||||||
}
|
}
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
@@ -1417,6 +1416,8 @@ func TestMainSubprocess_MissingLLMBaseURL(t *testing.T) {
|
|||||||
func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
// Note: cannot use baseSubprocessArgs() here because aicore provider
|
||||||
|
// does not require --llm-base-url / --llm-api-key; those are omitted.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--vcs-url", "https://gitea.example.com",
|
"--vcs-url", "https://gitea.example.com",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
@@ -1446,17 +1447,10 @@ func TestMainSubprocess_MissingAICoreCredentials(t *testing.T) {
|
|||||||
func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = append(baseSubprocessArgs(),
|
||||||
"--vcs-url", "https://gitea.example.com",
|
|
||||||
"--repo", "owner/repo",
|
|
||||||
"--pr", "1",
|
|
||||||
"--reviewer-token", "tok",
|
|
||||||
"--llm-base-url", "https://api.example.com",
|
|
||||||
"--llm-api-key", "key",
|
|
||||||
"--llm-model", "gpt-4",
|
|
||||||
"--persona", "security",
|
"--persona", "security",
|
||||||
"--persona-file", "custom.json",
|
"--persona-file", "custom.json",
|
||||||
}
|
)
|
||||||
main()
|
main()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1477,9 +1471,9 @@ func TestMainSubprocess_ConflictingPersonaFlags(t *testing.T) {
|
|||||||
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
func TestMainSubprocess_DeprecatedGiteaURLEnv(t *testing.T) {
|
||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
// Set required flags but omit --vcs-url; GITEA_URL should be picked up.
|
// Note: cannot use baseSubprocessArgs() here because --vcs-url must be
|
||||||
// The test will exit with an error after VCS init (no PR to fetch), but
|
// omitted — this test verifies that GITEA_URL env var is picked up as a
|
||||||
// the deprecation warning must appear.
|
// deprecated fallback when --vcs-url is absent.
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
// No --vcs-url: should fall back to GITEA_URL env var
|
// No --vcs-url: should fall back to GITEA_URL env var
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
@@ -1527,6 +1521,8 @@ func TestMainSubprocess_InvalidDocMapPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapPath")
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapPath")
|
||||||
|
// t.TempDir() is evaluated here in the outer process, producing a real directory
|
||||||
|
// that is passed as the GITHUB_WORKSPACE env var string to the subprocess.
|
||||||
cmd.Env = append(cleanEnv(),
|
cmd.Env = append(cleanEnv(),
|
||||||
"TEST_SUBPROCESS_MAIN=1",
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
"GITHUB_WORKSPACE="+t.TempDir(),
|
"GITHUB_WORKSPACE="+t.TempDir(),
|
||||||
@@ -1564,6 +1560,8 @@ func TestMainSubprocess_InvalidDocMapFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapFile")
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidDocMapFile")
|
||||||
|
// t.TempDir() is evaluated here in the outer process, producing a real directory
|
||||||
|
// that is passed as the GITHUB_WORKSPACE env var string to the subprocess.
|
||||||
cmd.Env = append(cleanEnv(),
|
cmd.Env = append(cleanEnv(),
|
||||||
"TEST_SUBPROCESS_MAIN=1",
|
"TEST_SUBPROCESS_MAIN=1",
|
||||||
"GITHUB_WORKSPACE="+t.TempDir(),
|
"GITHUB_WORKSPACE="+t.TempDir(),
|
||||||
|
|||||||
@@ -37,22 +37,33 @@ func validateDocmapPath(localPath, resolvedRoot string) error {
|
|||||||
return fmt.Errorf("cannot resolve path: %w", err)
|
return fmt.Errorf("cannot resolve path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lstat: do NOT follow symlinks. We want to inspect the entry itself.
|
// Resolve ALL symlink components, not just the final one.
|
||||||
fi, err := os.Lstat(absPath)
|
// os.Lstat only avoids following the *final* path component; intermediate
|
||||||
|
// directory symlinks are still followed. EvalSymlinks resolves every
|
||||||
|
// component, closing the directory-symlink bypass: a PR that commits
|
||||||
|
// .review-bot/ as a directory symlink pointing outside the repo would
|
||||||
|
// otherwise pass the filepath.Rel confinement check because the textual
|
||||||
|
// path is inside the root while the actual destination is not.
|
||||||
|
resolvedPath, err := filepath.EvalSymlinks(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot resolve path (symlink): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lstat the resolved path — at this point resolvedPath is symlink-free, so
|
||||||
|
// ModeSymlink will never be set. We keep the check as defense-in-depth.
|
||||||
|
fi, err := os.Lstat(resolvedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot stat file: %w", err)
|
return fmt.Errorf("cannot stat file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject symlinks outright — a symlink can point to /dev/zero or arbitrary
|
// Defense-in-depth: reject any remaining symlink indicator.
|
||||||
// host paths, bypassing both the confinement check and the size check.
|
|
||||||
if fi.Mode()&os.ModeSymlink != 0 {
|
if fi.Mode()&os.ModeSymlink != 0 {
|
||||||
return fmt.Errorf("symlinks are not allowed")
|
return fmt.Errorf("symlinks are not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confine to resolvedRoot: the cleaned absolute path must be a descendant
|
// Confine to resolvedRoot: use the fully-resolved path so that a directory
|
||||||
// of the repo root. This catches paths that escaped via "..", absolute
|
// symlink inside the repo cannot carry the path outside the root.
|
||||||
// paths that happen to be outside the root, etc.
|
rel, err := filepath.Rel(resolvedRoot, resolvedPath)
|
||||||
rel, err := filepath.Rel(resolvedRoot, absPath)
|
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
return fmt.Errorf("path must be within --repo-root")
|
return fmt.Errorf("path must be within --repo-root")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,23 +465,34 @@ mappings:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink
|
// TestValidateDocmapPath_Symlink verifies that --docmap pointing at a symlink
|
||||||
// is rejected before the file is read (prevents /dev/zero DOS or arbitrary
|
// whose resolved target is outside --repo-root is rejected (prevents reading
|
||||||
// host-file reads via PR-controlled symlinks).
|
// arbitrary host files via PR-controlled symlinks).
|
||||||
|
//
|
||||||
|
// Note: after the EvalSymlinks fix (issue #150), in-repo symlinks whose
|
||||||
|
// targets also reside within the repo root are now allowed — the confinement
|
||||||
|
// check is applied to the resolved path, not the symlink entry itself. The
|
||||||
|
// security invariant is: the resolved destination must be within the root.
|
||||||
func TestValidateDocmapPath_Symlink(t *testing.T) {
|
func TestValidateDocmapPath_Symlink(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
|
||||||
// Create a real docmap file to serve as the symlink target.
|
// Create a docmap file OUTSIDE the repo root to serve as the symlink
|
||||||
realDocmap := makeDocmapInDir(t, dir, `
|
// target. EvalSymlinks will resolve to this path, which the Rel check
|
||||||
mappings:
|
// must then reject.
|
||||||
- paths:
|
if err := os.MkdirAll(filepath.Join(outside, ".review-bot"), 0o755); err != nil {
|
||||||
- "lib/**"
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
docs:
|
}
|
||||||
- docs/foo.md
|
outsideDocmap := filepath.Join(outside, ".review-bot", "doc-map.yml")
|
||||||
`)
|
if err := os.WriteFile(outsideDocmap, []byte("mappings: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a symlink inside dir pointing to the real docmap.
|
// Create a symlink inside dir pointing to the file outside the repo.
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".review-bot"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
symlinkPath := filepath.Join(dir, ".review-bot", "doc-map-link.yml")
|
||||||
if err := os.Symlink(realDocmap, symlinkPath); err != nil {
|
if err := os.Symlink(outsideDocmap, symlinkPath); err != nil {
|
||||||
t.Fatalf("Symlink: %v", err)
|
t.Fatalf("Symlink: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +501,10 @@ mappings:
|
|||||||
[]string{"--docmap", symlinkPath, "--repo-root", dir},
|
[]string{"--docmap", symlinkPath, "--repo-root", dir},
|
||||||
)
|
)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("expected exit 2 for symlink docmap, got %d; stderr: %q", code, stderr)
|
t.Errorf("expected exit 2 for out-of-repo symlink docmap, got %d; stderr: %q", code, stderr)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr, "symlink") && !strings.Contains(stderr, "invalid") {
|
if !strings.Contains(stderr, "invalid") && !strings.Contains(stderr, "repo-root") {
|
||||||
t.Errorf("expected symlink rejection in stderr, got %q", stderr)
|
t.Errorf("expected confinement rejection in stderr, got %q", stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,3 +561,41 @@ func TestValidateDocmapPath_SizeLimit(t *testing.T) {
|
|||||||
t.Errorf("expected size limit error in stderr, got %q", stderr)
|
t.Errorf("expected size limit error in stderr, got %q", stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateDocmapPath_DirSymlinkBypass verifies that a directory-symlink
|
||||||
|
// inside the repo pointing outside cannot be used to read arbitrary host files.
|
||||||
|
//
|
||||||
|
// Attack vector: a PR commits .review-bot/ as a directory symlink targeting a
|
||||||
|
// directory outside the repo. The textual path of the docmap file is inside
|
||||||
|
// the repo root, so the old Rel-only check passed — but the actual file is
|
||||||
|
// outside. This is closed by calling EvalSymlinks on the full path before the
|
||||||
|
// confinement check.
|
||||||
|
func TestValidateDocmapPath_DirSymlinkBypass(t *testing.T) {
|
||||||
|
repoDir := t.TempDir()
|
||||||
|
outsideDir := t.TempDir()
|
||||||
|
|
||||||
|
// Secret file outside the repo.
|
||||||
|
secretPath := filepath.Join(outsideDir, "secret.yml")
|
||||||
|
if err := os.WriteFile(secretPath, []byte("mappings: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .review-bot/ as a directory symlink pointing outside the repo.
|
||||||
|
reviewBotDir := filepath.Join(repoDir, ".review-bot")
|
||||||
|
if err := os.Symlink(outsideDir, reviewBotDir); err != nil {
|
||||||
|
t.Skipf("cannot create dir symlink (platform may not support it): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textually inside repo — .review-bot/secret.yml — but resolves outside.
|
||||||
|
attackPath := filepath.Join(repoDir, ".review-bot", "secret.yml")
|
||||||
|
|
||||||
|
// Resolve repoDir to a symlink-free path, as runValidateDocmap does.
|
||||||
|
resolvedRoot, err := filepath.EvalSymlinks(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvalSymlinks(repoDir): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateDocmapPath(attackPath, resolvedRoot); err == nil {
|
||||||
|
t.Error("expected rejection of dir-symlink bypass, got nil error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ func TestIsBlockedIPForwarding(t *testing.T) {
|
|||||||
ip string
|
ip string
|
||||||
blocked bool
|
blocked bool
|
||||||
}{
|
}{
|
||||||
{"127.0.0.1", true}, // loopback — must be blocked
|
{"127.0.0.1", true}, // loopback — must be blocked
|
||||||
{"192.168.1.1", true}, // RFC1918 — must be blocked
|
{"192.168.1.1", true}, // RFC1918 — must be blocked
|
||||||
{"8.8.8.8", false}, // public — must not be blocked
|
{"8.8.8.8", false}, // public — must not be blocked
|
||||||
{"2001:4860:4860::8888", false}, // public IPv6 — must not be blocked
|
{"2001:4860:4860::8888", false}, // public IPv6 — must not be blocked
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
Reference in New Issue
Block a user