mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
d72a79b156
After the MCP search tools moved to the paginated /api/raw/events fan-out (prev commit), /api/raw/events/search had zero callers anywhere in the repo (no MCP tool, no web). Remove the now-dead surface end to end: - Delete SearchEventsFunction (raw/events/search + global/raw/events/search). - Drop both routes from EndpointAccessPolicyCatalog and the no-store cache middleware allowlist (+ its test InlineData). EndpointPolicyCatalog completeness test stays green (route and entry removed together). - Remove the now-orphaned repo method SearchEventsByTypesAsync (its only caller was the deleted endpoint) from ISessionRepository + TableSessionRepository delegation + TableStorageService impl. - Remove the non-paginated SearchSessionsByEventAsync: its only runtime caller was SearchEventsByTypesAsync, and it is strictly redundant with the resumable SearchSessionsByEventPageAsync (used by /api/raw/events + search/sessions-by-event). Reword the surviving paged method's xmldoc/comment that referenced it. No capability lost: multi-type cross-session event search still runs via the MCP fan-out over the paginated single-type endpoint; single-type primitives remain exposed via query_raw_events + search_sessions_by_event. Full solution builds clean (0 errors); catalog-completeness + cache-middleware tests 77/77. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1197 lines
57 KiB
C#
1197 lines
57 KiB
C#
using Azure;
|
|
using Azure.Data.Tables;
|
|
using AutopilotMonitor.Functions.Pagination;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Vulnerability;
|
|
using AutopilotMonitor.Shared.Pagination;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services
|
|
{
|
|
public partial class TableStorageService
|
|
{
|
|
// ===== EVENT TYPE INDEX =====
|
|
|
|
/// <summary>
|
|
/// Upserts entries into the EventTypeIndex table for each distinct event type in the batch.
|
|
/// PartitionKey = {tenantId}_{eventType}, RowKey = {invertedTicks}_{sessionId}
|
|
/// This enables efficient "find all sessions with event X" queries.
|
|
/// </summary>
|
|
public async Task UpsertEventTypeIndexBatchAsync(string tenantId, string sessionId, IEnumerable<AutopilotMonitor.Shared.Models.EnrollmentEvent> events)
|
|
{
|
|
try
|
|
{
|
|
var eventList = events.ToList();
|
|
if (eventList.Count == 0) return;
|
|
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.EventTypeIndex);
|
|
|
|
// Obtain stable StartedAt for deterministic RowKey (one GET per batch call)
|
|
DateTime startedAt;
|
|
try
|
|
{
|
|
var sessionsTable = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
|
var sessionEntity = await sessionsTable.GetEntityAsync<TableEntity>(tenantId, sessionId,
|
|
select: new[] { "StartedAt" });
|
|
startedAt = sessionEntity.Value.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.UtcNow;
|
|
}
|
|
catch (RequestFailedException ex) when (ex.Status == 404)
|
|
{
|
|
startedAt = eventList.Min(e => e.Timestamp);
|
|
}
|
|
|
|
var rowKey = $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
|
|
|
var groups = eventList.GroupBy(e => e.EventType);
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
var eventType = group.Key;
|
|
var partitionKey = $"{tenantId}_{eventType}";
|
|
|
|
var batchMaxSeverity = (int)group.Max(e => e.Severity);
|
|
var batchSources = group
|
|
.Where(e => !string.IsNullOrEmpty(e.Source))
|
|
.Select(e => e.Source!)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
for (int retry = 0; retry < 3; retry++)
|
|
{
|
|
try
|
|
{
|
|
int existingMaxSeverity = -2;
|
|
var existingSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
ETag etag = default;
|
|
bool exists = false;
|
|
|
|
try
|
|
{
|
|
var existing = await tableClient.GetEntityAsync<TableEntity>(partitionKey, rowKey);
|
|
existingMaxSeverity = existing.Value.GetInt32("MaxSeverity") ?? -2;
|
|
var sourcesStr = existing.Value.GetString("Sources") ?? "";
|
|
if (!string.IsNullOrEmpty(sourcesStr))
|
|
{
|
|
foreach (var s in sourcesStr.Split(','))
|
|
existingSources.Add(s.Trim());
|
|
}
|
|
etag = existing.Value.ETag;
|
|
exists = true;
|
|
}
|
|
catch (RequestFailedException ex2) when (ex2.Status == 404) { }
|
|
|
|
var mergedMaxSeverity = Math.Max(existingMaxSeverity, batchMaxSeverity);
|
|
existingSources.UnionWith(batchSources);
|
|
var mergedSources = string.Join(",", existingSources.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
|
var mergedSeverityName = ((AutopilotMonitor.Shared.Models.EventSeverity)mergedMaxSeverity).ToString();
|
|
|
|
var entity = new TableEntity(partitionKey, rowKey)
|
|
{
|
|
["SessionId"] = sessionId,
|
|
["TenantId"] = tenantId,
|
|
["EventType"] = eventType,
|
|
["MaxSeverity"] = mergedMaxSeverity,
|
|
["MaxSeverityName"] = mergedSeverityName,
|
|
["Sources"] = mergedSources,
|
|
};
|
|
|
|
if (exists)
|
|
await tableClient.UpdateEntityAsync(entity, etag, TableUpdateMode.Replace);
|
|
else
|
|
await tableClient.UpsertEntityAsync(entity);
|
|
|
|
break;
|
|
}
|
|
catch (RequestFailedException ex3) when (ex3.Status == 412 && retry < 2)
|
|
{
|
|
// ETag conflict — retry with fresh read
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to upsert EventTypeIndex for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
// ===== DEVICE SNAPSHOT =====
|
|
|
|
private static readonly HashSet<string> _deviceSnapshotEventTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"tpm_status",
|
|
"autopilot_profile",
|
|
"secureboot_status",
|
|
"bitlocker_status",
|
|
"hardware_spec",
|
|
"network_interface_info",
|
|
"aad_join_status"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Upserts a DeviceSnapshot entry for the session using one Props_{eventType} JSON column
|
|
/// per device event type. This stores ALL properties (including arrays) without explicit mapping.
|
|
/// New agent properties become searchable automatically — zero code changes required.
|
|
/// Existing columns are preserved (first-seen wins) via merge-on-write.
|
|
/// </summary>
|
|
public async Task UpsertDeviceSnapshotAsync(string tenantId, string sessionId, IEnumerable<AutopilotMonitor.Shared.Models.EnrollmentEvent> events)
|
|
{
|
|
try
|
|
{
|
|
var relevantEvents = events.Where(e => _deviceSnapshotEventTypes.Contains(e.EventType)).ToList();
|
|
if (relevantEvents.Count == 0) return;
|
|
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
|
|
|
TableEntity entity;
|
|
try
|
|
{
|
|
var existing = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
|
|
entity = existing.Value;
|
|
}
|
|
catch (RequestFailedException ex) when (ex.Status == 404)
|
|
{
|
|
entity = new TableEntity(tenantId, sessionId)
|
|
{
|
|
["SessionId"] = sessionId,
|
|
["TenantId"] = tenantId,
|
|
};
|
|
}
|
|
|
|
foreach (var evt in relevantEvents)
|
|
{
|
|
if (evt.Data == null || evt.Data.Count == 0) continue;
|
|
|
|
var columnName = $"Props_{evt.EventType}";
|
|
|
|
// First-seen wins: don't overwrite existing event data
|
|
if (entity.ContainsKey(columnName) && entity.GetString(columnName) != null)
|
|
continue;
|
|
|
|
try
|
|
{
|
|
var data = evt.Data;
|
|
|
|
// Compute derived values for hardware_spec
|
|
if (evt.EventType.Equals("hardware_spec", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var hasSSD = DetectHasSSD(data);
|
|
if (hasSSD.HasValue && !data.ContainsKey("hasSSD"))
|
|
data["hasSSD"] = hasSSD.Value;
|
|
}
|
|
|
|
var json = System.Text.Json.JsonSerializer.Serialize(data);
|
|
|
|
// Size guard: warn if approaching 64 KB column limit
|
|
if (json.Length > 50_000)
|
|
{
|
|
_logger.LogWarning(
|
|
"DeviceSnapshot column {Column} for session {SessionId} is {Size} bytes (approaching 64KB limit)",
|
|
columnName, sessionId, json.Length);
|
|
}
|
|
|
|
entity[columnName] = json;
|
|
}
|
|
catch (Exception evtEx)
|
|
{
|
|
_logger.LogDebug(evtEx, "DeviceSnapshot: error processing event type {EventType}", evt.EventType);
|
|
}
|
|
}
|
|
|
|
await tableClient.UpsertEntityAsync(entity);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to upsert DeviceSnapshot for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
private static bool? DetectHasSSD(Dictionary<string, object> data)
|
|
{
|
|
try
|
|
{
|
|
if (!data.TryGetValue("disks", out var disksObj) || disksObj == null) return null;
|
|
if (disksObj is not System.Collections.IEnumerable diskList) return null;
|
|
|
|
foreach (var disk in diskList)
|
|
{
|
|
if (disk is not Dictionary<string, object> diskDict) continue;
|
|
|
|
if (diskDict.TryGetValue("mediaType", out var mt))
|
|
{
|
|
var mtStr = mt?.ToString() ?? "";
|
|
if (mtStr.Equals("SSD", StringComparison.OrdinalIgnoreCase) ||
|
|
mtStr.Equals("NVMe", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ===== CVE INDEX =====
|
|
|
|
/// <summary>
|
|
/// Upserts CVE index entries so sessions can be searched by CVE identifier.
|
|
/// PartitionKey = {tenantId}_{cveId}, RowKey = sessionId
|
|
/// Uses individual parallel upserts (not batch transactions) because PK differs per CVE.
|
|
/// </summary>
|
|
public async Task UpsertCveIndexEntriesAsync(string tenantId, string sessionId, List<Dictionary<string, object>> findings)
|
|
{
|
|
try
|
|
{
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.CveIndex);
|
|
var tasks = new List<Task>();
|
|
|
|
foreach (var finding in findings)
|
|
{
|
|
var softwareName = finding.TryGetValue("softwareName", out var sn) ? sn?.ToString() ?? "" : "";
|
|
var overallRisk = finding.TryGetValue("riskLevel", out var rl) ? rl?.ToString() ?? "" : "";
|
|
|
|
if (!finding.TryGetValue("vulnerabilities", out var vulnsObj) || vulnsObj == null) continue;
|
|
if (vulnsObj is not System.Collections.IEnumerable vulnList) continue;
|
|
|
|
foreach (var vulnObj in vulnList)
|
|
{
|
|
Dictionary<string, object>? vuln = null;
|
|
if (vulnObj is Dictionary<string, object> vd)
|
|
vuln = vd;
|
|
|
|
if (vuln == null) continue;
|
|
|
|
var cveId = vuln.TryGetValue("cveId", out var cid) ? cid?.ToString() : null;
|
|
if (string.IsNullOrEmpty(cveId)) continue;
|
|
|
|
var partitionKey = $"{tenantId}_{cveId}";
|
|
double cvssScore = 0;
|
|
try { if (vuln.TryGetValue("cvssScore", out var cs) && cs != null) cvssScore = Convert.ToDouble(cs); } catch { }
|
|
|
|
var cvssSeverity = vuln.TryGetValue("cvssSeverity", out var csvs) ? csvs?.ToString() ?? "" : "";
|
|
bool isKev = false;
|
|
try { if (vuln.TryGetValue("isKev", out var ik) && ik is bool ikb) isKev = ikb; } catch { }
|
|
|
|
var entity = new TableEntity(partitionKey, sessionId)
|
|
{
|
|
["SessionId"] = sessionId,
|
|
["TenantId"] = tenantId,
|
|
["CveId"] = cveId,
|
|
["SoftwareName"] = softwareName,
|
|
["CvssScore"] = cvssScore,
|
|
["CvssSeverity"] = cvssSeverity,
|
|
["IsKev"] = isKev,
|
|
["OverallRisk"] = overallRisk,
|
|
["DetectedAt"] = DateTime.UtcNow,
|
|
};
|
|
|
|
tasks.Add(tableClient.UpsertEntityAsync(entity));
|
|
}
|
|
}
|
|
|
|
if (tasks.Count > 0)
|
|
await Task.WhenAll(tasks);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to upsert CveIndex entries for session {SessionId}", sessionId);
|
|
}
|
|
}
|
|
|
|
// ===== SEARCH METHODS =====
|
|
|
|
/// <summary>
|
|
/// Lightweight typeahead search: matches SessionId, SerialNumber, or DeviceName.
|
|
/// Supports exact contains match (priority) and fuzzy match (edit distance <= 2).
|
|
/// When tenantId is null, searches across all tenants (Global Admin).
|
|
/// </summary>
|
|
public async Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
|
|
{
|
|
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
|
var exactResults = new List<QuickSearchResult>();
|
|
var fuzzyResults = new List<QuickSearchResult>();
|
|
var q = query.Trim();
|
|
|
|
// Tenant-scoped or cross-tenant scan
|
|
string? filter = !string.IsNullOrEmpty(tenantId)
|
|
? $"PartitionKey eq '{tenantId}'"
|
|
: null;
|
|
|
|
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(filter: filter))
|
|
{
|
|
var sessionId = entity.GetString("SessionId") ?? ExtractSessionIdFromIndexRowKey(entity.RowKey);
|
|
var serial = entity.GetString("SerialNumber") ?? string.Empty;
|
|
var deviceName = entity.GetString("DeviceName") ?? string.Empty;
|
|
|
|
// Phase 1: exact substring match (highest priority)
|
|
string? matchedField = null;
|
|
if (sessionId.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
matchedField = "sessionId";
|
|
else if (serial.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
matchedField = "serialNumber";
|
|
else if (deviceName.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
matchedField = "deviceName";
|
|
|
|
if (matchedField != null)
|
|
{
|
|
exactResults.Add(BuildQuickSearchResult(entity, sessionId, serial, deviceName, matchedField));
|
|
if (exactResults.Count >= limit) break;
|
|
continue;
|
|
}
|
|
|
|
// Phase 2: fuzzy match — only if we don't have enough exact results yet
|
|
if (exactResults.Count + fuzzyResults.Count >= limit)
|
|
continue;
|
|
|
|
if (FuzzyContains(serial, q, maxDistance: 2))
|
|
matchedField = "serialNumber";
|
|
else if (FuzzyContains(deviceName, q, maxDistance: 2))
|
|
matchedField = "deviceName";
|
|
|
|
if (matchedField != null)
|
|
{
|
|
fuzzyResults.Add(BuildQuickSearchResult(entity, sessionId, serial, deviceName, matchedField));
|
|
}
|
|
}
|
|
|
|
// Exact matches first, then fuzzy, capped to limit
|
|
exactResults.AddRange(fuzzyResults);
|
|
return exactResults.Count > limit ? exactResults.GetRange(0, limit) : exactResults;
|
|
}
|
|
|
|
private QuickSearchResult BuildQuickSearchResult(
|
|
TableEntity entity, string sessionId, string serial, string deviceName, string matchedField)
|
|
{
|
|
var statusString = entity.GetString("Status") ?? "InProgress";
|
|
if (!Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status))
|
|
status = SessionStatus.Unknown;
|
|
|
|
return new QuickSearchResult
|
|
{
|
|
SessionId = sessionId,
|
|
SerialNumber = serial,
|
|
DeviceName = deviceName,
|
|
Status = status,
|
|
StartedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow,
|
|
MatchedField = matchedField,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if any substring of <paramref name="haystack"/> of length <paramref name="needle"/>.Length
|
|
/// is within edit distance <paramref name="maxDistance"/> of the needle (case-insensitive).
|
|
/// Uses a sliding-window Levenshtein approach for efficiency.
|
|
/// </summary>
|
|
private static bool FuzzyContains(string haystack, string needle, int maxDistance)
|
|
{
|
|
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
|
|
return false;
|
|
|
|
var h = haystack.ToLowerInvariant();
|
|
var n = needle.ToLowerInvariant();
|
|
var nLen = n.Length;
|
|
|
|
if (nLen > h.Length + maxDistance)
|
|
return false;
|
|
|
|
// Classic Levenshtein with early-termination: compute full distance between needle
|
|
// and every substring of haystack, but bail out once we find a match.
|
|
// We use a single-row DP optimisation.
|
|
var prev = new int[nLen + 1];
|
|
var curr = new int[nLen + 1];
|
|
|
|
for (int j = 0; j <= nLen; j++)
|
|
prev[j] = j;
|
|
|
|
for (int i = 1; i <= h.Length; i++)
|
|
{
|
|
curr[0] = 0; // Allow matching to start at any position in haystack
|
|
var minInRow = int.MaxValue;
|
|
|
|
for (int j = 1; j <= nLen; j++)
|
|
{
|
|
var cost = h[i - 1] == n[j - 1] ? 0 : 1;
|
|
curr[j] = Math.Min(
|
|
Math.Min(curr[j - 1] + 1, prev[j] + 1),
|
|
prev[j - 1] + cost);
|
|
minInRow = Math.Min(minInRow, curr[j]);
|
|
}
|
|
|
|
// If the best possible score in this row already exceeds threshold, this row contributes nothing
|
|
// but we can't prune early because curr[0]=0 resets at each position.
|
|
|
|
if (curr[nLen] <= maxDistance)
|
|
return true;
|
|
|
|
(prev, curr) = (curr, prev);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Searches enrollment sessions by filter. Uses DeviceSnapshot index for hardware filters,
|
|
/// otherwise scans Sessions table with OData filtering.
|
|
/// </summary>
|
|
public async Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
|
|
{
|
|
if (filter.HasDeviceSnapshotFilters)
|
|
return await SearchSessionsByDeviceSnapshotAsync(tenantId, filter);
|
|
else
|
|
return await SearchSessionsByScanAsync(tenantId, filter);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Paged variant of <see cref="SearchSessionsAsync"/> backing
|
|
/// <c>/api/search/sessions</c> + <c>/api/global/search/sessions</c>.
|
|
/// The <c>filter.Limit</c> field is ignored — pagination uses
|
|
/// <paramref name="pageSize"/> + <paramref name="continuation"/>.
|
|
/// Both code paths (scan + device-snapshot) emit Azure-Tables raw
|
|
/// continuation tokens; the caller's filter fingerprint must include
|
|
/// the filter args so a token from one path can't be replayed into
|
|
/// the other.
|
|
/// </summary>
|
|
public async Task<RawPage<SessionSummary>> SearchSessionsPageAsync(
|
|
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
|
{
|
|
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
|
return filter.HasDeviceSnapshotFilters
|
|
? await SearchSessionsByDeviceSnapshotPageAsync(tenantId, filter, pageSize, continuation)
|
|
: await SearchSessionsByScanPageAsync(tenantId, filter, pageSize, continuation);
|
|
}
|
|
|
|
private async Task<RawPage<SessionSummary>> SearchSessionsByScanPageAsync(
|
|
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
|
{
|
|
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
|
var oDataFilter = BuildSearchScanFilter(tenantId, filter);
|
|
|
|
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
|
client: indexTableClient,
|
|
filter: oDataFilter,
|
|
pageSize: pageSize,
|
|
continuation: continuation);
|
|
|
|
var sessions = new List<SessionSummary>(entities.Count);
|
|
foreach (var entity in entities)
|
|
{
|
|
var session = MapIndexEntityToSessionSummary(entity);
|
|
if (!MatchesScanClientFilters(session, filter)) continue;
|
|
sessions.Add(session);
|
|
}
|
|
// Note: a page may legitimately contain fewer than pageSize items after
|
|
// client-side filters; callers should follow nextLink until absent for
|
|
// forensics-grade exact-count semantics (Plan §"consume until absent").
|
|
return new RawPage<SessionSummary>(sessions, nextRawToken);
|
|
}
|
|
|
|
private async Task<RawPage<SessionSummary>> SearchSessionsByDeviceSnapshotPageAsync(
|
|
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
|
{
|
|
var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
|
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
|
|
|
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
|
client: snapshotTableClient,
|
|
filter: oDataFilter,
|
|
pageSize: pageSize,
|
|
continuation: continuation);
|
|
|
|
var sessionIds = new List<string>();
|
|
foreach (var entity in entities)
|
|
{
|
|
var properties = ReconstructDeviceProperties(entity);
|
|
if (properties == null || properties.Count == 0) continue;
|
|
if (!MatchesAllDeviceFilters(properties, filter.DeviceProperties!)) continue;
|
|
sessionIds.Add(entity.RowKey);
|
|
}
|
|
|
|
if (sessionIds.Count == 0)
|
|
{
|
|
return new RawPage<SessionSummary>(Array.Empty<SessionSummary>(), nextRawToken);
|
|
}
|
|
|
|
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
|
|
sessions = ApplyBasicFilters(sessions, filter);
|
|
return new RawPage<SessionSummary>(sessions, nextRawToken);
|
|
}
|
|
|
|
private static string? BuildSearchScanFilter(string? tenantId, SessionSearchFilter filter)
|
|
{
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
parts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
|
|
if (!string.IsNullOrEmpty(filter.Status))
|
|
parts.Add($"Status eq '{ODataSanitizer.EscapeValue(filter.Status)}'");
|
|
if (!string.IsNullOrEmpty(filter.Manufacturer))
|
|
parts.Add($"Manufacturer eq '{ODataSanitizer.EscapeValue(filter.Manufacturer)}'");
|
|
if (!string.IsNullOrEmpty(filter.Model))
|
|
parts.Add($"Model eq '{ODataSanitizer.EscapeValue(filter.Model)}'");
|
|
if (!string.IsNullOrEmpty(filter.EnrollmentType))
|
|
parts.Add($"EnrollmentType eq '{ODataSanitizer.EscapeValue(filter.EnrollmentType)}'");
|
|
if (!string.IsNullOrEmpty(filter.DeviceName))
|
|
{
|
|
var safeName = ODataSanitizer.EscapeValue(filter.DeviceName);
|
|
parts.Add($"DeviceName ge '{safeName}' and DeviceName lt '{safeName}~'");
|
|
}
|
|
if (!string.IsNullOrEmpty(filter.OsBuild))
|
|
{
|
|
var safeBuild = ODataSanitizer.EscapeValue(filter.OsBuild);
|
|
parts.Add($"OsBuild ge '{safeBuild}' and OsBuild lt '{safeBuild}~'");
|
|
}
|
|
// AgentVersion: exact match wins over prefix. Both push to OData so a
|
|
// sparse subset (e.g. "all V2 enrollments" — typically <1% of sessions)
|
|
// doesn't burn N round-trips of empty pages while the index walks past
|
|
// the dense majority. Prefix uses the same `ge/lt {value}~` range
|
|
// trick that DeviceName / OsBuild already use — '~' (0x7E) sorts after
|
|
// every printable ASCII, so the upper bound captures every key starting
|
|
// with the prefix.
|
|
if (!string.IsNullOrEmpty(filter.AgentVersion))
|
|
{
|
|
parts.Add($"AgentVersion eq '{ODataSanitizer.EscapeValue(filter.AgentVersion)}'");
|
|
}
|
|
else if (!string.IsNullOrEmpty(filter.AgentVersionPrefix))
|
|
{
|
|
var safe = ODataSanitizer.EscapeValue(filter.AgentVersionPrefix);
|
|
parts.Add($"AgentVersion ge '{safe}' and AgentVersion lt '{safe}~'");
|
|
}
|
|
if (!string.IsNullOrEmpty(filter.ImeAgentVersion))
|
|
{
|
|
parts.Add($"ImeAgentVersion eq '{ODataSanitizer.EscapeValue(filter.ImeAgentVersion)}'");
|
|
}
|
|
else if (!string.IsNullOrEmpty(filter.ImeAgentVersionPrefix))
|
|
{
|
|
var safe = ODataSanitizer.EscapeValue(filter.ImeAgentVersionPrefix);
|
|
parts.Add($"ImeAgentVersion ge '{safe}' and ImeAgentVersion lt '{safe}~'");
|
|
}
|
|
return parts.Count == 0 ? null : string.Join(" and ", parts);
|
|
}
|
|
|
|
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter)
|
|
{
|
|
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
|
!string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
|
|
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) return false;
|
|
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
|
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
if (filter.StartedAfter.HasValue && session.StartedAt < filter.StartedAfter.Value) return false;
|
|
if (filter.StartedBefore.HasValue && session.StartedAt > filter.StartedBefore.Value) return false;
|
|
// AgentVersion / ImeAgentVersion (exact + prefix) are pushed to OData
|
|
// in BuildSearchScanFilter — the server has already filtered them out
|
|
// before we see the page. No client-side check needed.
|
|
return true;
|
|
}
|
|
|
|
private async Task<List<SessionSummary>> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter)
|
|
{
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot);
|
|
var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'";
|
|
|
|
var sessionIds = new List<string>();
|
|
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
|
{
|
|
// Reconstruct properties from Props_* columns
|
|
var properties = ReconstructDeviceProperties(entity);
|
|
if (properties == null || properties.Count == 0) continue;
|
|
|
|
// Apply all device property filters
|
|
if (!MatchesAllDeviceFilters(properties, filter.DeviceProperties!))
|
|
continue;
|
|
|
|
sessionIds.Add(entity.RowKey); // RowKey = sessionId in DeviceSnapshot
|
|
if (sessionIds.Count >= filter.Limit * 3) break; // over-fetch to allow for missing sessions
|
|
}
|
|
|
|
if (sessionIds.Count == 0) return new List<SessionSummary>();
|
|
|
|
// Batch-get SessionSummaries from Sessions table
|
|
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
|
|
|
|
// Apply any additional basic filters
|
|
sessions = ApplyBasicFilters(sessions, filter);
|
|
|
|
return sessions.Take(filter.Limit).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconstructs a flat property dictionary from all Props_* JSON columns.
|
|
/// Keys use "eventType.propertyName" convention (e.g. "tpm_status.specVersion").
|
|
/// </summary>
|
|
private static Dictionary<string, object> ReconstructDeviceProperties(TableEntity entity)
|
|
{
|
|
var properties = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var key in entity.Keys)
|
|
{
|
|
if (!key.StartsWith("Props_", StringComparison.Ordinal)) continue;
|
|
|
|
var eventType = key.Substring(6); // strip "Props_"
|
|
var json = entity.GetString(key);
|
|
if (string.IsNullOrEmpty(json)) continue;
|
|
|
|
try
|
|
{
|
|
var data = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(
|
|
json, _jsonDeserializeOptions);
|
|
if (data == null) continue;
|
|
|
|
foreach (var (propKey, propValue) in data)
|
|
{
|
|
if (propValue != null)
|
|
properties[$"{eventType}.{propKey}"] = propValue;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Malformed JSON — skip this column
|
|
}
|
|
}
|
|
|
|
return properties;
|
|
}
|
|
|
|
private static readonly System.Text.Json.JsonSerializerOptions _jsonDeserializeOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Checks whether all filter expressions match against the property dictionary.
|
|
/// Supports: exact string match, boolean match, numeric operators (>=, <=, >, <),
|
|
/// and array substring search.
|
|
/// </summary>
|
|
private static bool MatchesAllDeviceFilters(
|
|
Dictionary<string, object> properties,
|
|
Dictionary<string, string> filters)
|
|
{
|
|
foreach (var (filterKey, filterValue) in filters)
|
|
{
|
|
if (!properties.TryGetValue(filterKey, out var actual) || actual == null)
|
|
return false;
|
|
|
|
if (!MatchesDevicePropertyFilter(actual, filterValue))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Matches a single property value against a filter expression.
|
|
/// Filter syntax:
|
|
/// ">=8" → numeric greater-than-or-equal
|
|
/// "<=5" → numeric less-than-or-equal
|
|
/// ">8" → numeric greater-than
|
|
/// "<5" → numeric less-than
|
|
/// "True"/"False" → boolean match
|
|
/// anything else → case-insensitive string equality
|
|
/// For array values: checks if filterValue appears as substring in any element.
|
|
/// </summary>
|
|
private static bool MatchesDevicePropertyFilter(object actual, string filterValue)
|
|
{
|
|
var actualStr = ConvertToString(actual);
|
|
|
|
// Handle array values: check if filter matches any element
|
|
if (actual is System.Text.Json.JsonElement je && je.ValueKind == System.Text.Json.JsonValueKind.Array)
|
|
{
|
|
foreach (var element in je.EnumerateArray())
|
|
{
|
|
var elemStr = element.ToString();
|
|
if (elemStr != null && elemStr.Contains(filterValue, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Numeric range operators
|
|
if (filterValue.StartsWith(">=") && double.TryParse(filterValue.AsSpan(2), out var geVal))
|
|
return double.TryParse(actualStr, out var av1) && av1 >= geVal;
|
|
if (filterValue.StartsWith("<=") && double.TryParse(filterValue.AsSpan(2), out var leVal))
|
|
return double.TryParse(actualStr, out var av2) && av2 <= leVal;
|
|
if (filterValue.StartsWith(">") && !filterValue.StartsWith(">=") && double.TryParse(filterValue.AsSpan(1), out var gtVal))
|
|
return double.TryParse(actualStr, out var av3) && av3 > gtVal;
|
|
if (filterValue.StartsWith("<") && !filterValue.StartsWith("<=") && double.TryParse(filterValue.AsSpan(1), out var ltVal))
|
|
return double.TryParse(actualStr, out var av4) && av4 < ltVal;
|
|
|
|
// Default: case-insensitive string equality (covers booleans too)
|
|
return string.Equals(actualStr, filterValue, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string ConvertToString(object value)
|
|
{
|
|
if (value is System.Text.Json.JsonElement jsonEl)
|
|
{
|
|
return jsonEl.ValueKind switch
|
|
{
|
|
System.Text.Json.JsonValueKind.String => jsonEl.GetString() ?? "",
|
|
System.Text.Json.JsonValueKind.True => "True",
|
|
System.Text.Json.JsonValueKind.False => "False",
|
|
System.Text.Json.JsonValueKind.Number => jsonEl.GetRawText(),
|
|
_ => jsonEl.ToString()
|
|
};
|
|
}
|
|
return value?.ToString() ?? "";
|
|
}
|
|
|
|
private async Task<List<SessionSummary>> SearchSessionsByScanAsync(string? tenantId, SessionSearchFilter filter)
|
|
{
|
|
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
|
|
|
var filterParts = new List<string>();
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
filterParts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'");
|
|
if (!string.IsNullOrEmpty(filter.Status))
|
|
filterParts.Add($"Status eq '{ODataSanitizer.EscapeValue(filter.Status)}'");
|
|
if (!string.IsNullOrEmpty(filter.Manufacturer))
|
|
filterParts.Add($"Manufacturer eq '{ODataSanitizer.EscapeValue(filter.Manufacturer)}'");
|
|
if (!string.IsNullOrEmpty(filter.Model))
|
|
filterParts.Add($"Model eq '{ODataSanitizer.EscapeValue(filter.Model)}'");
|
|
if (!string.IsNullOrEmpty(filter.EnrollmentType))
|
|
filterParts.Add($"EnrollmentType eq '{ODataSanitizer.EscapeValue(filter.EnrollmentType)}'");
|
|
if (!string.IsNullOrEmpty(filter.DeviceName))
|
|
{
|
|
var safeName = ODataSanitizer.EscapeValue(filter.DeviceName);
|
|
filterParts.Add($"DeviceName ge '{safeName}' and DeviceName lt '{safeName}~'");
|
|
}
|
|
if (!string.IsNullOrEmpty(filter.OsBuild))
|
|
{
|
|
var safeBuild = ODataSanitizer.EscapeValue(filter.OsBuild);
|
|
filterParts.Add($"OsBuild ge '{safeBuild}' and OsBuild lt '{safeBuild}~'");
|
|
}
|
|
|
|
var oDataFilter = filterParts.Count > 0 ? string.Join(" and ", filterParts) : null;
|
|
|
|
var sessions = new List<SessionSummary>();
|
|
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
|
{
|
|
var session = MapIndexEntityToSessionSummary(entity);
|
|
|
|
// Client-side filters
|
|
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
|
!string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) continue;
|
|
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) continue;
|
|
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
|
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (filter.StartedAfter.HasValue && session.StartedAt < filter.StartedAfter.Value) continue;
|
|
if (filter.StartedBefore.HasValue && session.StartedAt > filter.StartedBefore.Value) continue;
|
|
if (!string.IsNullOrEmpty(filter.AgentVersion) &&
|
|
!string.Equals(session.AgentVersion, filter.AgentVersion, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (!string.IsNullOrEmpty(filter.ImeAgentVersion) &&
|
|
!string.Equals(session.ImeAgentVersion, filter.ImeAgentVersion, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
if (string.IsNullOrEmpty(filter.AgentVersion)
|
|
&& !string.IsNullOrEmpty(filter.AgentVersionPrefix)
|
|
&& (session.AgentVersion == null
|
|
|| !session.AgentVersion.StartsWith(filter.AgentVersionPrefix!, StringComparison.OrdinalIgnoreCase)))
|
|
continue;
|
|
if (string.IsNullOrEmpty(filter.ImeAgentVersion)
|
|
&& !string.IsNullOrEmpty(filter.ImeAgentVersionPrefix)
|
|
&& (session.ImeAgentVersion == null
|
|
|| !session.ImeAgentVersion.StartsWith(filter.ImeAgentVersionPrefix!, StringComparison.OrdinalIgnoreCase)))
|
|
continue;
|
|
|
|
sessions.Add(session);
|
|
if (sessions.Count >= filter.Limit) break;
|
|
}
|
|
|
|
return sessions;
|
|
}
|
|
|
|
private List<SessionSummary> ApplyBasicFilters(List<SessionSummary> sessions, SessionSearchFilter filter)
|
|
{
|
|
return sessions.Where(s =>
|
|
{
|
|
if (!string.IsNullOrEmpty(filter.Status) && s.Status.ToString() != filter.Status) return false;
|
|
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
|
!string.Equals(s.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase)) return false;
|
|
if (filter.IsPreProvisioned.HasValue && s.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
|
|
if (filter.IsHybridJoin.HasValue && s.IsHybridJoin != filter.IsHybridJoin.Value) return false;
|
|
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
|
!string.Equals(s.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) return false;
|
|
if (filter.StartedAfter.HasValue && s.StartedAt < filter.StartedAfter.Value) return false;
|
|
if (filter.StartedBefore.HasValue && s.StartedAt > filter.StartedBefore.Value) return false;
|
|
if (!string.IsNullOrEmpty(filter.AgentVersion) &&
|
|
!string.Equals(s.AgentVersion, filter.AgentVersion, StringComparison.OrdinalIgnoreCase)) return false;
|
|
if (!string.IsNullOrEmpty(filter.ImeAgentVersion) &&
|
|
!string.Equals(s.ImeAgentVersion, filter.ImeAgentVersion, StringComparison.OrdinalIgnoreCase)) return false;
|
|
if (string.IsNullOrEmpty(filter.AgentVersion)
|
|
&& !string.IsNullOrEmpty(filter.AgentVersionPrefix)
|
|
&& (s.AgentVersion == null
|
|
|| !s.AgentVersion.StartsWith(filter.AgentVersionPrefix!, StringComparison.OrdinalIgnoreCase)))
|
|
return false;
|
|
if (string.IsNullOrEmpty(filter.ImeAgentVersion)
|
|
&& !string.IsNullOrEmpty(filter.ImeAgentVersionPrefix)
|
|
&& (s.ImeAgentVersion == null
|
|
|| !s.ImeAgentVersion.StartsWith(filter.ImeAgentVersionPrefix!, StringComparison.OrdinalIgnoreCase)))
|
|
return false;
|
|
return true;
|
|
}).ToList();
|
|
}
|
|
|
|
private async Task<List<SessionSummary>> BatchGetSessionsAsync(string? tenantId, List<string> sessionIds)
|
|
{
|
|
var sessionsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
|
var semaphore = new System.Threading.SemaphoreSlim(20, 20);
|
|
|
|
var tasks = sessionIds.Select(async sessionId =>
|
|
{
|
|
await semaphore.WaitAsync();
|
|
try
|
|
{
|
|
// Try to determine the tenantId for this session
|
|
var partitionKey = tenantId ?? string.Empty;
|
|
if (string.IsNullOrEmpty(partitionKey))
|
|
{
|
|
// Cross-tenant: scan SessionsIndex for this sessionId
|
|
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
|
await foreach (var idxEntity in indexTableClient.QueryAsync<TableEntity>(
|
|
filter: $"SessionId eq '{sessionId}'",
|
|
maxPerPage: 1))
|
|
{
|
|
return MapIndexEntityToSessionSummary(idxEntity);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
var response = await sessionsTableClient.GetEntityAsync<TableEntity>(partitionKey, sessionId);
|
|
return MapToSessionSummary(response.Value);
|
|
}
|
|
catch (RequestFailedException ex) when (ex.Status == 404)
|
|
{
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to get session {SessionId}", sessionId);
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
});
|
|
|
|
var results = await Task.WhenAll(tasks);
|
|
return results.Where(s => s != null).Select(s => s!).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Paged EventTypeIndex walk that drives
|
|
/// <c>QueryRawEventsFunction</c>'s cross-session walk. Returns up to
|
|
/// <paramref name="pageSize"/> distinct sessions plus the underlying
|
|
/// Azure-Tables continuation; caller follows the wire <c>nextLink</c>
|
|
/// to drain the full recall set.
|
|
/// </summary>
|
|
public async Task<RawPage<SessionSummary>> SearchSessionsByEventPageAsync(
|
|
string? tenantId, string eventType, string? source, string? severity, string? phase,
|
|
int pageSize, string? continuation)
|
|
{
|
|
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
|
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.EventTypeIndex);
|
|
|
|
// Build OData filter (PartitionKey eq for tenant-scoped, EventType eq
|
|
// for cross-tenant) — no limit*2 over-fetch; pagination handles depth.
|
|
int? minSeverity = null;
|
|
if (!string.IsNullOrEmpty(severity) &&
|
|
Enum.TryParse<AutopilotMonitor.Shared.Models.EventSeverity>(severity, ignoreCase: true, out var parsedSeverity))
|
|
{
|
|
minSeverity = (int)parsedSeverity;
|
|
}
|
|
|
|
// Tenant-scoped: partition-targeted query (PK exact match).
|
|
// Cross-tenant: server-side filter on the EventType column. The
|
|
// PartitionKey shape is `{tenantId}_{eventType}` and tenantIds may
|
|
// contain dashes but never underscores, while eventType itself may
|
|
// contain underscores (e.g. `app_install_failed`) — so the previous
|
|
// PK-suffix split via LastIndexOf('_') silently filtered out every
|
|
// multi-underscore event type. Filtering on the EventType column
|
|
// avoids that ambiguity entirely.
|
|
string? oDataFilter;
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
{
|
|
oDataFilter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}_{ODataSanitizer.EscapeValue(eventType)}'";
|
|
if (minSeverity.HasValue)
|
|
oDataFilter += $" and MaxSeverity ge {minSeverity.Value}";
|
|
}
|
|
else
|
|
{
|
|
oDataFilter = $"EventType eq '{ODataSanitizer.EscapeValue(eventType)}'";
|
|
if (minSeverity.HasValue)
|
|
oDataFilter += $" and MaxSeverity ge {minSeverity.Value}";
|
|
}
|
|
|
|
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
|
client: tableClient,
|
|
filter: oDataFilter,
|
|
pageSize: pageSize,
|
|
continuation: continuation);
|
|
|
|
var sessionIds = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var entity in entities)
|
|
{
|
|
|
|
if (!string.IsNullOrEmpty(source))
|
|
{
|
|
var sources = entity.GetString("Sources") ?? string.Empty;
|
|
if (!sources.Contains(source, StringComparison.OrdinalIgnoreCase)) continue;
|
|
}
|
|
|
|
var sessionId = entity.GetString("SessionId");
|
|
if (!string.IsNullOrEmpty(sessionId) && seen.Add(sessionId))
|
|
{
|
|
sessionIds.Add(sessionId);
|
|
}
|
|
}
|
|
|
|
if (sessionIds.Count == 0)
|
|
{
|
|
return new RawPage<SessionSummary>(Array.Empty<SessionSummary>(), nextRawToken);
|
|
}
|
|
|
|
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
|
|
return new RawPage<SessionSummary>(sessions, nextRawToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Searches sessions affected by a specific CVE using the CveIndex.
|
|
/// </summary>
|
|
public async Task<List<SessionSummary>> SearchSessionsByCveAsync(
|
|
string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50)
|
|
{
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.CveIndex);
|
|
|
|
string oDataFilter;
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
oDataFilter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}_{ODataSanitizer.EscapeValue(cveId)}'";
|
|
else
|
|
{
|
|
var safeCveId = ODataSanitizer.EscapeValue(cveId);
|
|
oDataFilter = $"PartitionKey ge '{safeCveId}' and PartitionKey lt '{safeCveId}~'";
|
|
}
|
|
|
|
var sessionIds = new List<string>();
|
|
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
|
{
|
|
if (minCvssScore.HasValue)
|
|
{
|
|
var score = entity.GetDouble("CvssScore");
|
|
if (score == null || score < minCvssScore.Value) continue;
|
|
}
|
|
if (!string.IsNullOrEmpty(overallRisk))
|
|
{
|
|
var risk = entity.GetString("OverallRisk");
|
|
if (!string.Equals(risk, overallRisk, StringComparison.OrdinalIgnoreCase)) continue;
|
|
}
|
|
|
|
var sessionId = entity.GetString("SessionId") ?? entity.RowKey;
|
|
if (!string.IsNullOrEmpty(sessionId) && !sessionIds.Contains(sessionId))
|
|
sessionIds.Add(sessionId);
|
|
|
|
if (sessionIds.Count >= limit * 2) break;
|
|
}
|
|
|
|
if (sessionIds.Count == 0) return new List<SessionSummary>();
|
|
|
|
// Extract tenantId from PartitionKey for cross-tenant lookup if needed
|
|
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
|
|
return sessions.Take(limit).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Paged variant of <see cref="SearchSessionsByCveAsync"/>. Walks the
|
|
/// CveIndex partition page-by-page so callers can drain the full set of
|
|
/// devices affected by a CVE — the legacy unpaged variant capped at
|
|
/// limit*2 candidate sessions and silently dropped the rest, which made
|
|
/// "how many of my devices are exposed to CVE-X" impossible to answer
|
|
/// truthfully on large tenants.
|
|
/// </summary>
|
|
public async Task<RawPage<SessionSummary>> SearchSessionsByCvePageAsync(
|
|
string? tenantId, string cveId, double? minCvssScore, string? overallRisk,
|
|
int pageSize, string? continuation)
|
|
{
|
|
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
|
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.CveIndex);
|
|
|
|
var oDataFilter = BuildCveIndexSearchFilter(tenantId, cveId);
|
|
|
|
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
|
client: tableClient,
|
|
filter: oDataFilter,
|
|
pageSize: pageSize,
|
|
continuation: continuation);
|
|
|
|
var sessionIds = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var entity in entities)
|
|
{
|
|
if (minCvssScore.HasValue)
|
|
{
|
|
var score = entity.GetDouble("CvssScore");
|
|
if (score == null || score < minCvssScore.Value) continue;
|
|
}
|
|
if (!string.IsNullOrEmpty(overallRisk))
|
|
{
|
|
var risk = entity.GetString("OverallRisk");
|
|
if (!string.Equals(risk, overallRisk, StringComparison.OrdinalIgnoreCase)) continue;
|
|
}
|
|
|
|
var sessionId = entity.GetString("SessionId") ?? entity.RowKey;
|
|
if (!string.IsNullOrEmpty(sessionId) && seen.Add(sessionId))
|
|
{
|
|
sessionIds.Add(sessionId);
|
|
}
|
|
}
|
|
|
|
if (sessionIds.Count == 0)
|
|
return new RawPage<SessionSummary>(Array.Empty<SessionSummary>(), nextRawToken);
|
|
|
|
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
|
|
return new RawPage<SessionSummary>(sessions, nextRawToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the CveIndex OData filter for the per-CVE session search.
|
|
/// CveIndex PartitionKey is <c>{tenantId}_{cveId}</c>.
|
|
/// <list type="bullet">
|
|
/// <item>Tenant-scoped: exact PK match — partition-targeted, cheap.</item>
|
|
/// <item>Cross-tenant (tenantId null): the PK begins with the tenant GUID,
|
|
/// NOT the cveId, so a PK range on the cveId matches nothing. We filter on
|
|
/// the <c>CveId</c> property instead — a server-side scan across partitions,
|
|
/// the same shape <see cref="ScanCveIndexAsync"/> uses for fleet aggregation.</item>
|
|
/// </list>
|
|
/// Exposed internal for regression testing (the old PK-range form silently
|
|
/// returned 0 cross-tenant results — see SearchSessionsByCveFilterTests).
|
|
/// </summary>
|
|
internal static string BuildCveIndexSearchFilter(string? tenantId, string cveId)
|
|
{
|
|
var safeCveId = ODataSanitizer.EscapeValue(cveId);
|
|
return string.IsNullOrEmpty(tenantId)
|
|
? $"CveId eq '{safeCveId}'"
|
|
: $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}_{safeCveId}'";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scans the CveIndex for fleet-wide vulnerability aggregation. Tenant-scoped
|
|
/// uses a partition-prefix range (`{tenantId}_` .. `{tenantId}_~`) so Azure
|
|
/// targets only that tenant's CVE partitions; cross-tenant (tenantId null) is
|
|
/// a bounded full-table scan — each row carries TenantId + CveId, so the
|
|
/// aggregate is correct without the loose suffix-range the per-CVE search
|
|
/// has to use. Reads at most <paramref name="maxRows"/> rows and reports
|
|
/// whether the cap was hit so the caller can flag partial results.
|
|
/// </summary>
|
|
public async Task<(IReadOnlyList<CveExposureEntry> Rows, bool Truncated)> ScanCveIndexAsync(
|
|
string? tenantId, int maxRows, CancellationToken ct = default)
|
|
{
|
|
if (maxRows < 1) maxRows = 1;
|
|
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.CveIndex);
|
|
|
|
string? filter = null;
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
{
|
|
var safe = ODataSanitizer.EscapeValue(tenantId);
|
|
filter = $"PartitionKey ge '{safe}_' and PartitionKey lt '{safe}_~'";
|
|
}
|
|
|
|
var select = new[]
|
|
{
|
|
"SessionId", "TenantId", "CveId", "SoftwareName",
|
|
"CvssScore", "CvssSeverity", "IsKev", "OverallRisk", "DetectedAt",
|
|
};
|
|
|
|
var rows = new List<CveExposureEntry>(Math.Min(maxRows, 2048));
|
|
var truncated = false;
|
|
await foreach (var e in tableClient.QueryAsync<TableEntity>(
|
|
filter: filter, maxPerPage: 1000, select: select, cancellationToken: ct))
|
|
{
|
|
if (rows.Count >= maxRows) { truncated = true; break; }
|
|
|
|
// TenantId column is always written, but fall back to the partition
|
|
// key prefix (`{tenantId}_{cveId}`) for any legacy row missing it.
|
|
var tid = e.GetString("TenantId");
|
|
if (string.IsNullOrEmpty(tid))
|
|
{
|
|
var us = e.PartitionKey.IndexOf('_');
|
|
tid = us > 0 ? e.PartitionKey.Substring(0, us) : string.Empty;
|
|
}
|
|
|
|
rows.Add(new CveExposureEntry
|
|
{
|
|
TenantId = tid ?? string.Empty,
|
|
CveId = e.GetString("CveId") ?? string.Empty,
|
|
SessionId = e.GetString("SessionId") ?? e.RowKey,
|
|
SoftwareName = e.GetString("SoftwareName") ?? string.Empty,
|
|
CvssScore = e.GetDouble("CvssScore") ?? 0,
|
|
CvssSeverity = e.GetString("CvssSeverity") ?? string.Empty,
|
|
IsKev = e.GetBoolean("IsKev") ?? false,
|
|
OverallRisk = e.GetString("OverallRisk") ?? string.Empty,
|
|
DetectedAt = e.GetDateTime("DetectedAt"),
|
|
});
|
|
}
|
|
|
|
return (rows, truncated);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns aggregated session metrics grouped by tenant, filtered to the last <paramref name="days"/> days.
|
|
/// Leverages the SessionsIndex inverted-tick RowKey ordering for an efficient server-side range scan.
|
|
/// </summary>
|
|
public async Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
|
{
|
|
// Clamp to a sane range so callers can't accidentally trigger an unbounded scan.
|
|
if (days < 1) days = 1;
|
|
if (days > 365) days = 365;
|
|
|
|
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
|
var cutoffRowKeyPrefix = ComputeCutoffRowKeyPrefix(days);
|
|
|
|
var filterParts = new List<string> { $"RowKey lt '{cutoffRowKeyPrefix}'" };
|
|
if (!string.IsNullOrEmpty(tenantId))
|
|
filterParts.Add($"PartitionKey eq '{tenantId}'");
|
|
var oDataFilter = string.Join(" and ", filterParts);
|
|
|
|
var groups = new Dictionary<string, (int total, int succeeded, int failed, int inProgress)>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
|
{
|
|
var pk = entity.PartitionKey;
|
|
var statusStr = entity.GetString("Status") ?? "InProgress";
|
|
|
|
if (!groups.TryGetValue(pk, out var g))
|
|
g = (0, 0, 0, 0);
|
|
|
|
var total = g.total + 1;
|
|
var succeeded = g.succeeded + (statusStr == "Succeeded" ? 1 : 0);
|
|
var failed = g.failed + (statusStr == "Failed" ? 1 : 0);
|
|
var inProg = g.inProgress + (statusStr == "InProgress" ? 1 : 0);
|
|
groups[pk] = (total, succeeded, failed, inProg);
|
|
}
|
|
|
|
return groups.Select(kvp => (object)new
|
|
{
|
|
tenantId = kvp.Key,
|
|
totalSessions = kvp.Value.total,
|
|
succeeded = kvp.Value.succeeded,
|
|
failed = kvp.Value.failed,
|
|
inProgress = kvp.Value.inProgress,
|
|
failureRate = kvp.Value.total > 0
|
|
? Math.Round((double)kvp.Value.failed / kvp.Value.total * 100, 1)
|
|
: 0.0,
|
|
windowDays = days
|
|
}).ToList();
|
|
}
|
|
}
|
|
}
|