lljz66 commited on
Commit
696378b
·
verified ·
1 Parent(s): fbbf992

fix: only send GitHub token to github hosts; add basic logging

Browse files
Files changed (1) hide show
  1. src/devnet/core/http.py +41 -14
src/devnet/core/http.py CHANGED
@@ -6,6 +6,7 @@ place, inherited by every source.
6
  from __future__ import annotations
7
 
8
  import ipaddress
 
9
  import os
10
  import re
11
  import threading
@@ -17,6 +18,8 @@ import requests
17
 
18
  from .errors import AuthRequired, RateLimited, UpstreamError
19
 
 
 
20
  UA = "devnet/0.1"
21
  TIMEOUT = 20
22
  MAX_BYTES = 4 * 1024 * 1024
@@ -24,6 +27,14 @@ MAX_BYTES = 4 * 1024 * 1024
24
  _BLOCKED_HOSTS = {"localhost", "instance-data", "metadata.google.internal"}
25
  _BLOCKED_SUFFIXES = (".local", ".localhost", ".internal", ".lan", ".home.arpa")
26
 
 
 
 
 
 
 
 
 
27
  # ponytail: in-process TTL cache. Ceiling: one container, lost on restart, nothing
28
  # shared between replicas. Upgrade: Redis when a second replica exists.
29
  _cache: dict[str, tuple[float, Any]] = {}
@@ -70,18 +81,25 @@ def strip_html(text: str | None) -> str | None:
70
  return " ".join(_TAG_RE.sub(" ", text).split()) or None
71
 
72
 
73
- def _headers(extra: dict | None = None) -> dict[str, str]:
 
 
 
 
 
74
  headers = {"User-Agent": UA, "Accept": "application/json"}
75
- token = os.environ.get("GITHUB_TOKEN")
76
- if token:
77
- headers["Authorization"] = f"Bearer {token}"
78
-
79
  if extra:
80
- headers.update(extra)
81
- # إذا تم تمرير Authorization كـ None في extra، يتم حذف الترويسة لمنع تسريب التوكن
82
- if extra.get("Authorization") is None and "Authorization" in headers:
83
- headers.pop("Authorization")
84
-
 
 
85
  return headers
86
 
87
 
