diff --git a/frontend/src/api/vulnerabilities.functions.ts b/frontend/src/api/vulnerabilities.functions.ts index 804c6a90..1ca5d05d 100644 --- a/frontend/src/api/vulnerabilities.functions.ts +++ b/frontend/src/api/vulnerabilities.functions.ts @@ -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(), diff --git a/frontend/src/components/features/dashboard/AnalystTriageWorkbench.tsx b/frontend/src/components/features/dashboard/AnalystTriageWorkbench.tsx index dbc0f3ff..488a4bd4 100644 --- a/frontend/src/components/features/dashboard/AnalystTriageWorkbench.tsx +++ b/frontend/src/components/features/dashboard/AnalystTriageWorkbench.tsx @@ -47,7 +47,7 @@ export function AnalystTriageWorkbench({ items, summary, isLoading }: Props) { diff --git a/frontend/src/components/features/vulnerabilities/VulnerabilityTable.tsx b/frontend/src/components/features/vulnerabilities/VulnerabilityTable.tsx index b0e6bf3b..11773f79 100644 --- a/frontend/src/components/features/vulnerabilities/VulnerabilityTable.tsx +++ b/frontend/src/components/features/vulnerabilities/VulnerabilityTable.tsx @@ -47,6 +47,7 @@ type VulnerabilityTableProps = { knownExploitedOnly: boolean; activeAlertOnly: boolean; presentOnly: boolean; + hasAssessmentOnly: boolean; selectedRemediationCaseIds: string[]; remediationOptions: Array<{ remediationCaseId: string; @@ -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; @@ -73,6 +75,7 @@ type VulnerabilityTableProps = { knownExploitedOnly: boolean; activeAlertOnly: boolean; presentOnly: boolean; + hasAssessmentOnly: boolean; remediationCaseIds: string[]; }) => void; onClearFilters: () => void; @@ -87,6 +90,7 @@ function getCurrentDraftFilters({ knownExploitedOnly, activeAlertOnly, presentOnly, + hasAssessmentOnly, selectedRemediationCaseIds, }: { severityFilter: string; @@ -97,6 +101,7 @@ function getCurrentDraftFilters({ knownExploitedOnly: boolean; activeAlertOnly: boolean; presentOnly: boolean; + hasAssessmentOnly: boolean; selectedRemediationCaseIds: string[]; }) { return { @@ -108,6 +113,7 @@ function getCurrentDraftFilters({ knownExploitedOnly, activeAlertOnly, presentOnly, + hasAssessmentOnly, remediationCaseIds: selectedRemediationCaseIds, }; } @@ -143,6 +149,7 @@ export function VulnerabilityTable({ knownExploitedOnly, activeAlertOnly, presentOnly, + hasAssessmentOnly, selectedRemediationCaseIds, remediationOptions, onPageChange, @@ -155,6 +162,7 @@ export function VulnerabilityTable({ onKnownExploitedOnlyChange, onActiveAlertOnlyChange, onPresentOnlyChange, + onHasAssessmentOnlyChange, onRemediationCaseIdsChange, onApplyStructuredFilters, onClearFilters, @@ -179,6 +187,7 @@ export function VulnerabilityTable({ knownExploitedOnly, activeAlertOnly, presentOnly, + hasAssessmentOnly, selectedRemediationCaseIds, }), ); @@ -204,6 +213,7 @@ export function VulnerabilityTable({ knownExploitedOnly, activeAlertOnly, presentOnly, + hasAssessmentOnly, selectedRemediationCaseIds, }); @@ -291,6 +301,15 @@ export function VulnerabilityTable({ }, } : null, + hasAssessmentOnly + ? { + key: "hasAssessmentOnly", + label: "AI assessment available", + onClear: () => { + onHasAssessmentOnlyChange(false); + }, + } + : null, selectedRemediationOptions.length > 0 ? { key: "remediations", @@ -310,12 +329,14 @@ export function VulnerabilityTable({ onKnownExploitedOnlyChange, onAgeFilterChange, onPresentOnlyChange, + onHasAssessmentOnlyChange, onPublicExploitOnlyChange, onRemediationCaseIdsChange, onSearchChange, onSeverityFilterChange, onStatusFilterChange, presentOnly, + hasAssessmentOnly, publicExploitOnly, searchValue, selectedRemediationOptions.length, @@ -337,6 +358,7 @@ export function VulnerabilityTable({ knownExploitedOnly ? "knownExploitedOnly" : "", activeAlertOnly ? "activeAlertOnly" : "", presentOnly ? "presentOnly" : "", + hasAssessmentOnly ? "hasAssessmentOnly" : "", selectedRemediationCaseIds.length > 0 ? "remediationCaseIds" : "", ].filter(Boolean).length, [ @@ -345,6 +367,7 @@ export function VulnerabilityTable({ ageOperator, ageHours, presentOnly, + hasAssessmentOnly, publicExploitOnly, selectedRemediationCaseIds.length, severityFilter, @@ -538,7 +561,8 @@ export function VulnerabilityTable({ publicExploitOnly: false, knownExploitedOnly: false, activeAlertOnly: false, - presentOnly: false, + presentOnly: true, + hasAssessmentOnly: false, remediationCaseIds: [], }); }} @@ -646,6 +670,24 @@ export function VulnerabilityTable({ + + + + { knownExploitedOnly: false, activeAlertOnly: true, presentOnly: false, + hasAssessmentOnly: false, page: 2, pageSize: 50, }), @@ -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', () => { @@ -42,6 +67,7 @@ describe('vulnerabilityQueryKeys', () => { knownExploitedOnly: false, activeAlertOnly: false, presentOnly: false, + hasAssessmentOnly: false, page: 1, pageSize: 25, }) @@ -61,6 +87,7 @@ describe('vulnerabilityQueryKeys', () => { false, false, false, + false, 1, 25, ]) diff --git a/frontend/src/features/vulnerabilities/list-state.ts b/frontend/src/features/vulnerabilities/list-state.ts index 4e3d2f6d..535b7f09 100644 --- a/frontend/src/features/vulnerabilities/list-state.ts +++ b/frontend/src/features/vulnerabilities/list-state.ts @@ -9,6 +9,7 @@ type VulnerabilitiesListSearch = { knownExploitedOnly: boolean activeAlertOnly: boolean presentOnly: boolean + hasAssessmentOnly: boolean remediationCaseIds?: string page: number pageSize: number @@ -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, @@ -50,6 +52,7 @@ export const vulnerabilityQueryKeys = { search.knownExploitedOnly, search.activeAlertOnly, search.presentOnly, + search.hasAssessmentOnly, ...(search.remediationCaseIds ? [search.remediationCaseIds] : []), search.page, search.pageSize, diff --git a/frontend/src/routes/_authed/vulnerabilities/index.tsx b/frontend/src/routes/_authed/vulnerabilities/index.tsx index 9eaa0dae..7c6ffa20 100644 --- a/frontend/src/routes/_authed/vulnerabilities/index.tsx +++ b/frontend/src/routes/_authed/vulnerabilities/index.tsx @@ -21,6 +21,7 @@ const vulnerabilitiesSearchSchema = baseListSearchSchema.extend({ knownExploitedOnly: searchBooleanSchema, activeAlertOnly: searchBooleanSchema, presentOnly: searchBooleanTrueSchema, + hasAssessmentOnly: searchBooleanSchema, remediationCaseIds: searchStringSchema.optional(), }) @@ -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, @@ -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(',')) }} @@ -129,6 +134,7 @@ function VulnerabilitiesPage() { knownExploitedOnly: filters.knownExploitedOnly, activeAlertOnly: filters.activeAlertOnly, presentOnly: filters.presentOnly, + hasAssessmentOnly: filters.hasAssessmentOnly, remediationCaseIds: filters.remediationCaseIds.join(','), }) }} @@ -144,6 +150,7 @@ function VulnerabilitiesPage() { knownExploitedOnly: false, activeAlertOnly: false, presentOnly: true, + hasAssessmentOnly: false, remediationCaseIds: '', }) }} diff --git a/src/PatchHound.Api/Controllers/VulnerabilitiesController.cs b/src/PatchHound.Api/Controllers/VulnerabilitiesController.cs index 29ddd67c..60f9cfa1 100644 --- a/src/PatchHound.Api/Controllers/VulnerabilitiesController.cs +++ b/src/PatchHound.Api/Controllers/VulnerabilitiesController.cs @@ -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) + ); var remediationCaseIds = ParseGuidList(filter.RemediationCaseIds); if (remediationCaseIds.Count > 0) { diff --git a/src/PatchHound.Api/Models/Vulnerabilities/VulnerabilityFilterQuery.cs b/src/PatchHound.Api/Models/Vulnerabilities/VulnerabilityFilterQuery.cs index a8338bf2..5c0d4f52 100644 --- a/src/PatchHound.Api/Models/Vulnerabilities/VulnerabilityFilterQuery.cs +++ b/src/PatchHound.Api/Models/Vulnerabilities/VulnerabilityFilterQuery.cs @@ -11,5 +11,6 @@ public record VulnerabilityFilterQuery( bool? KnownExploitedOnly = null, bool? ActiveAlertOnly = null, bool? PresentOnly = null, + bool? HasAssessmentOnly = null, string? RemediationCaseIds = null ); diff --git a/tests/PatchHound.Tests/Api/VulnerabilitiesControllerTests.cs b/tests/PatchHound.Tests/Api/VulnerabilitiesControllerTests.cs index b476a9cc..1ef47d10 100644 --- a/tests/PatchHound.Tests/Api/VulnerabilitiesControllerTests.cs +++ b/tests/PatchHound.Tests/Api/VulnerabilitiesControllerTests.cs @@ -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().Subject + .Value.Should().BeOfType>().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().Subject + .Value.Should().BeOfType>().Subject; + unfilteredPayload.Items.Select(item => item.Id).Should().BeEquivalentTo(new[] { assessed.Id, unassessed.Id }); + } + [Fact] public async Task Get_ReturnsCanonicalVulnerabilityWithThreatAssessment() {