Add agent fingerprinting, client certificate auth, and security audit documentation
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:
218
scripts/backfill_app_fingerprints.py
Normal file
218
scripts/backfill_app_fingerprints.py
Normal 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)
|
||||
133
scripts/generate-agent-cert.ps1
Normal file
133
scripts/generate-agent-cert.ps1
Normal 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'))"
|
||||
Reference in New Issue
Block a user