From ee9c8ded024fa06fc417677a1fb65b60e17760be Mon Sep 17 00:00:00 2001 From: Samir Khan Date: Sun, 21 Jun 2026 12:20:39 +0530 Subject: [PATCH] security: encrypt notification secrets at rest + honest security docs Addresses external review feedback on the project. - Add SecretProtector (Windows DPAPI, machine scope) and encrypt SMTP password and Teams/Slack/generic webhook URLs at rest in the database. Values are decrypted only in memory at send time; SMTP password is never returned by the API. Legacy plaintext rows are read transparently. - Rewrite README "Security & Maturity" section: honest beta positioning, read-only/least-privilege scope, credential handling, and a host hardening checklist (dedicated low-priv host, BitLocker, no public exposure, rotation). Notes certificate auth as recommended next step. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 41 +++++++++++--- .../M365SecurityDashboard.Api.csproj | 1 + src/M365SecurityDashboard.Api/Program.cs | 15 +++--- .../Services/NotificationSender.cs | 20 ++++--- .../Services/SecretProtector.cs | 54 +++++++++++++++++++ 5 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 src/M365SecurityDashboard.Api/Services/SecretProtector.cs diff --git a/README.md b/README.md index 5602ec8..a35ec71 100644 --- a/README.md +++ b/README.md @@ -233,13 +233,42 @@ sc.exe start M365SecurityDashboard --- -## Security Notes +## Security & Maturity -- Credentials are never stored in source code — use .NET User Secrets (dev) or `appsettings.Production.json` (prod, gitignored) -- The app uses **application permissions** (app-only) — no user sign-in required -- All Graph calls use short-lived bearer tokens via `ClientSecretCredential` (MSAL) -- Rate limiting is handled automatically (429 retry-after respected) -- Failed individual Graph sources do not stop the entire collection run +> **Read this before relying on Vigil365.** This is an open-source **read-only visibility aggregator**, currently **beta**. It surfaces signals that already exist across your Microsoft 365 admin centers in one place. It is **not** a replacement for native Microsoft security tooling (Defender XDR, Entra ID Protection, Purview), and it does **not** make security decisions or change configuration for you. Treat its output as a convenience view, verify findings in the source portal before acting, and do your own review of the code before deploying it in a sensitive environment. + +### What is in scope by design + +- **Read-only, least privilege.** Every Graph permission requested is `*.Read.All`. The app **cannot modify** users, devices, policies, or tenant settings even if the host is compromised. +- **No remediation automation.** "View in M365 Portal →" links only deep-link you to the correct blade. The app never tells you what to change and never makes changes — remediation stays in Microsoft's tooling where it belongs. +- **No inbound exposure by default.** The API binds to `localhost`. Remote access requires you to deliberately open a firewall port (and you should front it with TLS + auth if you do). +- **App-only client-credentials flow** via MSAL (`Azure.Identity`). Standard Microsoft auth, not a homegrown scheme. All Graph traffic is HTTPS/TLS. + +### How credentials and secrets are handled + +- The Graph client secret is **never** committed to source. Use .NET User Secrets (dev) or `appsettings.Production.json` / environment variables (prod, both gitignored). +- Notification secrets stored in the database (SMTP password, Teams/Slack & generic webhook URLs) are **encrypted at rest with the Windows Data Protection API (DPAPI), machine scope** — a leaked database row cannot be decrypted on another machine. Secrets are decrypted only in memory at send time and the SMTP password is never returned by the API. +- **Recommended:** use **certificate-based authentication** instead of a client secret for production (planned/optional). A non-exportable certificate in the Windows cert store removes the plaintext shared secret entirely. _(Not yet wired into the app — track this in Issues.)_ + +### Host hardening checklist (your responsibility) + +The security of this app is only as good as the box it runs on. Before production use: + +- [ ] Run on a **dedicated, patched, hardened** Windows host — not a shared workstation or a machine that handles untrusted input +- [ ] Run the service under a **dedicated low-privilege service account**, not an admin or your own login +- [ ] Enable **BitLocker / full-disk encryption** so the database and secrets are protected at rest +- [ ] Keep the host **off the public internet**; access the dashboard over the LAN/VPN only +- [ ] If you must expose it, put it behind a **reverse proxy with TLS and authentication** +- [ ] Ensure the host has **endpoint protection** and is **monitored** — a compromised host can read tokens in memory while the app runs +- [ ] **Rotate the Graph secret/certificate** on a schedule and immediately if the host is ever suspected compromised +- [ ] Restrict who can read `appsettings.Production.json` and the SQL database with NTFS/SQL permissions + +### Operational resilience + +- Rate limiting is handled automatically (429 `Retry-After` respected). +- A failed individual Graph source does not stop the whole collection run; each card degrades independently. + +> Found a security issue? See [SECURITY.md](SECURITY.md) — please report privately, not in a public issue. --- diff --git a/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj b/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj index 5a6654f..5a2c92f 100644 --- a/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj +++ b/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj @@ -15,5 +15,6 @@ + diff --git a/src/M365SecurityDashboard.Api/Program.cs b/src/M365SecurityDashboard.Api/Program.cs index f3137a0..72cfcae 100644 --- a/src/M365SecurityDashboard.Api/Program.cs +++ b/src/M365SecurityDashboard.Api/Program.cs @@ -14,6 +14,7 @@ builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -1057,36 +1058,36 @@ app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, Can }); // Notification settings (single row). Password is write-only — never returned. -app.MapGet("/api/notification-settings", async (AppDbContext db, CancellationToken ct) => +app.MapGet("/api/notification-settings", async (AppDbContext db, SecretProtector protector, CancellationToken ct) => { var s = await db.NotificationSettings.FirstOrDefaultAsync(ct) ?? new NotificationSettings { Id = 1 }; return Results.Ok(new { - s.TeamsEnabled, s.TeamsWebhookUrl, + s.TeamsEnabled, TeamsWebhookUrl = protector.Unprotect(s.TeamsWebhookUrl), s.EmailEnabled, s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpUsername, hasSmtpPassword = !string.IsNullOrEmpty(s.SmtpPassword), s.FromAddress, s.DefaultRecipient, - s.WebhookEnabled, s.WebhookUrl, + s.WebhookEnabled, WebhookUrl = protector.Unprotect(s.WebhookUrl), s.MinSeverity, }); }); -app.MapPut("/api/notification-settings", async (AppDbContext db, NotificationSettings input, CancellationToken ct) => +app.MapPut("/api/notification-settings", async (AppDbContext db, SecretProtector protector, NotificationSettings input, CancellationToken ct) => { var s = await db.NotificationSettings.FirstOrDefaultAsync(ct); if (s is null) { s = new NotificationSettings { Id = 1 }; db.NotificationSettings.Add(s); } s.TeamsEnabled = input.TeamsEnabled; - s.TeamsWebhookUrl = input.TeamsWebhookUrl; + s.TeamsWebhookUrl = protector.Protect(input.TeamsWebhookUrl); s.EmailEnabled = input.EmailEnabled; s.SmtpHost = input.SmtpHost; s.SmtpPort = input.SmtpPort <= 0 ? 587 : input.SmtpPort; s.SmtpUseSsl = input.SmtpUseSsl; s.SmtpUsername = input.SmtpUsername; - if (!string.IsNullOrEmpty(input.SmtpPassword)) s.SmtpPassword = input.SmtpPassword; // keep existing if blank + if (!string.IsNullOrEmpty(input.SmtpPassword)) s.SmtpPassword = protector.Protect(input.SmtpPassword); // keep existing if blank s.FromAddress = input.FromAddress; s.DefaultRecipient = input.DefaultRecipient; s.WebhookEnabled = input.WebhookEnabled; - s.WebhookUrl = input.WebhookUrl; + s.WebhookUrl = protector.Protect(input.WebhookUrl); s.MinSeverity = string.IsNullOrWhiteSpace(input.MinSeverity) ? "low" : input.MinSeverity; await db.SaveChangesAsync(ct); return Results.Ok(new { ok = true }); diff --git a/src/M365SecurityDashboard.Api/Services/NotificationSender.cs b/src/M365SecurityDashboard.Api/Services/NotificationSender.cs index f5ef6bb..d8d2b47 100644 --- a/src/M365SecurityDashboard.Api/Services/NotificationSender.cs +++ b/src/M365SecurityDashboard.Api/Services/NotificationSender.cs @@ -13,6 +13,7 @@ namespace M365SecurityDashboard.Api.Services; /// public sealed class NotificationSender( IHttpClientFactory httpFactory, + SecretProtector protector, ILogger logger) { private static readonly Dictionary SeverityRank = new(StringComparer.OrdinalIgnoreCase) @@ -28,11 +29,16 @@ public sealed class NotificationSender( if (Rank(alert.Severity) < Rank(cfg.MinSeverity)) return; // below the configured minimum severity — skip - if (cfg.TeamsEnabled && !string.IsNullOrWhiteSpace(cfg.TeamsWebhookUrl)) - await SendTeamsAsync(db, cfg.TeamsWebhookUrl!, alert, ct); + // Sensitive fields are stored DPAPI-encrypted at rest — decrypt for use only. + var teamsUrl = protector.Unprotect(cfg.TeamsWebhookUrl); + var webhookUrl = protector.Unprotect(cfg.WebhookUrl); + var smtpPassword = protector.Unprotect(cfg.SmtpPassword); - if (cfg.WebhookEnabled && !string.IsNullOrWhiteSpace(cfg.WebhookUrl)) - await SendWebhookAsync(db, cfg.WebhookUrl!, alert, ct); + if (cfg.TeamsEnabled && !string.IsNullOrWhiteSpace(teamsUrl)) + await SendTeamsAsync(db, teamsUrl!, alert, ct); + + if (cfg.WebhookEnabled && !string.IsNullOrWhiteSpace(webhookUrl)) + await SendWebhookAsync(db, webhookUrl!, alert, ct); if (cfg.EmailEnabled && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) { @@ -40,7 +46,7 @@ public sealed class NotificationSender( ? (FirstNonEmpty(cfg.DefaultRecipient) ?? cfg.FromAddress) : cfg.DefaultRecipient; if (!string.IsNullOrWhiteSpace(to)) - await SendEmailAsync(db, cfg, to!, alert, ct); + await SendEmailAsync(db, cfg, smtpPassword, to!, alert, ct); } } @@ -126,7 +132,7 @@ public sealed class NotificationSender( db.NotificationLogs.Add(log); } - private async Task SendEmailAsync(AppDbContext db, NotificationSettings cfg, string to, TriggeredAlert a, CancellationToken ct) + private async Task SendEmailAsync(AppDbContext db, NotificationSettings cfg, string? smtpPassword, string to, TriggeredAlert a, CancellationToken ct) { var log = new NotificationLog { TriggeredAlertId = a.Id, PolicyName = a.PolicyName, Channel = "email", Target = Truncate(to, 120) }; try @@ -157,7 +163,7 @@ public sealed class NotificationSender( EnableSsl = cfg.SmtpUseSsl, Credentials = string.IsNullOrWhiteSpace(cfg.SmtpUsername) ? CredentialCache.DefaultNetworkCredentials - : new NetworkCredential(cfg.SmtpUsername, cfg.SmtpPassword), + : new NetworkCredential(cfg.SmtpUsername, smtpPassword), }; await client.SendMailAsync(msg, ct); log.Success = true; diff --git a/src/M365SecurityDashboard.Api/Services/SecretProtector.cs b/src/M365SecurityDashboard.Api/Services/SecretProtector.cs new file mode 100644 index 0000000..c4fca38 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/SecretProtector.cs @@ -0,0 +1,54 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Encrypts sensitive values (SMTP password, webhook URLs) at rest using the +/// Windows Data Protection API (DPAPI), machine scope. Ciphertext is bound to +/// this host — a leaked database row cannot be decrypted on another machine. +/// On non-Windows hosts it falls back to returning values unchanged (with a +/// marker) so the app still runs in dev containers; production target is Windows. +/// +public sealed class SecretProtector(ILogger logger) +{ + private const string Prefix = "dpapi:"; // marks a value as DPAPI-encrypted + + public string? Protect(string? plaintext) + { + if (string.IsNullOrEmpty(plaintext)) return plaintext; + if (plaintext.StartsWith(Prefix, StringComparison.Ordinal)) return plaintext; // already protected + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return plaintext; + + try + { + var bytes = Encoding.UTF8.GetBytes(plaintext); + var cipher = ProtectedData.Protect(bytes, optionalEntropy: null, scope: DataProtectionScope.LocalMachine); + return Prefix + Convert.ToBase64String(cipher); + } + catch (Exception ex) + { + logger.LogWarning(ex, "DPAPI Protect failed; storing value unprotected"); + return plaintext; + } + } + + public string? Unprotect(string? stored) + { + if (string.IsNullOrEmpty(stored)) return stored; + if (!stored.StartsWith(Prefix, StringComparison.Ordinal)) return stored; // legacy plaintext — return as-is + + try + { + var cipher = Convert.FromBase64String(stored[Prefix.Length..]); + var bytes = ProtectedData.Unprotect(cipher, optionalEntropy: null, scope: DataProtectionScope.LocalMachine); + return Encoding.UTF8.GetString(bytes); + } + catch (Exception ex) + { + logger.LogWarning(ex, "DPAPI Unprotect failed; returning empty"); + return null; + } + } +}