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) {
}
+ render={}
>
Open vulnerability workbench
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()
{