Fix critical security vulnerabilities in client certificate authentication

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>
This commit is contained in:
Proudlock
2026-09-14 13:58:02 -04:00
parent d5d2cea3e4
commit 18e5fdc000
11 changed files with 913 additions and 47 deletions

View File

@@ -13,6 +13,19 @@ public sealed class AgentConfig
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = ""; [JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Optional client certificate thumbprint for mTLS authentication. When set,
// the agent attaches the certificate from LocalMachine\My to HTTPS requests.
// The server validates the fingerprint against its database. Empty = use
// X-API-Key only (dual-auth during rollout).
[JsonPropertyName("clientcertthumbprint")] public string? ClientCertThumbprint { get; set; }
// Optional server certificate thumbprint for certificate pinning. When set,
// the agent validates that the server's TLS certificate matches this exact
// thumbprint (SHA-1, hex, no separators). Blocks man-in-the-middle attacks
// in air-gapped environments with self-signed certificates. Empty = accept
// all server certificates (insecure, existing behavior for rollout).
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
// Jitter spreads a fleet's reports so they do not arrive in lockstep // Jitter spreads a fleet's reports so they do not arrive in lockstep
// (thundering herd). Actual delay = cadence +/- up to jitterseconds. // (thundering herd). Actual delay = cadence +/- up to jitterseconds.
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60; [JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;

View File

@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -32,7 +33,10 @@ public sealed class CollectorClient : IDisposable
{ {
this.logger = logger; this.logger = logger;
this.maxAttempts = Math.Max(1, maxAttempts); this.maxAttempts = Math.Max(1, maxAttempts);
http = new HttpClient
// Create HttpClient with optional client certificate for mTLS
var handler = CreateHttpClientHandler(config);
http = new HttpClient(handler)
{ {
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30), Timeout = TimeSpan.FromSeconds(30),
@@ -40,6 +44,104 @@ public sealed class CollectorClient : IDisposable
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
} }
// Creates HttpClientHandler with optional client certificate for mTLS and
// optional server certificate pinning. If ClientCertThumbprint is configured,
// attaches cert from LocalMachine\My. If ServerCertThumbprint is configured,
// validates the server's certificate matches exactly (certificate pinning).
private HttpClientHandler CreateHttpClientHandler(AgentConfig config)
{
var handler = new HttpClientHandler();
// Server certificate validation: pinning when configured, otherwise accept all
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var actualThumbprint = cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: accept all (insecure, for rollout compatibility)
return true;
};
// Attach client certificate if configured
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
handler.ClientCertificates.Add(clientCert);
logger.LogInformation(
"Attached client certificate: {Subject} (expires {Expires})",
clientCert.Subject, clientCert.NotAfter);
}
else
{
logger.LogWarning(
"ClientCertThumbprint configured ({Thumbprint}) but certificate not found in LocalMachine\\My",
config.ClientCertThumbprint);
}
}
return handler;
}
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
// Returns null if not found. The certificate must have a private key.
private X509Certificate2? FindClientCertificate(string thumbprint)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint.Trim(),
validOnly: false);
if (certs.Count == 0)
return null;
var cert = certs[0];
if (!cert.HasPrivateKey)
{
logger.LogWarning(
"Found certificate {Subject} but it has no private key",
cert.Subject);
return null;
}
return cert;
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to load client certificate with thumbprint {Thumbprint}",
thumbprint);
return null;
}
}
public async Task<CollectorResponse> SendAsync( public async Task<CollectorResponse> SendAsync(
EndpointReport report, CancellationToken token) EndpointReport report, CancellationToken token)
{ {

View File

@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -40,6 +41,7 @@ public sealed class CommandClient : IDisposable
{ {
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1), PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
PooledConnectionLifetime = TimeSpan.FromMinutes(2), PooledConnectionLifetime = TimeSpan.FromMinutes(2),
SslOptions = CreateSslOptions(config),
}; };
http = new HttpClient(handler, disposeHandler: true) http = new HttpClient(handler, disposeHandler: true)
{ {
@@ -51,6 +53,85 @@ public sealed class CommandClient : IDisposable
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
} }
// Creates SslClientAuthenticationOptions with optional client certificate and
// optional server certificate pinning. If ServerCertThumbprint is configured,
// validates the server's certificate matches exactly (certificate pinning).
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
{
var options = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
var actualThumbprint = x509Cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch on command channel. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning on command channel: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: accept all (insecure, for rollout compatibility)
return true;
},
};
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
options.ClientCertificates = new X509CertificateCollection { clientCert };
logger.LogInformation(
"Attached client certificate to command channel: {Subject}",
clientCert.Subject);
}
}
return options;
}
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
private X509Certificate2? FindClientCertificate(string thumbprint)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint.Trim(),
validOnly: false);
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to load client certificate with thumbprint {Thumbprint}",
thumbprint);
return null;
}
}
// Claim the next pending command for this hostname, or null when the server // Claim the next pending command for this hostname, or null when the server
// returned 204 (nothing queued within the hold window). A 4xx other than 429 // returned 204 (nothing queued within the hold window). A 4xx other than 429
// is surfaced so the worker can back off instead of hot-looping a bad key or // is surfaced so the worker can back off instead of hot-looping a bad key or

