diff --git a/AGENTS.md b/AGENTS.md
index 7e001e6d..57984eda 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,7 +89,7 @@ Canonical entities must enforce EF max-length caps and FK `Guid` validity at the
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **PatchHound** (11164 symbols, 92808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **PatchHound** (11435 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/CLAUDE.md b/CLAUDE.md
index 7e001e6d..57984eda 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,7 +89,7 @@ Canonical entities must enforce EF max-length caps and FK `Guid` validity at the
# GitNexus — Code Intelligence
-This project is indexed by GitNexus as **PatchHound** (11164 symbols, 92808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+This project is indexed by GitNexus as **PatchHound** (11435 symbols, 97109 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
diff --git a/frontend/src/api/ai-settings.schemas.ts b/frontend/src/api/ai-settings.schemas.ts
index 80f30899..9b182cce 100644
--- a/frontend/src/api/ai-settings.schemas.ts
+++ b/frontend/src/api/ai-settings.schemas.ts
@@ -47,7 +47,7 @@ export const saveTenantAiProfileSchema = z.object({
apiVersion: z.string(),
keepAlive: z.string(),
allowExternalResearch: z.boolean(),
- webResearchMode: z.enum(['Disabled', 'ProviderNative', 'PatchHoundManaged']),
+ webResearchMode: z.enum(['Disabled', 'ProviderNative', 'PatchHoundManaged', 'LocalVulnerabilityIntel']),
includeCitations: z.boolean(),
maxResearchSources: z.number().int().positive(),
allowedDomains: z.string(),
diff --git a/frontend/src/components/features/settings/TenantAiSettingsPage.tsx b/frontend/src/components/features/settings/TenantAiSettingsPage.tsx
index 34938dda..3725f290 100644
--- a/frontend/src/components/features/settings/TenantAiSettingsPage.tsx
+++ b/frontend/src/components/features/settings/TenantAiSettingsPage.tsx
@@ -893,7 +893,7 @@ function AiProfileEditorPage({
Allow external web research
- Use recent external context when supported by the provider or by PatchHound-managed research.
+ Use recent external context when supported by the provider or by PatchHound-managed research. Vulnerability assessments always use local PatchHound intel first.
@@ -917,9 +917,19 @@ function AiProfileEditorPage({
{draft.providerType === 'OpenAi' ? (
Provider native
) : null}
+ Local vulnerability intel
PatchHound managed
+ {draft.webResearchMode === 'PatchHoundManaged' ? (
+
+ PatchHound-managed external research sends search queries and fetched public pages through the configured research service.
+
+ ) : draft.webResearchMode === 'LocalVulnerabilityIntel' ? (
+
+ Local vulnerability intel uses PatchHound and NVD cache data only. It does not perform external HTTP research.
+
+ ) : null}
diff --git a/src/PatchHound.Api/appsettings.Development.json b/src/PatchHound.Api/appsettings.Development.json
index 40e97afe..aa9edda0 100644
--- a/src/PatchHound.Api/appsettings.Development.json
+++ b/src/PatchHound.Api/appsettings.Development.json
@@ -10,5 +10,8 @@
},
"ConnectionStrings": {
"PatchHound": ""
+ },
+ "AiResearch": {
+ "JinaSearchProvider": "Google"
}
}
diff --git a/src/PatchHound.Api/appsettings.json b/src/PatchHound.Api/appsettings.json
index 2299ae16..ad0b1d8d 100644
--- a/src/PatchHound.Api/appsettings.json
+++ b/src/PatchHound.Api/appsettings.json
@@ -27,6 +27,9 @@
"Frontend": {
"Origin": "http://localhost:5173"
},
+ "AiResearch": {
+ "JinaSearchProvider": "Google"
+ },
"FeatureManagement": {
"Workflows": true,
"AuthenticatedScans": true
diff --git a/src/PatchHound.Core/Enums/AiResearchProviderKind.cs b/src/PatchHound.Core/Enums/AiResearchProviderKind.cs
new file mode 100644
index 00000000..3ab08b7e
--- /dev/null
+++ b/src/PatchHound.Core/Enums/AiResearchProviderKind.cs
@@ -0,0 +1,7 @@
+namespace PatchHound.Core.Enums;
+
+public enum AiResearchProviderKind
+{
+ ExternalWebSearch = 0,
+ LocalVulnerabilityIntel = 1,
+}
diff --git a/src/PatchHound.Core/Enums/TenantAiWebResearchMode.cs b/src/PatchHound.Core/Enums/TenantAiWebResearchMode.cs
index 0bc539b8..9b624111 100644
--- a/src/PatchHound.Core/Enums/TenantAiWebResearchMode.cs
+++ b/src/PatchHound.Core/Enums/TenantAiWebResearchMode.cs
@@ -5,4 +5,5 @@ public enum TenantAiWebResearchMode
Disabled = 0,
ProviderNative = 1,
PatchHoundManaged = 2,
+ LocalVulnerabilityIntel = 3,
}
diff --git a/src/PatchHound.Core/Models/AiWebResearchRequest.cs b/src/PatchHound.Core/Models/AiWebResearchRequest.cs
index 7c2cffe3..c09966ee 100644
--- a/src/PatchHound.Core/Models/AiWebResearchRequest.cs
+++ b/src/PatchHound.Core/Models/AiWebResearchRequest.cs
@@ -1,8 +1,12 @@
+using PatchHound.Core.Enums;
+
namespace PatchHound.Core.Models;
public record AiWebResearchRequest(
string Query,
IReadOnlyList AllowedDomains,
int MaxSources,
- bool IncludeCitations
+ bool IncludeCitations,
+ IReadOnlyList? VulnerabilityIds = null,
+ IReadOnlyList? Providers = null
);
diff --git a/src/PatchHound.Infrastructure/DependencyInjection.cs b/src/PatchHound.Infrastructure/DependencyInjection.cs
index ebf1d60d..e6fc99b0 100644
--- a/src/PatchHound.Infrastructure/DependencyInjection.cs
+++ b/src/PatchHound.Infrastructure/DependencyInjection.cs
@@ -128,8 +128,11 @@ void ConfigureDbContext(IServiceProvider sp, DbContextOptionsBuilder options) =>
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.Configure(configuration.GetSection(AiResearchOptions.SectionName));
+ services.AddScoped();
+ services.AddScoped();
services
- .AddHttpClient()
+ .AddHttpClient()
.AddExternalHttpPolicies(maxConnectionsPerServer: 2);
services.AddScoped();
services.AddScoped();
diff --git a/src/PatchHound.Infrastructure/Options/AiResearchOptions.cs b/src/PatchHound.Infrastructure/Options/AiResearchOptions.cs
new file mode 100644
index 00000000..264b1424
--- /dev/null
+++ b/src/PatchHound.Infrastructure/Options/AiResearchOptions.cs
@@ -0,0 +1,8 @@
+namespace PatchHound.Infrastructure.Options;
+
+public class AiResearchOptions
+{
+ public const string SectionName = "AiResearch";
+
+ public string JinaSearchProvider { get; set; } = "Google";
+}
diff --git a/src/PatchHound.Infrastructure/Services/ExternalWebSearchResearchProvider.cs b/src/PatchHound.Infrastructure/Services/ExternalWebSearchResearchProvider.cs
new file mode 100644
index 00000000..453f0cdf
--- /dev/null
+++ b/src/PatchHound.Infrastructure/Services/ExternalWebSearchResearchProvider.cs
@@ -0,0 +1,385 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Options;
+using PatchHound.Core.Common;
+using PatchHound.Core.Models;
+using PatchHound.Infrastructure.Options;
+
+namespace PatchHound.Infrastructure.Services;
+
+public partial class ExternalWebSearchResearchProvider
+{
+ private const int MaxSnippetChars = 1800;
+ private const int MaxContextChars = 6000;
+
+ private readonly HttpClient _httpClient;
+ private readonly AiResearchOptions _options;
+
+ public ExternalWebSearchResearchProvider(
+ HttpClient httpClient,
+ IOptions? options = null
+ )
+ {
+ _httpClient = httpClient;
+ _options = options?.Value ?? new AiResearchOptions();
+ }
+
+ public async Task> ResearchAsync(
+ AiWebResearchRequest request,
+ CancellationToken ct
+ )
+ {
+ try
+ {
+ var query = BuildQuery(request);
+ var url = BuildSearchUrl(query, _options.JinaSearchProvider);
+
+ using var response = await _httpClient.GetAsync(url, ct);
+ var body = await response.Content.ReadAsStringAsync(ct);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return Result.Failure(
+ $"PatchHound-managed web research failed: {(int)response.StatusCode} {response.ReasonPhrase}"
+ );
+ }
+
+ if (string.IsNullOrWhiteSpace(body))
+ {
+ return Result.Failure(
+ "PatchHound-managed web research returned an empty response."
+ );
+ }
+
+ var sources = ExtractSources(body, request.MaxSources);
+ var sourceContexts = await FetchSourceContextsAsync(sources, ct);
+ var context = BuildContext(
+ body,
+ sources,
+ sourceContexts,
+ request.MaxSources,
+ request.IncludeCitations
+ );
+
+ if (string.IsNullOrWhiteSpace(context))
+ {
+ return Result.Failure(
+ "PatchHound-managed web research did not return usable context."
+ );
+ }
+
+ return Result.Success(
+ new AiWebResearchBundle(context, sources)
+ );
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(
+ $"PatchHound-managed web research failed: {ex.Message}"
+ );
+ }
+ }
+
+ private static string BuildQuery(AiWebResearchRequest request)
+ {
+ if (request.AllowedDomains.Count == 0)
+ {
+ return request.Query;
+ }
+
+ var domainTerms = request.AllowedDomains.Select(domain => $"site:{domain}");
+ return $"{request.Query} {string.Join(" OR ", domainTerms)}";
+ }
+
+ internal static string BuildSearchUrl(string query, string? provider)
+ {
+ var host = provider?.Trim().ToLowerInvariant() switch
+ {
+ "bing" => "www.bing.com",
+ _ => "www.google.com",
+ };
+
+ return $"https://r.jina.ai/http://{host}/search?q={Uri.EscapeDataString(query)}";
+ }
+
+ private static IReadOnlyList ExtractSources(string body, int maxSources)
+ {
+ var results = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (Match match in MarkdownLinkRegex().Matches(body))
+ {
+ var title = WebUtility.HtmlDecode(match.Groups["title"].Value.Trim());
+ var url = match.Groups["url"].Value.Trim();
+ if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(url))
+ {
+ continue;
+ }
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || !IsAllowedUrl(uri))
+ {
+ continue;
+ }
+
+ var normalizedUrl = uri.ToString();
+ if (!seen.Add(normalizedUrl))
+ {
+ continue;
+ }
+
+ results.Add(new AiWebResearchSource(title, normalizedUrl, null));
+ if (results.Count >= maxSources)
+ {
+ break;
+ }
+ }
+
+ if (results.Count >= maxSources)
+ {
+ return results;
+ }
+
+ foreach (Match match in BareUrlRegex().Matches(body))
+ {
+ var url = match.Value.TrimEnd('.', ',', ')', ']');
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || !IsAllowedUrl(uri))
+ {
+ continue;
+ }
+
+ var normalizedUrl = uri.ToString();
+ if (!seen.Add(normalizedUrl))
+ {
+ continue;
+ }
+
+ results.Add(new AiWebResearchSource(uri.Host, normalizedUrl, null));
+ if (results.Count >= maxSources)
+ {
+ break;
+ }
+ }
+
+ return results;
+ }
+
+ private async Task> FetchSourceContextsAsync(
+ IReadOnlyList sources,
+ CancellationToken ct
+ )
+ {
+ if (sources.Count == 0)
+ {
+ return new Dictionary();
+ }
+
+ var contexts = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var source in sources)
+ {
+ try
+ {
+ if (!Uri.TryCreate(source.Url, UriKind.Absolute, out var sourceUri))
+ {
+ continue;
+ }
+
+ var proxiedUrl = $"https://r.jina.ai/http://{sourceUri.Authority}{sourceUri.PathAndQuery}";
+ using var response = await _httpClient.GetAsync(proxiedUrl, ct);
+ if (!response.IsSuccessStatusCode)
+ {
+ continue;
+ }
+
+ var body = await response.Content.ReadAsStringAsync(ct);
+ var snippet = ExtractSourceSnippet(body);
+ if (!string.IsNullOrWhiteSpace(snippet))
+ {
+ contexts[source.Url] = snippet;
+ }
+ }
+ catch
+ {
+ // Source fetches are best effort; the managed search response still carries context.
+ }
+ }
+
+ return contexts;
+ }
+
+ private static bool IsAllowedUrl(Uri uri)
+ {
+ if (uri.Scheme is not ("http" or "https"))
+ {
+ return false;
+ }
+
+ if (uri.IsLoopback || IsInternalHostName(uri.Host))
+ {
+ return false;
+ }
+
+ if (uri.HostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6)
+ {
+ if (
+ IPAddress.TryParse(uri.Host, out var address)
+ && IsNonPublicAddress(address)
+ )
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsInternalHostName(string host)
+ {
+ var normalized = host.TrimEnd('.').ToLowerInvariant();
+ return normalized is "localhost"
+ || normalized.EndsWith(".localhost", StringComparison.Ordinal)
+ || normalized.EndsWith(".local", StringComparison.Ordinal)
+ || normalized.EndsWith(".internal", StringComparison.Ordinal);
+ }
+
+ private static bool IsNonPublicAddress(IPAddress address)
+ {
+ if (IPAddress.IsLoopback(address))
+ {
+ return true;
+ }
+
+ if (address.Equals(IPAddress.Any)
+ || address.Equals(IPAddress.IPv6Any)
+ || address.Equals(IPAddress.None)
+ || address.Equals(IPAddress.IPv6None))
+ {
+ return true;
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ var bytes = address.GetAddressBytes();
+ return address.IsIPv6LinkLocal
+ || address.IsIPv6SiteLocal
+ || address.IsIPv6Multicast
+ || (bytes[0] & 0xfe) == 0xfc;
+ }
+
+ return IsPrivateIpv4(address);
+ }
+
+ private static bool IsPrivateIpv4(IPAddress address)
+ {
+ if (address.AddressFamily != AddressFamily.InterNetwork)
+ {
+ return false;
+ }
+
+ var bytes = address.GetAddressBytes();
+ return bytes[0] == 10
+ || (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
+ || (bytes[0] == 192 && bytes[1] == 168)
+ || (bytes[0] == 169 && bytes[1] == 254)
+ || bytes[0] == 0
+ || bytes[0] >= 224;
+ }
+
+ private static string ExtractSourceSnippet(string body)
+ {
+ var text = body.Replace("\r", "\n");
+ var lines = text
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(line => !line.StartsWith("Title:", StringComparison.OrdinalIgnoreCase))
+ .Where(line => !line.StartsWith("URL Source:", StringComparison.OrdinalIgnoreCase))
+ .Where(line => !line.StartsWith("Markdown Content:", StringComparison.OrdinalIgnoreCase))
+ .Take(40);
+
+ return Truncate(string.Join('\n', lines), MaxSnippetChars);
+ }
+
+ private static string BuildContext(
+ string searchBody,
+ IReadOnlyList sources,
+ IReadOnlyDictionary sourceContexts,
+ int maxSources,
+ bool includeCitations
+ )
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("External research context:");
+ builder.AppendLine(ExtractSourceSnippet(searchBody));
+
+ if (sourceContexts.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("Fetched source context:");
+ foreach (var source in sources.Take(maxSources))
+ {
+ if (!sourceContexts.TryGetValue(source.Url, out var snippet))
+ {
+ continue;
+ }
+
+ builder.AppendLine($"- {source.Title} ({source.Url})");
+ builder.AppendLine(snippet);
+ }
+ }
+
+ if (includeCitations && sources.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("Sources:");
+ foreach (var source in sources.Take(maxSources))
+ {
+ builder.AppendLine($"- {source.Title}: {source.Url}");
+ }
+ }
+
+ return Truncate(builder.ToString().Trim(), MaxContextChars);
+ }
+
+ private static string Truncate(string value, int maxChars)
+ {
+ if (value.Length <= maxChars)
+ {
+ return value;
+ }
+
+ return value[..FindTruncationBoundary(value, maxChars)].TrimEnd() + "\n[truncated]";
+ }
+
+ private static int FindTruncationBoundary(string value, int maxChars)
+ {
+ var newline = value.LastIndexOf('\n', maxChars - 1, maxChars);
+ if (newline > maxChars / 2)
+ {
+ return newline;
+ }
+
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
+ {
+ if (value[index] is '.' or '!' or '?')
+ {
+ return index + 1;
+ }
+ }
+
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
+ {
+ if (char.IsWhiteSpace(value[index]))
+ {
+ return index;
+ }
+ }
+
+ return maxChars;
+ }
+
+ [GeneratedRegex(@"\[(?[^\]]+)\]\((?https?://[^)]+)\)", RegexOptions.IgnoreCase)]
+ private static partial Regex MarkdownLinkRegex();
+
+ [GeneratedRegex(@"https?://[^\s\])>]+", RegexOptions.IgnoreCase)]
+ private static partial Regex BareUrlRegex();
+}
diff --git a/src/PatchHound.Infrastructure/Services/LocalVulnerabilityIntelResearchProvider.cs b/src/PatchHound.Infrastructure/Services/LocalVulnerabilityIntelResearchProvider.cs
new file mode 100644
index 00000000..2f6a5f6e
--- /dev/null
+++ b/src/PatchHound.Infrastructure/Services/LocalVulnerabilityIntelResearchProvider.cs
@@ -0,0 +1,207 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using PatchHound.Core.Common;
+using PatchHound.Core.Entities;
+using PatchHound.Core.Models;
+using PatchHound.Infrastructure.Data;
+
+namespace PatchHound.Infrastructure.Services;
+
+public class LocalVulnerabilityIntelResearchProvider(PatchHoundDbContext db)
+{
+ private const int MaxRenderedVulnerabilities = 5;
+ private const int MaxContextChars = 6000;
+
+ public async Task> ResearchAsync(
+ AiWebResearchRequest request,
+ CancellationToken ct
+ )
+ {
+ if (request.VulnerabilityIds is not { Count: > 0 })
+ {
+ return Result.Success(new AiWebResearchBundle(string.Empty, []));
+ }
+
+ var vulnerabilities = await db.Vulnerabilities.AsNoTracking()
+ .Where(item => request.VulnerabilityIds.Contains(item.Id))
+ .OrderBy(item => item.ExternalId)
+ .ToListAsync(ct);
+
+ if (vulnerabilities.Count == 0)
+ {
+ return Result.Success(new AiWebResearchBundle(string.Empty, []));
+ }
+
+ var vulnerabilityIds = vulnerabilities.Select(item => item.Id).ToArray();
+ var externalIds = vulnerabilities.Select(item => item.ExternalId).ToArray();
+ var references = await db.VulnerabilityReferences.AsNoTracking()
+ .Where(item => vulnerabilityIds.Contains(item.VulnerabilityId))
+ .OrderBy(item => item.Source)
+ .ThenBy(item => item.Url)
+ .ToListAsync(ct);
+ var applicabilities = await db.VulnerabilityApplicabilities.AsNoTracking()
+ .Where(item => vulnerabilityIds.Contains(item.VulnerabilityId))
+ .OrderBy(item => item.CpeCriteria)
+ .ToListAsync(ct);
+ var assessments = await db.ThreatAssessments.AsNoTracking()
+ .Where(item => vulnerabilityIds.Contains(item.VulnerabilityId))
+ .ToListAsync(ct);
+ var nvdCache = await db.NvdCveCache.AsNoTracking()
+ .Where(item => externalIds.Contains(item.CveId))
+ .ToListAsync(ct);
+
+ var context = BuildContext(vulnerabilities, references, applicabilities, assessments, nvdCache);
+ var sources = references
+ .Select(item => new AiWebResearchSource(item.Source, item.Url, null))
+ .Concat(
+ nvdCache.SelectMany(item => ParseNvdReferences(item.ReferencesJson))
+ .Select(item => new AiWebResearchSource(item.Source, item.Url, null))
+ )
+ .GroupBy(item => item.Url, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .Take(request.MaxSources)
+ .ToList();
+
+ return Result.Success(new AiWebResearchBundle(context, sources));
+ }
+
+ private static string BuildContext(
+ IReadOnlyList vulnerabilities,
+ IReadOnlyList references,
+ IReadOnlyList applicabilities,
+ IReadOnlyList assessments,
+ IReadOnlyList nvdCache
+ )
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("Local vulnerability intel:");
+
+ foreach (var vulnerability in vulnerabilities.Take(MaxRenderedVulnerabilities))
+ {
+ builder.AppendLine($"- Vulnerability: {vulnerability.ExternalId}");
+ if (!string.IsNullOrWhiteSpace(vulnerability.Title))
+ {
+ builder.AppendLine($" Title: {vulnerability.Title}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(vulnerability.Description))
+ {
+ builder.AppendLine($" Canonical description: {vulnerability.Description}");
+ }
+
+ builder.AppendLine($" Severity: {vulnerability.VendorSeverity}");
+ if (vulnerability.CvssScore is not null)
+ {
+ builder.AppendLine($" CVSS score: {vulnerability.CvssScore}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(vulnerability.CvssVector))
+ {
+ builder.AppendLine($" CVSS vector: {vulnerability.CvssVector}");
+ }
+
+ var cached = nvdCache.FirstOrDefault(item =>
+ string.Equals(item.CveId, vulnerability.ExternalId, StringComparison.OrdinalIgnoreCase)
+ );
+ if (cached is not null)
+ {
+ builder.AppendLine($" NVD description: {cached.Description}");
+ if (cached.PublishedDate is not null)
+ {
+ builder.AppendLine($" NVD published: {cached.PublishedDate:O}");
+ }
+ }
+
+ var threat = assessments.FirstOrDefault(item => item.VulnerabilityId == vulnerability.Id);
+ if (threat is not null)
+ {
+ builder.AppendLine($" Threat score: {threat.ThreatScore}");
+ builder.AppendLine($" Exploit likelihood score: {threat.ExploitLikelihoodScore}");
+ if (threat.EpssScore is not null)
+ {
+ builder.AppendLine($" EPSS score: {threat.EpssScore}");
+ }
+
+ builder.AppendLine($" Known exploited: {threat.KnownExploited.ToString().ToLowerInvariant()}");
+ builder.AppendLine($" Public exploit: {threat.PublicExploit.ToString().ToLowerInvariant()}");
+ builder.AppendLine($" Active alert: {threat.ActiveAlert.ToString().ToLowerInvariant()}");
+ builder.AppendLine($" Ransomware association: {threat.HasRansomwareAssociation.ToString().ToLowerInvariant()}");
+ builder.AppendLine($" Malware association: {threat.HasMalwareAssociation.ToString().ToLowerInvariant()}");
+ }
+
+ var vulnReferences = references.Where(item => item.VulnerabilityId == vulnerability.Id).Take(10).ToList();
+ if (vulnReferences.Count > 0)
+ {
+ builder.AppendLine(" References:");
+ foreach (var reference in vulnReferences)
+ {
+ builder.AppendLine($" - {reference.Source}: {reference.Url}");
+ }
+ }
+
+ var vulnApplicabilities = applicabilities.Where(item => item.VulnerabilityId == vulnerability.Id).Take(10).ToList();
+ if (vulnApplicabilities.Count > 0)
+ {
+ builder.AppendLine(" Applicability:");
+ foreach (var applicability in vulnApplicabilities)
+ {
+ builder.AppendLine(
+ $" - CPE: {applicability.CpeCriteria ?? "mapped software product"}; vulnerable: {applicability.Vulnerable.ToString().ToLowerInvariant()}; version start including: {applicability.VersionStartIncluding ?? "n/a"}; version end including: {applicability.VersionEndIncluding ?? "n/a"}"
+ );
+ }
+ }
+ }
+
+ return Truncate(builder.ToString().Trim(), MaxContextChars);
+ }
+
+ private static IReadOnlyList ParseNvdReferences(string referencesJson)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize>(referencesJson) ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string Truncate(string value, int maxChars)
+ {
+ if (value.Length <= maxChars)
+ {
+ return value;
+ }
+
+ return value[..FindTruncationBoundary(value, maxChars)].TrimEnd() + "\n[truncated]";
+ }
+
+ private static int FindTruncationBoundary(string value, int maxChars)
+ {
+ var newline = value.LastIndexOf('\n', maxChars - 1, maxChars);
+ if (newline > maxChars / 2)
+ {
+ return newline;
+ }
+
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
+ {
+ if (value[index] is '.' or '!' or '?')
+ {
+ return index + 1;
+ }
+ }
+
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
+ {
+ if (char.IsWhiteSpace(value[index]))
+ {
+ return index;
+ }
+ }
+
+ return maxChars;
+ }
+}
diff --git a/src/PatchHound.Infrastructure/Services/TenantAiResearchService.cs b/src/PatchHound.Infrastructure/Services/TenantAiResearchService.cs
index 94167414..bd91e2da 100644
--- a/src/PatchHound.Infrastructure/Services/TenantAiResearchService.cs
+++ b/src/PatchHound.Infrastructure/Services/TenantAiResearchService.cs
@@ -1,323 +1,110 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-using System.Text.RegularExpressions;
using PatchHound.Core.Common;
+using PatchHound.Core.Enums;
using PatchHound.Core.Interfaces;
using PatchHound.Core.Models;
namespace PatchHound.Infrastructure.Services;
-public partial class TenantAiResearchService : ITenantAiResearchService
+public class TenantAiResearchService(
+ LocalVulnerabilityIntelResearchProvider localVulnerabilityIntelProvider,
+ ExternalWebSearchResearchProvider externalWebSearchProvider
+) : ITenantAiResearchService
{
- private readonly HttpClient _httpClient;
+ private const int MaxCombinedContextChars = 12000;
- public TenantAiResearchService(HttpClient httpClient)
- {
- _httpClient = httpClient;
- }
-
- public Task> ResearchAsync(
- TenantAiProfileResolved profile,
- AiWebResearchRequest request,
- CancellationToken ct
- )
- {
- return ResearchInternalAsync(profile, request, ct);
- }
-
- private async Task> ResearchInternalAsync(
+ public async Task> ResearchAsync(
TenantAiProfileResolved profile,
AiWebResearchRequest request,
CancellationToken ct
)
{
- try
- {
- var query = BuildQuery(request);
- var url =
- $"https://r.jina.ai/http://www.bing.com/search?q={Uri.EscapeDataString(query)}";
-
- using var response = await _httpClient.GetAsync(url, ct);
- var body = await response.Content.ReadAsStringAsync(ct);
-
- if (!response.IsSuccessStatusCode)
- {
- return Result.Failure(
- $"PatchHound-managed web research failed: {(int)response.StatusCode} {response.ReasonPhrase}"
- );
- }
-
- if (string.IsNullOrWhiteSpace(body))
- {
- return Result.Failure(
- "PatchHound-managed web research returned an empty response."
- );
- }
-
- var sources = ExtractSources(body, request.MaxSources);
- var sourceContexts = await FetchSourceContextsAsync(sources, ct);
- var context = BuildContext(
- body,
- sources,
- sourceContexts,
- request.MaxSources,
- request.IncludeCitations
- );
-
- if (string.IsNullOrWhiteSpace(context))
- {
- return Result.Failure(
- "PatchHound-managed web research did not return usable context."
- );
- }
-
- return Result.Success(
- new AiWebResearchBundle(context, sources)
- );
- }
- catch (Exception ex)
- {
- return Result.Failure(
- $"PatchHound-managed web research failed: {ex.Message}"
- );
- }
- }
-
- private static string BuildQuery(AiWebResearchRequest request)
- {
- if (request.AllowedDomains.Count == 0)
- {
- return request.Query;
- }
-
- var domainTerms = request.AllowedDomains.Select(domain => $"site:{domain}");
- return $"{request.Query} {string.Join(" OR ", domainTerms)}";
- }
-
- private static IReadOnlyList ExtractSources(string body, int maxSources)
- {
- var results = new List();
- var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (Match match in MarkdownLinkRegex().Matches(body))
- {
- var title = WebUtility.HtmlDecode(match.Groups["title"].Value.Trim());
- var url = match.Groups["url"].Value.Trim();
- if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(url))
- {
- continue;
- }
-
- if (!seen.Add(url))
- {
- continue;
- }
-
- results.Add(new AiWebResearchSource(title, url, null));
- if (results.Count >= maxSources)
- {
- break;
- }
- }
+ var providers = request.Providers is { Count: > 0 }
+ ? request.Providers
+ : [AiResearchProviderKind.ExternalWebSearch];
- foreach (Match match in BareUrlRegex().Matches(body))
- {
- var url = match.Value.Trim();
- if (!seen.Add(url))
- {
- continue;
- }
-
- results.Add(new AiWebResearchSource(url, url, null));
- if (results.Count >= maxSources)
- {
- break;
- }
- }
-
- return results;
- }
-
- private async Task> FetchSourceContextsAsync(
- IReadOnlyList sources,
- CancellationToken ct
- )
- {
var contexts = new List();
+ var sources = new List();
+ var errors = new List();
- foreach (var source in sources)
+ foreach (var provider in providers.Distinct())
{
- if (!IsAllowedUrl(source.Url))
+ var result = provider switch
{
- continue;
- }
-
- try
+ AiResearchProviderKind.LocalVulnerabilityIntel =>
+ await localVulnerabilityIntelProvider.ResearchAsync(request, ct),
+ AiResearchProviderKind.ExternalWebSearch =>
+ await externalWebSearchProvider.ResearchAsync(request, ct),
+ _ => Result.Success(new AiWebResearchBundle(string.Empty, [])),
+ };
+
+ if (!result.IsSuccess)
{
- var normalizedUrl = source.Url
- .Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
- .Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase);
- using var response = await _httpClient.GetAsync(
- $"https://r.jina.ai/http://{normalizedUrl}", ct);
- if (!response.IsSuccessStatusCode)
- {
- continue;
- }
-
- var body = await response.Content.ReadAsStringAsync(ct);
- if (string.IsNullOrWhiteSpace(body))
+ if (!string.IsNullOrWhiteSpace(result.Error))
{
- continue;
+ errors.Add(result.Error);
}
- var snippet = ExtractSourceSnippet(body);
- if (!string.IsNullOrWhiteSpace(snippet))
- {
- contexts.Add($"Source: {source.Title}\n{snippet}");
- }
+ continue;
}
- catch
+
+ if (!string.IsNullOrWhiteSpace(result.Value.Context))
{
- // Best-effort enrichment only; continue with available source context.
+ contexts.Add(result.Value.Context);
}
- }
-
- return contexts;
- }
- private static bool IsAllowedUrl(string url)
- {
- if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
- {
- return false;
+ sources.AddRange(result.Value.Sources);
}
- if (uri.Scheme is not ("http" or "https"))
+ if (contexts.Count == 0 && sources.Count == 0 && errors.Count > 0)
{
- return false;
+ return Result.Failure(errors[0]);
}
- var host = uri.Host;
-
- // Block private/internal/link-local addresses
- if (IPAddress.TryParse(host, out var ip))
- {
- if (ip.AddressFamily == AddressFamily.InterNetworkV6 && ip.IsIPv6LinkLocal)
- return false;
- var bytes = ip.GetAddressBytes();
- if (bytes.Length == 4)
- {
- // 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x, 169.254.x.x
- if (bytes[0] == 10) return false;
- if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
- if (bytes[0] == 192 && bytes[1] == 168) return false;
- if (bytes[0] == 127) return false;
- if (bytes[0] == 169 && bytes[1] == 254) return false;
- if (bytes[0] == 0) return false;
- }
- }
- else
- {
- // Block common internal hostnames
- if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
- return false;
- if (host.EndsWith(".local", StringComparison.OrdinalIgnoreCase))
- return false;
- if (host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase))
- return false;
- }
-
- return true;
+ return Result.Success(
+ new AiWebResearchBundle(
+ Truncate(string.Join("\n\n", contexts), MaxCombinedContextChars),
+ sources.GroupBy(item => item.Url, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .Take(request.MaxSources)
+ .ToList()
+ )
+ );
}
- private static string? ExtractSourceSnippet(string body)
+ private static string Truncate(string value, int maxChars)
{
- var normalizedBody = body
- .Replace("\r\n", "\n", StringComparison.Ordinal)
- .Replace('\r', '\n');
-
- var lines = normalizedBody
- .Split('\n', StringSplitOptions.TrimEntries)
- .Where(line =>
- !string.IsNullOrWhiteSpace(line)
- && !line.StartsWith("Title:", StringComparison.OrdinalIgnoreCase)
- && !line.StartsWith("URL Source:", StringComparison.OrdinalIgnoreCase)
- && !line.StartsWith("Markdown Content:", StringComparison.OrdinalIgnoreCase)
- )
- .Take(18)
- .ToList();
-
- if (lines.Count == 0)
+ if (value.Length <= maxChars)
{
- return null;
+ return value;
}
- var snippet = string.Join('\n', lines);
- return snippet.Length > 1800 ? snippet[..1800] : snippet;
+ return value[..FindTruncationBoundary(value, maxChars)].TrimEnd() + "\n[truncated]";
}
- private static string BuildContext(
- string body,
- IReadOnlyList sources,
- IReadOnlyList sourceContexts,
- int maxSources,
- bool includeCitations
- )
+ private static int FindTruncationBoundary(string value, int maxChars)
{
- var normalizedBody = body
- .Replace("\r\n", "\n", StringComparison.Ordinal)
- .Replace('\r', '\n');
-
- var lines = normalizedBody
- .Split('\n', StringSplitOptions.TrimEntries)
- .Where(line =>
- !string.IsNullOrWhiteSpace(line)
- && !line.StartsWith("Title:", StringComparison.OrdinalIgnoreCase)
- && !line.StartsWith("URL Source:", StringComparison.OrdinalIgnoreCase)
- && !line.StartsWith("Markdown Content:", StringComparison.OrdinalIgnoreCase)
- )
- .Take(40)
- .ToList();
-
- var builder = new StringBuilder();
- builder.AppendLine("External research context:");
- foreach (var line in lines)
+ var newline = value.LastIndexOf('\n', maxChars - 1, maxChars);
+ if (newline > maxChars / 2)
{
- builder.AppendLine(line);
+ return newline;
}
- if (sourceContexts.Count > 0)
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
{
- builder.AppendLine();
- builder.AppendLine("Fetched source context:");
- foreach (var sourceContext in sourceContexts.Take(maxSources))
+ if (value[index] is '.' or '!' or '?')
{
- builder.AppendLine(sourceContext);
- builder.AppendLine();
+ return index + 1;
}
}
- if (includeCitations && sources.Count > 0)
+ for (var index = maxChars - 1; index >= maxChars / 2; index--)
{
- builder.AppendLine();
- builder.AppendLine("Sources:");
- foreach (var source in sources.Take(maxSources))
+ if (char.IsWhiteSpace(value[index]))
{
- builder.Append("- ");
- builder.Append(source.Title);
- builder.Append(" — ");
- builder.AppendLine(source.Url);
+ return index;
}
}
- var context = builder.ToString().Trim();
- return context.Length > 6000 ? context[..6000] : context;
+ return maxChars;
}
-
- [GeneratedRegex(@"\[(?[^\]]+)\]\((?https?://[^)\s]+)\)", RegexOptions.IgnoreCase)]
- private static partial Regex MarkdownLinkRegex();
-
- [GeneratedRegex(@"https?://[^\s)>\]]+", RegexOptions.IgnoreCase)]
- private static partial Regex BareUrlRegex();
}
diff --git a/src/PatchHound.Worker/VulnerabilityAssessmentWorker.cs b/src/PatchHound.Worker/VulnerabilityAssessmentWorker.cs
index 7633f1f8..99087248 100644
--- a/src/PatchHound.Worker/VulnerabilityAssessmentWorker.cs
+++ b/src/PatchHound.Worker/VulnerabilityAssessmentWorker.cs
@@ -128,6 +128,7 @@ private async Task ProcessPendingJobAsync(CancellationToken ct)
var resolvedProfile = resolvedProfileResult.Value;
request = await BuildAssessmentRequestForProfileAsync(
resolvedProfile,
+ vulnerability.Id,
vulnerability.ExternalId,
request,
scope.ServiceProvider,
@@ -403,13 +404,17 @@ internal static AiTextGenerationRequest BuildAssessmentRequest(
&& profile.ProviderType == TenantAiProviderType.OpenAi
)
{
- return request with
+ var providerNativeRequest = request with
{
UseProviderNativeWebResearch = true,
AllowedDomains = ParseAllowedDomains(profile.AllowedDomains),
MaxResearchSources = profile.MaxResearchSources,
IncludeCitations = profile.IncludeCitations,
};
+
+ return string.IsNullOrWhiteSpace(externalContext)
+ ? providerNativeRequest
+ : providerNativeRequest with { ExternalContext = externalContext };
}
return string.IsNullOrWhiteSpace(externalContext)
@@ -419,6 +424,7 @@ internal static AiTextGenerationRequest BuildAssessmentRequest(
private async Task BuildAssessmentRequestForProfileAsync(
TenantAiProfileResolved resolvedProfile,
+ Guid vulnerabilityId,
string externalId,
AiTextGenerationRequest request,
IServiceProvider serviceProvider,
@@ -426,47 +432,63 @@ CancellationToken ct
)
{
var profile = resolvedProfile.Profile;
- if (!profile.AllowExternalResearch || profile.WebResearchMode == TenantAiWebResearchMode.Disabled)
- {
- return request;
- }
-
- if (
- profile.WebResearchMode == TenantAiWebResearchMode.ProviderNative
- && profile.ProviderType == TenantAiProviderType.OpenAi
- )
- {
- return BuildAssessmentRequest(externalId, profile, externalContext: null);
- }
-
- if (profile.WebResearchMode != TenantAiWebResearchMode.PatchHoundManaged)
- {
- return request;
- }
-
var researchService = serviceProvider.GetRequiredService();
+ var providers = profile.AllowExternalResearch && profile.WebResearchMode == TenantAiWebResearchMode.PatchHoundManaged
+ ? new[]
+ {
+ AiResearchProviderKind.LocalVulnerabilityIntel,
+ AiResearchProviderKind.ExternalWebSearch,
+ }
+ : new[]
+ {
+ AiResearchProviderKind.LocalVulnerabilityIntel,
+ };
+
var researchResult = await researchService.ResearchAsync(
resolvedProfile,
new AiWebResearchRequest(
BuildResearchQuery(externalId),
ParseAllowedDomains(profile.AllowedDomains),
profile.MaxResearchSources,
- profile.IncludeCitations
+ profile.IncludeCitations,
+ [vulnerabilityId],
+ providers
),
ct
);
- if (!researchResult.IsSuccess || string.IsNullOrWhiteSpace(researchResult.Value.Context))
+ var externalContext = researchResult.IsSuccess
+ ? researchResult.Value.Context
+ : null;
+
+ if (!researchResult.IsSuccess)
{
logger.LogWarning(
- "VulnerabilityAssessmentWorker: managed web research failed or returned no context for {ExternalId}: {Error}",
+ "VulnerabilityAssessmentWorker: managed research failed for {ExternalId}: {Error}",
externalId,
researchResult.Error ?? "empty context"
);
- return request;
}
- return BuildAssessmentRequest(externalId, profile, researchResult.Value.Context);
+ if (
+ profile.WebResearchMode == TenantAiWebResearchMode.ProviderNative
+ && profile.ProviderType == TenantAiProviderType.OpenAi
+ )
+ {
+ return BuildAssessmentRequest(externalId, profile, externalContext);
+ }
+
+ if (profile.AllowExternalResearch && profile.WebResearchMode == TenantAiWebResearchMode.PatchHoundManaged)
+ {
+ return BuildAssessmentRequest(externalId, profile, externalContext);
+ }
+
+ if (!string.IsNullOrWhiteSpace(externalContext))
+ {
+ return request with { ExternalContext = externalContext };
+ }
+
+ return request;
}
private static string BuildResearchQuery(string externalId) =>
diff --git a/src/PatchHound.Worker/appsettings.Development.json b/src/PatchHound.Worker/appsettings.Development.json
index 4e8c9f71..853e1c55 100644
--- a/src/PatchHound.Worker/appsettings.Development.json
+++ b/src/PatchHound.Worker/appsettings.Development.json
@@ -5,5 +5,8 @@
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
+ },
+ "AiResearch": {
+ "JinaSearchProvider": "Google"
}
}
diff --git a/src/PatchHound.Worker/appsettings.json b/src/PatchHound.Worker/appsettings.json
index 8adde7f6..82067bc4 100644
--- a/src/PatchHound.Worker/appsettings.json
+++ b/src/PatchHound.Worker/appsettings.json
@@ -6,6 +6,9 @@
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
+ "AiResearch": {
+ "JinaSearchProvider": "Google"
+ },
"FeatureManagement": {
"Workflows": true,
"AuthenticatedScans": true
diff --git a/tests/PatchHound.Tests/Infrastructure/TenantAiResearchServiceTests.cs b/tests/PatchHound.Tests/Infrastructure/TenantAiResearchServiceTests.cs
index 86b703c5..3b153cec 100644
--- a/tests/PatchHound.Tests/Infrastructure/TenantAiResearchServiceTests.cs
+++ b/tests/PatchHound.Tests/Infrastructure/TenantAiResearchServiceTests.cs
@@ -1,8 +1,15 @@
using System.Net;
+using System.Text.Json;
using System.Text;
using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using PatchHound.Core.Entities;
using PatchHound.Core.Enums;
+using PatchHound.Core.Interfaces;
using PatchHound.Core.Models;
+using PatchHound.Infrastructure.Data;
+using PatchHound.Infrastructure.Options;
using PatchHound.Infrastructure.Services;
using PatchHound.Tests.TestData;
@@ -29,7 +36,8 @@ Spring Framework remains widely deployed in enterprise environments.
),
}
);
- var service = new TenantAiResearchService(new HttpClient(handler));
+ await using var db = TestDbContextFactory.CreateSystemContext();
+ var service = CreateService(db, handler);
var profile = TenantAiProfileFactory.Create(
Guid.NewGuid(),
providerType: TenantAiProviderType.Ollama,
@@ -54,12 +62,277 @@ Spring Framework remains widely deployed in enterprise environments.
result.Value.Sources.Should().HaveCount(2);
result.Value.Sources[0].Url.Should().Be("https://spring.io/security");
handler.Requests.Should().HaveCount(3);
+ handler.Requests[0].RequestUri!.ToString().Should().StartWith("https://r.jina.ai/http://www.google.com/search?");
handler.Requests[0].RequestUri!.ToString().Should().Contain("site%3Anvd.nist.gov");
handler.Requests[0].RequestUri!.ToString().Should().Contain("site%3Aspring.io");
handler.Requests[1].RequestUri!.ToString().Should().Be("https://r.jina.ai/http://spring.io/security");
handler.Requests[2].RequestUri!.ToString().Should().Be("https://r.jina.ai/http://nvd.nist.gov/vuln/detail/CVE-2026-0001");
}
+ [Fact]
+ public async Task ResearchAsync_UsesConfiguredBingSearchProvider()
+ {
+ var handler = new RecordingHttpMessageHandler(
+ _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ Title: Bing
+ Markdown Content:
+ [NVD CVE Entry](https://nvd.nist.gov/vuln/detail/CVE-2026-0001)
+ """,
+ Encoding.UTF8,
+ "text/plain"
+ ),
+ }
+ );
+ await using var db = TestDbContextFactory.CreateSystemContext();
+ var service = CreateService(
+ db,
+ handler,
+ new AiResearchOptions { JinaSearchProvider = "Bing" }
+ );
+ var profile = TenantAiProfileFactory.Create(
+ Guid.NewGuid(),
+ providerType: TenantAiProviderType.Ollama,
+ allowExternalResearch: true,
+ webResearchMode: TenantAiWebResearchMode.PatchHoundManaged
+ );
+
+ await service.ResearchAsync(
+ new TenantAiProfileResolved(profile, string.Empty),
+ new AiWebResearchRequest("CVE-2026-0001", [], 1, true),
+ CancellationToken.None
+ );
+
+ handler.Requests[0].RequestUri!.ToString().Should().StartWith("https://r.jina.ai/http://www.bing.com/search?");
+ }
+
+ [Fact]
+ public void AddHttpClient_CanResolveTenantAiResearchService_WithConfiguredOptions()
+ {
+ var services = new ServiceCollection();
+ services.Configure(options => options.JinaSearchProvider = "Bing");
+ services.AddSingleton(TestDbContextFactory.CreateSystemContext());
+ services.AddScoped();
+ services.AddHttpClient();
+ services.AddScoped();
+
+ using var provider = services.BuildServiceProvider();
+
+ var service = provider.GetRequiredService();
+
+ service.Should().BeOfType();
+ }
+
+ [Fact]
+ public async Task ResearchAsync_ReturnsLocalVulnerabilityIntel_WithoutExternalHttp()
+ {
+ await using var db = TestDbContextFactory.CreateSystemContext();
+ var vulnerability = SeedLocalIntel(db);
+ var handler = new RecordingHttpMessageHandler(_ =>
+ throw new InvalidOperationException("External HTTP should not be used for local intel only.")
+ );
+ var service = CreateService(db, handler);
+ var profile = TenantAiProfileFactory.Create(
+ Guid.NewGuid(),
+ providerType: TenantAiProviderType.Ollama,
+ allowExternalResearch: false,
+ webResearchMode: TenantAiWebResearchMode.Disabled
+ );
+
+ var result = await service.ResearchAsync(
+ new TenantAiProfileResolved(profile, string.Empty),
+ new AiWebResearchRequest(
+ "CVE-2026-1234",
+ [],
+ 5,
+ true,
+ [vulnerability.Id],
+ [AiResearchProviderKind.LocalVulnerabilityIntel]
+ ),
+ CancellationToken.None
+ );
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Context.Should().Contain("Local vulnerability intel:");
+ result.Value.Context.Should().Contain("NVD cached description");
+ result.Value.Context.Should().Contain("Known exploited: true");
+ result.Value.Context.Should().Contain("https://vendor.example/advisory");
+ handler.Requests.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ResearchAsync_CombinesLocalIntelAndExternalWebSearch_WhenBothRequested()
+ {
+ await using var db = TestDbContextFactory.CreateSystemContext();
+ var vulnerability = SeedLocalIntel(db);
+ var handler = new RecordingHttpMessageHandler(
+ _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ """
+ Title: Search
+ Markdown Content:
+ [Vendor update](https://vendor.example/update)
+ Exploitation is being discussed publicly.
+ """,
+ Encoding.UTF8,
+ "text/plain"
+ ),
+ }
+ );
+ var service = CreateService(db, handler);
+ var profile = TenantAiProfileFactory.Create(
+ Guid.NewGuid(),
+ providerType: TenantAiProviderType.Ollama,
+ allowExternalResearch: true,
+ webResearchMode: TenantAiWebResearchMode.PatchHoundManaged
+ );
+
+ var result = await service.ResearchAsync(
+ new TenantAiProfileResolved(profile, string.Empty),
+ new AiWebResearchRequest(
+ "CVE-2026-1234",
+ [],
+ 5,
+ true,
+ [vulnerability.Id],
+ [AiResearchProviderKind.LocalVulnerabilityIntel, AiResearchProviderKind.ExternalWebSearch]
+ ),
+ CancellationToken.None
+ );
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Context.Should().Contain("Local vulnerability intel:");
+ result.Value.Context.Should().Contain("NVD cached description");
+ result.Value.Context.Should().Contain("External research context:");
+ result.Value.Context.Should().Contain("Exploitation is being discussed publicly.");
+ handler.Requests.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public async Task ResearchAsync_LocalIntelCacheMiss_ReturnsEmptyBundleWithoutExternalHttp()
+ {
+ await using var db = TestDbContextFactory.CreateSystemContext();
+ var handler = new RecordingHttpMessageHandler(_ =>
+ throw new InvalidOperationException("External HTTP should not be used for local intel only.")
+ );
+ var service = CreateService(db, handler);
+ var profile = TenantAiProfileFactory.Create(
+ Guid.NewGuid(),
+ providerType: TenantAiProviderType.Ollama,
+ allowExternalResearch: false,
+ webResearchMode: TenantAiWebResearchMode.Disabled
+ );
+
+ var result = await service.ResearchAsync(
+ new TenantAiProfileResolved(profile, string.Empty),
+ new AiWebResearchRequest(
+ "CVE-2026-9999",
+ [],
+ 5,
+ true,
+ [Guid.NewGuid()],
+ [AiResearchProviderKind.LocalVulnerabilityIntel]
+ ),
+ CancellationToken.None
+ );
+
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Context.Should().BeEmpty();
+ result.Value.Sources.Should().BeEmpty();
+ handler.Requests.Should().BeEmpty();
+ }
+
+ private static TenantAiResearchService CreateService(
+ PatchHoundDbContext db,
+ RecordingHttpMessageHandler handler,
+ AiResearchOptions? options = null
+ )
+ {
+ var externalProvider = new ExternalWebSearchResearchProvider(
+ new HttpClient(handler),
+ Options.Create(options ?? new AiResearchOptions())
+ );
+ return new TenantAiResearchService(
+ new LocalVulnerabilityIntelResearchProvider(db),
+ externalProvider
+ );
+ }
+
+ private static Vulnerability SeedLocalIntel(PatchHoundDbContext db)
+ {
+ var vulnerability = Vulnerability.Create(
+ "nvd",
+ "CVE-2026-1234",
+ "CVE-2026-1234",
+ "Canonical description",
+ Severity.Critical,
+ 9.8m,
+ "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
+ new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero)
+ );
+ db.Vulnerabilities.Add(vulnerability);
+ db.VulnerabilityReferences.Add(
+ VulnerabilityReference.Create(
+ vulnerability.Id,
+ "https://vendor.example/advisory",
+ "Vendor",
+ ["advisory"]
+ )
+ );
+ db.VulnerabilityApplicabilities.Add(
+ VulnerabilityApplicability.Create(
+ vulnerability.Id,
+ null,
+ "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*",
+ true,
+ "1.0",
+ null,
+ "2.0",
+ null,
+ "nvd"
+ )
+ );
+ db.ThreatAssessments.Add(
+ ThreatAssessment.Create(
+ vulnerability.Id,
+ 95m,
+ 90m,
+ 88m,
+ 70m,
+ 0.91m,
+ knownExploited: true,
+ publicExploit: true,
+ activeAlert: false,
+ hasRansomwareAssociation: true,
+ hasMalwareAssociation: false,
+ "[]",
+ "test"
+ )
+ );
+ db.NvdCveCache.Add(
+ NvdCveCache.Create(
+ "CVE-2026-1234",
+ "NVD cached description",
+ 9.8m,
+ "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
+ new DateTimeOffset(2026, 1, 2, 0, 0, 0, TimeSpan.Zero),
+ DateTimeOffset.UtcNow,
+ JsonSerializer.Serialize(
+ new[] { new NvdCachedReference("https://nvd.nist.gov/vuln/detail/CVE-2026-1234", "NVD", new List { "nvd" }) }
+ ),
+ JsonSerializer.Serialize(
+ new[] { new NvdCachedCpeMatch(true, "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", "1.0", null, "2.0", null) }
+ )
+ )
+ );
+ db.SaveChanges();
+
+ return vulnerability;
+ }
+
private sealed class RecordingHttpMessageHandler(
Func responder
) : HttpMessageHandler