Block builds on known-malicious packages - #12
Merged
Conversation
haksungjang
force-pushed
the
feat/malicious-gate
branch
from
August 4, 2026 23:44
15eb084 to
7ad5240
Compare
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.
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.
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.
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.
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.
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.
haksungjang
force-pushed
the
feat/malicious-gate
branch
from
August 5, 2026 00:55
7ad5240 to
4ae7381
Compare
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.
…edicate 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
MAL-1 shipped the malicious-package signal but nothing acted on it — a flagged package showed a badge and the build went green. This adds the fifth gate axis, blocking regardless of severity. A malicious package has no honest version to upgrade to, so weighing it against a CVE threshold asks the wrong question. 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.Blocking without an escape hatch would be worse than not blocking. An upstream false positive stops every build until the advisory is retracted, which takes days, so a license policy can carry
malicious_exceptions— package identifier, reason, and a required expiry. Licence waivers may reasonably be permanent; a malicious flag always resolves one way or the other, so an open-ended waiver would only park the decision out of sight. The waiver drops the component from the gate count and nothing else: badge, filter and drawer are unchanged.The axis needed no work to respect the new
refanchor —evaluate_gateresolves the scan once and each axis counts against it, so the branch decision arrives already made.Six DB-backed cases, including the sequence that matters: flagged → blocked → waiver → passing → waiver expires → blocked again, with no action in between. Also pinned:
clearand never-assessed rows do not block, and switching the axis off reports 0 withmalicious_gate_enforced=falseso a consumer can tell "none found" from "not checked".Notifications, the weekly re-stamp beat and the admin panel are MAL-2b, deliberately split — the review here is about bypass and waiver abuse, and that reading gets diluted if it shares a diff with an admin screen.