Skip to content

Commit b151823

Browse files
robgrameCopilot
andcommitted
feat(azure): replace file-system CSV export with browser streaming + ZIP
In the Azure topology, NO component was actually writing CSVs to App Service disk: CsvExportService is wired only in the on-prem Worker. The Web.Azure Settings page and HybridAgent appsettings.json still exposed CsvOutputPath / KeyValueExportPath fields that confused operators — they pointed at a feature that simply does not exist on Azure. This commit removes the dead surface and replaces it with browser-streaming exports so no file artifact ever lands on App Service storage. Changes ------- * Components/App.razor — add the missing `downloadTextFile` JS helper (the existing /audit CSV export was referencing it but it had never been ported from the on-prem Web/App.razor — broken at runtime today). Also add `downloadBinaryFile` for the new ZIP download. * Components/Pages/Settings.razor — remove CsvOutputPath, KeyValueExportPath, LogPath input fields (no consumer in the Azure flow). Update the "Sensitive feature" card copy to reflect the Azure-native model (no file system, browser streaming, audited). * HybridAgent/appsettings.json — remove the inherited-but-unused CsvOutputPath/KeyValueExportPath entries (replaced with a comment that explains the data flow). * Components/Pages/Devices/DeviceList.razor — add an "Export CSV (metadata)" button. Streams the current filtered slice of devices to the browser (metadata only — no recovery key values). Capped at 50k rows. Records the authenticated portal user in the audit log. * Components/Pages/Export.razor — NEW page at /export (AdminOnly). Streams a ZIP containing recovery-key-values_<ts>.csv + optional .salt sidecar to the browser. Each recovery key value is AES-256-GCM encrypted with the configured passphrase, bound to (KeyId, Source, DeviceId) via AAD — same row layout and snapshot semantics as the on-prem CsvExportService. Preflight count caps the batch at 100k rows. Cancels on circuit dispose. Handles mid-export DataKey lock by emitting Reason="encrypted-locked" rather than tearing down the whole export. * Components/Layout/MainLayout.razor — add NavLink to /export inside the AdminOnly AuthorizeView. Rationale (not a Storage Account) --------------------------------- User asked: "esportare su uno Storage Account oppure qualcosa diverso più sicuro". I chose streaming-to-browser over Blob Storage because: 1. It eliminates the "file artifact" concept entirely — no SAS rotation, no lifecycle policy, no ciphertext sitting around waiting to be exfiltrated. 2. Zero new infrastructure (no Bicep changes, no new Storage Account, no CMK, no private endpoint). 3. Mirrors the existing in-memory Audit CSV export pattern. A Blob backend can be added later behind an IExportSink abstraction if long-term archival becomes a requirement. Verification ------------ * dotnet build BitLockerKeyMonitor.Azure.slnx — 0 warn / 0 err * dotnet build BitLockerKeyMonitor.slnx — 0 warn / 0 err (on-prem solution untouched, Core unchanged) * dotnet test BitLockerKeyMonitor.Azure.slnx — 135/135 pass (112 Core + 15 Functions + 8 HybridAgent) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c061fa4 commit b151823

6 files changed

Lines changed: 651 additions & 20 deletions

File tree

src/BitLockerKeyMonitor.HybridAgent/appsettings.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@
4444
"IncludeRecoveryKeyValue": true,
4545
"MaxRetryAttempts": 5,
4646
"BaseRetryDelaySeconds": 2,
47-
"CsvOutputPath": "C:/ProgramData/BitLockerKeyMonitor.HybridAgent/Output",
48-
"KeyValueExportPath": "C:/ProgramData/BitLockerKeyMonitor.HybridAgent/Output/secure",
47+
"//Csv": "CsvOutputPath / KeyValueExportPath are intentionally OMITTED — the HybridAgent does not write CSV files. Recovery key data flows AD scan -> sealed batch -> Service Bus -> Function consumer -> Azure SQL. On-demand CSV exports happen browser-side from Web.Azure (no App Service file artifact).",
4948
"RetirementGraceDays": 14,
5049
"GraphMaxConcurrency": 5,
5150
"GraphPageSize": 999,

