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
1 change: 1 addition & 0 deletions frontend/src/api/vulnerabilities.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const fetchVulnerabilities = createServerFn({ method: 'GET' })
knownExploitedOnly: z.boolean().optional(),
activeAlertOnly: z.boolean().optional(),
presentOnly: z.boolean().optional(),
hasAssessmentOnly: z.boolean().optional(),
remediationCaseIds: z.string().optional(),
page: z.number().optional(),
pageSize: z.number().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function AnalystTriageWorkbench({ items, summary, isLoading }: Props) {
</div>
<Button
variant="outline"
render={<Link to="/vulnerabilities" search={{ page: 1, pageSize: 25, search: '', severity: '', status: '', source: '', ageOperator: '', ageHours: '', publicExploitOnly: false, knownExploitedOnly: false, activeAlertOnly: false, presentOnly: false }} />}
render={<Link to="/vulnerabilities" search={{ page: 1, pageSize: 25, search: '', severity: '', status: '', source: '', ageOperator: '', ageHours: '', publicExploitOnly: false, knownExploitedOnly: false, activeAlertOnly: false, presentOnly: false, hasAssessmentOnly: false }} />}
>
Open vulnerability workbench
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type VulnerabilityTableProps = {
knownExploitedOnly: boolean;
activeAlertOnly: boolean;
presentOnly: boolean;
hasAssessmentOnly: boolean;
selectedRemediationCaseIds: string[];
remediationOptions: Array<{
remediationCaseId: string;
Expand All @@ -63,6 +64,7 @@ type VulnerabilityTableProps = {
onKnownExploitedOnlyChange: (value: boolean) => void;
onActiveAlertOnlyChange: (value: boolean) => void;
onPresentOnlyChange: (value: boolean) => void;
onHasAssessmentOnlyChange: (value: boolean) => void;
onRemediationCaseIdsChange: (value: string[]) => void;
onApplyStructuredFilters: (filters: {
severity: string;
Expand All @@ -73,6 +75,7 @@ type VulnerabilityTableProps = {
knownExploitedOnly: boolean;
activeAlertOnly: boolean;
presentOnly: boolean;
hasAssessmentOnly: boolean;
remediationCaseIds: string[];
}) => void;
onClearFilters: () => void;
Expand All @@ -87,6 +90,7 @@ function getCurrentDraftFilters({
knownExploitedOnly,
activeAlertOnly,
presentOnly,
hasAssessmentOnly,
selectedRemediationCaseIds,
}: {
severityFilter: string;
Expand All @@ -97,6 +101,7 @@ function getCurrentDraftFilters({
knownExploitedOnly: boolean;
activeAlertOnly: boolean;
presentOnly: boolean;
hasAssessmentOnly: boolean;
selectedRemediationCaseIds: string[];
}) {
return {
Expand All @@ -108,6 +113,7 @@ function getCurrentDraftFilters({
knownExploitedOnly,
activeAlertOnly,
presentOnly,
hasAssessmentOnly,
remediationCaseIds: selectedRemediationCaseIds,
};
}
Expand Down Expand Up @@ -143,6 +149,7 @@ export function VulnerabilityTable({
knownExploitedOnly,
activeAlertOnly,
presentOnly,
hasAssessmentOnly,
selectedRemediationCaseIds,
remediationOptions,
onPageChange,
Expand All @@ -155,6 +162,7 @@ export function VulnerabilityTable({
onKnownExploitedOnlyChange,
onActiveAlertOnlyChange,
onPresentOnlyChange,
onHasAssessmentOnlyChange,
onRemediationCaseIdsChange,
onApplyStructuredFilters,
onClearFilters,
Expand All @@ -179,6 +187,7 @@ export function VulnerabilityTable({
knownExploitedOnly,
activeAlertOnly,
presentOnly,
hasAssessmentOnly,
selectedRemediationCaseIds,
}),
);
Expand All @@ -204,6 +213,7 @@ export function VulnerabilityTable({
knownExploitedOnly,
activeAlertOnly,
presentOnly,
hasAssessmentOnly,
selectedRemediationCaseIds,
});

Expand Down Expand Up @@ -291,6 +301,15 @@ export function VulnerabilityTable({
},
}
: null,
hasAssessmentOnly
? {
key: "hasAssessmentOnly",
label: "AI assessment available",
onClear: () => {
onHasAssessmentOnlyChange(false);
},
}
: null,
selectedRemediationOptions.length > 0
? {
key: "remediations",
Expand All @@ -310,12 +329,14 @@ export function VulnerabilityTable({
onKnownExploitedOnlyChange,
onAgeFilterChange,
onPresentOnlyChange,
onHasAssessmentOnlyChange,
onPublicExploitOnlyChange,
onRemediationCaseIdsChange,
onSearchChange,
onSeverityFilterChange,
onStatusFilterChange,
presentOnly,
hasAssessmentOnly,
publicExploitOnly,
searchValue,
selectedRemediationOptions.length,
Expand All @@ -337,6 +358,7 @@ export function VulnerabilityTable({
knownExploitedOnly ? "knownExploitedOnly" : "",
activeAlertOnly ? "activeAlertOnly" : "",
presentOnly ? "presentOnly" : "",
hasAssessmentOnly ? "hasAssessmentOnly" : "",
selectedRemediationCaseIds.length > 0 ? "remediationCaseIds" : "",
].filter(Boolean).length,
[
Expand All @@ -345,6 +367,7 @@ export function VulnerabilityTable({
ageOperator,
ageHours,
presentOnly,
hasAssessmentOnly,
publicExploitOnly,
selectedRemediationCaseIds.length,
severityFilter,
Expand Down Expand Up @@ -538,7 +561,8 @@ export function VulnerabilityTable({
publicExploitOnly: false,
knownExploitedOnly: false,
activeAlertOnly: false,
presentOnly: false,
presentOnly: true,
hasAssessmentOnly: false,
remediationCaseIds: [],
});
}}
Expand Down Expand Up @@ -646,6 +670,24 @@ export function VulnerabilityTable({
</label>
</WorkbenchFilterSection>

<WorkbenchFilterSection
title="AI Assessment"
description="Filter to vulnerabilities that already have an AI-generated patch assessment."
>
<label className="flex items-center gap-3 rounded-xl border border-border/70 bg-background/50 px-3 py-3 text-sm">
<Checkbox
checked={draftFilters.hasAssessmentOnly}
onCheckedChange={(checked) => {
setDraftFilters((current) => ({
...current,
hasAssessmentOnly: checked === true,
}));
}}
/>
<span>Only show vulnerabilities with AI assessment available</span>
</label>
</WorkbenchFilterSection>

<WorkbenchFilterSection
title="Remediation"
description="Show vulnerabilities covered by selected approved remediation cases."
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/features/vulnerabilities/list-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('buildVulnerabilitiesListRequest', () => {
knownExploitedOnly: false,
activeAlertOnly: true,
presentOnly: false,
hasAssessmentOnly: false,
page: 2,
pageSize: 50,
}),
Expand All @@ -26,6 +27,30 @@ describe('buildVulnerabilitiesListRequest', () => {
pageSize: 50,
})
})

it('includes hasAssessmentOnly when enabled', () => {
expect(
buildVulnerabilitiesListRequest({
search: '',
severity: '',
status: '',
source: '',
ageOperator: '',
ageHours: '',
publicExploitOnly: false,
knownExploitedOnly: false,
activeAlertOnly: false,
presentOnly: false,
hasAssessmentOnly: true,
page: 1,
pageSize: 25,
}),
).toEqual({
hasAssessmentOnly: true,
page: 1,
pageSize: 25,
})
})
})

describe('vulnerabilityQueryKeys', () => {
Expand All @@ -42,6 +67,7 @@ describe('vulnerabilityQueryKeys', () => {
knownExploitedOnly: false,
activeAlertOnly: false,
presentOnly: false,
hasAssessmentOnly: false,
page: 1,
pageSize: 25,
})
Expand All @@ -61,6 +87,7 @@ describe('vulnerabilityQueryKeys', () => {
false,
false,
false,
false,
1,
25,
])
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/features/vulnerabilities/list-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type VulnerabilitiesListSearch = {
knownExploitedOnly: boolean
activeAlertOnly: boolean
presentOnly: boolean
hasAssessmentOnly: boolean
remediationCaseIds?: string
page: number
pageSize: number
Expand All @@ -28,6 +29,7 @@ export function buildVulnerabilitiesListRequest(search: VulnerabilitiesListSearc
...(search.knownExploitedOnly ? { knownExploitedOnly: true } : {}),
...(search.activeAlertOnly ? { activeAlertOnly: true } : {}),
...(search.presentOnly ? { presentOnly: true } : {}),
...(search.hasAssessmentOnly ? { hasAssessmentOnly: true } : {}),
...(search.remediationCaseIds ? { remediationCaseIds: search.remediationCaseIds } : {}),
page: search.page,
pageSize: search.pageSize,
Expand All @@ -50,6 +52,7 @@ export const vulnerabilityQueryKeys = {
search.knownExploitedOnly,
search.activeAlertOnly,
search.presentOnly,
search.hasAssessmentOnly,
...(search.remediationCaseIds ? [search.remediationCaseIds] : []),
search.page,
search.pageSize,
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/_authed/vulnerabilities/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const vulnerabilitiesSearchSchema = baseListSearchSchema.extend({
knownExploitedOnly: searchBooleanSchema,
activeAlertOnly: searchBooleanSchema,
presentOnly: searchBooleanTrueSchema,
hasAssessmentOnly: searchBooleanSchema,
remediationCaseIds: searchStringSchema.optional(),
})

Expand Down Expand Up @@ -80,6 +81,7 @@ function VulnerabilitiesPage() {
knownExploitedOnly={search.knownExploitedOnly}
activeAlertOnly={search.activeAlertOnly}
presentOnly={search.presentOnly}
hasAssessmentOnly={search.hasAssessmentOnly}
selectedRemediationCaseIds={parseRemediationCaseIds(search.remediationCaseIds ?? '')}
remediationOptions={(remediationOptionsQuery.data?.items ?? []).map((item) => ({
remediationCaseId: item.remediationCaseId,
Expand Down Expand Up @@ -116,6 +118,9 @@ function VulnerabilitiesPage() {
onPresentOnlyChange={(value) => {
searchActions.updateField('presentOnly', value)
}}
onHasAssessmentOnlyChange={(value) => {
searchActions.updateField('hasAssessmentOnly', value)
}}
onRemediationCaseIdsChange={(value) => {
searchActions.updateField('remediationCaseIds', value.join(','))
}}
Expand All @@ -129,6 +134,7 @@ function VulnerabilitiesPage() {
knownExploitedOnly: filters.knownExploitedOnly,
activeAlertOnly: filters.activeAlertOnly,
presentOnly: filters.presentOnly,
hasAssessmentOnly: filters.hasAssessmentOnly,
remediationCaseIds: filters.remediationCaseIds.join(','),
})
}}
Expand All @@ -144,6 +150,7 @@ function VulnerabilitiesPage() {
knownExploitedOnly: false,
activeAlertOnly: false,
presentOnly: true,
hasAssessmentOnly: false,
remediationCaseIds: '',
})
}}
Expand Down
4 changes: 4 additions & 0 deletions src/PatchHound.Api/Controllers/VulnerabilitiesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ CancellationToken ct
&& e.Status == ExposureStatus.Open
)
);
if (filter.HasAssessmentOnly == true)
query = query.Where(v =>
_dbContext.VulnerabilityPatchAssessments.Any(a => a.VulnerabilityId == v.Id)
);
Comment thread
FrodeHus marked this conversation as resolved.
var remediationCaseIds = ParseGuidList(filter.RemediationCaseIds);
if (remediationCaseIds.Count > 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public record VulnerabilityFilterQuery(
bool? KnownExploitedOnly = null,
bool? ActiveAlertOnly = null,
bool? PresentOnly = null,
bool? HasAssessmentOnly = null,
string? RemediationCaseIds = null
);
45 changes: 45 additions & 0 deletions tests/PatchHound.Tests/Api/VulnerabilitiesControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,51 @@ public async Task List_ThreatFilters_ReturnsThreatSignalsFromCanonicalAssessment
item.EpssScore.Should().Be(0.870m);
}

[Fact]
public async Task List_HasAssessmentOnly_ReturnsOnlyVulnerabilitiesWithAssessment()
{
var assessed = Vulnerability.Create("nvd", "CVE-2026-0401", "Assessed vulnerability",
"desc", Severity.High, 8.1m, null, DateTimeOffset.UtcNow.AddDays(-2));
var unassessed = Vulnerability.Create("nvd", "CVE-2026-0402", "Unassessed vulnerability",
"desc", Severity.High, 7.4m, null, DateTimeOffset.UtcNow.AddDays(-1));
_dbContext.Vulnerabilities.AddRange(assessed, unassessed);
await _dbContext.SaveChangesAsync();

_dbContext.VulnerabilityPatchAssessments.Add(VulnerabilityPatchAssessment.Create(
assessed.Id,
"Patch within the next maintenance window.",
"High",
"Vendor-confirmed patch published.",
"normal",
"30 days",
"Vendor advisory available.",
"[]",
"[]",
"[]",
"Default AI",
null,
DateTimeOffset.UtcNow));
await _dbContext.SaveChangesAsync();

var filteredAction = await _controller.List(
new VulnerabilityFilterQuery(HasAssessmentOnly: true),
new PaginationQuery(),
CancellationToken.None
);
var filteredPayload = filteredAction.Result.Should().BeOfType<OkObjectResult>().Subject
.Value.Should().BeOfType<PagedResponse<VulnerabilityDto>>().Subject;
filteredPayload.Items.Should().ContainSingle(item => item.Id == assessed.Id);

var unfilteredAction = await _controller.List(
new VulnerabilityFilterQuery(),
new PaginationQuery(),
CancellationToken.None
);
var unfilteredPayload = unfilteredAction.Result.Should().BeOfType<OkObjectResult>().Subject
.Value.Should().BeOfType<PagedResponse<VulnerabilityDto>>().Subject;
unfilteredPayload.Items.Select(item => item.Id).Should().BeEquivalentTo(new[] { assessed.Id, unassessed.Id });
}

[Fact]
public async Task Get_ReturnsCanonicalVulnerabilityWithThreatAssessment()
{
Expand Down
Loading