diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index ec94d8c..cac27d2 100644 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc index e8b2cb5..9b5890b 100644 Binary files a/backend/app/__pycache__/models.cpython-313.pyc and b/backend/app/__pycache__/models.cpython-313.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index 66168de..6431cce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ import random import re import shutil +import subprocess import tempfile import threading import uuid @@ -75,6 +76,24 @@ from .scanners.semgrep import run_semgrep from .utils.fs import ensure_dir, safe_job_dir, safe_rmtree, unzip_to_dir + +REF_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-/]+$") + + +def validate_git_ref(repo_url: str, ref: str) -> bool: + try: + # Use ls-remote to check if ref exists in the remote repo. + # git ls-remote exits 0 with empty stdout when the ref doesn't + # exist, so we must check the output itself, not just the + # return code (which only signals errors like an unreachable + # or non-existent repo). + output = subprocess.check_output( + ["git", "ls-remote", "--", repo_url, ref] + ) + return bool(output.strip()) + except subprocess.CalledProcessError: + return False + _MAX_UPLOAD_MB_RAW = os.environ.get("MAX_UPLOAD_MB") RANKER = load_ranker() @@ -142,7 +161,6 @@ def health(): "scanners": scanners, } - @app.get("/api/health/ollama", tags=["Health"]) async def ollama_health(): """ @@ -325,17 +343,27 @@ def finding_key(f: Finding): ) -def github_zip_url(repo_url: str, ref: str = "main") -> str: - repo_url = repo_url.strip() - m = re.match( - r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", repo_url, re.IGNORECASE - ) +GITHUB_REPO_URL_RE = re.compile( + r"^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE +) + + +def parse_github_repo_url(repo_url: str) -> tuple[str, str]: + m = GITHUB_REPO_URL_RE.match(repo_url.strip()) if not m: raise HTTPException( status_code=400, detail="Only GitHub repo URLs are supported right now." ) - owner, repo = m.group(1), m.group(2) - return f"https://github.com/{owner}/{repo}/archive/refs/heads/{ref}.zip" + return m.group(1), m.group(2) + + +def github_zip_url(repo_url: str, ref: str = "main") -> str: + owner, repo = parse_github_repo_url(repo_url) + # GitHub's generic archive endpoint resolves branches, tags, and + # commit SHAs alike, so we don't need to know the ref type up + # front (and don't have to guess wrong for tags/SHAs by assuming + # refs/heads/). + return f"https://github.com/{owner}/{repo}/archive/{ref}.zip" ALLOWED_REDIRECT_HOSTS = { @@ -718,6 +746,21 @@ async def scan_url( is returned immediately for tracking scan progress and retrieving results. """ + + parse_github_repo_url(repo_url) # raises 400 early on a non-GitHub URL + + if not REF_PATTERN.match(ref): + raise HTTPException( + status_code=400, detail="Ref contains invalid characters." + ) + + is_valid_ref = await run_in_threadpool(validate_git_ref, repo_url, ref) + if not is_valid_ref: + raise HTTPException( + status_code=404, + detail=f"Ref '{ref}' not found in repository.", + ) + job_id = next(tempfile._get_candidate_names()) job_dir = WORK_ROOT / job_id ensure_dir(job_dir) @@ -1374,6 +1417,13 @@ async def _run_repo_scan_task( repo_dir = job_dir / "repo" ensure_dir(repo_dir) + if not REF_PATTERN.match(ref): + raise ValueError(f"Ref '{ref}' contains invalid characters.") + + is_valid_ref = await run_in_threadpool(validate_git_ref, repo_url, ref) + if not is_valid_ref: + raise ValueError(f"Ref '{ref}' not found in repository '{repo_url}'.") + zip_url = github_zip_url(repo_url, ref=ref) await download_to_path(zip_url, archive_path, cancel_event=cancel_event) if cancel_event and cancel_event.is_set(): @@ -1967,4 +2017,4 @@ async def get_blast_radius(org_job_id: str): links.append({"source": repo, "target": pkg}) - return {"nodes": list(nodes_dict.values()), "links": links} + return {"nodes": list(nodes_dict.values()), "links": links} \ No newline at end of file diff --git a/backend/app/remediation/__pycache__/engine.cpython-313.pyc b/backend/app/remediation/__pycache__/engine.cpython-313.pyc index beebbe7..ff0955e 100644 Binary files a/backend/app/remediation/__pycache__/engine.cpython-313.pyc and b/backend/app/remediation/__pycache__/engine.cpython-313.pyc differ diff --git a/backend/app/remediation/__pycache__/templates.cpython-313.pyc b/backend/app/remediation/__pycache__/templates.cpython-313.pyc index 47d7235..9f8466b 100644 Binary files a/backend/app/remediation/__pycache__/templates.cpython-313.pyc and b/backend/app/remediation/__pycache__/templates.cpython-313.pyc differ diff --git a/backend/app/reports/__pycache__/evidence_pack.cpython-313.pyc b/backend/app/reports/__pycache__/evidence_pack.cpython-313.pyc index 1e968c0..f78bee9 100644 Binary files a/backend/app/reports/__pycache__/evidence_pack.cpython-313.pyc and b/backend/app/reports/__pycache__/evidence_pack.cpython-313.pyc differ diff --git a/backend/app/sandbox/__pycache__/verify.cpython-313.pyc b/backend/app/sandbox/__pycache__/verify.cpython-313.pyc index eb236de..eccb59d 100644 Binary files a/backend/app/sandbox/__pycache__/verify.cpython-313.pyc and b/backend/app/sandbox/__pycache__/verify.cpython-313.pyc differ diff --git a/backend/app/scanners/__pycache__/gitleaks.cpython-313.pyc b/backend/app/scanners/__pycache__/gitleaks.cpython-313.pyc index 316bb5b..d8413b5 100644 Binary files a/backend/app/scanners/__pycache__/gitleaks.cpython-313.pyc and b/backend/app/scanners/__pycache__/gitleaks.cpython-313.pyc differ diff --git a/backend/app/scanners/__pycache__/osv.cpython-313.pyc b/backend/app/scanners/__pycache__/osv.cpython-313.pyc index 1ae5fde..d7c3623 100644 Binary files a/backend/app/scanners/__pycache__/osv.cpython-313.pyc and b/backend/app/scanners/__pycache__/osv.cpython-313.pyc differ diff --git a/backend/app/scanners/__pycache__/semgrep.cpython-313.pyc b/backend/app/scanners/__pycache__/semgrep.cpython-313.pyc index e6e7f33..fab1524 100644 Binary files a/backend/app/scanners/__pycache__/semgrep.cpython-313.pyc and b/backend/app/scanners/__pycache__/semgrep.cpython-313.pyc differ diff --git a/backend/app/utils/__pycache__/exec.cpython-313.pyc b/backend/app/utils/__pycache__/exec.cpython-313.pyc index 34ea456..d058e6b 100644 Binary files a/backend/app/utils/__pycache__/exec.cpython-313.pyc and b/backend/app/utils/__pycache__/exec.cpython-313.pyc differ diff --git a/backend/app/utils/__pycache__/fs.cpython-313.pyc b/backend/app/utils/__pycache__/fs.cpython-313.pyc index 66837b0..0fc3e70 100644 Binary files a/backend/app/utils/__pycache__/fs.cpython-313.pyc and b/backend/app/utils/__pycache__/fs.cpython-313.pyc differ diff --git a/backend/tests/test_scan_url.py b/backend/tests/test_scan_url.py index ff9c3c6..a191887 100644 --- a/backend/tests/test_scan_url.py +++ b/backend/tests/test_scan_url.py @@ -37,6 +37,9 @@ async def aiter_bytes(self, chunk_size): def test_scan_url_invalid_format(): + # No need to mock validate_git_ref here: repo_url format is + # validated before the ref is ever checked against the remote, so + # this never reaches validate_git_ref / the network. res = client.post( "/scan-url", data={"repo_url": "not-a-url", "project_name": "test_project"} ) @@ -44,8 +47,43 @@ def test_scan_url_invalid_format(): assert "Only GitHub repo URLs are supported right now." in res.json()["detail"] +def test_scan_url_invalid_ref_characters(): + res = client.post( + "/scan-url", + data={ + "repo_url": "https://github.com/owner/repo", + "ref": "main; rm -rf /", + "project_name": "test_project", + }, + ) + assert res.status_code == 400 + assert "invalid characters" in res.json()["detail"].lower() + + +@patch("app.main.validate_git_ref") +def test_scan_url_ref_not_found(mock_validate_git_ref): + mock_validate_git_ref.return_value = False + + res = client.post( + "/scan-url", + data={ + "repo_url": "https://github.com/owner/repo", + "ref": "does-not-exist", + "project_name": "test_project", + }, + ) + assert res.status_code == 404 + assert "not found" in res.json()["detail"].lower() + mock_validate_git_ref.assert_called_once_with( + "https://github.com/owner/repo", "does-not-exist" + ) + + +@patch("app.main.validate_git_ref") @patch("app.main.httpx.AsyncClient") -def test_scan_url_not_found(mock_async_client): +def test_scan_url_not_found(mock_async_client, mock_validate_git_ref): + mock_validate_git_ref.return_value = True + mock_client = MagicMock() mock_async_client.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_async_client.return_value.__aexit__ = AsyncMock(return_value=None) @@ -64,8 +102,11 @@ def test_scan_url_not_found(mock_async_client): assert "Failed to download repo ZIP" in res.json()["detail"] +@patch("app.main.validate_git_ref") @patch("app.main.httpx.AsyncClient") -def test_scan_url_timeout(mock_async_client): +def test_scan_url_timeout(mock_async_client, mock_validate_git_ref): + mock_validate_git_ref.return_value = True + mock_client = MagicMock() mock_async_client.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_async_client.return_value.__aexit__ = AsyncMock(return_value=None) @@ -83,14 +124,22 @@ def test_scan_url_timeout(mock_async_client): assert "Network error downloading repo" in res.json()["detail"] +@patch("app.main.validate_git_ref") @patch("app.main.httpx.AsyncClient") @patch("app.main.download_to_path", new_callable=AsyncMock) @patch("app.main.unzip_to_dir") @patch("app.main._scan_repo_dir") @patch("app.main.get_db") def test_scan_url_success( - mock_get_db, mock_scan, mock_unzip, mock_download, mock_async_client + mock_get_db, + mock_scan, + mock_unzip, + mock_download, + mock_async_client, + mock_validate_git_ref, ): + mock_validate_git_ref.return_value = True + mock_client = MagicMock() mock_async_client.return_value.__aenter__ = AsyncMock(return_value=mock_client) mock_async_client.return_value.__aexit__ = AsyncMock(return_value=None) @@ -119,3 +168,16 @@ def test_scan_url_success( assert data["project_name"] == "test_project" assert data["status"] == "running" assert "job_id" in data + + +def test_github_zip_url_supports_tags_and_shas(): + from app.main import github_zip_url + + assert ( + github_zip_url("https://github.com/owner/repo", ref="v1.0.0") + == "https://github.com/owner/repo/archive/v1.0.0.zip" + ) + assert ( + github_zip_url("https://github.com/owner/repo", ref="a1b2c3d4") + == "https://github.com/owner/repo/archive/a1b2c3d4.zip" + ) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5bb9692 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "PatchPilot", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}