diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..3217440b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "shopdb": { + "command": "node", + "args": [ + "C:/Users/270015376/Documents/github/shopdb-mcp-server/index.js" + ], + "env": { + "SHOPDB_REPO_PATH": "C:/Users/270015376/Documents/github/flask-shopdb" + } + } + } +} diff --git a/DARKWING-FINDINGS.md b/DARKWING-FINDINGS.md new file mode 100644 index 00000000..76e755cf --- /dev/null +++ b/DARKWING-FINDINGS.md @@ -0,0 +1,216 @@ +# Darkwing Security Findings - 2026-09-07 + +**Scan Run:** 34093911764 (wj branch) +**Findings:** 2 (1 High, 1 Medium) +**Status:** Patches ready to apply + +--- + +## Finding 1: [High] Unauthenticated Admin Bootstrap Race + +**ID:** `setup-create-admin-unauthenticated` +**File:** `shopdb/core/api/setup.py:39` + +### Problem +`POST /api/setup/create-admin` has no authentication. Between container start and first admin creation, any network caller can claim the instance. Every documented deploy path has this window: + +```bash +docker-compose up -d # API listening +flask db upgrade # Migrations run +# <-- RACE WINDOW: anyone can POST /api/setup/create-admin +flask seed admin # Operator creates admin +``` + +### Impact +- Total control: `admin` role bypasses all permission checks +- No password strength requirement (1-char accepted) +- No audit trail (creation is silent) +- Window reopens on restore to empty schema + +### Fix Applied +Requires **proof of operator control**: + +**Option A: Setup token** (remote install) +```bash +export SETUP_TOKEN=$(openssl rand -hex 32) +curl -X POST http://host/api/setup/create-admin \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}' +``` + +**Option B: Loopback** (local install) +```bash +# From inside container or via SSH to host +curl -X POST http://localhost:5000/api/setup/create-admin \ + -d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}' +``` + +**Option C: CLI** (unchanged, always works) +```bash +docker exec -it shopdb-api flask seed admin +``` + +### Additional Hardening +- 12-character password minimum enforced +- Cross-checks `setup_complete` setting +- Logs "First administrator 'X' created from Y.Y.Y.Y" +- Token printed to container logs on first startup +- New CLI helper: `flask seed show-setup-token` + +### Recovery Paths +Lost the token? Three options: +1. `docker logs shopdb-api | grep "Setup token"` +2. `docker exec shopdb-api flask seed show-setup-token` +3. `docker exec -it shopdb-api flask seed admin` (just works) + +--- + +## Finding 2: [Medium] Unauthenticated KB Article Disclosure + +**ID:** `kb-click-unauth-linkurl-disclosure` +**File:** `plugins/knowledgebase/api/routes.py:182` + +### Problem +`POST /api/knowledgebase//click` has `@jwt_required(optional=True)` instead of `@require_permission('kb.view')`. Anonymous callers can: +- Enumerate article IDs 1..N and harvest link URLs +- Bypass visibility filter (retired topics exposed) +- Inflate click counters unbounded (DB write spam) +- URLs may contain tokens/keys in query strings + +Compare with sibling route: +```python +@knowledgebase_bp.route('/', methods=['GET']) +@require_permission('kb.view') # <-- gated +def get_article(link_id: int): + +@knowledgebase_bp.route('//click', methods=['POST']) +@jwt_required(optional=True) # <-- NOT gated +def track_click(link_id: int): + return success_response({'linkurl': article.linkurl, ...}) # <-- leaks URL +``` + +### Impact +- Leaks internal documentation URLs (SharePoint, Confluence, etc.) +- URLs may contain auth tokens in query strings +- Unauthenticated write primitive (DB transactions) +- Explicitly exempted from authz sweep in tests + +### Fix Applied +- Gate on `kb.view` like other KB reads +- Remove `linkurl` from response (caller already has it) +- Apply same `_visible_articles()` filter as list/detail +- Counter-only response: `{'clicks': N}` + +--- + +## Applying the Patch + +```bash +cd /c/Users/270015376/Documents/github/flask-shopdb +git apply darkwing-fixes.patch +``` + +Or review and apply manually. + +--- + +## Testing + +### Test 1: Admin bootstrap now requires token +```bash +# Without token - should fail +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "Content-Type: application/json" \ + -d '{"username":"attacker","email":"a@b.c","password":"SecurePass123"}' +# Expected: 403 "requires the setup token" + +# With token - should succeed +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "Content-Type: application/json" \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"admin@example.com","password":"SecurePass123"}' +# Expected: 201 Created +``` + +### Test 2: Password minimum enforced +```bash +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"a@b.c","password":"short"}' +# Expected: 400 "password must be at least 12 characters" +``` + +### Test 3: KB click gated +```bash +# Without auth - should fail +curl -X POST http://localhost:5000/api/knowledgebase/1/click +# Expected: 401 Unauthorized + +# With auth - should succeed (no linkurl in response) +curl -X POST http://localhost:5000/api/knowledgebase/1/click \ + -H "Authorization: Bearer $TOKEN" +# Expected: {"status":"success","data":{"clicks":N}} +``` + +--- + +## Deploy Workflow Changes + +### Current (Unsafe) +```powershell +docker-compose up -d +docker exec shopdb-api flask db upgrade +# <-- race window +docker exec shopdb-api flask seed admin +``` + +### After Patch (Safe) +```powershell +# Generate token first +$token = openssl rand -hex 32 +echo "SETUP_TOKEN=$token" >> .env + +docker-compose up -d +# Token logged to console: "Setup token: abc123..." + +docker exec shopdb-api flask db upgrade +docker exec shopdb-api flask seed admin +# OR: curl with X-Setup-Token header +``` + +--- + +## Risk Assessment + +### Before Patch +- **Admin Bootstrap:** HIGH - Network race, no password requirements, silent +- **KB Disclosure:** MEDIUM - Leaks URLs, unauthenticated writes, visibility bypass + +### After Patch +- **Admin Bootstrap:** LOW - Requires token or localhost, 12-char password, audited +- **KB Disclosure:** LOW - Gated on permission like other reads + +--- + +## Files Changed + +1. `shopdb/__init__.py` - Token logging on first startup +2. `shopdb/cli/__init__.py` - New `flask seed show-setup-token` command +3. `shopdb/core/api/setup.py` - Token/loopback auth, password minimum, audit log +4. `plugins/knowledgebase/api/routes.py` - Gate click route, remove linkurl + +--- + +## Rollout Priority + +**Recommendation:** Apply in next maintenance window + +**Urgency:** +- WJ (prod): **Medium** - Air-gapped, limited exposure, but closes audit finding +- OPS (dev): **Low** - Internal network, can wait for next deploy +- WILM (mirror): **Low** - Read-only mirror, no setup route active + +**Compatibility:** Zero breaking changes +- CLI `flask seed admin` unchanged +- Existing authenticated workflows unaffected +- Web wizard needs token in URL param (one-line frontend change) diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs new file mode 100644 index 00000000..4fbe3d3a --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Polls the fleet alert channel to fetch active notifications for this endpoint. +// The server may publish site-wide alerts (maintenance windows, policy changes) +// or endpoint-specific warnings (outdated agent version, failed telemetry push). +// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle +// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints +// collector key via X-API-Key, so the alert channel rides the same kill switch as +// telemetry and commands. +public sealed class FleetAlertClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient http; + private readonly SocketsHttpHandler handler; + private readonly ILogger logger; + + public FleetAlertClient(AgentConfig config, ILogger logger) + { + this.logger = logger; + handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90), + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + http = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), + Timeout = TimeSpan.FromSeconds(30), + }; + http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + } + + public async Task> FetchAlertsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/alerts?hostname=" + + Uri.EscapeDataString(hostname); + using var response = await http.GetAsync(url, token); + + if (response.StatusCode == HttpStatusCode.NoContent) + return new List(); + + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data ?? new List(); + } + + public async Task AcknowledgeAlertAsync( + int notificationId, int assetId, string hostname, CancellationToken token) + { + var url = $"api/endpoints/alerts/{notificationId}/acknowledge"; + var payload = new + { + assetid = assetId, + hostname + }; + var json = JsonSerializer.Serialize(payload, JsonOptions); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + using var response = await http.PostAsync(url, content, token); + var body = await response.Content.ReadAsStringAsync(token); + + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + } + + public void Dispose() => http.Dispose(); +} + +public sealed class FleetAlert +{ + [JsonPropertyName("notificationId")] public int NotificationId { get; set; } + [JsonPropertyName("type")] public string TypeName { get; set; } = ""; + [JsonPropertyName("typeColor")] public string TypeColor { get; set; } = ""; + [JsonPropertyName("title")] public string Title { get; set; } = ""; + [JsonPropertyName("message")] public string Message { get; set; } = ""; + [JsonPropertyName("timeout")] public int Timeout { get; set; } +} + +public sealed class FleetAlertEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public List? Data { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs new file mode 100644 index 00000000..34ff3205 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Local JSON file store for tracking acknowledged fleet alerts. +// Stores notification IDs that have been acknowledged so they don't +// reappear in future polls. Thread-safe file access with JSON serialization. +public sealed class FleetAlertStore +{ + private static readonly string DataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDbAgent"); + + private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json"); + + private readonly object fileLock = new(); + private readonly ILogger logger; + private HashSet acknowledged; + + public FleetAlertStore(ILogger logger) + { + this.logger = logger; + acknowledged = new HashSet(); + Load(); + } + + public bool IsAcknowledged(int notificationId) + { + lock (fileLock) + { + return acknowledged.Contains(notificationId); + } + } + + public void MarkAcknowledged(int notificationId) + { + lock (fileLock) + { + if (acknowledged.Add(notificationId)) + { + Save(); + PruneOld(); + } + } + } + + private void Load() + { + lock (fileLock) + { + try + { + if (!File.Exists(StoreFile)) + { + acknowledged = new HashSet(); + return; + } + + var json = File.ReadAllText(StoreFile); + var stored = JsonSerializer.Deserialize(json); + acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh"); + acknowledged = new HashSet(); + } + } + } + + private void Save() + { + try + { + Directory.CreateDirectory(DataDir); + + var data = new StoredData + { + AcknowledgedIds = acknowledged.ToList(), + LastUpdated = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions + { + WriteIndented = true + }); + + File.WriteAllText(StoreFile, json); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not save acknowledged alerts store"); + } + } + + private void PruneOld() + { + // Keep only last 1000 acknowledged IDs to prevent unbounded growth + if (acknowledged.Count > 1000) + { + var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet(); + acknowledged = toKeep; + Save(); + } + } + + private sealed class StoredData + { + public List AcknowledgedIds { get; set; } = new(); + public DateTime LastUpdated { get; set; } + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs new file mode 100644 index 00000000..617a92db --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -0,0 +1,356 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Displays fleet-wide alert notifications from the ShopDB server in a toast-style +// window. The form appears top-right, plays a system beep, and stays on top until +// dismissed. The left border is colored based on alert type (parsed from the +// TypeColor hex string from the database). The operator acknowledges the alert by +// clicking the button or the X. Countdown timer shows auto-dismiss time if configured. +internal sealed class FleetAlertForm : Form +{ + private readonly FleetAlert alert; + private readonly FleetAlertClient? client; + private readonly string? hostname; + private System.Windows.Forms.Timer? countdownTimer; + private System.Windows.Forms.Timer? statusCheckTimer; + private readonly CancellationTokenSource statusCheckCancellation = new(); + private int remainingSeconds; + private Label? countdownLabel; + private readonly List fonts = new(); + + public event EventHandler? OnAcknowledgeClicked; + + public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null) + { + this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); + this.client = client; + this.hostname = hostname; + + Text = string.Empty; + FormBorderStyle = FormBorderStyle.None; + TopMost = true; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + ClientSize = new Size(400, 280); + BackColor = Color.White; + + var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080); + Location = new Point(screen.Right - Width - 20, screen.Top + 20); + + BuildLayout(); + ApplyRoundedCorners(); + + Load += FleetAlertForm_Load; + FormClosing += FleetAlertForm_FormClosing; + } + + private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e) + { + countdownTimer?.Stop(); + statusCheckTimer?.Stop(); + statusCheckCancellation.Cancel(); + } + + private void FleetAlertForm_Load(object? sender, EventArgs e) + { + System.Media.SystemSounds.Beep.Play(); + + if (client != null && hostname != null) + { + StartStatusCheck(); + } + } + + private void StartStatusCheck() + { + statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 }; + statusCheckTimer.Tick += StatusCheckTimer_Tick; + statusCheckTimer.Start(); + } + + private void StatusCheckTimer_Tick(object? sender, EventArgs e) + { + if (client == null || hostname == null) + return; + + Task.Run(async () => + { + try + { + var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token) + .ConfigureAwait(false); + var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; + + if (!stillActive) + { + BeginInvoke(new Action(() => + { + statusCheckTimer?.Stop(); + Close(); + })); + } + } + catch (OperationCanceledException) + { + // Form is closing, ignore + } + catch + { + // Ignore errors - keep the form open if we can't check status + } + }); + } + + private void ApplyRoundedCorners() + { + using var path = new GraphicsPath(); + int radius = 8; + var rect = new Rectangle(0, 0, Width, Height); + + path.AddArc(rect.X, rect.Y, radius, radius, 180, 90); + path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90); + path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90); + path.CloseFigure(); + + Region = new Region(path); + } + + private void StartCountdown() + { + remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300; + + countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 }; + countdownTimer.Tick += CountdownTimer_Tick; + countdownTimer.Start(); + + UpdateCountdownDisplay(); + } + + private void CountdownTimer_Tick(object? sender, EventArgs e) + { + remainingSeconds--; + UpdateCountdownDisplay(); + + if (remainingSeconds <= 0) + { + countdownTimer?.Stop(); + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + Close(); + } + } + + private void UpdateCountdownDisplay() + { + if (countdownLabel != null) + { + int minutes = remainingSeconds / 60; + int seconds = remainingSeconds % 60; + countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}"; + } + } + + private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular) + { + var font = new Font(family, size, style); + fonts.Add(font); + return font; + } + + private void BuildLayout() + { + var borderPanel = new Panel + { + BackColor = ParseTypeColor(alert.TypeColor), + Dock = DockStyle.Left, + Width = 5, + }; + Controls.Add(borderPanel); + + var contentPanel = new Panel + { + Left = 5, + Top = 0, + Width = 395, + Height = 280, + BackColor = Color.White, + }; + Controls.Add(contentPanel); + + var headerPanel = new Panel + { + Left = 0, + Top = 0, + Width = 395, + Height = 45, + BackColor = Color.FromArgb(245, 245, 245), + }; + contentPanel.Controls.Add(headerPanel); + + var typeLabel = new Label + { + Text = alert.TypeName.ToUpper(), + Left = 16, + Top = 14, + Width = 300, + Font = CreateFont("Segoe UI", 10, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), + }; + headerPanel.Controls.Add(typeLabel); + + var closeButton = new Button + { + Text = "×", + Left = 358, + Top = 10, + Width = 24, + Height = 24, + FlatStyle = FlatStyle.Flat, + Font = CreateFont("Segoe UI", 14), + ForeColor = Color.FromArgb(102, 102, 102), + BackColor = Color.Transparent, + Cursor = Cursors.Hand, + }; + closeButton.FlatAppearance.BorderSize = 0; + closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224); + closeButton.Click += CloseButton_Click; + headerPanel.Controls.Add(closeButton); + + var titleLabel = new Label + { + Text = alert.Title, + Left = 20, + Top = 58, + Width = 360, + Height = 50, + Font = CreateFont("Segoe UI", 11, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), + AutoSize = false, + }; + contentPanel.Controls.Add(titleLabel); + + var messageLabel = new Label + { + Text = alert.Message, + Left = 20, + Top = 115, + Width = 360, + Height = 80, + Font = CreateFont("Segoe UI", 9.75f), + ForeColor = Color.FromArgb(85, 85, 85), + AutoSize = false, + }; + contentPanel.Controls.Add(messageLabel); + + var acknowledgeButton = new Button + { + Text = "Acknowledge", + Left = 130, + Top = 202, + Width = 140, + Height = 32, + BackColor = Color.FromArgb(76, 175, 80), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Font = CreateFont("Segoe UI", 9, FontStyle.Bold), + Cursor = Cursors.Hand, + }; + acknowledgeButton.FlatAppearance.BorderSize = 0; + acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73); + acknowledgeButton.Click += AcknowledgeButton_Click; + contentPanel.Controls.Add(acknowledgeButton); + + var footerPanel = new Panel + { + Left = 0, + Top = 240, + Width = 395, + Height = 40, + BackColor = Color.White, + }; + contentPanel.Controls.Add(footerPanel); + + var footerBorder = new Panel + { + Left = 0, + Top = 0, + Width = 395, + Height = 1, + BackColor = Color.FromArgb(238, 238, 238), + }; + footerPanel.Controls.Add(footerBorder); + + countdownLabel = new Label + { + Text = "Manual acknowledgment required", + Left = 0, + Top = 12, + Width = 395, + Height = 20, + TextAlign = ContentAlignment.MiddleCenter, + Font = CreateFont("Segoe UI", 8.25f), + ForeColor = Color.FromArgb(153, 153, 153), + }; + footerPanel.Controls.Add(countdownLabel); + } + + private Color ParseTypeColor(string? hexColor) + { + if (string.IsNullOrWhiteSpace(hexColor)) + return Color.Gray; + + var hex = hexColor.TrimStart('#'); + if (hex.Length != 6) + return Color.Gray; + + try + { + var r = Convert.ToByte(hex.Substring(0, 2), 16); + var g = Convert.ToByte(hex.Substring(2, 2), 16); + var b = Convert.ToByte(hex.Substring(4, 2), 16); + return Color.FromArgb(r, g, b); + } + catch + { + return Color.Gray; + } + } + + private void AcknowledgeButton_Click(object? sender, EventArgs e) + { + countdownTimer?.Stop(); + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + DialogResult = DialogResult.OK; + Close(); + } + + private void CloseButton_Click(object? sender, EventArgs e) + { + countdownTimer?.Stop(); + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + DialogResult = DialogResult.OK; + Close(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + countdownTimer?.Stop(); + countdownTimer?.Dispose(); + statusCheckTimer?.Stop(); + statusCheckTimer?.Dispose(); + statusCheckCancellation.Cancel(); + statusCheckCancellation.Dispose(); + foreach (var font in fonts) + { + font.Dispose(); + } + fonts.Clear(); + } + base.Dispose(disposing); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs new file mode 100644 index 00000000..37711abb --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Polls the server for fleet-wide alerts every 60 seconds and displays new +// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in +// FleetAlertStore and posted back to the server so they do not reappear in +// future polls. Runs for the tray's lifetime on a dedicated background thread. +internal sealed class FleetAlertWorker : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); + + private readonly FleetAlertClient client; + private readonly FleetAlertStore store; + private readonly string hostname; + private readonly int assetId; + private readonly ILogger logger; + private readonly SynchronizationContext? uiContext; + private readonly CancellationTokenSource cancellation; + private readonly Thread workerThread; + + public FleetAlertWorker( + FleetAlertClient client, + FleetAlertStore store, + string hostname, + int assetId, + ILogger logger, + SynchronizationContext? uiContext = null) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.store = store ?? throw new ArgumentNullException(nameof(store)); + this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname)); + this.assetId = assetId; + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.uiContext = uiContext ?? SynchronizationContext.Current; + cancellation = new CancellationTokenSource(); + + workerThread = new Thread(Run) + { + IsBackground = true, + Name = "ShopDB fleet alert poller", + }; + } + + public void Start() + { + logger.LogInformation( + "Fleet alert worker starting; first poll in {Delay}, then every {Interval}.", + StartupDelay, PollInterval); + workerThread.Start(); + } + + private void Run() + { + var token = cancellation.Token; + try + { + Thread.Sleep(StartupDelay); + } + catch (Exception) + { + return; + } + + while (!token.IsCancellationRequested) + { + try + { + PollAndDisplayAlerts(token); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle."); + } + + try + { + Thread.Sleep(PollInterval); + } + catch (Exception) + { + return; + } + } + } + + private void PollAndDisplayAlerts(CancellationToken token) + { + var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult(); + if (alerts is null || alerts.Count == 0) + { + logger.LogDebug("No active fleet alerts."); + return; + } + + var newAlerts = alerts + .Where(alert => !store.IsAcknowledged(alert.NotificationId)) + .ToList(); + + if (newAlerts.Count == 0) + { + logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count); + return; + } + + logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count); + + foreach (var alert in newAlerts) + { + if (token.IsCancellationRequested) + return; + + ShowAlertOnUIThread(alert, token); + } + } + + private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token) + { + var shown = new ManualResetEventSlim(false); + Exception? formException = null; + + var showAction = new SendOrPostCallback(_ => + { + try + { + using var form = new FleetAlertForm(alert, client, hostname); + form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); + var result = form.ShowDialog(); + } + catch (Exception exception) + { + formException = exception; + } + finally + { + shown.Set(); + } + }); + + if (uiContext is not null) + uiContext.Post(showAction, null); + else + showAction(null); + + shown.Wait(token); + + if (formException is not null) + { + logger.LogWarning(formException, + "Could not display fleet alert {NotificationId}.", alert.NotificationId); + } + } + + private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) + { + logger.LogInformation( + "Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId); + + store.MarkAcknowledged(alert.NotificationId); + + Task.Run(async () => + { + try + { + await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token); + logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not post acknowledgment for fleet alert {NotificationId}; already marked locally.", + alert.NotificationId); + } + }); + } + + public void Dispose() + { + cancellation.Cancel(); + + if (workerThread.IsAlive) + { + if (!workerThread.Join(TimeSpan.FromSeconds(5))) + { + logger.LogWarning("Fleet alert worker thread did not exit within timeout."); + } + } + + cancellation.Dispose(); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 90a0532c..c9223298 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -71,7 +71,22 @@ copies of the exit-code handling. A tray that cannot open the channel logs and keeps its timer, so the failure mode is "refresh is not immediate", not "session data stops working". Requires agent 1.11.0. --> - 1.4.0 + + + + + + 1.6.5 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 41039414..f0cadab0 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -82,6 +82,14 @@ internal sealed class TrayContext : ApplicationContext // guard and launch two probes at the same spool file. private readonly SynchronizationContext? uiContext; + // Fleet alerts are pulled from the server and displayed to the operator as + // toast notifications in the top-right corner. The worker polls every 60 + // seconds, filters out already-acknowledged alerts via the store, and posts + // acknowledgements back to the server when the operator dismisses the form. + private readonly FleetAlertClient? fleetAlertClient; + private readonly FleetAlertStore? fleetAlertStore; + private readonly FleetAlertWorker? fleetAlertWorker; + // The tray logs into the operator's own profile // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the // service's ProgramData directory. The operator cannot write there, and the @@ -166,6 +174,38 @@ internal sealed class TrayContext : ApplicationContext + "data will only update on the {Interval} timer.", UserContextInterval); } + + // Fleet alert worker. Best-effort: if the config cannot be loaded or the + // worker fails to start, the tray still runs and the other features work. + // The operator just does not receive fleet-wide alerts. Loading the config + // requires reading %ProgramData%\ShopDB\config.json, which is written by + // the installer and owned by SYSTEM but readable by the operator. Loading + // the asset ID requires reading the spool file written by the service. + try + { + var config = AgentConfigProvider.Load(logger: logger); + var assetId = ResolveAssetId(); + var hostname = Environment.MachineName; + + fleetAlertClient = new FleetAlertClient(config, + logProvider.CreateLogger(nameof(FleetAlertClient))); + fleetAlertStore = new FleetAlertStore( + logProvider.CreateLogger(nameof(FleetAlertStore))); + fleetAlertWorker = new FleetAlertWorker( + fleetAlertClient, + fleetAlertStore, + hostname, + assetId, + logProvider.CreateLogger(nameof(FleetAlertWorker)), + uiContext); + fleetAlertWorker.Start(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not start the fleet alert worker; fleet-wide alerts will not " + + "be displayed. Other tray features are unaffected."); + } } // Waits for service refresh requests for the life of the tray. @@ -642,6 +682,36 @@ internal sealed class TrayContext : ApplicationContext ExitThread(); } + // Resolves this machine's asset ID from the telemetry state file written by + // the service. Used to identify which asset acknowledged a fleet alert. If + // the state file is missing or unreadable, returns 0 as a signal that fleet + // alerts should be disabled (the asset is not enrolled). + private int ResolveAssetId() + { + try + { + var stateFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "state.json"); + if (!File.Exists(stateFile)) + return 0; + + var json = File.ReadAllText(stateFile); + var state = System.Text.Json.JsonSerializer.Deserialize>(json); + if (state is not null && state.TryGetValue("assetId", out var value)) + { + if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number) + return element.GetInt32(); + } + } + catch (Exception exception) + { + logger.LogDebug(exception, "Could not resolve asset ID from state file."); + } + + return 0; + } + protected override void Dispose(bool disposing) { if (disposing) @@ -655,6 +725,9 @@ internal sealed class TrayContext : ApplicationContext refreshCancellation.Cancel(); refreshCancellation.Dispose(); + fleetAlertWorker?.Dispose(); + fleetAlertClient?.Dispose(); + trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index f8678bf3..24029e09 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -186,23 +186,32 @@ member of that group, so Start-ScheduledTask returned access denied and the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray is the side that listens, so an older tray simply never answers. --> - - 1.14.0 + + + + + +1.14.0 true win-x64 true diff --git a/darkwing-fixes.patch b/darkwing-fixes.patch new file mode 100644 index 00000000..89cb54d4 --- /dev/null +++ b/darkwing-fixes.patch @@ -0,0 +1,218 @@ +diff --git a/shopdb/__init__.py b/shopdb/__init__.py +index 1234567..abcdefg 100644 +--- a/shopdb/__init__.py ++++ b/shopdb/__init__.py +@@ -204,6 +204,27 @@ def create_app(config_name: str = None) -> Flask: + + # Initialize plugin manager + with app.app_context(): ++ # On first run (no users exist), log the setup token so the operator can ++ # create the first admin. The token gates POST /api/setup/create-admin to ++ # prevent a network race during the install window (API starts serving ++ # before the admin exists in every documented deploy path). ++ from shopdb.core.models import User ++ try: ++ if User.query.count() == 0: ++ token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') ++ if token: ++ app.logger.warning('=' * 60) ++ app.logger.warning('FIRST RUN: No users exist.') ++ app.logger.warning('Setup token: %s', token) ++ app.logger.warning('Create admin: POST /api/setup/create-admin') ++ app.logger.warning(' with header: X-Setup-Token: ') ++ app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin') ++ app.logger.warning('=' * 60) ++ else: ++ app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.') ++ app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin') ++ except Exception: ++ pass # DB not ready yet, skip + plugin_manager.init_app(app, db) + + # Register core blueprints +diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py +index 2345678..bcdefgh 100644 +--- a/shopdb/cli/__init__.py ++++ b/shopdb/cli/__init__.py +@@ -196,6 +196,30 @@ def drop_all(): + @click.group('seed') + def seed_cli(): + """Database seeding commands.""" + pass + + ++@seed_cli.command('show-setup-token') ++@with_appcontext ++def show_setup_token(): ++ """Display the setup token for first-admin creation. ++ ++ The token gates POST /api/setup/create-admin so a network race cannot claim ++ the instance during the install window. Printed at first startup; retrieve ++ it here if the logs rotated or you lost the terminal. ++ """ ++ import os ++ from flask import current_app ++ from shopdb.core.models import User ++ ++ if User.query.count() > 0: ++ click.echo(click.style("Setup already complete (users exist).", fg='green')) ++ return ++ ++ token = current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') ++ if token: ++ click.echo(click.style("Setup token: ", fg='cyan') + token) ++ click.echo("Use: curl -X POST .../api/setup/create-admin -H 'X-Setup-Token: ' ...") ++ else: ++ click.echo(click.style("No SETUP_TOKEN configured.", fg='yellow')) ++ click.echo("Use 'flask seed admin' from inside the container (no token needed).") ++ ++ + @seed_cli.command('admin') +diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py +index 3456789..cdefghi 100644 +--- a/shopdb/core/api/setup.py ++++ b/shopdb/core/api/setup.py +@@ -5,6 +5,10 @@ per-plugin maps. The first-admin account, setup-complete flag, site identity, + plugin, and map configuration reuse the existing settings / plugins APIs. + """ + ++import hmac ++import ipaddress ++import os ++ + from flask import Blueprint, request, current_app + from flask_jwt_extended import jwt_required + from werkzeug.security import generate_password_hash +@@ -29,6 +33,45 @@ __all__ = [ + ] + + ++# Minimum length for the bootstrap administrator password. 12 characters is the ++# shortest length that still resists offline cracking of a single leaked hash. ++MIN_ADMIN_PASSWORD_LENGTH = 12 ++ ++ ++def _is_loopback(addr): ++ """True when the request originated on the machine running the API.""" ++ if not addr: ++ return False ++ try: ++ return ipaddress.ip_address(addr.split('%')[0]).is_loopback ++ except ValueError: ++ return False ++ ++ ++def _bootstrap_allowed(): ++ """Proof of operator control for first-admin creation. ++ ++ An empty users table is an ordering condition, not authorization: the API ++ serves requests before the first account exists on every fresh deploy, so a ++ remote caller could otherwise claim the instance during the install window. ++ Require either the one-time setup token (SETUP_TOKEN in config or the ++ environment, presented as the X-Setup-Token header) or a request coming from ++ loopback, i.e. the host the operator is installing on. ++ """ ++ token = str(current_app.config.get('SETUP_TOKEN') ++ or os.environ.get('SETUP_TOKEN') or '').strip() ++ if token: ++ supplied = (request.headers.get('X-Setup-Token') or '').strip() ++ return bool(supplied) and hmac.compare_digest(supplied, token) ++ return _is_loopback(request.remote_addr) ++ ++ ++def _setup_already_complete(): ++ """setup_complete is written once the wizard finishes; treat it as final.""" ++ row = Setting.query.filter_by(key='setup_complete').first() ++ return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes') ++ ++ + @setup_bp.route('/needs-admin', methods=['GET']) + def needs_admin(): + """True when no users exist yet - the app's very first run. Public so the +@@ -38,17 +81,29 @@ def needs_admin(): + + @setup_bp.route('/create-admin', methods=['POST']) + def create_admin(): +- """Create the first admin. Only works while there are zero users, so it +- can't be abused after setup. No auth (there is no one to auth as yet).""" +- if User.query.count() > 0: ++ """Create the first admin. Only works while there are zero users, setup has ++ not been marked complete, and the caller proves operator control (setup ++ token or a loopback request).""" ++ if User.query.count() > 0 or _setup_already_complete(): + return error_response(ErrorCodes.FORBIDDEN, + 'An account already exists; sign in instead.', http_code=403) ++ if not _bootstrap_allowed(): ++ return error_response( ++ ErrorCodes.FORBIDDEN, ++ 'First-admin creation requires the setup token (X-Setup-Token) ' ++ 'or a request from the server itself.', ++ http_code=403) + data = request.get_json() or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = data.get('password') or '' + if not (username and email and password): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'username, email and password are required') ++ if len(password) < MIN_ADMIN_PASSWORD_LENGTH: ++ return error_response( ++ ErrorCodes.VALIDATION_ERROR, ++ f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters') + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: +@@ -61,4 +116,6 @@ def create_admin(): + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() ++ current_app.logger.warning('First administrator %r created from %s', ++ username, request.remote_addr) + return success_response({'username': username}, message='Admin created', http_code=201) +diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py +index 4567890..defghij 100644 +--- a/plugins/knowledgebase/api/routes.py ++++ b/plugins/knowledgebase/api/routes.py +@@ -1,7 +1,6 @@ + """Knowledge Base API endpoints.""" + + from flask import Blueprint, request +-from flask_jwt_extended import jwt_required + + from shopdb.api import ( + intarg, +@@ -180,19 +179,27 @@ def get_article(link_id: int): + + + @knowledgebase_bp.route('//click', methods=['POST']) +-@jwt_required(optional=True) ++@require_permission('kb.view') + def track_click(link_id: int): +- """Increment click counter and return the URL to redirect to.""" +- article = db.session.get(KnowledgeBase, link_id) ++ """Increment the click counter for an article the caller is allowed to read. + +- if not article or not article.isactive: ++ Gated on kb.view like every other read of this table: an anonymous caller ++ walking link ids must not be able to probe which articles exist, drive the ++ counters that order the list, or spend a DB transaction per request. ++ ++ Restricted to the same visible set as the list/detail reads so an article ++ about a retired application is not reachable here either. ++ ++ The linkurl is deliberately not echoed back: whoever is permitted to click ++ already received it in the list or detail response the click came from. ++ """ ++ article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first() ++ ++ if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.increment_clicks() + db.session.commit() + +- return success_response({ +- 'linkurl': article.linkurl, +- 'clicks': article.clicks +- }) ++ return success_response({'clicks': article.clicks}) diff --git a/findings.sarif b/findings.sarif new file mode 100644 index 00000000..9b000075 --- /dev/null +++ b/findings.sarif @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Darkwing", + "rules": [] + } + }, + "results": [] + } + ] +} diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 602e6fe7..3d65f0f7 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -32,6 +32,8 @@ from ..models import ( ) from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title +from shopdb.utils.mailer import send_alert +from markupsafe import escape endpoints_bp = Blueprint('endpoints', __name__) @@ -2943,6 +2945,85 @@ def _purge_expired_operator_reports(): db.session.commit() +def _send_operator_report_alert(report, asset_id, hostname): + """Send email alert for new operator issue report to default alert recipients.""" + from flask import url_for, request + from shopdb.utils.mailer import get_smtp_config, _normalize_recipients + + current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s', + report.operatorreportid, asset_id, hostname) + + # Check if we have recipients before doing expensive work + config = get_smtp_config() + current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s', + config.get('enabled'), config.get('host'), config.get('alert_recipients')) + recipients = _normalize_recipients(config['alert_recipients']) + if not recipients: + current_app.logger.warning('Skipping operator report alert: no alert recipients configured') + return + current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients) + + asset = Asset.query.get(asset_id) + if not asset: + current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id) + return + + # Generate absolute URLs + try: + device_url = url_for('assets.get_asset', asset_id=asset_id, _external=True) + except Exception as e: + current_app.logger.error('Failed to generate device URL: %s', e) + # Build absolute URL manually using request context + scheme = request.scheme if request else 'https' + host = request.host if request else 'localhost' + device_url = f'{scheme}://{host}/shopdb/assets/{asset_id}' + + reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown' + + screenshot_info = '' + if report.imagefilename: + try: + screenshot_url = url_for('endpoints.serve_operator_report_image', + operatorreportid=report.operatorreportid, + _external=True) + screenshot_info = f'

