feat: richer, self-describing session report content

The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-29 14:13:33 +02:00
parent 48308df4ff
commit 89c5c7da03
6 changed files with 262 additions and 65 deletions
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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"));
}
}
@@ -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<SessionReportService> logger)
{
_notificationRepo = notificationRepo;
@@ -40,6 +45,8 @@ namespace AutopilotMonitor.Functions.Services
_blobStorage = blobStorage;
_tableStorage = tableStorage;
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
_configService = configService;
_ruleRepo = ruleRepo;
}
/// <summary>
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<object?> 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;
}
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
/// <summary>
/// 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
/// <paramref name="fallback"/> when nothing usable remains.
/// </summary>
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);
@@ -57,6 +57,16 @@ namespace AutopilotMonitor.Shared.Models
/// session deletion and retention cleanup.
/// </summary>
public bool IncludeDiagnostics { get; set; }
/// <summary>Number of events the client had loaded when it generated the exports.</summary>
public int? ExportedEventCount { get; set; }
/// <summary>The session's EventCount as the client saw it — compared against
/// <see cref="ExportedEventCount"/> this flags a partial export.</summary>
public int? SessionEventCount { get; set; }
/// <summary>True when the client was still streaming event pages at submit time.</summary>
public bool? EventStreamActive { get; set; }
}
/// <summary>
@@ -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<void>;
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<File[]>([]);
const [screenshotError, setScreenshotError] = useState<string | null>(null);
const [agentLogFiles, setAgentLogFiles] = useState<File[]>([]);
const [agentLogError, setAgentLogError] = useState<string | null>(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 */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Screenshots <span className="text-gray-400">(optional)</span>
Screenshots <span className="text-gray-400">(optional, max 8 MB total)</span>
</label>
<input
ref={screenshotInputRef}
@@ -315,6 +331,9 @@ export default function ReportSessionModal({
className="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-blue-900/40 dark:file:text-blue-300"
disabled={submitting}
/>
{screenshotError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{screenshotError}</p>
)}
{screenshotFiles.length > 0 && (
<div className="mt-2 space-y-1">
{screenshotFiles.map((file, i) => (
@@ -374,6 +393,12 @@ export default function ReportSessionModal({
{screenshotFiles.length > 1 && <li>{screenshotFiles.length} screenshots (will be zipped)</li>}
{hasDiagnostics && includeDiagnostics && <li>Uploaded diagnostics archive (copied server-side)</li>}
</ul>
{eventsStreaming && (
<p className="mt-2 text-amber-700 dark:text-amber-400">
⚠ The event timeline is still loading — a report submitted now will contain a
partial export. Wait a moment for the complete timeline if possible.
</p>
)}
</div>
</>
)}
@@ -398,7 +423,7 @@ export default function ReportSessionModal({
</button>
<button
onClick={handleSubmit}
disabled={submitting || !!agentLogError}
disabled={submitting || !!agentLogError || !!screenshotError}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{submitting ? (
@@ -305,7 +305,12 @@ function SessionDetailContent() {
screenshotFileName,
agentLogBase64,
agentLogFileName,
includeDiagnostics
includeDiagnostics,
// Completeness signal: lets the operator detect a partial export
// (client had not finished streaming all event pages at submit time).
exportedEventCount: events.length,
sessionEventCount: session?.eventCount,
eventStreamActive: eventsApi.isStreamingMore
})
}
);
@@ -782,6 +787,7 @@ function SessionDetailContent() {
onSubmit={handleSubmitReport}
onCancel={() => setShowReportModal(false)}
submitting={reportSubmitting}
eventsStreaming={eventsApi.isStreamingMore}
/>
</main>
@@ -103,13 +103,30 @@ export interface SessionCsvData {
lastEventAt?: string;
durationSeconds: number;
diagnosticsBlobName?: string;
rebootCount?: number;
failureSource?: string;
reconcileReason?: string;
adminMarkedAction?: string;
validatedBy?: string;
isHybridJoin?: boolean;
isSelfDeployingProfile?: boolean;
connectionType?: string;
geoCountry?: string;
geoRegion?: string;
geoCity?: string;
avgApiLatencyMs?: number;
apiRequestCount?: number;
stalledAt?: string;
failureSnapshotJson?: string;
}
export function generateSessionCsvExport(session: SessionCsvData): string {
const esc = (v: string | undefined | null) => `"${String(v ?? "").replace(/"/g, '""')}"`;
// Columns match the Sessions Azure Table Storage schema exactly
// Columns mirror the Sessions Azure Table Storage schema
// PartitionKey = TenantId, RowKey = SessionId
const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName";
// Newer fields are APPENDED (never inserted) so the column prefix stays stable
// for anything parsing older exports.
const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName,RebootCount,FailureSource,ReconcileReason,AdminMarkedAction,ValidatedBy,IsHybridJoin,IsSelfDeployingProfile,ConnectionType,GeoCountry,GeoRegion,GeoCity,AvgApiLatencyMs,ApiRequestCount,StalledAt,FailureSnapshotJson";
const row = [
esc(session.tenantId),
esc(session.sessionId),
@@ -135,6 +152,21 @@ export function generateSessionCsvExport(session: SessionCsvData): string {
esc(session.lastEventAt),
String(session.durationSeconds ?? ""),
esc(session.diagnosticsBlobName),
String(session.rebootCount ?? ""),
esc(session.failureSource),
esc(session.reconcileReason),
esc(session.adminMarkedAction),
esc(session.validatedBy),
String(session.isHybridJoin ?? ""),
String(session.isSelfDeployingProfile ?? ""),
esc(session.connectionType),
esc(session.geoCountry),
esc(session.geoRegion),
esc(session.geoCity),
String(session.avgApiLatencyMs ?? ""),
String(session.apiRequestCount ?? ""),
esc(session.stalledAt),
esc(session.failureSnapshotJson),
].join(",");
return "\uFEFF" + header + "\n" + row;
}