Skip to content

Commit 4599155

Browse files
committed
Address remaining security review feedback
1 parent 1f85b92 commit 4599155

14 files changed

Lines changed: 524 additions & 31 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Check that committed security audit requirements are up to date."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
11+
REPO_ROOT = Path(__file__).resolve().parents[2]
12+
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
13+
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
14+
15+
16+
def _dependency_diff_refs() -> tuple[str, str]:
17+
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
18+
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
19+
if base_ref and not set(base_ref) <= {"0"}:
20+
return base_ref, head_ref
21+
return "HEAD^", "HEAD"
22+
23+
24+
def _dependency_inputs_changed() -> bool:
25+
base_ref, head_ref = _dependency_diff_refs()
26+
try:
27+
result = subprocess.run(
28+
[
29+
"git",
30+
"diff",
31+
"--name-only",
32+
base_ref,
33+
head_ref,
34+
"--",
35+
*DEPENDENCY_INPUTS,
36+
],
37+
check=True,
38+
cwd=REPO_ROOT,
39+
stderr=subprocess.PIPE,
40+
stdout=subprocess.PIPE,
41+
text=True,
42+
)
43+
except subprocess.CalledProcessError as exc:
44+
print(
45+
"Could not determine changed dependency inputs; checking requirements.",
46+
file=sys.stderr,
47+
)
48+
if exc.stderr:
49+
print(exc.stderr.strip(), file=sys.stderr)
50+
return True
51+
52+
changed_inputs = [line for line in result.stdout.splitlines() if line]
53+
if not changed_inputs:
54+
print("Dependency audit inputs unchanged; sync check skipped.")
55+
return False
56+
57+
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
58+
return True
59+
60+
61+
def main() -> int:
62+
if not _dependency_inputs_changed():
63+
return 0
64+
65+
generated_requirements = Path(os.environ["GENERATED_REQUIREMENTS"])
66+
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
67+
68+
subprocess.run(
69+
[
70+
"uv",
71+
"pip",
72+
"compile",
73+
"pyproject.toml",
74+
"--extra",
75+
"test",
76+
"--universal",
77+
"--generate-hashes",
78+
"--quiet",
79+
"--no-header",
80+
"--output-file",
81+
str(generated_requirements),
82+
],
83+
check=True,
84+
cwd=REPO_ROOT,
85+
)
86+
87+
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
88+
generated = generated_requirements.read_text(encoding="utf-8")
89+
if committed == generated:
90+
return 0
91+
92+
print(
93+
"Regenerate .github/security-audit-requirements.txt with the documented "
94+
"uv pip compile command.",
95+
file=sys.stderr,
96+
)
97+
return 1
98+
99+
100+
if __name__ == "__main__":
101+
raise SystemExit(main())

.github/security-audit-requirements.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# This file was autogenerated by uv via the following command:
2-
# uv pip compile pyproject.toml --extra test --universal --generate-hashes --output-file .github/security-audit-requirements.txt
31
annotated-doc==0.0.4 \
42
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
53
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4

.github/workflows/security.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
steps:
2424
- name: Checkout
2525
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
26+
with:
27+
fetch-depth: 2
2628

2729
- name: Install uv
2830
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
@@ -41,6 +43,14 @@ jobs:
4143
if: ${{ github.event_name == 'schedule' }}
4244
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off
4345

46+
- name: Check committed audit requirements are current
47+
if: ${{ github.event_name != 'schedule' }}
48+
env:
49+
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
50+
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
51+
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
52+
run: python .github/scripts/check_security_requirements.py
53+
4454
- name: Run pip-audit (committed requirements)
4555
if: ${{ github.event_name != 'schedule' }}
4656
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ uvx --from bandit==1.9.4 bandit -r src -lll --baseline .github/bandit-baseline.j
9191
Run these before changing dependency metadata, workflow execution code, subprocess usage, or security-sensitive paths. Pull request, push, and manual CI audits use the committed hashed requirements file so they stay deterministic. The scheduled CI audit also resolves the runtime and `test` extra dependency set across the supported Python and OS matrix to catch newly published advisories. If dependency metadata changes, refresh the committed audit input before running pip-audit:
9292

9393
```bash
94-
uv pip compile pyproject.toml --extra test --universal --generate-hashes --quiet --output-file .github/security-audit-requirements.txt
94+
uv pip compile pyproject.toml --extra test --universal --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt
9595
```
9696

9797
### Manual testing

src/specify_cli/__init__.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
from rich.tree import Tree
5555
from typer.core import TyperGroup
5656

