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 <noreply@anthropic.com>
This commit is contained in:
Samir Khan
2026-06-21 12:20:39 +05:30
co-authored by Claude Sonnet 4.6
parent 8730c0fa38
commit ee9c8ded02
5 changed files with 111 additions and 20 deletions
+35 -6
View File
@@ -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.
---
@@ -15,5 +15,6 @@
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
</Project>
+8 -7
View File
@@ -14,6 +14,7 @@ builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddHttpClient<GraphApiClient>();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<SecretProtector>();
builder.Services.AddScoped<GraphCollector>();
builder.Services.AddScoped<NotificationSender>();
builder.Services.AddScoped<AlertEvaluator>();
@@ -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 });
@@ -13,6 +13,7 @@ namespace M365SecurityDashboard.Api.Services;
/// </summary>
public sealed class NotificationSender(
IHttpClientFactory httpFactory,
SecretProtector protector,
ILogger<NotificationSender> logger)
{
private static readonly Dictionary<string, int> 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;
@@ -0,0 +1,54 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
namespace M365SecurityDashboard.Api.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class SecretProtector(ILogger<SecretProtector> 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;
}
}
}