feat: Added vuln filter
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m29s

This commit is contained in:
2026-08-07 11:58:42 +01:00
parent 82bcc5776f
commit e28238191d
5 changed files with 83 additions and 7 deletions
+17
View File
@@ -32,6 +32,7 @@ func listVulnerabilities(c *gin.Context) {
State: c.DefaultQuery("state", models.FindingOpen),
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -82,6 +83,22 @@ func groupByCVE(findings []models.VulnFinding) []vulnGroup {
return out
}
// hasFixFromQuery reads ?has_fix=true|false. Anything else, including an empty
// or malformed value, is no filter — a filter nobody asked for must never hide
// findings, and the wrong direction here hides the unfixable ones.
func hasFixFromQuery(c *gin.Context) *bool {
switch c.Query("has_fix") {
case "true":
v := true
return &v
case "false":
v := false
return &v
default:
return nil
}
}
// tagsFromQuery reads repeated tag=key:value parameters.
func tagsFromQuery(c *gin.Context) map[string]string {
out := map[string]string{}
+15
View File
@@ -112,6 +112,10 @@ type FindingFilter struct {
State string
ServerID string
Tags map[string]string
// HasFix nil is no filter. true is "a vendor fix exists, this is
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -130,6 +134,17 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = f.ServerID
}
// fixed_in is omitempty, so a finding with no vendor fix carries no such
// field at all rather than an empty string. Both forms must be matched, or
// the unfixable set reads as empty on any document written before this.
if f.HasFix != nil {
if *f.HasFix {
filter["fixed_in"] = bson.M{"$nin": bson.A{"", nil}}
} else {
filter["fixed_in"] = bson.M{"$in": bson.A{"", nil}}
}
}
// The tag selector resolves through ResolveTargets, the single answer to
// which servers a selector touches. A second matcher here could disagree
// with what a workflow means by env:prod.
+46 -5
View File
@@ -26,17 +26,33 @@ import { groupByPackage } from "@/lib/vulnPackages";
const STATES: FindingState[] = ["open", "accepted", "fixed"];
/*
* The fix filter. "Unfixable" is not a synonym for "ignorable": those findings
* are the ones whose action is to remove the package, disable the service or
* move off an end-of-life release, and they are invisible in a list sorted for
* patching. Splitting them is what lets the patchable list be worked top to
* bottom without them quietly disappearing.
*/
const FIX_FILTERS: { key: string; label: string; hasFix: boolean | undefined }[] = [
{ key: "all", label: "All", hasFix: undefined },
{ key: "fixable", label: "Fix available", hasFix: true },
{ key: "nofix", label: "No fix", hasFix: false },
];
export default function VulnerabilitiesPage() {
const { isAdmin } = useAuth();
const qc = useQueryClient();
const [state, setState] = useState<FindingState>("open");
const [severity, setSeverity] = useState<Severity | "">("");
const [fixFilter, setFixFilter] = useState("all");
const [accepting, setAccepting] = useState<VulnFinding | null>(null);
const hasFix = FIX_FILTERS.find((f) => f.key === fixFilter)?.hasFix;
const groups = useQuery({
queryKey: ["vulnerabilities", state, severity],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined }),
queryKey: ["vulnerabilities", state, severity, fixFilter],
queryFn: () => vulnerabilities.list({ state, severity: severity || undefined, hasFix }),
});
const summary = useQuery({
@@ -128,7 +144,7 @@ export default function VulnerabilitiesPage() {
))}
</div>
<div className="mb-4 flex gap-2">
<div className="mb-4 flex flex-wrap items-center gap-2">
{STATES.map((s) => (
<button
key={s}
@@ -142,6 +158,22 @@ export default function VulnerabilitiesPage() {
{s}
</button>
))}
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
{FIX_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => {
setFixFilter(f.key);
paged.reset();
}}
aria-pressed={fixFilter === f.key}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${fixFilter === f.key ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"}`}
>
{f.label}
</button>
))}
</div>
{groups.error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(groups.error as Error).message}</div>}
@@ -161,9 +193,18 @@ export default function VulnerabilitiesPage() {
) : (
<div className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
No {state} findings{severity ? ` at ${severity} severity` : ""}
{hasFix === true ? " with a fix available" : hasFix === false ? " without a vendor fix" : ""}.
</p>
{/* Named explicitly, because "no findings" under a filter
the reader has forgotten setting reads as a clean
fleet — the one claim this page must never make by
accident. */}
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
{hasFix !== undefined
? "This is a filtered view. Switch to All to see every finding in this state."
: "Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own page rather than counted as clean here."}
</p>
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own page rather than counted as clean here.</p>
</div>
)}
</Card>
+4 -1
View File
@@ -996,11 +996,14 @@ export interface VulnAlertRuleInput {
}
export const vulnerabilities = {
list(params?: { severity?: string; state?: string; server?: string; tags?: Record<string, string> }): Promise<VulnGroup[]> {
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
const q = new URLSearchParams();
if (params?.severity) q.set("severity", params.severity);
if (params?.state) q.set("state", params.state);
if (params?.server) q.set("server", params.server);
// Explicitly undefined-checked: `false` is a real selection here (the
// unfixable set), so a truthiness test would silently drop it.
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
const qs = q.toString();
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
File diff suppressed because one or more lines are too long