Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,38 @@ on:
branches: [main]

jobs:
backend-tests:
name: Backend Tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.12'
- name: Install dependencies
working-directory: backend
run: pip install -r requirements.txt
- name: Run pytest
working-directory: backend
run: python -m pytest tests/ -v --tb=short
env:
TESTING: "true"
INTERNAL_TOKEN: "test-token"
DATABASE_URL: ""
- name: Upload results on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: pytest-results
path: backend/
retention-days: 7

test:
name: Playwright E2E
runs-on: ubuntu-latest
timeout-minutes: 20
needs: backend-tests

defaults:
run:
Expand Down
25 changes: 13 additions & 12 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import uuid
import logging
import copy
import json
import time

# Ensure backend directory is in Python path for local imports
Expand Down Expand Up @@ -581,20 +582,20 @@ def scan_package_deep():
elif risk_score >= 1: risk_label = 'Low'
else: risk_label = 'Secure'

# Build grouped_packages to satisfy frontend contract
# Build grouped_packages to satisfy frontend contract.
# group_vulns_by_package returns a list of group dicts, not a dict.
grouped_vulns = group_vulns_by_package(vulnerabilities)
grouped_packages = []
for pkg_name_key, pkg_vulns in grouped_vulns.items():
sevs = [v.get('severity', 'LOW') for v in pkg_vulns]
highest = next((s for s in ['CRITICAL','HIGH','MEDIUM','LOW'] if s in sevs), 'LOW')
grouped_packages.append({
'package': pkg_name_key,
'version': pkg_vulns[0].get('installed_version', ''),
grouped_packages = [
{
'package': g['package'],
'version': g['version'],
'is_direct': True,
'vulnerabilities': pkg_vulns,
'highestSeverity': highest,
'recommended_fix': next((v.get('fix_version') for v in pkg_vulns if v.get('fix_version')), None),
})
'vulnerabilities': g['cves'],
'highestSeverity': g['highest_severity'],
'recommended_fix': g['recommended_fix'],
}
for g in grouped_vulns
]

# Build fixes list
fixes = []
Expand Down
23 changes: 10 additions & 13 deletions backend/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,13 @@ def purge_expired_scan_snapshots():
log.warning(f"Scan snapshot purge error: {e}")

def last_sync_time(source: str):
"""Return ISO timestamp of last successful sync for a source, or None."""
try:
with get_conn() as conn:
with get_cursor(conn) as cur:
cur.execute(
"SELECT synced_at FROM sync_log WHERE source=%s AND status='ok' ORDER BY synced_at DESC LIMIT 1",
(source,)
)
row = cur.fetchone()
return row["synced_at"].isoformat() if row else None
except Exception as e:
log.warning(f"last_sync_time error: {e}")
return None
"""Return ISO timestamp of last successful sync for a source, or None.
Raises on DB connection failure so callers can detect real DB outages."""
with get_conn() as conn:
with get_cursor(conn) as cur:
cur.execute(
"SELECT synced_at FROM sync_log WHERE source=%s AND status='ok' ORDER BY synced_at DESC LIMIT 1",
(source,)
)
row = cur.fetchone()
return row["synced_at"].isoformat() if row else None
2 changes: 1 addition & 1 deletion backend/resolvers/lockfile_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
All versions are already resolved in the lock file.
"""

def resolve(direct_deps):
def resolve(direct_deps, max_depth=2):
"""Build graph from lock file — fast, no HTTP calls.

Lock files don't expose explicit parent→child mapping in a simple form,
Expand Down
16 changes: 11 additions & 5 deletions backend/resolvers/maven_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,20 @@ def _fetch_deps(group, artifact, version):
if res.status_code != 200:
return {}
root = ET.fromstring(res.content)
# Maven POMs declare xmlns="http://maven.apache.org/POM/4.0.0".
# ElementTree represents every tag as {http://...}tagname, so plain
# .iter('dependency') matches nothing. Use the explicit namespace.
# Falls back to unnamespaced tags for legacy POMs via the _ns helper.
_pom_ns = 'http://maven.apache.org/POM/4.0.0'
_ns = lambda tag: f'{{{_pom_ns}}}{tag}' if root.tag.startswith(f'{{{_pom_ns}}}') else tag
deps = {}
for dep in root.iter('dependency'):
scope = dep.findtext('scope') or 'compile'
for dep in root.iter(_ns('dependency')):
scope = dep.findtext(_ns('scope')) or 'compile'
if scope in ('test', 'provided', 'system'):
continue
g = dep.findtext('groupId') or ''
a = dep.findtext('artifactId') or ''
v = dep.findtext('version') or 'unknown'
g = dep.findtext(_ns('groupId')) or ''
a = dep.findtext(_ns('artifactId')) or ''
v = dep.findtext(_ns('version')) or 'unknown'
if '${' in v:
v = 'unknown'
deps[f"{g}:{a}"] = {'group': g, 'artifact': a, 'version': v}
Expand Down
2 changes: 1 addition & 1 deletion backend/resolvers/npm_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _fetch_deps(name, version):
cached = get_resolver_cache(f"npm:{key}")
if cached is not None:
with _cache_lock:
_cache[key] = {'deps': cached, 'ts': time.time()}
_cache[key] = {'data': cached, 'ts': time.time()}
return cached
except Exception:
pass
Expand Down
12 changes: 10 additions & 2 deletions backend/resolvers/pypi_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,16 @@ def _fetch_deps(name, version):
continue
dep_name = match.group(1)
ver_str = match.group(2) or ''
ver_match = re.search(r'[\d\.]+', ver_str)
deps[dep_name] = ver_match.group(0) if ver_match else 'latest'
# Prefer a lower bound (>=x or >x) so we scan a version that
# actually satisfies the constraint. Without this, constraints
# starting with an upper bound like (<1.27,>=1.21.1) would have
# the first number extracted as 1.27 — an invalid upper limit.
lb_match = re.search(r'>=?\s*([\d][^,\s;]*)', ver_str)
if lb_match:
deps[dep_name] = lb_match.group(1).strip()
else:
any_match = re.search(r'[\d\.]+', ver_str)
deps[dep_name] = any_match.group(0) if any_match else 'latest'
with _cache_lock:
_cache[key] = deps
# Evict oldest entries if cache exceeds max size
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
conftest.py — shared fixtures for the backend test suite.

No fake CVE IDs, no synthetic OSV payloads, no hardcoded mock registry
responses are stored here. All vulnerability assertions are made against
the live OSV API using real package/version pairs with documented CVEs.
"""
import os
import sys
import pytest

# Must be set before any app import so the scheduler and DB init are skipped.
os.environ['DISABLE_SCHEDULER'] = 'true'
os.environ.setdefault('DATABASE_URL', 'postgresql://test:badpass@localhost/nonexistent_test')

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))


@pytest.fixture
def client():
"""Flask test client. Resets the in-memory rate-limiter store between
tests so rate-limit assertions do not bleed across test functions."""
from app import app, _rate_limiter
app.config['TESTING'] = True
_rate_limiter._store.clear()
with app.test_client() as c:
yield c
Loading
Loading