mirror of
https://github.com/sameerk27/vigil365.git
synced 2026-07-02 10:39:35 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
8730c0fa38
commit
ee9c8ded02
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user