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()