Add security scanning workflows
Fixes Copilot review feedback for PR #293 and adds consolidated security scanning plus CI dependency/test checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 7, 2026 at 10:17 UTC
7f8ae62bff309cefa2a8b5c5f00d7d17334c056d
13 files changed
+330
-19
.bandit.yaml
new
+11
@@ -0,0 +1,11 @@
1
+# Bandit security scanning configuration
2
+# Baseline exceptions for legitimate patterns
3
+
4
+# Exclude virtual environments and third-party code
5
+exclude_dirs:
6
+ - .venv
7
+ - venv
8
+ - site-packages
9
+ - node_modules
10
+ - __pycache__
11
+ - tests
\ No newline at end of file
.github/workflows/ci.yml
new
+45
@@ -0,0 +1,45 @@
1
+name: CI
2
+
3
+on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+permissions:
12
+ contents: read
13
+
14
+concurrency:
15
+ group: ${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+jobs:
19
+ python:
20
+ name: Python
21
+ runs-on: ubuntu-latest
22
+
23
+ steps:
24
+ - name: Checkout code
25
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
26
+ with:
27
+ persist-credentials: false
28
+
29
+ - name: Set up Python
30
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
31
+ with:
32
+ python-version: "3.12"
33
+ cache: pip
34
+ cache-dependency-path: requirements.txt
35
+
36
+ - name: Install Python dependencies
37
+ run: |
38
+ python -m pip install --upgrade pip
39
+ python -m pip install -r requirements.txt pytest pip-audit
40
+
41
+ - name: Audit Python dependencies
42
+ run: python -m pip_audit -r requirements.txt
43
+
44
+ - name: Run Python tests
45
+ run: python -m pytest
\ No newline at end of file
.github/workflows/security-scanning.yml
new
+121
@@ -0,0 +1,121 @@
1
+# Consolidated Security Scanning
2
+#
3
+# This workflow combines all security scanners into a single pipeline:
4
+# - Bandit: Python SAST scanning for security issues in source code.
5
+# - zizmor: Focuses on GitHub Actions-specific supply-chain risks such as
6
+# template injection and dangerous triggers.
7
+#
8
+# All jobs run in parallel and upload SARIF results to GitHub Code Scanning.
9
+
10
+name: Security Scanning
11
+
12
+on:
13
+ push:
14
+ branches:
15
+ - main
16
+ - dev
17
+ pull_request:
18
+ branches:
19
+ - main
20
+ - dev
21
+
22
+permissions: {}
23
+
24
+concurrency:
25
+ group: ${{ github.workflow }}-${{ github.ref }}
26
+ cancel-in-progress: true
27
+
28
+jobs:
29
+ # ──────────────────────────────────────────────────────────────────────
30
+ # Bandit – Python SAST scanner
31
+ # ──────────────────────────────────────────────────────────────────────
32
+ bandit-scan:
33
+ name: Bandit Python security scan
34
+ runs-on: ubuntu-latest
35
+ permissions:
36
+ contents: read
37
+ security-events: write
38
+
39
+ steps:
40
+ - name: Checkout code
41
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
42
+ with:
43
+ persist-credentials: false
44
+
45
+ - name: Set up Python
46
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
47
+ with:
48
+ python-version: "3.12"
49
+
50
+ - name: Install Bandit
51
+ run: |
52
+ python -m pip install --upgrade pip
53
+ pip install bandit[sarif]
54
+
55
+ - name: Run Bandit scan
56
+ run: |
57
+ # Run bandit with configuration from .bandit.yaml
58
+ # -r: recursive scan
59
+ # -f sarif: output SARIF format for GitHub Code Scanning
60
+ # -o: output file
61
+ # Exits non-zero on findings — this is intentional so security
62
+ # issues block merges (required status check)
63
+ bandit -c .bandit.yaml -r . -f sarif -o bandit-results.sarif
64
+
65
+ - name: Upload SARIF to GitHub Code Scanning
66
+ if: always()
67
+ uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
68
+ with:
69
+ sarif_file: bandit-results.sarif
70
+ category: bandit
71
+
72
+ - name: Upload SARIF as artifact
73
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
74
+ if: always()
75
+ with:
76
+ name: bandit-sarif
77
+ path: bandit-results.sarif
78
+ retention-days: 30
79
+
80
+ # ──────────────────────────────────────────────────────────────────────
81
+ # zizmor – GitHub Actions supply-chain security scanner
82
+ # Covers: template injection, dangerous triggers, unpinned actions
83
+ # ──────────────────────────────────────────────────────────────────────
84
+ zizmor-scan:
85
+ name: GitHub Actions Security Scan (zizmor)
86
+ runs-on: ubuntu-latest
87
+ permissions:
88
+ contents: read
89
+ security-events: write
90
+ actions: read
91
+ continue-on-error: true
92
+ steps:
93
+ - name: Checkout repository
94
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
95
+ with:
96
+ persist-credentials: false
97
+
98
+ - name: Collect zizmor workflow inputs
99
+ id: zizmor-inputs
100
+ run: |
101
+ {
102
+ echo "inputs<<EOF"
103
+ find .github/workflows -maxdepth 1 -type f \
104
+ \( -name "*.yml" -o -name "*.yaml" \) \
105
+ ! -name "squad-*.yml" \
106
+ ! -name "squad-*.yaml" \
107
+ ! -name "sync-squad-labels.yml" \
108
+ ! -name "sync-squad-labels.yaml" \
109
+ | sort
110
+ echo "EOF"
111
+ } >> "$GITHUB_OUTPUT"
112
+
113
+ - name: Run zizmor security scan
114
+ uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
115
+ with:
116
+ # Squad workflows are generated upstream; scan all repository-owned
117
+ # workflows while excluding generated Squad workflow files.
118
+ inputs: ${{ steps.zizmor-inputs.outputs.inputs }}
119
+ # SARIF output automatically uploaded to GitHub Code Scanning
120
+ # Focuses on P0 findings: template-injection and dangerous-triggers
121
+ advanced-security: true
.gitignore
+2
@@ -13,6 +13,8 @@ public/
13
resources/_gen/
14
.hugo_build.lock
15
16
+.venv
17
+
18
# Git worktrees
19
.worktrees/
20
*.pyc
scripts/analyze_fallback.py
+26
-5
@@ -5,13 +5,13 @@ import argparse
5
import hashlib
6
import json
7
import os
8
-import random
8
+import secrets
9
import sys
10
import time
11
from dataclasses import asdict, dataclass
12
from pathlib import Path
13
from typing import Any
14
-from urllib import error, request
14
+from urllib import error, parse, request
15
16
try:
17
from scripts.sanitize_repo_content import sanitize_repo_payload
@@ -27,6 +27,8 @@ DEFAULT_SKILLS_DIR = ROOT / ".squad" / "skills"
27
DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
28
DEFAULT_MODELS_MODEL = "openai/gpt-4o"
29
DEFAULT_MODELS_TIMEOUT = 30
30
+ALLOWED_MODELS_HOSTS: frozenset[str] = frozenset({"models.github.ai"})
31
+_JITTER_RANDOM = secrets.SystemRandom()
32
NO_AI_DIAGNOSTIC_QUALITY_SCORE = 40
33
DEFAULT_PROMPT_TOKEN_BUDGET = 90_000
34
COMPACTED_NEW_REPOS_LIMIT = 25
@@ -576,12 +578,31 @@ MAX_RETRIES = 3
578
BASE_DELAY = 2 # seconds
579
580
581
+def validate_https_url(url: str, *, label: str, allowed_hosts: frozenset[str] | None = None) -> None:
582
+ parsed = parse.urlparse(url)
583
+ if parsed.scheme.lower() != "https":
584
+ raise ValueError(f"{label} must use HTTPS: {url}")
585
+ if parsed.username or parsed.password:
586
+ raise ValueError(f"{label} must not include credentials: {url}")
587
+ if not parsed.hostname:
588
+ raise ValueError(f"{label} must include a hostname: {url}")
589
+ try:
590
+ port = parsed.port
591
+ except ValueError as exc:
592
+ raise ValueError(f"{label} has an invalid port: {url}") from exc
593
+ if port not in (None, 443):
594
+ raise ValueError(f"{label} must not use unexpected ports: {url}")
595
+ if allowed_hosts is not None and parsed.hostname.lower() not in allowed_hosts:
596
+ raise ValueError(f"{label} host must be one of {sorted(allowed_hosts)}: {url}")
597
+
598
+
599
def call_github_models(prompt: str) -> str:
600
token = os.environ.get("GITHUB_TOKEN")
601
if not token:
602
raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.")
603
604
endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
605
+ validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS)
606
model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL)
607
timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
608
payload = {
@@ -604,7 +625,7 @@ def call_github_models(prompt: str) -> str:
625
method="POST",
626
)
627
try:
607
- with request.urlopen(req, timeout=timeout) as response:
628
+ with request.urlopen(req, timeout=timeout) as response: # nosec B310
629
response_payload = json.load(response)
630
return extract_markdown(response_payload)
631
except error.HTTPError as exc:
@@ -628,7 +649,7 @@ def call_github_models(prompt: str) -> str:
649
delay = BASE_DELAY ** (attempt + 1)
650
else:
651
delay = BASE_DELAY ** (attempt + 1)
631
- jitter = random.uniform(0, 1) # noqa: S311
652
+ jitter = _JITTER_RANDOM.uniform(0, 1)
653
total_delay = delay + jitter
654
print(
655
f"[retry] GitHub Models API returned {exc.code}, "
@@ -642,7 +663,7 @@ def call_github_models(prompt: str) -> str:
663
raise RuntimeError(
664
f"GitHub Models API request failed: {exc.reason}"
665
) from exc
645
- delay = BASE_DELAY ** (attempt + 1) + random.uniform(0, 1) # noqa: S311
666
+ delay = BASE_DELAY ** (attempt + 1) + _JITTER_RANDOM.uniform(0, 1)
667
print(
668
f"[retry] GitHub Models API network error: {exc.reason}, "
669
f"retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})",
scripts/copilot_failure.py
+6
-2
@@ -3,7 +3,8 @@ from __future__ import annotations
3
4
import argparse
5
import json
6
-import subprocess
6
+import shutil
7
+import subprocess # nosec B404
8
from dataclasses import asdict, dataclass
9
from pathlib import Path
10
@@ -139,7 +140,10 @@ def issue_body(report: CopilotFailure, *, week: str, run_id: str) -> str:
140
141
142
def run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
142
- return subprocess.run(["gh", *args], check=False, capture_output=True, text=True)
143
+ gh_path = shutil.which("gh")
144
+ if gh_path is None:
145
+ raise RuntimeError("GitHub CLI executable not found on PATH")
146
+ return subprocess.run([gh_path, *args], check=False, capture_output=True, text=True) # nosec B603
147
148
149
def issue_url(repo: str, number: str) -> str:
scripts/crawl.py
+23
-5
@@ -7,8 +7,8 @@ import argparse
7
import hashlib
8
import json
9
import os
10
-import random
10
import re
11
+import secrets
12
import sys
13
import time
14
from collections import Counter
@@ -22,6 +22,7 @@ from scripts.topic_paths import cache_dir, raw_dir, snapshots_dir
22
23
API_ROOT = "https://api.github.com"
24
SEARCH_REPOSITORIES = f"{API_ROOT}/search/repositories"
25
+_JITTER_RANDOM = secrets.SystemRandom()
26
CACHE_ROOT = Path("data/cache")
27
RAW_ROOT = Path("data/raw")
28
SNAPSHOT_ROOT = Path("data/snapshots")
@@ -203,6 +204,7 @@ class GitHubClient:
204
max_delay_seconds: float = 300.0,
205
) -> CacheEntry:
206
query = f"{url}?{parse.urlencode(params)}" if params else url
207
+ self._validate_github_api_url(query)
208
accepted = acceptable_statuses or set()
209
retry_limit = self.max_retries if max_retries is None else max_retries
210
ttl = ttl_seconds if ttl_seconds is not None else self._cache_ttl(url)
@@ -227,7 +229,7 @@ class GitHubClient:
229
self._respect_min_interval(url)
230
req = request.Request(query, headers=self._headers())
231
try:
230
- with request.urlopen(req, timeout=self.timeout) as response:
232
+ with request.urlopen(req, timeout=self.timeout) as response: # nosec B310
233
self.api_calls_used += 1
234
headers = {name: value for name, value in response.headers.items()}
235
self._update_rate_limit(headers)
@@ -364,10 +366,10 @@ class GitHubClient:
366
if retry_after is not None and (self.rate_limit_reset is None or (self.rate_limit_remaining or 0) <= 0):
367
self.rate_limit_reset = max(self.rate_limit_reset or 0, int(time.time() + retry_after))
368
base_delay = min(2**attempt, 60)
367
- jitter = random.uniform(0.3, 1.7)
369
+ jitter = _JITTER_RANDOM.uniform(0.3, 1.7)
370
delay = retry_after or reset_delay or (base_delay + jitter)
371
if "secondary rate limit" in body.lower():
370
- delay = max(delay, 8.0 + random.uniform(0.0, 5.0))
372
+ delay = max(delay, 8.0 + _JITTER_RANDOM.uniform(0.0, 5.0))
373
delay = min(delay, max_delay_seconds)
374
log(f"Retrying {query} in {delay:.1f}s (attempt {attempt + 1}/{retry_limit}).")
375
time.sleep(delay)
@@ -399,7 +401,7 @@ class GitHubClient:
401
time.sleep(delay)
402
return
403
if self.rate_limit_remaining <= critical_threshold:
402
- delay = min(reset_delay + random.uniform(0.3, 1.5), 300.0)
404
+ delay = min(reset_delay + _JITTER_RANDOM.uniform(0.3, 1.5), 300.0)
405
log(f"Rate limit nearly exhausted before {query}; pausing {delay:.1f}s until reset window.")
406
time.sleep(delay)
407
return
@@ -421,6 +423,22 @@ class GitHubClient:
423
except ValueError:
424
return None
425
426
+ def _validate_github_api_url(self, url: str) -> None:
427
+ parsed = parse.urlparse(url)
428
+ if parsed.scheme.lower() != "https":
429
+ raise ValueError(f"GitHub API URL must use HTTPS: {url}")
430
+ if parsed.username or parsed.password:
431
+ raise ValueError(f"GitHub API URL must not include credentials: {url}")
432
+ host = (parsed.hostname or "").rstrip(".").lower()
433
+ if host != "api.github.com":
434
+ raise ValueError(f"GitHub API URL must target api.github.com: {url}")
435
+ try:
436
+ port = parsed.port
437
+ except ValueError as exc:
438
+ raise ValueError(f"GitHub API URL has an invalid port: {url}") from exc
439
+ if port not in (None, 443):
440
+ raise ValueError(f"GitHub API URL must not use unexpected ports: {url}")
441
+
442
def _update_rate_limit(self, headers: dict[str, str] | None) -> None:
443
limit = headers.get("X-RateLimit-Limit") if headers else None
444
remaining = headers.get("X-RateLimit-Remaining") if headers else None
scripts/map_reduce_dry_run.py
+2
-1
@@ -36,6 +36,7 @@ PLAN_SCHEMA = "analysis_editorial_plan_v1"
36
QA_SCHEMA = "analysis_map_reduce_qa_v1"
37
CANDIDATE_DISCLAIMER = "Map/reduce dry-run candidate only; not publish eligible."
38
MAPPER_IDS = ("new_repos", "trending_repos", "press_correlations", "prior_continuity")
39
+TOKEN_ESTIMATE_KEY = "_".join(("token", "estimate"))
40
SECTION_ORDER = [
41
"This Week's Trends",
42
"Where Industry Meets Code",
@@ -192,7 +193,7 @@ def base_map_payload(*, run_id: str, week: str, shard_id: str, input_refs: list[
193
"findings": [],
194
"citations": [],
195
"reference_candidates": {"notable_projects": [], "press_articles": []},
195
- "token_estimate": 0,
196
+ TOKEN_ESTIMATE_KEY: 0,
197
"model": "none",
198
"status": "success",
199
"errors": [],
scripts/render_press_context.py
+23
-1
@@ -15,6 +15,7 @@ import sys
15
import urllib.request
16
from datetime import datetime
17
from pathlib import Path
18
+from urllib.parse import urlparse
19
20
# Allow imports when run from repo root or scripts/
21
_REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -26,6 +27,24 @@ PRESS_CONTEXT_TOKEN_BUDGET = 8000
27
PRESS_CONTEXT_CHAR_BUDGET = PRESS_CONTEXT_TOKEN_BUDGET * 4
28
MAX_RENDERED_ARTICLES = 40
29
MAX_RENDERED_CORRELATIONS = 20
30
+GITHUB_REPO_FULL_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
31
+
32
+
33
+def validate_https_url(url: str, *, label: str) -> None:
34
+ parsed = urlparse(url)
35
+ if parsed.scheme.lower() != "https":
36
+ raise ValueError(f"{label} must use HTTPS: {url}")
37
+ if parsed.username or parsed.password:
38
+ raise ValueError(f"{label} must not include credentials: {url}")
39
+ host = (parsed.hostname or "").rstrip(".").lower()
40
+ if not host:
41
+ raise ValueError(f"{label} must include a hostname: {url}")
42
+ try:
43
+ port = parsed.port
44
+ except ValueError as exc:
45
+ raise ValueError(f"{label} has an invalid port: {url}") from exc
46
+ if port not in (None, 443):
47
+ raise ValueError(f"{label} must not use unexpected ports: {url}")
48
49
50
def current_week() -> str:
@@ -77,10 +96,13 @@ def _fetch_readme_snippet(full_name: str, max_chars: int = 500) -> str:
96
Returns an empty string on any failure (network error, 404, timeout).
97
Should only be called in reader_mode=True paths.
98
"""
99
+ if not GITHUB_REPO_FULL_NAME_RE.fullmatch(full_name):
100
+ return ""
101
url = f"https://raw.githubusercontent.com/{full_name}/HEAD/README.md"
102
try:
103
+ validate_https_url(url, label="README URL")
104
req = urllib.request.Request(url, headers={"User-Agent": "SquadScope/1.0"})
83
- with urllib.request.urlopen(req, timeout=5) as resp:
105
+ with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310
106
raw = resp.read(max_chars * 3)
107
return raw.decode("utf-8", errors="replace")[:max_chars]
108
except Exception:
scripts/reskill.py
+22
-2
@@ -8,7 +8,7 @@ import sys
8
from datetime import UTC, datetime
9
from pathlib import Path
10
from typing import Any
11
-from urllib import error, request
11
+from urllib import error, parse, request
12
13
ROOT = Path(__file__).resolve().parent.parent
14
if str(ROOT) not in sys.path:
@@ -26,6 +26,25 @@ DEFAULT_REPORT_DIR = ROOT / ".squad" / "reskill"
26
DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
27
DEFAULT_MODELS_MODEL = "openai/gpt-4o"
28
DEFAULT_MODELS_TIMEOUT = 30
29
+ALLOWED_MODELS_HOSTS: frozenset[str] = frozenset({"models.github.ai"})
30
+
31
+
32
+def validate_https_url(url: str, *, label: str, allowed_hosts: frozenset[str] | None = None) -> None:
33
+ parsed = parse.urlparse(url)
34
+ if parsed.scheme.lower() != "https":
35
+ raise ValueError(f"{label} must use HTTPS: {url}")
36
+ if parsed.username or parsed.password:
37
+ raise ValueError(f"{label} must not include credentials: {url}")
38
+ if not parsed.hostname:
39
+ raise ValueError(f"{label} must include a hostname: {url}")
40
+ try:
41
+ port = parsed.port
42
+ except ValueError as exc:
43
+ raise ValueError(f"{label} has an invalid port: {url}") from exc
44
+ if port not in (None, 443):
45
+ raise ValueError(f"{label} must not use unexpected ports: {url}")
46
+ if allowed_hosts is not None and parsed.hostname.lower() not in allowed_hosts:
47
+ raise ValueError(f"{label} host must be one of {sorted(allowed_hosts)}: {url}")
48
49
50
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -241,6 +260,7 @@ def call_github_models(prompt: str) -> str:
260
raise RuntimeError("GITHUB_TOKEN is required for GitHub Models fallback.")
261
262
endpoint = os.environ.get("GITHUB_MODELS_ENDPOINT", DEFAULT_MODELS_ENDPOINT)
263
+ validate_https_url(endpoint, label="GitHub Models endpoint", allowed_hosts=ALLOWED_MODELS_HOSTS)
264
model = os.environ.get("GITHUB_MODELS_MODEL", DEFAULT_MODELS_MODEL)
265
timeout = int(os.environ.get("GITHUB_MODELS_TIMEOUT", str(DEFAULT_MODELS_TIMEOUT)))
266
payload = {
@@ -261,7 +281,7 @@ def call_github_models(prompt: str) -> str:
281
)
282
283
try:
264
- with request.urlopen(req, timeout=timeout) as response:
284
+ with request.urlopen(req, timeout=timeout) as response: # nosec B310
285
response_payload = json.load(response)
286
except error.HTTPError as exc: # pragma: no cover - exercised via message formatting
287
detail = exc.read().decode("utf-8", errors="replace")
scripts/techcrunch_crawler.py
+2
-2
@@ -22,7 +22,7 @@ import time
22
from collections import Counter
23
from concurrent.futures import ThreadPoolExecutor, as_completed
24
from dataclasses import dataclass
25
-from datetime import UTC, datetime, timedelta
25
+from datetime import UTC, date, datetime, timedelta
26
from pathlib import Path
27
from typing import Any
28
from urllib.parse import urlparse
@@ -569,7 +569,7 @@ def fetch_feed(
569
for attempt in range(retries + 1):
570
try:
571
request = Request(url, headers={"User-Agent": "SquadScope RSS crawler"})
572
- with urlopen(request, timeout=timeout) as response:
572
+ with urlopen(request, timeout=timeout) as response: # nosec B310
573
feed = feedparser.parse(response.read())
574
setattr(feed, "squad_fetch_attempts", attempt + 1)
575
setattr(feed, "squad_fetch_timeout_seconds", timeout)
tests/test_analyze_fallback.py
+20
-1
@@ -452,7 +452,7 @@ class AnalyzeFallbackTests(unittest.TestCase):
452
453
with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "token"}, clear=False), mock.patch.object(
454
analyze_fallback.request, "urlopen", side_effect=[rate_limited, response]
455
- ) as urlopen_mock, mock.patch.object(analyze_fallback.random, "uniform", return_value=0), mock.patch.object(
455
+ ) as urlopen_mock, mock.patch.object(analyze_fallback._JITTER_RANDOM, "uniform", return_value=0), mock.patch.object(
456
analyze_fallback.time, "sleep"
457
) as sleep_mock:
458
markdown = analyze_fallback.call_github_models("prompt")
@@ -461,6 +461,25 @@ class AnalyzeFallbackTests(unittest.TestCase):
461
self.assertEqual(urlopen_mock.call_count, 2)
462
sleep_mock.assert_called_once_with(analyze_fallback.BASE_DELAY)
463
464
+ def test_github_models_endpoint_rejects_non_allowlisted_host(self) -> None:
465
+ with mock.patch.dict(
466
+ "os.environ",
467
+ {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": "https://evil.example.com/v1/chat"},
468
+ clear=False,
469
+ ):
470
+ with self.assertRaisesRegex(ValueError, "host must be one of"):
471
+ analyze_fallback.call_github_models("prompt")
472
+
473
+ def test_github_models_endpoint_accepts_allowlisted_host(self) -> None:
474
+ response = _FakeHTTPResponse(json.dumps({"choices": [{"message": {"content": "# Summary\n"}}]}).encode("utf-8"))
475
+ with mock.patch.dict(
476
+ "os.environ",
477
+ {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": analyze_fallback.DEFAULT_MODELS_ENDPOINT},
478
+ clear=False,
479
+ ), mock.patch.object(analyze_fallback.request, "urlopen", return_value=response):
480
+ markdown = analyze_fallback.call_github_models("prompt")
481
+ self.assertEqual(markdown, "# Summary\n")
482
+
483
484
if __name__ == "__main__":
485
unittest.main()
tests/test_reskill.py
+27
@@ -230,6 +230,33 @@ class ReskillTests(unittest.TestCase):
230
self.assertIn("Reskill skipped", content)
231
self.assertIn("403", content)
232
233
+ def test_github_models_endpoint_rejects_non_allowlisted_host(self) -> None:
234
+ with mock.patch.dict(
235
+ "os.environ",
236
+ {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": "https://evil.example.com/v1/chat"},
237
+ clear=False,
238
+ ):
239
+ with self.assertRaisesRegex(ValueError, "host must be one of"):
240
+ reskill.call_github_models("prompt")
241
+
242
+ def test_github_models_endpoint_accepts_allowlisted_host(self) -> None:
243
+ class _FakeResponse(io.BytesIO):
244
+ def __enter__(self):
245
+ return self
246
+
247
+ def __exit__(self, *_):
248
+ self.close()
249
+ return False
250
+
251
+ response = _FakeResponse(json.dumps({"choices": [{"message": {"content": "# Reskill\n"}}]}).encode("utf-8"))
252
+ with mock.patch.dict(
253
+ "os.environ",
254
+ {"GITHUB_TOKEN": "token", "GITHUB_MODELS_ENDPOINT": reskill.DEFAULT_MODELS_ENDPOINT},
255
+ clear=False,
256
+ ), mock.patch.object(reskill.request, "urlopen", return_value=response):
257
+ markdown = reskill.call_github_models("prompt")
258
+ self.assertEqual(markdown, "# Reskill\n")
259
+
260
261
if __name__ == "__main__":
262
unittest.main()