From faa13ea8be515a2975240602feb0a7bf04e85c8f Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Tue, 4 Aug 2026 17:02:39 +0900 Subject: [PATCH] feat(api): accept ?release= as a snapshot anchor on detail reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning a past snapshot needed its scan UUID, which a caller cannot know: answering "what shipped in 4.0" meant listing releases, matching the label, then re-requesting. Fourteen detail endpoints now also take ?release=, so /notice?release=4.0 is a permanent address for a version. Resolved once in a shared FastAPI dependency rather than threaded through fourteen endpoints and the services behind them — the translation is the same everywhere, and the services keep their scan-id contract. scan_id wins when both are given; an unknown label 404s exactly like an unusable id. --- apps/backend/api/v1/_snapshot_anchor.py | 124 +++++++++++++++ apps/backend/api/v1/compliance.py | 11 +- apps/backend/api/v1/licenses.py | 11 +- apps/backend/api/v1/obligations.py | 23 +-- apps/backend/api/v1/policy_gate.py | 12 +- apps/backend/api/v1/projects.py | 31 +--- apps/backend/api/v1/reports.py | 10 +- apps/backend/api/v1/sbom.py | 11 +- apps/backend/api/v1/source_tree.py | 11 +- apps/backend/api/v1/vulnerabilities.py | 21 +-- apps/backend/core/errors.py | 18 +++ .../integration/test_release_snapshots_api.py | 146 ++++++++++++++++++ .../backend/tests/unit/openapi_endpoints.json | 14 ++ docs-site/docs/user-guide/projects.md | 2 + .../current/user-guide/projects.md | 2 + 15 files changed, 329 insertions(+), 118 deletions(-) create mode 100644 apps/backend/api/v1/_snapshot_anchor.py diff --git a/apps/backend/api/v1/_snapshot_anchor.py b/apps/backend/api/v1/_snapshot_anchor.py new file mode 100644 index 0000000..599819a --- /dev/null +++ b/apps/backend/api/v1/_snapshot_anchor.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 TRUSCA contributors +""" +The shared ``?scan_id=`` / ``?release=`` snapshot anchor for detail reads. + +Fourteen endpoints let a caller read a project surface as of one succeeded +scan. Until now the only way to name that scan was its UUID, which a caller +cannot know in advance: answering "what shipped in 4.0" meant listing releases, +matching the label client-side, then re-requesting with the id it found. Two +round-trips, and no URL you could write down or paste into a ticket. + +``release`` closes that: ``/notice?release=4.0`` is a permanent address for a +version, because a label identifies exactly one live snapshot (see +``tasks.scan_retention.supersede_prior_release_scans``). + +Resolving it HERE rather than in each service is deliberate. The alternative — +a second parameter threaded through fourteen endpoints and the ten services +behind them — multiplies the number of places the precedence rule could drift, +for a translation that is the same everywhere: turn a label into the scan id +the endpoint already knows how to handle. Services keep their existing +``snapshot_scan_id`` contract and never learn that labels exist. + +Precedence: ``scan_id`` wins when both are given. It names one immutable +snapshot, whereas a label names whichever snapshot currently holds it — so the +more specific of the two should not be overridden by the looser one. + +Authorization: none here, matching ``services.scan_resolution``. The lookup is +scoped to ``project_id`` and both "no such label" and "no such project you can +see" surface as the same 404, so resolving before the endpoint's team check +tells an outside caller nothing it could not already infer. +""" + +from __future__ import annotations + +import uuid + +from fastapi import Depends, Query +from sqlalchemy import String, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from models import Scan +from services.scan_resolution import SnapshotScanNotFound + +_SCAN_ID_DESCRIPTION = ( + "Optional release-snapshot anchor. Read this surface as of ONE specific " + "succeeded scan instead of the project's current state. Must belong to this " + "project and be succeeded, else 404. Takes precedence over ``release``." +) + +_RELEASE_DESCRIPTION = ( + "Optional version anchor — read this surface as of the release carrying " + "this label (e.g. '4.0'). Equivalent to looking the label up on " + "``/releases?release=`` and pinning the ``scan_id`` it returns, but as one " + "permanent URL. Matched exactly, whitespace trimmed. Unknown label → 404. " + "Ignored when ``scan_id`` is also given." +) + + +async def resolve_release_label( + session: AsyncSession, + project_id: uuid.UUID, + label: str, +) -> uuid.UUID | None: + """Return the live snapshot carrying *label*, or ``None``. + + Mirrors the releases-list filter exactly — succeeded, not superseded, + trimmed comparison — so a label that appears there resolves here and one + that does not, does not. Superseded rows are excluded because a rescan + moves the label: ``4.0`` must mean the snapshot that currently holds it, + not every scan that ever claimed it. + + Served by ``ix_scans_project_release_label``. + """ + stripped = label.strip() + if not stripped: + return None + stmt = ( + select(Scan.id) + .where(Scan.project_id == project_id) + .where(cast(Scan.status, String) == "succeeded") + .where(Scan.superseded_at.is_(None)) + .where(func.jsonb_typeof(Scan.scan_metadata["release"]) == "string") + .where(func.btrim(Scan.scan_metadata["release"].astext) == stripped) + .order_by(Scan.created_at.desc(), Scan.id.desc()) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def snapshot_anchor( + project_id: uuid.UUID, + scan_id: uuid.UUID | None = Query(default=None, description=_SCAN_ID_DESCRIPTION), + release: str | None = Query( + default=None, max_length=100, description=_RELEASE_DESCRIPTION + ), + session: AsyncSession = Depends(get_db), +) -> uuid.UUID | None: + """Resolve the effective ``scan_id`` for a detail read. + + Returns what the endpoint's service already expects: ``None`` for "current + state", or a scan id to pin. An unresolvable label raises + :class:`SnapshotScanNotFound`, which ``core.errors`` renders as the same + existence-hiding 404 an unresolvable ``scan_id`` produces — a caller must + not be able to tell "that version does not exist" from "that scan id is not + yours". + + A pinned ``scan_id`` is returned unvalidated; the service still passes it + through ``resolve_snapshot_scan_id``, which owns the ownership + succeeded + checks. Validating twice here would cost a round-trip on every request to + move a check that is already in the right place. + """ + if scan_id is not None or release is None: + return scan_id + resolved = await resolve_release_label(session, project_id, release) + if resolved is None: + raise SnapshotScanNotFound( + f"no release labelled {release!r} in project {project_id}" + ) + return resolved + + +__all__ = ["resolve_release_label", "snapshot_anchor"] diff --git a/apps/backend/api/v1/compliance.py b/apps/backend/api/v1/compliance.py index 677ef64..cdc4cb2 100644 --- a/apps/backend/api/v1/compliance.py +++ b/apps/backend/api/v1/compliance.py @@ -29,6 +29,7 @@ from fastapi import APIRouter, Depends, Query, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.db import get_db from core.errors import problem_response from core.security import CurrentUser, require_role @@ -114,15 +115,7 @@ async def list_project_compliance_endpoint( pattern=r"^(category|license_name|spdx_id|affected_count)$", ), order: str = Query(default="desc", pattern=r"^(asc|desc)$"), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, the " - "grid reflects this specific succeeded scan instead of the " - "project's latest succeeded scan. Must belong to this project " - "and be succeeded, else 404." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: diff --git a/apps/backend/api/v1/licenses.py b/apps/backend/api/v1/licenses.py index 2a20fa3..49cc774 100644 --- a/apps/backend/api/v1/licenses.py +++ b/apps/backend/api/v1/licenses.py @@ -34,6 +34,7 @@ from fastapi import APIRouter, Depends, Query, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.db import get_db from core.errors import problem_response from core.security import CurrentUser, require_role @@ -120,15 +121,7 @@ async def list_project_licenses_endpoint( "non_commercial = CC-BY-NC…. Omit to list all licenses." ), ), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, list " - "license rows of this SPECIFIC succeeded scan instead of the project's " - "latest succeeded scan. Must belong to this project and be succeeded, " - "else 404. Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: diff --git a/apps/backend/api/v1/obligations.py b/apps/backend/api/v1/obligations.py index d46e374..1cb9c18 100644 --- a/apps/backend/api/v1/obligations.py +++ b/apps/backend/api/v1/obligations.py @@ -46,6 +46,7 @@ from fastapi import APIRouter, Depends, Query, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.db import get_db from core.errors import problem_response from core.ratelimit import limiter @@ -130,15 +131,7 @@ async def list_project_obligations_endpoint( pattern=r"^(category|license_name|kind|affected_count)$", ), order: str = Query(default="desc", pattern=r"^(asc|desc)$"), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, list " - "obligation rows of this SPECIFIC succeeded scan instead of the " - "project's latest succeeded scan. Must belong to this project and be " - "succeeded, else 404. Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -312,17 +305,7 @@ def _format_content_disposition(project_name: str, ext: str) -> str: async def get_project_notice_endpoint( request: Request, project_id: uuid.UUID, - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor. When given, compose the NOTICE " - "from this SPECIFIC succeeded scan instead of the project's latest " - "succeeded scan — this is how the attribution document for an " - "already-shipped release stays retrievable after a newer scan " - "succeeds. Must belong to this project and be succeeded, else 404. " - "Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), fmt: str = Query( default="text", alias="format", diff --git a/apps/backend/api/v1/policy_gate.py b/apps/backend/api/v1/policy_gate.py index f2c3697..fefaaaf 100644 --- a/apps/backend/api/v1/policy_gate.py +++ b/apps/backend/api/v1/policy_gate.py @@ -43,6 +43,7 @@ from sqlalchemy import String, cast, func, select from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.api_key_auth import get_api_key_principal from core.audit import bind_audit_team, get_audit_context, mask_sensitive_columns from core.authz import assert_team_access @@ -239,16 +240,7 @@ def _build_response_body(result: GateResult) -> GateResultResponse: async def get_gate_result_endpoint( request: Request, project_id: uuid.UUID, - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, evaluate " - "the build gate against this SPECIFIC succeeded scan instead of the " - "project's latest succeeded scan (so the Overview gate card can reflect " - "a pinned release). Must belong to this project and be succeeded, else " - "404. Omit for the default latest-succeeded behaviour (the CI contract)." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), ref: str | None = Query( default=None, max_length=255, diff --git a/apps/backend/api/v1/projects.py b/apps/backend/api/v1/projects.py index 35df537..2c551e3 100644 --- a/apps/backend/api/v1/projects.py +++ b/apps/backend/api/v1/projects.py @@ -38,6 +38,7 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile, status from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.api_key_auth import require_role_or_api_key from core.audit import bind_audit_team, get_audit_context, mask_sensitive_columns from core.config import scan_trigger_rate_limit @@ -429,15 +430,7 @@ async def delete_project_endpoint( async def get_project_overview_endpoint( request: Request, project_id: uuid.UUID, - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, aggregate " - "this SPECIFIC succeeded scan instead of the project's latest succeeded " - "scan. Must belong to this project and be succeeded, else 404. Omit for " - "the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -593,15 +586,7 @@ async def list_project_components_endpoint( ), sort: str = Query(default="name", pattern=r"^(name|severity|license)$"), order: str = Query(default="asc", pattern=r"^(asc|desc)$"), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, list " - "components of this SPECIFIC succeeded scan instead of the project's " - "latest succeeded scan. Must belong to this project and be succeeded, " - "else 404. Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -833,15 +818,7 @@ async def diff_project_releases_endpoint( async def get_dependency_graph_endpoint( request: Request, project_id: uuid.UUID, - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor. When given, serialize this SPECIFIC " - "succeeded scan's graph instead of the project's latest succeeded scan. " - "Must belong to this project and be succeeded, else 404 (existence-hide). " - "Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: diff --git a/apps/backend/api/v1/reports.py b/apps/backend/api/v1/reports.py index 8655796..0706e14 100644 --- a/apps/backend/api/v1/reports.py +++ b/apps/backend/api/v1/reports.py @@ -43,6 +43,7 @@ from fastapi.concurrency import run_in_threadpool from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.authz import assert_team_access from core.db import get_db from core.errors import problem_response @@ -463,14 +464,7 @@ async def list_project_report_history_endpoint( "Omit for all four types." ), ), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional filter — return only rows where ``scan_id`` matches. " - "Pair with ``type=sbom`` etc. to find all artefacts produced for " - "one scan." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query( default=PAGE_SIZE_DEFAULT, diff --git a/apps/backend/api/v1/sbom.py b/apps/backend/api/v1/sbom.py index edf5244..786917b 100644 --- a/apps/backend/api/v1/sbom.py +++ b/apps/backend/api/v1/sbom.py @@ -37,6 +37,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.api_key_auth import require_role_or_api_key from core.authz import assert_team_access from core.config import ( @@ -245,15 +246,7 @@ async def export_project_sbom_endpoint( alias="format", description="SBOM output format.", ), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, export " - "this SPECIFIC succeeded scan instead of the project's latest succeeded " - "scan. Must belong to this project and be succeeded, else 404. Omit for " - "the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), profile: SBOMProfile | None = Query( default=None, description=( diff --git a/apps/backend/api/v1/source_tree.py b/apps/backend/api/v1/source_tree.py index e09507f..6444ef1 100644 --- a/apps/backend/api/v1/source_tree.py +++ b/apps/backend/api/v1/source_tree.py @@ -32,6 +32,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.db import get_db from core.errors import problem_response from core.security import CurrentUser, require_role @@ -116,10 +117,7 @@ async def get_source_tree( ), page: int = Query(default=1, ge=1, description="1-based page index."), size: int = Query(default=100, ge=1, le=500, description="Page size (max 500)."), - scan_id: uuid.UUID | None = Query( - default=None, - description="Scan to read; defaults to the project's latest scan.", - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> JSONResponse | SourceTreePage: @@ -185,10 +183,7 @@ async def get_source_file( request: Request, project_id: uuid.UUID, path: str = Query(description="File to read, relative to the source root."), - scan_id: uuid.UUID | None = Query( - default=None, - description="Scan to read; defaults to the project's latest scan.", - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), raw: bool = Query( default=False, description=( diff --git a/apps/backend/api/v1/vulnerabilities.py b/apps/backend/api/v1/vulnerabilities.py index a17140b..47a65ba 100644 --- a/apps/backend/api/v1/vulnerabilities.py +++ b/apps/backend/api/v1/vulnerabilities.py @@ -27,6 +27,7 @@ from fastapi import APIRouter, Depends, Query, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession +from api.v1._snapshot_anchor import snapshot_anchor from core.db import get_db from core.errors import problem_response from core.security import CurrentUser, require_role @@ -181,15 +182,7 @@ async def list_project_vulnerabilities_endpoint( ), ), order: str = Query(default="desc", pattern=r"^(asc|desc)$"), - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, list CVE " - "findings of this SPECIFIC succeeded scan instead of the project's " - "latest succeeded scan. Must belong to this project and be succeeded, " - "else 404. Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: @@ -243,15 +236,7 @@ async def list_project_vulnerabilities_endpoint( async def list_upgrade_clusters_endpoint( request: Request, project_id: uuid.UUID, - scan_id: uuid.UUID | None = Query( - default=None, - description=( - "Optional release-snapshot anchor (feature #28). When given, cluster " - "the CVE findings of this SPECIFIC succeeded scan instead of the " - "project's latest succeeded scan. Must belong to this project and be " - "succeeded, else 404. Omit for the default latest-succeeded behaviour." - ), - ), + scan_id: uuid.UUID | None = Depends(snapshot_anchor), session: AsyncSession = Depends(get_db), actor: CurrentUser = Depends(require_role("developer")), ) -> Response: diff --git a/apps/backend/core/errors.py b/apps/backend/core/errors.py index 328141a..03dbf28 100644 --- a/apps/backend/core/errors.py +++ b/apps/backend/core/errors.py @@ -28,6 +28,8 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.requests import Request +from services.scan_resolution import SnapshotScanNotFound + PROBLEM_CONTENT_TYPE = "application/problem+json" # Sentinel emitted in place of any user-provided value that Pydantic v2's @@ -112,6 +114,22 @@ async def _http_exception_handler( instance=request.url.path, ) + @app.exception_handler(SnapshotScanNotFound) + async def _snapshot_not_found_handler( + request: Request, exc: SnapshotScanNotFound + ) -> JSONResponse: + # Raised by the shared ``?scan_id=`` / ``?release=`` anchor dependency, + # which runs before the endpoint body and so cannot use the routers' + # local try/except. The wording is deliberately the same for an unknown + # version label and an unusable scan id: the anchor existence-hides, so + # the response must not distinguish them. + return problem_response( + status_code=status.HTTP_404_NOT_FOUND, + title="Scan Snapshot Not Found", + detail="No succeeded scan matching that anchor exists for this project.", + instance=request.url.path, + ) + @app.exception_handler(RequestValidationError) async def _validation_exception_handler( request: Request, exc: RequestValidationError diff --git a/apps/backend/tests/integration/test_release_snapshots_api.py b/apps/backend/tests/integration/test_release_snapshots_api.py index 6bb4a1e..c4b9252 100644 --- a/apps/backend/tests/integration/test_release_snapshots_api.py +++ b/apps/backend/tests/integration/test_release_snapshots_api.py @@ -857,6 +857,152 @@ async def test_gate_ref_with_no_succeeded_scan_does_not_borrow_another_branch( assert response.json()["critical_cve_count"] == 0 +# --------------------------------------------------------------------------- +# ?release= anchor — a permanent URL per version +# --------------------------------------------------------------------------- + + +async def _seed_labelled_project(client: AsyncClient): + """v1.0 (2 critical + forbidden licence) then an unlabelled newer scan.""" + team, user = await _seed_team_with_user(client) + project_id = await _seed_empty_project(client, team_id=team.id) + base = datetime(2026, 5, 20, tzinfo=UTC) + tagged = await _seed_succeeded_scan( + client, + project_id=project_id, + created_at=base, + n_critical=2, + forbidden_license=True, + release="v1.0", + ref="main", + ) + newer = await _seed_succeeded_scan( + client, project_id=project_id, created_at=base + timedelta(days=5), ref="main" + ) + return user, project_id, tagged, newer + + +async def test_release_anchor_reads_that_version_across_surfaces(client) -> None: + user, project_id, tagged, _newer = await _seed_labelled_project(client) + headers = _bearer_for(user) + + # Current state is the newer, empty scan on every surface. + overview = await client.get(f"/v1/projects/{project_id}/overview", headers=headers) + assert overview.json()["severity_distribution"]["critical"] == 0 + + # ?release= reaches the tagged snapshot without the caller knowing its id. + pinned = await client.get( + f"/v1/projects/{project_id}/overview", headers=headers, params={"release": "v1.0"} + ) + assert pinned.status_code == 200, pinned.text + assert pinned.json()["severity_distribution"]["critical"] == 2 + + gate = await client.get( + f"/v1/projects/{project_id}/gate-result", + headers=headers, + params={"release": "v1.0"}, + ) + assert gate.status_code == 200, gate.text + assert gate.json()["scan_id"] == str(tagged) + assert gate.json()["gate"] == "fail" + + notice = await client.get( + f"/v1/projects/{project_id}/notice", headers=headers, params={"release": "v1.0"} + ) + assert notice.status_code == 200, notice.text + assert "GPL-3.0-only" in notice.text + + vulns = await client.get( + f"/v1/projects/{project_id}/vulnerabilities", + headers=headers, + params={"release": "v1.0"}, + ) + assert vulns.json()["total"] == 2 + + +async def test_release_anchor_trims_and_404s_on_unknown_label(client) -> None: + user, project_id, tagged, _newer = await _seed_labelled_project(client) + headers = _bearer_for(user) + + trimmed = await client.get( + f"/v1/projects/{project_id}/overview", + headers=headers, + params={"release": " v1.0 "}, + ) + assert trimmed.status_code == 200, trimmed.text + assert trimmed.json()["severity_distribution"]["critical"] == 2 + + unknown = await client.get( + f"/v1/projects/{project_id}/overview", headers=headers, params={"release": "v9.9"} + ) + assert unknown.status_code == 404, unknown.text + assert unknown.headers["content-type"].startswith(PROBLEM_JSON) + + +async def test_release_anchor_does_not_leak_another_projects_version(client) -> None: + user, project_id, _tagged, _newer = await _seed_labelled_project(client) + headers = _bearer_for(user) + other_team, _ = await _seed_team_with_user(client) + other_project = await _seed_empty_project(client, team_id=other_team.id) + await _seed_succeeded_scan( + client, + project_id=other_project, + created_at=datetime(2026, 5, 21, tzinfo=UTC), + n_critical=3, + release="theirs-1.0", + ) + + response = await client.get( + f"/v1/projects/{project_id}/overview", + headers=headers, + params={"release": "theirs-1.0"}, + ) + assert response.status_code == 404, response.text + + +async def test_scan_id_wins_when_both_anchors_are_given(client) -> None: + # scan_id names one immutable snapshot; a label names whichever snapshot + # currently holds it. The more specific one must not be overridden. + user, project_id, tagged, newer = await _seed_labelled_project(client) + headers = _bearer_for(user) + + response = await client.get( + f"/v1/projects/{project_id}/overview", + headers=headers, + params={"scan_id": str(newer), "release": "v1.0"}, + ) + assert response.status_code == 200, response.text + assert response.json()["severity_distribution"]["critical"] == 0 + + +async def test_release_anchor_ignores_a_superseded_claim_on_the_label(client) -> None: + # Rescanning v1.0 moves the label; the anchor must follow it to the winner + # rather than resolving the snapshot that used to hold it. + user, project_id, first, _newer = await _seed_labelled_project(client) + headers = _bearer_for(user) + factory = await _factory(client) + async with factory() as session: + scan = (await session.execute(select(Scan).where(Scan.id == first))).scalar_one() + scan.superseded_at = datetime(2026, 5, 26, tzinfo=UTC) + await session.commit() + rescan = await _seed_succeeded_scan( + client, + project_id=project_id, + created_at=datetime(2026, 5, 27, tzinfo=UTC), + n_high=1, + release="v1.0", + ref="main", + ) + + gate = await client.get( + f"/v1/projects/{project_id}/gate-result", + headers=headers, + params={"release": "v1.0"}, + ) + assert gate.status_code == 200, gate.text + assert gate.json()["scan_id"] == str(rescan) + + # --------------------------------------------------------------------------- # IDOR + invalid-pin guards # --------------------------------------------------------------------------- diff --git a/apps/backend/tests/unit/openapi_endpoints.json b/apps/backend/tests/unit/openapi_endpoints.json index 78be542..6eb2e38 100644 --- a/apps/backend/tests/unit/openapi_endpoints.json +++ b/apps/backend/tests/unit/openapi_endpoints.json @@ -227,6 +227,7 @@ "offset", "order", "project_id", + "release", "scan_id", "search", "sort" @@ -242,6 +243,7 @@ "order", "outdated", "project_id", + "release", "scan_id", "search", "severity", @@ -249,6 +251,7 @@ ], "GET /v1/projects/{project_id}/dependency-graph": [ "project_id", + "release", "scan_id" ], "GET /v1/projects/{project_id}/diff": [ @@ -259,6 +262,7 @@ "GET /v1/projects/{project_id}/gate-result": [ "project_id", "ref", + "release", "scan_id" ], "GET /v1/projects/{project_id}/governance": [ @@ -271,6 +275,7 @@ "offset", "order", "project_id", + "release", "review_flag", "scan_id", "search", @@ -280,6 +285,7 @@ "download", "format", "project_id", + "release", "scan_id" ], "GET /v1/projects/{project_id}/obligations": [ @@ -289,6 +295,7 @@ "offset", "order", "project_id", + "release", "scan_id", "search", "sort" @@ -299,6 +306,7 @@ ], "GET /v1/projects/{project_id}/overview": [ "project_id", + "release", "scan_id" ], "GET /v1/projects/{project_id}/releases": [ @@ -317,6 +325,7 @@ "page", "page_size", "project_id", + "release", "scan_id", "type" ], @@ -324,6 +333,7 @@ "format", "profile", "project_id", + "release", "scan_id" ], "GET /v1/projects/{project_id}/sbom/attestation": [ @@ -358,12 +368,14 @@ "path", "project_id", "raw", + "release", "scan_id" ], "GET /v1/projects/{project_id}/source-tree": [ "page", "path", "project_id", + "release", "scan_id", "size" ], @@ -379,6 +391,7 @@ "order", "project_id", "reachable", + "release", "scan_id", "search", "severity", @@ -388,6 +401,7 @@ ], "GET /v1/projects/{project_id}/vulnerabilities/upgrade-clusters": [ "project_id", + "release", "scan_id" ], "GET /v1/projects/{project_id}/vulnerability-report.pdf": [ diff --git a/docs-site/docs/user-guide/projects.md b/docs-site/docs/user-guide/projects.md index 1d10014..1e673f0 100644 --- a/docs-site/docs/user-guide/projects.md +++ b/docs-site/docs/user-guide/projects.md @@ -245,6 +245,8 @@ Over the API, `GET /v1/projects/{id}/releases?release=4.0` returns the snapshot The match is exact, not a search — `4.0` does not find `4.0.1`. Surrounding whitespace is trimmed on both sides. +Every detail read also takes the label directly, so you rarely need the two-step. `GET /v1/projects/{id}/notice?release=4.0`, `.../overview?release=4.0`, `.../vulnerabilities?release=4.0` and the rest read as of that version — a permanent URL you can put in a ticket, because the label follows the snapshot that currently holds it. Pass `?scan_id=` instead to pin one immutable scan; if you pass both, `scan_id` wins. An unknown label returns `404`, the same response an unusable `scan_id` gets, so neither reveals whether the other exists. + ### Filtering by branch Both `GET /v1/projects/{id}/releases?ref=main` and `GET /v1/projects/{id}/scans?ref=main` narrow to one branch. A bare branch and a fully-qualified ref (`refs/heads/main`, `refs/pull/12/merge`) both work — they are normalized the same way scan triggers normalize them, so a branch reaches its own rows either way. diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md index 290a821..7a8d970 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/projects.md @@ -244,6 +244,8 @@ API에서는 `GET /v1/projects/{id}/releases?release=4.0`이 그 버전 라벨 검색이 아니라 정확히 일치하는 값을 찾습니다. `4.0`으로는 `4.0.1`을 찾지 못합니다. 앞뒤 공백은 양쪽 모두 제거하고 비교합니다. +상세 조회도 라벨을 직접 받으므로 두 단계를 거칠 일은 드뭅니다. `GET /v1/projects/{id}/notice?release=4.0`, `.../overview?release=4.0`, `.../vulnerabilities?release=4.0` 등이 그 버전 기준으로 응답합니다. 라벨은 현재 그것을 가진 스냅샷을 따라가므로 티켓에 적어 둘 수 있는 영구 주소가 됩니다. 변하지 않는 스캔 하나를 고정하려면 `?scan_id=`를 쓰고, 둘 다 주면 `scan_id`가 이깁니다. 없는 라벨은 `404`를 반환하는데 쓸 수 없는 `scan_id`와 같은 응답이라, 어느 쪽도 상대의 존재 여부를 알려 주지 않습니다. + ### 브랜치로 거르기 `GET /v1/projects/{id}/releases?ref=main`과 `GET /v1/projects/{id}/scans?ref=main` 모두 브랜치 하나로 좁힙니다. 짧은 브랜치명과 전체 형식(`refs/heads/main`, `refs/pull/12/merge`) 둘 다 받습니다. 스캔 트리거와 같은 방식으로 정규화하므로 어느 쪽으로 적어도 그 브랜치의 행에 닿습니다.