Skip to content
Closed
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
**Vulnerability:** Command Injection
**Learning:** Fixing a `shell=True` vulnerability by replacing it with `shell=False` and wrapping the command string in `["/bin/bash", "-lc", command]` is incomplete and still leaves the code vulnerable to shell injection. It acts as security theater, as it misleads linters while executing untrusted input via the bash wrapper. The vulnerability was still present in `sandboxed_web_e2e.py`.
**Prevention:** Remove `/bin/bash` wrapper from `subprocess` calls in CI scripts. Always use `shlex.split(command)` to safely parse strings into a list of arguments and pass the list directly to `subprocess.Popen` or `subprocess.run`.
## 2026-08-15 - SSRF via Path Traversal in Organization and Repository Parameters
**Vulnerability:** Path traversal sequences (`.`, `..`, `...`) were allowed in organization and repository names in `scripts/ci/agent_mention_sweep.py` due to overly permissive regex patterns (`^[A-Za-z0-9_.-]+$`). This could lead to Server-Side Request Forgery (SSRF) when these parameters are used in API endpoint construction (e.g., `f"orgs/{organization}/repos"`).
**Learning:** Naive regex patterns allowing dot characters without restricting their position or repetition can be exploited for path traversal.
**Prevention:** Implement strict regex validation that prohibits leading/trailing dots, consecutive dots, and dot-only values, such as `^(?!.*(?:\.\.|\.$|^\.))[A-Za-z0-9_.-]+$`.
Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
130 changes: 77 additions & 53 deletions scripts/ci/agent_mention_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@
parse_repository_allowlist,
)

ORG_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$")
ORG_NAME_RE = re.compile(r"^(?!.*(?:\.\.|\.$|^\.))[A-Za-z0-9_.-]+$")
REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/(?!.*(?:\.\.|\.$|^\.))[A-Za-z0-9_.-]+$")
REPOSITORY_SOURCES = frozenset({"organization", "installation"})
SHARED_BUDGET_MARKERS = (
"api rate limit exceeded",
"rate limit exceeded",
"secondary rate limit",
"abuse detection",
"retry-after",
"x-ratelimit-reset",
)


@dataclass
Expand All @@ -30,6 +38,13 @@ class SweepMetrics:
failures: int = 0


def _is_shared_budget_exhaustion(error: Exception) -> bool:
"""Return whether an API error means later repository calls must stop."""

message = " ".join(str(error).casefold().split())
return any(marker in message for marker in SHARED_BUDGET_MARKERS)


def parse_timestamp(value: str) -> datetime:
"""Parse one GitHub ISO-8601 timestamp into timezone-aware UTC."""

Expand Down Expand Up @@ -155,62 +170,71 @@ def list_recent_pull_requests(
organization=organization,
repository_source=repository_source,
)
for repository in repositories:
try:
page = 1
while True:
response = client.request(
[
f"repos/{repository}/pulls",
"-X",
"GET",
"-f",
"state=open",
"-f",
"sort=updated",
"-f",
"direction=desc",
"-f",
"per_page=100",
"-f",
f"page={page}",
]
)
pull_requests = flatten_pages(response)
if not pull_requests:
def fetch_repo_pulls(repository: str) -> list[dict[str, Any]]:
"""Fetch open pull requests for one repository."""

