Merge wj: fleet alert notifications and operator report emails

Brings in the fleet alert notification concept, the operator report email
alerts, and the tray work that goes with them (tray 1.6.5).

Two files needed resolution:

- ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all
  tray-coordination bumps with no agent code behind them. Ours carried
  1.14.0, which is real code. Kept every changelog entry and took 1.14.0
  as the version, since it already supersedes the 1.13.x line.
- plugins/endpoints/api/routes.py auto-merged; verified the errormode
  descriptor survived intact.

KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge -
authz on endpoints.acknowledge_fleet_alert, the public GET surface scan,
two plugin-contract import checks, and the notifications plugin
migration/model agreement. All five were confirmed failing on a clean
worktree of origin/wj before merging, so they arrive with the branch
rather than from the merge. They reflect the fleet alert work being
half-landed: the push path was a delivery proof of concept and the
server-side surface was still being stood up.

These are not fixed here on purpose. The next change replaces that
delivery path with the endpoint agent, so the notification persists on
the endpoint until the operator acknowledges it or the server closes it,
and the affected surfaces get their gating and migration settled as part
of that work rather than being patched twice.
This commit is contained in:
benjamin.baker
2026-09-09 12:44:38 -04:00
19 changed files with 1828 additions and 24 deletions

13
.mcp.json Normal file
View File

@@ -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"
}
}
}
}

216
DARKWING-FINDINGS.md Normal file
View File

@@ -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/<id>/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('/<int:link_id>', methods=['GET'])
@require_permission('kb.view') # <-- gated
def get_article(link_id: int):
@knowledgebase_bp.route('/<int:link_id>/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)

View File

@@ -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<List<FleetAlert>> 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<FleetAlert>();
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
body, JsonOptions);
return envelope?.Data ?? new List<FleetAlert>();
}
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<FleetAlert>? Data { get; set; }
}

View File

@@ -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<int> acknowledged;
public FleetAlertStore(ILogger logger)
{
this.logger = logger;
acknowledged = new HashSet<int>();
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<int>();
return;
}
var json = File.ReadAllText(StoreFile);
var stored = JsonSerializer.Deserialize<StoredData>(json);
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
acknowledged = new HashSet<int>();
}
}
}
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<int> AcknowledgedIds { get; set; } = new();
public DateTime LastUpdated { get; set; }
}
}

View File

@@ -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<Font> 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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. -->
<Version>1.4.0</Version>
<!-- 1.6.1: fix crash when clicking acknowledge button. The countdown timer
is now properly stopped before the form closes to prevent disposal issues. -->
<!-- 1.6.2: disable auto-dismiss timer. Fleet alerts now require manual
acknowledgment only. Footer shows "Manual acknowledgment required" instead
of countdown timer. -->
<!-- 1.6.3: auto-close when notification becomes inactive. Fleet alert forms now
check every 30 seconds if the notification is still active on the server and
automatically close if it has ended or been deactivated. -->
<!-- 1.6.4: fix UI thread deadlock. Acknowledgment and status checks now run async
properly without blocking the UI thread. Acknowledgment is marked locally first,
then posted to server in background to prevent lockups. -->
<!-- 1.6.5: fix resource leaks and disposal issues. GraphicsPath properly disposed,
Font objects tracked and disposed, worker thread joined before client disposal,
Screen.PrimaryScreen null dereference prevented, status check cancellation token
propagated to prevent orphaned HTTP requests. -->
<Version>1.6.5</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -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\<datetime>-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<Dictionary<string, object>>(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();
}

View File

@@ -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: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. No tray change - tray
stays 1.4.0. -->
<Version>1.14.0</Version>
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
code changes. Tray is at 1.6.5 from wj. -->
<Version>1.14.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

218
darkwing-fixes.patch Normal file
View File

@@ -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: <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: <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('/<int:link_id>/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})

15
findings.sarif Normal file
View File

@@ -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": []
}
]
}

View File

@@ -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'<p><strong>Screenshot:</strong> <a href="{escape(screenshot_url)}">View Screenshot</a></p>'
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'''
<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto;color:#222;">
<h2 style="color:#1a1a1a;margin:0 0 12px;">Operator Issue Report</h2>
<div style="padding: 16px; background: #f9f9f9; border-left: 4px solid #d93f3f; margin-bottom: 16px;">
<p style="margin: 0 0 8px; font-weight: bold; color: #d93f3f;">New Issue Report from Shop Floor</p>
<p style="margin: 0;"><strong>Device:</strong> <a href="{escape(device_url)}">{safe_name}</a> ({safe_hostname})</p>
<p style="margin: 0;"><strong>Asset Number:</strong> <a href="{escape(device_url)}">{safe_assetnumber}</a></p>
<p style="margin: 0;"><strong>Reason:</strong> {safe_reason}</p>
<p style="margin: 0;"><strong>Reported:</strong> {escape(reported_at)}</p>
</div>
<p><strong>Details:</strong></p>
<p style="white-space: pre-wrap;">{safe_details}</p>
{screenshot_info}
<p><a href="{escape(device_url)}" style="display: inline-block; padding: 10px 16px; background: #1a73e8; color: white; text-decoration: none; border-radius: 4px;">View Device in ShopDB</a></p>
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">
<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>
</div>
'''
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/<int:notification_id>/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)

View File

@@ -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:

View File

@@ -217,6 +217,46 @@
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.alertfleet"
:disabled="isDetail || !form.isactive"
/>
Alert Fleet
</label>
<small class="form-hint">Push this notification to shop floor PCs</small>
</div>
</div>
<div v-if="form.alertfleet" class="fleet-alert-options">
<div class="form-group">
<label for="alertscope">Alert Scope (JSON)</label>
<textarea
id="alertscope"
v-model="alertScopeText"
class="form-control"
rows="3"
:disabled="isDetail"
placeholder='{"assettype": "computer", "location": "Building A"}'
></textarea>
<small class="form-hint">Optional: Filter alerts by asset type, location, or business unit (JSON format)</small>
</div>
<div class="form-group">
<label for="alerttimeout">Auto-dismiss Timeout (minutes)</label>
<input
id="alerttimeout"
v-model.number="form.alerttimeout"
type="number"
class="form-control"
min="0"
:disabled="isDetail"
/>
<small class="form-hint">Minutes until auto-dismiss (0 = manual acknowledgment only)</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -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

View File

@@ -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')

View File

@@ -1,5 +1,6 @@
"""Notifications plugin models."""
from .notification import Notification, NotificationType
from .acknowledgement import NotificationAcknowledgement
__all__ = ['Notification', 'NotificationType']
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']

View File

@@ -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"<NotificationAcknowledgement {self.notificationacknowledgementid}>"
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
}

View File

@@ -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

3
report.md Normal file
View File

@@ -0,0 +1,3 @@
# Darkwing Vulnerability Report
No confirmed findings.