Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@
"cached_at": time.time(),
}
try:
path.write_text(json.dumps(entry)) # lgtm [py/clear-text-storage-sensitive-data] redacted above
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
path.write_text(json.dumps(entry))

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
This expression stores
sensitive data (secret)
as clear text.
This expression stores
sensitive data (secret)
as clear text.
Comment thread
saagpatel marked this conversation as resolved.
Dismissed
except OSError:
pass # Cache write failure is non-fatal

Expand Down
6 changes: 2 additions & 4 deletions src/ghas_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import requests

from src.cache import ResponseCache
from src.http_link_header import next_link_from_header

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -85,11 +86,8 @@ def _paginate(
if next_link:
next_url = next_link
else:
import re
link_header = resp.headers.get("Link", "")
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
if match:
next_url = match.group(1)
next_url = next_link_from_header(link_header)

return results

Expand Down
8 changes: 3 additions & 5 deletions src/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import base64
import logging
import re
import sys
import time
from collections.abc import Callable
Expand All @@ -13,6 +12,7 @@
from urllib3.util import Retry

from src.cache import ResponseCache
from src.http_link_header import next_link_from_header
from src.models import RepoMetadata

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

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

return results
Expand Down
27 changes: 27 additions & 0 deletions src/http_link_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations


def next_link_from_header(link_header: str) -> str | None:
"""Return the first ``rel=next`` target from a GitHub Link header.

``requests.Response.links`` remains the primary parser. This bounded,
linear fallback accepts GitHub's standard Link shape and fails closed for
malformed entries.
"""
for raw_entry in link_header.split(","):
entry = raw_entry.strip()
if not entry.startswith("<"):
continue
target_end = entry.find(">")
if target_end <= 1:
continue
target = entry[1:target_end]
for raw_parameter in entry[target_end + 1 :].split(";"):
name, separator, value = raw_parameter.partition("=")
if (
separator
and name.strip().lower() == "rel"
and value.strip().strip("\"'").lower() == "next"
):
return target
return None
8 changes: 6 additions & 2 deletions src/operator_control_center_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
"json_path": str(weekly_json),
"markdown_path": str(weekly_md),
}
json_path.write_text(json.dumps(payload, indent=2)) # codeql[py/clear-text-storage-sensitive-data] guarded above
md_path.write_text(render_control_center_markdown(snapshot, username, generated_at.isoformat())) # codeql[py/clear-text-storage-sensitive-data] guarded above
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
json_path.write_text(json.dumps(payload, indent=2))

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
This expression stores
sensitive data (secret)
as clear text.
This expression stores
sensitive data (secret)
as clear text.
Comment thread
saagpatel marked this conversation as resolved.
Dismissed
# lgtm[py/clear-text-storage-sensitive-data] Credential-shaped data is rejected above.
md_path.write_text(
render_control_center_markdown(snapshot, username, generated_at.isoformat())

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
Comment thread
saagpatel marked this conversation as resolved.
Dismissed
)
return json_path, md_path, weekly_json, weekly_md, payload
44 changes: 44 additions & 0 deletions tests/test_github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import requests

from src.github_client import REST_API_VERSION, GitHubClient
from src.http_link_header import next_link_from_header


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

def test_pagination_uses_linear_link_header_fallback(self, monkeypatch):
client = GitHubClient()

class _Page:
def __init__(self, payload, link_header=""):
self._payload = payload
self.links = {}
self.headers = {"Link": link_header}

def json(self):
return self._payload

pages = iter(
[
_Page(
[{"page": 1}],
'<https://api.github.test/items?page=2>; rel="next", '
'<https://api.github.test/items?page=2>; rel="last"',
),
_Page([{"page": 2}]),
]
)
requested = []

def _request(url, params=None):
requested.append((url, params))
return next(pages)

monkeypatch.setattr(client, "_request", _request)

assert client._paginate(
"https://api.github.test/items", {"per_page": 100}
) == [{"page": 1}, {"page": 2}]
assert requested == [
("https://api.github.test/items", {"per_page": 100}),
("https://api.github.test/items?page=2", {}),
]

def test_link_header_fallback_fails_closed_on_large_malformed_input(self):
malformed = "<" + "<=" * 100_000

assert next_link_from_header(malformed) is None

def test_repo_list_cache_key_includes_owner_private_scope(self, monkeypatch):
cache = _MemoryCache()
client = GitHubClient(token="secret", cache=cache)
Expand Down