diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportZipEntryNameTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportZipEntryNameTests.cs new file mode 100644 index 00000000..2c9da039 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportZipEntryNameTests.cs @@ -0,0 +1,39 @@ +using AutopilotMonitor.Functions.Services; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Customer-supplied attachment file names go verbatim into ZIP entry names of the +/// session-report archive. The sanitizer must strip path components so a crafted name +/// cannot produce traversal-shaped entries that an extraction tool would write outside +/// the target directory. +/// +public class SessionReportZipEntryNameTests +{ + [Theory] + [InlineData("agent.log", "agent.log")] + [InlineData("agent_20260729.log", "agent_20260729.log")] + [InlineData("logs/agent.log", "agent.log")] + [InlineData("..\\..\\evil.exe", "evil.exe")] + [InlineData("../../../etc/passwd", "passwd")] + [InlineData("C:\\Windows\\System32\\bad.dll", "bad.dll")] + [InlineData(" spaced.log ", "spaced.log")] + public void SanitizeZipEntryName_StripsPathComponents(string input, string expected) + { + Assert.Equal(expected, SessionReportService.SanitizeZipEntryName(input, "fallback.log")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("..")] + [InlineData(".")] + [InlineData("logs/")] + [InlineData("a/../")] + public void SanitizeZipEntryName_FallsBackWhenNothingUsable(string? input) + { + Assert.Equal("fallback.log", SessionReportService.SanitizeZipEntryName(input, "fallback.log")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index ecf1c138..56a93463 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Azure.Storage.Blobs.Models; @@ -26,6 +27,8 @@ namespace AutopilotMonitor.Functions.Services private readonly BlobStorageService _blobStorage; private readonly TableStorageService _tableStorage; private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier; + private readonly TenantConfigurationService _configService; + private readonly IRuleRepository _ruleRepo; private const string ContainerName = "session-reports"; public SessionReportService( @@ -33,6 +36,8 @@ namespace AutopilotMonitor.Functions.Services BlobStorageService blobStorage, TableStorageService tableStorage, SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier, + TenantConfigurationService configService, + IRuleRepository ruleRepo, ILogger logger) { _notificationRepo = notificationRepo; @@ -40,6 +45,8 @@ namespace AutopilotMonitor.Functions.Services _blobStorage = blobStorage; _tableStorage = tableStorage; _diagnosticsArchiveCopier = diagnosticsArchiveCopier; + _configService = configService; + _ruleRepo = ruleRepo; } /// @@ -58,8 +65,12 @@ namespace AutopilotMonitor.Functions.Services // Session diag blobs die with the session (user delete / retention); the copy in // the session-reports container does not. Fail-soft: a failed copy never blocks // the report — the status lands on the row + in report-metadata.json instead. + // The source blob name/destination is recorded too: when the copy failed, the + // operator can still fetch the original while the session is alive. string? copiedDiagnosticsBlobName = null; string? diagnosticsCopyStatus = null; + string? sourceDiagnosticsBlobName = null; + string? sourceDiagnosticsDestination = null; if (request.IncludeDiagnostics) { var session = await _tableStorage.GetSessionAsync(request.TenantId, request.SessionId); @@ -69,6 +80,8 @@ namespace AutopilotMonitor.Functions.Services } else { + sourceDiagnosticsBlobName = session.DiagnosticsBlobName; + sourceDiagnosticsDestination = session.DiagnosticsBlobDestination; var destinationName = $"{request.TenantId}_{request.SessionId}_diag_archive_{timestamp}.zip"; var copyResult = await _diagnosticsArchiveCopier.CopyAsync( request.TenantId, request.SessionId, session.DiagnosticsBlobName, destinationName); @@ -78,6 +91,15 @@ namespace AutopilotMonitor.Functions.Services } } + // Tenant-side detection context (custom rule/pattern state at report time — it may + // have changed by the time an operator investigates). Fail-soft; never blocks. + var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId); + + // Decode attachments up front so report-metadata.json can record their outcome + // instead of silently dropping invalid payloads. + var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot"); + var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "agent log"); + // 1. Create ZIP in memory using var zipStream = new MemoryStream(); using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) @@ -116,47 +138,45 @@ namespace AutopilotMonitor.Functions.Services request.Email, submittedBy, submittedAt = DateTime.UtcNow.ToString("O"), + export = new + { + exportedEventCount = request.ExportedEventCount, + sessionEventCount = request.SessionEventCount, + eventStreamActive = request.EventStreamActive + }, includedDiagnostics = new { requested = request.IncludeDiagnostics, blobName = copiedDiagnosticsBlobName, - status = diagnosticsCopyStatus - } + status = diagnosticsCopyStatus, + sourceBlobName = sourceDiagnosticsBlobName, + sourceDestination = sourceDiagnosticsDestination + }, + attachments = new + { + screenshot = screenshotStatus, + agentLog = agentLogStatus + }, + tenantContext }); // Optional screenshot - if (!string.IsNullOrEmpty(request.ScreenshotBase64)) + if (screenshotBytes != null) { - try - { - var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64); - var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png"); - if (string.IsNullOrEmpty(ext)) ext = ".png"; - var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(screenshotBytes); - } - catch (FormatException ex) - { - _logger.LogWarning(ex, "Invalid base64 screenshot data in report {ReportId}", reportId); - } + var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png"); + if (string.IsNullOrEmpty(ext)) ext = ".png"; + var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(screenshotBytes); } // Optional agent log file (max 5 MB enforced by frontend) - if (!string.IsNullOrEmpty(request.AgentLogBase64)) + if (agentLogBytes != null) { - try - { - var logBytes = Convert.FromBase64String(request.AgentLogBase64); - var logFileName = request.AgentLogFileName ?? "agent.log"; - var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(logBytes); - } - catch (FormatException ex) - { - _logger.LogWarning(ex, "Invalid base64 agent log data in report {ReportId}", reportId); - } + var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "agent.log"); + var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(agentLogBytes); } } @@ -210,6 +230,9 @@ namespace AutopilotMonitor.Functions.Services var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip"; + var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot"); + var (agentLogBytes, agentLogStatus) = DecodeAttachment(request.AgentLogBase64, reportId, "log payload"); + using var zipStream = new MemoryStream(); using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) { @@ -221,42 +244,29 @@ namespace AutopilotMonitor.Functions.Services request.Comment, request.Email, submittedBy, - submittedAt = DateTime.UtcNow.ToString("O") + submittedAt = DateTime.UtcNow.ToString("O"), + attachments = new + { + screenshot = screenshotStatus, + agentLog = agentLogStatus + } }); - if (!string.IsNullOrEmpty(request.ScreenshotBase64)) + if (screenshotBytes != null) { - try - { - var screenshotBytes = Convert.FromBase64String(request.ScreenshotBase64); - var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png"); - if (string.IsNullOrEmpty(ext)) ext = ".png"; - var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(screenshotBytes); - } - catch (FormatException ex) - { - _logger.LogWarning(ex, "Invalid base64 screenshot data in diag-files report {ReportId}", reportId); - } + var ext = Path.GetExtension(request.ScreenshotFileName ?? ".png"); + if (string.IsNullOrEmpty(ext)) ext = ".png"; + var entry = archive.CreateEntry($"screenshot{ext}", CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(screenshotBytes); } - if (!string.IsNullOrEmpty(request.AgentLogBase64)) + if (agentLogBytes != null) { - try - { - var logBytes = Convert.FromBase64String(request.AgentLogBase64); - var logFileName = string.IsNullOrEmpty(request.AgentLogFileName) - ? "diag-files.bin" - : request.AgentLogFileName; - var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal); - using var entryStream = entry.Open(); - await entryStream.WriteAsync(logBytes); - } - catch (FormatException ex) - { - _logger.LogWarning(ex, "Invalid base64 log payload in diag-files report {ReportId}", reportId); - } + var logFileName = SanitizeZipEntryName(request.AgentLogFileName, "diag-files.bin"); + var entry = archive.CreateEntry(logFileName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + await entryStream.WriteAsync(agentLogBytes); } } @@ -315,6 +325,81 @@ namespace AutopilotMonitor.Functions.Services return await _notificationRepo.UpdateSessionReportAdminNoteAsync(reportId, adminNote); } + /// + /// Snapshot of the tenant-side detection state relevant to a session report: upload + /// destination flags and the IDs of custom rules/patterns active at report time. + /// Deliberately excludes anything secret-bearing (SAS URLs, tokens) and full rule + /// definitions — IDs + timestamps are enough to correlate with the (persisted) rule + /// tables even after the customer edits them. Fail-soft: null on any error. + /// + private async Task CollectTenantContextAsync(string tenantId, string reportId) + { + try + { + var config = await _configService.GetConfigurationAsync(tenantId); + var gatherRules = await _ruleRepo.GetGatherRulesAsync(tenantId); + var analyzeRules = await _ruleRepo.GetAnalyzeRulesAsync(tenantId); + var imePatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId); + + return new + { + diagnosticsUploadMode = config.DiagnosticsUploadMode, + diagnosticsUploadDestination = config.DiagnosticsUploadDestination, + gatherRuleDebugLogEnabled = config.EnableGatherRuleDebugLog, + customGatherRules = gatherRules + .Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(), + customAnalyzeRules = analyzeRules + .Select(r => new { ruleId = r.RuleId, updatedAt = r.UpdatedAt }).ToList(), + customImePatternIds = imePatterns.Select(p => p.PatternId).ToList() + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Report {ReportId}: tenant-context snapshot failed — omitted from metadata", reportId); + return null; + } + } + + /// + /// Decodes an optional base64 attachment. Returns the bytes plus a status string + /// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json — + /// an invalid payload must be visible to the operator, not silently dropped. + /// + private (byte[]? Bytes, string Status) DecodeAttachment(string? base64, string reportId, string label) + { + if (string.IsNullOrEmpty(base64)) + return (null, "none"); + try + { + return (Convert.FromBase64String(base64), "included"); + } + catch (FormatException ex) + { + _logger.LogWarning(ex, "Invalid base64 {Label} data in report {ReportId}", label, reportId); + return (null, "invalid-base64"); + } + } + + /// + /// Sanitizes a caller-supplied file name for use as a ZIP entry: strips any path + /// components (no traversal-shaped entries inside the archive) and falls back to + /// when nothing usable remains. + /// + internal static string SanitizeZipEntryName(string? fileName, string fallback) + { + if (string.IsNullOrWhiteSpace(fileName)) + return fallback; + // Normalize both separator styles, then keep only the final segment. + var candidate = fileName.Replace('\\', '/'); + var lastSlash = candidate.LastIndexOf('/'); + if (lastSlash >= 0) + candidate = candidate[(lastSlash + 1)..]; + candidate = candidate.Trim(); + if (string.IsNullOrEmpty(candidate) || candidate == "." || candidate == "..") + return fallback; + return candidate; + } + private static void AddJsonEntry(ZipArchive archive, string name, object data) { var entry = archive.CreateEntry(name, CompressionLevel.Optimal); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs index d28e8cbf..3873e823 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ReportApiModels.cs @@ -57,6 +57,16 @@ namespace AutopilotMonitor.Shared.Models /// session deletion and retention cleanup. /// public bool IncludeDiagnostics { get; set; } + + /// Number of events the client had loaded when it generated the exports. + public int? ExportedEventCount { get; set; } + + /// The session's EventCount as the client saw it — compared against + /// this flags a partial export. + public int? SessionEventCount { get; set; } + + /// True when the client was still streaming event pages at submit time. + public bool? EventStreamActive { get; set; } } /// diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/ReportSessionModal.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/ReportSessionModal.tsx index 11f43ed2..223a2874 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/ReportSessionModal.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/ReportSessionModal.tsx @@ -4,6 +4,10 @@ import { useState, useEffect, useRef } from "react"; import { Session, EnrollmentEvent, RuleResult } from "@/types"; const MAX_AGENT_LOG_SIZE = 5 * 1024 * 1024; // 5 MB +// Backend caps the whole request body at 20 MB. Base64 adds ~33%, and the body also +// carries agent logs (≤5 MB raw) plus CSV/TXT exports — 8 MB raw screenshots keeps +// the worst case comfortably under the cap. +const MAX_SCREENSHOT_SIZE = 8 * 1024 * 1024; // 8 MB interface ReportSessionModalProps { show: boolean; @@ -18,6 +22,8 @@ interface ReportSessionModalProps { ) => Promise; onCancel: () => void; submitting: boolean; + /** True while the timeline is still streaming event pages — the export would be partial. */ + eventsStreaming?: boolean; } function formatFileSize(bytes: number): string { @@ -27,11 +33,12 @@ function formatFileSize(bytes: number): string { } export default function ReportSessionModal({ - show, session, events, analysisResults, onSubmit, onCancel, submitting + show, session, events, analysisResults, onSubmit, onCancel, submitting, eventsStreaming }: ReportSessionModalProps) { const [comment, setComment] = useState(""); const [email, setEmail] = useState(""); const [screenshotFiles, setScreenshotFiles] = useState([]); + const [screenshotError, setScreenshotError] = useState(null); const [agentLogFiles, setAgentLogFiles] = useState([]); const [agentLogError, setAgentLogError] = useState(null); const [includeDiagnostics, setIncludeDiagnostics] = useState(true); @@ -77,17 +84,25 @@ export default function ReportSessionModal({ }; const addScreenshots = (newFiles: File[]) => { + setScreenshotError(null); const merged = [...screenshotFiles]; for (const f of newFiles) { if (!merged.some(existing => existing.name === f.name && existing.size === f.size)) { merged.push(f); } } + const totalSize = merged.reduce((sum, f) => sum + f.size, 0); + if (totalSize > MAX_SCREENSHOT_SIZE) { + setScreenshotError(`Total size (${formatFileSize(totalSize)}) exceeds 8 MB limit. Remove some files or add smaller ones.`); + // Keep existing files, don't add the new ones + return; + } setScreenshotFiles(merged); if (screenshotInputRef.current) screenshotInputRef.current.value = ""; }; const removeScreenshot = (index: number) => { + setScreenshotError(null); setScreenshotFiles(prev => prev.filter((_, i) => i !== index)); }; @@ -148,6 +163,7 @@ export default function ReportSessionModal({ setComment(""); setEmail(""); setScreenshotFiles([]); + setScreenshotError(null); setAgentLogFiles([]); setAgentLogError(null); } catch (err: unknown) { @@ -304,7 +320,7 @@ export default function ReportSessionModal({ {/* Screenshots */}
+ {screenshotError && ( +

{screenshotError}

+ )} {screenshotFiles.length > 0 && (
{screenshotFiles.map((file, i) => ( @@ -374,6 +393,12 @@ export default function ReportSessionModal({ {screenshotFiles.length > 1 &&
  • {screenshotFiles.length} screenshots (will be zipped)
  • } {hasDiagnostics && includeDiagnostics &&
  • Uploaded diagnostics archive (copied server-side)
  • } + {eventsStreaming && ( +

    + ⚠ The event timeline is still loading — a report submitted now will contain a + partial export. Wait a moment for the complete timeline if possible. +

    + )}
    )} @@ -398,7 +423,7 @@ export default function ReportSessionModal({