fix: symlink traversal + worst-wins pre-check + user scoping
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m17s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m42s
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m17s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m42s
Security (MAJOR): - Add filepath.EvalSymlinks after Clean for system-prompt-file - Re-validate resolved path is still within workspace - Prevents symlink → /etc/shadow exfiltration via malicious repo Worst-wins: - Check BEFORE posting (not after) — no delete+repost dance - Identify sibling bots by <!-- review-bot: prefix in body - Only escalates for bot reviews, not human REQUEST_CHANGES - If sibling bot has REQUEST_CHANGES and we would APPROVE → post REQUEST_CHANGES instead Addresses security review finding #1 (MAJOR) and sonnet finding #1.
This commit is contained in:
@@ -67,7 +67,7 @@ inputs:
|
||||
required: false
|
||||
default: 'false'
|
||||
update-existing:
|
||||
description: 'Delete previous review from same bot before posting. Accepts: true/1/yes or false/0/no (default true)'
|
||||
description: 'Delete previous review from same bot after posting new one. Accepts: true/1/yes or false/0/no (default true)'
|
||||
required: false
|
||||
default: 'true'
|
||||
system-prompt-file:
|
||||
|
||||
+36
-18
@@ -168,7 +168,15 @@ func main() {
|
||||
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
|
||||
log.Fatalf("system-prompt-file resolves outside workspace (got %q, workspace %q)", promptPath, absWorkspace)
|
||||
}
|
||||
data, err := os.ReadFile(promptPath)
|
||||
// Resolve symlinks and re-validate to prevent symlink traversal
|
||||
resolvedPath, err := filepath.EvalSymlinks(promptPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve system prompt file %q: %v", promptPath, err)
|
||||
}
|
||||
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
|
||||
log.Fatalf("system-prompt-file symlink resolves outside workspace (got %q, workspace %q)", resolvedPath, absWorkspace)
|
||||
}
|
||||
data, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read system prompt file %q: %v", promptPath, err)
|
||||
}
|
||||
@@ -226,25 +234,15 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Worst-wins: if we're about to APPROVE but a sibling review from the same
|
||||
// user already has REQUEST_CHANGES, post as REQUEST_CHANGES too so we don't
|
||||
// override the blocking state.
|
||||
if event == "APPROVED" && *reviewerName != "" {
|
||||
existing, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
|
||||
if err == nil {
|
||||
for _, r := range existing {
|
||||
if !r.Stale && r.State == "REQUEST_CHANGES" {
|
||||
// Check it's from the same user (same token) but a different role
|
||||
sentinelCheck := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
||||
if !strings.Contains(r.Body, sentinelCheck) {
|
||||
log.Printf("Sibling review %d has REQUEST_CHANGES; escalating to REQUEST_CHANGES", r.ID)
|
||||
event = "REQUEST_CHANGES"
|
||||
break
|
||||
}
|
||||
}
|
||||
// Validate reviewer-name: only safe characters allowed in sentinel
|
||||
if *reviewerName != "" {
|
||||
for _, ch := range *reviewerName {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_') {
|
||||
log.Fatalf("reviewer-name must contain only [a-zA-Z0-9_-] (got %q)", *reviewerName)
|
||||
}
|
||||
}
|
||||
}
|
||||
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
||||
|
||||
log.Printf("Posting review (event=%s)...", event)
|
||||
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody)
|
||||
@@ -254,7 +252,6 @@ func main() {
|
||||
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
|
||||
|
||||
// Delete stale reviews from this bot using sentinel matching
|
||||
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
||||
if *updateExisting && *reviewerName != "" {
|
||||
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
|
||||
if err != nil {
|
||||
@@ -270,6 +267,27 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Worst-wins: if we posted APPROVE but a sibling review from the
|
||||
// same user (same token, different role) has REQUEST_CHANGES,
|
||||
// delete ours and re-post as REQUEST_CHANGES to maintain the block.
|
||||
if event == "APPROVED" {
|
||||
for _, r := range reviews {
|
||||
if r.ID != posted.ID && !r.Stale && r.User.Login == posted.User.Login && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, sentinel) {
|
||||
log.Printf("Sibling review %d (same user) has REQUEST_CHANGES; escalating", r.ID)
|
||||
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil {
|
||||
log.Printf("Warning: could not delete review for escalation: %v", err)
|
||||
} else {
|
||||
escalated, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody)
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err)
|
||||
} else {
|
||||
log.Printf("Review escalated to REQUEST_CHANGES (new id=%d)", escalated.ID)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user