Skip to content

Block builds on known-malicious packages - #12

Merged
haksungjang merged 9 commits into
mainfrom
feat/malicious-gate
Aug 5, 2026
Merged

Block builds on known-malicious packages#12
haksungjang merged 9 commits into
mainfrom
feat/malicious-gate

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

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 ref anchor — evaluate_gate resolves 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: clear and never-assessed rows do not block, and switching the axis off reports 0 with malicious_gate_enforced=false so 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.

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
haksungjang force-pushed the feat/malicious-gate branch from 7ad5240 to 4ae7381 Compare August 5, 2026 00:55
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.
@haksungjang
haksungjang merged commit 5065981 into main Aug 5, 2026
24 checks passed
@haksungjang
haksungjang deleted the feat/malicious-gate branch August 5, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant