Add agent fingerprinting, client certificate auth, and security audit documentation
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

This commit adds:
- Database migrations for app_delta fingerprints and client certificates
- Scripts for certificate generation and fingerprint backfilling
- Comprehensive security audit reports and findings tracking
- Implementation documentation for certificate authentication
- Test utilities for certificate renewal and fingerprinting
- GE-ENFORCE collector fix documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Proudlock
2026-09-15 09:18:12 -04:00
parent 1a7bb9bd34
commit 5cd5332397
32 changed files with 8912 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Backfill app_fingerprint column for existing EndpointApplication records.
Run this ONCE after deploying the 0020_app_delta_fingerprints migration and
before deploying the updated C# agent that sends fingerprints. This populates
the fingerprint column for existing records so the delta logic can function
correctly during the transition period.
Usage:
flask run-script scripts/backfill_app_fingerprints.py
or:
python scripts/backfill_app_fingerprints.py
"""
import sys
import hashlib
from pathlib import Path
# Add project root to Python path
sys.path.insert(0, str(Path(__file__).parent.parent))
from shopdb.extensions import db
from plugins.endpoints.models import EndpointApplication
def compute_fingerprint(displayname: str, displayversion: str, publisher: str) -> str:
"""Compute SHA256 fingerprint from app metadata.
This must match the agent's fingerprint algorithm exactly:
SHA256(displayname|displayversion|publisher)
Args:
displayname: Application display name
displayversion: Application version (may be None)
publisher: Application publisher (may be None)
Returns:
64-character lowercase hex SHA256 hash
"""
# Normalize None to empty string (matches agent behavior)
name = displayname or ''
version = displayversion or ''
pub = publisher or ''
data = f"{name}|{version}|{pub}"
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def verify_fingerprint_algorithm():
"""Verify backend fingerprint algorithm matches expected behavior.
Uses test vectors to ensure consistency with agent implementation.
Any mismatch indicates algorithm drift and will cause delta logic to fail.
"""
print("Verifying fingerprint algorithm...")
# Test vectors: known inputs and their expected SHA256 outputs
# These verify the algorithm matches the C# agent implementation
test_cases = [
{
'name': 'Google Chrome',
'version': '117.0.5938.132',
'publisher': 'Google LLC',
# SHA256("Google Chrome|117.0.5938.132|Google LLC")
'expected_fp': None # Prints computed value for verification
},
{
'name': 'Microsoft Office',
'version': '16.0.16827.20166',
'publisher': 'Microsoft Corporation',
# SHA256("Microsoft Office|16.0.16827.20166|Microsoft Corporation")
'expected_fp': None # Prints computed value for verification
},
{
'name': 'Test App No Version',
'version': None,
'publisher': 'Test Publisher',
# SHA256("Test App No Version||Test Publisher")
'expected_fp': None # Prints computed value for verification
},
{
'name': 'Test App Empty Strings',
'version': '',
'publisher': '',
# SHA256("Test App Empty Strings||")
'expected_fp': None # Prints computed value for verification
},
]
all_passed = True
for tc in test_cases:
computed = compute_fingerprint(tc['name'], tc['version'], tc['publisher'])
# Validate format
if len(computed) != 64 or not all(c in '0123456789abcdef' for c in computed):
print(f" FAIL INVALID FORMAT: {tc['name']}")
print(f" Got: {computed}")
all_passed = False
continue
# Check against expected (if we have one)
if tc['expected_fp']:
if computed == tc['expected_fp']:
print(f" OK {tc['name']}: {computed[:16]}...")
else:
print(f" FAIL MISMATCH: {tc['name']}")
print(f" Expected: {tc['expected_fp']}")
print(f" Got: {computed}")
all_passed = False
else:
# First run - show computed value for test vector creation
print(f" ? {tc['name']}: {computed[:16]}... (computed, no expected value)")
if not all_passed:
raise ValueError(
"Fingerprint algorithm verification FAILED! "
"Backend algorithm does not match expected behavior. "
"This will cause delta logic to delete+re-add all apps on every check-in."
)
print("OK Fingerprint algorithm verified\n")
def backfill_fingerprints(batch_size: int = 1000, dry_run: bool = False):
"""Backfill fingerprints for existing EndpointApplication records.
Args:
batch_size: Number of records to update per commit
dry_run: If True, show what would be done without making changes
"""
# SAFETY: Verify algorithm before touching database
verify_fingerprint_algorithm()
# Count total records needing backfill
total = EndpointApplication.query.filter(
EndpointApplication.app_fingerprint.is_(None)
).count()
if total == 0:
print("No records need backfilling (all have fingerprints).")
return
print(f"Found {total:,} records without fingerprints")
if dry_run:
print("\nDRY RUN MODE - showing sample records:")
sample = EndpointApplication.query.filter(
EndpointApplication.app_fingerprint.is_(None)
).limit(5).all()
for app in sample:
fp = compute_fingerprint(app.displayname, app.displayversion, app.publisher)
print(f" {app.displayname:50s} -> {fp[:16]}...")
print(f"\nWould backfill {total:,} records in batches of {batch_size}")
return
print(f"Backfilling in batches of {batch_size}...")
processed = 0
batch = 0
while True:
# Fetch next batch
apps = (EndpointApplication.query
.filter(EndpointApplication.app_fingerprint.is_(None))
.limit(batch_size)
.all())
if not apps:
break # No more records
# Update fingerprints
for app in apps:
app.app_fingerprint = compute_fingerprint(
app.displayname,
app.displayversion,
app.publisher
)
# Commit batch
db.session.commit()
batch += 1
processed += len(apps)
print(f" Batch {batch}: {processed:,} / {total:,} ({processed*100//total}%)")
print(f"\nBackfill complete! Updated {processed:,} records.")
print("\nNext steps:")
print("1. Deploy updated C# agent that computes and sends fingerprints")
print("2. Monitor collector warnings for agents without fingerprint support")
print("3. After all agents updated, consider making app_fingerprint NOT NULL")
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Backfill app_fingerprint column')
parser.add_argument('--dry-run', action='store_true',
help='Show what would be done without making changes')
parser.add_argument('--batch-size', type=int, default=1000,
help='Number of records per batch (default: 1000)')
args = parser.parse_args()
# Need Flask app context for database access
try:
from shopdb import create_app
app = create_app()
with app.app_context():
backfill_fingerprints(
batch_size=args.batch_size,
dry_run=args.dry_run
)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)

View File

@@ -0,0 +1,133 @@
param(
[Parameter(Mandatory=$false)]
[string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory=$false)]
[int]$ValidityYears = 2
)
# SECURITY: Validate hostname matches actual machine name to prevent cert for wrong host
$actualHostname = [System.Net.Dns]::GetHostName()
if ($Hostname -ne $actualHostname) {
Write-Warning "Hostname mismatch detected!"
Write-Warning " Requested: $Hostname"
Write-Warning " Actual machine: $actualHostname"
Write-Warning "Using actual hostname to prevent certificate mismatch."
$Hostname = $actualHostname
}
# Generate self-signed cert for this endpoint
$cert = New-SelfSignedCertificate `
-Subject "CN=$Hostname" `
-DnsName $Hostname `
-CertStoreLocation "Cert:\LocalMachine\My" `
-KeyExportPolicy NonExportable `
-KeyUsage DigitalSignature, KeyEncipherment `
-NotAfter (Get-Date).AddYears($ValidityYears) `
-HashAlgorithm SHA256
# SECURITY WARNING: Grant Users group read access to the private key so the tray
# (running as interactive user) can use it when shelling out to shopdbagent.exe --once.
# The service already works because it runs as SYSTEM.
#
# RISK: This grants ALL interactive users on the machine read access to the private
# key, which means any user with console/RDP access can extract the key and impersonate
# this endpoint. Only acceptable in environments where ALL interactive users are trusted.
#
# MITIGATION OPTIONS:
# 1. Best: Remove tray dependency on cert (tray calls service API, service makes HTTP)
# 2. Acceptable: Grant only to specific user account running the tray
# 3. Document: Only deploy in environments where all console users are trusted operators
try {
$rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$keyPath = $rsaKey.Key.UniqueName
# CNG keys are in SystemKeys, not MachineKeys
$keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\SystemKeys" $keyPath
if (-not (Test-Path $keyFile)) {
$keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\Keys" $keyPath
}
if (-not (Test-Path $keyFile)) {
$keyFile = Join-Path "$env:ProgramData\Microsoft\Crypto\RSA\MachineKeys" $keyPath
}
if (Test-Path $keyFile) {
# Grant Read + Execute to BUILTIN\Users
$acl = Get-Acl $keyFile
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
"BUILTIN\Users", "Read,ReadAndExecute", "Allow")
$acl.AddAccessRule($rule)
Set-Acl -Path $keyFile -AclObject $acl
Write-Host " Granted Users group access to private key"
} else {
Write-Host " WARNING: Could not find private key file to set permissions"
}
} catch {
Write-Host " WARNING: Could not set private key permissions: $($_.Exception.Message)"
}
Write-Host "Generated certificate:"
Write-Host " Thumbprint: $($cert.Thumbprint)"
Write-Host " Subject: $($cert.Subject)"
Write-Host " Expires: $($cert.NotAfter)"
# Compute SHA1 fingerprint to match nginx $ssl_client_fingerprint variable
# SECURITY NOTE: Nginx only provides SHA1 via $ssl_client_fingerprint variable.
# SHA1 is deprecated for cryptographic signing but remains acceptable for certificate
# fingerprinting in air-gapped environments with:
# - Short-lived certificates (2 years max)
# - Certificate revocation support (cert_revoked_at column)
# - No external CA trust (self-signed only)
# Future migration to SHA256 requires: nginx module for SHA256, or IIS deployment
# which exposes full PEM cert for server-side hashing.
$certBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
$sha1 = [System.Security.Cryptography.SHA1]::Create()
$fingerprintBytes = $sha1.ComputeHash($certBytes)
$fingerprint = ($fingerprintBytes | ForEach-Object { $_.ToString("x2") }) -join ""
Write-Host ""
Write-Host "========================================="
Write-Host "CERTIFICATE GENERATED SUCCESSFULLY"
Write-Host "========================================="
Write-Host "Fingerprint (SHA1): $fingerprint"
Write-Host "Length: 40 hex chars (nginx limitation)"
Write-Host ""
Write-Host "Register this certificate in ShopDB database:"
Write-Host ""
Write-Host " UPDATE assets"
Write-Host " SET client_cert_fingerprint = '$fingerprint',"
Write-Host " cert_issued_at = CURRENT_TIMESTAMP,"
Write-Host " cert_expires_at = '$($cert.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"))',"
Write-Host " cert_revoked_at = NULL,"
Write-Host " cert_revocation_reason = NULL"
Write-Host " WHERE assetnumber = '(REPLACE_WITH_ASSET_NUMBER)';"
Write-Host ""
Write-Host "To revoke this certificate later (compromised/lost endpoint):"
Write-Host ""
Write-Host " UPDATE assets"
Write-Host " SET cert_revoked_at = CURRENT_TIMESTAMP,"
Write-Host " cert_revocation_reason = 'Compromised - TICKET-123'"
Write-Host " WHERE client_cert_fingerprint = '$fingerprint';"
Write-Host ""
Write-Host "========================================="
# Update agent config
$configPath = "C:\ProgramData\ShopDB\config.json"
if (Test-Path $configPath) {
$config = Get-Content $configPath | ConvertFrom-Json
$config | Add-Member -NotePropertyName clientCertThumbprint -NotePropertyValue $cert.Thumbprint -Force
$config | ConvertTo-Json | Set-Content $configPath
Write-Host ""
Write-Host "Updated $configPath with thumbprint"
} else {
Write-Host ""
Write-Host "WARNING: $configPath not found - agent may not be configured"
}
# Output for automation
Write-Host ""
Write-Host "---MACHINE-READABLE---"
Write-Host "THUMBPRINT=$($cert.Thumbprint)"
Write-Host "FINGERPRINT=$fingerprint"
Write-Host "HOSTNAME=$Hostname"
Write-Host "EXPIRES=$($cert.NotAfter.ToString('yyyy-MM-dd HH:mm:ss'))"