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.
545 lines
15 KiB
Go
545 lines
15 KiB
Go
// 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
|
|
}
|