57+
from ._download_security import read_response_limited
5758
from .integration_runtime import (
5859
invoke_separator_for_integration as _invoke_separator_for_integration,
5960
resolve_integration_options as _resolve_integration_options_impl,
@@ -1772,7 +1773,13 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
17721773
req.add_header("Authorization", f"Bearer {token}")
17731774
try:
17741775
with urllib.request.urlopen(req, timeout=5) as resp:
1775-
payload = json.loads(resp.read().decode("utf-8"))
1776+
payload = json.loads(
1777+
read_response_limited(
1778+
resp,
1779+
max_bytes=1024 * 1024,
1780+
label="GitHub latest release",
1781+
).decode("utf-8")
1782+
)
17761783
tag = payload.get("tag_name")
17771784
if not isinstance(tag, str) or not tag:
17781785
raise ValueError("GitHub API response missing valid tag_name")
@@ -3376,8 +3383,10 @@ def preset_add(
33763383
zip_path = Path(tmpdir) / "preset.zip"
33773384
try:
33783385
with urllib.request.urlopen(from_url, timeout=60) as response:
3379-
zip_path.write_bytes(response.read())
3380-
except urllib.error.URLError as e:
3386+
zip_path.write_bytes(
3387+
read_response_limited(response, label=f"preset {from_url}")
3388+
)
3389+
except (urllib.error.URLError, ValueError) as e:
33813390
console.print(f"[red]Error:[/red] Failed to download: {e}")
33823391
raise typer.Exit(1)
33833392

@@ -4280,12 +4289,15 @@ def extension_add(
42804289

42814290
try:
42824291
with urllib.request.urlopen(from_url, timeout=60) as response:
4283-
zip_data = response.read()
4292+
zip_data = read_response_limited(
4293+
response,
4294+
label=f"extension {from_url}",
4295+
)
42844296
zip_path.write_bytes(zip_data)
42854297

42864298
# Install from downloaded ZIP
42874299
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
4288-
except urllib.error.URLError as e:
4300+
except (urllib.error.URLError, ValueError) as e:
42894301
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
42904302
raise typer.Exit(1)
42914303
finally:
@@ -5526,7 +5538,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
55265538
console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
55275539
raise typer.Exit(1)
55285540
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
5529-
tmp.write(resp.read())
5541+
tmp.write(read_response_limited(resp, label=f"workflow {source}"))
55305542
tmp_path = Path(tmp.name)
55315543
except typer.Exit:
55325544
raise
@@ -5630,7 +5642,9 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
56305642
f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}"
56315643
)
56325644
raise typer.Exit(1)
5633-
workflow_file.write_bytes(response.read())
5645+
workflow_file.write_bytes(
5646+
read_response_limited(response, label=f"workflow {source}")
5647+
)
56345648
except Exception as exc:
56355649
if workflow_dir.exists():
56365650
import shutil

src/specify_cli/_download_security.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _safe_zip_name(name: str, *, error_type: type[ErrorT]) -> str:
8080

8181
normalized = name.replace("\\", "/")
8282
path = PurePosixPath(normalized)
83-
has_windows_drive = re.match(r"^[A-Za-z]:/", normalized) is not None
83+
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
8484
if (
8585
not path.parts
8686
or path.is_absolute()

src/specify_cli/extensions.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def _validate(self):
250250

251251
normalized_file = cmd["file"].replace("\\", "/")
252252
file_path = PurePosixPath(normalized_file)
253-
has_windows_drive = re.match(r"^[A-Za-z]:/", normalized_file) is not None
253+
has_windows_drive = re.match(r"^[A-Za-z]:", normalized_file) is not None
254254
if (
255255
file_path.is_absolute()
256256
or has_windows_drive
@@ -1921,7 +1921,13 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False
19211921
# Fetch from network
19221922
try:
19231923
with self._open_url(entry.url, timeout=10) as response:
1924-
catalog_data = json.loads(response.read())
1924+
catalog_data = json.loads(
1925+
read_response_limited(
1926+
response,
1927+
error_type=ExtensionError,
1928+
label=f"extension catalog {entry.url}",
1929+
)
1930+
)
19251931

19261932
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
19271933
raise ExtensionError(f"Invalid catalog format from {entry.url}")
@@ -2037,7 +2043,13 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
20372043
import urllib.error
20382044

20392045
with self._open_url(catalog_url, timeout=10) as response:
2040-
catalog_data = json.loads(response.read())
2046+
catalog_data = json.loads(
2047+
read_response_limited(
2048+
response,
2049+
error_type=ExtensionError,
2050+
label=f"extension catalog {catalog_url}",
2051+
)
2052+
)
20412053

20422054
# Validate catalog structure
20432055
if "schema_version" not in catalog_data or "extensions" not in catalog_data:

src/specify_cli/integrations/catalog.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import yaml
2222
from packaging import version as pkg_version
2323

24+
from .._download_security import read_response_limited
25+
2426

2527
# ---------------------------------------------------------------------------
2628
# Errors
@@ -294,7 +296,13 @@ def _fetch_single_catalog(
294296
final_url = resp.geturl()
295297
if final_url != entry.url:
296298
self._validate_catalog_url(final_url)
297-
catalog_data = json.loads(resp.read())
299+
catalog_data = json.loads(
300+
read_response_limited(
301+
resp,
302+
error_type=IntegrationCatalogError,
303+
label=f"integration catalog {entry.url}",
304+
)
305+
)
298306

299307
if not isinstance(catalog_data, dict):
300308
raise IntegrationCatalogError(

src/specify_cli/presets.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def _validate(self):
226226
)
227227
normalized = file_path.replace("\\", "/")
228228
normalized_path = PurePosixPath(normalized)
229-
has_windows_drive = re.match(r"^[A-Za-z]:/", normalized) is not None
229+
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
230230
if normalized_path.is_absolute() or any(
231231
part == ".." for part in normalized_path.parts
232232
) or has_windows_drive:
@@ -2045,7 +2045,13 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool =
20452045

20462046
try:
20472047
with self._open_url(entry.url, timeout=10) as response:
2048-
catalog_data = json.loads(response.read())
2048+
catalog_data = json.loads(
2049+
read_response_limited(
2050+
response,
2051+
error_type=PresetError,
2052+
label=f"preset catalog {entry.url}",
2053+
)
2054+
)
20492055

20502056
if (
20512057
"schema_version" not in catalog_data
@@ -2138,7 +2144,13 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
21382144

21392145
try:
21402146
with self._open_url(catalog_url, timeout=10) as response:
2141-
catalog_data = json.loads(response.read())
2147+
catalog_data = json.loads(
2148+
read_response_limited(
2149+
response,
2150+
error_type=PresetError,
2151+
label=f"preset catalog {catalog_url}",
2152+
)
2153+
)
21422154

21432155
if (
21442156
"schema_version" not in catalog_data

tests/integrations/test_integration_catalog.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def __init__(self, data, url=""):
173173
self._data = json.dumps(data).encode()
174174
self._url = url
175175

176-
def read(self):
176+
def read(self, _size=-1):
177177
return self._data
178178

179179
def geturl(self):
@@ -294,6 +294,50 @@ def test_invalid_catalog_format(self, tmp_path, monkeypatch):
294294
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
295295
cat.search()
296296

297+
def test_fetch_single_catalog_uses_bounded_read(self, tmp_path, monkeypatch):
298+
cat = IntegrationCatalog(tmp_path)
299+
entry = IntegrationCatalogEntry(
300+
url="https://example.com/catalog.json",
301+
name="test",
302+
priority=1,
303+
install_allowed=True,
304+
)
305+
306+
class FakeResponse:
307+
def read(self, _size=-1):
308+
return b"{}"
309+
310+
def geturl(self):
311+
return entry.url
312+
313+
def __enter__(self):
314+
return self
315+
316+
def __exit__(self, *_args):
317+
pass
318+
319+
def fake_urlopen(url, timeout=10):
320+
assert url == entry.url
321+
assert timeout == 10
322+
return FakeResponse()
323+
324+
def fake_read_response_limited(response, **kwargs):
325+
assert isinstance(response, FakeResponse)
326+
assert kwargs["error_type"] is IntegrationCatalogError
327+
assert kwargs["label"] == "integration catalog https://example.com/catalog.json"
328+
raise IntegrationCatalogError("catalog too large")
329+
330+
import urllib.request
331+
332+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
333+
monkeypatch.setattr(
334+
"specify_cli.integrations.catalog.read_response_limited",
335+
fake_read_response_limited,
336+
)
337+
338+
with pytest.raises(IntegrationCatalogError, match="catalog too large"):
339+
cat._fetch_single_catalog(entry, force_refresh=True)
340+
297341
def test_clear_cache(self, tmp_path):
298342
(tmp_path / ".specify").mkdir()
299343
cat = IntegrationCatalog(tmp_path)
@@ -492,7 +536,7 @@ class FakeResponse:
492536
def __init__(self, data, url=""):
493537
self._data = json.dumps(data).encode()
494538
self._url = url
495-
def read(self):
539+
def read(self, _size=-1):
496540
return self._data
497541
def geturl(self):
498542
return self._url

0 commit comments

Comments
 (0)