src/BitLockerKeyMonitor.Web.Azure/Components/App.razor

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,39 @@
1818
<Routes />
1919
<ReconnectModal />
2020
<script src="@Assets["_framework/blazor.web.js"]"></script>
21+
<script>
22+
// Triggers a client-side file download from a UTF-8 text payload
23+
// (used by /audit, /devices and /export CSV downloads).
24+
window.downloadTextFile = (filename, mimeType, text) => {
25+
const blob = new Blob([text], { type: mimeType + ';charset=utf-8' });
26+
const url = URL.createObjectURL(blob);
27+
const a = document.createElement('a');
28+
a.href = url;
29+
a.download = filename;
30+
document.body.appendChild(a);
31+
a.click();
32+
document.body.removeChild(a);
33+
// Free the blob after a short delay so the navigation has time to start.
34+
setTimeout(() => URL.revokeObjectURL(url), 1500);
35+
};
36+
37+
// Triggers a client-side file download from a base64-encoded binary payload
38+
// (used by /export ZIP downloads — encrypted recovery key values + salt sidecar).
39+
window.downloadBinaryFile = (filename, mimeType, base64) => {
40+
const binary = atob(base64);
41+
const bytes = new Uint8Array(binary.length);
42+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
43+
const blob = new Blob([bytes], { type: mimeType });
44+
const url = URL.createObjectURL(blob);
45+
const a = document.createElement('a');
46+
a.href = url;
47+
a.download = filename;
48+
document.body.appendChild(a);
49+
a.click();
50+
document.body.removeChild(a);
51+
setTimeout(() => URL.revokeObjectURL(url), 1500);
52+
};
53+
</script>
2154
</body>
2255

2356
</html>

src/BitLockerKeyMonitor.Web.Azure/Components/Layout/MainLayout.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
</AuthorizeView>
1616
<AuthorizeView Policy="AdminOnly">
1717
<Authorized>
18+
<li><NavLink href="/export">📤 Export</NavLink></li>
1819
<li><NavLink href="/settings">⚙️ Settings</NavLink></li>
1920
<li><NavLink href="/audit">🔍 Audit</NavLink></li>
2021
</Authorized>

src/BitLockerKeyMonitor.Web.Azure/Components/Pages/Devices/DeviceList.razor

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
@page "/devices"
22
@rendermode InteractiveServer
33
@implements IDisposable
4+
@using BitLockerKeyMonitor.Core.Models
5+
@using BitLockerKeyMonitor.Core.Services.Interfaces
46
@inject IDbContextFactory<MonitorDbContext> DbFactory
7+
@inject IAuditService Audit
8+
@inject IJSRuntime JS
59

610
<PageTitle>Devices - BitLocker Key Monitor</PageTitle>
711

@@ -39,6 +43,12 @@
3943
<input type="checkbox" @bind="_showRetired" @bind:after="OnShowRetiredChangedAsync" />
4044
Show retired devices
4145
</label>
46+
<button type="button" class="btn btn-secondary"
47+
@onclick="ExportMetadataCsvAsync"
48+
disabled="@_exporting"
49+
title="Streams a metadata-only CSV (no recovery key values) to your browser. The file never lands on App Service storage.">
50+
@if (_exporting) { <text>Exporting</text> } else { <text>⬇️ Export CSV (metadata)</text> }
51+
</button>
4252
</div>
4353

4454
@if (_loading)
@@ -113,14 +123,21 @@ else
113123
}
114124

