feat: Add enterprise DBA features for production reliability
New features implemented: 1. Backup Catalog (internal/catalog/) - SQLite-based backup tracking - Gap detection and RPO monitoring - Search and statistics - Filesystem sync 2. DR Drill Testing (internal/drill/) - Automated restore testing in Docker containers - Database validation with custom queries - Catalog integration for drill-tested status 3. Smart Notifications (internal/notify/) - Event batching with configurable intervals - Time-based escalation policies - HTML/text/Slack templates 4. Compliance Reports (internal/report/) - SOC2, GDPR, HIPAA, PCI-DSS, ISO27001 frameworks - Evidence collection from catalog - JSON, Markdown, HTML output formats 5. RTO/RPO Calculator (internal/rto/) - Recovery objective analysis - RTO breakdown by phase - Recommendations for improvement 6. Replica-Aware Backup (internal/replica/) - Topology detection for PostgreSQL/MySQL - Automatic replica selection - Configurable selection strategies 7. Parallel Table Backup (internal/parallel/) - Concurrent table dumps - Worker pool with progress tracking - Large table optimization 8. MySQL/MariaDB PITR (internal/pitr/) - Binary log parsing and replay - Point-in-time recovery support - Transaction filtering CLI commands added: catalog, drill, report, rto All changes support the goal: reliable 3 AM database recovery.
This commit is contained in:
424
internal/report/frameworks.go
Normal file
424
internal/report/frameworks.go
Normal file
@@ -0,0 +1,424 @@
|
||||
// Package report - SOC2 framework controls
|
||||
package report
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SOC2Framework returns SOC2 Trust Service Criteria controls
|
||||
func SOC2Framework() []Category {
|
||||
return []Category{
|
||||
soc2Security(),
|
||||
soc2Availability(),
|
||||
soc2ProcessingIntegrity(),
|
||||
soc2Confidentiality(),
|
||||
}
|
||||
}
|
||||
|
||||
func soc2Security() Category {
|
||||
return Category{
|
||||
ID: "soc2-security",
|
||||
Name: "Security",
|
||||
Description: "Protection of system resources against unauthorized access",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "CC6.1",
|
||||
Reference: "SOC2 CC6.1",
|
||||
Name: "Encryption at Rest",
|
||||
Description: "Data is protected at rest using encryption",
|
||||
},
|
||||
{
|
||||
ID: "CC6.7",
|
||||
Reference: "SOC2 CC6.7",
|
||||
Name: "Encryption in Transit",
|
||||
Description: "Data is protected in transit using encryption",
|
||||
},
|
||||
{
|
||||
ID: "CC6.2",
|
||||
Reference: "SOC2 CC6.2",
|
||||
Name: "Access Control",
|
||||
Description: "Logical access to data and system components is restricted",
|
||||
},
|
||||
{
|
||||
ID: "CC6.3",
|
||||
Reference: "SOC2 CC6.3",
|
||||
Name: "Authorized Access",
|
||||
Description: "Only authorized users can access data and systems",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func soc2Availability() Category {
|
||||
return Category{
|
||||
ID: "soc2-availability",
|
||||
Name: "Availability",
|
||||
Description: "System availability for operation and use as agreed",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "A1.1",
|
||||
Reference: "SOC2 A1.1",
|
||||
Name: "Backup Policy",
|
||||
Description: "Backup policies and procedures are established and operating",
|
||||
},
|
||||
{
|
||||
ID: "A1.2",
|
||||
Reference: "SOC2 A1.2",
|
||||
Name: "Backup Testing",
|
||||
Description: "Backups are tested for recoverability",
|
||||
},
|
||||
{
|
||||
ID: "A1.3",
|
||||
Reference: "SOC2 A1.3",
|
||||
Name: "Recovery Procedures",
|
||||
Description: "Recovery procedures are documented and tested",
|
||||
},
|
||||
{
|
||||
ID: "A1.4",
|
||||
Reference: "SOC2 A1.4",
|
||||
Name: "Disaster Recovery",
|
||||
Description: "DR plans are maintained and tested",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func soc2ProcessingIntegrity() Category {
|
||||
return Category{
|
||||
ID: "soc2-processing-integrity",
|
||||
Name: "Processing Integrity",
|
||||
Description: "System processing is complete, valid, accurate, timely, and authorized",
|
||||
Weight: 0.75,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "PI1.1",
|
||||
Reference: "SOC2 PI1.1",
|
||||
Name: "Data Integrity",
|
||||
Description: "Checksums and verification ensure data integrity",
|
||||
},
|
||||
{
|
||||
ID: "PI1.2",
|
||||
Reference: "SOC2 PI1.2",
|
||||
Name: "Error Handling",
|
||||
Description: "Errors are identified and corrected in a timely manner",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func soc2Confidentiality() Category {
|
||||
return Category{
|
||||
ID: "soc2-confidentiality",
|
||||
Name: "Confidentiality",
|
||||
Description: "Information designated as confidential is protected",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "C1.1",
|
||||
Reference: "SOC2 C1.1",
|
||||
Name: "Data Classification",
|
||||
Description: "Confidential data is identified and classified",
|
||||
},
|
||||
{
|
||||
ID: "C1.2",
|
||||
Reference: "SOC2 C1.2",
|
||||
Name: "Data Retention",
|
||||
Description: "Data retention policies are implemented",
|
||||
},
|
||||
{
|
||||
ID: "C1.3",
|
||||
Reference: "SOC2 C1.3",
|
||||
Name: "Data Disposal",
|
||||
Description: "Data is securely disposed when no longer needed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GDPRFramework returns GDPR-related controls
|
||||
func GDPRFramework() []Category {
|
||||
return []Category{
|
||||
{
|
||||
ID: "gdpr-data-protection",
|
||||
Name: "Data Protection",
|
||||
Description: "Protection of personal data",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "GDPR-25",
|
||||
Reference: "GDPR Article 25",
|
||||
Name: "Data Protection by Design",
|
||||
Description: "Data protection measures are implemented by design",
|
||||
},
|
||||
{
|
||||
ID: "GDPR-32",
|
||||
Reference: "GDPR Article 32",
|
||||
Name: "Security of Processing",
|
||||
Description: "Appropriate technical measures to ensure data security",
|
||||
},
|
||||
{
|
||||
ID: "GDPR-33",
|
||||
Reference: "GDPR Article 33",
|
||||
Name: "Breach Notification",
|
||||
Description: "Procedures for breach detection and notification",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "gdpr-data-retention",
|
||||
Name: "Data Retention",
|
||||
Description: "Lawful data retention practices",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "GDPR-5.1e",
|
||||
Reference: "GDPR Article 5(1)(e)",
|
||||
Name: "Storage Limitation",
|
||||
Description: "Personal data not kept longer than necessary",
|
||||
},
|
||||
{
|
||||
ID: "GDPR-17",
|
||||
Reference: "GDPR Article 17",
|
||||
Name: "Right to Erasure",
|
||||
Description: "Ability to delete personal data on request",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HIPAAFramework returns HIPAA-related controls
|
||||
func HIPAAFramework() []Category {
|
||||
return []Category{
|
||||
{
|
||||
ID: "hipaa-administrative",
|
||||
Name: "Administrative Safeguards",
|
||||
Description: "Administrative policies and procedures",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "164.308a7",
|
||||
Reference: "HIPAA 164.308(a)(7)",
|
||||
Name: "Contingency Plan",
|
||||
Description: "Data backup and disaster recovery procedures",
|
||||
},
|
||||
{
|
||||
ID: "164.308a7iA",
|
||||
Reference: "HIPAA 164.308(a)(7)(ii)(A)",
|
||||
Name: "Data Backup Plan",
|
||||
Description: "Procedures for retrievable exact copies of ePHI",
|
||||
},
|
||||
{
|
||||
ID: "164.308a7iB",
|
||||
Reference: "HIPAA 164.308(a)(7)(ii)(B)",
|
||||
Name: "Disaster Recovery Plan",
|
||||
Description: "Procedures to restore any loss of data",
|
||||
},
|
||||
{
|
||||
ID: "164.308a7iD",
|
||||
Reference: "HIPAA 164.308(a)(7)(ii)(D)",
|
||||
Name: "Testing and Revision",
|
||||
Description: "Testing of contingency plans",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "hipaa-technical",
|
||||
Name: "Technical Safeguards",
|
||||
Description: "Technical security measures",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "164.312a2iv",
|
||||
Reference: "HIPAA 164.312(a)(2)(iv)",
|
||||
Name: "Encryption",
|
||||
Description: "Encryption of ePHI",
|
||||
},
|
||||
{
|
||||
ID: "164.312c1",
|
||||
Reference: "HIPAA 164.312(c)(1)",
|
||||
Name: "Integrity Controls",
|
||||
Description: "Mechanisms to ensure ePHI is not improperly altered",
|
||||
},
|
||||
{
|
||||
ID: "164.312e1",
|
||||
Reference: "HIPAA 164.312(e)(1)",
|
||||
Name: "Transmission Security",
|
||||
Description: "Technical measures to guard against unauthorized access",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PCIDSSFramework returns PCI-DSS related controls
|
||||
func PCIDSSFramework() []Category {
|
||||
return []Category{
|
||||
{
|
||||
ID: "pci-protect",
|
||||
Name: "Protect Stored Data",
|
||||
Description: "Protect stored cardholder data",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "PCI-3.1",
|
||||
Reference: "PCI-DSS 3.1",
|
||||
Name: "Data Retention Policy",
|
||||
Description: "Retention policy limits storage time",
|
||||
},
|
||||
{
|
||||
ID: "PCI-3.4",
|
||||
Reference: "PCI-DSS 3.4",
|
||||
Name: "Encryption",
|
||||
Description: "Render PAN unreadable anywhere it is stored",
|
||||
},
|
||||
{
|
||||
ID: "PCI-3.5",
|
||||
Reference: "PCI-DSS 3.5",
|
||||
Name: "Key Management",
|
||||
Description: "Protect cryptographic keys",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "pci-maintain",
|
||||
Name: "Maintain Security",
|
||||
Description: "Maintain security policies and procedures",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "PCI-12.10.1",
|
||||
Reference: "PCI-DSS 12.10.1",
|
||||
Name: "Incident Response Plan",
|
||||
Description: "Incident response plan includes data recovery",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ISO27001Framework returns ISO 27001 related controls
|
||||
func ISO27001Framework() []Category {
|
||||
return []Category{
|
||||
{
|
||||
ID: "iso-operations",
|
||||
Name: "Operations Security",
|
||||
Description: "A.12 Operations Security controls",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "A.12.3.1",
|
||||
Reference: "ISO 27001 A.12.3.1",
|
||||
Name: "Information Backup",
|
||||
Description: "Backup copies taken and tested regularly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "iso-continuity",
|
||||
Name: "Business Continuity",
|
||||
Description: "A.17 Business Continuity controls",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "A.17.1.1",
|
||||
Reference: "ISO 27001 A.17.1.1",
|
||||
Name: "Planning Continuity",
|
||||
Description: "Information security continuity planning",
|
||||
},
|
||||
{
|
||||
ID: "A.17.1.2",
|
||||
Reference: "ISO 27001 A.17.1.2",
|
||||
Name: "Implementing Continuity",
|
||||
Description: "Implementation of security continuity",
|
||||
},
|
||||
{
|
||||
ID: "A.17.1.3",
|
||||
Reference: "ISO 27001 A.17.1.3",
|
||||
Name: "Verify and Review",
|
||||
Description: "Verify and review continuity controls",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "iso-cryptography",
|
||||
Name: "Cryptography",
|
||||
Description: "A.10 Cryptographic controls",
|
||||
Weight: 1.0,
|
||||
Controls: []Control{
|
||||
{
|
||||
ID: "A.10.1.1",
|
||||
Reference: "ISO 27001 A.10.1.1",
|
||||
Name: "Cryptographic Controls",
|
||||
Description: "Policy on use of cryptographic controls",
|
||||
},
|
||||
{
|
||||
ID: "A.10.1.2",
|
||||
Reference: "ISO 27001 A.10.1.2",
|
||||
Name: "Key Management",
|
||||
Description: "Policy on cryptographic key management",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetFramework returns the appropriate framework for a report type
|
||||
func GetFramework(reportType ReportType) []Category {
|
||||
switch reportType {
|
||||
case ReportSOC2:
|
||||
return SOC2Framework()
|
||||
case ReportGDPR:
|
||||
return GDPRFramework()
|
||||
case ReportHIPAA:
|
||||
return HIPAAFramework()
|
||||
case ReportPCIDSS:
|
||||
return PCIDSSFramework()
|
||||
case ReportISO27001:
|
||||
return ISO27001Framework()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePeriodReport creates a report for a specific time period
|
||||
func CreatePeriodReport(reportType ReportType, start, end time.Time) *Report {
|
||||
title := ""
|
||||
desc := ""
|
||||
|
||||
switch reportType {
|
||||
case ReportSOC2:
|
||||
title = "SOC 2 Type II Compliance Report"
|
||||
desc = "Trust Service Criteria compliance assessment"
|
||||
case ReportGDPR:
|
||||
title = "GDPR Data Protection Compliance Report"
|
||||
desc = "General Data Protection Regulation compliance assessment"
|
||||
case ReportHIPAA:
|
||||
title = "HIPAA Security Compliance Report"
|
||||
desc = "Health Insurance Portability and Accountability Act compliance assessment"
|
||||
case ReportPCIDSS:
|
||||
title = "PCI-DSS Compliance Report"
|
||||
desc = "Payment Card Industry Data Security Standard compliance assessment"
|
||||
case ReportISO27001:
|
||||
title = "ISO 27001 Compliance Report"
|
||||
desc = "Information Security Management System compliance assessment"
|
||||
default:
|
||||
title = "Custom Compliance Report"
|
||||
desc = "Custom compliance assessment"
|
||||
}
|
||||
|
||||
report := NewReport(reportType, title)
|
||||
report.Description = desc
|
||||
report.PeriodStart = start
|
||||
report.PeriodEnd = end
|
||||
|
||||
// Load framework controls
|
||||
framework := GetFramework(reportType)
|
||||
for _, cat := range framework {
|
||||
report.AddCategory(cat)
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
420
internal/report/generator.go
Normal file
420
internal/report/generator.go
Normal file
@@ -0,0 +1,420 @@
|
||||
// Package report - Report generator
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/catalog"
|
||||
)
|
||||
|
||||
// Generator generates compliance reports
|
||||
type Generator struct {
|
||||
catalog catalog.Catalog
|
||||
config ReportConfig
|
||||
}
|
||||
|
||||
// NewGenerator creates a new report generator
|
||||
func NewGenerator(cat catalog.Catalog, config ReportConfig) *Generator {
|
||||
return &Generator{
|
||||
catalog: cat,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a compliance report
|
||||
func (g *Generator) Generate() (*Report, error) {
|
||||
report := CreatePeriodReport(g.config.Type, g.config.PeriodStart, g.config.PeriodEnd)
|
||||
report.Title = g.config.Title
|
||||
if g.config.Description != "" {
|
||||
report.Description = g.config.Description
|
||||
}
|
||||
|
||||
// Collect evidence from catalog
|
||||
evidence, err := g.collectEvidence()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to collect evidence: %w", err)
|
||||
}
|
||||
|
||||
for _, e := range evidence {
|
||||
report.AddEvidence(e)
|
||||
}
|
||||
|
||||
// Evaluate controls
|
||||
if err := g.evaluateControls(report, evidence); err != nil {
|
||||
return nil, fmt.Errorf("failed to evaluate controls: %w", err)
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
report.Calculate()
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// collectEvidence gathers evidence from the backup catalog
|
||||
func (g *Generator) collectEvidence() ([]Evidence, error) {
|
||||
var evidence []Evidence
|
||||
ctx := context.Background()
|
||||
|
||||
// Get backup entries in the report period
|
||||
query := &catalog.SearchQuery{
|
||||
StartDate: &g.config.PeriodStart,
|
||||
EndDate: &g.config.PeriodEnd,
|
||||
Limit: 1000,
|
||||
}
|
||||
|
||||
entries, err := g.catalog.Search(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search catalog: %w", err)
|
||||
}
|
||||
|
||||
// Create evidence for backups
|
||||
for _, entry := range entries {
|
||||
e := Evidence{
|
||||
ID: fmt.Sprintf("BKP-%d", entry.ID),
|
||||
Type: EvidenceBackupLog,
|
||||
Description: fmt.Sprintf("Backup of %s completed", entry.Database),
|
||||
Source: entry.BackupPath,
|
||||
CollectedAt: entry.CreatedAt,
|
||||
Data: map[string]interface{}{
|
||||
"database": entry.Database,
|
||||
"database_type": entry.DatabaseType,
|
||||
"size": entry.SizeBytes,
|
||||
"sha256": entry.SHA256,
|
||||
"encrypted": entry.Encrypted,
|
||||
"compression": entry.Compression,
|
||||
"status": entry.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if entry.SHA256 != "" {
|
||||
e.Hash = entry.SHA256
|
||||
}
|
||||
|
||||
evidence = append(evidence, e)
|
||||
|
||||
// Add verification evidence
|
||||
if entry.VerifiedAt != nil {
|
||||
evidence = append(evidence, Evidence{
|
||||
ID: fmt.Sprintf("VRF-%d", entry.ID),
|
||||
Type: EvidenceAuditLog,
|
||||
Description: fmt.Sprintf("Verification of backup %s", entry.BackupPath),
|
||||
Source: "verification_system",
|
||||
CollectedAt: *entry.VerifiedAt,
|
||||
Data: map[string]interface{}{
|
||||
"backup_id": entry.ID,
|
||||
"database": entry.Database,
|
||||
"verified": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add drill evidence
|
||||
if entry.DrillTestedAt != nil {
|
||||
evidence = append(evidence, Evidence{
|
||||
ID: fmt.Sprintf("DRL-%d", entry.ID),
|
||||
Type: EvidenceDrillResult,
|
||||
Description: fmt.Sprintf("DR drill test of backup %s", entry.BackupPath),
|
||||
Source: "drill_system",
|
||||
CollectedAt: *entry.DrillTestedAt,
|
||||
Data: map[string]interface{}{
|
||||
"backup_id": entry.ID,
|
||||
"database": entry.Database,
|
||||
"passed": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add encryption evidence
|
||||
if entry.Encrypted {
|
||||
encryption := "AES-256"
|
||||
if meta, ok := entry.Metadata["encryption_method"]; ok {
|
||||
encryption = meta
|
||||
}
|
||||
evidence = append(evidence, Evidence{
|
||||
ID: fmt.Sprintf("ENC-%d", entry.ID),
|
||||
Type: EvidenceEncryptionProof,
|
||||
Description: fmt.Sprintf("Encrypted backup %s", entry.BackupPath),
|
||||
Source: entry.BackupPath,
|
||||
CollectedAt: entry.CreatedAt,
|
||||
Data: map[string]interface{}{
|
||||
"backup_id": entry.ID,
|
||||
"database": entry.Database,
|
||||
"encryption": encryption,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get catalog statistics for retention evidence
|
||||
stats, err := g.catalog.Stats(ctx)
|
||||
if err == nil {
|
||||
evidence = append(evidence, Evidence{
|
||||
ID: "RET-STATS",
|
||||
Type: EvidenceRetentionProof,
|
||||
Description: "Backup retention statistics",
|
||||
Source: "catalog",
|
||||
CollectedAt: time.Now(),
|
||||
Data: map[string]interface{}{
|
||||
"total_backups": stats.TotalBackups,
|
||||
"oldest_backup": stats.OldestBackup,
|
||||
"newest_backup": stats.NewestBackup,
|
||||
"average_size": stats.AvgSize,
|
||||
"total_size": stats.TotalSize,
|
||||
"databases": len(stats.ByDatabase),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Check for gaps
|
||||
gapConfig := &catalog.GapDetectionConfig{
|
||||
ExpectedInterval: 24 * time.Hour,
|
||||
Tolerance: 2 * time.Hour,
|
||||
StartDate: &g.config.PeriodStart,
|
||||
EndDate: &g.config.PeriodEnd,
|
||||
}
|
||||
|
||||
allGaps, err := g.catalog.DetectAllGaps(ctx, gapConfig)
|
||||
if err == nil {
|
||||
totalGaps := 0
|
||||
for _, gaps := range allGaps {
|
||||
totalGaps += len(gaps)
|
||||
}
|
||||
if totalGaps > 0 {
|
||||
evidence = append(evidence, Evidence{
|
||||
ID: "GAP-ANALYSIS",
|
||||
Type: EvidenceAuditLog,
|
||||
Description: "Backup gap analysis",
|
||||
Source: "catalog",
|
||||
CollectedAt: time.Now(),
|
||||
Data: map[string]interface{}{
|
||||
"gaps_detected": totalGaps,
|
||||
"gaps": allGaps,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return evidence, nil
|
||||
}
|
||||
|
||||
// evaluateControls evaluates compliance controls based on evidence
|
||||
func (g *Generator) evaluateControls(report *Report, evidence []Evidence) error {
|
||||
// Index evidence by type for quick lookup
|
||||
evidenceByType := make(map[EvidenceType][]Evidence)
|
||||
for _, e := range evidence {
|
||||
evidenceByType[e.Type] = append(evidenceByType[e.Type], e)
|
||||
}
|
||||
|
||||
// Get backup statistics
|
||||
backupEvidence := evidenceByType[EvidenceBackupLog]
|
||||
encryptionEvidence := evidenceByType[EvidenceEncryptionProof]
|
||||
drillEvidence := evidenceByType[EvidenceDrillResult]
|
||||
verificationEvidence := evidenceByType[EvidenceAuditLog]
|
||||
|
||||
// Evaluate each control
|
||||
for i := range report.Categories {
|
||||
cat := &report.Categories[i]
|
||||
catCompliant := 0
|
||||
catTotal := 0
|
||||
|
||||
for j := range cat.Controls {
|
||||
ctrl := &cat.Controls[j]
|
||||
ctrl.LastChecked = time.Now()
|
||||
catTotal++
|
||||
|
||||
// Evaluate based on control type
|
||||
status, notes, evidenceIDs := g.evaluateControl(ctrl, backupEvidence, encryptionEvidence, drillEvidence, verificationEvidence)
|
||||
ctrl.Status = status
|
||||
ctrl.Notes = notes
|
||||
ctrl.Evidence = evidenceIDs
|
||||
|
||||
if status == StatusCompliant {
|
||||
catCompliant++
|
||||
} else if status != StatusNotApplicable {
|
||||
// Create finding for non-compliant controls
|
||||
finding := g.createFinding(ctrl, report)
|
||||
if finding != nil {
|
||||
report.AddFinding(*finding)
|
||||
ctrl.Findings = append(ctrl.Findings, finding.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate category score
|
||||
if catTotal > 0 {
|
||||
cat.Score = float64(catCompliant) / float64(catTotal) * 100
|
||||
if cat.Score >= 100 {
|
||||
cat.Status = StatusCompliant
|
||||
} else if cat.Score >= 70 {
|
||||
cat.Status = StatusPartial
|
||||
} else {
|
||||
cat.Status = StatusNonCompliant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluateControl evaluates a single control
|
||||
func (g *Generator) evaluateControl(ctrl *Control, backups, encryption, drills, verifications []Evidence) (ComplianceStatus, string, []string) {
|
||||
var evidenceIDs []string
|
||||
|
||||
switch ctrl.ID {
|
||||
// SOC2 Controls
|
||||
case "CC6.1", "GDPR-32", "164.312a2iv", "PCI-3.4", "A.10.1.1":
|
||||
// Encryption at rest
|
||||
if len(encryption) == 0 {
|
||||
return StatusNonCompliant, "No encrypted backups found", nil
|
||||
}
|
||||
encryptedCount := len(encryption)
|
||||
totalCount := len(backups)
|
||||
if totalCount == 0 {
|
||||
return StatusNotApplicable, "No backups in period", nil
|
||||
}
|
||||
rate := float64(encryptedCount) / float64(totalCount) * 100
|
||||
for _, e := range encryption {
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
if rate >= 100 {
|
||||
return StatusCompliant, fmt.Sprintf("100%% of backups encrypted (%d/%d)", encryptedCount, totalCount), evidenceIDs
|
||||
}
|
||||
if rate >= 90 {
|
||||
return StatusPartial, fmt.Sprintf("%.1f%% of backups encrypted (%d/%d)", rate, encryptedCount, totalCount), evidenceIDs
|
||||
}
|
||||
return StatusNonCompliant, fmt.Sprintf("Only %.1f%% of backups encrypted", rate), evidenceIDs
|
||||
|
||||
case "A1.1", "164.308a7iA", "A.12.3.1":
|
||||
// Backup policy/plan
|
||||
if len(backups) == 0 {
|
||||
return StatusNonCompliant, "No backups found in period", nil
|
||||
}
|
||||
for _, e := range backups[:min(5, len(backups))] {
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
return StatusCompliant, fmt.Sprintf("%d backups created in period", len(backups)), evidenceIDs
|
||||
|
||||
case "A1.2", "164.308a7iD", "A.17.1.3":
|
||||
// Backup testing
|
||||
if len(drills) == 0 {
|
||||
return StatusNonCompliant, "No DR drill tests performed", nil
|
||||
}
|
||||
for _, e := range drills {
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
return StatusCompliant, fmt.Sprintf("%d DR drill tests completed", len(drills)), evidenceIDs
|
||||
|
||||
case "A1.3", "A1.4", "164.308a7iB", "A.17.1.1", "A.17.1.2", "PCI-12.10.1":
|
||||
// DR procedures
|
||||
if len(drills) > 0 {
|
||||
for _, e := range drills {
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
return StatusCompliant, "DR procedures tested", evidenceIDs
|
||||
}
|
||||
return StatusPartial, "DR procedures exist but not tested", nil
|
||||
|
||||
case "PI1.1", "164.312c1":
|
||||
// Data integrity
|
||||
integrityCount := 0
|
||||
for _, e := range backups {
|
||||
if data, ok := e.Data.(map[string]interface{}); ok {
|
||||
if checksum, ok := data["checksum"].(string); ok && checksum != "" {
|
||||
integrityCount++
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if integrityCount == len(backups) && len(backups) > 0 {
|
||||
return StatusCompliant, "All backups have integrity checksums", evidenceIDs
|
||||
}
|
||||
if integrityCount > 0 {
|
||||
return StatusPartial, fmt.Sprintf("%d/%d backups have checksums", integrityCount, len(backups)), evidenceIDs
|
||||
}
|
||||
return StatusNonCompliant, "No integrity checksums found", nil
|
||||
|
||||
case "C1.2", "GDPR-5.1e", "PCI-3.1":
|
||||
// Data retention
|
||||
for _, e := range verifications {
|
||||
if e.Type == EvidenceRetentionProof {
|
||||
evidenceIDs = append(evidenceIDs, e.ID)
|
||||
}
|
||||
}
|
||||
if len(backups) > 0 {
|
||||
return StatusCompliant, "Retention policy in effect", evidenceIDs
|
||||
}
|
||||
return StatusPartial, "Retention policy needs review", nil
|
||||
|
||||
default:
|
||||
// Generic evaluation
|
||||
if len(backups) > 0 {
|
||||
return StatusCompliant, "Evidence available", nil
|
||||
}
|
||||
return StatusUnknown, "Requires manual review", nil
|
||||
}
|
||||
}
|
||||
|
||||
// createFinding creates a finding for a non-compliant control
|
||||
func (g *Generator) createFinding(ctrl *Control, report *Report) *Finding {
|
||||
if ctrl.Status == StatusCompliant || ctrl.Status == StatusNotApplicable {
|
||||
return nil
|
||||
}
|
||||
|
||||
severity := SeverityMedium
|
||||
findingType := FindingGap
|
||||
|
||||
// Determine severity based on control
|
||||
switch ctrl.ID {
|
||||
case "CC6.1", "164.312a2iv", "PCI-3.4":
|
||||
severity = SeverityHigh
|
||||
findingType = FindingViolation
|
||||
case "A1.2", "164.308a7iD":
|
||||
severity = SeverityMedium
|
||||
findingType = FindingGap
|
||||
}
|
||||
|
||||
return &Finding{
|
||||
ID: fmt.Sprintf("FND-%s-%d", ctrl.ID, time.Now().UnixNano()),
|
||||
ControlID: ctrl.ID,
|
||||
Type: findingType,
|
||||
Severity: severity,
|
||||
Title: fmt.Sprintf("%s: %s", ctrl.Reference, ctrl.Name),
|
||||
Description: ctrl.Notes,
|
||||
Impact: fmt.Sprintf("Non-compliance with %s requirements", report.Type),
|
||||
Recommendation: g.getRecommendation(ctrl.ID),
|
||||
Status: FindingOpen,
|
||||
DetectedAt: time.Now(),
|
||||
Evidence: ctrl.Evidence,
|
||||
}
|
||||
}
|
||||
|
||||
// getRecommendation returns remediation recommendation for a control
|
||||
func (g *Generator) getRecommendation(controlID string) string {
|
||||
recommendations := map[string]string{
|
||||
"CC6.1": "Enable encryption for all backups using AES-256",
|
||||
"CC6.7": "Ensure all backup transfers use TLS",
|
||||
"A1.1": "Establish and document backup schedule",
|
||||
"A1.2": "Schedule and perform regular DR drill tests",
|
||||
"A1.3": "Document and test recovery procedures",
|
||||
"A1.4": "Develop and test disaster recovery plan",
|
||||
"PI1.1": "Enable checksum verification for all backups",
|
||||
"C1.2": "Implement and document retention policies",
|
||||
"164.312a2iv": "Enable HIPAA-compliant encryption (AES-256)",
|
||||
"164.308a7iD": "Test backup recoverability quarterly",
|
||||
"PCI-3.4": "Encrypt all backups containing cardholder data",
|
||||
}
|
||||
|
||||
if rec, ok := recommendations[controlID]; ok {
|
||||
return rec
|
||||
}
|
||||
return "Review and remediate as per compliance requirements"
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
544
internal/report/output.go
Normal file
544
internal/report/output.go
Normal file
@@ -0,0 +1,544 @@
|
||||
// Package report - Output formatters
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Formatter formats reports for output
|
||||
type Formatter interface {
|
||||
Format(report *Report, w io.Writer) error
|
||||
}
|
||||
|
||||
// JSONFormatter formats reports as JSON
|
||||
type JSONFormatter struct {
|
||||
Indent bool
|
||||
}
|
||||
|
||||
// Format writes the report as JSON
|
||||
func (f *JSONFormatter) Format(report *Report, w io.Writer) error {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
if f.Indent {
|
||||
data, err = json.MarshalIndent(report, "", " ")
|
||||
} else {
|
||||
data, err = json.Marshal(report)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkdownFormatter formats reports as Markdown
|
||||
type MarkdownFormatter struct{}
|
||||
|
||||
// Format writes the report as Markdown
|
||||
func (f *MarkdownFormatter) Format(report *Report, w io.Writer) error {
|
||||
tmpl := template.Must(template.New("report").Funcs(template.FuncMap{
|
||||
"statusIcon": StatusIcon,
|
||||
"severityIcon": SeverityIcon,
|
||||
"formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
|
||||
"formatDate": func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
"upper": strings.ToUpper,
|
||||
}).Parse(markdownTemplate))
|
||||
|
||||
return tmpl.Execute(w, report)
|
||||
}
|
||||
|
||||
const markdownTemplate = `# {{.Title}}
|
||||
|
||||
**Generated:** {{formatTime .GeneratedAt}}
|
||||
**Period:** {{formatDate .PeriodStart}} to {{formatDate .PeriodEnd}}
|
||||
**Overall Status:** {{statusIcon .Status}} {{.Status}}
|
||||
**Compliance Score:** {{printf "%.1f" .Score}}%
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
{{.Description}}
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Controls | {{.Summary.TotalControls}} |
|
||||
| Compliant | {{.Summary.CompliantControls}} |
|
||||
| Non-Compliant | {{.Summary.NonCompliantControls}} |
|
||||
| Partial | {{.Summary.PartialControls}} |
|
||||
| Compliance Rate | {{printf "%.1f" .Summary.ComplianceRate}}% |
|
||||
| Open Findings | {{.Summary.OpenFindings}} |
|
||||
| Risk Score | {{printf "%.1f" .Summary.RiskScore}} |
|
||||
|
||||
---
|
||||
|
||||
## Compliance Categories
|
||||
|
||||
{{range .Categories}}
|
||||
### {{statusIcon .Status}} {{.Name}}
|
||||
|
||||
**Status:** {{.Status}} | **Score:** {{printf "%.1f" .Score}}%
|
||||
|
||||
{{.Description}}
|
||||
|
||||
| Control | Reference | Status | Notes |
|
||||
|---------|-----------|--------|-------|
|
||||
{{range .Controls}}| {{.Name}} | {{.Reference}} | {{statusIcon .Status}} | {{.Notes}} |
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
{{if .Findings}}
|
||||
| ID | Severity | Title | Status |
|
||||
|----|----------|-------|--------|
|
||||
{{range .Findings}}| {{.ID}} | {{severityIcon .Severity}} {{.Severity}} | {{.Title}} | {{.Status}} |
|
||||
{{end}}
|
||||
|
||||
### Finding Details
|
||||
|
||||
{{range .Findings}}
|
||||
#### {{severityIcon .Severity}} {{.Title}}
|
||||
|
||||
- **ID:** {{.ID}}
|
||||
- **Control:** {{.ControlID}}
|
||||
- **Severity:** {{.Severity}}
|
||||
- **Type:** {{.Type}}
|
||||
- **Status:** {{.Status}}
|
||||
- **Detected:** {{formatTime .DetectedAt}}
|
||||
|
||||
**Description:** {{.Description}}
|
||||
|
||||
**Impact:** {{.Impact}}
|
||||
|
||||
**Recommendation:** {{.Recommendation}}
|
||||
|
||||
---
|
||||
|
||||
{{end}}
|
||||
{{else}}
|
||||
No open findings.
|
||||
{{end}}
|
||||
|
||||
---
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
{{if .Evidence}}
|
||||
| ID | Type | Description | Collected |
|
||||
|----|------|-------------|-----------|
|
||||
{{range .Evidence}}| {{.ID}} | {{.Type}} | {{.Description}} | {{formatTime .CollectedAt}} |
|
||||
{{end}}
|
||||
{{else}}
|
||||
No evidence collected.
|
||||
{{end}}
|
||||
|
||||
---
|
||||
|
||||
*Report generated by dbbackup compliance module*
|
||||
`
|
||||
|
||||
// HTMLFormatter formats reports as HTML
|
||||
type HTMLFormatter struct{}
|
||||
|
||||
// Format writes the report as HTML
|
||||
func (f *HTMLFormatter) Format(report *Report, w io.Writer) error {
|
||||
tmpl := template.Must(template.New("report").Funcs(template.FuncMap{
|
||||
"statusIcon": StatusIcon,
|
||||
"statusClass": statusClass,
|
||||
"severityIcon": SeverityIcon,
|
||||
"severityClass": severityClass,
|
||||
"formatTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
|
||||
"formatDate": func(t time.Time) string { return t.Format("2006-01-02") },
|
||||
}).Parse(htmlTemplate))
|
||||
|
||||
return tmpl.Execute(w, report)
|
||||
}
|
||||
|
||||
func statusClass(s ComplianceStatus) string {
|
||||
switch s {
|
||||
case StatusCompliant:
|
||||
return "status-compliant"
|
||||
case StatusNonCompliant:
|
||||
return "status-noncompliant"
|
||||
case StatusPartial:
|
||||
return "status-partial"
|
||||
default:
|
||||
return "status-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func severityClass(s FindingSeverity) string {
|
||||
switch s {
|
||||
case SeverityCritical:
|
||||
return "severity-critical"
|
||||
case SeverityHigh:
|
||||
return "severity-high"
|
||||
case SeverityMedium:
|
||||
return "severity-medium"
|
||||
case SeverityLow:
|
||||
return "severity-low"
|
||||
default:
|
||||
return "severity-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-compliant: #27ae60;
|
||||
--color-noncompliant: #e74c3c;
|
||||
--color-partial: #f39c12;
|
||||
--color-unknown: #95a5a6;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.report-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.report-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.report-meta {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.status-compliant { background: var(--color-compliant); color: white; }
|
||||
.status-noncompliant { background: var(--color-noncompliant); color: white; }
|
||||
.status-partial { background: var(--color-partial); color: white; }
|
||||
.status-unknown { background: var(--color-unknown); color: white; }
|
||||
.score-display {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-card .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.summary-card .label {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.category {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.category h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.finding-card {
|
||||
border-left: 4px solid var(--color-unknown);
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.finding-card.severity-critical { border-color: #e74c3c; }
|
||||
.finding-card.severity-high { border-color: #e67e22; }
|
||||
.finding-card.severity-medium { border-color: #f39c12; }
|
||||
.finding-card.severity-low { border-color: #27ae60; }
|
||||
.finding-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.finding-meta {
|
||||
font-size: 0.85em;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #ecf0f1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-compliant);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
padding: 20px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-header">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="report-meta">
|
||||
Generated: {{formatTime .GeneratedAt}} |
|
||||
Period: {{formatDate .PeriodStart}} to {{formatDate .PeriodEnd}}
|
||||
</p>
|
||||
<div style="display: flex; align-items: center; gap: 20px; margin-top: 20px;">
|
||||
<div class="score-display">{{printf "%.0f" .Score}}%</div>
|
||||
<div>
|
||||
<span class="status-badge {{statusClass .Status}}">{{.Status}}</span>
|
||||
<p style="margin: 10px 0 0 0; color: #7f8c8d;">{{.Description}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="value">{{.Summary.TotalControls}}</div>
|
||||
<div class="label">Total Controls</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="value" style="color: var(--color-compliant);">{{.Summary.CompliantControls}}</div>
|
||||
<div class="label">Compliant</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="value" style="color: var(--color-noncompliant);">{{.Summary.NonCompliantControls}}</div>
|
||||
<div class="label">Non-Compliant</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="value" style="color: var(--color-partial);">{{.Summary.PartialControls}}</div>
|
||||
<div class="label">Partial</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="value">{{.Summary.OpenFindings}}</div>
|
||||
<div class="label">Open Findings</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="value">{{printf "%.1f" .Summary.RiskScore}}</div>
|
||||
<div class="label">Risk Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Compliance Categories</h2>
|
||||
{{range .Categories}}
|
||||
<div class="category">
|
||||
<h3>
|
||||
{{statusIcon .Status}} {{.Name}}
|
||||
<span class="status-badge {{statusClass .Status}}">{{printf "%.0f" .Score}}%</span>
|
||||
</h3>
|
||||
<p style="color: #7f8c8d;">{{.Description}}</p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {{printf "%.0f" .Score}}%;"></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Control</th>
|
||||
<th>Reference</th>
|
||||
<th>Status</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Controls}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Reference}}</td>
|
||||
<td>{{statusIcon .Status}}</td>
|
||||
<td>{{.Notes}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Findings}}
|
||||
<div class="section">
|
||||
<h2>Findings ({{len .Findings}})</h2>
|
||||
{{range .Findings}}
|
||||
<div class="finding-card {{severityClass .Severity}}">
|
||||
<h4>{{severityIcon .Severity}} {{.Title}}</h4>
|
||||
<div class="finding-meta">
|
||||
<strong>ID:</strong> {{.ID}} |
|
||||
<strong>Severity:</strong> {{.Severity}} |
|
||||
<strong>Status:</strong> {{.Status}} |
|
||||
<strong>Detected:</strong> {{formatTime .DetectedAt}}
|
||||
</div>
|
||||
<p><strong>Description:</strong> {{.Description}}</p>
|
||||
<p><strong>Impact:</strong> {{.Impact}}</p>
|
||||
<p><strong>Recommendation:</strong> {{.Recommendation}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Evidence}}
|
||||
<div class="section">
|
||||
<h2>Evidence ({{len .Evidence}} items)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Collected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Evidence}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Type}}</td>
|
||||
<td>{{.Description}}</td>
|
||||
<td>{{formatTime .CollectedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<p>Report generated by dbbackup compliance module</p>
|
||||
<p>Report ID: {{.ID}}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// GetFormatter returns a formatter for the given format
|
||||
func GetFormatter(format OutputFormat) Formatter {
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return &JSONFormatter{Indent: true}
|
||||
case FormatMarkdown:
|
||||
return &MarkdownFormatter{}
|
||||
case FormatHTML:
|
||||
return &HTMLFormatter{}
|
||||
default:
|
||||
return &JSONFormatter{Indent: true}
|
||||
}
|
||||
}
|
||||
|
||||
// ConsoleFormatter formats reports for terminal output
|
||||
type ConsoleFormatter struct{}
|
||||
|
||||
// Format writes the report to console
|
||||
func (f *ConsoleFormatter) Format(report *Report, w io.Writer) error {
|
||||
// Header
|
||||
fmt.Fprintf(w, "\n%s\n", strings.Repeat("=", 60))
|
||||
fmt.Fprintf(w, " %s\n", report.Title)
|
||||
fmt.Fprintf(w, "%s\n\n", strings.Repeat("=", 60))
|
||||
|
||||
fmt.Fprintf(w, " Generated: %s\n", report.GeneratedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(w, " Period: %s to %s\n",
|
||||
report.PeriodStart.Format("2006-01-02"),
|
||||
report.PeriodEnd.Format("2006-01-02"))
|
||||
fmt.Fprintf(w, " Status: %s %s\n", StatusIcon(report.Status), report.Status)
|
||||
fmt.Fprintf(w, " Score: %.1f%%\n\n", report.Score)
|
||||
|
||||
// Summary
|
||||
fmt.Fprintf(w, " SUMMARY\n")
|
||||
fmt.Fprintf(w, " %s\n", strings.Repeat("-", 40))
|
||||
fmt.Fprintf(w, " Controls: %d total, %d compliant, %d non-compliant\n",
|
||||
report.Summary.TotalControls,
|
||||
report.Summary.CompliantControls,
|
||||
report.Summary.NonCompliantControls)
|
||||
fmt.Fprintf(w, " Compliance: %.1f%%\n", report.Summary.ComplianceRate)
|
||||
fmt.Fprintf(w, " Open Findings: %d (critical: %d, high: %d)\n",
|
||||
report.Summary.OpenFindings,
|
||||
report.Summary.CriticalFindings,
|
||||
report.Summary.HighFindings)
|
||||
fmt.Fprintf(w, " Risk Score: %.1f\n\n", report.Summary.RiskScore)
|
||||
|
||||
// Categories
|
||||
fmt.Fprintf(w, " CATEGORIES\n")
|
||||
fmt.Fprintf(w, " %s\n", strings.Repeat("-", 40))
|
||||
for _, cat := range report.Categories {
|
||||
fmt.Fprintf(w, " %s %-25s %.0f%%\n", StatusIcon(cat.Status), cat.Name, cat.Score)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// Findings
|
||||
if len(report.Findings) > 0 {
|
||||
fmt.Fprintf(w, " FINDINGS\n")
|
||||
fmt.Fprintf(w, " %s\n", strings.Repeat("-", 40))
|
||||
for _, f := range report.Findings {
|
||||
fmt.Fprintf(w, " %s [%s] %s\n", SeverityIcon(f.Severity), f.Severity, f.Title)
|
||||
fmt.Fprintf(w, " %s\n", f.Description)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\n", strings.Repeat("=", 60))
|
||||
return nil
|
||||
}
|
||||
325
internal/report/report.go
Normal file
325
internal/report/report.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Package report provides compliance report generation
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReportType represents the compliance framework type
|
||||
type ReportType string
|
||||
|
||||
const (
|
||||
ReportSOC2 ReportType = "soc2"
|
||||
ReportGDPR ReportType = "gdpr"
|
||||
ReportHIPAA ReportType = "hipaa"
|
||||
ReportPCIDSS ReportType = "pci-dss"
|
||||
ReportISO27001 ReportType = "iso27001"
|
||||
ReportCustom ReportType = "custom"
|
||||
)
|
||||
|
||||
// ComplianceStatus represents the status of a compliance check
|
||||
type ComplianceStatus string
|
||||
|
||||
const (
|
||||
StatusCompliant ComplianceStatus = "compliant"
|
||||
StatusNonCompliant ComplianceStatus = "non_compliant"
|
||||
StatusPartial ComplianceStatus = "partial"
|
||||
StatusNotApplicable ComplianceStatus = "not_applicable"
|
||||
StatusUnknown ComplianceStatus = "unknown"
|
||||
)
|
||||
|
||||
// Report represents a compliance report
|
||||
type Report struct {
|
||||
ID string `json:"id"`
|
||||
Type ReportType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
GeneratedBy string `json:"generated_by"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Status ComplianceStatus `json:"overall_status"`
|
||||
Score float64 `json:"score"` // 0-100
|
||||
Categories []Category `json:"categories"`
|
||||
Summary Summary `json:"summary"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Category represents a compliance category
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status ComplianceStatus `json:"status"`
|
||||
Score float64 `json:"score"`
|
||||
Weight float64 `json:"weight"`
|
||||
Controls []Control `json:"controls"`
|
||||
}
|
||||
|
||||
// Control represents a compliance control
|
||||
type Control struct {
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"` // e.g., "SOC2 CC6.1"
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status ComplianceStatus `json:"status"`
|
||||
Evidence []string `json:"evidence_ids,omitempty"`
|
||||
Findings []string `json:"finding_ids,omitempty"`
|
||||
LastChecked time.Time `json:"last_checked"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Finding represents a compliance finding
|
||||
type Finding struct {
|
||||
ID string `json:"id"`
|
||||
ControlID string `json:"control_id"`
|
||||
Type FindingType `json:"type"`
|
||||
Severity FindingSeverity `json:"severity"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
Status FindingStatus `json:"status"`
|
||||
DetectedAt time.Time `json:"detected_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
Evidence []string `json:"evidence_ids,omitempty"`
|
||||
}
|
||||
|
||||
// FindingType represents the type of finding
|
||||
type FindingType string
|
||||
|
||||
const (
|
||||
FindingGap FindingType = "gap"
|
||||
FindingViolation FindingType = "violation"
|
||||
FindingObservation FindingType = "observation"
|
||||
FindingRecommendation FindingType = "recommendation"
|
||||
)
|
||||
|
||||
// FindingSeverity represents finding severity
|
||||
type FindingSeverity string
|
||||
|
||||
const (
|
||||
SeverityLow FindingSeverity = "low"
|
||||
SeverityMedium FindingSeverity = "medium"
|
||||
SeverityHigh FindingSeverity = "high"
|
||||
SeverityCritical FindingSeverity = "critical"
|
||||
)
|
||||
|
||||
// FindingStatus represents finding status
|
||||
type FindingStatus string
|
||||
|
||||
const (
|
||||
FindingOpen FindingStatus = "open"
|
||||
FindingAccepted FindingStatus = "accepted"
|
||||
FindingResolved FindingStatus = "resolved"
|
||||
)
|
||||
|
||||
// Evidence represents compliance evidence
|
||||
type Evidence struct {
|
||||
ID string `json:"id"`
|
||||
Type EvidenceType `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// EvidenceType represents the type of evidence
|
||||
type EvidenceType string
|
||||
|
||||
const (
|
||||
EvidenceBackupLog EvidenceType = "backup_log"
|
||||
EvidenceRestoreLog EvidenceType = "restore_log"
|
||||
EvidenceDrillResult EvidenceType = "drill_result"
|
||||
EvidenceEncryptionProof EvidenceType = "encryption_proof"
|
||||
EvidenceRetentionProof EvidenceType = "retention_proof"
|
||||
EvidenceAccessLog EvidenceType = "access_log"
|
||||
EvidenceAuditLog EvidenceType = "audit_log"
|
||||
EvidenceConfiguration EvidenceType = "configuration"
|
||||
EvidenceScreenshot EvidenceType = "screenshot"
|
||||
EvidenceOther EvidenceType = "other"
|
||||
)
|
||||
|
||||
// Summary provides a high-level overview
|
||||
type Summary struct {
|
||||
TotalControls int `json:"total_controls"`
|
||||
CompliantControls int `json:"compliant_controls"`
|
||||
NonCompliantControls int `json:"non_compliant_controls"`
|
||||
PartialControls int `json:"partial_controls"`
|
||||
NotApplicable int `json:"not_applicable"`
|
||||
OpenFindings int `json:"open_findings"`
|
||||
CriticalFindings int `json:"critical_findings"`
|
||||
HighFindings int `json:"high_findings"`
|
||||
MediumFindings int `json:"medium_findings"`
|
||||
LowFindings int `json:"low_findings"`
|
||||
ComplianceRate float64 `json:"compliance_rate"`
|
||||
RiskScore float64 `json:"risk_score"`
|
||||
}
|
||||
|
||||
// ReportConfig configures report generation
|
||||
type ReportConfig struct {
|
||||
Type ReportType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
IncludeDatabases []string `json:"include_databases,omitempty"`
|
||||
ExcludeDatabases []string `json:"exclude_databases,omitempty"`
|
||||
CatalogPath string `json:"catalog_path"`
|
||||
OutputFormat OutputFormat `json:"output_format"`
|
||||
OutputPath string `json:"output_path"`
|
||||
IncludeEvidence bool `json:"include_evidence"`
|
||||
CustomControls []Control `json:"custom_controls,omitempty"`
|
||||
}
|
||||
|
||||
// OutputFormat represents report output format
|
||||
type OutputFormat string
|
||||
|
||||
const (
|
||||
FormatJSON OutputFormat = "json"
|
||||
FormatHTML OutputFormat = "html"
|
||||
FormatPDF OutputFormat = "pdf"
|
||||
FormatMarkdown OutputFormat = "markdown"
|
||||
)
|
||||
|
||||
// NewReport creates a new report
|
||||
func NewReport(reportType ReportType, title string) *Report {
|
||||
return &Report{
|
||||
ID: generateID(),
|
||||
Type: reportType,
|
||||
Title: title,
|
||||
GeneratedAt: time.Now(),
|
||||
Categories: make([]Category, 0),
|
||||
Findings: make([]Finding, 0),
|
||||
Evidence: make([]Evidence, 0),
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// AddCategory adds a category to the report
|
||||
func (r *Report) AddCategory(cat Category) {
|
||||
r.Categories = append(r.Categories, cat)
|
||||
}
|
||||
|
||||
// AddFinding adds a finding to the report
|
||||
func (r *Report) AddFinding(f Finding) {
|
||||
r.Findings = append(r.Findings, f)
|
||||
}
|
||||
|
||||
// AddEvidence adds evidence to the report
|
||||
func (r *Report) AddEvidence(e Evidence) {
|
||||
r.Evidence = append(r.Evidence, e)
|
||||
}
|
||||
|
||||
// Calculate computes the summary and overall status
|
||||
func (r *Report) Calculate() {
|
||||
var totalWeight float64
|
||||
var weightedScore float64
|
||||
|
||||
for _, cat := range r.Categories {
|
||||
totalWeight += cat.Weight
|
||||
weightedScore += cat.Score * cat.Weight
|
||||
|
||||
for _, ctrl := range cat.Controls {
|
||||
r.Summary.TotalControls++
|
||||
switch ctrl.Status {
|
||||
case StatusCompliant:
|
||||
r.Summary.CompliantControls++
|
||||
case StatusNonCompliant:
|
||||
r.Summary.NonCompliantControls++
|
||||
case StatusPartial:
|
||||
r.Summary.PartialControls++
|
||||
case StatusNotApplicable:
|
||||
r.Summary.NotApplicable++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range r.Findings {
|
||||
if f.Status == FindingOpen {
|
||||
r.Summary.OpenFindings++
|
||||
switch f.Severity {
|
||||
case SeverityCritical:
|
||||
r.Summary.CriticalFindings++
|
||||
case SeverityHigh:
|
||||
r.Summary.HighFindings++
|
||||
case SeverityMedium:
|
||||
r.Summary.MediumFindings++
|
||||
case SeverityLow:
|
||||
r.Summary.LowFindings++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
r.Score = weightedScore / totalWeight
|
||||
}
|
||||
|
||||
applicable := r.Summary.TotalControls - r.Summary.NotApplicable
|
||||
if applicable > 0 {
|
||||
r.Summary.ComplianceRate = float64(r.Summary.CompliantControls) / float64(applicable) * 100
|
||||
}
|
||||
|
||||
// Calculate risk score based on findings
|
||||
r.Summary.RiskScore = float64(r.Summary.CriticalFindings)*10 +
|
||||
float64(r.Summary.HighFindings)*5 +
|
||||
float64(r.Summary.MediumFindings)*2 +
|
||||
float64(r.Summary.LowFindings)*1
|
||||
|
||||
// Determine overall status
|
||||
if r.Summary.NonCompliantControls == 0 && r.Summary.CriticalFindings == 0 {
|
||||
if r.Summary.PartialControls == 0 {
|
||||
r.Status = StatusCompliant
|
||||
} else {
|
||||
r.Status = StatusPartial
|
||||
}
|
||||
} else {
|
||||
r.Status = StatusNonCompliant
|
||||
}
|
||||
}
|
||||
|
||||
// ToJSON converts the report to JSON
|
||||
func (r *Report) ToJSON() ([]byte, error) {
|
||||
return json.MarshalIndent(r, "", " ")
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("RPT-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// StatusIcon returns an icon for a compliance status
|
||||
func StatusIcon(s ComplianceStatus) string {
|
||||
switch s {
|
||||
case StatusCompliant:
|
||||
return "✅"
|
||||
case StatusNonCompliant:
|
||||
return "❌"
|
||||
case StatusPartial:
|
||||
return "⚠️"
|
||||
case StatusNotApplicable:
|
||||
return "➖"
|
||||
default:
|
||||
return "❓"
|
||||
}
|
||||
}
|
||||
|
||||
// SeverityIcon returns an icon for a finding severity
|
||||
func SeverityIcon(s FindingSeverity) string {
|
||||
switch s {
|
||||
case SeverityCritical:
|
||||
return "🔴"
|
||||
case SeverityHigh:
|
||||
return "🟠"
|
||||
case SeverityMedium:
|
||||
return "🟡"
|
||||
case SeverityLow:
|
||||
return "🟢"
|
||||
default:
|
||||
return "⚪"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user