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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion actions/scan/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -266,6 +272,8 @@ 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')
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.
Expand All @@ -276,6 +284,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.
Expand All @@ -287,12 +296,21 @@ 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 |"
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} |"
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
Expand Down
53 changes: 53 additions & 0 deletions apps/backend/alembic/versions/0048_malicious_policy_exceptions.py
Original file line number Diff line number Diff line change
@@ -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).")
3 changes: 3 additions & 0 deletions apps/backend/api/v1/policy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ 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,
malicious_scan_assessed=result.malicious_scan_assessed,
project_id=result.project_id,
scan_id=result.scan_id,
evaluated_at=result.evaluated_at,
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/models/license_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/schemas/action_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
56 changes: 56 additions & 0 deletions apps/backend/schemas/license_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -222,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
Expand Down
28 changes: 28 additions & 0 deletions apps/backend/schemas/policy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,34 @@ 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_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 "
"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,
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/schemas/project_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
Loading
Loading