You can automate Windows Server compliance automation reporting for PCI DSS, HIPAA, SOC 2, or CMMC using a scheduled Python script that collects evidence from your systems, generates timestamped PDF reports, and emails them to you automatically. Organizations with automated compliance processes spend 63% less time preparing for audits and have 47% fewer audit findings. For Volusia County businesses, this replaces the frantic two-week sprint before an auditor visit with continuous, always-current documentation.
Every compliance framework — PCI DSS, HIPAA, SOC 2, CMMC — has one thing in common: they all want documentation. Lots of it. And not just any documentation — they want evidence that you are continuously monitoring, continuously logging, and continuously proving that your controls actually work. Most small businesses in Volusia County handle this by cramming everything into a frantic two-week sprint before the auditor shows up. Spreadsheets get dusted off. Screenshots get taken. Someone digs through six months of firewall logs trying to remember what changed in October. It is stressful, it is error-prone, and it is completely unnecessary — because you can automate the entire thing.
In this guide, we are building a Python-powered compliance reporting system that runs on a schedule, collects evidence from your actual systems, generates professional PDF reports, and delivers them to your inbox. By the time your auditor asks for documentation, you will already have it — organized, timestamped, and ready to hand over.
Why Manual Compliance Reporting Fails
Before we write a single line of code, let us talk about why the manual approach creates so many problems — and why auditors can spot it immediately.
The Cramming Problem
When you only generate compliance documentation right before an audit, you are not documenting your actual security posture. You are documenting what you remember about your security posture. There is a massive difference. Auditors know this. They look for gaps in timestamps, inconsistent formatting, and evidence that was clearly generated all at once rather than continuously.
A 2025 Ponemon Institute study found that organizations with automated compliance processes spent 63% less time preparing for audits and had 47% fewer audit findings. That is not because automation makes you magically more compliant — it is because continuous documentation catches problems when they happen, not six months later when an auditor finds them.
What Auditors Actually Want
Every compliance framework boils down to three questions:
- Do you have controls in place? (Policies, configurations, tools)
- Are those controls working? (Evidence, logs, monitoring data)
- Can you prove it over time? (Historical records, trend data, timestamps)
The third question is where most small businesses fail. You might have great security controls today, but if you cannot show they were working last Tuesday, or last month, or six months ago, the auditor has to assume they were not. Automated reporting solves this by creating a continuous paper trail that proves compliance over time — not just at the moment someone is watching.
The Real Cost of Manual Compliance
Here is what manual compliance reporting actually costs a small business:
| Activity | Manual Hours/Year | Automated Hours/Year |
|---|---|---|
| Evidence collection | 120-160 hours | 5-10 hours |
| Report generation | 80-120 hours | 0 (automated) |
| Gap analysis | 40-60 hours | 10-15 hours |
| Audit preparation | 60-80 hours | 10-20 hours |
| Remediation (from late discovery) | 100+ hours | 20-40 hours |
| Total | 400-520 hours | 45-85 hours |
That is roughly $15,000-$25,000 in labor costs for a small business paying someone $35-50/hour — every single year. Automation does not eliminate all compliance work, but it eliminates the repetitive evidence-collection grind that eats most of those hours.
Building Your Compliance Report Generator
Let us build something practical. We are going to create a Python system that:
- Collects compliance evidence from your systems
- Evaluates controls against requirements
- Generates a professional PDF report
- Emails the report to stakeholders
- Runs automatically on a schedule
Project Setup
First, create your project directory and install dependencies:
mkdir compliance-reporter && cd compliance-reporter
python -m venv venv
source venv/bin/activate # On Windows: venvScriptsactivate</p>
<p>pip install jinja2 weasyprint pyyaml requests schedule smtplib-replacement
pip install psutil python-dateutil
text
Jinja2 handles our report templates — it lets us write HTML with Python-like placeholders that get filled with real data. WeasyPrint converts that rendered HTML into professional PDFs. PyYAML manages our compliance framework definitions. The rest handle scheduling, system checks, and email delivery.
Define Your Compliance Framework
Before we can check compliance, we need to define what we are checking against. Create a YAML file that maps your requirements: Our guide to How to Encrypt Your Business Data in Transit and at Rest (Plain English) walks through this in more detail.
framework: name: "Small Business Security Baseline" version: "2.0" description: "Core security controls for small businesses" based_on: - "NIST CSF 2.0" - "CIS Controls v8 (IG1)" - "PCI DSS 4.0 (applicable controls)"</p> <p>categories: - id: "AC" name: "Access Control" controls: - id: "AC-1" title: "Multi-Factor Authentication" description: "MFA enabled for all user accounts" check_type: "script" check_script: "checks/mfa_status.py" evidence_type: "user_list_with_mfa_status" severity: "critical" frameworks: ["PCI-4.0-8.4", "HIPAA-164.312(d)", "NIST-IA-2"]</p>text- id: "AC-2" title: "Password Policy Compliance" description: "Password policy meets minimum requirements" check_type: "script" check_script: "checks/password_policy.py" evidence_type: "policy_configuration" severity: "high" frameworks: ["PCI-4.0-8.3", "HIPAA-164.312(a)(1)"] - id: "AC-3" title: "Account Review" description: "User accounts reviewed quarterly" check_type: "manual" evidence_type: "review_log" severity: "medium" frameworks: ["PCI-4.0-7.2", "SOC2-CC6.1"] - id: "AC-4" title: "Privileged Access Management" description: "Admin accounts are limited and monitored" check_type: "script" check_script: "checks/admin_accounts.py" evidence_type: "admin_user_list" severity: "critical" frameworks: ["PCI-4.0-7.2.1", "NIST-AC-6"]<ul>
<li>
<p>id: "DI"
name: "Data Integrity"
controls:</p>
<ul>
<li>
<p>id: "DI-1"
title: "Backup Verification"
description: "Backups completed and verified within 24 hours"
check_type: "script"
check_script: "checks/backup_status.py"
evidence_type: "backup_log"
severity: "critical"
frameworks: ["HIPAA-164.308(a)(7)", "NIST-CP-9"]</p>
</li>
<li>
<p>id: "DI-2"
title: "Data Encryption at Rest"
description: "Sensitive data encrypted on all storage"
check_type: "script"
check_script: "checks/encryption_status.py"
evidence_type: "encryption_report"
severity: "high"
frameworks: ["PCI-4.0-3.5", "HIPAA-164.312(a)(2)(iv)"]</p>
</li>
</ul>
</li>
<li>
<p>id: "NP"
name: "Network Protection"
controls:</p>
<ul>
<li>
<p>id: "NP-1"
title: "Firewall Configuration"
description: "Firewall enabled with deny-by-default policy"
check_type: "script"
check_script: "checks/firewall_status.py"
evidence_type: "firewall_rules"
severity: "critical"
frameworks: ["PCI-4.0-1.2", "NIST-SC-7"]</p>
</li>
<li>
<p>id: "NP-2"
title: "Network Segmentation"
description: "Sensitive systems isolated from general network"
check_type: "manual"
evidence_type: "network_diagram"
severity: "high"
frameworks: ["PCI-4.0-1.3", "HIPAA-164.312(e)(1)"]</p>
</li>
</ul>
</li>
<li>
<p>id: "SM"
name: "Security Monitoring"
controls:</p>
<ul>
<li>
<p>id: "SM-1"
title: "Antivirus/EDR Status"
description: "Endpoint protection active and updated"
check_type: "script"
check_script: "checks/av_status.py"
evidence_type: "endpoint_protection_report"
severity: "critical"
frameworks: ["PCI-4.0-5.2", "NIST-SI-3"]</p>
</li>
<li>
<p>id: "SM-2"
title: "Log Collection"
description: "Security logs collected and retained 90+ days"
check_type: "script"
check_script: "checks/log_retention.py"
evidence_type: "log_config"
severity: "high"
frameworks: ["PCI-4.0-10.7", "HIPAA-164.312(b)"]</p>
</li>
</ul>
</li>
<li>
<p>id: "VM"
name: "Vulnerability Management"
controls:</p>
<ul>
<li>
<p>id: "VM-1"
title: "Patch Management"
description: "Critical patches applied within 30 days"
check_type: "script"
check_script: "checks/patch_status.py"
evidence_type: "patch_report"
severity: "high"
frameworks: ["PCI-4.0-6.3.3", "NIST-SI-2"]</p>
</li>
<li>
<p>id: "VM-2"
title: "Vulnerability Scanning"
description: "Network scanned for vulnerabilities quarterly"
check_type: "manual"
evidence_type: "scan_report"
severity: "medium"
frameworks: ["PCI-4.0-11.3", "NIST-RA-5"]
Notice how each control maps back to multiple frameworks. This is intentional. Most compliance frameworks overlap significantly — PCI DSS, HIPAA, and NIST share probably 70% of their requirements. By mapping controls to multiple frameworks, a single check can satisfy evidence requirements across all of them. One scan, multiple auditors happy.The Compliance Check Engine
Now let us build the engine that runs these checks and collects evidence. This is the core of the system:
!/usr/bin/env python3 """ compliance_engine.py — Core compliance check and report engine. Loads framework definitions, runs automated checks, and collects evidence. """ from datetime import datetime, timedelta from pathlib import Path from dataclasses import dataclass, field, asdict from typing import Optional @dataclass class ControlResult: """Result of a single compliance control check.""" control_id: str title: str category: str status: str # "pass", "fail", "warning", "manual", "error" severity: str # "critical", "high", "medium", "low" description: str evidence: str # Raw evidence data evidence_type: str frameworks: list checked_at: str details: str = "" # Human-readable explanation remediation: str = "" # What to fix if failed @dataclass class ComplianceReport: """Complete compliance report with all check results.""" framework_name: str framework_version: str report_id: str generated_at: str hostname: str total_controls: int = 0 passed: int = 0 failed: int = 0 warnings: int = 0 manual_review: int = 0 errors: int = 0 score: float = 0.0 results: list = field(default_factory=list) summary: str = "" class ComplianceEngine: """ Loads compliance framework definitions and executes checks. Each check returns structured evidence that gets bundled into the final report. """def __init__(self, framework_path: str, evidence_dir: str = "evidence"): self.framework = self._load_framework(framework_path) self.evidence_dir = Path(evidence_dir) self.evidence_dir.mkdir(parents=True, exist_ok=True) self.results = [] def _load_framework(self, path: str) -> dict: """Load and validate framework YAML definition.""" with open(path, 'r') as f: data = yaml.safe_load(f) if 'framework' not in data or 'categories' not in data: raise ValueError(f"Invalid framework file: {path}") return data def run_all_checks(self) -> ComplianceReport: """Execute every control check in the framework.""" print(f"n{'='*60}") print(f" Compliance Check: {self.framework['framework']['name']}") print(f" Version: {self.framework['framework']['version']}") print(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print(f"{'='*60}n") self.results = [] for category in self.framework['categories']: cat_name = category['name'] cat_id = category['id'] print(f"n[{cat_id}] {cat_name}") print(f"{''*40}") for control in category['controls']: result = self._run_check(control, cat_name) self.results.append(result) # Status indicator status_icon = { 'pass': '', 'fail': '', 'warning': '', 'manual': '?', 'error': '!' }.get(result.status, '?') print(f" [{status_icon}] {control['id']}: " f"{control['title']} — {result.status.upper()}") # Build the report report = self._build_report() self._save_evidence(report) return report def _run_check(self, control: dict, category: str) -> ControlResult: """Run a single compliance control check.""" check_type = control.get('check_type', 'manual') now = datetime.now().isoformat() if check_type == 'manual': return ControlResult( control_id=control['id'], title=control['title'], category=category, status='manual', severity=control.get('severity', 'medium'), description=control['description'], evidence='Manual review required', evidence_type=control.get('evidence_type', 'manual'), frameworks=control.get('frameworks', []), checked_at=now, details='This control requires manual verification.', remediation='Complete manual review and attach evidence.' ) if check_type == 'script': return self._run_script_check(control, category, now) return ControlResult( control_id=control['id'], title=control['title'], category=category, status='error', severity=control.get('severity', 'medium'), description=control['description'], evidence=f'Unknown check type: {check_type}', evidence_type='error', frameworks=control.get('frameworks', []), checked_at=now, details=f'Check type "{check_type}" is not supported.', remediation='Update framework definition with valid check_type.' ) def _run_script_check(self, control: dict, category: str, timestamp: str) -> ControlResult: """Execute a check script and parse its output.""" script_path = control.get('check_script', '') if not os.path.exists(script_path): return ControlResult( control_id=control['id'], title=control['title'], category=category, status='error', severity=control.get('severity', 'medium'), description=control['description'], evidence=f'Check script not found: {script_path}', evidence_type='error', frameworks=control.get('frameworks', []), checked_at=timestamp, details=f'Script "{script_path}" does not exist.', remediation=f'Create check script at {script_path}' ) try: result = subprocess.run( [sys.executable, script_path], capture_output=True, text=True, timeout=60 # 60-second timeout per check ) # Check scripts output JSON with status and evidence output = json.loads(result.stdout) return ControlResult( control_id=control['id'], title=control['title'], category=category, status=output.get('status', 'error'), severity=control.get('severity', 'medium'), description=control['description'], evidence=json.dumps(output.get('evidence', {}), indent=2), evidence_type=control.get('evidence_type', 'script_output'), frameworks=control.get('frameworks', []), checked_at=timestamp, details=output.get('details', ''), remediation=output.get('remediation', '') ) except subprocess.TimeoutExpired: return ControlResult( control_id=control['id'], title=control['title'], category=category, status='error', severity=control.get('severity', 'medium'), description=control['description'], evidence='Check script timed out after 60 seconds', evidence_type='error', frameworks=control.get('frameworks', []), checked_at=timestamp, details='The check script exceeded the 60-second timeout.', remediation='Optimize the check script or increase timeout.' ) except (json.JSONDecodeError, Exception) as e: return ControlResult( control_id=control['id'], title=control['title'], category=category, status='error', severity=control.get('severity', 'medium'), description=control['description'], evidence=f'Script error: {str(e)}', evidence_type='error', frameworks=control.get('frameworks', []), checked_at=timestamp, details=f'Check script failed: {str(e)}', remediation='Review and fix the check script.' ) def _build_report(self) -> ComplianceReport: """Compile all results into a ComplianceReport.""" passed = sum(1 for r in self.results if r.status == 'pass') failed = sum(1 for r in self.results if r.status == 'fail') warnings = sum(1 for r in self.results if r.status == 'warning') manual = sum(1 for r in self.results if r.status == 'manual') errors = sum(1 for r in self.results if r.status == 'error') total = len(self.results) # Score based on automated checks only (exclude manual) automated = total - manual score = (passed / automated * 100) if automated > 0 else 0 import socket hostname = socket.gethostname() report_id = (f"CR-{datetime.now().strftime('%Y%m%d')}-" f"{hostname[:8].upper()}") # Generate summary critical_fails = [r for r in self.results if r.status == 'fail' and r.severity == 'critical'] high_fails = [r for r in self.results if r.status == 'fail' and r.severity == 'high'] summary_parts = [ f"Compliance score: {score:.1f}%", f"{passed}/{automated} automated controls passing" ] if critical_fails: summary_parts.append( f" {len(critical_fails)} CRITICAL failures require " f"immediate attention" ) if high_fails: summary_parts.append( f"{len(high_fails)} high-severity failures need remediation" ) if manual > 0: summary_parts.append( f"{manual} controls require manual review" ) return ComplianceReport( framework_name=self.framework['framework']['name'], framework_version=self.framework['framework']['version'], report_id=report_id, generated_at=datetime.now().isoformat(), hostname=hostname, total_controls=total, passed=passed, failed=failed, warnings=warnings, manual_review=manual, errors=errors, score=score, results=[asdict(r) for r in self.results], summary="n".join(summary_parts) ) def _save_evidence(self, report: ComplianceReport): """Save raw evidence to timestamped directory.""" timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') evidence_path = self.evidence_dir / timestamp evidence_path.mkdir(parents=True, exist_ok=True) # Save the full report as JSON (machine-readable archive) report_json = evidence_path / "report.json" with open(report_json, 'w') as f: json.dump(asdict(report), f, indent=2, default=str) # Save individual evidence files per control for result in self.results: if result.status != 'manual': evidence_file = (evidence_path / f"{result.control_id}_evidence.json") with open(evidence_file, 'w') as f: json.dump({ 'control_id': result.control_id, 'checked_at': result.checked_at, 'status': result.status, 'evidence': result.evidence }, f, indent=2) print(f"nEvidence saved to: {evidence_path}")Let me walk you through the key design decisions here. The <strong>ControlResult</strong> dataclass captures everything an auditor needs: what was checked, what the result was, raw evidence, and — critically — a timestamp. The <strong>ComplianceEngine</strong> loads your framework YAML and runs each check, either by executing a Python script or flagging it for manual review. Every single check gets timestamped and stored individually. This is important because auditors love being able to trace a specific finding back to a specific piece of evidence at a specific point in time.</p> <p>The <strong>_run_script_check</strong> method executes external Python scripts that return JSON with a <code>status</code>, <code>evidence</code>, <code>details</code>, and <code>remediation</code> field. This modular approach means you can add new checks without touching the engine — just write a new check script and add it to your framework YAML.</p> <h3>Writing Check Scripts</h3> <p>Each check script is a standalone Python file that examines one specific aspect of your system. Here is an example that checks firewall status:</p> <p>python
!/usr/bin/env python3
“””
checks/firewall_status.py — Verify Windows Firewall configuration.
Returns JSON with status, evidence, and remediation guidance.
“””def check_firewall():
“””Check Windows Firewall status across all profiles.”””
try:
# Query all firewall profiles
result = subprocess.run(
[‘netsh’, ‘advfirewall’, ‘show’, ‘allprofiles’, ‘state’],
capture_output=True, text=True, timeout=15
)output = result.stdout profiles = {} current_profile = None for line in output.splitlines(): line = line.strip() if 'Profile Settings' in line: current_profile = line.split()[0] profiles[current_profile] = {} elif 'State' in line and current_profile: state = line.split()[-1] profiles[current_profile]['state'] = state # Check if all profiles are ON all_enabled = all( p.get('state', '').upper() == 'ON' for p in profiles.values() ) # Check for overly permissive inbound rules rules_result = subprocess.run( ['netsh', 'advfirewall', 'firewall', 'show', 'rule', 'name=all', 'dir=in', 'status=enabled'], capture_output=True, text=True, timeout=30 ) # Count allow-all rules (risky) allow_any_count = rules_result.stdout.lower().count('any') risky_rules = allow_any_count > 20 # Threshold for concern if all_enabled and not risky_rules: status = 'pass' details = ('All firewall profiles enabled. ' 'No excessive allow-any rules detected.') remediation = '' elif all_enabled and risky_rules: status = 'warning' details = (f'Firewall enabled but {allow_any_count} ' f'allow-any rules detected. Review for ' f'unnecessary open ports.') remediation = ('Review inbound rules and remove ' 'unnecessary allow-any entries. ' 'Run: netsh advfirewall firewall show rule ' 'name=all dir=in | findstr "Any"') else: disabled = [name for name, data in profiles.items() if data.get('state', '').upper() != 'ON'] status = 'fail' details = (f'Firewall disabled on profiles: ' f'{", ".join(disabled)}') remediation = ('Enable firewall on all profiles: ' 'netsh advfirewall set allprofiles state on') return { 'status': status, 'evidence': { 'profiles': profiles, 'risky_rule_count': allow_any_count, 'checked_at': __import__('datetime').datetime.now().isoformat() }, 'details': details, 'remediation': remediation } except Exception as e: return { 'status': 'error', 'evidence': {'error': str(e)}, 'details': f'Firewall check failed: {str(e)}', 'remediation': 'Ensure script has admin privileges.' }if name == ‘main‘:
result = check_firewall()
print(json.dumps(result))Here is another check script for backup verification — because nothing ruins an audit faster than discovering your backups have not actually been running:</p> <p>python
!/usr/bin/env python3
“””
checks/backup_status.py — Verify backup completion and recency.
Checks backup locations for recent files and validates integrity.
“””from datetime import datetime, timedelta
from pathlib import Pathdef check_backups():
“””Verify backups exist, are recent, and pass basic integrity checks.”””
# Configure your backup locations here
backup_locations = [
r”D:BackupsDaily”,
r”D:BackupsWeekly”,
r”NASBackupsServerBackup”,
]max_age_hours = 26 # Allow 2-hour grace period over 24h results = [] overall_status = 'pass' for location in backup_locations: loc_result = { 'path': location, 'exists': False, 'latest_file': None, 'latest_age_hours': None, 'file_count': 0, 'total_size_mb': 0, 'status': 'error' } if not os.path.exists(location): loc_result['status'] = 'fail' loc_result['details'] = f'Backup path does not exist: {location}' overall_status = 'fail' results.append(loc_result) continue loc_result['exists'] = True # Find the most recent backup file backup_files = [] for f in Path(location).rglob('*'): if f.is_file() and f.suffix.lower() in ( '.bak', '.zip', '.tar', '.gz', '.7z', '.vhdx', '.vhd' ): backup_files.append(f) loc_result['file_count'] = len(backup_files) if not backup_files: loc_result['status'] = 'fail' loc_result['details'] = 'No backup files found' overall_status = 'fail' results.append(loc_result) continue # Find newest file newest = max(backup_files, key=lambda f: f.stat().st_mtime) newest_time = datetime.fromtimestamp(newest.stat().st_mtime) age = datetime.now() - newest_time age_hours = age.total_seconds() / 3600 loc_result['latest_file'] = str(newest.name) loc_result['latest_age_hours'] = round(age_hours, 1) loc_result['total_size_mb'] = round( sum(f.stat().st_size for f in backup_files) / (1024 * 1024), 1 ) # Check file size (empty backups are a red flag) if newest.stat().st_size < 1024: # Less than 1KB loc_result['status'] = 'fail' loc_result['details'] = ( f'Latest backup suspiciously small: ' f'{newest.stat().st_size} bytes' ) overall_status = 'fail' elif age_hours > max_age_hours: loc_result['status'] = 'fail' loc_result['details'] = ( f'Latest backup is {age_hours:.1f} hours old ' f'(max allowed: {max_age_hours}h)' ) overall_status = 'fail' elif age_hours > max_age_hours * 0.8: loc_result['status'] = 'warning' loc_result['details'] = ( f'Backup age approaching threshold: {age_hours:.1f}h' ) if overall_status == 'pass': overall_status = 'warning' else: loc_result['status'] = 'pass' loc_result['details'] = ( f'Backup current: {age_hours:.1f}h old, ' f'{loc_result["total_size_mb"]} MB' ) results.append(loc_result) return { 'status': overall_status, 'evidence': { 'locations_checked': len(backup_locations), 'results': results, 'checked_at': datetime.now().isoformat() }, 'details': (f'{sum(1 for r in results if r["status"]=="pass")}' f'/{len(results)} backup locations healthy'), 'remediation': ('Check backup job schedules and verify ' 'target paths are accessible.' if overall_status != 'pass' else '') }if name == ‘main‘:
result = check_backups()
print(json.dumps(result, default=str))Notice the pattern: every check script is completely self-contained. It checks one thing, returns structured JSON, and includes remediation guidance. This means you can run any check script independently for troubleshooting, and you can add new checks without modifying the engine. When the auditor asks "How do you verify backups?" you can literally show them the script that runs every night.</p> <h3>The PDF Report Template</h3> <p>Now let us build the Jinja2 template that turns raw check data into a professional compliance report. This is where the magic happens — automated evidence becomes an auditor-friendly document:</p> <p>html
@page { size: letter; margin: 1in; @bottom-center { content: "Page " counter(page) " of " counter(pages); font-size: 9pt; color: #666; } } body { font-family: "Segoe UI", Calibri, Arial, sans-serif; font-size: 11pt; line-height: 1.5; color: #1a1a1a; } .cover { text-align: center; padding-top: 200px; page-break-after: always; } .cover h1 { font-size: 28pt; color: #0d47a1; margin-bottom: 10px; } .cover .subtitle { font-size: 14pt; color: #555; margin-bottom: 40px; } .cover .meta { font-size: 11pt; color: #777; line-height: 2; } .confidential { color: #c62828; font-weight: bold; font-size: 12pt; margin-top: 60px; border: 2px solid #c62828; display: inline-block; padding: 8px 20px; } h2 { color: #0d47a1; border-bottom: 2px solid #0d47a1; padding-bottom: 5px; margin-top: 30px; } h3 { color: #1565c0; margin-top: 20px; } .summary-box { background: #e3f2fd; border-left: 4px solid #0d47a1; padding: 15px 20px; margin: 20px 0; border-radius: 0 4px 4px 0; } .score-display { font-size: 36pt; font-weight: bold; text-align: center; margin: 20px 0; } .score-pass { color: #2e7d32; } .score-warn { color: #f57f17; } .score-fail { color: #c62828; } table { width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 10pt; } th { background: #0d47a1; color: white; padding: 8px 10px; text-align: left; } td { padding: 6px 10px; border-bottom: 1px solid #ddd; } tr:nth-child(even) { background: #f5f5f5; } .status-pass { color: #2e7d32; font-weight: bold; } .status-fail { color: #c62828; font-weight: bold; } .status-warning { color: #f57f17; font-weight: bold; } .status-manual { color: #1565c0; font-weight: bold; } .status-error { color: #6a1b9a; font-weight: bold; } .severity-critical { background: #ffcdd2; color: #b71c1c; padding: 2px 6px; border-radius: 3px; font-weight: bold; font-size: 9pt; } .severity-high { background: #ffe0b2; color: #e65100; padding: 2px 6px; border-radius: 3px; font-size: 9pt; } .severity-medium { background: #fff9c4; color: #f57f17; padding: 2px 6px; border-radius: 3px; font-size: 9pt; } .remediation-box { background: #fff3e0; border-left: 4px solid #e65100; padding: 10px 15px; margin: 10px 0; font-size: 10pt; } .evidence-block { background: #fafafa; border: 1px solid #ddd; padding: 10px; font-family: "Consolas", monospace; font-size: 9pt; white-space: pre-wrap; margin: 10px 0; } .page-break { page-break-before: always; }
Compliance Assessment Report
{{ report.framework_name }}Version {{ report.framework_version }}CONFIDENTIAL
Executive Summary
Overall Compliance Score
{% set score_class = ‘score-pass’ if report.score >= 80 else (‘score-warn’
if report.score >= 60 else ‘score-fail’) %}{{ “%.1f”|format(report.score) }}%
| Metric | Count |
|---|---|
| Total Controls Assessed | {{ report.total_controls }} |
| Passed | {{ report.passed }} |
| Failed | {{ report.failed }} |
| Warnings | {{ report.warnings }} |
| Manual Review Required | {{ report.manual_review }} |
| Errors | {{ report.errors }} |
Key Findings
‘) }}
{% set critical_failures = report.results | selectattr(‘status’, ‘equalto’,
‘fail’) | selectattr(‘severity’, ‘equalto’, ‘critical’) | list %} {% set
high_failures = report.results | selectattr(‘status’, ‘equalto’, ‘fail’) |
selectattr(‘severity’, ‘equalto’, ‘high’) | list %} {% if critical_failures
or high_failures %}
Priority Remediation Items
The following failures require immediate attention, ordered by severity:
| Control | Severity | Finding | Remediation |
|---|---|---|---|
| {{ item.control_id }} | {{ item.severity | upper }} | {{ item.details }} | {{ item.remediation }} |
{% endif %}
Detailed Assessment Results
{% set categories = report.results | groupby(‘category’) %} {% for category,
items in categories %}
{{ category }}
| ID | Control | Status | Severity | Details |
|---|---|---|---|---|
| {{ item.control_id }} | {{ item.title }} | {{ item.status | upper }} | {{ item.severity }} | {{ item.details }} |
{% for item in items %} {% if item.status == ‘fail’ and item.remediation %}
{% endif %} {% endfor %} {% endfor %}
Appendix: Raw Evidence
The following evidence was collected during automated assessment. Each
entry is timestamped and stored independently for audit trail purposes.
{% for item in report.results %} {% if item.status != ‘manual’ %}
{{ item.control_id }}: {{ item.title }}
Checked: {{ item.checked_at[:19] }} |
Status:
{{ item.status | upper }} |
Frameworks: {{ item.frameworks | join(‘, ‘) }}
{% endif %} {% endfor %}
Report Attestation
This report was generated automatically by the Compliance Assessment
Engine. All evidence was collected from live systems at the time
indicated. Manual review items require separate attestation by qualified
personnel.
| Report ID | {{ report.report_id }} |
| Generated | {{ report.generated_at[:19] }} |
| Framework | {{ report.framework_name }} v{{ report.framework_version }} |
| System | {{ report.hostname }} |
| Automated Controls | {{ report.total_controls – report.manual_review }} |
| Manual Controls | {{ report.manual_review }} |
Reviewed by: __________________________________ Date: ______________
Approved by: __________________________________ Date: ______________
That template is doing a lot of heavy lifting. It generates a professional report with a cover page, executive summary with a color-coded compliance score, prioritized remediation items, detailed results grouped by category, a raw evidence appendix, and a sign-off page. This is exactly the format auditors expect. The color-coding makes it easy to scan — green for pass, red for fail, orange for warnings.</p>
<h3>The Report Generator</h3>
<p>Now let us tie the engine and template together with the report generator that produces the actual PDF:</p>
<p>
python
!/usr/bin/env python3
“””
generate_report.py — Generate PDF compliance reports from check results.
Uses Jinja2 for templating and WeasyPrint for PDF conversion.
“””
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email import encoders
from datetime import datetime
from pathlib import Path
from dataclasses import asdict
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
from compliance_engine import ComplianceEngine
def generate_pdf_report(report, output_path: str = None) -> str:
“””Render compliance report as PDF using Jinja2 + WeasyPrint.”””
# Set up Jinja2 environment
env = Environment(
loader=FileSystemLoader('templates'),
autoescape=True
)
template = env.get_template('compliance_report.html')
# Render the HTML
report_data = report if isinstance(report, dict) else asdict(report)
html_content = template.render(report=report_data)
# Generate output path if not specified
if output_path is None:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
output_dir = Path('reports')
output_dir.mkdir(exist_ok=True)
output_path = str(output_dir / f"compliance_report_{timestamp}.pdf")
# Convert HTML to PDF
HTML(string=html_content).write_pdf(output_path)
file_size = Path(output_path).stat().st_size / 1024
print(f"nPDF report generated: {output_path} ({file_size:.1f} KB)")
return output_path
def email_report(pdf_path: str, config: dict):
“””Email the compliance report to stakeholders.”””
msg = MIMEMultipart()
msg['From'] = config['from_email']
msg['To'] = ', '.join(config['to_emails'])
msg['Subject'] = (f"Compliance Report — "
f"{datetime.now().strftime('%B %d, %Y')}")
# Email body
body = f"""
Your scheduled compliance assessment report is attached.
Report: {Path(pdf_path).name}
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
This is an automated report. Review any FAILED or WARNING items
and take corrective action as needed.
— Compliance Automation System
"""
msg.attach(MIMEText(body, 'plain'))
# Attach PDF
with open(pdf_path, 'rb') as f:
pdf_attachment = MIMEBase('application', 'pdf')
pdf_attachment.set_payload(f.read())
encoders.encode_base64(pdf_attachment)
pdf_attachment.add_header(
'Content-Disposition',
f'attachment; filename="{Path(pdf_path).name}"'
)
msg.attach(pdf_attachment)
# Send email
try:
with smtplib.SMTP(config['smtp_server'],
config['smtp_port']) as server:
server.starttls()
server.login(config['smtp_user'], config['smtp_password'])
server.send_message(msg)
print(f"Report emailed to: {', '.join(config['to_emails'])}")
except Exception as e:
print(f"Email failed: {e}")
print(f"Report saved locally at: {pdf_path}")
def main():
“””Run compliance checks and generate report.”””
import argparse
parser = argparse.ArgumentParser(
description='Generate compliance assessment report'
)
parser.add_argument(
'--framework', '-f',
default='frameworks/baseline-security.yaml',
help='Path to framework definition YAML'
)
parser.add_argument(
'--output', '-o',
help='Output PDF path (default: auto-generated)'
)
parser.add_argument(
'--email', '-e',
action='store_true',
help='Email report to configured recipients'
)
parser.add_argument(
'--email-config',
default='config/email.yaml',
help='Path to email configuration YAML'
)
args = parser.parse_args()
# Run compliance checks
engine = ComplianceEngine(args.framework)
report = engine.run_all_checks()
# Generate PDF
pdf_path = generate_pdf_report(report, args.output)
# Optionally email the report
if args.email:
import yaml
with open(args.email_config, 'r') as f:
email_config = yaml.safe_load(f)
email_report(pdf_path, email_config)
# Print summary
print(f"n{'='*60}")
print(f" COMPLIANCE SUMMARY")
print(f"{'='*60}")
print(f" Score: {report.score:.1f}%")
print(f" Passed: {report.passed}/{report.total_controls}")
print(f" Failed: {report.failed}")
print(f" Warnings: {report.warnings}")
print(f" Manual: {report.manual_review}")
print(f" Report: {pdf_path}")
print(f"{'='*60}n")
if name == ‘main‘:
main()
Scheduling with Cron (Linux/Mac) or Task Scheduler (Windows)
The whole point is that this runs without you thinking about it. Here is how to schedule it on both platforms.
Linux/Mac — Cron:
Edit crontab
crontab -e
Run compliance report every Monday at 6 AM
0 6 * * 1 cd /opt/compliance-reporter && /opt/compliance-reporter/venv/bin/python generate_report.py --email
Run daily checks (just evidence collection, no email)
0 2 * * * cd /opt/compliance-reporter && /opt/compliance-reporter/venv/bin/python generate_report.py
Run monthly comprehensive report on the 1st
0 8 1 * * cd /opt/compliance-reporter && /opt/compliance-reporter/venv/bin/python generate_report.py --framework frameworks/comprehensive.yaml --email
<strong>Windows — Task Scheduler via PowerShell</strong>:</p>
<p>
powershell
Create a scheduled task for weekly compliance reports
$Action = New-ScheduledTaskAction -Execute "C:compliance-reportervenvScriptspython.exe"
-Argument “generate_report.py –email” `
-WorkingDirectory “C:compliance-reporter”
$Trigger = New-ScheduledTaskTrigger `
-Weekly -DaysOfWeek Monday -At 6:00AM
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries
-DontStopIfGoingOnBatteries -StartWhenAvailable
-RunOnlyIfNetworkAvailable
$Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM"
-LogonType ServiceAccount `
-RunLevel Highest
Register-ScheduledTask -TaskName "Weekly Compliance Report"
-Action $Action -Trigger $Trigger
-Settings $Settings -Principal $Principal
-Description “Automated weekly compliance assessment and report generation”
Running as <strong>SYSTEM</strong> ensures the task has access to check firewall settings, read backup directories, and query Active Directory — all things that might fail under a regular user account. The <strong>-StartWhenAvailable</strong> flag means if the server is off at 6 AM Monday (maybe it restarted for patches), it will run the report as soon as it comes back online. Small detail, but it is the difference between "we have 52 weekly reports" and "we have 48 weekly reports and four gaps the auditor will ask about."</p>
<h2>Historical Evidence and Trend Reporting</h2>
<p>Running checks once produces a snapshot. Running them every day for six months produces a compliance story — and that is what auditors really want to see. Here is a script that analyzes your evidence archive to show compliance trends over time:</p>
<p>
python
!/usr/bin/env python3
“””
trend_report.py — Analyze historical compliance data and generate
trend visualization showing compliance score over time.
“””
from datetime import datetime
from pathlib import Path
from collections import defaultdict
def load_historical_reports(evidence_dir: str = “evidence”) -> list:
“””Load all historical report JSON files.”””
reports = []
evidence_path = Path(evidence_dir)
if not evidence_path.exists():
print(f"No evidence directory found at: {evidence_dir}")
return reports
for report_dir in sorted(evidence_path.iterdir()):
if report_dir.is_dir():
report_file = report_dir / "report.json"
if report_file.exists():
with open(report_file, 'r') as f:
try:
data = json.load(f)
reports.append(data)
except json.JSONDecodeError:
print(f"Skipping invalid JSON: {report_file}")
return reports
def generate_trend_summary(reports: list) -> dict:
“””Generate compliance trend data from historical reports.”””
if not reports:
return {‘error’: ‘No historical data available’}
trends = {
'total_reports': len(reports),
'date_range': {
'first': reports[0].get('generated_at', 'unknown')[:10],
'last': reports[-1].get('generated_at', 'unknown')[:10]
},
'scores': [],
'control_history': defaultdict(list),
'improvement_areas': [],
'regression_areas': []
}
for report in reports:
date = report.get('generated_at', '')[:10]
score = report.get('score', 0)
trends['scores'].append({'date': date, 'score': score})
# Track per-control status over time
for result in report.get('results', []):
control_id = result.get('control_id', 'unknown')
trends['control_history'][control_id].append({
'date': date,
'status': result.get('status', 'unknown')
})
# Identify improvements and regressions
if len(reports) >= 2:
latest = {r['control_id']: r['status']
for r in reports[-1].get('results', [])}
previous = {r['control_id']: r['status']
for r in reports[-2].get('results', [])}
for control_id in latest:
if control_id in previous:
if previous[control_id] == 'fail' and latest[control_id] == 'pass':
trends['improvement_areas'].append(control_id)
elif previous[control_id] == 'pass' and latest[control_id] == 'fail':
trends['regression_areas'].append(control_id)
# Calculate average score
all_scores = [s['score'] for s in trends['scores']]
trends['average_score'] = sum(all_scores) / len(all_scores)
trends['score_trend'] = ('improving'
if len(all_scores) >= 2
and all_scores[-1] > all_scores[0]
else 'declining'
if len(all_scores) >= 2
and all_scores[-1] < all_scores[0]
else 'stable')
return trends
def print_trend_report(trends: dict):
“””Print a formatted trend report to the console.”””
if ‘error’ in trends:
print(f”nError: {trends[‘error’]}”)
return
print(f"n{'='*60}")
print(f" COMPLIANCE TREND REPORT")
print(f"{'='*60}")
print(f" Period: {trends['date_range']['first']} → "
f"{trends['date_range']['last']}")
print(f" Reports analyzed: {trends['total_reports']}")
print(f" Average score: {trends['average_score']:.1f}%")
print(f" Trend: {trends['score_trend'].upper()}")
if trends['improvement_areas']:
print(f"n Improved controls: "
f"{', '.join(trends['improvement_areas'])}")
if trends['regression_areas']:
print(f"n Regressed controls: "
f"{', '.join(trends['regression_areas'])}")
print(f"n Score History:")
for entry in trends['scores'][-12:]: # Last 12 entries
bar_length = int(entry['score'] / 2)
bar = '' * bar_length + '' * (50 - bar_length)
print(f" {entry['date']} [{bar}] {entry['score']:.1f}%")
print(f"{'='*60}n")
if name == ‘main‘:
reports = load_historical_reports()
trends = generate_trend_summary(reports)
print_trend_report(trends)
When you hand an auditor a trend report showing your compliance score over six months — with timestamps, individual control tracking, and documented improvements — you are telling them a story. Not "we scrambled to get compliant last week" but "we have been monitoring and improving continuously." That is the difference between a stressful audit and one where the auditor says "this is the most organized documentation I have seen from a company your size." For a deeper look at this topic, see our guide on <a href="/blog/security-monitoring-small-businesses-watch-automate-alerts/">Security Monitoring for Small Businesses: What to Watch and How to Automate Alerts</a>.</p>
<h2>Framework-Specific Configuration Examples</h2>
<p>The YAML framework definition approach means you can create different profiles for different compliance needs. Here are some common ones for <a href="/it-consulting-ormond-beach">Ormond Beach</a> businesses:</p>
<h3>Healthcare (HIPAA-Focused)</h3>
<p>
yaml
frameworks/hipaa-focus.yaml
framework:
name: “HIPAA Security Rule Compliance”
version: “1.0”
categories:
– id: “ADMIN”
name: “Administrative Safeguards (§164.308)”
controls:
– id: “ADMIN-1”
title: “Risk Analysis”
description: “Annual risk analysis of ePHI”
check_type: “manual”
severity: “critical”
frameworks: [“HIPAA-164.308(a)(1)(ii)(A)”]
- id: "ADMIN-2"
title: "Workforce Security"
description: "Access authorization procedures"
check_type: "script"
check_script: "checks/ad_access_review.py"
severity: "high"
frameworks: ["HIPAA-164.308(a)(3)"]
- id: "ADMIN-3"
title: "Security Awareness Training"
description: "All workforce members trained annually"
check_type: "manual"
severity: "high"
frameworks: ["HIPAA-164.308(a)(5)"]
- id: "ADMIN-4"
title: "Contingency Plan"
description: "Data backup and disaster recovery plan"
check_type: "script"
check_script: "checks/backup_status.py"
severity: "critical"
frameworks: ["HIPAA-164.308(a)(7)"]
-
id: “TECH”
name: “Technical Safeguards (§164.312)”
controls:-
id: “TECH-1”
title: “Access Control”
description: “Unique user identification”
check_type: “script”
check_script: “checks/unique_accounts.py”
severity: “critical”
frameworks: [“HIPAA-164.312(a)(2)(i)”] -
id: “TECH-2”
title: “Audit Controls”
description: “Hardware/software recording mechanisms”
check_type: “script”
check_script: “checks/log_retention.py”
severity: “high”
frameworks: [“HIPAA-164.312(b)”] -
id: “TECH-3”
title: “Transmission Security”
description: “ePHI encrypted in transit”
check_type: “script”
check_script: “checks/tls_status.py”
severity: “critical”
frameworks: [“HIPAA-164.312(e)(1)”]
Retail/Hospitality (PCI DSS-Focused)
-
For businesses handling credit card data, you will want a PCI-focused framework. We covered this in depth in our PCI DSS compliance guide for Daytona Beach retail and hospitality — the check scripts we built there plug directly into this reporting system. Same scripts, same evidence, now with automated scheduling and professional PDF output.
Email Configuration
Set up your email delivery so reports land in the right inboxes automatically:
config/email.yaml
SMTP configuration for automated report delivery
For M365: use smtp.office365.com:587
For Google Workspace: use smtp.gmail.com:587 (requires App Password)
smtp_server: "smtp.office365.com"
smtp_port: 587
smtp_user: "[email protected]"
smtp_password: "${COMPLIANCE_EMAIL_PASSWORD}" # Use environment variable
from_email: "[email protected]"
to_emails:
- "[email protected]"
- "[email protected]"
- "[email protected]"
Optional: CC external compliance consultant
cc_emails:
- "[email protected]"
``text
A word about the password: never hardcode SMTP credentials in a file that might end up in version control. Use an environment variable —export COMPLIANCE_EMAIL_PASSWORD="your-password"in your.bashrc` or set it in the Windows Task Scheduler task. If you are using Microsoft 365, you can also use an App Password specifically for this automation, which lets you revoke it without affecting your regular login.
Putting It All Together
Here is the complete workflow, end to end:
Monday 2:00 AM: Cron triggers generate_report.py
2:00:01 AM: Engine loads baseline-security.yaml framework definition
2:00:02 AM: Check scripts execute — firewall, backups, MFA, encryption, patches, logs
2:00:30 AM: Results compiled into ComplianceReport with scores and evidence
2:00:31 AM: Evidence saved to evidence/20260320_020030/ with individual control files
2:00:35 AM: Jinja2 renders HTML report → WeasyPrint converts to PDF
2:00:40 AM: PDF emailed to stakeholders
2:00:41 AM: Done. Total runtime: ~40 seconds
You did not touch anything. You did not remember to run anything. You did not scramble to find evidence. The system collected it, evaluated it, formatted it, and delivered it — every single week, with timestamps that prove continuous monitoring.
When your auditor shows up in October, you will not need to reconstruct what happened in April. You will have the report from April. And May. And June. And every week in between. That is what continuous compliance looks like.
Common Customizations
Adding Slack Notifications for Failures
If a critical control fails, you probably want to know immediately — not wait until Monday morning's email. Add a Slack webhook notification for failures:
def notify_slack(webhook_url: str, report):
“””Send Slack alert if critical controls fail.”””
critical_fails = [r for r in report.results
if r[‘status’] == ‘fail’
and r[‘severity’] == ‘critical’]
if not critical_fails:
return # Only alert on critical failures
blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": " Critical Compliance Failure"
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (f"*{len(critical_fails)} critical control(s) "
f"failed* on {report.hostname}n"
f"Overall score: {report.score:.1f}%")
}
}
]
for fail in critical_fails:
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (f"*{fail['control_id']}*: {fail['title']}n"
f"_{fail['details']}_n"
f"Fix: {fail['remediation']}")
}
})
requests.post(webhook_url, json={"blocks": blocks})
Multiple Framework Reports
Run different frameworks on different schedules:
Weekly: baseline security (all businesses)
0 6 * * 1 cd /opt/compliance-reporter && python generate_report.py -f frameworks/baseline-security.yaml –email
Monthly: HIPAA deep dive (healthcare clients)
0 8 1 * * cd /opt/compliance-reporter && python generate_report.py -f frameworks/hipaa-focus.yaml –email
Quarterly: PCI DSS (retail/hospitality)
0 8 1 */3 * cd /opt/compliance-reporter && python generate_report.py -f frameworks/pci-focus.yaml –email
FAQ
How much technical knowledge do I need to set up automated compliance reporting?
You need basic comfort with Python and command-line tools. If you can install Python packages with pip and edit a YAML file, you can get this system running. The check scripts are modular — start with the ones we provide, then add your own as you learn. Most Volusia County businesses we work with have their IT person or managed service provider handle the initial setup, then the system runs hands-off from there.
Will automated compliance reports satisfy auditors?
Yes — in fact, automated reports often impress auditors because they demonstrate continuous monitoring rather than point-in-time compliance. The key is including raw evidence (not just pass/fail), timestamps for every check, and a historical archive. Our report template includes all of these. That said, some controls genuinely require manual review (like physical security or policy review), and the framework YAML handles those by flagging them as “manual” rather than pretending they can be automated.
Can I use this system for SOC 2 Type II audits?
Absolutely. SOC 2 Type II specifically requires evidence of controls operating effectively over a period of time — typically 6-12 months. That is exactly what scheduled compliance reporting provides. Run checks weekly, store the evidence, and when your auditor asks for proof that access controls were working in March, you pull up the March reports. The trend reporting script even shows your compliance score over time, which is precisely what Type II auditors want.
What if a compliance check fails — does the report still generate?
Yes. Failed checks are documented in the report with their status, severity, evidence of what was found, and remediation guidance. This is actually valuable audit evidence — it shows you detected the issue. What matters for compliance is not that you never have failures, but that you detect them and remediate them promptly. A report showing a failure in Week 12 and a pass in Week 13 tells the auditor your monitoring and remediation process works.
How do I add checks for systems the Python script cannot reach directly?
For systems behind network boundaries (cloud services, SaaS platforms, remote offices), you have two options. First, many services offer APIs — write a check script that calls the API (Microsoft Graph for M365, Google Admin SDK for Workspace). Second, for systems without APIs, have those systems push their status to a shared location (like a network share or S3 bucket) that the compliance engine can read. The modular check script design makes both approaches straightforward.
What is the cost of running this system?
Effectively zero for the software — Python, Jinja2, WeasyPrint, and PyYAML are all free and open source. You need a machine to run it on, but any existing server or always-on workstation works. The only potential cost is email delivery if you do not already have SMTP access, but most businesses with Microsoft 365 or Google Workspace already have that included. Compare that to compliance automation platforms like Vanta ($10,000+/year) or Drata ($5,000+/year), and the DIY approach makes a lot of sense for businesses with straightforward compliance needs.