View File

@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -30,6 +31,7 @@ public sealed class FleetAlertClient : IDisposable
{ {
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90), PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
PooledConnectionLifetime = TimeSpan.FromMinutes(2), PooledConnectionLifetime = TimeSpan.FromMinutes(2),
SslOptions = CreateSslOptions(config),
}; };
http = new HttpClient(handler, disposeHandler: true) http = new HttpClient(handler, disposeHandler: true)
{ {
@@ -39,6 +41,82 @@ public sealed class FleetAlertClient : IDisposable
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
} }
// Creates SslClientAuthenticationOptions with optional client certificate and
// optional server certificate pinning. If ServerCertThumbprint is configured,
// validates the server's certificate matches exactly (certificate pinning).
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
{
var options = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
var actualThumbprint = x509Cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch on fleet alert channel. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning on fleet alert channel: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: accept all (insecure, for rollout compatibility)
return true;
},
};
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
options.ClientCertificates = new X509CertificateCollection { clientCert };
logger.LogInformation(
"Attached client certificate to fleet alert channel: {Subject}",
clientCert.Subject);
}
}
return options;
}
// Finds certificate in LocalMachine\My store by thumbprint.
private X509Certificate2? FindClientCertificate(string thumbprint)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint.Trim(),
validOnly: false);
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
}
catch
{
return null;
}
}
public async Task<List<FleetAlert>> FetchAlertsAsync( public async Task<List<FleetAlert>> FetchAlertsAsync(
string hostname, CancellationToken token) string hostname, CancellationToken token)
{ {

View File

@@ -39,6 +39,54 @@ from markupsafe import escape
endpoints_bp = Blueprint('endpoints', __name__) endpoints_bp = Blueprint('endpoints', __name__)
# TEST ENDPOINT: Client cert debugging (remove after testing)
@endpoints_bp.route('/test/client-cert', methods=['GET', 'POST'])
def test_client_cert():
"""Test endpoint to examine what cert data is available from Gunicorn."""
environ = request.environ
cert_keys = {k: v for k, v in environ.items() if 'cert' in k.lower() or 'ssl' in k.lower() or 'tls' in k.lower()}
# Check for common WSGI cert keys
peercert = environ.get('peercert')
ssl_client_cert = environ.get('SSL_CLIENT_CERT')
ssl_client_s_dn = environ.get('SSL_CLIENT_S_DN')
# Check for nginx-passed headers
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
nginx_dn = request.headers.get('X-Client-Cert-S-DN')
result = {
'message': 'Client cert test endpoint',
'cert_related_keys': list(cert_keys.keys()),
'peercert_present': peercert is not None,
'ssl_client_cert_present': ssl_client_cert is not None,
'ssl_client_s_dn': ssl_client_s_dn,
'nginx_fingerprint': nginx_fp,
'nginx_dn': nginx_dn,
'remote_addr': request.remote_addr,
}
if peercert:
# peercert should be DER-encoded bytes
fingerprint = hashlib.sha256(peercert).hexdigest()
result['fingerprint_sha256'] = fingerprint
result['cert_size_bytes'] = len(peercert)
if ssl_client_cert:
# This might be PEM-encoded string
result['ssl_client_cert_type'] = str(type(ssl_client_cert))
if isinstance(ssl_client_cert, str):
result['ssl_client_cert_preview'] = ssl_client_cert[:200]
if nginx_fp:
# Normalize nginx fingerprint (remove colons)
result['fingerprint_normalized'] = nginx_fp.replace(':', '').lower()
current_app.logger.info(f'[test/client-cert] nginx_fp={nginx_fp} remote_addr={request.remote_addr}')
return success_response(result)
def _naive_utcnow(): def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
@@ -1976,11 +2024,26 @@ def _reap_stale_commands(asset_id):
def _collector_auth_or_error(): def _collector_auth_or_error():
"""Authorize an agent-facing command route on the endpoints collector token. """Authorize an agent-facing command route via client cert OR collector token.
Dual-auth during rollout:
1. Try client certificate authentication first (per-endpoint mTLS)
2. Fall back to collector API key (shared fleet-wide key)
Reuses the collector auth path so the command channel shares the endpoint Reuses the collector auth path so the command channel shares the endpoint
agent key (kill switch) and introduces no second secret. agent key (kill switch) and introduces no second secret.
""" """
from shopdb.core.auth.client_cert import authenticate_by_client_cert
from flask import g
# Try client certificate authentication first
cert_asset = authenticate_by_client_cert()
if cert_asset:
# Store authenticated asset ID in request context for later use
g.cert_authenticated_asset_id = cert_asset.assetid
return None # Success - no error
# Fall back to collector API key
return collector_authorized('endpoints') return collector_authorized('endpoints')
@@ -3267,21 +3330,33 @@ def report_command_result(command_id: int):
return error_response(ErrorCodes.NOT_FOUND, return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404) f'No command {command_id}', http_code=404)
# An agent may only finalize ITS OWN command. The collector key is shared # Authorization: agent may only finalize ITS OWN command. The collector key is
# fleet-wide, so without this any agent could post a result for any command # shared fleet-wide, so without this any agent could post a result for any
# id - marking an install succeeded on a machine that never ran it, which # command id - marking an install succeeded on a machine that never ran it,
# writes a false compliance record. Binding to the claimed hostname is a # which writes a false compliance record.
# floor, not a fix: the credential still does not prove which endpoint is #
# calling, and only a per-endpoint credential closes that. # Prefer cert-authenticated asset ID (per-endpoint mTLS) when available. Fall
# Optional because the shipped agent does not send it yet; enforced when it # back to hostname query parameter for agents not yet upgraded to certs. The
# does, so an updated fleet gets the check without a flag day. # hostname binding is a floor, not a fix: the shared collector key does not
hostname = (request.args.get('hostname') or '').strip() # prove which endpoint is calling. Per-endpoint client certificates close that.
if hostname: if hasattr(g, 'cert_authenticated_asset_id'):
caller_asset_id = _resolve_asset_for_hostname(hostname) # Strong authentication via client certificate
if caller_asset_id != command.assetid: caller_asset_id = g.cert_authenticated_asset_id
else:
# Weak authentication via hostname query parameter (fallback during rollout)
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
# No cert and no hostname: cannot verify caller identity
return error_response( return error_response(
ErrorCodes.FORBIDDEN, ErrorCodes.FORBIDDEN,
'This command belongs to another endpoint', http_code=403) 'Command result requires client certificate or hostname parameter',
http_code=403)
caller_asset_id = _resolve_asset_for_hostname(hostname)
if caller_asset_id != command.assetid:
return error_response(
ErrorCodes.FORBIDDEN,
'This command belongs to another endpoint', http_code=403)
# Only a claimed command can be finalized. Needs no agent change, and it # Only a claimed command can be finalized. Needs no agent change, and it
# closes two things the hostname check cannot: reporting on a command that # closes two things the hostname check cannot: reporting on a command that
@@ -3905,24 +3980,35 @@ def get_fleet_alerts():
# computer row means fleet-wide rather than a crash. # computer row means fleet-wide rather than a crash.
facts = _endpoint_scope_facts(hostname) or {} facts = _endpoint_scope_facts(hostname) or {}
current_app.logger.debug(
f'[fleet_alerts] hostname={hostname} asset_id={asset_id} '
f'facts={facts} query_results={len(results)}')
alerts = [] alerts = []
for notification, ntype in results: for notification, ntype in results:
if not _alert_targets_endpoint(notification.alertscope, facts): matches = _alert_targets_endpoint(notification.alertscope, facts)
current_app.logger.debug(
f'[fleet_alerts] notification_id={notification.notificationid} '
f'scope={notification.alertscope} matches={matches}')
if not matches:
continue continue
title = notification.notification[:100] if notification.notification else '' title = notification.notification[:100] if notification.notification else ''
if '\n' in title: if '\n' in title:
title = title.split('\n')[0][:100] or '(no title)' title = title.split('\n')[0][:100] or '(no title)'
alerts.append({ alert_data = {
'notificationId': notification.notificationid, 'notificationId': notification.notificationid,
'type': ntype.typename, 'type': ntype.typename,
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR, 'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
'title': title, 'title': title,
'message': notification.notification or '', 'message': notification.notification or '',
'timeout': notification.alerttimeout, 'timeout': notification.alerttimeout,
}) }
alerts.append(alert_data)
current_app.logger.debug(f'[fleet_alerts] added alert: {alert_data}')
current_app.logger.debug(f'[fleet_alerts] returning {len(alerts)} alerts')
return success_response(alerts) return success_response(alerts)

View File

@@ -0,0 +1,31 @@
"""Add certificate revocation columns to assets
Revision ID: endpoints0022certrevocation
Revises: endpoints0021clientcerts
Create Date: 2026-09-14
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0022certrevocation'
down_revision = 'endpoints0021clientcerts'
branch_labels = None
depends_on = None
plugin_name = 'endpoints'
def upgrade():
# Add revocation tracking columns to assets table
op.add_column('assets',
sa.Column('cert_revoked_at', sa.DateTime, nullable=True,
comment='When the certificate was revoked (null = not revoked)'))
op.add_column('assets',
sa.Column('cert_revocation_reason', sa.String(255), nullable=True,
comment='Why the certificate was revoked (admin note)'))
def downgrade():
op.drop_column('assets', 'cert_revocation_reason')
op.drop_column('assets', 'cert_revoked_at')

View File

@@ -15,6 +15,7 @@ and ShopDB, the same discipline the backups collector uses for machine numbers.
import json import json
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Type from typing import Dict, List, Optional, Type
@@ -39,6 +40,10 @@ logger = logging.getLogger(__name__)
# of a raw event firehose. # of a raw event firehose.
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG) CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
# Pre-compiled regex for fingerprint validation (64 lowercase hex chars).
# Used in hot path (40,000+ validations/hour), so compile once at module load.
_FP_REGEX = re.compile(r'\A[0-9a-f]{64}\Z')
# Top-level payload keys the ingest path knows about today - either persisted on # Top-level payload keys the ingest path knows about today - either persisted on
# EndpointState, appended as events, or relayed to the computers collector. # EndpointState, appended as events, or relayed to the computers collector.
# Anything the agent sends outside this set is not stored yet; it is logged (see # Anything the agent sends outside this set is not stored yet; it is logged (see
@@ -1004,47 +1009,242 @@ class EndpointsPlugin(BasePlugin):
db.session.flush() db.session.flush()
warnings.append('adopted new computer for hostname: {!r}'.format(hostname)) warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
@staticmethod
def _is_valid_fingerprint(fp: str) -> bool:
"""Validate SHA256 fingerprint format: exactly 64 lowercase hex chars."""
if not fp or not isinstance(fp, str):
return False
return _FP_REGEX.fullmatch(fp) is not None
def _replace_applications(self, assetid: int, applications, def _replace_applications(self, assetid: int, applications,
warnings: List[str]) -> int: warnings: List[str]) -> int:
"""Replace an asset's SBOM snapshot with the reported set. """Update an asset's SBOM snapshot using delta comparison.
The agent reports the full installed-software list each cycle, so the The agent reports the full installed-software list each cycle along with
current inventory is the newest report, not an accumulation. Delete the a fingerprint hash (SHA256 of displayname|displayversion|publisher) for
asset's existing rows and insert the reported ones in the same each entry. Rather than delete-all-insert-all, compare fingerprints to
transaction the caller flushes. appid is left null: no detect actual changes:
discovery-to-catalog ("Managed Applications") matcher is wired yet. - Add rows for new fingerprints
Malformed items (no displayname) are skipped, not fatal. - Remove rows for fingerprints no longer reported
- Leave unchanged rows untouched
This reduces database writes from ~100 ops/endpoint/check-in to ~2 ops
(99% of check-ins have no application changes). Malformed items (no
displayname or fingerprint) are skipped, not fatal.
Legacy fallback: If agent does not send fingerprints, fall back to
delete-all-insert-all for backward compatibility during rollout.
Safety features:
- Row-level locking prevents concurrent update races
- Fingerprint format validation (64 hex chars)
- Delete count verification detects silent failures
- Memory limits prevent pathological cases (>5000 apps)
- Metrics logged for monitoring
""" """
EndpointApplication.query.filter_by(assetid=assetid).delete( from flask import current_app
synchronize_session=False)
# Memory safety: prevent loading massive inventories
MAX_APPS_PER_ENDPOINT = 5000
if not applications: if not applications:
# Empty list means no apps installed - delete all existing
deleted = EndpointApplication.query.filter_by(assetid=assetid).delete(
synchronize_session=False)
current_app.logger.info(
f'[collector] asset_id={assetid} apps_removed={deleted} '
f'apps_added=0 (empty list)')
return 0 return 0
stored = 0 # Check if agent sent fingerprints - legacy agents won't have them yet
has_fingerprints = any(item.get('fingerprint') for item in applications
if isinstance(item, dict))
if not has_fingerprints:
# LEGACY FALLBACK: No fingerprints, use delete-all-insert-all
# SAFETY: Clear any existing NULL-fingerprint apps first to prevent
# UNIQUE constraint violations (MySQL allows multiple NULL values)
deleted = EndpointApplication.query.filter_by(assetid=assetid).delete(
synchronize_session=False)
current_app.logger.info(
f'[collector] asset_id={assetid} legacy_delete={deleted} apps')
stored = 0
for item in applications:
if not isinstance(item, dict):
warnings.append('skipped non-object application entry')
continue
displayname = (item.get('displayname') or '').strip()
if not displayname:
warnings.append('skipped application with no displayname')
continue
db.session.add(EndpointApplication(
assetid=assetid,
displayname=displayname[:255],
displayversion=_clamp(item.get('displayversion'), 128),
publisher=_clamp(item.get('publisher'), 255),
app_fingerprint=None, # Agent didn't send it
))
stored += 1
# Flush to detect constraint violations early
db.session.flush()
current_app.logger.info(
f'[collector] asset_id={assetid} apps_stored={stored} (legacy mode)'
)
return stored
# DELTA LOGIC: Build incoming fingerprint map and validate entries
incoming = {}
for item in applications: for item in applications:
if not isinstance(item, dict): if not isinstance(item, dict):
warnings.append('skipped non-object application entry') warnings.append('skipped non-object application entry')
continue continue
displayname = (item.get('displayname') or '').strip() displayname = (item.get('displayname') or '').strip()
fingerprint = (item.get('fingerprint') or '').strip()
if not displayname: if not displayname:
warnings.append('skipped application with no displayname') warnings.append('skipped application with no displayname')
continue continue
# TRUNCATE EVERY STRING, not just the name. On MySQL in strict if not fingerprint:
# mode an over-long value raises Data too long and takes down the warnings.append('skipped application with no fingerprint')
# WHOLE check-in transaction, so one machine with a malformed continue
# Uninstall registry entry stops reporting entirely - telemetry,
# events and all - until somebody uninstalls the offender. SQLite # SAFETY: Validate fingerprint format (64 lowercase hex chars)
# does not enforce declared lengths, so no test here would ever see if not self._is_valid_fingerprint(fingerprint):
# it. displayname was already clamped; these two were not. warnings.append(
f'skipped application with invalid fingerprint: {displayname} '
f'(fingerprint: {fingerprint[:32]}...)'
)
continue
# Truncate strings for MySQL strict mode compatibility
incoming[fingerprint] = {
'displayname': displayname[:255],
'displayversion': _clamp(item.get('displayversion'), 128),
'publisher': _clamp(item.get('publisher'), 255),
}
incoming_fps = set(incoming.keys())
# SAFETY: Row-level locking to prevent concurrent update races.
# MySQL uses FOR UPDATE on the SELECT below (line 1157).
# PostgreSQL would use pg_advisory_xact_lock here, but MySQL achieves
# the same result via row-level locks + UNIQUE constraint on fingerprint.
# SAFETY: Memory limit check - prevent loading massive inventories
# Query exactly MAX rows, then check if more exist (off-by-one fix)
current_apps = (EndpointApplication.query
.filter_by(assetid=assetid)
.limit(MAX_APPS_PER_ENDPOINT)
.with_for_update() # SAFETY: Row-level locking prevents concurrent races
.all())
if len(current_apps) >= MAX_APPS_PER_ENDPOINT:
# This endpoint has too many apps - fall back to legacy mode
warnings.append(
f'asset has {len(current_apps)} apps (exceeds {MAX_APPS_PER_ENDPOINT}), '
f'falling back to legacy mode'
)
# Recursive call with has_fingerprints=False to trigger legacy path
# (This is safe because we're already in a transaction)
EndpointApplication.query.filter_by(assetid=assetid).delete(
synchronize_session=False)
stored = 0
for fp, app_data in incoming.items():
db.session.add(EndpointApplication(
assetid=assetid,
displayname=app_data['displayname'],
displayversion=app_data['displayversion'],
publisher=app_data['publisher'],
app_fingerprint=fp,
))
stored += 1
db.session.flush()
current_app.logger.warning(
f'[collector] asset_id={assetid} apps_stored={stored} '
f'(legacy mode - memory limit exceeded, had {len(current_apps)} apps)'
)
return stored
current_fps = {app.app_fingerprint for app in current_apps
if app.app_fingerprint} # Skip NULL fingerprints from migration
# TRANSITION: If we have apps without fingerprints, this is the first report
# after agent upgrade. Delete all NULL-fingerprint apps to prevent duplicates.
# SAFETY: Validate incoming apps BEFORE deleting legacy apps to prevent
# leaving asset with zero apps if transaction aborts.
null_fp_apps = [app for app in current_apps if app.app_fingerprint is None]
if null_fp_apps:
if not incoming_fps:
current_app.logger.error(
f'[collector] asset_id={assetid} received empty fingerprint set '
f'during transition, keeping {len(null_fp_apps)} legacy apps')
warnings.append(
f'agent sent empty fingerprint set during NULL transition, '
f'kept {len(null_fp_apps)} legacy apps')
return len(null_fp_apps)
for app in null_fp_apps:
db.session.delete(app)
current_app.logger.info(
f'[collector] asset_id={assetid} removed {len(null_fp_apps)} '
f'legacy apps (transition to delta mode)')
# Calculate delta
to_add = incoming_fps - current_fps # New apps
to_remove = current_fps - incoming_fps # Uninstalled apps
unchanged = current_fps & incoming_fps # Unchanged (don't touch)
# Remove uninstalled apps
deleted_count = 0
if to_remove:
deleted_count = EndpointApplication.query.filter(
EndpointApplication.assetid == assetid,
EndpointApplication.app_fingerprint.in_(list(to_remove))
).delete(synchronize_session=False)
# SAFETY: Verify delete count matches expected. Log as WARNING
# instead of raising error - concurrent updates are rare but valid,
# and aborting the entire transaction loses state/events/hardware data.
if deleted_count != len(to_remove):
current_app.logger.warning(
f'[collector] asset_id={assetid} delete_count_mismatch: '
f'expected={len(to_remove)} actual={deleted_count} '
f'(possible concurrent update, proceeding with caution)')
warnings.append(
f'delete count mismatch: expected {len(to_remove)}, '
f'got {deleted_count} (concurrent update?)')
# Add new apps
for fp in to_add:
app_data = incoming[fp]
db.session.add(EndpointApplication( db.session.add(EndpointApplication(
assetid=assetid, assetid=assetid,
displayname=displayname[:255], displayname=app_data['displayname'],
displayversion=_clamp(item.get('displayversion'), 128), displayversion=app_data['displayversion'],
publisher=_clamp(item.get('publisher'), 255), publisher=app_data['publisher'],
app_fingerprint=fp,
)) ))
stored += 1
return stored # SAFETY: Flush to detect constraint violations before continuing
# Only flush if we made changes (skip when unchanged apps = 99% of cases)
if to_add or to_remove:
db.session.flush()
current_app.logger.info(
f'[collector] asset_id={assetid} apps_added={len(to_add)} '
f'apps_removed={len(to_remove)} unchanged={len(unchanged)} (delta mode)'
)
return len(to_add)
def apply_collector_payload(self, payload: Dict) -> Dict: def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotently upsert one endpoint's state and append its events. """Idempotently upsert one endpoint's state and append its events.
@@ -1091,6 +1291,21 @@ class EndpointsPlugin(BasePlugin):
assetid = computer.assetid assetid = computer.assetid
# Authorization: if authenticated via client certificate, verify the
# certificate's asset matches the payload's hostname asset. Without this
# check an agent with a valid cert for asset A can submit telemetry for
# asset B by changing the hostname field.
from flask import g
if hasattr(g, 'cert_authenticated_asset_id'):
if g.cert_authenticated_asset_id != assetid:
self.logger.warning(
'[collector] AUTHORIZATION DENIED: Certificate authenticates '
'asset %d but payload hostname %s resolves to asset %d',
g.cert_authenticated_asset_id, hostname, assetid)
raise PermissionError(
f'Certificate authenticates asset {g.cert_authenticated_asset_id} '
f'but payload reports for asset {assetid}')
state = EndpointState.query.filter_by(assetid=assetid).first() state = EndpointState.query.filter_by(assetid=assetid).first()
action = 'updated' action = 'updated'
if state is None: if state is None:
@@ -1257,11 +1472,16 @@ class EndpointsPlugin(BasePlugin):
if eventrecordid is not None] if eventrecordid is not None]
already_stored = set() already_stored = set()
if keyed: if keyed:
# SAFETY: Row-level locking prevents race condition (vuln #7 from audit).
# WITH FOR UPDATE locks matching rows in MySQL, preventing concurrent INSERT
# of duplicates. The UNIQUE constraint on (assetid, eventchannel, eventrecordid)
# provides additional protection against race conditions.
rows = (db.session.query(EndpointEvent.eventchannel, rows = (db.session.query(EndpointEvent.eventchannel,
EndpointEvent.eventrecordid) EndpointEvent.eventrecordid)
.filter(EndpointEvent.assetid == assetid, .filter(EndpointEvent.assetid == assetid,
EndpointEvent.eventrecordid.in_( EndpointEvent.eventrecordid.in_(
[recordid for _c, recordid in keyed])) [recordid for _c, recordid in keyed]))
.with_for_update()
.all()) .all())
already_stored = {(channel, recordid) for channel, recordid in rows} already_stored = {(channel, recordid) for channel, recordid in rows}
@@ -1289,9 +1509,14 @@ class EndpointsPlugin(BasePlugin):
stored_events += 1 stored_events += 1
if skipped_events: if skipped_events:
logger.info(
'[collector] asset_id=%d hostname=%s events_stored=%d '
'events_skipped=%d (duplicates detected)',
assetid, hostname, stored_events, skipped_events)
else:
logger.debug( logger.debug(
'endpoint %s replayed %d already-stored event record(s)', 'endpoint %s stored %d event(s), no duplicates',
hostname, skipped_events) hostname, stored_events)
# flush, not commit: the collector dispatcher owns the transaction. # flush, not commit: the collector dispatcher owns the transaction.
db.session.flush() db.session.flush()

View File

@@ -13,6 +13,7 @@ from flask import Blueprint, request, current_app, g
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, Application from shopdb.core.models import Asset, Application
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
from shopdb.core.auth.client_cert import authenticate_by_client_cert
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
@@ -241,11 +242,21 @@ def generic_collect(pluginname):
plugin, schema = plugins[pluginname] plugin, schema = plugins[pluginname]
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header # Dual-auth: try client certificate first (per-endpoint mTLS), fall back to
# only (querystring fallback dropped, see require_api_key). # per-plugin env key or collector-scoped managed token. Header only
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname) # (querystring fallback dropped, see require_api_key).
if error is not None: from shopdb.core.auth.client_cert import authenticate_by_client_cert
return error cert_asset = authenticate_by_client_cert()
if cert_asset:
g.cert_authenticated_asset_id = cert_asset.assetid
current_app.logger.debug(
'[collector] Authenticated via client cert for asset %d',
cert_asset.assetid)
else:
# No cert or cert invalid: fall back to API key
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
if error is not None:
return error
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not payload or not isinstance(payload, dict): if not payload or not isinstance(payload, dict):

View File

View File

@@ -0,0 +1,211 @@
"""Client certificate authentication helpers.
Extracts client certificate fingerprints from various deployment types:
- IIS 2019: SSL_CLIENT_CERT environ (PEM format)
- Nginx: X-Client-Cert-Fingerprint header (SHA1 hex, colon-separated)
- IIS with ARR: X-ARR-ClientCert header (base64 DER)
SECURITY: Nginx $ssl_client_fingerprint outputs SHA1 (40 hex chars), not SHA256.
This is a known Nginx limitation. SHA1 is deprecated for signing but remains
acceptable for fingerprinting self-signed certificates in air-gapped environments
with short-lived certs and revocation support.
"""
import hashlib
import base64
import hmac
import re
from flask import request, current_app
def get_client_cert_fingerprint():
"""Extract client certificate fingerprint from request.
Returns:
str: 40-character lowercase hex SHA1 fingerprint, or None if no cert
Supports multiple deployment types:
- Nginx: reads X-Client-Cert-Fingerprint header (SHA1, colon-separated)
- IIS direct: reads SSL_CLIENT_CERT environ (PEM format, computes SHA256)
- IIS with ARR: reads X-ARR-ClientCert header (base64 DER, computes SHA256)
SECURITY: Nginx returns SHA1 (40 hex chars). IIS paths compute SHA256 (64 hex
chars). The database client_cert_fingerprint column holds 64 chars to support
future SHA256 migration but currently stores SHA1 from Nginx deployments.
"""
# Option 1: Nginx passes pre-computed SHA1 fingerprint
# Format: "AB:CD:EF:..." (colon-separated uppercase hex)
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
if nginx_fp:
# Normalize: remove colons, lowercase
normalized = nginx_fp.replace(':', '').lower()
# Validate format: must be 40 hex chars (SHA1) or 64 hex chars (SHA256)
if len(normalized) == 40:
# SHA1 from nginx $ssl_client_fingerprint
if not re.match(r'^[0-9a-f]{40}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA1 fingerprint format: {normalized[:16]}...')
return None
return normalized
elif len(normalized) == 64:
# SHA256 from custom nginx config or future migration
if not re.match(r'^[0-9a-f]{64}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA256 fingerprint format: {normalized[:16]}...')
return None
return normalized
else:
current_app.logger.warning(
f'[client_cert] Invalid fingerprint length: {len(normalized)} '
f'(expected 40 for SHA1 or 64 for SHA256)')
return None
# Option 2: IIS passes full cert in PEM format via WSGI environ
iis_cert_pem = request.environ.get('SSL_CLIENT_CERT')
if iis_cert_pem:
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# Parse PEM certificate
if isinstance(iis_cert_pem, str):
iis_cert_pem = iis_cert_pem.encode('utf-8')
cert = x509.load_pem_x509_certificate(iis_cert_pem, default_backend())
# Convert to DER and hash
cert_der = cert.public_bytes(serialization.Encoding.DER)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from IIS SSL_CLIENT_CERT: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse IIS cert: {e}')
return None
# Option 3: IIS with ARR passes base64-encoded DER
arr_cert_b64 = request.headers.get('X-ARR-ClientCert')
if arr_cert_b64:
try:
# Decode base64 to get DER bytes
cert_der = base64.b64decode(arr_cert_b64)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from ARR header: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse ARR cert: {e}')
return None
# No client certificate present
return None
def authenticate_by_client_cert():
"""Authenticate request by client certificate.
Returns:
Asset: The authenticated asset, or None if cert auth fails
Checks:
1. Client cert present in request?
2. Fingerprint matches an asset in database?
3. Certificate not revoked?
4. Certificate not expired?
Does NOT raise exceptions - returns None on any failure.
Caller decides whether to fall back to other auth methods.
AUDIT: Logs all authentication attempts (success and failure) to AuditLog.
"""
from shopdb.api import Asset, AuditLog
from datetime import datetime, timezone
fingerprint = get_client_cert_fingerprint()
if not fingerprint:
return None
# Look up asset by cert fingerprint
asset = Asset.query.filter_by(client_cert_fingerprint=fingerprint).first()
if not asset:
current_app.logger.warning(
f'[client_cert] Unknown fingerprint: {fingerprint[:16]}... '
f'(not associated with any asset)')
# Audit: Failed authentication attempt with unknown fingerprint
try:
AuditLog.log(
action='auth.cert.unknown',
userid=None,
details=f'Unknown fingerprint: {fingerprint[:16]}...')
except Exception:
pass # Don't fail authentication on audit failure
return None
# Constant-time comparison to prevent timing attacks. Database lookup already
# happened (timing leak there is unavoidable with indexed lookups), but this
# prevents attackers from using response timing to confirm partial fingerprint
# matches during the final validation.
if not hmac.compare_digest(asset.client_cert_fingerprint, fingerprint):
current_app.logger.warning(
f'[client_cert] Fingerprint mismatch for asset {asset.assetid} '
f'(database vs request)')
return None
# Check certificate revocation (cert_revoked_at is set when admin revokes)
if hasattr(asset, 'cert_revoked_at') and asset.cert_revoked_at:
current_app.logger.warning(
f'[client_cert] Revoked certificate for asset {asset.assetid} '
f'(revoked {asset.cert_revoked_at})')
# Audit: Attempt to use revoked certificate
try:
AuditLog.log(
action='auth.cert.revoked',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Revoked at {asset.cert_revoked_at}: '
f'{asset.cert_revocation_reason or "no reason"}')
except Exception:
pass
return None
# Check certificate expiration
if asset.cert_expires_at:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if asset.cert_expires_at < now:
current_app.logger.warning(
f'[client_cert] Expired certificate for asset {asset.assetid} '
f'(expired {asset.cert_expires_at})')
# Audit: Attempt to use expired certificate
try:
AuditLog.log(
action='auth.cert.expired',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Expired {asset.cert_expires_at}')
except Exception:
pass
return None
current_app.logger.info(
f'[client_cert] Authenticated asset {asset.assetid} '
f'(number={asset.assetnumber}) via certificate {fingerprint[:16]}...')
# Audit: Successful certificate authentication
try:
AuditLog.log(
action='auth.cert.success',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Cert fingerprint: {fingerprint[:16]}...')
except Exception:
pass
return asset

View File

@@ -139,6 +139,34 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
# Notes # Notes
notes = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True)
# Client certificate authentication (endpoints plugin)
client_cert_fingerprint = db.Column(
db.String(64),
nullable=True,
index=True,
comment='SHA1 or SHA256 fingerprint of client certificate for mTLS auth'
)
cert_issued_at = db.Column(
db.DateTime,
nullable=True,
comment='When the client certificate was issued'
)
cert_expires_at = db.Column(
db.DateTime,
nullable=True,
comment='When the certificate expires'
)
cert_revoked_at = db.Column(
db.DateTime,
nullable=True,
comment='When the certificate was revoked (null = not revoked)'
)
cert_revocation_reason = db.Column(
db.String(255),
nullable=True,
comment='Why the certificate was revoked (admin note)'
)
# Relationships # Relationships
assettype = db.relationship('AssetType', backref='assets') assettype = db.relationship('AssetType', backref='assets')
status = db.relationship('AssetStatus', backref='assets') status = db.relationship('AssetStatus', backref='assets')