mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-03 20:12:58 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ComplianceFramework represents a compliance framework
|
||||
type ComplianceFramework struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ComplianceControl represents a compliance control
|
||||
type ComplianceControl struct {
|
||||
ID string `json:"id"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Requirement string `json:"requirement"`
|
||||
TestProcedure string `json:"test_procedure"`
|
||||
Status string `json:"status"` // "compliant", "non_compliant", "not_applicable", "pending"
|
||||
LastAssessed *time.Time `json:"last_assessed,omitempty"`
|
||||
Evidence string `json:"evidence"`
|
||||
Metadata string `json:"metadata"`
|
||||
}
|
||||
|
||||
// ComplianceReport represents a compliance assessment report
|
||||
type ComplianceReport struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
AssessmentDate time.Time `json:"assessment_date"`
|
||||
Assessor string `json:"assessor"`
|
||||
OverallStatus string `json:"overall_status"`
|
||||
Score int `json:"score"` // 0-100
|
||||
Controls []ComplianceControl `json:"controls"`
|
||||
Risks []ComplianceRisk `json:"risks"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
}
|
||||
|
||||
// ComplianceRisk represents a compliance risk
|
||||
type ComplianceRisk struct {
|
||||
ID string `json:"id"`
|
||||
ControlID string `json:"control_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Impact string `json:"impact"` // "high", "medium", "low"
|
||||
Likelihood string `json:"likelihood"` // "high", "medium", "low"
|
||||
Mitigation string `json:"mitigation"`
|
||||
}
|
||||
|
||||
// ComplianceManager handles compliance operations
|
||||
type ComplianceManager struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewComplianceManager creates a new compliance manager
|
||||
func NewComplianceManager(db *database.DB) *ComplianceManager {
|
||||
return &ComplianceManager{db: db}
|
||||
}
|
||||
|
||||
// InitializeGDPRFramework initializes GDPR compliance framework
|
||||
func (cm *ComplianceManager) InitializeGDPRFramework() error {
|
||||
framework := ComplianceFramework{
|
||||
ID: uuid.New().String(),
|
||||
Name: "GDPR",
|
||||
Description: "General Data Protection Regulation compliance framework",
|
||||
Version: "1.0",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Insert framework
|
||||
_, err := cm.db.Exec(`
|
||||
INSERT INTO compliance_frameworks (id, name, description, version, enabled, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (name) DO UPDATE SET version = $4, enabled = $5
|
||||
`, framework.ID, framework.Name, framework.Description, framework.Version, framework.Enabled, framework.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GDPR framework: %w", err)
|
||||
}
|
||||
|
||||
// Add GDPR controls
|
||||
controls := []ComplianceControl{
|
||||
{
|
||||
ID: uuid.New().String(),
|
||||
FrameworkID: framework.ID,
|
||||
Code: "GDPR-Art-32",
|
||||
Title: "Security of Processing",
|
||||
Description: "Technical and organizational measures to ensure data security",
|
||||
Category: "Security",
|
||||
Requirement: "Implement appropriate technical and organizational measures to ensure a level of security appropriate to the risk",
|
||||
TestProcedure: "Review security controls, encryption policies, access controls, and incident response procedures",
|
||||
Status: "pending",
|
||||
Evidence: "",
|
||||
Metadata: `{"risk_level": "high", "review_frequency": "quarterly"}`,
|
||||
},
|
||||
{
|
||||
ID: uuid.New().String(),
|
||||
FrameworkID: framework.ID,
|
||||
Code: "GDPR-Art-25",
|
||||
Title: "Data Protection by Design and by Default",
|
||||
Description: "Implement data protection measures in system design",
|
||||
Category: "Privacy by Design",
|
||||
Requirement: "Implement data protection principles in system design and default settings",
|
||||
TestProcedure: "Review system architecture, privacy settings, and data minimization practices",
|
||||
Status: "pending",
|
||||
Evidence: "",
|
||||
Metadata: `{"risk_level": "medium", "review_frequency": "biannual"}`,
|
||||
},
|
||||
{
|
||||
ID: uuid.New().String(),
|
||||
FrameworkID: framework.ID,
|
||||
Code: "GDPR-Art-24",
|
||||
Title: "Responsibility of the Controller",
|
||||
Description: "Data controller responsibility and compliance demonstration",
|
||||
Category: "Governance",
|
||||
Requirement: "Implement measures to ensure and demonstrate compliance with GDPR",
|
||||
TestProcedure: "Review governance policies, documentation, and compliance monitoring",
|
||||
Status: "pending",
|
||||
Evidence: "",
|
||||
Metadata: `{"risk_level": "medium", "review_frequency": "annual"}`,
|
||||
},
|
||||
{
|
||||
ID: uuid.New().String(),
|
||||
FrameworkID: framework.ID,
|
||||
Code: "GDPR-Art-33",
|
||||
Title: "Notification of Personal Data Breach",
|
||||
Description: "Procedures for notifying data breaches to authorities",
|
||||
Category: "Incident Response",
|
||||
Requirement: "Implement procedures for notifying personal data breaches within 72 hours",
|
||||
TestProcedure: "Review incident response procedures, notification templates, and breach detection mechanisms",
|
||||
Status: "pending",
|
||||
Evidence: "",
|
||||
Metadata: `{"risk_level": "high", "review_frequency": "quarterly"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, control := range controls {
|
||||
_, err := cm.db.Exec(`
|
||||
INSERT INTO compliance_controls (id, framework_id, code, title, description, category, requirement, test_procedure, status, last_assessed, evidence, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL, $10, $11)
|
||||
ON CONFLICT (framework_id, code) DO UPDATE SET title = $4, description = $5, requirement = $7, test_procedure = $8
|
||||
`, control.ID, control.FrameworkID, control.Code, control.Title, control.Description,
|
||||
control.Category, control.Requirement, control.TestProcedure, control.Status, control.Evidence, control.Metadata)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert GDPR control %s: %v", control.Code, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssessCompliance performs a compliance assessment
|
||||
func (cm *ComplianceManager) AssessCompliance(projectID, frameworkID, assessor string) (*ComplianceReport, error) {
|
||||
reportID := uuid.New().String()
|
||||
|
||||
report := &ComplianceReport{
|
||||
ID: reportID,
|
||||
ProjectID: projectID,
|
||||
FrameworkID: frameworkID,
|
||||
AssessmentDate: time.Now(),
|
||||
Assessor: assessor,
|
||||
OverallStatus: "in_progress",
|
||||
Score: 0,
|
||||
Controls: []ComplianceControl{},
|
||||
Risks: []ComplianceRisk{},
|
||||
Recommendations: []string{},
|
||||
}
|
||||
|
||||
// Insert report record
|
||||
_, err := cm.db.Exec(`
|
||||
INSERT INTO compliance_reports (id, project_id, framework_id, assessment_date, assessor, overall_status, score)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, report.ID, report.ProjectID, report.FrameworkID, report.AssessmentDate, report.Assessor, report.OverallStatus, report.Score)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create compliance report: %w", err)
|
||||
}
|
||||
|
||||
// Start assessment in background
|
||||
go cm.performAssessment(report)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// performAssessment executes the compliance assessment
|
||||
func (cm *ComplianceManager) performAssessment(report *ComplianceReport) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get framework controls
|
||||
controls, err := cm.getFrameworkControls(report.FrameworkID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get framework controls: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var assessedControls []ComplianceControl
|
||||
var risks []ComplianceRisk
|
||||
var recommendations []string
|
||||
compliantCount := 0
|
||||
|
||||
if len(controls) == 0 {
|
||||
_, updateErr := cm.db.Exec(`
|
||||
UPDATE compliance_reports
|
||||
SET overall_status = $1, score = $2
|
||||
WHERE id = $3
|
||||
`, "non_compliant", 0, report.ID)
|
||||
if updateErr != nil {
|
||||
log.Printf("Failed to update compliance report %s with empty control set: %v", report.ID, updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, control := range controls {
|
||||
assessedControl := cm.assessControl(ctx, report.ProjectID, control)
|
||||
assessedControls = append(assessedControls, assessedControl)
|
||||
|
||||
if assessedControl.Status == "compliant" {
|
||||
compliantCount++
|
||||
} else if assessedControl.Status == "non_compliant" {
|
||||
// Generate risk for non-compliant controls
|
||||
risk := ComplianceRisk{
|
||||
ID: uuid.New().String(),
|
||||
ControlID: assessedControl.ID,
|
||||
Title: fmt.Sprintf("Non-compliance: %s", assessedControl.Title),
|
||||
Description: fmt.Sprintf("Control %s is not compliant", assessedControl.Code),
|
||||
Impact: cm.getRiskImpact(assessedControl),
|
||||
Likelihood: cm.getRiskLikelihood(assessedControl),
|
||||
Mitigation: cm.generateMitigation(assessedControl),
|
||||
}
|
||||
risks = append(risks, risk)
|
||||
|
||||
// Generate recommendation
|
||||
rec := fmt.Sprintf("Implement controls to achieve compliance for %s: %s", assessedControl.Code, assessedControl.Title)
|
||||
recommendations = append(recommendations, rec)
|
||||
}
|
||||
|
||||
// Update control status in database
|
||||
_, err := cm.db.Exec(`
|
||||
UPDATE compliance_controls
|
||||
SET status = $1, last_assessed = $2, evidence = $3
|
||||
WHERE id = $4
|
||||
`, assessedControl.Status, assessedControl.LastAssessed, assessedControl.Evidence, assessedControl.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update control %s: %v", assessedControl.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate overall score
|
||||
score := int((float64(compliantCount) / float64(len(controls))) * 100)
|
||||
|
||||
// Determine overall status
|
||||
overallStatus := "non_compliant"
|
||||
if score >= 90 {
|
||||
overallStatus = "compliant"
|
||||
} else if score >= 70 {
|
||||
overallStatus = "partially_compliant"
|
||||
}
|
||||
|
||||
// Update report with results
|
||||
_, err = cm.db.Exec(`
|
||||
UPDATE compliance_reports
|
||||
SET overall_status = $1, score = $2
|
||||
WHERE id = $3
|
||||
`, overallStatus, score, report.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update compliance report %s: %v", report.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Store risks and recommendations
|
||||
for _, risk := range risks {
|
||||
_, err := cm.db.Exec(`
|
||||
INSERT INTO compliance_risks (id, report_id, control_id, title, description, impact, likelihood, mitigation)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, risk.ID, report.ID, risk.ControlID, risk.Title, risk.Description, risk.Impact, risk.Likelihood, risk.Mitigation)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to store risk %s: %v", risk.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getFrameworkControls retrieves all controls for a framework
|
||||
func (cm *ComplianceManager) getFrameworkControls(frameworkID string) ([]ComplianceControl, error) {
|
||||
rows, err := cm.db.Query(`
|
||||
SELECT id, framework_id, code, title, description, category, requirement, test_procedure, status, last_assessed, evidence, metadata
|
||||
FROM compliance_controls WHERE framework_id = $1
|
||||
`, frameworkID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var controls []ComplianceControl
|
||||
for rows.Next() {
|
||||
var control ComplianceControl
|
||||
var lastAssessed sql.NullTime
|
||||
|
||||
err := rows.Scan(&control.ID, &control.FrameworkID, &control.Code, &control.Title, &control.Description,
|
||||
&control.Category, &control.Requirement, &control.TestProcedure, &control.Status, &lastAssessed, &control.Evidence, &control.Metadata)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if lastAssessed.Valid {
|
||||
control.LastAssessed = &lastAssessed.Time
|
||||
}
|
||||
|
||||
controls = append(controls, control)
|
||||
}
|
||||
|
||||
return controls, nil
|
||||
}
|
||||
|
||||
// assessControl assesses a single compliance control
|
||||
func (cm *ComplianceManager) assessControl(ctx context.Context, projectID string, control ComplianceControl) ComplianceControl {
|
||||
assessed := control
|
||||
now := time.Now()
|
||||
assessed.LastAssessed = &now
|
||||
|
||||
_ = ctx
|
||||
|
||||
switch control.Code {
|
||||
case "GDPR-Art-32":
|
||||
encryption := cm.checkDataEncryption(projectID)
|
||||
accessControl := cm.checkAccessControl(projectID)
|
||||
incidentResponse := cm.checkIncidentResponse(projectID)
|
||||
|
||||
if encryption.OK && accessControl.OK && incidentResponse.OK {
|
||||
assessed.Status = "compliant"
|
||||
assessed.Evidence = strings.Join([]string{
|
||||
encryption.Evidence,
|
||||
accessControl.Evidence,
|
||||
incidentResponse.Evidence,
|
||||
}, " | ")
|
||||
} else {
|
||||
assessed.Status = "non_compliant"
|
||||
missing := []string{}
|
||||
if !encryption.OK {
|
||||
missing = append(missing, "data encryption")
|
||||
}
|
||||
if !accessControl.OK {
|
||||
missing = append(missing, "access controls")
|
||||
}
|
||||
if !incidentResponse.OK {
|
||||
missing = append(missing, "incident response procedures")
|
||||
}
|
||||
assessed.Evidence = fmt.Sprintf(
|
||||
"Missing controls: %s. Details: %s | %s | %s",
|
||||
strings.Join(missing, ", "),
|
||||
encryption.Evidence,
|
||||
accessControl.Evidence,
|
||||
incidentResponse.Evidence,
|
||||
)
|
||||
}
|
||||
|
||||
case "GDPR-Art-25":
|
||||
dataMinimization := cm.checkDataMinimization(projectID)
|
||||
privacySettings := cm.checkPrivacySettings(projectID)
|
||||
|
||||
if dataMinimization.OK && privacySettings.OK {
|
||||
assessed.Status = "compliant"
|
||||
assessed.Evidence = strings.Join([]string{
|
||||
dataMinimization.Evidence,
|
||||
privacySettings.Evidence,
|
||||
}, " | ")
|
||||
} else {
|
||||
assessed.Status = "non_compliant"
|
||||
assessed.Evidence = fmt.Sprintf(
|
||||
"Privacy by design principles not fully implemented: %s | %s",
|
||||
dataMinimization.Evidence,
|
||||
privacySettings.Evidence,
|
||||
)
|
||||
}
|
||||
|
||||
case "GDPR-Art-24":
|
||||
governance := cm.checkGovernance(projectID)
|
||||
if governance.OK {
|
||||
assessed.Status = "compliant"
|
||||
assessed.Evidence = governance.Evidence
|
||||
} else {
|
||||
assessed.Status = "non_compliant"
|
||||
assessed.Evidence = governance.Evidence
|
||||
}
|
||||
|
||||
case "GDPR-Art-33":
|
||||
breachReadiness := cm.checkBreachNotificationReadiness(projectID)
|
||||
if breachReadiness.OK {
|
||||
assessed.Status = "compliant"
|
||||
assessed.Evidence = breachReadiness.Evidence
|
||||
} else {
|
||||
assessed.Status = "non_compliant"
|
||||
assessed.Evidence = breachReadiness.Evidence
|
||||
}
|
||||
|
||||
default:
|
||||
// Default assessment for other controls
|
||||
assessed.Status = "pending"
|
||||
assessed.Evidence = "Assessment pending manual review"
|
||||
}
|
||||
|
||||
return assessed
|
||||
}
|
||||
|
||||
type controlCheckResult struct {
|
||||
OK bool
|
||||
Evidence string
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkDataEncryption(projectID string) controlCheckResult {
|
||||
var insecureServiceURLs int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
AND COALESCE(public_url, '') <> ''
|
||||
AND LOWER(public_url) LIKE 'http://%'
|
||||
`, projectID).Scan(&insecureServiceURLs); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to evaluate service TLS posture: %v", err)}
|
||||
}
|
||||
|
||||
var sslDisabledDatabases int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM projects p
|
||||
JOIN database_services ds ON ds.user_id = p.owner_id
|
||||
JOIN database_settings st ON st.database_id = ds.id
|
||||
WHERE p.id = $1
|
||||
AND COALESCE(st.ssl_enabled, false) = false
|
||||
`, projectID).Scan(&sslDisabledDatabases); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to evaluate database TLS posture: %v", err)}
|
||||
}
|
||||
|
||||
ok := insecureServiceURLs == 0 && sslDisabledDatabases == 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf(
|
||||
"service_http_endpoints=%d, databases_with_ssl_disabled=%d",
|
||||
insecureServiceURLs,
|
||||
sslDisabledDatabases,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkAccessControl(projectID string) controlCheckResult {
|
||||
var memberCount int
|
||||
if err := cm.db.QueryRow(`SELECT COUNT(*) FROM project_members WHERE project_id = $1`, projectID).Scan(&memberCount); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect project membership: %v", err)}
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
if err := cm.db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, projectID).Scan(&ownerID); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect project owner: %v", err)}
|
||||
}
|
||||
|
||||
var ownerRoleCount int
|
||||
err := cm.db.QueryRow(`SELECT COUNT(*) FROM user_roles WHERE user_id::text = $1`, ownerID).Scan(&ownerRoleCount)
|
||||
if err != nil {
|
||||
// If RBAC tables are absent in older deployments, fallback to membership signal.
|
||||
ownerRoleCount = 1
|
||||
}
|
||||
|
||||
ok := memberCount > 0 && ownerRoleCount > 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf("project_members=%d, owner_roles=%d", memberCount, ownerRoleCount),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkIncidentResponse(projectID string) controlCheckResult {
|
||||
var recentSecurityScans int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM security_scans
|
||||
WHERE project_id = $1
|
||||
AND status = 'completed'
|
||||
AND started_at > NOW() - INTERVAL '30 days'
|
||||
`, projectID).Scan(&recentSecurityScans); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect security scans: %v", err)}
|
||||
}
|
||||
|
||||
var recentAuditEvents int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE (
|
||||
(resource = 'project' AND resource_id::text = $1)
|
||||
OR details->>'project_id' = $1
|
||||
)
|
||||
AND created_at > NOW() - INTERVAL '30 days'
|
||||
`, projectID).Scan(&recentAuditEvents); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect audit events: %v", err)}
|
||||
}
|
||||
|
||||
ok := recentSecurityScans > 0 && recentAuditEvents > 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf(
|
||||
"completed_scans_30d=%d, audit_events_30d=%d",
|
||||
recentSecurityScans,
|
||||
recentAuditEvents,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkDataMinimization(projectID string) controlCheckResult {
|
||||
var nonSecretSensitiveVars int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM environment_variables ev
|
||||
JOIN services s ON s.id = ev.service_id
|
||||
WHERE s.project_id = $1
|
||||
AND COALESCE(ev.is_secret, false) = false
|
||||
AND (
|
||||
LOWER(ev.key) LIKE '%password%'
|
||||
OR LOWER(ev.key) LIKE '%token%'
|
||||
OR LOWER(ev.key) LIKE '%secret%'
|
||||
OR LOWER(ev.key) LIKE '%apikey%'
|
||||
OR LOWER(ev.key) LIKE '%api_key%'
|
||||
)
|
||||
`, projectID).Scan(&nonSecretSensitiveVars); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect environment variable minimization: %v", err)}
|
||||
}
|
||||
|
||||
ok := nonSecretSensitiveVars == 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf("non_secret_sensitive_env_vars=%d", nonSecretSensitiveVars),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkPrivacySettings(projectID string) controlCheckResult {
|
||||
var totalPublic int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
AND COALESCE(public_url, '') <> ''
|
||||
`, projectID).Scan(&totalPublic); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect public endpoints: %v", err)}
|
||||
}
|
||||
|
||||
var httpsPublic int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
AND COALESCE(public_url, '') <> ''
|
||||
AND LOWER(public_url) LIKE 'https://%'
|
||||
`, projectID).Scan(&httpsPublic); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect public endpoint TLS settings: %v", err)}
|
||||
}
|
||||
|
||||
var retentionPolicies int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM data_retention_policies
|
||||
WHERE enabled = true
|
||||
`).Scan(&retentionPolicies); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect retention policies: %v", err)}
|
||||
}
|
||||
|
||||
ok := (totalPublic == 0 || totalPublic == httpsPublic) && retentionPolicies > 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf(
|
||||
"public_services=%d, https_public_services=%d, retention_policies_enabled=%d",
|
||||
totalPublic,
|
||||
httpsPublic,
|
||||
retentionPolicies,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkGovernance(projectID string) controlCheckResult {
|
||||
var reports int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM compliance_reports
|
||||
WHERE project_id = $1
|
||||
AND assessment_date > NOW() - INTERVAL '180 days'
|
||||
`, projectID).Scan(&reports); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect compliance reporting cadence: %v", err)}
|
||||
}
|
||||
|
||||
var auditEvents int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE (
|
||||
(resource = 'project' AND resource_id::text = $1)
|
||||
OR details->>'project_id' = $1
|
||||
)
|
||||
AND created_at > NOW() - INTERVAL '90 days'
|
||||
`, projectID).Scan(&auditEvents); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect governance audit trail: %v", err)}
|
||||
}
|
||||
|
||||
ok := reports > 0 && auditEvents > 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf("compliance_reports_180d=%d, audit_events_90d=%d", reports, auditEvents),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) checkBreachNotificationReadiness(projectID string) controlCheckResult {
|
||||
var incidentSignals int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM audit_logs
|
||||
WHERE (
|
||||
(resource = 'project' AND resource_id::text = $1)
|
||||
OR details->>'project_id' = $1
|
||||
)
|
||||
AND (
|
||||
LOWER(action) LIKE '%incident%'
|
||||
OR LOWER(action) LIKE '%security_%'
|
||||
OR LOWER(action) LIKE '%breach%'
|
||||
)
|
||||
AND created_at > NOW() - INTERVAL '365 days'
|
||||
`, projectID).Scan(&incidentSignals); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect incident/breach logs: %v", err)}
|
||||
}
|
||||
|
||||
var scans int
|
||||
if err := cm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM security_scans
|
||||
WHERE project_id = $1
|
||||
AND status = 'completed'
|
||||
AND started_at > NOW() - INTERVAL '90 days'
|
||||
`, projectID).Scan(&scans); err != nil {
|
||||
return controlCheckResult{OK: false, Evidence: fmt.Sprintf("failed to inspect scan readiness: %v", err)}
|
||||
}
|
||||
|
||||
ok := scans > 0 && incidentSignals > 0
|
||||
return controlCheckResult{
|
||||
OK: ok,
|
||||
Evidence: fmt.Sprintf("security_scans_90d=%d, incident_signals_365d=%d", scans, incidentSignals),
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) getRiskImpact(control ComplianceControl) string {
|
||||
// Extract impact from metadata or default based on category
|
||||
var metadata map[string]interface{}
|
||||
json.Unmarshal([]byte(control.Metadata), &metadata)
|
||||
|
||||
if impact, ok := metadata["risk_level"].(string); ok {
|
||||
return impact
|
||||
}
|
||||
|
||||
// Default impact based on category
|
||||
switch control.Category {
|
||||
case "Security", "Incident Response":
|
||||
return "high"
|
||||
case "Privacy by Design":
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) getRiskLikelihood(control ComplianceControl) string {
|
||||
// Default likelihood based on control complexity
|
||||
if strings.Contains(control.Requirement, "implement") || strings.Contains(control.Requirement, "procedures") {
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
func (cm *ComplianceManager) generateMitigation(control ComplianceControl) string {
|
||||
return fmt.Sprintf("Implement and document controls for %s as specified in the requirements", control.Title)
|
||||
}
|
||||
|
||||
// GetComplianceReport retrieves a compliance report by ID
|
||||
func (cm *ComplianceManager) GetComplianceReport(reportID string) (*ComplianceReport, error) {
|
||||
var report ComplianceReport
|
||||
|
||||
err := cm.db.QueryRow(`
|
||||
SELECT id, project_id, framework_id, assessment_date, assessor, overall_status, score
|
||||
FROM compliance_reports WHERE id = $1
|
||||
`, reportID).Scan(&report.ID, &report.ProjectID, &report.FrameworkID, &report.AssessmentDate, &report.Assessor, &report.OverallStatus, &report.Score)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load controls
|
||||
controls, err := cm.getFrameworkControls(report.FrameworkID)
|
||||
if err == nil {
|
||||
report.Controls = controls
|
||||
}
|
||||
|
||||
// Load risks
|
||||
risks, err := cm.getReportRisks(report.ID)
|
||||
if err == nil {
|
||||
report.Risks = risks
|
||||
}
|
||||
|
||||
return &report, nil
|
||||
}
|
||||
|
||||
// getReportRisks retrieves risks for a compliance report
|
||||
func (cm *ComplianceManager) getReportRisks(reportID string) ([]ComplianceRisk, error) {
|
||||
rows, err := cm.db.Query(`
|
||||
SELECT id, control_id, title, description, impact, likelihood, mitigation
|
||||
FROM compliance_risks WHERE report_id = $1
|
||||
`, reportID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var risks []ComplianceRisk
|
||||
for rows.Next() {
|
||||
var risk ComplianceRisk
|
||||
err := rows.Scan(&risk.ID, &risk.ControlID, &risk.Title, &risk.Description, &risk.Impact, &risk.Likelihood, &risk.Mitigation)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
risks = append(risks, risk)
|
||||
}
|
||||
|
||||
return risks, nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// EncryptionManager handles data encryption and decryption
|
||||
type EncryptionManager struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
// NewEncryptionManager creates a new encryption manager
|
||||
func NewEncryptionManager(key string) (*EncryptionManager, error) {
|
||||
// Convert key to 32 bytes for AES-256
|
||||
keyHash := sha256.Sum256([]byte(key))
|
||||
|
||||
block, err := aes.NewCipher(keyHash[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptionManager{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data using AES-256 GCM
|
||||
func (em *EncryptionManager) Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
nonce := make([]byte, em.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := em.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data using AES-256 GCM
|
||||
func (em *EncryptionManager) Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := em.gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext_bytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := em.gcm.Open(nil, nonce, ciphertext_bytes, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// EncryptSensitiveData encrypts sensitive data fields
|
||||
func (em *EncryptionManager) EncryptSensitiveData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
for key, value := range data {
|
||||
if em.isSensitiveField(key) {
|
||||
strValue, ok := value.(string)
|
||||
if ok {
|
||||
encrypted, err := em.Encrypt(strValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt field %s: %w", key, err)
|
||||
}
|
||||
result[key] = encrypted
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptSensitiveData decrypts sensitive data fields
|
||||
func (em *EncryptionManager) DecryptSensitiveData(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
for key, value := range data {
|
||||
if em.isSensitiveField(key) {
|
||||
strValue, ok := value.(string)
|
||||
if ok {
|
||||
decrypted, err := em.Decrypt(strValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt field %s: %w", key, err)
|
||||
}
|
||||
result[key] = decrypted
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isSensitiveField determines if a field contains sensitive data
|
||||
func (em *EncryptionManager) isSensitiveField(fieldName string) bool {
|
||||
sensitiveFields := []string{
|
||||
"password", "secret", "token", "key", "api_key", "private_key",
|
||||
"database_url", "connection_string", "credit_card", "ssn",
|
||||
"social_security", "bank_account", "auth_token", "jwt_secret",
|
||||
"encryption_key", "webhook_secret", "oauth_secret", "access_token",
|
||||
"refresh_token", "client_secret", "private", "confidential",
|
||||
}
|
||||
|
||||
fieldName = strings.ToLower(fieldName)
|
||||
for _, sensitive := range sensitiveFields {
|
||||
if strings.Contains(fieldName, sensitive) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DataRetentionManager handles data retention policies
|
||||
type DataRetentionManager struct {
|
||||
encryptionManager *EncryptionManager
|
||||
}
|
||||
|
||||
// NewDataRetentionManager creates a new data retention manager
|
||||
func NewDataRetentionManager(encryptionManager *EncryptionManager) *DataRetentionManager {
|
||||
return &DataRetentionManager{
|
||||
encryptionManager: encryptionManager,
|
||||
}
|
||||
}
|
||||
|
||||
// RetentionPolicy defines data retention rules
|
||||
type RetentionPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DataType string `json:"data_type"`
|
||||
RetentionPeriod time.Duration `json:"retention_period"`
|
||||
Action string `json:"action"` // "delete", "anonymize", "archive"
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AnonymizedData represents anonymized user data
|
||||
type AnonymizedData struct {
|
||||
OriginalID string `json:"original_id"`
|
||||
AnonymizedID string `json:"anonymized_id"`
|
||||
DataType string `json:"data_type"`
|
||||
AnonymizedAt time.Time `json:"anonymized_at"`
|
||||
RetainedData string `json:"retained_data"` // Encrypted non-sensitive data
|
||||
}
|
||||
|
||||
// AnonymizeUserData anonymizes user data for GDPR compliance
|
||||
func (drm *DataRetentionManager) AnonymizeUserData(userData map[string]interface{}) (*AnonymizedData, error) {
|
||||
anonymizedID := fmt.Sprintf("anon_%d", time.Now().UnixNano())
|
||||
|
||||
// Separate sensitive and non-sensitive data
|
||||
sensitiveData := make(map[string]interface{})
|
||||
nonSensitiveData := make(map[string]interface{})
|
||||
|
||||
for key, value := range userData {
|
||||
if drm.isPersonalData(key) {
|
||||
sensitiveData[key] = value
|
||||
} else {
|
||||
nonSensitiveData[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt non-sensitive data for retention
|
||||
nonSensitiveJSON, _ := json.Marshal(nonSensitiveData)
|
||||
encryptedRetainedData, err := drm.encryptionManager.Encrypt(string(nonSensitiveJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt retained data: %w", err)
|
||||
}
|
||||
|
||||
// Create anonymized record
|
||||
anonymized := &AnonymizedData{
|
||||
OriginalID: fmt.Sprintf("%v", userData["id"]),
|
||||
AnonymizedID: anonymizedID,
|
||||
DataType: "user",
|
||||
AnonymizedAt: time.Now(),
|
||||
RetainedData: encryptedRetainedData,
|
||||
}
|
||||
|
||||
return anonymized, nil
|
||||
}
|
||||
|
||||
// isPersonalData determines if data is personal information under GDPR
|
||||
func (drm *DataRetentionManager) isPersonalData(fieldName string) bool {
|
||||
personalDataFields := []string{
|
||||
"name", "email", "phone", "address", "birthdate", "gender",
|
||||
"ip_address", "user_agent", "location", "biometric", "health",
|
||||
"political", "religious", "sexual", "criminal", "financial",
|
||||
"education", "employment", "family", "social", "behavioral",
|
||||
"identifier", "cookie", "tracking", "profile", "preferences",
|
||||
}
|
||||
|
||||
fieldName = strings.ToLower(fieldName)
|
||||
for _, personal := range personalDataFields {
|
||||
if strings.Contains(fieldName, personal) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyRetentionPolicy applies retention policies to data
|
||||
func (drm *DataRetentionManager) ApplyRetentionPolicy(dataType string, dataTimestamp time.Time, policy RetentionPolicy) string {
|
||||
if !policy.Enabled {
|
||||
return "retain"
|
||||
}
|
||||
|
||||
expiryDate := dataTimestamp.Add(policy.RetentionPeriod)
|
||||
if time.Now().Before(expiryDate) {
|
||||
return "retain"
|
||||
}
|
||||
|
||||
return policy.Action
|
||||
}
|
||||
|
||||
// GenerateDataSubjectReport generates a report of all data held about a user
|
||||
func (drm *DataRetentionManager) GenerateDataSubjectReport(userID string, userData map[string]interface{}) (map[string]interface{}, error) {
|
||||
report := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"report_generated": time.Now(),
|
||||
"data_categories": drm.categorizeUserData(userData),
|
||||
"retention_policies": drm.getApplicablePolicies(userData),
|
||||
"data_sources": []string{"database", "logs", "analytics"},
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// categorizeUserData categorizes user data by type
|
||||
func (drm *DataRetentionManager) categorizeUserData(userData map[string]interface{}) map[string][]string {
|
||||
categories := map[string][]string{
|
||||
"identity": {},
|
||||
"contact": {},
|
||||
"technical": {},
|
||||
"behavioral": {},
|
||||
"preferences": {},
|
||||
}
|
||||
|
||||
for key := range userData {
|
||||
lowerKey := strings.ToLower(key)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lowerKey, "name") || strings.Contains(lowerKey, "id"):
|
||||
categories["identity"] = append(categories["identity"], key)
|
||||
case strings.Contains(lowerKey, "email") || strings.Contains(lowerKey, "phone"):
|
||||
categories["contact"] = append(categories["contact"], key)
|
||||
case strings.Contains(lowerKey, "ip") || strings.Contains(lowerKey, "agent"):
|
||||
categories["technical"] = append(categories["technical"], key)
|
||||
case strings.Contains(lowerKey, "activity") || strings.Contains(lowerKey, "behavior"):
|
||||
categories["behavioral"] = append(categories["behavioral"], key)
|
||||
case strings.Contains(lowerKey, "preference") || strings.Contains(lowerKey, "setting"):
|
||||
categories["preferences"] = append(categories["preferences"], key)
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// getApplicablePolicies returns applicable retention policies
|
||||
func (drm *DataRetentionManager) getApplicablePolicies(userData map[string]interface{}) []string {
|
||||
policies := []string{
|
||||
"user_data_2_years",
|
||||
"analytics_data_6_months",
|
||||
"logs_data_90_days",
|
||||
"deleted_users_30_days",
|
||||
}
|
||||
|
||||
return policies
|
||||
}
|
||||
|
||||
// AuditLogger handles security audit logging
|
||||
type AuditLogger struct {
|
||||
encryptionManager *EncryptionManager
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewAuditLogger creates a new audit logger
|
||||
func NewAuditLogger(encryptionManager *EncryptionManager, db *database.DB) *AuditLogger {
|
||||
return &AuditLogger{
|
||||
encryptionManager: encryptionManager,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// AuditEvent represents a security audit event
|
||||
type AuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ResourceID string `json:"resource_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Resource string `json:"resource"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// LogAuditEvent logs a security audit event
|
||||
func (al *AuditLogger) LogAuditEvent(event AuditEvent) error {
|
||||
if al == nil || al.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(event.ID) == "" {
|
||||
event.ID = uuid.New().String()
|
||||
}
|
||||
if event.Timestamp.IsZero() {
|
||||
event.Timestamp = time.Now().UTC()
|
||||
}
|
||||
|
||||
// Encrypt sensitive details
|
||||
if event.Details != nil {
|
||||
encryptedDetails, err := al.encryptionManager.EncryptSensitiveData(event.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt audit details: %w", err)
|
||||
}
|
||||
event.Details = encryptedDetails
|
||||
}
|
||||
|
||||
detailsJSON, err := json.Marshal(event.Details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal audit details: %w", err)
|
||||
}
|
||||
|
||||
var userID sql.NullString
|
||||
if id := strings.TrimSpace(event.UserID); id != "" {
|
||||
if _, err := uuid.Parse(id); err == nil {
|
||||
userID = sql.NullString{String: id, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
var resourceID sql.NullString
|
||||
if id := strings.TrimSpace(event.ResourceID); id != "" {
|
||||
if _, err := uuid.Parse(id); err == nil {
|
||||
resourceID = sql.NullString{String: id, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
var ipAddress sql.NullString
|
||||
if ip := strings.TrimSpace(event.IPAddress); ip != "" && net.ParseIP(ip) != nil {
|
||||
ipAddress = sql.NullString{String: ip, Valid: true}
|
||||
}
|
||||
|
||||
var userAgent sql.NullString
|
||||
if ua := strings.TrimSpace(event.UserAgent); ua != "" {
|
||||
userAgent = sql.NullString{String: ua, Valid: true}
|
||||
}
|
||||
|
||||
_, err = al.db.Exec(
|
||||
`INSERT INTO audit_logs (
|
||||
id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`,
|
||||
event.ID,
|
||||
userID,
|
||||
event.Resource,
|
||||
resourceID,
|
||||
event.Action,
|
||||
string(detailsJSON),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
event.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to persist audit event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogSecurityEvent logs security-related events
|
||||
func (al *AuditLogger) LogSecurityEvent(userID, resourceID, action, resource string, details map[string]interface{}, ipAddress, userAgent string, success bool) error {
|
||||
event := AuditEvent{
|
||||
UserID: userID,
|
||||
ResourceID: resourceID,
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Details: details,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
Success: success,
|
||||
}
|
||||
|
||||
return al.LogAuditEvent(event)
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Vulnerability represents a security vulnerability
|
||||
type Vulnerability struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "dependency", "configuration", "code"
|
||||
Severity string `json:"severity"` // "critical", "high", "medium", "low"
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ServiceID string `json:"service_id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Status string `json:"status"` // "open", "resolved", "ignored"
|
||||
FoundAt time.Time `json:"found_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
Metadata string `json:"metadata"` // JSON string for additional data
|
||||
}
|
||||
|
||||
// SecurityScan represents a security scan result
|
||||
type SecurityScan struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ServiceID *string `json:"service_id,omitempty"`
|
||||
ScanType string `json:"scan_type"` // "dependency", "configuration", "comprehensive"
|
||||
Status string `json:"status"` // "running", "completed", "failed"
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Vulnerabilities []Vulnerability `json:"vulnerabilities"`
|
||||
Summary ScanSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// ScanSummary provides a summary of scan results
|
||||
type ScanSummary struct {
|
||||
Total int `json:"total"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Score int `json:"score"` // 0-100 security score
|
||||
}
|
||||
|
||||
// Scanner handles security scanning operations
|
||||
type Scanner struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewScanner creates a new security scanner
|
||||
func NewScanner(db *database.DB) *Scanner {
|
||||
return &Scanner{db: db}
|
||||
}
|
||||
|
||||
// StartSecurityScan initiates a security scan
|
||||
func (s *Scanner) StartSecurityScan(projectID, serviceID, scanType string) (*SecurityScan, error) {
|
||||
scanID := uuid.New().String()
|
||||
|
||||
scan := &SecurityScan{
|
||||
ID: scanID,
|
||||
ProjectID: projectID,
|
||||
ScanType: scanType,
|
||||
Status: "running",
|
||||
StartedAt: time.Now(),
|
||||
Summary: ScanSummary{},
|
||||
}
|
||||
|
||||
if serviceID != "" {
|
||||
scan.ServiceID = &serviceID
|
||||
}
|
||||
|
||||
// Insert scan record
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO security_scans (id, project_id, service_id, scan_type, status, started_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, scan.ID, scan.ProjectID, scan.ServiceID, scan.ScanType, scan.Status, scan.StartedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create security scan: %w", err)
|
||||
}
|
||||
|
||||
// Start scan in background
|
||||
go s.performScan(scan)
|
||||
|
||||
return scan, nil
|
||||
}
|
||||
|
||||
// performScan executes the actual security scan
|
||||
func (s *Scanner) performScan(scan *SecurityScan) {
|
||||
ctx := context.Background()
|
||||
var vulnerabilities []Vulnerability
|
||||
|
||||
switch scan.ScanType {
|
||||
case "dependency":
|
||||
vulnerabilities = s.scanDependencies(ctx, scan)
|
||||
case "configuration":
|
||||
vulnerabilities = s.scanConfiguration(ctx, scan)
|
||||
case "comprehensive":
|
||||
vulnerabilities = s.scanComprehensive(ctx, scan)
|
||||
default:
|
||||
vulnerabilities = []Vulnerability{}
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
summary := s.calculateSummary(vulnerabilities)
|
||||
|
||||
// Update scan with results
|
||||
completedAt := time.Now()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE security_scans
|
||||
SET status = $1, completed_at = $2, summary = $3
|
||||
WHERE id = $4
|
||||
`, "completed", completedAt, summaryToJSON(summary), scan.ID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update security scan %s: %v", scan.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Store vulnerabilities
|
||||
for _, vuln := range vulnerabilities {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO vulnerabilities (id, type, severity, title, description, service_id, project_id, status, found_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, vuln.ID, vuln.Type, vuln.Severity, vuln.Title, vuln.Description, vuln.ServiceID, vuln.ProjectID, vuln.Status, vuln.FoundAt, vuln.Metadata)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanDependencies scans for known vulnerable dependencies
|
||||
func (s *Scanner) scanDependencies(ctx context.Context, scan *SecurityScan) []Vulnerability {
|
||||
var vulnerabilities []Vulnerability
|
||||
|
||||
// Get project services
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
COALESCE(source_type, ''),
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
health_check_url
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
`
|
||||
args := []interface{}{scan.ProjectID}
|
||||
if scan.ServiceID != nil {
|
||||
query += ` AND id = $2`
|
||||
args = append(args, *scan.ServiceID)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to query services for scan: %v", err)
|
||||
return vulnerabilities
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
serviceID string
|
||||
serviceName string
|
||||
sourceType string
|
||||
sourceURL sql.NullString
|
||||
imageName sql.NullString
|
||||
buildCommand sql.NullString
|
||||
startCommand sql.NullString
|
||||
healthCheckURL sql.NullString
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&serviceID,
|
||||
&serviceName,
|
||||
&sourceType,
|
||||
&sourceURL,
|
||||
&imageName,
|
||||
&buildCommand,
|
||||
&startCommand,
|
||||
&healthCheckURL,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
serviceVulns := s.evaluateDependencyFindings(scan.ProjectID, dependencyEvidence{
|
||||
ServiceID: serviceID,
|
||||
ServiceName: serviceName,
|
||||
SourceType: strings.TrimSpace(sourceType),
|
||||
SourceURL: strings.TrimSpace(sourceURL.String),
|
||||
ImageName: strings.TrimSpace(imageName.String),
|
||||
BuildCommand: strings.TrimSpace(buildCommand.String),
|
||||
StartCommand: strings.TrimSpace(startCommand.String),
|
||||
HealthCheckURL: strings.TrimSpace(healthCheckURL.String),
|
||||
})
|
||||
vulnerabilities = append(vulnerabilities, serviceVulns...)
|
||||
}
|
||||
|
||||
return vulnerabilities
|
||||
}
|
||||
|
||||
type dependencyEvidence struct {
|
||||
ServiceID string
|
||||
ServiceName string
|
||||
SourceType string
|
||||
SourceURL string
|
||||
ImageName string
|
||||
BuildCommand string
|
||||
StartCommand string
|
||||
HealthCheckURL string
|
||||
}
|
||||
|
||||
func (s *Scanner) evaluateDependencyFindings(projectID string, evidence dependencyEvidence) []Vulnerability {
|
||||
var vulns []Vulnerability
|
||||
|
||||
appendVuln := func(severity, title, description string, metadata map[string]interface{}) {
|
||||
vulns = append(vulns, Vulnerability{
|
||||
ID: uuid.New().String(),
|
||||
Type: "dependency",
|
||||
Severity: severity,
|
||||
Title: title,
|
||||
Description: description,
|
||||
ServiceID: evidence.ServiceID,
|
||||
ProjectID: projectID,
|
||||
Status: "open",
|
||||
FoundAt: time.Now(),
|
||||
Metadata: toJSONMetadata(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
image := strings.ToLower(evidence.ImageName)
|
||||
if image != "" {
|
||||
if !strings.Contains(image, ":") || strings.HasSuffix(image, ":latest") {
|
||||
appendVuln(
|
||||
"medium",
|
||||
"Unpinned container image tag",
|
||||
"Container image is not pinned to an immutable version/tag, increasing supply-chain risk.",
|
||||
map[string]interface{}{"service": evidence.ServiceName, "image": evidence.ImageName},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(evidence.SourceURL), "http://") {
|
||||
appendVuln(
|
||||
"high",
|
||||
"Insecure source transport",
|
||||
"Source URL uses plain HTTP instead of HTTPS, enabling tampering during fetch.",
|
||||
map[string]interface{}{"service": evidence.ServiceName, "source_url": evidence.SourceURL},
|
||||
)
|
||||
}
|
||||
|
||||
build := strings.ToLower(evidence.BuildCommand)
|
||||
if strings.Contains(build, "npm install") && !strings.Contains(build, "npm ci") {
|
||||
appendVuln(
|
||||
"medium",
|
||||
"Non-deterministic npm dependency install",
|
||||
"Build command uses `npm install`; prefer `npm ci` to enforce lockfile integrity in CI/CD.",
|
||||
map[string]interface{}{"service": evidence.ServiceName, "build_command": evidence.BuildCommand},
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(build, "pip install") && !strings.Contains(build, "--require-hashes") {
|
||||
appendVuln(
|
||||
"medium",
|
||||
"Python dependencies installed without hash verification",
|
||||
"Build command uses `pip install` without `--require-hashes`, reducing dependency integrity guarantees.",
|
||||
map[string]interface{}{"service": evidence.ServiceName, "build_command": evidence.BuildCommand},
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(build, "curl") && strings.Contains(build, "|") && (strings.Contains(build, "sh") || strings.Contains(build, "bash")) {
|
||||
appendVuln(
|
||||
"high",
|
||||
"Remote script execution in build pipeline",
|
||||
"Build command pipes remote content directly to shell execution (`curl ... | sh/bash`).",
|
||||
map[string]interface{}{"service": evidence.ServiceName, "build_command": evidence.BuildCommand},
|
||||
)
|
||||
}
|
||||
|
||||
if evidence.SourceType == "github" && evidence.SourceURL == "" {
|
||||
appendVuln(
|
||||
"low",
|
||||
"Missing source repository URL",
|
||||
"Service is configured as GitHub source but source URL is empty, preventing provenance verification.",
|
||||
map[string]interface{}{"service": evidence.ServiceName},
|
||||
)
|
||||
}
|
||||
|
||||
if evidence.HealthCheckURL == "" {
|
||||
appendVuln(
|
||||
"low",
|
||||
"No health check URL configured",
|
||||
"Service has no health check URL, reducing detection speed for vulnerable or failing releases.",
|
||||
map[string]interface{}{"service": evidence.ServiceName},
|
||||
)
|
||||
}
|
||||
|
||||
return vulns
|
||||
}
|
||||
|
||||
// scanConfiguration scans for security configuration issues
|
||||
func (s *Scanner) scanConfiguration(ctx context.Context, scan *SecurityScan) []Vulnerability {
|
||||
var vulnerabilities []Vulnerability
|
||||
|
||||
appendVuln := func(severity, title, description, serviceID string, metadata map[string]interface{}) {
|
||||
vulnerabilities = append(vulnerabilities, Vulnerability{
|
||||
ID: uuid.New().String(),
|
||||
Type: "configuration",
|
||||
Severity: severity,
|
||||
Title: title,
|
||||
Description: description,
|
||||
ServiceID: serviceID,
|
||||
ProjectID: scan.ProjectID,
|
||||
Status: "open",
|
||||
FoundAt: time.Now(),
|
||||
Metadata: toJSONMetadata(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
serviceQuery := `
|
||||
SELECT id, name, COALESCE(public_url, ''), COALESCE(health_check_url, ''), COALESCE(build_command, ''), COALESCE(start_command, ''), COALESCE(status, '')
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
`
|
||||
args := []interface{}{scan.ProjectID}
|
||||
if scan.ServiceID != nil {
|
||||
serviceQuery += ` AND id = $2`
|
||||
args = append(args, *scan.ServiceID)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(serviceQuery, args...)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
serviceID string
|
||||
serviceName string
|
||||
publicURL string
|
||||
healthCheckURL string
|
||||
buildCommand string
|
||||
startCommand string
|
||||
status string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&serviceID, &serviceName, &publicURL, &healthCheckURL, &buildCommand, &startCommand, &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if publicURL != "" && strings.HasPrefix(strings.ToLower(publicURL), "http://") {
|
||||
appendVuln(
|
||||
"high",
|
||||
"Insecure public service URL",
|
||||
"Service exposes an HTTP URL without TLS.",
|
||||
serviceID,
|
||||
map[string]interface{}{"service": serviceName, "public_url": publicURL},
|
||||
)
|
||||
}
|
||||
|
||||
activeStatus := strings.ToLower(strings.TrimSpace(status))
|
||||
if (activeStatus == "running" || activeStatus == "building" || activeStatus == "deploying") && strings.TrimSpace(healthCheckURL) == "" {
|
||||
appendVuln(
|
||||
"medium",
|
||||
"Missing runtime health check",
|
||||
"Running/deploying service has no configured health check URL.",
|
||||
serviceID,
|
||||
map[string]interface{}{"service": serviceName},
|
||||
)
|
||||
}
|
||||
|
||||
exec := strings.ToLower(buildCommand + " " + startCommand)
|
||||
if strings.Contains(exec, "--debug") || strings.Contains(exec, "--reload") || strings.Contains(exec, "node_env=development") {
|
||||
appendVuln(
|
||||
"medium",
|
||||
"Potential debug/development runtime configuration",
|
||||
"Service command includes debug/development flags that are risky in production.",
|
||||
serviceID,
|
||||
map[string]interface{}{"service": serviceName},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbRows, dbErr := s.db.Query(`
|
||||
SELECT ds.id, ds.name
|
||||
FROM projects p
|
||||
JOIN database_services ds ON ds.user_id = p.owner_id
|
||||
JOIN database_settings st ON st.database_id = ds.id
|
||||
WHERE p.id = $1 AND COALESCE(st.ssl_enabled, false) = false
|
||||
`, scan.ProjectID)
|
||||
if dbErr == nil {
|
||||
defer dbRows.Close()
|
||||
for dbRows.Next() {
|
||||
var dbID, dbName string
|
||||
if err := dbRows.Scan(&dbID, &dbName); err != nil {
|
||||
continue
|
||||
}
|
||||
appendVuln(
|
||||
"high",
|
||||
"Database SSL disabled",
|
||||
"Managed database has SSL/TLS disabled; encrypt database traffic in transit.",
|
||||
"",
|
||||
map[string]interface{}{"database_id": dbID, "database_name": dbName},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var hasRecentAudit bool
|
||||
auditErr := s.db.QueryRow(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM audit_logs
|
||||
WHERE (
|
||||
(resource = 'project' AND resource_id::text = $1)
|
||||
OR details->>'project_id' = $1
|
||||
)
|
||||
AND created_at > NOW() - INTERVAL '30 days'
|
||||
)
|
||||
`, scan.ProjectID).Scan(&hasRecentAudit)
|
||||
if auditErr == nil && !hasRecentAudit {
|
||||
appendVuln(
|
||||
"low",
|
||||
"No recent security audit events",
|
||||
"No audit log entries were found for this project in the last 30 days.",
|
||||
"",
|
||||
map[string]interface{}{"project_id": scan.ProjectID},
|
||||
)
|
||||
}
|
||||
|
||||
return vulnerabilities
|
||||
}
|
||||
|
||||
// scanComprehensive performs a comprehensive security scan
|
||||
func (s *Scanner) scanComprehensive(ctx context.Context, scan *SecurityScan) []Vulnerability {
|
||||
var allVulnerabilities []Vulnerability
|
||||
|
||||
// Run all scan types
|
||||
allVulnerabilities = append(allVulnerabilities, s.scanDependencies(ctx, scan)...)
|
||||
allVulnerabilities = append(allVulnerabilities, s.scanConfiguration(ctx, scan)...)
|
||||
|
||||
return allVulnerabilities
|
||||
}
|
||||
|
||||
// calculateSummary calculates scan summary from vulnerabilities
|
||||
func (s *Scanner) calculateSummary(vulnerabilities []Vulnerability) ScanSummary {
|
||||
summary := ScanSummary{
|
||||
Total: len(vulnerabilities),
|
||||
}
|
||||
|
||||
for _, vuln := range vulnerabilities {
|
||||
switch vuln.Severity {
|
||||
case "critical":
|
||||
summary.Critical++
|
||||
case "high":
|
||||
summary.High++
|
||||
case "medium":
|
||||
summary.Medium++
|
||||
case "low":
|
||||
summary.Low++
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate security score (0-100, higher is better)
|
||||
if summary.Total == 0 {
|
||||
summary.Score = 100
|
||||
} else {
|
||||
deduction := (summary.Critical * 25) + (summary.High * 15) + (summary.Medium * 8) + (summary.Low * 3)
|
||||
summary.Score = max(0, 100-deduction)
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// GetSecurityScan retrieves a security scan by ID
|
||||
func (s *Scanner) GetSecurityScan(scanID string) (*SecurityScan, error) {
|
||||
var scan SecurityScan
|
||||
var summaryJSON sql.NullString
|
||||
var completedAt sql.NullTime
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, project_id, service_id, scan_type, status, started_at, completed_at, summary
|
||||
FROM security_scans WHERE id = $1
|
||||
`, scanID).Scan(&scan.ID, &scan.ProjectID, &scan.ServiceID, &scan.ScanType, &scan.Status, &scan.StartedAt, &completedAt, &summaryJSON)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if completedAt.Valid {
|
||||
scan.CompletedAt = &completedAt.Time
|
||||
}
|
||||
|
||||
if summaryJSON.Valid {
|
||||
scan.Summary = jsonToSummary(summaryJSON.String)
|
||||
}
|
||||
|
||||
// Load vulnerabilities
|
||||
vulns, err := s.getVulnerabilitiesForScan(scan.ID)
|
||||
if err == nil {
|
||||
scan.Vulnerabilities = vulns
|
||||
}
|
||||
|
||||
return &scan, nil
|
||||
}
|
||||
|
||||
// getVulnerabilitiesForScan retrieves vulnerabilities for a scan
|
||||
func (s *Scanner) getVulnerabilitiesForScan(scanID string) ([]Vulnerability, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, type, severity, title, description, service_id, project_id, status, found_at, resolved_at, metadata
|
||||
FROM vulnerabilities WHERE project_id = (SELECT project_id FROM security_scans WHERE id = $1)
|
||||
ORDER BY severity DESC, found_at DESC
|
||||
`, scanID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vulnerabilities []Vulnerability
|
||||
for rows.Next() {
|
||||
var vuln Vulnerability
|
||||
var resolvedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(&vuln.ID, &vuln.Type, &vuln.Severity, &vuln.Title, &vuln.Description,
|
||||
&vuln.ServiceID, &vuln.ProjectID, &vuln.Status, &vuln.FoundAt, &resolvedAt, &vuln.Metadata)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resolvedAt.Valid {
|
||||
vuln.ResolvedAt = &resolvedAt.Time
|
||||
}
|
||||
|
||||
vulnerabilities = append(vulnerabilities, vuln)
|
||||
}
|
||||
|
||||
return vulnerabilities, nil
|
||||
}
|
||||
|
||||
// GetProjectSecurityHistory retrieves security scan history for a project
|
||||
func (s *Scanner) GetProjectSecurityHistory(projectID string, limit int) ([]SecurityScan, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, project_id, service_id, scan_type, status, started_at, completed_at, summary
|
||||
FROM security_scans
|
||||
WHERE project_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $2
|
||||
`, projectID, limit)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scans []SecurityScan
|
||||
for rows.Next() {
|
||||
var scan SecurityScan
|
||||
var summaryJSON sql.NullString
|
||||
var completedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(&scan.ID, &scan.ProjectID, &scan.ServiceID, &scan.ScanType, &scan.Status,
|
||||
&scan.StartedAt, &completedAt, &summaryJSON)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if completedAt.Valid {
|
||||
scan.CompletedAt = &completedAt.Time
|
||||
}
|
||||
|
||||
if summaryJSON.Valid {
|
||||
scan.Summary = jsonToSummary(summaryJSON.String)
|
||||
}
|
||||
|
||||
scans = append(scans, scan)
|
||||
}
|
||||
|
||||
return scans, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func summaryToJSON(summary ScanSummary) string {
|
||||
data, _ := json.Marshal(summary)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func jsonToSummary(jsonStr string) ScanSummary {
|
||||
var summary ScanSummary
|
||||
json.Unmarshal([]byte(jsonStr), &summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func toJSONMetadata(metadata map[string]interface{}) string {
|
||||
if len(metadata) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
b, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEvaluateDependencyFindingsFlagsRiskyConfiguration(t *testing.T) {
|
||||
scanner := &Scanner{}
|
||||
|
||||
vulns := scanner.evaluateDependencyFindings("project-1", dependencyEvidence{
|
||||
ServiceID: "service-1",
|
||||
ServiceName: "billing-api",
|
||||
SourceType: "github",
|
||||
SourceURL: "http://github.example.com/org/repo",
|
||||
ImageName: "ghcr.io/example/billing:latest",
|
||||
BuildCommand: "curl -fsSL https://example/install.sh | sh && npm install",
|
||||
StartCommand: "node server.js",
|
||||
HealthCheckURL: "",
|
||||
})
|
||||
|
||||
expectTitle(t, vulns, "Unpinned container image tag")
|
||||
expectTitle(t, vulns, "Insecure source transport")
|
||||
expectTitle(t, vulns, "Non-deterministic npm dependency install")
|
||||
expectTitle(t, vulns, "Remote script execution in build pipeline")
|
||||
expectTitle(t, vulns, "No health check URL configured")
|
||||
}
|
||||
|
||||
func TestEvaluateDependencyFindingsForPinnedSecureService(t *testing.T) {
|
||||
scanner := &Scanner{}
|
||||
|
||||
vulns := scanner.evaluateDependencyFindings("project-1", dependencyEvidence{
|
||||
ServiceID: "service-2",
|
||||
ServiceName: "worker",
|
||||
SourceType: "github",
|
||||
SourceURL: "https://github.com/org/repo",
|
||||
ImageName: "ghcr.io/example/worker:v1.4.2",
|
||||
BuildCommand: "npm ci",
|
||||
StartCommand: "node worker.js",
|
||||
HealthCheckURL: "/health",
|
||||
})
|
||||
|
||||
if len(vulns) != 0 {
|
||||
t.Fatalf("expected no dependency findings, got %d", len(vulns))
|
||||
}
|
||||
}
|
||||
|
||||
func expectTitle(t *testing.T, vulns []Vulnerability, title string) {
|
||||
t.Helper()
|
||||
|
||||
for _, vuln := range vulns {
|
||||
if vuln.Title == title {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected vulnerability title %q, got %#v", title, vulns)
|
||||
}
|
||||
Reference in New Issue
Block a user