From 45f871f4a67c8d61bac6ed1b68463ab3710e4802 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 00:26:00 +0900 Subject: [PATCH 1/9] feat(gate): block builds on known-malicious packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fifth gate axis, blocking regardless of severity — a malicious package has no honest version to upgrade to, so weighing it against a CVE threshold would be the wrong question. On by default (GATE_MALICIOUS_ENABLED). A false positive would otherwise stop every build until the advisory is retracted upstream, so a license policy can carry malicious_exceptions with a mandatory expiry. The waiver drops the component from the gate count only; the badge, filter and drawer are untouched. --- actions/scan/action.yml | 16 +- .../0048_malicious_policy_exceptions.py | 53 +++ apps/backend/api/v1/policy_gate.py | 2 + apps/backend/models/license_policy.py | 15 + apps/backend/schemas/license_policy.py | 48 +++ apps/backend/schemas/policy_gate.py | 17 + apps/backend/services/policy_gate.py | 159 ++++++++ .../services/test_policy_gate_malicious.py | 351 ++++++++++++++++++ 8 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 apps/backend/alembic/versions/0048_malicious_policy_exceptions.py create mode 100644 apps/backend/tests/unit/services/test_policy_gate_malicious.py diff --git a/actions/scan/action.yml b/actions/scan/action.yml index 4b6f81a..a0a5f54 100644 --- a/actions/scan/action.yml +++ b/actions/scan/action.yml @@ -64,6 +64,12 @@ outputs: forbidden-license-count: description: 'Distinct components carrying a forbidden-classification license.' value: ${{ steps.gate.outputs.forbidden_license_count }} + malicious-component-count: + description: >- + Distinct components the malicious-package snapshot flags. These block + regardless of severity — remove the package and rotate the credentials + this build could reach; upgrading does not help. + value: ${{ steps.gate.outputs.malicious_component_count }} # ----------------------------------------------------------------------------- # Steps @@ -266,6 +272,7 @@ runs: reason=$(echo "$json_body" | jq -r '.reason // ""') critical=$(echo "$json_body" | jq -r '.critical_cve_count // 0') forbidden=$(echo "$json_body" | jq -r '.forbidden_license_count // 0') + malicious=$(echo "$json_body" | jq -r '.malicious_component_count // 0') # GITHUB_OUTPUT supports multi-line via heredoc, but reason is single # line by contract — we still defensively strip newlines. @@ -276,6 +283,7 @@ runs: echo "reason=${reason_oneline}" echo "critical_cve_count=${critical}" echo "forbidden_license_count=${forbidden}" + echo "malicious_component_count=${malicious}" } >> "$GITHUB_OUTPUT" # Job summary block — visible on the workflow run page. @@ -287,12 +295,18 @@ runs: echo "| Verdict | **${gate}** |" echo "| Critical CVEs | ${critical} |" echo "| Forbidden licenses | ${forbidden} |" + # Only shown when non-zero: a "Malicious packages | 0" row on every + # passing build would read as an all-clear, and the count is 0 when + # the axis is switched off too. + if [ "${malicious}" != "0" ]; then + echo "| Known-malicious packages | ${malicious} — remove, rotate credentials |" + fi if [ -n "${reason_oneline}" ]; then echo "| Reason | ${reason_oneline} |" fi } >> "$GITHUB_STEP_SUMMARY" - echo "TrustedOSS gate=${gate} critical=${critical} forbidden=${forbidden}" + echo "TrustedOSS gate=${gate} critical=${critical} forbidden=${forbidden} malicious=${malicious}" # ------------------------------------------------------------------------- # 4) Post PR comment — only on pull_request events, only when caller diff --git a/apps/backend/alembic/versions/0048_malicious_policy_exceptions.py b/apps/backend/alembic/versions/0048_malicious_policy_exceptions.py new file mode 100644 index 0000000..9931a87 --- /dev/null +++ b/apps/backend/alembic/versions/0048_malicious_policy_exceptions.py @@ -0,0 +1,53 @@ +"""license_policies.malicious_exceptions (#26 MAL-2). + +Adds a JSONB array of temporary waivers for packages the malicious snapshot +flags: ``[{"component_purl", "reason", "expires_at"}, ...]``. + +Why a separate array rather than a shape on ``license_exceptions``: that one +keys on ``spdx_id`` and this one on a package identifier, so a single array +would need a discriminator and both readers would have to filter. The expiry +rule differs too — ``expires_at`` is REQUIRED here. + +Why the expiry is mandatory: a licence waiver can reasonably be permanent +(counsel cleared this dependency and the answer will not change), but a +malicious flag always resolves. Either the advisory is wrong, in which case +challenging it upstream drops the package from the next snapshot, or it is +right, in which case the package has to go. An open-ended waiver would only +park an unfinished decision out of sight. + +The waiver removes a component from the gate count. It does not touch the +badge, the filter or the drawer — deferring a block is not the same as hiding +the signal. + +Forward-only per CLAUDE.md §6. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0048" +down_revision: str | None = "0047" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "license_policies", + sa.Column( + "malicious_exceptions", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) + + +def downgrade() -> None: + raise NotImplementedError("Migrations are forward-only (CLAUDE.md §6).") diff --git a/apps/backend/api/v1/policy_gate.py b/apps/backend/api/v1/policy_gate.py index fefaaaf..c2ecbbc 100644 --- a/apps/backend/api/v1/policy_gate.py +++ b/apps/backend/api/v1/policy_gate.py @@ -221,6 +221,8 @@ def _build_response_body(result: GateResult) -> GateResultResponse: epss_threshold=result.epss_threshold, reachable_critical_cve_count=result.reachable_critical_cve_count, reachable_gate_enforced=result.reachable_gate_enforced, + malicious_component_count=result.malicious_component_count, + malicious_gate_enforced=result.malicious_gate_enforced, project_id=result.project_id, scan_id=result.scan_id, evaluated_at=result.evaluated_at, diff --git a/apps/backend/models/license_policy.py b/apps/backend/models/license_policy.py index a0f0295..a734017 100644 --- a/apps/backend/models/license_policy.py +++ b/apps/backend/models/license_policy.py @@ -207,6 +207,21 @@ class LicensePolicy(Base): JSONB, nullable=False, server_default=EMPTY_JSONB_ARR ) + # Array of {"component_purl","reason","expires_at"} — temporary waivers for + # packages the malicious snapshot flags (#26). Kept separate from + # ``license_exceptions`` because that array keys on ``spdx_id`` and this one + # on a package identifier, and because ``expires_at`` is REQUIRED here: a + # malicious flag is either an upstream mistake that gets retracted or a + # package that has to go, so an open-ended waiver only hides an unfinished + # decision. Shape validation lives in the Pydantic layer. + # + # The waiver removes the component from the GATE COUNT only. The badge, the + # filter and the drawer keep showing it — this is a deferral of blocking, + # not a way to make the signal disappear. + malicious_exceptions: Mapped[list[Any]] = mapped_column( + JSONB, nullable=False, server_default=EMPTY_JSONB_ARR + ) + # Gate posture for licenses absent from both the override map and the static # catalog. One of allowed|conditional|forbidden. Default ``conditional`` — # an uncatalogued license should be reviewed, not silently allowed/blocked. diff --git a/apps/backend/schemas/license_policy.py b/apps/backend/schemas/license_policy.py index eb1d71a..67c678e 100644 --- a/apps/backend/schemas/license_policy.py +++ b/apps/backend/schemas/license_policy.py @@ -145,6 +145,44 @@ def _check_purl(cls, v: str | None) -> str | None: return v +class MaliciousException(BaseModel): + """One temporary waiver for a package the malicious snapshot flags (#26). + + Separate from :class:`LicenseException` rather than an extra shape on it: + that one keys on ``spdx_id`` and this one on a package identifier, and the + expiry rules differ. + + ``expires_at`` is REQUIRED here, where the licence waiver treats it as + optional. A licence exception can reasonably be permanent — a lawyer + cleared this dependency and the answer does not change. A malicious flag is + either a mistake upstream (fixed by challenging the advisory, which lands + in the next snapshot) or correct (in which case the package must go). Both + resolve, so an open-ended waiver would only hide an unfinished decision. + """ + + model_config = ConfigDict(extra="forbid") + + component_purl: str = Field(..., min_length=1, max_length=_MAX_PURL_LEN) + reason: str = Field(..., min_length=1, max_length=_MAX_REASON_LEN) + expires_at: datetime + + @field_validator("component_purl") + @classmethod + def _check_purl(cls, v: str) -> str: + if not v.strip(): + raise ValueError("component_purl must be non-empty") + if _has_control_chars(v): + raise ValueError("component_purl must not contain control characters") + return v + + @field_validator("reason") + @classmethod + def _check_reason(cls, v: str) -> str: + if _has_control_chars(v.replace("\n", "").replace("\r", "").replace("\t", "")): + raise ValueError("reason must not contain control characters") + return v + + # --------------------------------------------------------------------------- # Upsert input # --------------------------------------------------------------------------- @@ -163,6 +201,7 @@ class LicensePolicyUpsertIn(BaseModel): name: str | None = Field(default=None, max_length=_MAX_NAME_LEN) category_overrides: dict[str, PolicyCategory] = Field(default_factory=dict) license_exceptions: list[LicenseException] = Field(default_factory=list) + malicious_exceptions: list[MaliciousException] = Field(default_factory=list) unknown_license_category: PolicyCategory = "conditional" compound_operator_strategy: dict[CompoundOperator, CompoundStrategy] = Field( default_factory=lambda: _default_compound_strategy() @@ -194,6 +233,15 @@ def _check_exceptions(cls, v: list[LicenseException]) -> list[LicenseException]: raise ValueError(f"license_exceptions exceeds {_MAX_EXCEPTIONS} entries") return v + @field_validator("malicious_exceptions") + @classmethod + def _check_malicious_exceptions( + cls, v: list[MaliciousException] + ) -> list[MaliciousException]: + if len(v) > _MAX_EXCEPTIONS: + raise ValueError(f"malicious_exceptions exceeds {_MAX_EXCEPTIONS} entries") + return v + @model_validator(mode="after") def _check_compound_keys(self) -> LicensePolicyUpsertIn: # Pydantic already constrains keys to CompoundOperator + values to diff --git a/apps/backend/schemas/policy_gate.py b/apps/backend/schemas/policy_gate.py index 072d98a..c34920e 100644 --- a/apps/backend/schemas/policy_gate.py +++ b/apps/backend/schemas/policy_gate.py @@ -81,6 +81,23 @@ class GateResultResponse(BaseModel): "when the EPSS gate is disabled (unset/unparseable env), in which case the " "gate behaves exactly as the critical-CVE + forbidden-license gate.", ) + malicious_component_count: int = Field( + default=0, + ge=0, + description="Distinct components on the evaluated scan that the vendored " + "OSV MAL- snapshot lists as known-malicious. These block regardless of " + "severity: a malicious package was published to attack whoever installs " + "it, so the response is removal plus rotating the credentials the build " + "could reach, not an upgrade. Not a vulnerability count — these never " + "appear in ``critical_cve_count`` or any severity total.", + ) + malicious_gate_enforced: bool = Field( + default=True, + description="Whether the known-malicious axis was active for this " + "evaluation (``GATE_MALICIOUS_ENABLED``, on by default). When ``false`` " + "the count is 0 because nothing was checked, NOT because nothing was " + "found — consumers must not render that as a clean result.", + ) reachable_critical_cve_count: int = Field( default=0, ge=0, diff --git a/apps/backend/services/policy_gate.py b/apps/backend/services/policy_gate.py index 021c643..dade6f0 100644 --- a/apps/backend/services/policy_gate.py +++ b/apps/backend/services/policy_gate.py @@ -86,6 +86,7 @@ LicenseFinding, LicensePolicy, Project, + ScanComponent, VulnerabilityFinding, ) from models import ( @@ -96,6 +97,7 @@ ) from services.license_expression import evaluate_expression from services.license_policy_service import effective_category, get_effective_policy +from services.malicious import malicious_catalog from services.scan_resolution import latest_succeeded_scan_id log = structlog.get_logger("policy_gate.service") @@ -149,6 +151,13 @@ class GateResult: # ``reachable_relaxation_applied`` is False — the gate ran at full strength. # Consumers (SCA comment) use this to render an accurate advisory. reachable_relaxation_applied: bool = False + # Known-malicious components on this scan (#26). Blocks regardless of + # severity: a malicious package has no honest version to upgrade to, so the + # response is removal plus credential rotation. ``malicious_gate_enforced`` + # records whether the axis was active, so a consumer can tell "none found" + # from "not checked" — the same NULL-vs-clear distinction the column carries. + malicious_component_count: int = 0 + malicious_gate_enforced: bool = True # The latest-succeeded-scan resolver was PROMOTED to ``services.scan_resolution`` @@ -293,6 +302,23 @@ def _resolve_reachable_critical_only() -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} +def _resolve_gate_malicious_enabled() -> bool: + """Read ``GATE_MALICIOUS_ENABLED`` at evaluation time (core rule #11). + + Defaults to ON, unlike the EPSS and reachability knobs. Those tune how + strictly an existing signal is read; this one decides whether an active + attack blocks the build. Shipping it off by default would mean a package + published to steal the build's credentials passes CI until someone opts in. + + Only the exact falsy tokens disable, matching ``MALICIOUS_ENABLED``. + """ + return os.getenv("GATE_MALICIOUS_ENABLED", "true").strip().lower() not in { + "false", + "0", + "no", + } + + def _resolve_epss_threshold() -> float | None: """Read ``GATE_EPSS_THRESHOLD`` at evaluation time, or None if disabled. @@ -374,6 +400,112 @@ async def _count_forbidden_license_components( return int(result.scalar_one()) +async def _flagged_component_purls( + session: AsyncSession, + scan_id: uuid.UUID, +) -> list[str]: + """Versioned purls of the components on ``scan_id`` the snapshot flags (#26). + + Reads the catalog column rather than a findings table, because this signal + deliberately creates no finding: a malicious package is removed and the + build's credentials rotated, not patched, so putting it on the severity + axis would prescribe the wrong action. That makes the shape here match the + project overview's rollup rather than the licence axis beside it. + + Only ``flagged`` counts. A row that was never assessed is NULL and a row + the snapshot did not list is ``clear``; neither blocks, but they mean + different things and the gate must not read the first as the second. + + DISTINCT on ``component_version_id`` so a package reachable by several + dependency paths is returned once. The purls come back rather than a count + because the caller subtracts policy waivers before counting. + """ + stmt = ( + select( + func.distinct(ScanComponent.component_version_id), + ComponentVersion.purl_with_version, + ) + .select_from(ScanComponent) + .join( + ComponentVersion, + ComponentVersion.id == ScanComponent.component_version_id, + ) + .where(ScanComponent.scan_id == scan_id) + .where(ComponentVersion.malicious_state == "flagged") + ) + result = await session.execute(stmt) + return [row[1] for row in result.all()] + + +def _active_malicious_waivers( + policy: LicensePolicy | None, now: datetime +) -> frozenset[str]: + """Base purls waived by *policy* and not yet expired. + + An entry without a parseable ``expires_at`` is ignored rather than treated + as permanent: the schema requires the field, so a row missing it reached + the column some other way and the conservative reading of a malformed + waiver is "no waiver". + """ + if policy is None: + return frozenset() + waived: set[str] = set() + for entry in policy.malicious_exceptions or []: + if not isinstance(entry, dict): + continue + purl = entry.get("component_purl") + raw_expiry = entry.get("expires_at") + if not isinstance(purl, str) or not purl: + continue + if not isinstance(raw_expiry, str): + continue + try: + expires = datetime.fromisoformat(raw_expiry.replace("Z", "+00:00")) + except ValueError: + continue + if expires.tzinfo is None: + expires = expires.replace(tzinfo=UTC) + if expires > now: + waived.add(malicious_catalog.base_purl(purl)) + return frozenset(waived) + + +async def _resolve_malicious_count( + session: AsyncSession, + *, + project_id: uuid.UUID, + scan_id: uuid.UUID, + now: datetime, +) -> int: + """Blocking malicious-component count, with policy waivers removed. + + A waiver exists because the alternative is worse: an upstream false + positive would otherwise stop every build until the advisory is retracted, + which takes days. It buys time to challenge the advisory — nothing more — + so it is bounded by a mandatory expiry and it only affects this count. The + component keeps its badge everywhere else. + + Waivers are matched on the BASE purl (version stripped), because an + advisory that names a package names it across the versions it applies to; + asking an operator to waive each version separately during an incident + would be the wrong ergonomics. + """ + purls = await _flagged_component_purls(session, scan_id) + if not purls: + return 0 + + team_id = await _team_id_for_project(session, project_id) + policy = ( + await get_effective_policy(session, team_id=team_id) + if team_id is not None + else None + ) + waived = _active_malicious_waivers(policy, now) + if not waived: + return len(purls) + return sum(1 for purl in purls if malicious_catalog.base_purl(purl) not in waived) + + def _static_default_for(spdx_id: str) -> str: """Return the static-catalog category for a SINGLE simple SPDX id. @@ -587,6 +719,7 @@ def _build_reason( forbidden_license_count: int, epss_gate_count: int = 0, epss_threshold: float | None = None, + malicious_component_count: int = 0, *, reachable_critical_only: bool = False, ) -> str | None: @@ -619,6 +752,14 @@ def _build_reason( f"{epss_gate_count} open " f"{'CVE' if epss_gate_count == 1 else 'CVEs'} with EPSS >= {epss_threshold:g}", ) + if malicious_component_count > 0: + # Worded as an instruction, not a count: an upgrade is the wrong move + # here and the reason line is often all a CI reader sees. + parts.append( + f"{malicious_component_count} known-malicious " + f"{'package' if malicious_component_count == 1 else 'packages'} " + f"detected — remove and rotate exposed credentials", + ) if not parts: return None return "; ".join(parts) @@ -662,6 +803,8 @@ async def evaluate_gate( epss_threshold = _resolve_epss_threshold() # Opt-in reachable-only critical mode (default OFF → legacy behaviour). reachable_critical_only = _resolve_reachable_critical_only() + # #26 — on by default; see the resolver for why this one differs. + malicious_gate_enabled = _resolve_gate_malicious_enabled() if scan_id is None: # No signal: we explicitly pass. See module docstring. @@ -679,6 +822,8 @@ async def evaluate_gate( reachable_gate_enforced=reachable_critical_only, # No scan → nothing analysed → the relaxation can never have applied. reachable_relaxation_applied=False, + malicious_component_count=0, + malicious_gate_enforced=malicious_gate_enabled, ) log.info( "policy_gate.evaluated", @@ -692,6 +837,8 @@ async def evaluate_gate( reachable_critical_cve_count=0, reachable_gate_enforced=reachable_critical_only, reachable_relaxation_applied=False, + malicious_component_count=0, + malicious_gate_enforced=malicious_gate_enabled, reason=None, ) return result @@ -709,6 +856,13 @@ async def evaluate_gate( if epss_threshold is not None else 0 ) + malicious_component_count = ( + await _resolve_malicious_count( + session, project_id=project_id, scan_id=scan_id, now=evaluated_at + ) + if malicious_gate_enabled + else 0 + ) # The critical count that DRIVES the verdict. # @@ -759,6 +913,7 @@ async def evaluate_gate( forbidden_license_count, epss_gate_count, epss_threshold, + malicious_component_count, reachable_critical_only=relaxation_applies, ) gate: GateOutcome = "fail" if reason is not None else "pass" @@ -780,6 +935,8 @@ async def evaluate_gate( reachable_critical_cve_count=reachable_critical_cve_count, reachable_gate_enforced=reachable_critical_only, reachable_relaxation_applied=relaxation_applies, + malicious_component_count=malicious_component_count, + malicious_gate_enforced=malicious_gate_enabled, ) log.info( "policy_gate.evaluated", @@ -796,6 +953,8 @@ async def evaluate_gate( forbidden_license_count=forbidden_license_count, epss_gate_count=epss_gate_count, epss_threshold=epss_threshold, + malicious_component_count=malicious_component_count, + malicious_gate_enforced=malicious_gate_enabled, reason=reason, ) return result diff --git a/apps/backend/tests/unit/services/test_policy_gate_malicious.py b/apps/backend/tests/unit/services/test_policy_gate_malicious.py new file mode 100644 index 0000000..bc6b267 --- /dev/null +++ b/apps/backend/tests/unit/services/test_policy_gate_malicious.py @@ -0,0 +1,351 @@ +""" +DB-backed unit tests for the malicious axis of the build gate (#26 MAL-2a). + +The axis blocks regardless of severity, which is the whole point: a malicious +package has no honest version to upgrade to, so it must not be weighed against +a CVE threshold. What needs pinning is therefore not "does it fail" alone but +the lifecycle around it — a waiver that expires puts the block back, and +switching the axis off must not read as a clean build. + +Cases: + - a flagged component fails the gate, with the count and reason surfaced. + - `clear` and never-assessed components do not fail it. + - GATE_MALICIOUS_ENABLED=false skips the axis (count 0, enforced False). + - lifecycle sequence: flagged → fail → waiver → pass → waiver expires → fail. + - a waiver for a different package does not lift the block. + - the waiver matches on base purl, so it covers the versioned row. +""" + +from __future__ import annotations + +import os +import subprocess +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from tests._helpers import ( + make_membership, + make_organization, + make_project, + make_scan, + make_team, + make_user, + unique_suffix, +) + +BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent.parent + +pytestmark = pytest.mark.integration + + +def _require_database_url() -> str: + url = os.getenv("DATABASE_URL") + if not url: + pytest.skip("DATABASE_URL not set — skip malicious gate tests") + return url + + +@pytest.fixture(scope="module", autouse=True) +def _migrate_once() -> None: + _require_database_url() + result = subprocess.run( + ["alembic", "upgrade", "head"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + pytest.skip( + f"alembic upgrade head failed; malicious gate tests cannot run\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + +@pytest.fixture +async def db_session() -> AsyncIterator[AsyncSession]: + from core.audit import install_audit_listeners + from core.config import database_url + + engine = create_async_engine(database_url(), pool_pre_ping=True, future=True) + factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + install_audit_listeners(factory) + async with factory() as session: + yield session + await engine.dispose() + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + + +async def _seed_project(session: AsyncSession): + org = await make_organization(session) + team = await make_team(session, organization=org) + user = await make_user(session) + await make_membership(session, user=user, team=team, role="team_admin") + project = await make_project(session, team=team) + return org, team, project + + +async def _make_component( + session: AsyncSession, + *, + malicious_state: str | None, + advisory_id: str | None = None, +): + from models import Component, ComponentVersion + + suffix = unique_suffix() + purl = f"pkg:npm/gate-mal-{suffix}" + component = Component(purl=purl, package_type="npm", name=f"gate-mal-{suffix}") + session.add(component) + await session.commit() + await session.refresh(component) + + cv = ComponentVersion( + component_id=component.id, + version="1.0.0", + purl_with_version=f"{purl}@1.0.0", + malicious_state=malicious_state, + malicious_id=advisory_id, + malicious_source="osv.dev@seed" if malicious_state else None, + malicious_evaluated_at=datetime.now(tz=UTC) if malicious_state else None, + ) + session.add(cv) + await session.commit() + await session.refresh(cv) + return purl, cv + + +async def _attach(session: AsyncSession, *, scan_id, cv_id): + from models import ScanComponent + + session.add(ScanComponent(scan_id=scan_id, component_version_id=cv_id, direct=True)) + await session.commit() + + +async def _make_policy( + session: AsyncSession, + *, + org_id: uuid.UUID, + team_id: uuid.UUID, + malicious_exceptions: list | None = None, +): + from models import LicensePolicy + + policy = LicensePolicy( + organization_id=org_id, + team_id=team_id, + name="gate-mal-policy", + category_overrides={}, + license_exceptions=[], + malicious_exceptions=malicious_exceptions or [], + unknown_license_category="conditional", + enabled=True, + ) + session.add(policy) + await session.commit() + await session.refresh(policy) + return policy + + +async def _seeded_scan(session: AsyncSession, project): + scan = await make_scan(session, project=project, status="succeeded") + return scan + + +# --------------------------------------------------------------------------- +# The axis itself +# --------------------------------------------------------------------------- + + +async def test_a_flagged_component_fails_the_gate(db_session: AsyncSession) -> None: + from services.policy_gate import evaluate_gate + + _, _, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, cv = await _make_component( + db_session, malicious_state="flagged", advisory_id="MAL-0000-SEED" + ) + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.gate == "fail" + assert result.malicious_component_count == 1 + assert result.malicious_gate_enforced is True + assert result.reason is not None and "known-malicious" in result.reason + # It is not a vulnerability: the CVE axis stays untouched. + assert result.critical_cve_count == 0 + + +async def test_clear_and_unassessed_components_do_not_block( + db_session: AsyncSession, +) -> None: + from services.policy_gate import evaluate_gate + + _, _, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, clear_cv = await _make_component(db_session, malicious_state="clear") + _, unassessed_cv = await _make_component(db_session, malicious_state=None) + await _attach(db_session, scan_id=scan.id, cv_id=clear_cv.id) + await _attach(db_session, scan_id=scan.id, cv_id=unassessed_cv.id) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.gate == "pass" + assert result.malicious_component_count == 0 + + +async def test_disabled_axis_reports_zero_but_says_it_was_not_checked( + db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """0 with the axis off must be distinguishable from 0 with it on. + + Both render as "no malicious packages" if a consumer reads only the count, + which is why the flag rides alongside it. + """ + from services.policy_gate import evaluate_gate + + monkeypatch.setenv("GATE_MALICIOUS_ENABLED", "false") + + _, _, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, cv = await _make_component(db_session, malicious_state="flagged") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.gate == "pass" + assert result.malicious_component_count == 0 + assert result.malicious_gate_enforced is False + + +# --------------------------------------------------------------------------- +# Waiver lifecycle +# --------------------------------------------------------------------------- + + +async def test_waiver_lifts_the_block_then_expiry_puts_it_back( + db_session: AsyncSession, +) -> None: + """The sequence the waiver exists for, start to finish. + + A single-state test would pass on an implementation that never expires + anything — the expiry is the whole reason the field is mandatory, so the + return to blocking is what needs pinning. + """ + from services.policy_gate import evaluate_gate + + org, team, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + purl, cv = await _make_component(db_session, malicious_state="flagged") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + # 1. Flagged, no waiver → blocked. + first = await evaluate_gate(db_session, project.id, scan_id=scan.id) + assert first.gate == "fail" + + # 2. Waiver with a future expiry → the build moves again. + future = (datetime.now(tz=UTC) + timedelta(days=7)).isoformat() + policy = await _make_policy( + db_session, + org_id=org.id, + team_id=team.id, + malicious_exceptions=[ + { + "component_purl": purl, + "reason": "advisory challenged upstream, awaiting retraction", + "expires_at": future, + } + ], + ) + second = await evaluate_gate(db_session, project.id, scan_id=scan.id) + assert second.gate == "pass" + assert second.malicious_component_count == 0 + + # 3. The waiver expires → blocked again, with no action from anyone. + past = (datetime.now(tz=UTC) - timedelta(minutes=1)).isoformat() + policy.malicious_exceptions = [ + { + "component_purl": purl, + "reason": "advisory challenged upstream, awaiting retraction", + "expires_at": past, + } + ] + db_session.add(policy) + await db_session.commit() + + third = await evaluate_gate(db_session, project.id, scan_id=scan.id) + assert third.gate == "fail" + assert third.malicious_component_count == 1 + + +async def test_a_waiver_for_another_package_does_not_lift_the_block( + db_session: AsyncSession, +) -> None: + from services.policy_gate import evaluate_gate + + org, team, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, cv = await _make_component(db_session, malicious_state="flagged") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + future = (datetime.now(tz=UTC) + timedelta(days=7)).isoformat() + await _make_policy( + db_session, + org_id=org.id, + team_id=team.id, + malicious_exceptions=[ + { + "component_purl": "pkg:npm/some-other-package", + "reason": "unrelated", + "expires_at": future, + } + ], + ) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + assert result.gate == "fail" + assert result.malicious_component_count == 1 + + +async def test_waiver_written_with_a_version_still_matches( + db_session: AsyncSession, +) -> None: + """Waivers match on the base purl. + + An operator reaching for this during an incident will paste whatever the + UI showed them, which carries a version. Matching only the exact string + would silently fail to lift the block. + """ + from services.policy_gate import evaluate_gate + + org, team, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + purl, cv = await _make_component(db_session, malicious_state="flagged") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + future = (datetime.now(tz=UTC) + timedelta(days=7)).isoformat() + await _make_policy( + db_session, + org_id=org.id, + team_id=team.id, + malicious_exceptions=[ + { + "component_purl": f"{purl}@1.0.0", + "reason": "pasted from the drawer", + "expires_at": future, + } + ], + ) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + assert result.gate == "pass" From 2972c98570af405702b6ff121529f3feabed115e Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 00:26:08 +0900 Subject: [PATCH 2/9] docs: cover the malicious gate axis and its waiver The axis blocks regardless of severity and its waiver needs an expiry, both of which differ from every other gate knob, so they are spelled out rather than left to the reference table. Also states that switching the axis off reports 0 because nothing was checked, not because nothing was found. --- docs-site/docs/reference/analysis-types.md | 16 +++++++++++ docs-site/docs/reference/env-variables.md | 1 + docs-site/docs/reference/license-policies.md | 27 +++++++++++++++++++ .../current/reference/analysis-types.md | 14 ++++++++++ .../current/reference/env-variables.md | 1 + .../current/reference/license-policies.md | 25 +++++++++++++++++ 6 files changed, 84 insertions(+) diff --git a/docs-site/docs/reference/analysis-types.md b/docs-site/docs/reference/analysis-types.md index c13fa86..98f75f4 100644 --- a/docs-site/docs/reference/analysis-types.md +++ b/docs-site/docs/reference/analysis-types.md @@ -47,6 +47,22 @@ See [Scan a container image](../user-guide/scans.md#scan-a-container-image) and The build gate is not a scanner — it **evaluates** the output of a completed scan against your rules to reach a build verdict. It counts components whose license resolves to `forbidden` and vulnerabilities over the configured thresholds, then returns a pass/fail result. In CI, a failing gate exits with code `1` to block the build. Thresholds and posture are set through `GATE_*` environment variables (see [Environment variables](./env-variables.md)) and, per team or organization, through a [license policy](./license-policies.md) that re-classifies licenses dynamically before counting. +The gate has one axis that is not a threshold. A component the +[malicious-package snapshot](../user-guide/components-and-licenses.md#known-malicious-packages) +flags **blocks regardless of severity**, because a package published to attack +its installers has no honest version to upgrade to — the response is removal +plus rotating the credentials the build could reach. Turn the axis off with +`GATE_MALICIOUS_ENABLED=false`; note that the count then reads 0 because +nothing was checked, not because nothing was found. + +A false positive would otherwise stop every build until the advisory is +retracted upstream, which takes days. A [license policy](./license-policies.md) +can therefore carry `malicious_exceptions` — a package identifier, a reason, +and a **required expiry**. The waiver removes the component from the gate count +only: its badge, filter entry and drawer row stay exactly as they were, and the +block returns on its own when the waiver lapses. + + See [Approvals](../user-guide/approvals.md) for the human workflow around conditional licenses and [GitHub Actions](../ci-integration/github-actions.md) for the CI wiring. ## Reachability analysis {#reachability-detail} diff --git a/docs-site/docs/reference/env-variables.md b/docs-site/docs/reference/env-variables.md index 1b51b99..8669891 100644 --- a/docs-site/docs/reference/env-variables.md +++ b/docs-site/docs/reference/env-variables.md @@ -114,6 +114,7 @@ The CI build gate fails a build on Critical CVEs and forbidden licenses out of t | Key | Default | Read by | Description | |---|---|---|---| +| `GATE_MALICIOUS_ENABLED` | `true` | `config.py` | Whether the build gate blocks on known-malicious packages. On by default, unlike the other `GATE_*` knobs: those tune how strictly an existing signal is read, this one decides whether an active attack reaches production. Blocks regardless of severity — a malicious package has no honest version to upgrade to. When off the gate's `malicious_component_count` is 0 because nothing was checked, and `malicious_gate_enforced` says so. Only `false` / `0` / `no` disable. | | `GATE_EPSS_THRESHOLD` | (unset) | `config.py` | Optional EPSS gate. A value from `0` to `1`. When set, the build gate also fails if any open finding has `epss_score >= GATE_EPSS_THRESHOLD`, and the gate result carries `epss_gate_count` + `epss_threshold`. **Unset (the default) disables the EPSS gate** — only the existing Critical-CVE / forbidden-license conditions apply. Findings without an EPSS value never trip the gate. EPSS data is sourced from the Trivy DB, so only CVEs Trivy supplies a value for are eligible. | See [build gate](./glossary.md#build-gates) for the gate model and [Gate the build on EPSS](../ci-integration/github-actions.md#gate-the-build-on-epss-optional) for the CI walkthrough. diff --git a/docs-site/docs/reference/license-policies.md b/docs-site/docs/reference/license-policies.md index 66382a9..8de8cc2 100644 --- a/docs-site/docs/reference/license-policies.md +++ b/docs-site/docs/reference/license-policies.md @@ -52,6 +52,7 @@ turn dynamic policy off and back on without re-authoring it. | `name` | string \| null | Display label for the UI. | | `category_overrides` | object | SPDX id → `allowed` \| `conditional` \| `forbidden`. Replaces the catalog verdict for that exact id. | | `license_exceptions` | array | Explicit waivers — each forces the matched license to `allowed`. | +| `malicious_exceptions` | array | Time-boxed waivers for packages the malicious snapshot flags — gate count only. | | `unknown_license_category` | enum | Posture for licenses absent from the catalog and the override map. Default `conditional`. | | `compound_operator_strategy` | object | How a compound SPDX expression (`A AND B`, `A OR B`, `A WITH exc`) is resolved. | | `enabled` | bool | Master toggle. `false` → policy ignored during resolution. | @@ -83,6 +84,32 @@ to a single component instead of every component carrying the license. ] ``` +### `malicious_exceptions` + +Each entry needs `component_purl`, `reason` and `expires_at` — the expiry is +**required** here, unlike a licence waiver. A licence waiver can reasonably be +permanent: counsel cleared the dependency and the answer will not change. A +[malicious flag](../user-guide/components-and-licenses.md#known-malicious-packages) +always resolves — either the advisory is wrong, in which case challenging it +upstream drops the package from the next snapshot, or it is right, in which case +the package has to go. An open-ended waiver would only park that decision out of +sight. + +```json +[ + { + "component_purl": "pkg:npm/some-package", + "reason": "advisory challenged upstream, TICKET-456", + "expires_at": "2026-08-20T00:00:00Z" + } +] +``` + +The waiver removes the component from the **build gate count only**. Its badge, +its place in the `?malicious=true` filter and its drawer row are unchanged — this +defers a block while you sort the advisory out, it does not retract the finding. +Matching is on the base purl, so an entry pasted with a version still applies. + ### `compound_operator_strategy` ```json diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/analysis-types.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/analysis-types.md index 91ce5dc..1b2ff08 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/analysis-types.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/analysis-types.md @@ -47,6 +47,20 @@ TRUSCA는 코드와 그 의존성에 대해 서로 다른 여러 **종류**의 빌드 게이트는 스캐너가 아닙니다 — 완료된 스캔의 출력을 규칙에 대조해 빌드 판정을 내리는 **평가** 단계입니다. 라이선스가 `forbidden`으로 판정되는 컴포넌트와 설정된 임계값을 넘는 취약점을 세어 pass/fail 결과를 냅니다. CI에서 실패한 게이트는 exit code `1`로 빌드를 차단합니다. 임계값과 태세는 `GATE_*` 환경변수([환경변수](./env-variables.md) 참조)로, 팀·조직 단위로는 카운트 전에 라이선스를 동적으로 재분류하는 [라이선스 정책](./license-policies.md)으로 설정합니다. +게이트에는 임계값이 아닌 축이 하나 있습니다. +[악성 패키지 스냅샷](../user-guide/components-and-licenses.md#known-malicious-packages)이 +지목한 컴포넌트는 **심각도와 무관하게 차단**됩니다. 설치하는 쪽을 공격하려고 배포된 +패키지에는 올라갈 정상 버전이 없기 때문입니다. 대응은 제거와 자격증명 교체입니다. +`GATE_MALICIOUS_ENABLED=false`로 이 축을 끌 수 있지만, 끄면 개수가 0으로 나오는 것은 +찾지 못해서가 아니라 확인하지 않았기 때문입니다. + +오탐이 나면 상류에서 권고가 철회될 때까지 며칠 동안 모든 빌드가 멈춥니다. 그래서 +[라이선스 정책](./license-policies.md)에 `malicious_exceptions`를 둘 수 있습니다. +패키지 식별자와 사유, 그리고 **만료 시한(필수)**으로 이루어집니다. 이 면제는 게이트 +집계에서만 제외하며 배지·필터·드로어 표시는 그대로 두고, 시한이 지나면 차단이 저절로 +돌아옵니다. + + 조건부 라이선스를 둘러싼 사람의 워크플로우는 [승인](../user-guide/approvals.md)을, CI 배선은 [GitHub Actions](../ci-integration/github-actions.md)를 참조하세요. ## Reachability 분석 {#reachability-detail} diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/env-variables.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/env-variables.md index 3c7deda..0b4ece8 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/env-variables.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/env-variables.md @@ -113,6 +113,7 @@ CI 빌드 게이트는 기본적으로 Critical CVE와 금지 라이선스에서 | 키 | 기본값 | 읽는 위치 | 설명 | |---|---|---|---| +| `GATE_MALICIOUS_ENABLED` | `true` | `config.py` | 빌드 게이트가 알려진 악성 패키지를 차단할지 정합니다. 다른 `GATE_*` 설정과 달리 기본값이 켜짐입니다. 나머지는 이미 있는 신호를 얼마나 엄격히 읽을지 조정하지만 이것은 공격이 그대로 배포될지를 정하기 때문입니다. 심각도와 무관하게 차단합니다. 악성 패키지에는 올라갈 정상 버전이 없습니다. 끄면 `malicious_component_count`가 0이 되는데 이는 확인하지 않았다는 뜻이며 `malicious_gate_enforced`가 그것을 알려 줍니다. `false` / `0` / `no`만 끕니다. | | `GATE_EPSS_THRESHOLD` | (미설정) | `config.py` | 선택적 EPSS 게이트. `0`~`1` 값. 설정 시 미해결 결과 중 `epss_score >= GATE_EPSS_THRESHOLD`인 것이 있으면 빌드 게이트도 실패하며, 게이트 결과에 `epss_gate_count` + `epss_threshold`가 실립니다. **미설정(기본)이면 EPSS 게이트는 비활성** — 기존 Critical-CVE / 금지-라이선스 조건만 적용됩니다. EPSS 값이 없는 결과는 게이트를 트리거하지 않습니다. EPSS 데이터는 Trivy DB에서 옵니다 — Trivy가 값을 제공하는 CVE만 대상입니다. | 게이트 모델은 [빌드 게이트](./glossary.md#빌드-게이트), CI 워크스루는 [EPSS로 빌드 게이팅](../ci-integration/github-actions.md#epss로-빌드-게이팅-선택) 참고. diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/license-policies.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/license-policies.md index d388f1e..77c4e25 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/license-policies.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/reference/license-policies.md @@ -51,6 +51,7 @@ sidebar_position: 5 | `name` | string \| null | UI 표시 라벨. | | `category_overrides` | object | SPDX id → `allowed` \| `conditional` \| `forbidden`. 해당 id의 카탈로그 판정을 대체. | | `license_exceptions` | array | 명시적 예외 — 매칭된 라이선스를 `allowed`로 강제. | +| `malicious_exceptions` | 배열 | 악성으로 지목된 패키지의 기한부 면제 — 게이트 집계에만 적용됩니다. | | `unknown_license_category` | enum | 카탈로그·오버라이드 맵에 없는 라이선스의 자세. 기본 `conditional`. | | `compound_operator_strategy` | object | 복합 SPDX 식(`A AND B`·`A OR B`·`A WITH exc`) 해석 방식. | | `enabled` | bool | 마스터 토글. `false` → 해석 시 정책 무시. | @@ -82,6 +83,30 @@ sidebar_position: 5 ] ``` +### `malicious_exceptions` + +각 항목에는 `component_purl`, `reason`, `expires_at`이 필요하며 만료 시한은 +라이선스 면제와 달리 **필수**입니다. 라이선스 면제는 영구적일 수 있습니다. 법무 검토가 +끝났다면 답이 달라지지 않기 때문입니다. 반면 +[악성 표시](../user-guide/components-and-licenses.md#known-malicious-packages)는 항상 +결론이 납니다. 권고가 틀렸다면 상류에 이의를 제기해 다음 스냅샷에서 빠지고, 맞다면 +패키지를 걷어내야 합니다. 기한 없는 면제는 그 결정을 보이지 않는 곳에 미뤄 둘 뿐입니다. + +```json +[ + { + "component_purl": "pkg:npm/some-package", + "reason": "상류에 이의 제기함, TICKET-456", + "expires_at": "2026-08-20T00:00:00Z" + } +] +``` + +면제는 **빌드 게이트 집계에서만** 컴포넌트를 제외합니다. 배지도, `?malicious=true` +필터에 나오는 것도, 드로어 항목도 그대로입니다. 권고를 정리하는 동안 차단을 미루는 +장치이지 표시를 철회하는 것이 아닙니다. 대조는 버전을 뗀 식별자로 하므로 버전이 붙은 +채로 적어도 적용됩니다. + ### `compound_operator_strategy` ```json From fee0915cb9c849cfd790035ba63a4b516a27c21d Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 09:50:09 +0900 Subject: [PATCH 3/9] fix(gate): persist malicious waivers, cap them, and bound their lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upsert path never copied malicious_exceptions onto the row, so a waiver saved through the API returned 200 and did nothing. The tests missed it by building policies through the ORM. Fixed, and the field is now readable in the response — a waiver nobody can enumerate is one nobody reviews. Expiry gains the bounds the required field could not express: it must be in the future, carry a timezone, and stay inside MALICIOUS_WAIVE_MAX_DAYS (30, shorter than the licence cap — this one is a stopgap, not a decision). A malformed exceptions array now reads as no waivers instead of raising. --- apps/backend/schemas/license_policy.py | 8 ++ .../services/license_policy_service.py | 62 ++++++++++++ apps/backend/services/policy_gate.py | 95 ++++++++++++++----- 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/apps/backend/schemas/license_policy.py b/apps/backend/schemas/license_policy.py index 67c678e..890780a 100644 --- a/apps/backend/schemas/license_policy.py +++ b/apps/backend/schemas/license_policy.py @@ -270,6 +270,14 @@ class LicensePolicyOut(BaseModel): name: str | None category_overrides: dict[str, str] license_exceptions: list[dict[str, object]] + malicious_exceptions: list[dict[str, object]] = Field( + default_factory=list, + description=( + "Time-boxed waivers for packages the malicious snapshot flags. " + "Readable so an operator can see which waivers are live and when " + "they lapse — a waiver nobody can enumerate is one nobody reviews." + ), + ) unknown_license_category: str compound_operator_strategy: dict[str, str] enabled: bool diff --git a/apps/backend/services/license_policy_service.py b/apps/backend/services/license_policy_service.py index 42cd93d..3a773d2 100644 --- a/apps/backend/services/license_policy_service.py +++ b/apps/backend/services/license_policy_service.py @@ -115,6 +115,9 @@ def _now() -> datetime: # precedence steps 2–4, skipping the step-1 exception match. _DEFAULT_MAX_WAIVE_DAYS = 90 +# Shorter than the licence cap on purpose — a malicious waiver is a stopgap +# while an advisory is challenged upstream, not a settled decision. +_DEFAULT_MALICIOUS_WAIVE_DAYS = 30 # Default unknown-license posture when no policy row exists yet (mirrors # ``schemas.license_policy.LicensePolicyUpsertIn.unknown_license_category``). _DEFAULT_UNKNOWN_POSTURE = "conditional" @@ -172,6 +175,62 @@ def _base_category( return unknown_posture +def _max_malicious_waive_days() -> int: + """Cap on a malicious-package waiver's lifetime, in days. + + Deliberately shorter than the licence cap. A licence waiver can be a + settled decision — counsel cleared the dependency. A malicious waiver only + buys time to challenge the advisory upstream and wait for the next + snapshot, so a long one is a decision nobody finished making. + + Read at call time from ``MALICIOUS_WAIVE_MAX_DAYS`` (rule #11). + """ + raw = os.getenv("MALICIOUS_WAIVE_MAX_DAYS") + if raw is None or raw.strip() == "": + return _DEFAULT_MALICIOUS_WAIVE_DAYS + try: + value = int(raw.strip()) + except (TypeError, ValueError): + log.warning("license_policy.bad_malicious_waive_days", raw=raw) + return _DEFAULT_MALICIOUS_WAIVE_DAYS + if value <= 0: + log.warning("license_policy.bad_malicious_waive_days", raw=raw) + return _DEFAULT_MALICIOUS_WAIVE_DAYS + return value + + +def _enforce_malicious_ttl(exceptions: list[Any]) -> None: + """Reject a malicious waiver that is already expired or outlives the cap. + + The schema makes ``expires_at`` required; this adds the two bounds a + required field cannot express — that it is in the future at all, and that + it is not so far out as to be permanent in practice. Without the upper + bound ``9999-12-31`` is a valid answer, which is exactly the open-ended + waiver the required field was meant to prevent. + """ + max_days = _max_malicious_waive_days() + now = _now() + cap = now + timedelta(days=max_days) + for exc in exceptions: + expires_at = exc.expires_at + if expires_at.tzinfo is None: + # Naive input is read as UTC everywhere downstream; say so rather + # than letting an operator's local midnight drift by their offset. + raise LicensePolicyValidationError( + f"the waiver for '{exc.component_purl}' must give expires_at " + "with a timezone (e.g. 2026-08-20T00:00:00Z)" + ) + if expires_at <= now: + raise LicensePolicyValidationError( + f"the waiver for '{exc.component_purl}' has already expired" + ) + if expires_at > cap: + raise LicensePolicyValidationError( + f"the waiver for '{exc.component_purl}' exceeds the maximum of " + f"{max_days} days" + ) + + def _enforce_forbidden_ttl( exceptions: list[LicenseException], category_overrides: dict[str, Any] | None, @@ -233,6 +292,7 @@ def _apply_upsert(row: LicensePolicy, payload: LicensePolicyUpsertIn) -> None: row.name = payload.name row.category_overrides = dumped["category_overrides"] row.license_exceptions = dumped["license_exceptions"] + row.malicious_exceptions = dumped["malicious_exceptions"] row.unknown_license_category = payload.unknown_license_category row.compound_operator_strategy = dumped["compound_operator_strategy"] row.enabled = payload.enabled @@ -282,6 +342,7 @@ async def upsert_team_policy( payload.category_overrides, payload.unknown_license_category, ) + _enforce_malicious_ttl(payload.malicious_exceptions) bind_audit_team(team_id) @@ -520,6 +581,7 @@ async def upsert_org_policy( payload.category_overrides, payload.unknown_license_category, ) + _enforce_malicious_ttl(payload.malicious_exceptions) existing = ( await session.execute( diff --git a/apps/backend/services/policy_gate.py b/apps/backend/services/policy_gate.py index dade6f0..4f5662b 100644 --- a/apps/backend/services/policy_gate.py +++ b/apps/backend/services/policy_gate.py @@ -158,6 +158,9 @@ class GateResult: # from "not checked" — the same NULL-vs-clear distinction the column carries. malicious_component_count: int = 0 malicious_gate_enforced: bool = True + #: False when this scan's components were never evaluated — a count of 0 + #: then means "not checked", not "nothing found". + malicious_scan_assessed: bool = False # The latest-succeeded-scan resolver was PROMOTED to ``services.scan_resolution`` @@ -400,10 +403,25 @@ async def _count_forbidden_license_components( return int(result.scalar_one()) -async def _flagged_component_purls( +@dataclass(frozen=True) +class _MaliciousScanCounts: + """What the scan's catalog rows say about malicious packages. + + ``assessed`` exists so the gate can tell "nothing malicious" from "nobody + looked". Both produce a count of zero, and only one of them is reassuring. + Rows predating the feature, scans persisted with ``MALICIOUS_ENABLED=false`` + and scans whose snapshot failed to load all land in the second case. + """ + + flagged_purls: list[str] + assessed: int + total: int + + +async def _malicious_scan_counts( session: AsyncSession, scan_id: uuid.UUID, -) -> list[str]: +) -> _MaliciousScanCounts: """Versioned purls of the components on ``scan_id`` the snapshot flags (#26). Reads the catalog column rather than a findings table, because this signal @@ -416,14 +434,14 @@ async def _flagged_component_purls( the snapshot did not list is ``clear``; neither blocks, but they mean different things and the gate must not read the first as the second. - DISTINCT on ``component_version_id`` so a package reachable by several - dependency paths is returned once. The purls come back rather than a count + DISTINCT over the projected pair so a package reachable by several + dependency paths is counted once. The purls come back rather than a count because the caller subtracts policy waivers before counting. """ stmt = ( select( - func.distinct(ScanComponent.component_version_id), ComponentVersion.purl_with_version, + ComponentVersion.malicious_state, ) .select_from(ScanComponent) .join( @@ -431,10 +449,14 @@ async def _flagged_component_purls( ComponentVersion.id == ScanComponent.component_version_id, ) .where(ScanComponent.scan_id == scan_id) - .where(ComponentVersion.malicious_state == "flagged") + .distinct() + ) + rows = (await session.execute(stmt)).all() + flagged = [purl for purl, state in rows if state == "flagged"] + assessed = sum(1 for _, state in rows if state is not None) + return _MaliciousScanCounts( + flagged_purls=flagged, assessed=assessed, total=len(rows) ) - result = await session.execute(stmt) - return [row[1] for row in result.all()] def _active_malicious_waivers( @@ -449,8 +471,17 @@ def _active_malicious_waivers( """ if policy is None: return frozenset() + entries = policy.malicious_exceptions + if not isinstance(entries, list): + # JSONB holds whatever was written to it. A shape we cannot read is + # not a reason to fail every gate read for this team, and it is not a + # reason to honour waivers we cannot parse either — so: no waivers. + log.warning( + "policy_gate.malicious_exceptions_malformed", policy_id=str(policy.id) + ) + return frozenset() waived: set[str] = set() - for entry in policy.malicious_exceptions or []: + for entry in entries: if not isinstance(entry, dict): continue purl = entry.get("component_purl") @@ -476,8 +507,8 @@ async def _resolve_malicious_count( project_id: uuid.UUID, scan_id: uuid.UUID, now: datetime, -) -> int: - """Blocking malicious-component count, with policy waivers removed. +) -> tuple[int, _MaliciousScanCounts]: + """Blocking malicious count plus the raw scan tallies behind it. A waiver exists because the alternative is worse: an upstream false positive would otherwise stop every build until the advisory is retracted, @@ -490,9 +521,9 @@ async def _resolve_malicious_count( asking an operator to waive each version separately during an incident would be the wrong ergonomics. """ - purls = await _flagged_component_purls(session, scan_id) - if not purls: - return 0 + counts = await _malicious_scan_counts(session, scan_id) + if not counts.flagged_purls: + return 0, counts team_id = await _team_id_for_project(session, project_id) policy = ( @@ -502,8 +533,13 @@ async def _resolve_malicious_count( ) waived = _active_malicious_waivers(policy, now) if not waived: - return len(purls) - return sum(1 for purl in purls if malicious_catalog.base_purl(purl) not in waived) + return len(counts.flagged_purls), counts + blocking = sum( + 1 + for purl in counts.flagged_purls + if malicious_catalog.base_purl(purl) not in waived + ) + return blocking, counts def _static_default_for(spdx_id: str) -> str: @@ -823,7 +859,11 @@ async def evaluate_gate( # No scan → nothing analysed → the relaxation can never have applied. reachable_relaxation_applied=False, malicious_component_count=0, - malicious_gate_enforced=malicious_gate_enabled, + # Nothing was evaluated because nothing was scanned. Claiming the + # axis was enforced here would be the same lie as reporting a + # clean result for an unexamined scan. + malicious_gate_enforced=False, + malicious_scan_assessed=False, ) log.info( "policy_gate.evaluated", @@ -838,7 +878,8 @@ async def evaluate_gate( reachable_gate_enforced=reachable_critical_only, reachable_relaxation_applied=False, malicious_component_count=0, - malicious_gate_enforced=malicious_gate_enabled, + malicious_gate_enforced=False, + malicious_scan_assessed=False, reason=None, ) return result @@ -856,13 +897,19 @@ async def evaluate_gate( if epss_threshold is not None else 0 ) - malicious_component_count = ( - await _resolve_malicious_count( + if malicious_gate_enabled: + malicious_component_count, malicious_counts = await _resolve_malicious_count( session, project_id=project_id, scan_id=scan_id, now=evaluated_at ) - if malicious_gate_enabled - else 0 - ) + # "assessed" is what separates a clean scan from an unexamined one. A + # scan with components but none of them evaluated reports zero for the + # same reason an empty project does, and only one of those is good news. + malicious_scan_assessed = ( + malicious_counts.total == 0 or malicious_counts.assessed > 0 + ) + else: + malicious_component_count = 0 + malicious_scan_assessed = False # The critical count that DRIVES the verdict. # @@ -937,6 +984,7 @@ async def evaluate_gate( reachable_relaxation_applied=relaxation_applies, malicious_component_count=malicious_component_count, malicious_gate_enforced=malicious_gate_enabled, + malicious_scan_assessed=malicious_scan_assessed, ) log.info( "policy_gate.evaluated", @@ -955,6 +1003,7 @@ async def evaluate_gate( epss_threshold=epss_threshold, malicious_component_count=malicious_component_count, malicious_gate_enforced=malicious_gate_enabled, + malicious_scan_assessed=malicious_scan_assessed, reason=reason, ) return result From 1d22d981337b6b7b3bc51b3bfea14bb9217d4e3c Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 09:50:09 +0900 Subject: [PATCH 4/9] fix(gate): report whether the malicious axis actually ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A count of zero meant both "nothing malicious" and "nobody looked" — scans predating the feature, scans persisted with flagging off, and the no-scan path, which claimed the axis was enforced while checking nothing. malicious_scan_assessed now carries that apart, the way the reachability axis already separates "flag set" from "relaxation applied". Also fixes a func.distinct that compiled to SELECT DISTINCT over the whole row rather than the component it named. --- apps/backend/api/v1/policy_gate.py | 1 + apps/backend/schemas/policy_gate.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/apps/backend/api/v1/policy_gate.py b/apps/backend/api/v1/policy_gate.py index c2ecbbc..b3c50f4 100644 --- a/apps/backend/api/v1/policy_gate.py +++ b/apps/backend/api/v1/policy_gate.py @@ -223,6 +223,7 @@ def _build_response_body(result: GateResult) -> GateResultResponse: reachable_gate_enforced=result.reachable_gate_enforced, malicious_component_count=result.malicious_component_count, malicious_gate_enforced=result.malicious_gate_enforced, + malicious_scan_assessed=result.malicious_scan_assessed, project_id=result.project_id, scan_id=result.scan_id, evaluated_at=result.evaluated_at, diff --git a/apps/backend/schemas/policy_gate.py b/apps/backend/schemas/policy_gate.py index c34920e..2fed17f 100644 --- a/apps/backend/schemas/policy_gate.py +++ b/apps/backend/schemas/policy_gate.py @@ -91,6 +91,17 @@ class GateResultResponse(BaseModel): "could reach, not an upgrade. Not a vulnerability count — these never " "appear in ``critical_cve_count`` or any severity total.", ) + malicious_scan_assessed: bool = Field( + default=False, + description=( + "Whether this scan's components actually carry malicious verdicts. " + "``false`` means the scan predates the feature, ran with flagging " + "off, or hit a snapshot problem — so ``malicious_component_count`` " + "of 0 says nothing was checked rather than nothing was found. " + "``true`` on a scan with no components at all (there was nothing " + "to check)." + ), + ) malicious_gate_enforced: bool = Field( default=True, description="Whether the known-malicious axis was active for this " From 08fa4499cba21eae4cfaacf459aee18d2d721138 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 09:50:21 +0900 Subject: [PATCH 5/9] fix(queue): count malicious packages in the blocked-builds panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel listed builds blocked by three of the gate's axes and silently dropped anything blocked only by a malicious package — the axis whose builds most need listing. The parity test that exists to catch exactly this was not extended when the axis landed, so it stayed green. Waivers are not applied here; the count is an upper bound and the per-project gate result stays authoritative. --- apps/backend/schemas/action_queue.py | 9 +++ apps/backend/services/action_queue_service.py | 57 ++++++++++++++++++- .../test_action_queue_gate_parity.py | 44 ++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/backend/schemas/action_queue.py b/apps/backend/schemas/action_queue.py index 4a4fb10..c76731b 100644 --- a/apps/backend/schemas/action_queue.py +++ b/apps/backend/schemas/action_queue.py @@ -41,6 +41,15 @@ class GateBlockedProject(BaseModel): "when GATE_EPSS_THRESHOLD is unset, which disables that condition." ), ) + malicious_component_count: int = Field( + default=0, + ge=0, + description=( + "Components the malicious snapshot flags on this scan. An upper " + "bound: team policy waivers are resolved per project by the gate, " + "not here." + ), + ) class KevSlaBucket(BaseModel): diff --git a/apps/backend/services/action_queue_service.py b/apps/backend/services/action_queue_service.py index 986015c..74227e3 100644 --- a/apps/backend/services/action_queue_service.py +++ b/apps/backend/services/action_queue_service.py @@ -60,10 +60,12 @@ from core.security import CurrentUser from models import ( ComponentApproval, + ComponentVersion, License, LicenseFinding, Project, Scan, + ScanComponent, Vulnerability, VulnerabilityFinding, ) @@ -158,6 +160,45 @@ async def _gate_input_counts( return critical, forbidden +async def _malicious_counts( + session: AsyncSession, + *, + scan_ids: list[uuid.UUID], +) -> dict[uuid.UUID, int]: + """Components each scan carries that the malicious snapshot flags. + + A project can be blocked by this term alone — the gate fails on any + non-empty reason clause, and this is one of five. Omitting it here would + drop exactly those builds from the panel whose whole purpose is listing + blocked builds, and the most urgent ones at that: a malicious package is + an attack in the build, not a defect to schedule. + + Policy waivers are NOT applied. This panel answers "what is blocked", and + a waived component does not block — but the waiver is resolved per team in + `evaluate_gate`, and reproducing that here would duplicate the resolution + the parity test exists to prevent. The count is therefore an upper bound; + the per-project gate result is authoritative. + """ + if not scan_ids: + return {} + + stmt = ( + select( + ScanComponent.scan_id, + func.count(func.distinct(ScanComponent.component_version_id)), + ) + .select_from(ScanComponent) + .join( + ComponentVersion, + ComponentVersion.id == ScanComponent.component_version_id, + ) + .where(ScanComponent.scan_id.in_(scan_ids)) + .where(ComponentVersion.malicious_state == "flagged") + .group_by(ScanComponent.scan_id) + ) + return {sid: int(count) for sid, count in (await session.execute(stmt)).all()} + + async def _epss_counts( session: AsyncSession, *, @@ -269,11 +310,17 @@ async def _gate_blocked( ) epss = await _epss_counts(session, scan_ids=scan_ids) + malicious = await _malicious_counts(session, scan_ids=scan_ids) blocked_scan_ids = [ sid for sid in scan_ids - if critical.get(sid, 0) or forbidden.get(sid, 0) or epss.get(sid, 0) + if ( + critical.get(sid, 0) + or forbidden.get(sid, 0) + or epss.get(sid, 0) + or malicious.get(sid, 0) + ) ] if not blocked_scan_ids: return [] @@ -299,6 +346,7 @@ async def _gate_blocked( critical_cve_count=critical.get(scan_id, 0), forbidden_license_count=forbidden.get(scan_id, 0), epss_gate_count=epss.get(scan_id, 0), + malicious_component_count=malicious.get(scan_id, 0), ) for scan_id, project_id, name in rows ] @@ -307,7 +355,12 @@ async def _gate_blocked( # the one dropped at the BUCKET_LIMIT boundary changes at random. blocked.sort( key=lambda b: ( - -(b.critical_cve_count + b.forbidden_license_count + b.epss_gate_count), + -( + b.critical_cve_count + + b.forbidden_license_count + + b.epss_gate_count + + b.malicious_component_count + ), b.project_name, ) ) diff --git a/apps/backend/tests/integration/test_action_queue_gate_parity.py b/apps/backend/tests/integration/test_action_queue_gate_parity.py index 8b2b35c..dd15383 100644 --- a/apps/backend/tests/integration/test_action_queue_gate_parity.py +++ b/apps/backend/tests/integration/test_action_queue_gate_parity.py @@ -602,3 +602,47 @@ async def test_parity_holds_when_only_the_epss_gate_blocks( assert verdict.forbidden_license_count == 0 assert blocked, "the queue omitted a project blocked only by the EPSS gate" assert blocked[0].epss_gate_count == verdict.epss_gate_count + + +async def test_parity_holds_when_only_a_malicious_package_blocks( + db_session: AsyncSession, +) -> None: + """The panel must list a build blocked solely by a malicious package. + + This axis is the one an operator most needs to see in a list of blocked + builds — the response is removal plus credential rotation, not a scheduled + upgrade — and it is the one an aggregate written before the axis existed + silently omits. + """ + from services.action_queue_service import _blocked_for_projects + from services.policy_gate import evaluate_gate + + org = await make_organization(db_session) + team = await make_team(db_session, organization=org) + project_id, scan_id = await _project_with_scan(db_session, team=team) + + from models import ComponentVersion, ScanComponent + + cv_id = await _component_version(db_session) + cv = await db_session.get(ComponentVersion, cv_id) + assert cv is not None + cv.malicious_state = "flagged" + cv.malicious_id = "MAL-0000-PARITY" + cv.malicious_source = "osv.dev@seed" + db_session.add(cv) + db_session.add( + ScanComponent( + scan_id=scan_id, component_version_id=cv_id, direct=True, raw_data={} + ) + ) + await db_session.commit() + + verdict = await evaluate_gate(db_session, project_id) + blocked = await _blocked_for_projects(db_session, project_ids=[project_id]) + + assert verdict.gate == "fail" + assert verdict.critical_cve_count == 0 + assert verdict.forbidden_license_count == 0 + assert verdict.malicious_component_count == 1 + assert blocked, "the queue omitted a project blocked only by a malicious package" + assert blocked[0].malicious_component_count == verdict.malicious_component_count From 4ae738167998c195ef81bd557c44a4a42dee69df Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 09:50:21 +0900 Subject: [PATCH 6/9] fix(ui): say why a build blocked only by a malicious package failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate card assembles its reason from structured counts rather than the backend string, so a build failing on this axis alone rendered the generic copy above a grid reading "Critical CVEs 0 / Forbidden licenses 0". The governance band produced an empty reason for the same input. Both now carry the count, and the clause goes first — ahead of CVEs, as on the Overview chip, because it prescribes a different action. --- apps/backend/schemas/project_governance.py | 9 ++++++++ .../services/project_governance_service.py | 1 + .../src/features/projects/api/governance.ts | 1 + .../features/projects/api/projectDetailApi.ts | 4 ++++ .../projects/components/GateResultCard.tsx | 21 +++++++++++++++++++ .../projects/components/GovernanceBand.tsx | 16 +++++++++++++- .../src/locales/en/project_detail.json | 11 +++++++--- .../src/locales/ko/project_detail.json | 11 +++++++--- .../features/projects/GateResultCard.test.tsx | 2 ++ .../features/projects/GovernanceBand.test.tsx | 4 ++++ .../projects/ProjectDetailPage.test.tsx | 2 ++ 11 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/backend/schemas/project_governance.py b/apps/backend/schemas/project_governance.py index ab9baea..9747d08 100644 --- a/apps/backend/schemas/project_governance.py +++ b/apps/backend/schemas/project_governance.py @@ -41,6 +41,15 @@ class GovernanceGate(BaseModel): ge=0, description="Always zero when GATE_EPSS_THRESHOLD is unset, which disables that condition.", ) + malicious_component_count: int = Field( + default=0, + ge=0, + description=( + "Components the malicious snapshot flags on the anchored scan. " + "Carried so the band can say why a build is blocked when this is " + "the only axis failing — otherwise it renders zeros and no reason." + ), + ) scan_id: uuid.UUID | None = Field( default=None, description="The snapshot the verdict was computed from." ) diff --git a/apps/backend/services/project_governance_service.py b/apps/backend/services/project_governance_service.py index 2316c20..7800ecd 100644 --- a/apps/backend/services/project_governance_service.py +++ b/apps/backend/services/project_governance_service.py @@ -194,6 +194,7 @@ async def get_project_governance( critical_cve_count=gate_result.critical_cve_count, forbidden_license_count=gate_result.forbidden_license_count, epss_gate_count=gate_result.epss_gate_count, + malicious_component_count=gate_result.malicious_component_count, scan_id=current_scan_id, ), kev_sla=GovernanceKevSla(overdue=kev.overdue, due_soon=kev.due_soon), diff --git a/apps/frontend/src/features/projects/api/governance.ts b/apps/frontend/src/features/projects/api/governance.ts index 9ada8b9..eb0f8ed 100644 --- a/apps/frontend/src/features/projects/api/governance.ts +++ b/apps/frontend/src/features/projects/api/governance.ts @@ -22,6 +22,7 @@ export interface GovernanceGate { critical_cve_count: number; forbidden_license_count: number; epss_gate_count: number; + malicious_component_count: number; scan_id: string | null; } diff --git a/apps/frontend/src/features/projects/api/projectDetailApi.ts b/apps/frontend/src/features/projects/api/projectDetailApi.ts index b09fa83..da82cee 100644 --- a/apps/frontend/src/features/projects/api/projectDetailApi.ts +++ b/apps/frontend/src/features/projects/api/projectDetailApi.ts @@ -497,6 +497,10 @@ export interface GateResultResponse { * when the EPSS gate is disabled (`epss_threshold === null`). */ epss_gate_count: number; + /** #26 — components the malicious snapshot flags on the evaluated scan. */ + malicious_component_count: number; + /** Whether the malicious axis ran at all (false → the count means "not checked"). */ + malicious_scan_assessed: boolean; /** Active EPSS gate threshold in [0, 1], or `null` when the EPSS gate is off. */ epss_threshold: number | null; project_id: string; diff --git a/apps/frontend/src/features/projects/components/GateResultCard.tsx b/apps/frontend/src/features/projects/components/GateResultCard.tsx index bc67884..1f94aed 100644 --- a/apps/frontend/src/features/projects/components/GateResultCard.tsx +++ b/apps/frontend/src/features/projects/components/GateResultCard.tsx @@ -201,6 +201,16 @@ export function GateResultCard({ projectId, scanId }: GateResultCardProps) { emphasize={data.forbidden_license_count > 0} testid="gate-metric-forbidden" /> + {data.malicious_component_count > 0 ? ( + + ) : null} {data.epss_threshold != null ? ( 0) { + // First, ahead of the CVE clause — the same order the Overview chip uses. + // A CVE is a defect to schedule; this is an attack already in the build, + // and the instruction it carries (remove, rotate credentials) is not the + // one the other clauses imply. + clauses.unshift( + t("overview.gate_card.reason.malicious", { + count: data.malicious_component_count, + }), + ); + } if (data.forbidden_license_count > 0) { clauses.push( t("overview.gate_card.reason.forbidden_license", { diff --git a/apps/frontend/src/features/projects/components/GovernanceBand.tsx b/apps/frontend/src/features/projects/components/GovernanceBand.tsx index 9bf8710..36d5342 100644 --- a/apps/frontend/src/features/projects/components/GovernanceBand.tsx +++ b/apps/frontend/src/features/projects/components/GovernanceBand.tsx @@ -133,7 +133,12 @@ function TrendSpark({ points }: { points: GovernanceTrendPoint[] }) { * because that condition can block on its own. */ function gateReason( - gate: { critical_cve_count: number; forbidden_license_count: number; epss_gate_count: number }, + gate: { + critical_cve_count: number; + forbidden_license_count: number; + epss_gate_count: number; + malicious_component_count: number; + }, t: (key: string, options?: Record) => string, ): string { const clauses: string[] = []; @@ -143,6 +148,15 @@ function gateReason( if (gate.forbidden_license_count > 0) { clauses.push(t("governance.gate_reason_licenses", { count: gate.forbidden_license_count })); } + if (gate.malicious_component_count > 0) { + // First clause, not last: this one says remove-and-rotate, and a reader + // who stops after the first phrase should get that one. + clauses.unshift( + t("governance.gate_reason_malicious", { + count: gate.malicious_component_count, + }), + ); + } if (gate.epss_gate_count > 0) { clauses.push(t("governance.gate_reason_epss", { count: gate.epss_gate_count })); } diff --git a/apps/frontend/src/locales/en/project_detail.json b/apps/frontend/src/locales/en/project_detail.json index f49f9e5..4627089 100644 --- a/apps/frontend/src/locales/en/project_detail.json +++ b/apps/frontend/src/locales/en/project_detail.json @@ -223,13 +223,16 @@ "critical_cve": "{{count}} open critical CVE(s)", "forbidden_license": "{{count}} forbidden license(s)", "epss": "{{count}} finding(s) with EPSS ≥ {{threshold}}", - "fallback": "The build gate failed. See the metrics below for details." + "fallback": "The build gate failed. See the metrics below for details.", + "malicious": "{{count}} known-malicious package — remove it and rotate the credentials this build could reach", + "malicious_plural": "{{count}} known-malicious packages — remove them and rotate the credentials this build could reach" }, "errors": { "not_found": "This project no longer exists or you no longer have access to it.", "forbidden": "You do not have permission to view this project's build gate.", "unknown": "Could not load the build gate. Please try again." - } + }, + "malicious_packages": "Known-malicious packages" }, "errors": { "title": "Could not load overview.", @@ -1293,6 +1296,8 @@ "error": "Could not load the governance summary. Retry before reading its absence as good news.", "gate_reason_critical": "{{count}} critical", "gate_reason_licenses": "{{count}} forbidden licences", - "gate_reason_epss": "{{count}} above the EPSS threshold" + "gate_reason_epss": "{{count}} above the EPSS threshold", + "gate_reason_malicious": "{{count}} known-malicious package", + "gate_reason_malicious_plural": "{{count}} known-malicious packages" } } diff --git a/apps/frontend/src/locales/ko/project_detail.json b/apps/frontend/src/locales/ko/project_detail.json index 00e7dfc..7c3b1d9 100644 --- a/apps/frontend/src/locales/ko/project_detail.json +++ b/apps/frontend/src/locales/ko/project_detail.json @@ -223,13 +223,16 @@ "critical_cve": "미해결 Critical CVE {{count}}건", "forbidden_license": "금지 라이선스 {{count}}건", "epss": "EPSS ≥ {{threshold}} 취약점 {{count}}건", - "fallback": "빌드 차단 게이트가 실패했습니다. 자세한 내용은 아래 지표를 확인하세요." + "fallback": "빌드 차단 게이트가 실패했습니다. 자세한 내용은 아래 지표를 확인하세요.", + "malicious": "알려진 악성 패키지 {{count}}개 — 제거하고 이 빌드가 접근할 수 있던 자격증명을 교체하십시오", + "malicious_plural": "알려진 악성 패키지 {{count}}개 — 제거하고 이 빌드가 접근할 수 있던 자격증명을 교체하십시오" }, "errors": { "not_found": "이 프로젝트가 더 이상 존재하지 않거나 접근 권한이 없습니다.", "forbidden": "이 프로젝트의 빌드 차단 게이트를 볼 권한이 없습니다.", "unknown": "빌드 차단 게이트를 불러오지 못했습니다. 다시 시도해 주세요." - } + }, + "malicious_packages": "알려진 악성 패키지" }, "errors": { "title": "개요를 불러오지 못했습니다.", @@ -1293,6 +1296,8 @@ "error": "거버넌스 요약을 불러오지 못했습니다. 값이 비어 있는 것을 좋은 신호로 읽기 전에 다시 시도하세요.", "gate_reason_critical": "치명 {{count}}건", "gate_reason_licenses": "금지 라이선스 {{count}}건", - "gate_reason_epss": "EPSS 임계 초과 {{count}}건" + "gate_reason_epss": "EPSS 임계 초과 {{count}}건", + "gate_reason_malicious": "알려진 악성 패키지 {{count}}개", + "gate_reason_malicious_plural": "알려진 악성 패키지 {{count}}개" } } diff --git a/apps/frontend/tests/unit/features/projects/GateResultCard.test.tsx b/apps/frontend/tests/unit/features/projects/GateResultCard.test.tsx index 95fda6c..ff2cc58 100644 --- a/apps/frontend/tests/unit/features/projects/GateResultCard.test.tsx +++ b/apps/frontend/tests/unit/features/projects/GateResultCard.test.tsx @@ -34,6 +34,8 @@ function gate(overrides: Partial = {}): GateResultResponse { forbidden_license_count: 0, epss_gate_count: 0, epss_threshold: null, + malicious_component_count: 0, + malicious_scan_assessed: true, project_id: PROJECT_ID, scan_id: "22222222-2222-2222-2222-222222222222", evaluated_at: "2026-05-23T00:00:00Z", diff --git a/apps/frontend/tests/unit/features/projects/GovernanceBand.test.tsx b/apps/frontend/tests/unit/features/projects/GovernanceBand.test.tsx index d438cbd..23091d6 100644 --- a/apps/frontend/tests/unit/features/projects/GovernanceBand.test.tsx +++ b/apps/frontend/tests/unit/features/projects/GovernanceBand.test.tsx @@ -30,6 +30,7 @@ function band(overrides: Partial = {}): ProjectGovernance { critical_cve_count: 0, forbidden_license_count: 0, epss_gate_count: 0, + malicious_component_count: 0, scan_id: "s-1", }, kev_sla: { overdue: 0, due_soon: 0 }, @@ -67,6 +68,7 @@ describe("GovernanceBand", () => { critical_cve_count: 0, forbidden_license_count: 0, epss_gate_count: 0, + malicious_component_count: 0, scan_id: null, }, }), @@ -89,6 +91,7 @@ describe("GovernanceBand", () => { critical_cve_count: 3, forbidden_license_count: 1, epss_gate_count: 0, + malicious_component_count: 0, scan_id: "s-1", }, }), @@ -112,6 +115,7 @@ describe("GovernanceBand", () => { critical_cve_count: 0, forbidden_license_count: 0, epss_gate_count: 3, + malicious_component_count: 0, scan_id: "s-1", }, }), diff --git a/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx b/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx index 1b63073..623c9e1 100644 --- a/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx +++ b/apps/frontend/tests/unit/features/projects/ProjectDetailPage.test.tsx @@ -233,6 +233,8 @@ describe("ProjectDetailPage", () => { critical_cve_count: 10, forbidden_license_count: 0, epss_gate_count: 0, + malicious_component_count: 0, + malicious_scan_assessed: true, epss_threshold: null, project_id: "proj-1", scan_id: "scan-latest", From 8bb431e1c90257bacc251d95bd75f590142ee39a Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 11:11:17 +0900 Subject: [PATCH 7/9] fix(gate): close the gaps the second review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit malicious_scan_assessed asked `assessed > 0`, which calls a scan assessed when the persist hook died after two components and skipped the rest — the exact reading the field exists to prevent. Now `assessed == total`. The action queue counted flagged components without subtracting waivers, so a build the gate passes stayed on the blocked list until the waiver expired. It reuses the gate's own resolver rather than restating the rule. Also: the tallies query no longer drops the partial index, and an expired waiver is pruned on write instead of 422-ing every later policy edit. --- apps/backend/services/action_queue_service.py | 66 ++++++++++++- .../services/license_policy_service.py | 50 +++++++--- apps/backend/services/policy_gate.py | 93 ++++++++++++++----- 3 files changed, 169 insertions(+), 40 deletions(-) diff --git a/apps/backend/services/action_queue_service.py b/apps/backend/services/action_queue_service.py index 74227e3..6582c1b 100644 --- a/apps/backend/services/action_queue_service.py +++ b/apps/backend/services/action_queue_service.py @@ -81,9 +81,12 @@ _latest_succeeded_scan_ids, ) from services.license_policy_service import get_effective_policy +from services.malicious import malicious_catalog from services.policy_gate import ( _CLOSED_FINDING_STATUSES, + _active_malicious_waivers, _count_forbidden_license_components_dynamic, + _flagged_purls_for_scan, _resolve_epss_threshold, ) @@ -173,11 +176,9 @@ async def _malicious_counts( blocked builds, and the most urgent ones at that: a malicious package is an attack in the build, not a defect to schedule. - Policy waivers are NOT applied. This panel answers "what is blocked", and - a waived component does not block — but the waiver is resolved per team in - `evaluate_gate`, and reproducing that here would duplicate the resolution - the parity test exists to prevent. The count is therefore an upper bound; - the per-project gate result is authoritative. + Raw tally: waivers are subtracted afterwards by + ``_malicious_counts_under_policy``, which reuses the gate's own resolver + rather than restating the rule here. """ if not scan_ids: return {} @@ -285,6 +286,58 @@ async def _forbidden_counts_under_policy( return counts +async def _malicious_counts_under_policy( + session: AsyncSession, + *, + scan_by_project: dict[uuid.UUID, uuid.UUID], + raw_counts: dict[uuid.UUID, int], +) -> dict[uuid.UUID, int]: + """Subtract active waivers so the panel agrees with the gate. + + A waived component does not block, so listing its project under "blocked + builds" is the failure this module's docstring already describes for the + licence axis: the panel keeps naming a project until people stop reading + the panel. + + No waiver rule is duplicated here — the resolution is + ``policy_gate._active_malicious_waivers``, reused. Only teams that run a + policy pay for the extra lookup, and a team with no malicious waivers + exits after one dict miss. + """ + if not raw_counts or not scan_by_project: + return raw_counts + + now = datetime.now(tz=UTC) + team_rows = ( + await session.execute( + select(Project.id, Project.team_id).where( + Project.id.in_(list(scan_by_project)) + ) + ) + ).all() + + policies: dict[uuid.UUID, LicensePolicy | None] = {} + counts = dict(raw_counts) + + for project_id, team_id in team_rows: + scan_id = scan_by_project.get(project_id) + if scan_id is None or not counts.get(scan_id): + continue + if team_id is None: + continue + if team_id not in policies: + policies[team_id] = await get_effective_policy(session, team_id=team_id) + waived = _active_malicious_waivers(policies[team_id], now) + if not waived: + continue + purls = await _flagged_purls_for_scan(session, scan_id) + counts[scan_id] = sum( + 1 for purl in purls if malicious_catalog.base_purl(purl) not in waived + ) + + return counts + + async def _gate_blocked( session: AsyncSession, *, @@ -311,6 +364,9 @@ async def _gate_blocked( epss = await _epss_counts(session, scan_ids=scan_ids) malicious = await _malicious_counts(session, scan_ids=scan_ids) + malicious = await _malicious_counts_under_policy( + session, scan_by_project=scan_by_project, raw_counts=malicious + ) blocked_scan_ids = [ sid diff --git a/apps/backend/services/license_policy_service.py b/apps/backend/services/license_policy_service.py index 3a773d2..4bad6b9 100644 --- a/apps/backend/services/license_policy_service.py +++ b/apps/backend/services/license_policy_service.py @@ -199,14 +199,44 @@ def _max_malicious_waive_days() -> int: return value -def _enforce_malicious_ttl(exceptions: list[Any]) -> None: - """Reject a malicious waiver that is already expired or outlives the cap. +def _prune_expired_waivers(exceptions: list[Any]) -> list[Any]: + """Drop waivers whose expiry has passed. - The schema makes ``expires_at`` required; this adds the two bounds a - required field cannot express — that it is in the future at all, and that - it is not so far out as to be permanent in practice. Without the upper - bound ``9999-12-31`` is a valid answer, which is exactly the open-ended - waiver the required field was meant to prevent. + The gate already ignores them, so no verdict changes — this keeps the + persisted array equal to the live set, which is what makes the policy + response worth reading during an incident. + """ + now = _now() + live: list[Any] = [] + for exc in exceptions: + raw = exc.get("expires_at") if isinstance(exc, dict) else None + if not isinstance(raw, str): + continue + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + continue + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + if parsed > now: + live.append(exc) + return live + + +def _enforce_malicious_ttl(exceptions: list[Any]) -> None: + """Reject a malicious waiver that outlives the cap. + + The schema makes ``expires_at`` required; this adds the bound a required + field cannot express — that it is not so far out as to be permanent in + practice. Without it ``9999-12-31`` is a valid answer, which is exactly + the open-ended waiver the required field was meant to prevent. + + Already-expired entries are NOT rejected. Rejecting them would lock the + whole policy: a waiver written 30 days ago lapses, and from then on any + read-edit-write round trip fails validation, including edits that have + nothing to do with this axis. They are pruned on write instead (see + ``_prune_expired_waivers``), which also keeps the stored array equal to + the set of live waivers. """ max_days = _max_malicious_waive_days() now = _now() @@ -220,10 +250,6 @@ def _enforce_malicious_ttl(exceptions: list[Any]) -> None: f"the waiver for '{exc.component_purl}' must give expires_at " "with a timezone (e.g. 2026-08-20T00:00:00Z)" ) - if expires_at <= now: - raise LicensePolicyValidationError( - f"the waiver for '{exc.component_purl}' has already expired" - ) if expires_at > cap: raise LicensePolicyValidationError( f"the waiver for '{exc.component_purl}' exceeds the maximum of " @@ -292,7 +318,7 @@ def _apply_upsert(row: LicensePolicy, payload: LicensePolicyUpsertIn) -> None: row.name = payload.name row.category_overrides = dumped["category_overrides"] row.license_exceptions = dumped["license_exceptions"] - row.malicious_exceptions = dumped["malicious_exceptions"] + row.malicious_exceptions = _prune_expired_waivers(dumped["malicious_exceptions"]) row.unknown_license_category = payload.unknown_license_category row.compound_operator_strategy = dumped["compound_operator_strategy"] row.enabled = payload.enabled diff --git a/apps/backend/services/policy_gate.py b/apps/backend/services/policy_gate.py index 4f5662b..8eae401 100644 --- a/apps/backend/services/policy_gate.py +++ b/apps/backend/services/policy_gate.py @@ -434,31 +434,75 @@ async def _malicious_scan_counts( the snapshot did not list is ``clear``; neither blocks, but they mean different things and the gate must not read the first as the second. - DISTINCT over the projected pair so a package reachable by several - dependency paths is counted once. The purls come back rather than a count - because the caller subtracts policy waivers before counting. + Two statements rather than one. Reading every component to derive the + tallies would drop the ``malicious_state = 'flagged'`` predicate, and that + predicate is the only reason the partial index exists — a projects page, + a release snapshot and a PR comment each evaluate the gate, so hauling + every component of a large scan into Python has a cost the axis does not + need to pay. The tallies come from one conditional aggregate row; the + purls come from an indexed lookup over the flagged minority. + + The purls come back rather than a count because the caller subtracts + policy waivers before counting. """ - stmt = ( - select( - ComponentVersion.purl_with_version, - ComponentVersion.malicious_state, - ) - .select_from(ScanComponent) - .join( - ComponentVersion, - ComponentVersion.id == ScanComponent.component_version_id, + tallies = ( + await session.execute( + select( + func.count(func.distinct(ScanComponent.component_version_id)), + func.count( + func.distinct( + case( + ( + ComponentVersion.malicious_state.is_not(None), + ScanComponent.component_version_id, + ), + ) + ) + ), + ) + .select_from(ScanComponent) + .join( + ComponentVersion, + ComponentVersion.id == ScanComponent.component_version_id, + ) + .where(ScanComponent.scan_id == scan_id) ) - .where(ScanComponent.scan_id == scan_id) - .distinct() - ) - rows = (await session.execute(stmt)).all() - flagged = [purl for purl, state in rows if state == "flagged"] - assessed = sum(1 for _, state in rows if state is not None) + ).one() + total, assessed = int(tallies[0]), int(tallies[1]) + return _MaliciousScanCounts( - flagged_purls=flagged, assessed=assessed, total=len(rows) + flagged_purls=await _flagged_purls_for_scan(session, scan_id), + assessed=assessed, + total=total, ) +async def _flagged_purls_for_scan( + session: AsyncSession, + scan_id: uuid.UUID, +) -> list[str]: + """Versioned purls the snapshot flags on ``scan_id``. + + Split out because the action queue needs the same list to subtract its + waivers, and a second implementation there is exactly what the gate-parity + test exists to prevent. Rides the partial index. + """ + rows = ( + await session.execute( + select(ComponentVersion.purl_with_version) + .select_from(ScanComponent) + .join( + ComponentVersion, + ComponentVersion.id == ScanComponent.component_version_id, + ) + .where(ScanComponent.scan_id == scan_id) + .where(ComponentVersion.malicious_state == "flagged") + .distinct() + ) + ).scalars() + return list(rows) + + def _active_malicious_waivers( policy: LicensePolicy | None, now: datetime ) -> frozenset[str]: @@ -901,11 +945,14 @@ async def evaluate_gate( malicious_component_count, malicious_counts = await _resolve_malicious_count( session, project_id=project_id, scan_id=scan_id, now=evaluated_at ) - # "assessed" is what separates a clean scan from an unexamined one. A - # scan with components but none of them evaluated reports zero for the - # same reason an empty project does, and only one of those is good news. + # Every component, not merely one. The persist hook disables its + # evaluator on the first exception and leaves the rest of the scan + # unstamped, so a partially evaluated scan is the realistic failure — + # and `assessed > 0` would call that "assessed" and report a clean + # zero for the 4,998 rows nobody looked at. An empty scan satisfies + # this trivially, which is correct: there was nothing to examine. malicious_scan_assessed = ( - malicious_counts.total == 0 or malicious_counts.assessed > 0 + malicious_counts.assessed == malicious_counts.total ) else: malicious_component_count = 0 From 2a437231ac1062d50eb2509dddce8248b6064240 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Wed, 5 Aug 2026 11:11:17 +0900 Subject: [PATCH 8/9] test(gate): cover the malicious waiver round trip and the assessed predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waiver bug survived the first review because every test built policies through the ORM. These go through the service: PUT, read back, watch the gate change its mind — checked against the unfixed code, where the round-trip test fails. Also pins the expiry bounds, the partial-evaluation case, and a malformed waiver array. CI and the gate card now say "not assessed" instead of drawing a zero nobody computed. --- actions/scan/action.yml | 4 + .../test_action_queue_gate_parity.py | 123 ++++++++ .../test_malicious_waiver_lifecycle.py | 276 ++++++++++++++++++ .../services/test_policy_gate_malicious.py | 78 +++++ .../projects/components/GateResultCard.tsx | 10 + .../src/locales/en/project_detail.json | 3 +- .../src/locales/ko/project_detail.json | 3 +- 7 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py diff --git a/actions/scan/action.yml b/actions/scan/action.yml index a0a5f54..9fef049 100644 --- a/actions/scan/action.yml +++ b/actions/scan/action.yml @@ -273,6 +273,7 @@ runs: critical=$(echo "$json_body" | jq -r '.critical_cve_count // 0') forbidden=$(echo "$json_body" | jq -r '.forbidden_license_count // 0') malicious=$(echo "$json_body" | jq -r '.malicious_component_count // 0') + malicious_assessed=$(echo "$json_body" | jq -r '.malicious_scan_assessed // false') # GITHUB_OUTPUT supports multi-line via heredoc, but reason is single # line by contract — we still defensively strip newlines. @@ -300,6 +301,9 @@ runs: # the axis is switched off too. if [ "${malicious}" != "0" ]; then echo "| Known-malicious packages | ${malicious} — remove, rotate credentials |" + elif [ "${malicious_assessed}" != "true" ]; then + # A zero nobody computed must not read like a clean result. + echo "| Known-malicious packages | not assessed on this scan |" fi if [ -n "${reason_oneline}" ]; then echo "| Reason | ${reason_oneline} |" diff --git a/apps/backend/tests/integration/test_action_queue_gate_parity.py b/apps/backend/tests/integration/test_action_queue_gate_parity.py index dd15383..4cba069 100644 --- a/apps/backend/tests/integration/test_action_queue_gate_parity.py +++ b/apps/backend/tests/integration/test_action_queue_gate_parity.py @@ -646,3 +646,126 @@ async def test_parity_holds_when_only_a_malicious_package_blocks( assert verdict.malicious_component_count == 1 assert blocked, "the queue omitted a project blocked only by a malicious package" assert blocked[0].malicious_component_count == verdict.malicious_component_count + + +async def test_parity_holds_when_a_waiver_lifts_a_malicious_block( + db_session: AsyncSession, +) -> None: + """A waived package must leave the panel, not linger until it expires. + + The panel and the gate answer the same question. If a waiver clears the + build but the panel keeps naming the project, the list stops meaning + "blocked" and people stop reading it — the failure this module's docstring + describes for the licence axis. + """ + from models import ComponentVersion, LicensePolicy, ScanComponent + from services.action_queue_service import _blocked_for_projects + from services.policy_gate import evaluate_gate + + org = await make_organization(db_session) + team = await make_team(db_session, organization=org) + project_id, scan_id = await _project_with_scan(db_session, team=team) + + cv_id = await _component_version(db_session) + cv = await db_session.get(ComponentVersion, cv_id) + assert cv is not None + cv.malicious_state = "flagged" + cv.malicious_id = "MAL-0000-WAIVED" + cv.malicious_source = "osv.dev@seed" + db_session.add(cv) + db_session.add( + ScanComponent( + scan_id=scan_id, component_version_id=cv_id, direct=True, raw_data={} + ) + ) + await db_session.commit() + + # Without a waiver both agree it blocks. + assert (await evaluate_gate(db_session, project_id)).gate == "fail" + assert await _blocked_for_projects(db_session, project_ids=[project_id]) + + base_purl = cv.purl_with_version.rsplit("@", 1)[0] + db_session.add( + LicensePolicy( + organization_id=org.id, + team_id=team.id, + name="waiver", + category_overrides={}, + license_exceptions=[], + malicious_exceptions=[ + { + "component_purl": base_purl, + "reason": "challenged upstream", + "expires_at": ( + datetime.now(tz=UTC) + timedelta(days=7) + ).isoformat(), + } + ], + unknown_license_category="conditional", + enabled=True, + ) + ) + await db_session.commit() + + verdict = await evaluate_gate(db_session, project_id) + blocked = await _blocked_for_projects(db_session, project_ids=[project_id]) + + assert verdict.gate == "pass" + assert verdict.malicious_component_count == 0 + assert not blocked, "the panel still lists a project the waiver unblocked" + + +async def test_parity_holds_when_a_malicious_waiver_has_expired( + db_session: AsyncSession, +) -> None: + """An expired waiver puts the project back on both surfaces.""" + from models import ComponentVersion, LicensePolicy, ScanComponent + from services.action_queue_service import _blocked_for_projects + from services.policy_gate import evaluate_gate + + org = await make_organization(db_session) + team = await make_team(db_session, organization=org) + project_id, scan_id = await _project_with_scan(db_session, team=team) + + cv_id = await _component_version(db_session) + cv = await db_session.get(ComponentVersion, cv_id) + assert cv is not None + cv.malicious_state = "flagged" + cv.malicious_source = "osv.dev@seed" + db_session.add(cv) + db_session.add( + ScanComponent( + scan_id=scan_id, component_version_id=cv_id, direct=True, raw_data={} + ) + ) + base_purl = cv.purl_with_version.rsplit("@", 1)[0] + db_session.add( + LicensePolicy( + organization_id=org.id, + team_id=team.id, + name="lapsed", + category_overrides={}, + license_exceptions=[], + malicious_exceptions=[ + { + "component_purl": base_purl, + "reason": "lapsed", + "expires_at": ( + datetime.now(tz=UTC) - timedelta(minutes=1) + ).isoformat(), + } + ], + unknown_license_category="conditional", + enabled=True, + ) + ) + await db_session.commit() + + verdict = await evaluate_gate(db_session, project_id) + blocked = await _blocked_for_projects(db_session, project_ids=[project_id]) + + assert verdict.gate == "fail" + assert verdict.malicious_component_count == 1 + assert blocked + assert blocked[0].malicious_component_count == 1 + diff --git a/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py b/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py new file mode 100644 index 0000000..a0117dc --- /dev/null +++ b/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py @@ -0,0 +1,276 @@ +""" +Malicious waivers through the API, and the bounds on their lifetime (#26). + +These exist because the first version of this feature shipped a waiver that +never reached the database: the schema had the field, the model had the +column, and the upsert service quietly dropped it. Every test passed, because +they all built policies through the ORM and never went through a request. + +So the round-trip here is the point — PUT, read it back, watch the gate change +its mind. A test that constructs the row directly cannot fail the way that bug +failed. +""" + +from __future__ import annotations + +import os +import subprocess +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from tests._helpers import ( + make_membership, + make_organization, + make_project, + make_scan, + make_team, + make_user, + unique_suffix, +) + +BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent.parent + +pytestmark = pytest.mark.integration + + +def _require_database_url() -> str: + url = os.getenv("DATABASE_URL") + if not url: + pytest.skip("DATABASE_URL not set — skip malicious waiver tests") + return url + + +@pytest.fixture(scope="module", autouse=True) +def _migrate_once() -> None: + _require_database_url() + result = subprocess.run( + ["alembic", "upgrade", "head"], + cwd=BACKEND_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + pytest.skip(f"alembic upgrade head failed:\n{result.stderr}") + + +@pytest.fixture +async def db_session() -> AsyncIterator[AsyncSession]: + from core.audit import install_audit_listeners + from core.config import database_url + + engine = create_async_engine(database_url(), pool_pre_ping=True, future=True) + factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + install_audit_listeners(factory) + async with factory() as session: + yield session + await engine.dispose() + + +def _future(days: int = 7) -> str: + return (datetime.now(tz=UTC) + timedelta(days=days)).isoformat() + + +async def _seed(session: AsyncSession): + org = await make_organization(session) + team = await make_team(session, organization=org) + user = await make_user(session) + await make_membership(session, user=user, team=team, role="team_admin") + project = await make_project(session, team=team) + scan = await make_scan(session, project=project, status="succeeded") + return org, team, user, project, scan + + +async def _flagged_component(session: AsyncSession, scan_id: uuid.UUID) -> str: + from models import Component, ComponentVersion, ScanComponent + + suffix = unique_suffix() + purl = f"pkg:npm/waiver-{suffix}" + component = Component(purl=purl, package_type="npm", name=f"waiver-{suffix}") + session.add(component) + await session.commit() + await session.refresh(component) + + cv = ComponentVersion( + component_id=component.id, + version="1.0.0", + purl_with_version=f"{purl}@1.0.0", + malicious_state="flagged", + malicious_id="MAL-0000-RT", + malicious_source="osv.dev@seed", + malicious_evaluated_at=datetime.now(tz=UTC), + ) + session.add(cv) + await session.commit() + await session.refresh(cv) + session.add(ScanComponent(scan_id=scan_id, component_version_id=cv.id, direct=True)) + await session.commit() + return purl + + +# --------------------------------------------------------------------------- +# The round trip +# --------------------------------------------------------------------------- + + +async def test_a_waiver_saved_through_the_service_reaches_the_gate( + db_session: AsyncSession, +) -> None: + """PUT → read back → the gate changes its verdict. + + The regression this pins: the upsert dropped `malicious_exceptions` on the + floor and answered 200, so an operator saw success while the build stayed + blocked. Nothing raised, and every ORM-built test still passed. + """ + from schemas.license_policy import LicensePolicyUpsertIn + from services.license_policy_service import get_team_policy_row, upsert_team_policy + from services.policy_gate import evaluate_gate + + _, team, user, project, scan = await _seed(db_session) + purl = await _flagged_component(db_session, scan.id) + + from core.security import CurrentUser + + actor = CurrentUser( + id=user.id, + email=user.email, + role="team_admin", + team_ids=[team.id], + team_roles={team.id: "team_admin"}, + is_superuser=False, + ) + + assert (await evaluate_gate(db_session, project.id, scan_id=scan.id)).gate == "fail" + + await upsert_team_policy( + db_session, + actor, + team_id=team.id, + payload=LicensePolicyUpsertIn( + malicious_exceptions=[ + { + "component_purl": purl, + "reason": "challenged upstream", + "expires_at": _future(), + } + ] + ), + ) + + # Readable afterwards — a waiver nobody can enumerate is one nobody reviews. + stored = await get_team_policy_row(db_session, team_id=team.id) + assert stored is not None + assert len(stored.malicious_exceptions) == 1 + assert stored.malicious_exceptions[0]["component_purl"] == purl + + assert (await evaluate_gate(db_session, project.id, scan_id=scan.id)).gate == "pass" + + +# --------------------------------------------------------------------------- +# Lifetime bounds +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("expires_at", "expected_fragment"), + [ + # Beyond the cap — the open-ended waiver the required field was meant + # to prevent, written as a date instead of an omission. + ("9999-12-31T00:00:00Z", "maximum"), + # No timezone: read as UTC downstream, so a KST operator's "midnight" + # would outlive their intent by nine hours. + ("2099-01-01T00:00:00", "timezone"), + ], +) +async def test_waiver_expiry_bounds_are_enforced( + db_session: AsyncSession, expires_at: str, expected_fragment: str +) -> None: + from schemas.license_policy import LicensePolicyUpsertIn + from services.license_policy_service import ( + LicensePolicyValidationError, + upsert_team_policy, + ) + + _, team, user, _, _ = await _seed(db_session) + from core.security import CurrentUser + + actor = CurrentUser( + id=user.id, + email=user.email, + role="team_admin", + team_ids=[team.id], + team_roles={team.id: "team_admin"}, + is_superuser=False, + ) + + with pytest.raises(LicensePolicyValidationError) as excinfo: + await upsert_team_policy( + db_session, + actor, + team_id=team.id, + payload=LicensePolicyUpsertIn( + malicious_exceptions=[ + { + "component_purl": "pkg:npm/whatever", + "reason": "r", + "expires_at": expires_at, + } + ] + ), + ) + assert expected_fragment in str(excinfo.value) + + +async def test_an_expired_waiver_is_pruned_rather_than_blocking_the_edit( + db_session: AsyncSession, +) -> None: + """A lapsed waiver must not lock the policy. + + Rejecting already-expired entries would mean that once a waiver lapses, + every later edit — including ones with nothing to do with this axis — + fails validation until someone hand-strips the payload. They are dropped + on write instead, which also keeps the stored array equal to the live set. + """ + from schemas.license_policy import LicensePolicyUpsertIn + from services.license_policy_service import get_team_policy_row, upsert_team_policy + + _, team, user, _, _ = await _seed(db_session) + from core.security import CurrentUser + + actor = CurrentUser( + id=user.id, + email=user.email, + role="team_admin", + team_ids=[team.id], + team_roles={team.id: "team_admin"}, + is_superuser=False, + ) + + await upsert_team_policy( + db_session, + actor, + team_id=team.id, + payload=LicensePolicyUpsertIn( + category_overrides={"MIT": "forbidden"}, + malicious_exceptions=[ + { + "component_purl": "pkg:npm/lapsed", + "reason": "r", + "expires_at": ( + datetime.now(tz=UTC) - timedelta(days=1) + ).isoformat(), + } + ], + ), + ) + + stored = await get_team_policy_row(db_session, team_id=team.id) + assert stored is not None + assert stored.malicious_exceptions == [] + # The unrelated part of the edit still landed. + assert stored.category_overrides == {"MIT": "forbidden"} diff --git a/apps/backend/tests/unit/services/test_policy_gate_malicious.py b/apps/backend/tests/unit/services/test_policy_gate_malicious.py index bc6b267..a55e016 100644 --- a/apps/backend/tests/unit/services/test_policy_gate_malicious.py +++ b/apps/backend/tests/unit/services/test_policy_gate_malicious.py @@ -349,3 +349,81 @@ async def test_waiver_written_with_a_version_still_matches( result = await evaluate_gate(db_session, project.id, scan_id=scan.id) assert result.gate == "pass" + + +async def test_a_partly_evaluated_scan_does_not_claim_to_be_assessed( + db_session: AsyncSession, +) -> None: + """One evaluated row must not vouch for the ones nobody looked at. + + The persist hook turns its evaluator off on the first exception and leaves + the rest of the scan unstamped, so "some rows have verdicts" is the shape + a failed enrichment leaves behind — not the shape of a healthy scan. An + `assessed > 0` test would call this assessed and report a clean zero. + """ + from services.policy_gate import evaluate_gate + + _, _, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, evaluated = await _make_component(db_session, malicious_state="clear") + _, never_evaluated = await _make_component(db_session, malicious_state=None) + await _attach(db_session, scan_id=scan.id, cv_id=evaluated.id) + await _attach(db_session, scan_id=scan.id, cv_id=never_evaluated.id) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.gate == "pass" + assert result.malicious_component_count == 0 + assert result.malicious_scan_assessed is False + + +async def test_a_fully_evaluated_scan_reports_assessed( + db_session: AsyncSession, +) -> None: + from services.policy_gate import evaluate_gate + + _, _, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + for _ in range(2): + _, cv = await _make_component(db_session, malicious_state="clear") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.malicious_scan_assessed is True + + +async def test_a_malformed_waiver_array_leaves_the_block_in_place( + db_session: AsyncSession, +) -> None: + """JSONB holds whatever was written to it. + + A shape the reader cannot parse is not a reason to 500 every gate read for + the team, and not a reason to honour waivers it cannot read either. + """ + from models import LicensePolicy + from services.policy_gate import evaluate_gate + + org, team, project = await _seed_project(db_session) + scan = await _seeded_scan(db_session, project) + _, cv = await _make_component(db_session, malicious_state="flagged") + await _attach(db_session, scan_id=scan.id, cv_id=cv.id) + + policy = LicensePolicy( + organization_id=org.id, + team_id=team.id, + name="malformed", + category_overrides={}, + license_exceptions=[], + malicious_exceptions={"not": "a list"}, + unknown_license_category="conditional", + enabled=True, + ) + db_session.add(policy) + await db_session.commit() + + result = await evaluate_gate(db_session, project.id, scan_id=scan.id) + + assert result.gate == "fail" + assert result.malicious_component_count == 1 + diff --git a/apps/frontend/src/features/projects/components/GateResultCard.tsx b/apps/frontend/src/features/projects/components/GateResultCard.tsx index 1f94aed..ad38d5b 100644 --- a/apps/frontend/src/features/projects/components/GateResultCard.tsx +++ b/apps/frontend/src/features/projects/components/GateResultCard.tsx @@ -210,6 +210,16 @@ export function GateResultCard({ projectId, scanId }: GateResultCardProps) { emphasize testid="gate-metric-malicious" /> + ) : !data.malicious_scan_assessed ? ( + /* A zero nobody computed. Rendering it as a metric would make + an unexamined scan look like a clean one, so it says which + it is instead. */ +
+ {t("overview.gate_card.malicious_unassessed")} +
) : null} {data.epss_threshold != null ? ( Date: Wed, 5 Aug 2026 11:50:36 +0900 Subject: [PATCH 9/9] fix(test): build waiver payloads as models, not dicts mypy rejects a dict literal where the schema declares MaliciousException. Caught by CI because I ran mypy on selected files rather than the whole tree, which is the range CI checks. --- .../test_malicious_waiver_lifecycle.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py b/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py index a0117dc..96017ab 100644 --- a/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py +++ b/apps/backend/tests/unit/services/test_malicious_waiver_lifecycle.py @@ -127,7 +127,7 @@ async def test_a_waiver_saved_through_the_service_reaches_the_gate( floor and answered 200, so an operator saw success while the build stayed blocked. Nothing raised, and every ORM-built test still passed. """ - from schemas.license_policy import LicensePolicyUpsertIn + from schemas.license_policy import LicensePolicyUpsertIn, MaliciousException from services.license_policy_service import get_team_policy_row, upsert_team_policy from services.policy_gate import evaluate_gate @@ -153,11 +153,11 @@ async def test_a_waiver_saved_through_the_service_reaches_the_gate( team_id=team.id, payload=LicensePolicyUpsertIn( malicious_exceptions=[ - { - "component_purl": purl, - "reason": "challenged upstream", - "expires_at": _future(), - } + MaliciousException( + component_purl=purl, + reason="challenged upstream", + expires_at=datetime.fromisoformat(_future()), + ) ] ), ) @@ -190,7 +190,7 @@ async def test_a_waiver_saved_through_the_service_reaches_the_gate( async def test_waiver_expiry_bounds_are_enforced( db_session: AsyncSession, expires_at: str, expected_fragment: str ) -> None: - from schemas.license_policy import LicensePolicyUpsertIn + from schemas.license_policy import LicensePolicyUpsertIn, MaliciousException from services.license_policy_service import ( LicensePolicyValidationError, upsert_team_policy, @@ -215,11 +215,11 @@ async def test_waiver_expiry_bounds_are_enforced( team_id=team.id, payload=LicensePolicyUpsertIn( malicious_exceptions=[ - { - "component_purl": "pkg:npm/whatever", - "reason": "r", - "expires_at": expires_at, - } + MaliciousException( + component_purl="pkg:npm/whatever", + reason="r", + expires_at=datetime.fromisoformat(expires_at), + ) ] ), ) @@ -236,7 +236,7 @@ async def test_an_expired_waiver_is_pruned_rather_than_blocking_the_edit( fails validation until someone hand-strips the payload. They are dropped on write instead, which also keeps the stored array equal to the live set. """ - from schemas.license_policy import LicensePolicyUpsertIn + from schemas.license_policy import LicensePolicyUpsertIn, MaliciousException from services.license_policy_service import get_team_policy_row, upsert_team_policy _, team, user, _, _ = await _seed(db_session) @@ -258,13 +258,11 @@ async def test_an_expired_waiver_is_pruned_rather_than_blocking_the_edit( payload=LicensePolicyUpsertIn( category_overrides={"MIT": "forbidden"}, malicious_exceptions=[ - { - "component_purl": "pkg:npm/lapsed", - "reason": "r", - "expires_at": ( - datetime.now(tz=UTC) - timedelta(days=1) - ).isoformat(), - } + MaliciousException( + component_purl="pkg:npm/lapsed", + reason="r", + expires_at=datetime.now(tz=UTC) - timedelta(days=1), + ) ], ), )