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;
+ }
+ }
+}