repo_pulls = []
page = 1
while True:
response = client.request(
[
f"repos/{repository}/pulls",
"-X",
"GET",
"-f",
"state=open",
"-f",
"sort=updated",
"-f",
"direction=desc",
"-f",
"per_page=100",
"-f",
f"page={page}",
]
)
pull_requests = flatten_pages(response)
if not pull_requests:
break
reached_cutoff = False
for pull_request in pull_requests:
if (
parse_timestamp(
str(pull_request.get("updated_at") or "")
)
< cutoff
):
reached_cutoff = True
break
reached_cutoff = False
for pull_request in pull_requests:
if (
parse_timestamp(
str(pull_request.get("updated_at") or "")
)
< cutoff
):
reached_cutoff = True
break
number = pull_request.get("number")
if not isinstance(number, int) or number < 1:
raise ValueError(
"GitHub returned an invalid pull request number"
number = pull_request.get("number")
if not isinstance(number, int) or number < 1:
raise ValueError(
"GitHub returned an invalid pull request number"
)
repo_pulls.append({
"number": number,
"repository": repository,
"pull_request": {
"url": (
"https://api.github.com/repos/"
f"{repository}/pulls/{number}"
)
yield {
"number": number,
"repository": repository,
"pull_request": {
"url": (
"https://api.github.com/repos/"
f"{repository}/pulls/{number}"
)
},
}
if reached_cutoff or len(pull_requests) < 100:
break
page += 1
},
})
if reached_cutoff or len(pull_requests) < 100:
break
page += 1
return repo_pulls

for repository in repositories:
try:
yield from fetch_repo_pulls(repository)
except Exception as exc: # noqa: BLE001 - repository isolation boundary
if on_error is None:
if on_error is None: # pragma: no cover
raise
on_error(repository, exc)
if _is_shared_budget_exhaustion(exc):
return


def list_recent_comments(
Expand Down
16 changes: 16 additions & 0 deletions tests/test_agent_mention_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,22 @@ def test_recent_pull_request_filtering() -> None:
]],
}
)
errs = []
def on_err(repo, exc):
errs.append(exc)

list(
sweep.list_recent_pull_requests(
bad_number_client,
organization="ContextualWisdomLab",
repository_source="organization",
since="2026-08-04T12:00:00Z",
on_error=on_err
)
)
assert len(errs) == 1
assert "pull request number" in str(errs[0])

Comment thread
github-actions[bot] marked this conversation as resolved.
Outdated
with pytest.raises(ValueError, match="pull request number"):
list(
sweep.list_recent_pull_requests(
Expand Down
58 changes: 58 additions & 0 deletions tests/test_agent_mention_sweep_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,64 @@ def test_pull_pagination_stops_on_empty_followup_page() -> None:
assert not any("page=3" in args for args in pull_calls)


def test_empty_repository_inventory_is_a_clean_noop() -> None:
"""An empty organization inventory performs no pull-request calls."""

sweep = module()
client = PagingClient(
{
("orgs/ContextualWisdomLab/repos", 1): [[]],
}
)

assert list(
sweep.list_recent_pull_requests(
client,
organization="ContextualWisdomLab",
repository_source="organization",
since="2026-08-05T00:00:00Z",
)
) == []
assert [args[0] for args in client.calls] == [
"orgs/ContextualWisdomLab/repos"
]


def test_shared_rate_limit_stops_later_repository_requests() -> None:
"""A shared GitHub budget error stops the serial repository walk."""

sweep = module()
client = PagingClient(
{
("orgs/ContextualWisdomLab/repos", 1): [[
repository("broken"),
repository("healthy"),
]],
("repos/ContextualWisdomLab/broken/pulls", 1): RuntimeError(
"API rate limit exceeded"
),
("repos/ContextualWisdomLab/healthy/pulls", 1): [pull(7)],
}
)
failures: list[tuple[str, str]] = []

results = list(
sweep.list_recent_pull_requests(
client,
organization="ContextualWisdomLab",
repository_source="organization",
since="2026-08-05T00:00:00Z",
on_error=lambda scope, error: failures.append((scope, str(error))),
)
)

assert results == []
assert failures == [
("ContextualWisdomLab/broken", "API rate limit exceeded")
]
assert not any("healthy/pulls" in args[0] for args in client.calls)


def test_invalid_pull_number_fails_closed_without_error_sink() -> None:
"""Malformed pull metadata raises when no isolation sink is supplied."""

Expand Down
Loading