@@ -96,22 +114,30 @@ def get(
96
  """Fetch JSON or text with a TTL cache and four-state error mapping."""
97
 
98
  def produce() -> Any:
 
99
  try:
100
- resp = requests.get(url, params=params, headers=_headers(headers), timeout=TIMEOUT)
 
 
 
 
 
101
  except requests.RequestException as exc:
 
102
  raise UpstreamError(f"{url}: {exc}") from exc
 
103
  if resp.status_code == 429:
 
104
  raise RateLimited(f"{url}: upstream rate limit")
105
  if resp.status_code in (401, 403):
106
  body = resp.text[:300].lower()
107
  if resp.headers.get("x-ratelimit-remaining") == "0" or "rate limit" in body:
 
108
  raise RateLimited(f"{url}: upstream rate limit")
109
  raise AuthRequired(f"{url}: HTTP {resp.status_code}")
110
  if resp.status_code >= 400:
111
- # Keep the upstream's own message - SO/HF return JSON error bodies that
112
- # explain why (bad key, missing filter, backoff), and the HTTP code alone
113
- # hides it.
114
  snippet = (resp.text or "").strip().replace("\n", " ")[:200]
 
115
  raise UpstreamError(f"{url}: HTTP {resp.status_code} {snippet}")
116
  if len(resp.content) > MAX_BYTES:
117
  raise UpstreamError(f"{url}: response over {MAX_BYTES} bytes")
@@ -120,6 +146,7 @@ def get(
120
  key = f"{url}|{params}|{headers}|{as_json}"
121
  hit = _cache.get(key)
122
  if hit and time.time() - hit[0] < ttl:
 
123
  return hit[1]
124
  value = produce()
125
  with _lock:
 
6
  from __future__ import annotations
7
 
8
  import ipaddress
9
+ import logging
10
  import os
11
  import re
12
  import threading
 
18
 
19
  from .errors import AuthRequired, RateLimited, UpstreamError
20
 
21
+ logger = logging.getLogger("devnet.http")
22
+
23
  UA = "devnet/0.1"
24
  TIMEOUT = 20
25
  MAX_BYTES = 4 * 1024 * 1024
 
27
  _BLOCKED_HOSTS = {"localhost", "instance-data", "metadata.google.internal"}
28
  _BLOCKED_SUFFIXES = (".local", ".localhost", ".internal", ".lan", ".home.arpa")
29
 
30
+ # Only these hostnames ever receive the GitHub bearer token. Every other upstream
31
+ # gets a clean request with no credentials attached. Before this guard existed the
32
+ # token was attached to every outbound request - pypi, npm, crates, HN, every RSS
33
+ # target, SO, HF. That was a live secret leak.
34
+ _GITHUB_HOSTS = frozenset(
35
+ {"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com"}
36
+ )
37
+
38
  # ponytail: in-process TTL cache. Ceiling: one container, lost on restart, nothing
39
  # shared between replicas. Upgrade: Redis when a second replica exists.
40
  _cache: dict[str, tuple[float, Any]] = {}
 
81
  return " ".join(_TAG_RE.sub(" ", text).split()) or None
82
 
83
 
84
+ def _is_github(url: str) -> bool:
85
+ host = (urlsplit(url).hostname or "").lower()
86
+ return host in _GITHUB_HOSTS or host.endswith(".githubusercontent.com") or host.endswith(".github.com")
87
+
88
+
89
+ def _headers(extra: dict | None = None, *, github: bool = False) -> dict[str, str]:
90
  headers = {"User-Agent": UA, "Accept": "application/json"}
91
+ if github:
92
+ token = os.environ.get("GITHUB_TOKEN")
93
+ if token:
94
+ headers["Authorization"] = f"Bearer {token}"
95
  if extra:
96
+ # None values mean "strip this header" so callers can suppress one
97
+ # without knowing which token a previous layer set.
98
+ for k, v in extra.items():
99
+ if v is None:
100
+ headers.pop(k, None)
101
+ else:
102
+ headers[k] = v
103
  return headers
104
 
105
 
 
114
  """Fetch JSON or text with a TTL cache and four-state error mapping."""
115
 
116
  def produce() -> Any:
117
+ url_clean = url.split("?", 1)[0] # never log query strings - may carry keys
118
  try:
119
+ resp = requests.get(
120
+ url,
121
+ params=params,
122
+ headers=_headers(headers, github=_is_github(url)),
123
+ timeout=TIMEOUT,
124
+ )
125
  except requests.RequestException as exc:
126
+ logger.warning("request failed: %s %s", type(exc).__name__, url_clean)
127
  raise UpstreamError(f"{url}: {exc}") from exc
128
+ logger.debug("%s %s", resp.status_code, url_clean)
129
  if resp.status_code == 429:
130
+ logger.info("rate limited: %s", url_clean)
131
  raise RateLimited(f"{url}: upstream rate limit")
132
  if resp.status_code in (401, 403):
133
  body = resp.text[:300].lower()
134
  if resp.headers.get("x-ratelimit-remaining") == "0" or "rate limit" in body:
135
+ logger.info("rate limited: %s", url_clean)
136
  raise RateLimited(f"{url}: upstream rate limit")
137
  raise AuthRequired(f"{url}: HTTP {resp.status_code}")
138
  if resp.status_code >= 400:
 
 
 
139
  snippet = (resp.text or "").strip().replace("\n", " ")[:200]
140
+ logger.warning("upstream %s: %s %s", resp.status_code, url_clean, snippet)
141
  raise UpstreamError(f"{url}: HTTP {resp.status_code} {snippet}")
142
  if len(resp.content) > MAX_BYTES:
143
  raise UpstreamError(f"{url}: response over {MAX_BYTES} bytes")
 
146
  key = f"{url}|{params}|{headers}|{as_json}"
147
  hit = _cache.get(key)
148
  if hit and time.time() - hit[0] < ttl:
149
+ logger.debug("cache hit: %s", url.split("?", 1)[0])
150
  return hit[1]
151
  value = produce()
152
  with _lock: