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(@"\[(?[^\]]+)\]\((?<url>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<Result<AiWebResearchBundle>> ResearchAsync( + AiWebResearchRequest request, + CancellationToken ct + ) + { + if (request.VulnerabilityIds is not { Count: > 0 }) + { + return Result<AiWebResearchBundle>.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<AiWebResearchBundle>.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<AiWebResearchBundle>.Success(new AiWebResearchBundle(context, sources)); + } + + private static string BuildContext( + IReadOnlyList<Vulnerability> vulnerabilities, + IReadOnlyList<VulnerabilityReference> references, + IReadOnlyList<VulnerabilityApplicability> applicabilities, + IReadOnlyList<ThreatAssessment> assessments, + IReadOnlyList<NvdCveCache> 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<NvdCachedReference> ParseNvdReferences(string referencesJson) + { + try + { + return JsonSerializer.Deserialize<List<NvdCachedReference>>(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<Result<AiWebResearchBundle>> ResearchAsync( - TenantAiProfileResolved profile, - AiWebResearchRequest request, - CancellationToken ct - ) - { - return ResearchInternalAsync(profile, request, ct); - } - - private async Task<Result<AiWebResearchBundle>> ResearchInternalAsync( + public async Task<Result<AiWebResearchBundle>> 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<AiWebResearchBundle>.Failure( - $"PatchHound-managed web research failed: {(int)response.StatusCode} {response.ReasonPhrase}" - ); - } - - if (string.IsNullOrWhiteSpace(body)) - { - return Result<AiWebResearchBundle>.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<AiWebResearchBundle>.Failure( - "PatchHound-managed web research did not return usable context." - ); - } - - return Result<AiWebResearchBundle>.Success( - new AiWebResearchBundle(context, sources) - ); - } - catch (Exception ex) - { - return Result<AiWebResearchBundle>.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<AiWebResearchSource> ExtractSources(string body, int maxSources) - { - var results = new List<AiWebResearchSource>(); - var seen = new HashSet<string>(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<IReadOnlyList<string>> FetchSourceContextsAsync( - IReadOnlyList<AiWebResearchSource> sources, - CancellationToken ct - ) - { var contexts = new List<string>(); + var sources = new List<AiWebResearchSource>(); + var errors = new List<string>(); - 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<AiWebResearchBundle>.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<AiWebResearchBundle>.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<AiWebResearchBundle>.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<AiWebResearchSource> sources, - IReadOnlyList<string> 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(@"\[(?<title>[^\]]+)\]\((?<url>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<AiTextGenerationRequest> 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<ITenantAiResearchService>(); + 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<AiResearchOptions>(options => options.JinaSearchProvider = "Bing"); + services.AddSingleton(TestDbContextFactory.CreateSystemContext()); + services.AddScoped<LocalVulnerabilityIntelResearchProvider>(); + services.AddHttpClient<ExternalWebSearchResearchProvider>(); + services.AddScoped<ITenantAiResearchService, TenantAiResearchService>(); + + using var provider = services.BuildServiceProvider(); + + var service = provider.GetRequiredService<ITenantAiResearchService>(); + + service.Should().BeOfType<TenantAiResearchService>(); + } + + [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<string> { "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<HttpRequestMessage, HttpResponseMessage> responder ) : HttpMessageHandler