115125
@code {
126+
[CascadingParameter]
127+
private Task<Microsoft.AspNetCore.Components.Authorization.AuthenticationState>? AuthState { get; set; }
128+
116129
private bool _loading = true;
130+
private bool _exporting = false;
117131
private bool _noConsistencyData = false;
118132
private List<DeviceListItem> _devices = [];
119133
private string _statusFilter = "";
120134
private string _searchFilter = "";
121135
private bool _showRetired = false;
122136
private int _page = 1;
123137
private const int _pageSize = 50;
138+
// Cap on metadata-only CSV export. Devices table is typically a few thousand rows;
139+
// 50k is generous and keeps the response bounded.
140+
private const int _exportRowCap = 50_000;
124141

125142
// Debounce timer for the search box: avoids issuing a DB query per keystroke.
126143
private CancellationTokenSource? _searchDebounceCts;
@@ -335,6 +352,185 @@ else
335352
private async Task PrevPage() { _page = Math.Max(1, _page - 1); await LoadDevicesAsync(); }
336353
private async Task NextPage() { _page++; await LoadDevicesAsync(); }
337354

355+
// Streams a metadata-only CSV of the CURRENT filtered view (status + search + showRetired)
356+
// straight to the browser. No App Service file artifact, no recovery key VALUES — only
357+
// device identity + key COUNTS + consistency status. Capped at _exportRowCap rows.
358+
private async Task ExportMetadataCsvAsync()
359+
{
360+
_exporting = true;
361+
StateHasChanged();
362+
try
363+
{
364+
await using var db = await DbFactory.CreateDbContextAsync();
365+
List<DeviceCsvRow> rows;
366+
367+
if (_showRetired)
368+
{
369+
var q = db.Devices
370+
.Where(d => d.RetirementState != RetirementState.Active)
371+
.AsQueryable();
372+
if (!string.IsNullOrEmpty(_searchFilter))
373+
{
374+
var term = _searchFilter;
375+
q = q.Where(d => EF.Functions.Like(d.DisplayName, $"%{term}%")
376+
|| (d.DnsHostName != null && EF.Functions.Like(d.DnsHostName, $"%{term}%")));
377+
}
378+
rows = await q.OrderBy(d => d.DisplayName)
379+
.Take(_exportRowCap)
380+
.Select(d => new DeviceCsvRow
381+
{
382+
DisplayName = d.DisplayName,
383+
DnsHostName = d.DnsHostName,
384+
AdObjectGuid = d.AdObjectGuid.HasValue ? d.AdObjectGuid.Value.ToString() : null,
385+
EntraDeviceId = d.EntraDeviceId.HasValue ? d.EntraDeviceId.Value.ToString() : null,
386+
IsHybridJoined = d.IsHybridJoined,
387+
AdKeyCount = db.RecoveryKeys.Count(k => k.DeviceId == d.Id && k.Source == RecoveryKeySource.ActiveDirectory),
388+
EntraKeyCount = db.RecoveryKeys.Count(k => k.DeviceId == d.Id && k.Source == RecoveryKeySource.EntraId),
389+
Status = null,
390+
Retirement = d.RetirementState,
391+
RetiredAt = d.RetiredAt,
392+
LastSeenInAd = d.LastSeenInAd,
393+
LastSeenInEntra = d.LastSeenInEntra,
394+
CheckedAt = d.UpdatedAt.UtcDateTime
395+
})
396+
.ToListAsync();
397+
}
398+
else
399+
{
400+
var latestScanId = await db.ScanRuns
401+
.Where(s => s.Status == ScanRunStatus.Completed || s.Status == ScanRunStatus.PartiallyCompleted)
402+
.Where(s => db.ConsistencyResults.Any(c => c.ScanRunId == s.Id))
403+
.OrderByDescending(s => s.StartedAt)
404+
.Select(s => s.Id)
405+
.FirstOrDefaultAsync();
406+
407+
if (latestScanId > 0)
408+
{
409+
var q = db.ConsistencyResults
410+
.Where(c => c.ScanRunId == latestScanId)
411+
.Include(c => c.Device)
412+
.AsQueryable();
413+
if (!string.IsNullOrEmpty(_statusFilter) && Enum.TryParse<ConsistencyStatus>(_statusFilter, out var status))
414+
q = q.Where(c => c.Status == status);
415+
if (!string.IsNullOrEmpty(_searchFilter))
416+
{
417+
var term = _searchFilter;
418+
q = q.Where(c => EF.Functions.Like(c.Device.DisplayName, $"%{term}%")
419+
|| (c.Device.DnsHostName != null && EF.Functions.Like(c.Device.DnsHostName, $"%{term}%")));
420+
}
421+
rows = await q.OrderBy(c => c.Device.DisplayName)
422+
.Take(_exportRowCap)
423+
.Select(c => new DeviceCsvRow
424+
{
425+
DisplayName = c.Device.DisplayName,
426+
DnsHostName = c.Device.DnsHostName,
427+
AdObjectGuid = c.Device.AdObjectGuid.HasValue ? c.Device.AdObjectGuid.Value.ToString() : null,
428+
EntraDeviceId = c.Device.EntraDeviceId.HasValue ? c.Device.EntraDeviceId.Value.ToString() : null,
429+
IsHybridJoined = c.Device.IsHybridJoined,
430+
AdKeyCount = c.AdKeyCount,
431+
EntraKeyCount = c.EntraKeyCount,
432+
Status = c.Status,
433+
Retirement = c.Device.RetirementState,
434+
RetiredAt = c.Device.RetiredAt,
435+
LastSeenInAd = c.Device.LastSeenInAd,
436+
LastSeenInEntra = c.Device.LastSeenInEntra,
437+
CheckedAt = c.CheckedAt
438+
})
439+
.ToListAsync();
440+
}
441+
else
442+
{
443+
var q = db.Devices
444+
.Where(d => d.RetirementState == RetirementState.Active)
445+
.AsQueryable();
446+
if (!string.IsNullOrEmpty(_searchFilter))
447+
{
448+
var term = _searchFilter;
449+
q = q.Where(d => EF.Functions.Like(d.DisplayName, $"%{term}%")
450+
|| (d.DnsHostName != null && EF.Functions.Like(d.DnsHostName, $"%{term}%")));
451+
}
452+
rows = await q.OrderBy(d => d.DisplayName)
453+
.Take(_exportRowCap)
454+
.Select(d => new DeviceCsvRow
455+
{
456+
DisplayName = d.DisplayName,
457+
DnsHostName = d.DnsHostName,
458+
AdObjectGuid = d.AdObjectGuid.HasValue ? d.AdObjectGuid.Value.ToString() : null,
459+
EntraDeviceId = d.EntraDeviceId.HasValue ? d.EntraDeviceId.Value.ToString() : null,
460+
IsHybridJoined = d.IsHybridJoined,
461+
AdKeyCount = db.RecoveryKeys.Count(k => k.DeviceId == d.Id && k.Source == RecoveryKeySource.ActiveDirectory),
462+
EntraKeyCount = db.RecoveryKeys.Count(k => k.DeviceId == d.Id && k.Source == RecoveryKeySource.EntraId),
463+
Status = ConsistencyStatus.DeviceNotCorrelated,
464+
Retirement = d.RetirementState,
465+
RetiredAt = d.RetiredAt,
466+
LastSeenInAd = d.LastSeenInAd,
467+
LastSeenInEntra = d.LastSeenInEntra,
468+
CheckedAt = d.UpdatedAt.UtcDateTime
469+
})
470+
.ToListAsync();
471+
}
472+
}
473+
474+
var sb = new System.Text.StringBuilder(rows.Count * 200);
475+
sb.AppendLine("DisplayName,DnsHostName,AdObjectGuid,EntraDeviceId,IsHybridJoined,AdKeyCount,EntraKeyCount,Status,Retirement,RetiredAt,LastSeenInAd,LastSeenInEntra,CheckedAt");
476+
foreach (var r in rows)
477+
{
478+
sb.Append(CsvField(r.DisplayName)).Append(',');
479+
sb.Append(CsvField(r.DnsHostName)).Append(',');
480+
sb.Append(CsvField(r.AdObjectGuid)).Append(',');
481+
sb.Append(CsvField(r.EntraDeviceId)).Append(',');
482+
sb.Append(r.IsHybridJoined ? "true" : "false").Append(',');
483+
sb.Append(r.AdKeyCount).Append(',');
484+
sb.Append(r.EntraKeyCount).Append(',');
485+
sb.Append(r.Status.HasValue ? r.Status.Value.ToString() : "").Append(',');
486+
sb.Append(r.Retirement.ToString()).Append(',');
487+
sb.Append(r.RetiredAt.HasValue ? r.RetiredAt.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture) : "").Append(',');
488+
sb.Append(r.LastSeenInAd.HasValue ? r.LastSeenInAd.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture) : "").Append(',');
489+
sb.Append(r.LastSeenInEntra.HasValue ? r.LastSeenInEntra.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture) : "").Append(',');
490+
sb.Append(r.CheckedAt.HasValue ? r.CheckedAt.Value.ToString("o", System.Globalization.CultureInfo.InvariantCulture) : "");
491+
sb.Append('\n');
492+
}
493+
494+
var filename = $"devices_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv";
495+
await JS.InvokeVoidAsync("downloadTextFile", filename, "text/csv", sb.ToString());
496+
497+
var userName = "(no auth state)";
498+
if (AuthState != null)
499+
{
500+
var state = await AuthState;
501+
userName = state.User.Identity?.Name ?? "unknown";
502+
}
503+
504+
// Metadata-only export is NOT a sensitive-key reveal — record it as a plain export
505+
// (no key values exposed) so the audit log still shows who pulled which slice.
506+
await Audit.RecordAsync(
507+
AuditAction.ExportPlaintext,
508+
user: userName,
509+
outcome: AuditOutcome.Success,
510+
subject: filename,
511+
details: $"Devices metadata-only CSV (rows={rows.Count}, status='{_statusFilter}', search='{_searchFilter}', retired={_showRetired})");
512+
}
513+
finally
514+
{
515+
_exporting = false;
516+
StateHasChanged();
517+
}
518+
}
519+
520+
// RFC 4180 CSV quoting + spreadsheet formula-injection neutralization
521+
// (mirrors Audit.razor:376 — keep both in sync if either is updated).
522+
private static string CsvField(string? value)
523+
{
524+
if (string.IsNullOrEmpty(value)) return "";
525+
var safe = value;
526+
var first = safe[0];
527+
if (first is '=' or '+' or '-' or '@' or '\t' or '\r')
528+
safe = "'" + safe;
529+
var needsQuote = safe.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0;
530+
var escaped = safe.Replace("\"", "\"\"");
531+
return needsQuote ? $"\"{escaped}\"" : escaped;
532+
}
533+
338534
public void Dispose()
339535
{
340536
_searchDebounceCts?.Cancel();
@@ -413,4 +609,23 @@ else
413609
public DateTime? LastSeenInEntra { get; set; }
414610
public DateTime? CheckedAt { get; set; }
415611
}
612+
613+
// Wider projection used only by ExportMetadataCsvAsync. Kept separate from
614+
// DeviceListItem so the in-page rendering query stays lean.
615+
private class DeviceCsvRow
616+
{
617+
public string DisplayName { get; set; } = "";
618+
public string? DnsHostName { get; set; }
619+
public string? AdObjectGuid { get; set; }
620+
public string? EntraDeviceId { get; set; }
621+
public bool IsHybridJoined { get; set; }
622+
public int AdKeyCount { get; set; }
623+
public int EntraKeyCount { get; set; }
624+
public ConsistencyStatus? Status { get; set; }
625+
public RetirementState Retirement { get; set; }
626+
public DateTime? RetiredAt { get; set; }
627+
public DateTime? LastSeenInAd { get; set; }
628+
public DateTime? LastSeenInEntra { get; set; }
629+
public DateTime? CheckedAt { get; set; }
630+
}
416631
}

0 commit comments

Comments
 (0)