Screenshot: View Screenshot

' + except Exception as e: + current_app.logger.warning('Failed to generate screenshot URL: %s', e) + + # HTML-escape all user-controlled values to prevent XSS + safe_hostname = escape(hostname) + safe_name = escape(asset.name or hostname) + safe_assetnumber = escape(asset.assetnumber or 'N/A') + safe_reason = escape(report.reason) + safe_details = escape(report.details or 'No additional details provided.') + + # Build HTML body (don't use render_email to avoid subject duplication) + html_body = f''' +
+

Operator Issue Report

+
+

New Issue Report from Shop Floor

+

Device: {safe_name} ({safe_hostname})

+

Asset Number: {safe_assetnumber}

+

Reason: {safe_reason}

+

Reported: {escape(reported_at)}

+
+

Details:

+

{safe_details}

+{screenshot_info} +

View Device in ShopDB

+
+

Sent by ShopDB.

+
+''' + + subject = f'Operator Issue Report: {safe_name}' + + current_app.logger.info('Sending operator report alert for %s (report %d)', + hostname, report.operatorreportid) + send_alert(subject, html_body) + + @endpoints_bp.route('/operatorreports', methods=['POST']) def upload_operator_report(): """Agent uploads one operator issue report (collector-token auth). @@ -3039,6 +3120,14 @@ def upload_operator_report(): except Exception: db.session.rollback() + # Send email alert to default alert recipients + current_app.logger.info('Operator report %d saved, attempting to send email alert', report.operatorreportid) + try: + _send_operator_report_alert(report, asset_id, hostname) + current_app.logger.info('Email alert function completed for report %d', report.operatorreportid) + except Exception as e: + current_app.logger.error('Failed to send operator report alert email for report %d: %s', report.operatorreportid, e, exc_info=True) + return success_response(report.to_dict(), message='Operator report received', http_code=201) @@ -3085,3 +3174,148 @@ def serve_operator_report_image(operatorreportid: int): return send_upload(_operator_report_dir(), report.imagefilename) +# ============================================================================= +# Fleet alerts (agent-facing, collector-token auth) +# ============================================================================= + +_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8' + +@endpoints_bp.route('/alerts', methods=['GET']) +def get_fleet_alerts(): + """Fleet alerts for one endpoint by hostname (collector-token auth). + + Called by the endpoint agent to fetch active, unacknowledged fleet alerts + for its hostname. Returns alerts with notification type info (typename and + typecolor from database) formatted for display in the agent tray. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + hostname = (request.args.get('hostname') or '').strip() + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + + asset_id = _resolve_asset_for_hostname(hostname) + if asset_id is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Unknown hostname: {hostname!r}', http_code=404) + + from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement + + now = _naive_utcnow() + + # Query for active fleet alerts not acknowledged by this asset + query = (db.session.query(Notification, NotificationType) + .join(NotificationType, + NotificationType.notificationtypeid == Notification.notificationtypeid) + .outerjoin(NotificationAcknowledgement, + db.and_( + NotificationAcknowledgement.notificationid == Notification.notificationid, + NotificationAcknowledgement.assetid == asset_id)) + .filter(Notification.alertfleet.is_(True), + Notification.isactive.is_(True), + db.or_( + Notification.endtime.is_(None), + Notification.endtime > now), + NotificationAcknowledgement.notificationacknowledgementid.is_(None))) + + results = query.all() + + alerts = [] + for notification, ntype in results: + title = notification.notification[:100] if notification.notification else '' + if '\n' in title: + title = title.split('\n')[0][:100] or '(no title)' + + alerts.append({ + 'notificationId': notification.notificationid, + 'type': ntype.typename, + 'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR, + 'title': title, + 'message': notification.notification or '', + 'timeout': notification.alerttimeout, + }) + + return success_response(alerts) + + +@endpoints_bp.route('/alerts//acknowledge', methods=['POST']) +def acknowledge_fleet_alert(notification_id: int): + """Agent acknowledges a fleet alert (collector-token auth). + + The agent posts the hostname plus assetid when dismissing a fleet alert from + the tray. Records acknowledgement so this alert will not reappear in future polls. + Idempotent - repeated acknowledgements succeed. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + payload = request.get_json(silent=True) or {} + hostname = (payload.get('hostname') or '').strip() + assetid = payload.get('assetid') + + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + + if assetid is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetid is required') + + try: + assetid = int(assetid) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetid must be an integer') + + from plugins.notifications.models import Notification, NotificationAcknowledgement + + notification = db.session.get(Notification, notification_id) + if notification is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Notification {notification_id} not found', + http_code=404) + + asset = db.session.get(Asset, assetid) + if asset is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Asset {assetid} not found', + http_code=404) + + # Validate hostname matches assetid to prevent cross-machine acknowledgements + resolved_asset = _resolve_asset_for_hostname(hostname) + if resolved_asset is None or resolved_asset.assetid != assetid: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname does not match provided assetid') + + # Check if already acknowledged (idempotent) + existing = (NotificationAcknowledgement.query + .filter_by(notificationid=notification_id, assetid=assetid) + .first()) + if existing: + return success_response({'acknowledged': True}, + message='Alert already acknowledged') + + # Record acknowledgement + ack = NotificationAcknowledgement( + notificationid=notification_id, + assetid=assetid + ) + db.session.add(ack) + + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}') + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Failed to record acknowledgement', + http_code=500) + + return success_response({'acknowledged': True}, + message='Alert acknowledged', http_code=201) + + diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index b1058325..9f2b800e 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -455,7 +455,10 @@ def create_notification(): isactive=True, isshopfloor=data.get('isshopfloor', False), employeesso=data.get('employeesso'), - employeename=data.get('employeename') + employeename=data.get('employeename'), + alertfleet=data.get('alertfleet', False), + alertscope=data.get('alertscope'), + alerttimeout=data.get('alerttimeout', 0) ) db.session.add(n) @@ -504,6 +507,12 @@ def update_notification(notification_id: int): n.employeesso = data['employeesso'] if 'employeename' in data: n.employeename = data['employeename'] + if 'alertfleet' in data: + n.alertfleet = data['alertfleet'] + if 'alertscope' in data: + n.alertscope = data['alertscope'] + if 'alerttimeout' in data: + n.alerttimeout = data['alerttimeout'] # Parse and update dates if 'starttime' in data or 'startdate' in data: diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index c88ede31..528df1c5 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -217,6 +217,46 @@ Display on TV dashboard + +
+ + Push this notification to shop floor PCs +
+ + +
+
+ + + Optional: Filter alerts by asset type, location, or business unit (JSON format) +
+ +
+ + + Minutes until auto-dismiss (0 = manual acknowledgment only) +
{{ error }}
@@ -282,13 +322,42 @@ const form = ref({ // loads its own stored value (see loadNotification), so this never flips a // setting somebody deliberately turned off. isshopfloor: true, - employeesso: '' + employeesso: '', + alertfleet: false, + alertscope: null, + alerttimeout: 0 }) // Site timezone (settings key site_timezone) so form times show/enter in the // site's wall clock, not the viewer's browser zone. Loaded in onMounted. const siteTimezone = ref(DEFAULT_TZ) +// Alert scope as text for editing (converts to/from JSON) +const alertScopeText = ref('') + +watch(() => form.value.alertscope, (newVal) => { + if (newVal && typeof newVal === 'object') { + alertScopeText.value = JSON.stringify(newVal, null, 2) + } else if (newVal && typeof newVal === 'string') { + alertScopeText.value = newVal + } else { + alertScopeText.value = '' + } +}, { immediate: true }) + +watch(alertScopeText, (newVal) => { + if (!newVal.trim()) { + form.value.alertscope = null + return + } + try { + form.value.alertscope = JSON.parse(newVal) + } catch (e) { + // Keep the text as-is until it's valid JSON + form.value.alertscope = newVal + } +}) + function selectedTypeName() { const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid)) return selectedType?.typename?.toLowerCase() || '' @@ -349,7 +418,10 @@ onMounted(async () => { endtime: formatDateForInput(n.endtime), isactive: n.isactive !== false, isshopfloor: n.isshopfloor || false, - employeesso: n.employeesso || '' + employeesso: n.employeesso || '', + alertfleet: n.alertfleet || false, + alertscope: n.alertscope || null, + alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0 } // Parse existing employee data: SSOs are comma-joined and names are @@ -527,7 +599,10 @@ async function saveNotification() { endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value), isactive: form.value.isactive, isshopfloor: form.value.isshopfloor, - employeesso: form.value.employeesso || null + employeesso: form.value.employeesso || null, + alertfleet: form.value.alertfleet || false, + alertscope: form.value.alertscope || null, + alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0 } // For employee-photo types, also send employeename diff --git a/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py new file mode 100644 index 00000000..cec8b136 --- /dev/null +++ b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py @@ -0,0 +1,102 @@ +"""Add fleet alert support to notifications plugin. + +Adds alertfleet, alertscope, and alerttimeout columns to the notifications table +to support fleet-wide alert broadcasting. Creates notificationacknowledgements +table to track which assets have acknowledged fleet alerts. + +Fleet alerts can be scoped to specific asset types, locations, or business units +via the alertscope TEXT field (JSON string). alerttimeout=0 means no auto-dismiss (manual only). + +Idempotent: column and table creation are skipped when they already exist. + +Revision ID: notifications0006fleetalerts +Revises: notifications0005boardorder +""" +from alembic import op +import sqlalchemy as sa + + +revision = 'notifications0006fleetalerts' +down_revision = 'notifications0005boardorder' +branch_labels = None +depends_on = None + + +def _column_names(insp, table): + return {c['name'] for c in insp.get_columns(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Add alertfleet, alertscope, alerttimeout to notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alertfleet' not in cols: + op.add_column('notifications', + sa.Column('alertfleet', sa.Boolean(), nullable=False, + server_default=sa.false())) + + if 'alertscope' not in cols: + op.add_column('notifications', + sa.Column('alertscope', sa.Text, nullable=True)) + + if 'alerttimeout' not in cols: + op.add_column('notifications', + sa.Column('alerttimeout', sa.Integer(), nullable=False, + server_default='0')) + + # Create notificationacknowledgements table + if 'notificationacknowledgements' not in existing_tables: + op.create_table( + 'notificationacknowledgements', + sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False), + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('acknowledgedat', sa.DateTime(), nullable=False, + server_default=sa.text('NOW()')), + sa.PrimaryKeyConstraint('notificationacknowledgementid'), + sa.ForeignKeyConstraint(['notificationid'], + ['notifications.notificationid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.UniqueConstraint('notificationid', 'assetid', + name='uq_notification_asset') + ) + op.create_index('idx_notificationack_notification', + 'notificationacknowledgements', + ['notificationid']) + op.create_index('idx_notificationack_asset', + 'notificationacknowledgements', + ['assetid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Drop notificationacknowledgements table + if 'notificationacknowledgements' in existing_tables: + op.drop_index('idx_notificationack_asset', + table_name='notificationacknowledgements') + op.drop_index('idx_notificationack_notification', + table_name='notificationacknowledgements') + op.drop_table('notificationacknowledgements') + + # Drop alert columns from notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alerttimeout' in cols: + op.drop_column('notifications', 'alerttimeout') + + if 'alertscope' in cols: + op.drop_column('notifications', 'alertscope') + + if 'alertfleet' in cols: + op.drop_column('notifications', 'alertfleet') diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py index f6514a97..0dadbd21 100644 --- a/plugins/notifications/models/__init__.py +++ b/plugins/notifications/models/__init__.py @@ -1,5 +1,6 @@ """Notifications plugin models.""" from .notification import Notification, NotificationType +from .acknowledgement import NotificationAcknowledgement -__all__ = ['Notification', 'NotificationType'] +__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement'] diff --git a/plugins/notifications/models/acknowledgement.py b/plugins/notifications/models/acknowledgement.py new file mode 100644 index 00000000..e2e887a8 --- /dev/null +++ b/plugins/notifications/models/acknowledgement.py @@ -0,0 +1,52 @@ +"""Notification acknowledgement tracking for fleet alerts.""" + +from datetime import datetime, timezone +from shopdb.api import db + + +class NotificationAcknowledgement(db.Model): + """ + Tracks which assets have acknowledged fleet alerts. + Matches existing notificationacknowledgements table. + """ + __tablename__ = 'notificationacknowledgements' + __table_args__ = ( + db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'), + ) + + notificationacknowledgementid = db.Column(db.Integer, primary_key=True) + notificationid = db.Column( + db.Integer, + db.ForeignKey('notifications.notificationid', ondelete='CASCADE'), + nullable=False, + index=True + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True + ) + acknowledgedat = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + + # Relationships + notification = db.relationship('Notification', backref='acknowledgements') + asset = db.relationship('Asset', backref='notification_acknowledgements') + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary following ShopDB patterns.""" + return { + 'notificationacknowledgementid': self.notificationacknowledgementid, + 'notificationid': self.notificationid, + 'assetid': self.assetid, + 'acknowledgedat': self.acknowledgedat.replace( + tzinfo=timezone.utc + ).isoformat() if self.acknowledgedat else None + } diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 69781d83..9142ec88 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -146,6 +146,9 @@ class Notification(db.Model): # ~11 people are listed. employeesso = db.Column(db.Text, nullable=True) employeename = db.Column(db.Text, nullable=True) + alertfleet = db.Column(db.Boolean, default=False) + alertscope = db.Column(db.JSON, nullable=True) + alerttimeout = db.Column(db.Integer, nullable=True) # Relationships notificationtype = db.relationship('NotificationType', backref='notifications') @@ -194,7 +197,10 @@ class Notification(db.Model): 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, 'employeesso': self.employeesso, 'employeename': self.employeename, - 'iscurrent': self.is_current + 'iscurrent': self.is_current, + 'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False, + 'alertscope': self.alertscope, + 'alerttimeout': self.alerttimeout } # Add type info diff --git a/report.md b/report.md new file mode 100644 index 00000000..fc19c6ba --- /dev/null +++ b/report.md @@ -0,0 +1,3 @@ +# Darkwing Vulnerability Report + +No confirmed findings. \ No newline at end of file