Skip to content
Binary file modified backend/app/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/__pycache__/models.cpython-313.pyc
Binary file not shown.
68 changes: 59 additions & 9 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import random
import re
import shutil
import subprocess
import tempfile
import threading
import uuid
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -142,7 +161,6 @@ def health():
"scanners": scanners,
}


@app.get("/api/health/ollama", tags=["Health"])
async def ollama_health():
"""
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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}
Binary file modified backend/app/remediation/__pycache__/engine.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/remediation/__pycache__/templates.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/reports/__pycache__/evidence_pack.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/sandbox/__pycache__/verify.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/gitleaks.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/osv.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/scanners/__pycache__/semgrep.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/utils/__pycache__/exec.cpython-313.pyc
Binary file not shown.
Binary file modified backend/app/utils/__pycache__/fs.cpython-313.pyc
Binary file not shown.
68 changes: 65 additions & 3 deletions backend/tests/test_scan_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,53 @@ 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"}
)
assert res.status_code == 400
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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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"
)
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading