Skip to content

Commit a639af7

Browse files
authored
fix: make Link pagination parsing linear (#189)
1 parent 7aa3c52 commit a639af7

6 files changed

Lines changed: 84 additions & 12 deletions

File tree

src/cache.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ def put(
105105
"cached_at": time.time(),
106106
}
107107
try:
108-
path.write_text(json.dumps(entry)) # lgtm [py/clear-text-storage-sensitive-data] redacted above
108+
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
109+
path.write_text(json.dumps(entry))
109110
except OSError:
110111
pass # Cache write failure is non-fatal
111112

src/ghas_alerts.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import requests
2929

3030
from src.cache import ResponseCache
31+
from src.http_link_header import next_link_from_header
3132

3233
logger = logging.getLogger(__name__)
3334

@@ -85,11 +86,8 @@ def _paginate(
8586
if next_link:
8687
next_url = next_link
8788
else:
88-
import re
8989
link_header = resp.headers.get("Link", "")
90-
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
91-
if match:
92-
next_url = match.group(1)
90+
next_url = next_link_from_header(link_header)
9391

9492
return results
9593

src/github_client.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import base64
44
import logging
5-
import re
65
import sys
76
import time
87
from collections.abc import Callable
@@ -13,6 +12,7 @@
1312
from urllib3.util import Retry
1413

1514
from src.cache import ResponseCache
15+
from src.http_link_header import next_link_from_header
1616
from src.models import RepoMetadata
1717

1818
logger = logging.getLogger(__name__)
@@ -130,12 +130,10 @@ def _paginate(self, url: str, params: dict | None = None) -> list[dict]:
130130
# After the first request, params are baked into the next URL
131131
params = {}
132132

133-
# Parse next link — prefer response.links, fall back to regex
133+
# Parse next link — prefer requests' parser, then a linear fallback.
134134
next_link = response.links.get("next", {}).get("url")
135135
if not next_link:
136-
link_header = response.headers.get("Link", "")
137-
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
138-
next_link = match.group(1) if match else None
136+
next_link = next_link_from_header(response.headers.get("Link", ""))
139137
url = next_link # type: ignore[assignment]
140138

141139
return results

src/http_link_header.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
4+
def next_link_from_header(link_header: str) -> str | None:
5+
"""Return the first ``rel=next`` target from a GitHub Link header.
6+
7+
``requests.Response.links`` remains the primary parser. This bounded,
8+
linear fallback accepts GitHub's standard Link shape and fails closed for
9+
malformed entries.
10+
"""
11+
for raw_entry in link_header.split(","):
12+
entry = raw_entry.strip()
13+
if not entry.startswith("<"):
14+
continue
15+
target_end = entry.find(">")
16+
if target_end <= 1:
17+
continue
18+
target = entry[1:target_end]
19+
for raw_parameter in entry[target_end + 1 :].split(";"):
20+
name, separator, value = raw_parameter.partition("=")
21+
if (
22+
separator
23+
and name.strip().lower() == "rel"
24+
and value.strip().strip("\"'").lower() == "next"
25+
):
26+
return target
27+
return None

src/operator_control_center_artifacts.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ def write_control_center_artifacts(
9191
"json_path": str(weekly_json),
9292
"markdown_path": str(weekly_md),
9393
}
94-
json_path.write_text(json.dumps(payload, indent=2)) # codeql[py/clear-text-storage-sensitive-data] guarded above
95-
md_path.write_text(render_control_center_markdown(snapshot, username, generated_at.isoformat())) # codeql[py/clear-text-storage-sensitive-data] guarded above
94+
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
95+
json_path.write_text(json.dumps(payload, indent=2))
96+
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
97+
md_path.write_text(
98+
render_control_center_markdown(snapshot, username, generated_at.isoformat())
99+
)
96100
return json_path, md_path, weekly_json, weekly_md, payload

tests/test_github_client.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import requests
77

88
from src.github_client import REST_API_VERSION, GitHubClient
9+
from src.http_link_header import next_link_from_header
910

1011

1112
class _MemoryCache:
@@ -30,6 +31,49 @@ def test_rest_session_sets_explicit_api_version(self):
3031
client = GitHubClient()
3132
assert client.session.headers["X-GitHub-Api-Version"] == REST_API_VERSION
3233

34+
def test_pagination_uses_linear_link_header_fallback(self, monkeypatch):
35+
client = GitHubClient()
36+
37+
class _Page:
38+
def __init__(self, payload, link_header=""):
39+
self._payload = payload
40+
self.links = {}
41+
self.headers = {"Link": link_header}
42+
43+
def json(self):
44+
return self._payload
45+
46+
pages = iter(
47+
[
48+
_Page(
49+
[{"page": 1}],
50+
'<https://api.github.test/items?page=2>; rel="next", '
51+
'<https://api.github.test/items?page=2>; rel="last"',
52+
),
53+
_Page([{"page": 2}]),
54+
]
55+
)
56+
requested = []
57+
58+
def _request(url, params=None):
59+
requested.append((url, params))
60+
return next(pages)
61+
62+
monkeypatch.setattr(client, "_request", _request)
63+
64+
assert client._paginate(
65+
"https://api.github.test/items", {"per_page": 100}
66+
) == [{"page": 1}, {"page": 2}]
67+
assert requested == [
68+
("https://api.github.test/items", {"per_page": 100}),
69+
("https://api.github.test/items?page=2", {}),
70+
]
71+
72+
def test_link_header_fallback_fails_closed_on_large_malformed_input(self):
73+
malformed = "<" + "<=" * 100_000
74+
75+
assert next_link_from_header(malformed) is None
76+
3377
def test_repo_list_cache_key_includes_owner_private_scope(self, monkeypatch):
3478
cache = _MemoryCache()
3579
client = GitHubClient(token="secret", cache=cache)

0 commit comments

Comments
 (0)