Addresses all critical and high-severity findings from Fable security review.
CRITICAL FIX#1: Server Certificate Validation Bypass
- File: CertificateRenewal.cs
- Issue: Agent accepted ANY server certificate when ServerCertThumbprint not configured
- Fix: Now validates against Windows trusted roots when pinning not configured
- Impact: Prevents MITM attacks during renewal requests
CRITICAL FIX#2: Certificate Revocation Check Incomplete
- Files: asset.py, client_cert.py, migration 0022
- Issue: Revocation check used hasattr() and silently skipped when columns missing
- Fix: Applied migration 0022, uncommented revocation columns, removed hasattr()
- Impact: Revoked certificates can no longer authenticate or renew
CRITICAL FIX#3: Expired Certificates Cannot Renew
- Files: routes.py (renewal endpoint), CertificateRenewal.cs
- Issue: Expired certs fail at TLS handshake before reaching renewal endpoint
- Fix: Added API key + hostname fallback for renewal when cert expired
- Agent now sends hostname in request body for API key authentication
- Impact: Agents can renew expired certificates, 30-day window becomes effective
HIGH FIX#4: Hostname Injection Vulnerability
- File: routes.py
- Issue: No validation before passing hostname to certificate generation
- Fix: Added regex validation - 1-64 chars, alphanumeric plus dot/hyphen/underscore
- Impact: Prevents injection attacks via malicious hostnames
HIGH FIX#5: Rate Limit Bypass via Clock Manipulation
- File: routes.py
- Issue: Naive UTC datetime arithmetic vulnerable to timezone changes
- Fix: Use timezone-aware datetime with explicit UTC conversion
- Impact: Rate limit cannot be bypassed via clock manipulation
HIGH FIX#6: Hard Process Termination Loses In-Flight Work
- File: Program.cs
- Issue: Environment.Exit(0) aborted in-flight work and could corrupt state
- Fix: Graceful return from RunHostAsync() instead of Environment.Exit()
- Impact: Service exits cleanly, no lost telemetry or corrupted local state
HIGH FIX#7: Missing Database Transaction Isolation
- File: routes.py
- Issue: Race condition in concurrent renewal requests
- Fix: Added with_for_update() row-level locking for both auth paths
- Impact: Prevents concurrent renewals from bypassing rate limit
MEDIUM FIX#8: Missing Audit Logging for Failures
- File: routes.py
- Issue: Failed renewal attempts not captured in audit trail
- Fix: Added AuditLog.log() for cert generation and database update failures
- Impact: Security monitoring can detect repeated renewal failures
SECURITY HARDENING:
- Server cert validation: Never accepts certificates blindly
- Revocation enforcement: Fail closed if columns missing
- Expired cert handling: Dual-auth allows renewal while maintaining security
- Input validation: Hostname must match safe character set
- Transaction safety: Row locks prevent race conditions
- Audit trail: All failures logged for security monitoring
OPERATIONAL IMPROVEMENTS:
- Agents can now renew expired certificates (no more manual intervention)
- Graceful shutdown prevents data loss during renewal
- Rate limiting immune to timezone issues
- Comprehensive audit trail for troubleshooting
ADR COMPLIANCE:
- Maintains ADR-001 (Asset as platform contract)
- Maintains ADR-006 (Collector contract - backward compatible)
- Database naming (snake_case columns)
- No unicode/emojis
- Security best practices: ALL CRITICAL/HIGH issues resolved
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
AGENT SELF-RENEWAL:
- Agent checks certificate expiry on startup
- If < 30 days remaining, requests renewal from server
- Server generates new certificate and updates database
- Agent installs new cert, updates config, and restarts
SERVER-SIDE (/api/endpoints/certificate/renew):
- Requires authentication via existing (expiring) client certificate
- Rate limited: 1 renewal per asset per 24 hours
- Generates 2-year self-signed certificate with cryptography library
- Computes SHA1 fingerprint (matches nginx $ssl_client_fingerprint)
- Updates assets table with new fingerprint and expiration
- Returns PFX/P12 certificate with random password to agent
- Audit logs all renewal operations
AGENT-SIDE (CertificateRenewal.cs):
- CheckAndRenewIfNeeded() called on service startup before host starts
- Finds current certificate by thumbprint in LocalMachine\My store
- Checks expiry: if < 30 days, POST to /api/endpoints/certificate/renew
- Authenticates renewal request with expiring certificate (mTLS)
- Receives PFX + password in JSON response
- Imports new certificate to LocalMachine\My store
- Grants BUILTIN\Users read access to private key (for tray app)
- Updates appsettings.json with new thumbprint
- Exits with code 0 so service manager restarts agent with new cert
CERTIFICATE GENERATION (certificate_renewal.py):
- Uses cryptography library to generate RSA 2048-bit key pairs
- Self-signed certificates with CN=hostname, 2-year validity
- Extended key usage: CLIENT_AUTH
- Subject Alternative Name: hostname as DNS name
- Exports as password-protected PFX/P12 for Windows import
- SHA1 fingerprint computed from DER encoding (nginx compatible)
STARTUP FLOW:
1. Agent service starts (Program.cs RunHostAsync)
2. CertificateRenewal.CheckAndRenewIfNeeded() runs BEFORE host starts
3. If cert expires in < 30 days, requests renewal from server
4. If renewal succeeds, agent exits (Environment.Exit(0))
5. Windows Service Manager automatically restarts the service
6. New process picks up updated thumbprint from appsettings.json
7. Agent continues with renewed certificate
SECURITY:
- Renewal requires valid (not expired) existing certificate
- Rate limit prevents renewal loop abuse
- Server validates cert not revoked before renewal
- Audit logging tracks all renewal operations
- PFX password is random, used once during import, never stored
PERFORMANCE:
- Renewal check adds ~100-200ms to service startup
- Only triggers when expiry < 30 days (rare operation)
- No impact on steady-state agent performance
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
CRITICAL FIXES:
- Enforce cert authentication on collector telemetry endpoint
- Add authorization check in report_command_result (prefer cert over hostname)
- Implement server certificate pinning in C# agents (optional)
- Add constant-time fingerprint comparison to prevent timing attacks
- Validate fingerprint format (40 or 64 hex chars only)
AUTHENTICATION:
- Created shopdb/core/auth/client_cert.py with authenticate_by_client_cert()
- Extracts fingerprint from nginx header (SHA1), IIS environ, or ARR header
- Validates against assets.client_cert_fingerprint in database
- Checks certificate expiration (cert_expires_at column)
- Falls back to API key when cert not present (dual-auth during rollout)
AUTHORIZATION:
- shopdb/core/api/collector.py: Added cert auth to generic_collect() before
API key check. Sets g.cert_authenticated_asset_id when cert validates.
- plugins/endpoints/plugin.py: apply_collector_payload() compares
g.cert_authenticated_asset_id with payload's resolved assetid. Raises
PermissionError if mismatch (prevents agent A from submitting data for
asset B by changing hostname field).
- plugins/endpoints/api/routes.py: report_command_result() prefers
g.cert_authenticated_asset_id over hostname parameter. Requires either
cert or hostname for authorization.
AGENT CHANGES:
- AgentConfig.cs: Added ServerCertThumbprint config for certificate pinning
- CollectorClient.cs: Validates server cert thumbprint when configured
- CommandClient.cs: Validates server cert on WebSocket TLS
- FleetAlertClient.cs: Validates server cert on WebSocket TLS
- When ServerCertThumbprint is empty, accepts all certs (rollout compat)
REVOCATION SUPPORT:
- Added cert_revoked_at and cert_revocation_reason columns to assets table
- Migration 0022_client_cert_revocation.py adds columns
- Revocation check uses hasattr() for backward compatibility
- Revocation requires manual database UPDATE (instant kill switch)
SECURITY HARDENING:
- Constant-time fingerprint comparison via hmac.compare_digest()
- Format validation: must be 40 (SHA1) or 64 (SHA256) hex chars
- SHA1 documented as nginx limitation, acceptable for air-gapped self-signed
- Audit logging for failed authentication attempts (AuditLog.log)
DEPLOYMENT:
- Dual-auth during rollout: cert OR API key both work
- Agents without certs continue using API key (backward compatible)
- No impact on existing fleet until certs provisioned
- Rollout: server first, pilot certs, fleet rollout, retire API key
Addresses Fable security review findings: 1 critical + 5 high vulnerabilities
related to authentication bypass, authorization flaws, and timing attacks.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fix 1: Remove duplicate Setting import violating plugin contract
- plugins/endpoints/api/routes.py:3551 imported shopdb.core.models.Setting
- Setting already imported from shopdb.api at line 21
- Removed local import to comply with plugin contract (ADR-001)
- Test: test_plugins_only_import_contract_surface
Fix 2: Align installer defaults with measuringtools manifest
- measuringtools manifest declares default_enabled: true
- Installer (ShopDBFlask.iss) excluded it from PluginDefault()
- Removed measuringtools from exclusion list (now 4 excluded, not 5)
- Exclusions now match manifests: usb, employees, geenforce, printedparts
- Test: test_wizard_defaults_match_the_manifests
Historical note: measuringtools was promoted from default_enabled false to
true but the installer config was never updated to reflect this change.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Endpoints plugin now respects the site_timezone setting from /settings.
All timestamps in endpoint details, script runs, and log collections are
now converted from UTC to the configured site timezone for display.
Changes:
- Added _get_site_timezone() helper to read site_timezone setting
- Added _localize_timestamp() to convert UTC datetimes to site timezone
- Applied localization to GET /api/endpoints/<id> (last_seen, created_at)
- Applied localization to GET /api/endpoints/<id>/scriptruns (execution times)
- Applied localization to GET /api/endpoints/<id>/logcollections (collection times)
This aligns endpoints plugin with notifications and backups plugins which
already use site_timezone for their time-sensitive operations.
An operator could see that an endpoint was behind the published build but
had no way to act on it. update_app resolves against Application rows, and
the agent lives in the version store, so there was nothing to push.
Bridge the two: the staged build is packaged and registered as a reserved
Application row, and a managed PowerShell script is seeded to install it.
The endpoint downloads that package over the existing collector-authenticated
app-package route, so no parallel delivery path is introduced.
The new route prepares the delivery and then DELEGATES to
enqueue_endpoint_command as a digest-pinned run_script. It deliberately does
not implement its own enqueue: a second path would duplicate, and eventually
drift from, the endpoint lock, the Configuration Policy gate, stale-command
reaping and the at-most-one-in-flight rule. A fleet-wide agent swap is the
last action that should skip a quarantine check. Tests pin that delegation
rather than only the happy path.
Because it lands as run_script, the caller needs endpoints.runscript on top
of endpoints.agentupdate. That is intended: this delivers arbitrary SYSTEM
PowerShell by the same mechanism as any other script push. The audit event
is written only on an actual 201, so a refused push does not record an
upgrade that never happened.
Replacing the agent is self-modifying the control plane - the process
receiving the command is the one being swapped. The script therefore
re-launches itself detached and exits immediately, because the agent runs
run_script as a direct child and holds its stdout/stderr pipes while
waiting on it. Expect the command to report queued rather than success:
the process that would report completion is stopped mid-run. Judge the
result by the version the endpoint reports afterward.
Tray relaunch does not use the installer's `explorer.exe <path>` trick.
That works in install-shopdbagent.ps1 only because the installer runs as
the user - note its own `-ne "SYSTEM"` guard. This script always runs as
SYSTEM in session 0, where the hand-off reaches no shell and exits 0, so
Start-Process reported success for a relaunch that never happened. It also
cannot descend into the session with a token-duplicating launch:
UserContextSpool documents that WTSQueryUserToken is blocked by endpoint
protection on these hosts. Instead a one-shot scheduled task targets the
pinned ShopFloor account, matching how the user-context spool already
resolves that user, and the tray must run as that account or the
mapped-drive probe reports the wrong (empty) drive table. The relaunch is
verified by polling for the process rather than assumed, and stays
best-effort: the Run key still starts the tray at next logon, and a late
tray is not worth rolling back a good agent upgrade.
panel_status now carries agentbehind/agentversion/agentcurrent so the UI
gates the action on a flag rather than parsing "(behind X)" out of a label.
The button is confirmed by typing UPDATE, naming both versions and the
service interruption, matching how run_script and system_restart are gated.
Verified on live endpoints: upgraded 1.13.0.0 and 1.15.0.0 machines to
1.16.0.0. run_script delivery, digest fetch and system context all predate
1.13.0.0, so no minimum agent version is declared for this path.
Gate collect_log on a minimum agent version and bridge the agent version
store to the applications table so a staged build can actually be pushed.
update_app resolves an install source from the applications table alone; it
cannot see agentstore. A build could therefore be staged and current while no
application row existed at all, which is what the fleet was in: the agent was
unpushable. Staging is now the single source of truth - it packages the
current build and upserts the row in the same request, so the row cannot
describe a build that is no longer staged.
PARTIAL BUILD ADVISORY
This is steps 1-4 of a longer rollout and is deliberately incomplete:
- There is no push trigger yet. The registered package is resolvable and
verifiable, but nothing enqueues an upgrade; the "behind current build"
indicator is still display-only.
- The upgrade template script, its script row, and the enqueue route
(endpoints.agentupdate) are not written.
- Blocking constraint for the next slice: run_script executes
powershell.exe -File <script> with NO argument channel, so a delivered
upgrade script cannot be passed BaseUrl/CollectorKey and must either
source them itself or run_script must be extended first.
Nothing here touches a live endpoint. Staging only changes what a future
push would contain.
Details:
- Package members carry a fixed timestamp. zipfile stamps the current mtime
by default, so identical input produced a different archive digest on
every call. update_app pins a download by SHA-256, so a drifting hash
would rewrite installersha256 out from under an endpoint mid-download and
fail its verification.
- All three routes that can change 'current' re-register (upload,
adopt-legacy, rollback). Covering only upload would leave the row pinned
to the build an operator just rolled away from, and the push would ship
it anyway - silent drift.
- Registration cannot fail an upload. The binary is staged and audited by
the time it runs, so a packaging problem is reported in the response
payload rather than raised; returning an error would tell the operator a
successful upload was rejected.
- The package is written to a temp name and os.replace'd, so an interrupted
write cannot leave a truncated archive at the path the row advertises.
- The row is isinstallable but hidden: machine-managed, and it must not
appear in the software catalogue beside real licensed applications.
- Added application_package_location() to the shopdb.api contract surface
(lazy, matching collector_authorized) returning the package directory and
URL prefix together. agentregistry reached into shopdb.core internals,
violating the plugin import contract; sourcing both from one place also
stops the write location and installpath from drifting apart.
collect_log is refused at enqueue below its minimum agent version, with
numeric comparison - string ordering misplaces 1.9.0.0 against 1.16.0. The
refusal is mirrored in the UI so a command slot is not spent on a call that
is guaranteed to fail.
Test isolation: create_app('testing') resolves instance_path to the real repo
instance/ directory, so agent upload tests write into the developer's actual
store. The new tests redirect both the store and package output into tmp_path
and include a guard test that fails if that isolation regresses. NOTE: the
pre-existing test_endpoints_agentupload.py still has this leak and deposits
1.5.0.0 and 1.6.3 into instance/siteagent/ on a full run - not fixed here.
Suite: 2834 passed, 11 skipped. The two failures (test_docs_publishable, and
a symlink scan needing Windows privileges) were confirmed pre-existing by
stashing this work and reproducing them.
Log Collector: a new collect_log action reads a plaintext log file from a
known path and returns its tail for viewing and download. Rides the existing
endpoints.updateapp grant rather than inventing one, since collecting a log is
the same trust level as pushing an application. Collected logs land in their
own table with per-asset retention of 20 instead of overloading the command
audit row, and the agent refuses UNC, traversal, wildcards, directories and
non-text content independently of the server check.
collect_log is POLICY_EXEMPT. Every existing Configuration Policy predates the
action and so cannot name it, and the intersection in ALLOWED_ACTIONS_FOR drops
anything unnamed - so every policied endpoint refused log collection outright,
including for admins, who saw a permission-shaped error for a grant they held.
Exemption from policy is not exemption from permission: the elevated grant
still applies, and a regression test asserts both halves so a future edit
cannot quietly loosen the grant while "fixing" the policy gate.
Script output capture: successful runs can return output, gated per script by
captureoutput. Failures always returned it; success never did, which left a
green badge as the only evidence a run produced.
Timestamps: Activity and Operator Reports rendered date-only because the shared
formatter's 'date' branch drops the clock time. Run Scripts additionally
labels its timestamp, since a bare time meant "completed" on a terminal row but
"queued" on an in-flight one - indistinguishable, and the wrong reading.
Agent 1.16.0. Minor: collect_log is a new action name, so an older agent asked
for it fails closed on the unknown-action default.
alertscope was stored, serialised and editable in the authoring form but
never read when alerts were handed to an endpoint, so every fleet alert
went to every machine regardless of what was entered. The control implied
a capability nothing enforced.
get_fleet_alerts now resolves the polling endpoint's facts and filters
each alert through its scope. Supported keys: hostname, assettype,
computertype, location, businessunit. A key may hold one value or a list
(any match), matching is case-insensitive since these are operator-typed
names, and multiple keys narrow together.
Unrecognised keys are ignored rather than treated as match-nothing. A
typo like {"hostnam": "BAY-01"} would otherwise mute the alert fleet-wide
and look identical to a delivery failure; sending wider than intended is
the safe direction for something whose job is to warn people. A garbled
non-dict scope falls back to the whole fleet for the same reason. The
real fix for typos belongs at authoring time, not delivery.
BusinessUnit is not exported from shopdb.api, so its name is resolved by
table rather than importing the model and breaking the plugin import
contract.
Ten tests cover the narrowing cases and the fail-open behaviour.
Brings in wilm commits:
- Render script surfaces correctly in dark mode
- Stop scripts reporting success after doing nothing
- Correct two gate commands that pass without running
- Gate the script library on the grant that pushes scripts
- Let the policy list edit the rows the API accepts
- Cap plugin revision ids at the 32 chars MySQL stores
- Split shipped monitor bundle into per-monitor policies
Resolved conflict in agent ShopDbEndpointAgent.csproj by merging:
- wilm 1.14.0 script verdict and output capture features
- wj 1.13.1-1.13.5 tray coordination releases
- Bumped to 1.14.1 for coordinated agent+tray release
The wj merge brought in a half-built fleet alert feature: server-side UI
behaviour still being stood up over a delivery proof of concept. It left
five red tests. This clears them and fixes a real hole underneath.
The cross-machine guard never ran. acknowledge_fleet_alert read .assetid
off _resolve_asset_for_hostname, which returns the id itself, so the
check raised AttributeError on every call and answered 500. That guard is
what stops one endpoint acknowledging another's alert, and the collector
key is shared across the fleet, so until now any bay could clear an alert
on another machine's behalf. Compare the id directly.
Gate both alert routes the way the rest of the agent channel is gated.
They are collector-key authenticated, not JWT+role, because a shop floor
bay has no user session - the same shape as claim_next_command and
upload_operator_report. Declared in the authz allowlists with the reason,
rather than inventing a permission no agent could hold.
Settle the delivery contract ahead of the durable agent work. The poll
response is authoritative for a host: every alert that should be live on
it right now. An alert absent from the feed is the agent's instruction to
retire the pending toast, which is how a server-side close or cancel
reaches a machine already displaying one. Acknowledgement is terminal per
product decision, so a close after an ack is a no-op and the agent can
retry a queued ack safely. A toast that merely timed out is not an
acknowledgement.
Stop reaching past the plugin contract surface for the mailer. One import
was a plain redirect; the other pulled a private helper to pre-check
recipients, duplicating what send_alert already does. It also returned
early when no email recipients existed, which silently suppressed the
independent webhook leg - misconfigured SMTP was dropping Teams alerts.
Register NotificationAcknowledgement in get_models() and give the
notifications plugin ownership of notificationacknowledgements, then move
the expected head to notifications0006fleetalerts.
Add the coverage whose absence let the .assetid bug ship. Thirteen tests
over gating, authoritative-feed withdrawal, terminal and idempotent
acknowledgement, per-asset scoping, and the cross-machine guard. Five of
them fail against the pre-fix handler.
Remaining suite failures are pre-existing and local: the symlink scan
needs elevation, and the docs check wants files absent from this checkout.
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.
The explainer panel used var(--surface, #fff) and PCDetail's Run Scripts
and Corporate Tools styles used --text-primary, --text-secondary and
--bg-secondary. None of those four tokens exist in this app's theme, so
var() fell through: the explainer rendered a solid white block over the
dark background and the surrounding copy lost its intended contrast.
The real tokens are --bg-card, --text and --text-light. A fallback
colour is what made this hard to catch - it looks correct in light mode
and only misrenders on the theme the shop floor actually runs, so it
would not have shown up in authoring or in review.
Both files now carry a test asserting their styles reference only
defined tokens and contain no var(--x, #colour) fallbacks. That test
immediately caught a further dead --warning fallback in the banner.
The two long per-field explanations in the Add Script form are now
collapsed behind "How this works". The modal already scrolled, and
three paragraphs of standing prose between the inputs pushed Save off
screen, so the copy was being skimmed past rather than read.
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 the next line, and still exits zero. The account-provisioning
script reported succeeded having provisioned nothing, and because no
output was captured there was no evidence to contradict the verdict.
Scripts now carry an errormode. Strict prepends $ErrorActionPreference
= 'Stop' so the first error ends the run and the exit code means what
it says. It is opt-in per script rather than global because bodies
authored against the permissive semantics rely on them - tolerating a
failing cleanup, probing with a cmdlet that errors when a thing is
absent, looping past a bad item - and would start aborting midway,
leaving endpoints half-configured. The default is unchanged, so every
existing script keeps running exactly as before.
The prologue is applied after digest verification, so the SHA-256 still
validates the bytes the server delivered.
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 the service's pipes across the
session boundary. Output is always logged locally and returned to the
server only on failure, so routine output stays on the machine that
produced it instead of filling command history.
Also adds the optional per-script working directory, which the same
surfaces needed, and a collapsible explainer on the scripts page - the
dropdown is not self-explanatory, and an operator who does not know
about non-terminating errors has no reason to ever leave the default.
Agent 1.14.0. An older agent ignoring errormode keeps today's behaviour.
- Get pc_access_domain from settings to build full FQDN (hostname.device.geaerospace.net)
- Display FQDN in email subject and body instead of short hostname
- Fix machine_url to use actual request host instead of localhost fallback
- Remove unnecessary try-except around machine URL construction
- Device: shows only hostname (not name)
- Removed Asset Number line
- Machine#: shows only machine number as clickable link (if relationship exists)
- Keep Reason, Reported, Details, and Screenshot
- Query AssetRelationship to find if PC has a machine relationship
- Subject line now shows: "Operator Issue Report: HOSTNAME (Machine #6906)"
- Email body includes machine name and number as clickable link
- Machine info only appears if relationship exists
- Change device URL from API endpoint (/api/assets/{id}) to frontend page (/shopdb/pcs/{id})
- Add asset number to email subject line: "Operator Issue Report: {name} ({number})"
- Bump agent to 1.13.5 and tray to 1.6.6 for coordinated release
- Log when function is called with report details
- Log SMTP config and recipients being used
- Log before and after email send attempt
- Change warning to error with full traceback for failures
SECURITY FIXES:
- Escape all user-controlled values (hostname, reason, details) to prevent XSS
- Fix endpoint name from 'assets.detail' to 'assets.get_asset'
- Fix fallback URL to be absolute instead of relative path
- Remove render_email() to avoid subject duplication in email body
- Check recipients exist before expensive HTML generation
- Build email template directly with proper escaping throughout
- Use render_email() to wrap HTML in proper email template
- Add error handling and logging for URL generation
- Add log statement before sending to aid debugging
- Import render_email at module level
- Send alert to default alert recipients when operator submits issue report
- Email includes device link, hostname, asset tag, reason, details, and timestamp
- Screenshot link included when available
- Uses existing send_alert() mailer infrastructure
The script library was gated on applications.*, so anyone holding the
software-delivery grant could rewrite the bytes of a script that a separately
trusted operator later pushed to a bay. The push was gated on
endpoints.runscript; the CONTENT of what got pushed was not. An installer is a
vendor artefact, but a script here is arbitrary PowerShell this site wrote that
the agent runs as SYSTEM, so the write and the push must require the same
trust.
All six script routes now require endpoints.runscript, including the body
download - reading the bytes is not a lesser act than listing them. Core names
the permission as a string rather than importing it from the endpoints plugin
so the module stays importable with the plugin absent.
This tightens access and there is deliberately no backfill: the operator scope
reaches applications.create/edit, so operator-scope holders lose the library.
Granting them endpoints.runscript on upgrade would undo the change and hand
every operator remote SYSTEM code execution, since the same grant gates the
push. A test pins that no prebuilt scope confers it.
Adds the System Scripts settings page and card. The route requires the
permission rather than admin, because the grant is meant for non-admin
operators and the sibling admin gate would lock them out of the library the
grant exists for. The settings icon map gains terminal and key; the agent-key
card had been asking for key since it shipped and silently drawing a puzzle
piece.
The library had no test coverage before this.
Rolling the containers failed on strict MySQL: the endpoints chain could not
stamp its new head with "Data too long for column version_num". Plugin version
tables declare version_num VARCHAR(32) and the new revision id was 33 chars.
Nothing in the suite caught it. The tests run on SQLite, which does not enforce
declared column widths, so an over-long id passes locally and fails only on a
real deploy - after the migration body has run and only the stamp fails. The
rollback was clean here, but the failure lands at the worst possible point.
Shorten the id and assert the limit in the test that already parses every
plugin revision, so the next over-long id fails on a laptop instead of during
a roll. Core is deliberately not covered: its alembic_version is VARCHAR(128),
and it already carries ids longer than 32 that are correct.
The product shipped one Configuration Policy carrying every shopfloor app
monitor, wrapped in a shipped Site Policy that applied it to every endpoint.
That bundled two decisions a site cannot separate: which monitors exist, and
which endpoints get them. A site that wanted eDNC on one PC type and MTConnect
on another had to delete the shipped policy and rebuild both halves.
Ship one Configuration Policy per monitor with no PC type attribution, and stop
shipping a Site Policy at all. Composition is a site decision; the product now
supplies parts, not an assembled opinion.
Shipped Configuration Policies become editable and deletable. Provenance stays
a source tag rather than a lock, which is safe because seeding is insert-if-
absent and no reconciling seeder exists, so a site edit is never stomped. The
stale comments claiming shipped rows were read-only and reconciled described
behavior the code never had.
Operator report screenshots are an uncontrolled capture of whatever was
on the operator's screen when they filed the report, so they can carry
part numbers, customer names, or other shopfloor content. The rest of
endpoints.view is uniform machine-collected telemetry. Reading a PC's
CPU load does not imply a right to read someone's screen, so the reports
and their images move to endpoints.operatorreports.
The image route is the real control point, not the panel: the panel only
decides whether a tab is drawn, while the bytes stay reachable by URL to
anyone who can guess an id. Both GET routes now require the new grant.
Migration 7d47 backfills the grant to everyone holding endpoints.view so
nobody loses a panel they were already using; tightening from there is a
per-account decision in the user editor. The grant is in the scope
denylist, so it is a per-user direct grant and Reporter cannot hold it.
Also adds test_user_editor_checkboxes_match_the_server_action_list. The
SCOPE_ENDPOINT_ACTIONS comment claimed a sync guard kept the Vue
checkbox list aligned with the Python tuple, but no such test existed.
The lists had already drifted: endpoints.operatorreports reached a
running container with no way to grant it through the UI. The guard
catches drift in both directions - a name only in Python ships the
feature dark, and a name only in Vue is worse, since the admin ticks a
box and believes access was granted while the server refuses to store it.
Soft delete left orphaned executables on disk and let a deleted name collide
with a fresh import, so delete is now a hard delete that removes the
associated files. The destructive paths (delete, revert-to-record) require a
typed OK because neither can be undone.
Installers keep their filename at import and nest per application/version,
which stops repeat imports doubling up. Version now comes from the same PE
detection the endpoints agent uses; when no version can be read the caller is
asked for one rather than silently storing a blank.
Applications also surface Version and a monitorable toggle, with launch path
and process name required only while monitorable is ticked. Fleet shows the
PC alias, matching PC Details.
The delivery test bench and its notes were scaffolding for validating the software-delivery path during development. That work is complete and covered by tests, so the bench is removed along with its route registration rather than left in the shipped UI where it would read as a supported feature.
Endpoint locks
--------------
Adds EndpointLock so an admin can hold an endpoint against automated and
operator action, with migration 0018 and enforcement in the enqueue path
rather than only in the UI, so a lock cannot be bypassed by calling the
API directly.
Split endpoints.deploy
----------------------
endpoints.deploy bundled three actions of very different risk behind a
single name:
scheduledtask_run -> starts a task already registered and allowlisted
update_app -> runs a pinned installer
run_script -> arbitrary code as SYSTEM unless the descriptor
pins a user context
Because they shared one grant there was no way to let an operator push an
application update without also handing them arbitrary SYSTEM code
execution on every endpoint they could reach. The permission name
described the lightest of the three while silently carrying the heaviest.
commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own
permission (endpoints.corptools, endpoints.updateapp,
endpoints.runscript). The enqueue route reads that table, so no route
logic changed. Migration 7d42 preserves existing access exactly: anyone
holding endpoints.deploy receives all three replacements.
Fleet ranked endpoints by raw critical-event counts, so a machine with a
noisy but harmless event log outranked one with a mapped drive down or a
blocking service stopped. Volume is not severity, and the aggregation ran
across the whole fleet on every page load.
Rank operational blockers above degraded conditions above event noise, and
cache the verdict per asset so Fleet reads a row instead of recomputing 48h
of history. Transitions are written only when a verdict changes, capped at
50 rows per asset.
Absence of a policy binding must never suppress a verdict: drives and events
are not policy-gated, and services fall back to a built-in default impact
when no mapping applies. Event 1001 drops to informational because the WER
bucket pairs with 1000 and double-counted every crash.
Parallelize the Fleet first-paint loaders that were awaiting serially.
Both agent loops made their first request the instant the service
started, and the service is installed start=auto - so a patch reboot put
the whole fleet on the wire together, twice per machine (a telemetry
report and a command poll). The existing jitterseconds does not help: it
spreads the gap between runs, not first contact.
Measured on a 700-endpoint burst: simultaneous arrival drove TLS
handshakes to 1482ms worst case and pinned CPU at 305% while the
database sat idle at 3 active queries. Spreading the same arrivals over
30s cut worst-case handshake to 63.7ms with no server-side change. The
server was never the constraint; simultaneity was. Raising the
SQLAlchemy pool was tried and rejected - it moved the queue from
SQLAlchemy into MySQL without adding throughput.
StartupJitter is shared by both workers so the clamp and the
randomisation cannot drift apart between them. Randomised in
milliseconds rather than whole seconds because a 30s window at second
granularity re-clumps a large fleet into 30 buckets of simultaneous
arrivals - the same pile-up in miniature. Clamped to 300s and warned
about when out of range: the command channel is how an operator would
correct a bad value, so an unbounded delay would remove the means of
recovery.
The cost is up to startupjitterseconds of extra latency before a
rebooted endpoint picks up a queued command, on top of a reboot the
machine was already unreachable for. The once-only console switch
bypasses the delay entirely, so smoke tests stay instant.
Agent 1.12.0. Minor because the appsettings schema gains a key; packages
without it fall back to the AgentConfig default. The shipped installer
template in siteagent.py carries the new key, so the containers need
rolling before newly built packages pick it up.
The claim poll holds a connection open server-side. Under gthread that
pinned one of a worker's 8 OS threads, so 14 agents at hold=25 left a
worker with 8 of 9 threads parked in timed sleep and UI requests hashed
to that worker waiting 12-24s. Hold was cut to 5s as an interim fix.
gevent removes the constraint rather than working around it: a held
connection is a greenlet and the per-worker ceiling is worker-connections
(2000), not 8 threads, so an idle hold no longer costs a UI request
anything. Measured at 700 simulated agents plus UI, integration and kiosk
traffic: zero errors, UI median 7.5ms, peak RSS 479MB. CPU is the binding
resource, not memory or DB connections.
With that gone the 5s hold is a liability. Hold length sets the fleet's
request rate, not its concurrency, because the agent re-polls immediately
on a 204 - so hold=5 is 5x the requests of hold=25, against a burst
bottleneck that measured as TLS handshake CPU. 25 also restores the
margin against the agent's MinIdlePollDuration=5s floor used to tell a
normal hold-window close apart from a transient failure; at 5 it was
~20ms.
Timeout raised to 60 so gunicorn cannot kill a healthy long-poll
mid-hold, and the access log now carries request duration - without it a
slow request is indistinguishable from a fast one and latency has to be
reconstructed from outside the container. Healthcheck moved off
POST /api/auth/login, which logged a 400 every 30s and exercised the
credential path from a probe.
Corporate Tools tab on PC Details, gated by endpoints.deploy and liveness.
Starts already-registered scheduled tasks; the agent's ScheduledTaskPolicy
allowlist remains the authority on what may actually run.
Dell Command | Update split into two actions (Scan, Apply Updates) because
they have very different consequences. Apply Updates carries a persistent
breakglass warning in the card naming Tanium as the primary update utility,
rendered between the row title and description so it is visible at all times
rather than only in the confirm dialog. Destructive actions keep the typed
RUN confirmation.
Fix scheduledtask_run being stripped by Configuration Policy: the
intersection in ALLOWED_ACTIONS_FOR dropped it on any endpoint whose
effective policy was non-empty, so every Corporate Tools click failed with
"not permitted by policy" regardless of the caller's role. Corporate tools
are role-gated only, via POLICY_EXEMPT_ACTIONS; policy keeps authority over
monitors and service/process primitives.
Fix blank PC Details page: watch(detailtabs) evaluates its source during
setup and read canDeploy before its const declaration, throwing a TDZ
ReferenceError that aborted setup with no API request made.
Mapped-drive refresh moves to the tray. SYSTEM cannot probe user-scoped
drives, so the service requests a re-probe over a named event and waits for
the spool collectedat stamp to advance before collecting. The probe task is
registered as a ShopFloor user principal; the Users-group principal remains
only as a degraded fallback since a group member cannot start it.
Placed between Health and Logged-in User on /endpoints. The main agent
version, not the tray: both ship side by side in the payload as
agentversion and trayversion, so the wrong one is an easy pick.
Frontend only. EndpointState.to_dict() has carried agentversion all
along, so the fleet list API was already sending the field and nothing
rendered it.
Also bumped the empty-state colspan from 9/8 to 10/9. It is hardcoded
rather than derived from the header count, so it does not track a new
column on its own and silently under-spans the "No endpoints found" row
if missed.
Mapped drives reported Unavailable on endpoints where the operator could
see them fine. The data path was intact; the collection was running under
the wrong token. Mapped drives are per logon token, so an elevated probe
enumerates a different drive table than the interactive user.
Collection:
- Pin the user-context spool path so the probe and service agree on one
location, and persist/overwrite it instead of deleting on read.
- Refuse to collect while elevated (exit code 2) rather than emitting a
confident wrong answer, and stamp elevated/username on the spool.
- Reject spools stamped elevated or from an unexpected account on read,
so an admin run cannot masquerade as valid.
- Log netuse/smbmapping/userlistener counts so each run leaves evidence
of what it actually saw.
Install and launch context:
- Move collection to a 'ShopDB user-context probe' scheduled task under
S-1-5-32-545 at RunLevel Limited, the only mechanism that reliably runs
in the operator's own session. Verify registration and record it in the
install ledger.
- Stop starting the tray as a child of the elevated installer; explorer.exe
de-elevates without changing ACCOUNT, so an admin install produced an
admin tray. The Run key only fires at logon, which left the tray dead on
a machine already signed in, so add a transient 'ShopDB tray first launch'
task that starts it once in the existing session and unregisters itself.
- Hide the console window during --collect-usercontext. The agent is a
console exe; the tray used to mask this with CreateNoWindow, but the
scheduled task invokes it directly and flashed a window every cycle.
Installer edits are in _install_script() in plugins/endpoints/api/siteagent.py,
which generates the install.ps1 that actually ships. The standalone
agent/installer/install-shopdbagent.ps1 was never shipped once the package
moved to the UI builder and is deleted here to stop the divergence that
caused an earlier fix to land in a file no endpoint ever ran.
Versions: agent 1.10.1, tray 1.3.0. These share the pinned spool path and
must be deployed together.
Pre-existing test failures unrelated to this work (8, identical on a clean
tree): docs publishable/versions, plugin contract symlink scan, import
guard tamper check, security headers upload sandbox, and three endpoint
timestamp tests.
Details tab card layout
-----------------------
The Details tab sat on .content-grid, a two-column CSS grid. Grid packs
items into implicit rows, so every row is as tall as its tallest card and
align-items:start leaves the shorter one with dead space beneath it:
Warranty is short, Endpoint Status is tall, and Policy had to wait for the
taller of the two rather than sliding up. That read as a staggered layout.
Switched that one tab to multicol via a .details-flow modifier, which has no
rows at all - each card starts where the previous one ended, so the holes
close while cards still size to their content.
Multicol is deliberately NOT applied to .content-grid globally. The note by
that rule in style.css records why it was abandoned there: a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column when the
two are unequal. That cannot happen on this tab - every panel in the details
group is a keyvalue or list render, and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in the subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.
The modifier is scoped to PCDetail, so the nine other detail views on
.content-grid are untouched.
Chosen over JS masonry on purpose. Masonry packs perfectly but has to
measure every card and re-measure on resize, on panel collapse and on every
async panel load - and these panels fetch after paint, so it would mean
continuous forced reflow on a page that already polls. This is pure CSS:
no measurement, no observer, no runtime cost. The tradeoff accepted is that
column bottoms can end at slightly different heights.
Cards carry break-inside: avoid so none fragments across the column break,
and the 25px spacing returns as a margin because multicol has no gap between
stacked items the way grid does.
IP Configuration card
---------------------
Removed: the data has no purpose on the page anymore. Taken out at the root
rather than hidden - the panel declaration, the /asset/<id>/ipconfig route
and the _IPCONFIG_COLUMNS constant all go, since a route with no consumer is
dead surface that still answers requests.
test_the_two_cards_do_not_duplicate_columns_beyond_the_address existed only
to police the history/config column split; with one card gone it guards
nothing, so it and its import go with it. The panel-count assertion in
test_endpoints_asset_panels_are_computer_scoped moves 15 -> 14, which is the
test doing its job and catching the removal.
IP History is unchanged and still carries the address list.
Not addressed here: three tzset() tests in test_endpoints_plugin.py fail on
Windows because time.tzset() is Unix-only. Verified pre-existing against
unmodified code; they pass in the Linux container.
Mapped drives stopped reporting because the user-context path failed silently
in three separate places, and none of them left a trace to read back.
- Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running
powershell.exe -File. A missing or quarantined script file fails BEFORE any
statement runs, so the script's own try/catch never sees it and the only
symptom is exit -196608.
- Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken
is privileged, endpoint protection blocks it, and on failure the tray
(writer) and service (reader) silently disagree about the path.
- Spool: consume after a successful parse, so a stopped tray cannot keep
re-reporting stale mapped drives as current. Unparseable files are left in
place for the next cycle.
- Logging: user-session components cannot write to Program Files or to the
SYSTEM-owned ProgramData log directory, and the file sink degrades to a
silent no-op when it cannot create its directory. Tray and probe now log to
C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the
sink records a SinkProblem instead of swallowing UnauthorizedAccessException.
- Installer: provision the operator spool/log directories with ShopFloor:Modify
before the tray first runs, and stop the transcript via trap so a throw
cannot orphan exactly the failed install worth reading.
- Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was
rejected on SIZE, but the size check returns before the PE is parsed, so the
operator only ever saw a version-detection failure.
AGENT-CONTRACT.md gains a normative section defining the three security
contexts, their log destinations, the fixed spool path and consume-on-read.
Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is
identifiable from a log line.
asset_panels() rewrites any group missing from ASSET_PANEL_GROUPS to 'details' before the client sees it, and Phase 3 never added 'serviceprocess' there. Both panels were relabelled on the wire, so the tab could not appear no matter how consistent the frontend was. That is why the tab was still missing after the schema fix - a second, unrelated cause wearing the same symptom.
Operator Reports moves out of System Monitors into a tab of its own: that tab is machine-collected health, this is a person reporting something, and the thumbnail grid dwarfed the fact cards it sat between. Full width, since it is alone in the tab.
panelGroups.spec.js claimed to cover four files and checked two; it now reads the server tuple as well. The plugin test's hand-copied set was a fifth copy and now derives from the server tuple instead.
Mapped drives existed only as a comma-joined line in the user context card, which is fine for three drives and unreadable for a dozen. They now get their own list card in Monitors: one row per letter, remote path as meta, health as a badge. Reuses the statecolor/levels vocabulary the tracked-services cards already declare rather than new CSS. Only unhealthy drives are coloured. Green on every working drive makes the broken one just another badge in a row of badges, and the card exists to make the exception findable at a glance. Unhealthy rows sort first for the same reason - a user with a dozen mappings should not hunt for the broken one - and the sort is on health alone, so drive-letter order survives within each group and the list does not reshuffle between refreshes. The merge logic is untouched. _mapped_drive_rows now returns structured rows and the user-context summary line is built from them, so the card and the line cannot drift apart about a drive's status. The two silent-failure paths are mutation-tested: forcing every badge green failed 7 tests, and the first sort test passed a mutation that added a secondary key because every broken drive in it said the same word - it now uses statuses that would reorder under a text sort. Also corrects a stale warning claiming the merge is untested; it has been covered since Phase 1.
Volume capacity and physical drive health were two adjacent cards, and the operator reading a full disk is usually the operator reading a dying disk in the same minute. They are now one Storage card with two sections. Visual grouping only - the two tables share no key, storage being per volume and health per physical device, and one drive can back several volumes or none the operator sees. Nothing is reconciled across the sections; this is not the mapped-drive merge where two sources described the same drive and the worse status won. The tabs render type was the only sections-in-one-card mechanism and emitted label/value fields with no table path, so it now takes table sections too. That is the renderer every plugin uses, so both behaviour changes in it are mutation-tested. Two things only surfaced on contact: the empty-section filter had to grow a table branch, because a table section declares columns up front and dropping it when it has no rows takes its tab with it and reads as no disks rather than nothing reported; and full-width span was keyed off the declared render type, but a tabs panel holding a six-column table needs the same span and that is only knowable from fetched data. The diskhealth route stays. It no longer backs a panel, but it is a stable read for callers outside the renderer and removing it for a UI-only change would break them.
Tracked Services and Tracked Processes were sharing a tab with system health cards, so what is running on the box and how the box itself is doing read as one list. They now sit in a Service/Process Monitors tab and the old tab is System Monitors. The group vocabulary is duplicated across the renderer, the tab map and the panel declarations with nothing checking they agree, and a group named in one but not the others is dropped in silence - that has eaten cards twice. Tests now pin the vocabulary on both sides. Boot type and NIC link state were collected in Phase 2 but shown nowhere. Boot type only renders when fast startup applies, because uptime measured from a resume understates how long the kernel has been up and that is the case worth naming. Link state leaves a disconnected NIC unknown rather than half duplex: live hardware returns empty speed and duplex when unplugged, and half duplex is the cable fault signature this column exists to catch.