Skip to content

Commit 8994fe2

Browse files
committed
fix: pre-empt review feedback on pins, predicate reuse, and baseline gates
- align the setup-uv pin in security.yml with test.yml (v8.2.0) - use is_https_or_localhost_http for the preset_add/extension_add URL checks and pass strict_redirects=True to the latest-release fetch and the release-asset resolver call sites - baseline gate scripts fail closed on unresolvable refs and git read errors instead of treating them as "baseline did not exist"; the security workflow re-runs on labeled/unlabeled so the ack label can turn the gate green without a push - regenerate the bandit baseline against HEAD (two entries referenced removed code, one had drifted); track baseline entries by file+test_id in tests so line drift no longer breaks them - raise ZIP size-limit errors outside the broad except in safe_extract_zip so an error_type subclassing OSError/RuntimeError cannot re-wrap them - tests: drop two redirect tests duplicated from test_authentication, move the downgrade test next to its siblings, assert the workflow catalog max_bytes, route OpenerDirector.open through urlopen in the modules that patch urlopen, add set -euo pipefail to the secret scan, misc cleanup (unused helper, redundant imports, EOF-less fake read)
1 parent b39b140 commit 8994fe2

19 files changed

Lines changed: 191 additions & 150 deletions

.github/bandit-baseline.json

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,7 @@
11
{
22
"results": [
33
{
4-
"code": "103 if not req.get_header(\"Authorization\") and not strict_redirects:\n104 return urllib.request.urlopen(req, timeout=timeout)\n105 \n",
5-
"col_offset": 15,
6-
"end_col_offset": 59,
7-
"filename": "src/specify_cli/_github_http.py",
8-
"issue_confidence": "HIGH",
9-
"issue_cwe": {
10-
"id": 22,
11-
"link": "https://cwe.mitre.org/data/definitions/22.html"
12-
},
13-
"issue_severity": "MEDIUM",
14-
"issue_text": "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.",
15-
"line_number": 104,
16-
"line_range": [
17-
104
18-
],
19-
"more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b310-urllib-urlopen",
20-
"test_id": "B310",
21-
"test_name": "blacklist"
22-
},
23-
{
24-
"code": "113 \n114 with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310\n115 payload = _json.loads(\n",
25-
"col_offset": 17,
26-
"end_col_offset": 56,
27-
"filename": "src/specify_cli/authentication/azure_devops.py",
28-
"issue_confidence": "HIGH",
29-
"issue_cwe": {
30-
"id": 22,
31-
"link": "https://cwe.mitre.org/data/definitions/22.html"
32-
},
33-
"issue_severity": "MEDIUM",
34-
"issue_text": "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.",
35-
"line_number": 114,
36-
"line_range": [
37-
114
38-
],
39-
"more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b310-urllib-urlopen",
40-
"test_id": "B310",
41-
"test_name": "blacklist"
42-
},
43-
{
44-
"code": "170 return opener.open(req, timeout=timeout)\n171 return urllib.request.urlopen(req, timeout=timeout) # noqa: S310\n",
4+
"code": "168 return opener.open(req, timeout=timeout)\n169 return urllib.request.urlopen(req, timeout=timeout) # noqa: S310\n",
455
"col_offset": 11,
466
"end_col_offset": 55,
477
"filename": "src/specify_cli/authentication/http.py",
@@ -52,9 +12,9 @@
5212
},
5313
"issue_severity": "MEDIUM",
5414
"issue_text": "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected.",
55-
"line_number": 171,
15+
"line_number": 169,
5616
"line_range": [
57-
171
17+
169
5818
],
5919
"more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b310-urllib-urlopen",
6020
"test_id": "B310",

.github/scripts/check_bandit_baseline.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,38 @@
4343
ACK_LABEL = "security-baseline-change"
4444

4545

46+
def _git_ok(*args: str) -> bool:
47+
"""True if the git command exits 0 (output discarded)."""
48+
return (
49+
subprocess.run(
50+
["git", *args],
51+
cwd=REPO_ROOT,
52+
stdout=subprocess.DEVNULL,
53+
stderr=subprocess.DEVNULL,
54+
).returncode
55+
== 0
56+
)
57+
58+
4659
def _read_baseline_at(ref: str) -> tuple[dict, bool]:
4760
"""Return (baseline_json, file_existed_at_ref).
4861
4962
Used for the base side. The head side reads the working tree to avoid
5063
silently fail-opening on an unfetched/invalid head ref.
64+
65+
Only a missing *path* at a resolvable ref counts as "did not exist";
66+
an unresolvable ref or a failing ``git show`` aborts instead, so a
67+
transient git failure cannot silently disable the gate.
5168
"""
5269
if not ref:
5370
return {"results": []}, False
71+
if not _git_ok("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"):
72+
raise SystemExit(
73+
f"Base ref {ref!r} cannot be resolved (unfetched or invalid). "
74+
f"Refusing to fail-open on a security gate."
75+
)
76+
if not _git_ok("cat-file", "-e", f"{ref}:{BASELINE_PATH}"):
77+
return {"results": []}, False
5478
try:
5579
blob = subprocess.run(
5680
["git", "show", f"{ref}:{BASELINE_PATH}"],
@@ -60,8 +84,11 @@ def _read_baseline_at(ref: str) -> tuple[dict, bool]:
6084
stderr=subprocess.PIPE,
6185
text=True,
6286
).stdout
63-
except subprocess.CalledProcessError:
64-
return {"results": []}, False
87+
except subprocess.CalledProcessError as exc:
88+
raise SystemExit(
89+
f"Could not read baseline at {ref!r}: {exc.stderr.strip()}. "
90+
f"Refusing to fail-open on a security gate."
91+
)
6592
try:
6693
return json.loads(blob), True
6794
except json.JSONDecodeError:

.github/scripts/check_secrets_baseline.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,35 @@ def log_safe(self) -> str:
5656
)
5757

5858

59+
def _git_ok(*args: str) -> bool:
60+
"""True if the git command exits 0 (output discarded)."""
61+
return (
62+
subprocess.run(
63+
["git", *args],
64+
cwd=REPO_ROOT,
65+
stdout=subprocess.DEVNULL,
66+
stderr=subprocess.DEVNULL,
67+
).returncode
68+
== 0
69+
)
70+
71+
5972
def _read_baseline_at(ref: str) -> tuple[dict, bool]:
60-
"""Return (baseline_json, file_existed_at_ref). Base side only."""
73+
"""Return (baseline_json, file_existed_at_ref). Base side only.
74+
75+
Only a missing *path* at a resolvable ref counts as "did not exist";
76+
an unresolvable ref or a failing ``git show`` aborts instead, so a
77+
transient git failure cannot silently disable the gate.
78+
"""
6179
if not ref:
6280
return {"results": {}}, False
81+
if not _git_ok("rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"):
82+
raise SystemExit(
83+
f"Base ref {ref!r} cannot be resolved (unfetched or invalid). "
84+
f"Refusing to fail-open on a security gate."
85+
)
86+
if not _git_ok("cat-file", "-e", f"{ref}:{BASELINE_PATH}"):
87+
return {"results": {}}, False
6388
try:
6489
blob = subprocess.run(
6590
["git", "show", f"{ref}:{BASELINE_PATH}"],
@@ -69,8 +94,11 @@ def _read_baseline_at(ref: str) -> tuple[dict, bool]:
6994
stderr=subprocess.PIPE,
7095
text=True,
7196
).stdout
72-
except subprocess.CalledProcessError:
73-
return {"results": {}}, False
97+
except subprocess.CalledProcessError as exc:
98+
raise SystemExit(
99+
f"Could not read baseline at {ref!r}: {exc.stderr.strip()}. "
100+
f"Refusing to fail-open on a security gate."
101+
)
74102
try:
75103
return json.loads(blob), True
76104
except json.JSONDecodeError:

.github/workflows/security.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
push:
88
branches: ["main"]
99
pull_request:
10+
# labeled/unlabeled so the baseline-growth gates re-evaluate when the
11+
# acknowledgement label is added or removed, without requiring a push.
12+
types: [opened, synchronize, reopened, labeled, unlabeled]
1013
schedule:
1114
- cron: "17 4 * * 1"
1215
workflow_dispatch:
@@ -27,7 +30,7 @@ jobs:
2730
fetch-depth: 2
2831

2932
- name: Install uv
30-
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
33+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
3134

3235
- name: Set up Python ${{ matrix.python-version }}
3336
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -73,7 +76,7 @@ jobs:
7376
fetch-depth: 0
7477

7578
- name: Install uv
76-
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
79+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
7780

7881
- name: Set up Python
7982
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -154,7 +157,7 @@ jobs:
154157
fetch-depth: 0
155158

156159
- name: Install uv
157-
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
160+
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
158161

159162
- name: Set up Python
160163
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
@@ -168,6 +171,7 @@ jobs:
168171
# rewriting the baseline file (so there's no spurious git diff).
169172
- name: Run detect-secrets
170173
run: |
174+
set -euo pipefail
171175
git ls-files -z \
172176
-- ':!:.secrets.baseline' \
173177
':!:uv.lock' \

src/specify_cli/__init__.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -689,11 +689,8 @@ def preset_add(
689689

690690
elif from_url:
691691
# Validate URL scheme before downloading
692-
from urllib.parse import urlparse as _urlparse
693-
_parsed = _urlparse(from_url)
694-
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
695-
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
696-
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
692+
if not is_https_or_localhost_http(from_url):
693+
console.print("[red]Error:[/red] URL must use HTTPS. HTTP is only allowed for localhost.")
697694
raise typer.Exit(1)
698695

699696
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
@@ -703,11 +700,15 @@ def preset_add(
703700
with tempfile.TemporaryDirectory() as tmpdir:
704701
zip_path = Path(tmpdir) / "preset.zip"
705702
try:
703+
from functools import partial
704+
706705
from specify_cli.authentication.http import open_url as _open_url
707706
from specify_cli._github_http import resolve_github_release_asset_api_url
708707

709708
_preset_extra_headers = None
710-
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
709+
_resolved_from_url = resolve_github_release_asset_api_url(
710+
from_url, partial(_open_url, strict_redirects=True)
711+
)
711712
if _resolved_from_url:
712713
from_url = _resolved_from_url
713714
_preset_extra_headers = {"Accept": "application/octet-stream"}
@@ -1601,13 +1602,9 @@ def extension_add(
16011602
# Guard with ``not dev`` so that --dev + --from does not show a
16021603
# confusing confirmation for a URL that will be ignored.
16031604
if from_url and not dev:
1604-
from urllib.parse import urlparse
16051605
from rich.markup import escape as _escape_markup
16061606

1607-
parsed = urlparse(from_url)
1608-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
1609-
1610-
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
1607+
if not is_https_or_localhost_http(from_url):
16111608
console.print("[red]Error:[/red] URL must use HTTPS for security.")
16121609
console.print("HTTP is only allowed for localhost URLs.")
16131610
raise typer.Exit(1)
@@ -3083,6 +3080,8 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
30833080

30843081
# Try as URL (http/https)
30853082
if source.startswith("http://") or source.startswith("https://"):
3083+
from functools import partial
3084+
30863085
from specify_cli.authentication.http import open_url as _open_url
30873086

30883087
if not is_https_or_localhost_http(source):
@@ -3092,7 +3091,9 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
30923091
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
30933092

30943093
_wf_url_extra_headers = None
3095-
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30)
3094+
_resolved_wf_url = _resolve_gh_asset(
3095+
source, partial(_open_url, strict_redirects=True), timeout=30
3096+
)
30963097
if _resolved_wf_url:
30973098
source = _resolved_wf_url
30983099
_wf_url_extra_headers = {"Accept": "application/octet-stream"}
@@ -3177,11 +3178,15 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
31773178
workflow_file = workflow_dir / "workflow.yml"
31783179

31793180
try:
3181+
from functools import partial
3182+
31803183
from specify_cli.authentication.http import open_url as _open_url
31813184
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
31823185

31833186
_wf_cat_extra_headers = None
3184-
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30)
3187+
_resolved_workflow_url = _resolve_gh_asset(
3188+
workflow_url, partial(_open_url, strict_redirects=True), timeout=30
3189+
)
31853190
if _resolved_workflow_url:
31863191
workflow_url = _resolved_workflow_url
31873192
_wf_cat_extra_headers = {"Accept": "application/octet-stream"}

src/specify_cli/_download_security.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@
3838
def is_https_or_localhost_http(url: str) -> bool:
3939
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
4040
41-
Shared redirect-safety predicate used by the GitHub and auth HTTP redirect
42-
handlers so the rule (and any future tightening of it) lives in one place.
41+
Shared scheme-safety predicate used by the auth HTTP redirect handler and
42+
by the direct URL validations in the CLI download flows, so the rule (and
43+
any future tightening of it) lives in one place.
4344
4445
The loopback allowance is a deliberate *exact-string* match on
4546
``localhost`` / ``127.0.0.1`` / ``::1``, not an IP-range check: other
@@ -304,6 +305,10 @@ def safe_extract_zip(
304305
exc,
305306
)
306307
written = 0
308+
# Raised outside the try below: if error_type subclasses OSError or
309+
# RuntimeError, raising inside would re-wrap the limit error as
310+
# "Failed to extract" and lose the size-bound message.
311+
limit_error: str | None = None
307312
try:
308313
with zf.open(member, "r") as source, member_path.open("wb") as dest:
309314
while True:
@@ -312,22 +317,24 @@ def safe_extract_zip(
312317
break
313318
written += len(chunk)
314319
if written > max_member_bytes:
315-
_raise(
316-
error_type,
320+
limit_error = (
317321
f"ZIP member {member.filename} exceeds maximum size "
318-
f"of {max_member_bytes} bytes",
322+
f"of {max_member_bytes} bytes"
319323
)
324+
break
320325
total_written += len(chunk)
321326
if total_written > max_total_bytes:
322-
_raise(
323-
error_type,
327+
limit_error = (
324328
f"ZIP archive exceeds maximum uncompressed size "
325-
f"of {max_total_bytes} bytes",
329+
f"of {max_total_bytes} bytes"
326330
)
331+
break
327332
dest.write(chunk)
328333
except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
329334
_raise_from(
330335
error_type,
331336
f"Failed to extract ZIP member {member.filename}: {exc}",
332337
exc,
333338
)
339+
if limit_error is not None:
340+
_raise(error_type, limit_error)

src/specify_cli/_version.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
119119
GITHUB_API_LATEST,
120120
timeout=5,
121121
extra_headers={"Accept": "application/vnd.github+json"},
122+
strict_redirects=True,
122123
) as resp:
123124
payload = json.loads(
124125
read_response_limited(

tests/http_helpers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import io
44
import json
5+
import urllib.request
56
from unittest.mock import MagicMock
67

8+
import pytest
9+
710

811
def mock_urlopen_response(payload: dict) -> MagicMock:
912
"""Build a urlopen context-manager mock whose read returns JSON."""
@@ -14,3 +17,25 @@ def mock_urlopen_response(payload: dict) -> MagicMock:
1417
cm.__enter__.return_value = resp
1518
cm.__exit__.return_value = False
1619
return cm
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def route_opener_open_through_urlopen(monkeypatch):
24+
"""Route OpenerDirector.open through urllib.request.urlopen.
25+
26+
``open_url(..., strict_redirects=True)`` fetches via
27+
``build_opener(...).open()``, which bypasses ``urllib.request.urlopen``
28+
— and with it the urlopen patches these test modules are built on.
29+
Delegating ``open()`` to urlopen at call time keeps those patches
30+
effective; the redirect handler's own behavior is covered by
31+
``TestRedirectStripping`` in test_authentication.py.
32+
33+
Import this fixture into a test module to activate it there.
34+
"""
35+
monkeypatch.setattr(
36+
urllib.request.OpenerDirector,
37+
"open",
38+
lambda self, req, data=None, timeout=None: urllib.request.urlopen(
39+
req, timeout=timeout
40+
),
41+
)

0 commit comments

Comments
 (0)