358 lines
13 KiB
Go
358 lines
13 KiB
Go
|
|
package datastore
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
domain2 "epigas.gitea.cloud/RiskRancher/core/pkg/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *SQLiteStore) GetSheriffAnalytics(ctx context.Context) (domain2.SheriffAnalytics, error) {
|
||
|
|
var metrics domain2.SheriffAnalytics
|
||
|
|
|
||
|
|
s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE is_cisa_kev = 1 AND status NOT IN ('Patched', 'Risk Accepted', 'False Positive')").Scan(&metrics.ActiveKEVs)
|
||
|
|
s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE severity = 'Critical' AND status NOT IN ('Patched', 'Risk Accepted', 'False Positive')").Scan(&metrics.OpenCriticals)
|
||
|
|
s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE remediation_due_date < CURRENT_TIMESTAMP AND status NOT IN ('Patched', 'Risk Accepted', 'False Positive')").Scan(&metrics.TotalOverdue)
|
||
|
|
|
||
|
|
mttrQuery := `
|
||
|
|
SELECT COALESCE(AVG(julianday(t.patched_at) - julianday(t.created_at)), 0)
|
||
|
|
FROM tickets t
|
||
|
|
WHERE t.status = 'Patched'
|
||
|
|
`
|
||
|
|
var mttrFloat float64
|
||
|
|
s.DB.QueryRowContext(ctx, mttrQuery).Scan(&mttrFloat)
|
||
|
|
metrics.GlobalMTTRDays = int(mttrFloat)
|
||
|
|
|
||
|
|
sourceQuery := `
|
||
|
|
SELECT
|
||
|
|
t.source,
|
||
|
|
SUM(CASE WHEN t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive') THEN 1 ELSE 0 END) as total_open,
|
||
|
|
SUM(CASE WHEN t.severity = 'Critical' AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive') THEN 1 ELSE 0 END) as criticals,
|
||
|
|
SUM(CASE WHEN t.is_cisa_kev = 1 AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive') THEN 1 ELSE 0 END) as cisa_kevs,
|
||
|
|
SUM(CASE WHEN t.status = 'Waiting to be Triaged' THEN 1 ELSE 0 END) as untriaged,
|
||
|
|
SUM(CASE WHEN t.remediation_due_date < CURRENT_TIMESTAMP AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive') THEN 1 ELSE 0 END) as patch_overdue,
|
||
|
|
SUM(CASE WHEN t.status = 'Pending Risk Approval' THEN 1 ELSE 0 END) as pending_risk,
|
||
|
|
|
||
|
|
SUM(CASE WHEN t.status IN ('Patched', 'Risk Accepted', 'False Positive') THEN 1 ELSE 0 END) as total_closed,
|
||
|
|
SUM(CASE WHEN t.status = 'Patched' THEN 1 ELSE 0 END) as patched,
|
||
|
|
SUM(CASE WHEN t.status = 'Risk Accepted' THEN 1 ELSE 0 END) as risk_accepted,
|
||
|
|
SUM(CASE WHEN t.status = 'False Positive' THEN 1 ELSE 0 END) as false_positive
|
||
|
|
FROM tickets t
|
||
|
|
GROUP BY t.source
|
||
|
|
ORDER BY criticals DESC, patch_overdue DESC
|
||
|
|
`
|
||
|
|
rows, err := s.DB.QueryContext(ctx, sourceQuery)
|
||
|
|
if err == nil {
|
||
|
|
defer rows.Close()
|
||
|
|
for rows.Next() {
|
||
|
|
var sm domain2.SourceMetrics
|
||
|
|
rows.Scan(&sm.Source, &sm.TotalOpen, &sm.Criticals, &sm.CisaKEVs, &sm.Untriaged, &sm.PatchOverdue, &sm.PendingRisk, &sm.TotalClosed, &sm.Patched, &sm.RiskAccepted, &sm.FalsePositive)
|
||
|
|
|
||
|
|
topAssigneeQ := `
|
||
|
|
SELECT COALESCE(ta.assignee, 'Unassigned'), COUNT(t.id) as c
|
||
|
|
FROM tickets t LEFT JOIN ticket_assignments ta ON t.id = ta.ticket_id
|
||
|
|
WHERE t.source = ? AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive')
|
||
|
|
GROUP BY ta.assignee ORDER BY c DESC LIMIT 1`
|
||
|
|
|
||
|
|
var assignee string
|
||
|
|
var count int
|
||
|
|
s.DB.QueryRowContext(ctx, topAssigneeQ, sm.Source).Scan(&assignee, &count)
|
||
|
|
if count > 0 {
|
||
|
|
sm.TopAssignee = fmt.Sprintf("%s (%d)", assignee, count)
|
||
|
|
} else {
|
||
|
|
sm.TopAssignee = "N/A"
|
||
|
|
}
|
||
|
|
|
||
|
|
if sm.PatchOverdue > 0 {
|
||
|
|
sm.StrategicNote = "🚨 SLA Breach (Escalate to IT Managers)"
|
||
|
|
} else if sm.Untriaged > 0 {
|
||
|
|
sm.StrategicNote = "⚠️ Triage Bottleneck (Check Analysts)"
|
||
|
|
} else if sm.PendingRisk > 0 {
|
||
|
|
sm.StrategicNote = "⚖️ Blocked by Exec Adjudication"
|
||
|
|
} else if sm.Criticals > 0 {
|
||
|
|
sm.StrategicNote = "🔥 High Risk (Monitor closely)"
|
||
|
|
} else if sm.RiskAccepted > sm.Patched && sm.TotalClosed > 0 {
|
||
|
|
sm.StrategicNote = "👀 High Risk Acceptance Rate (Audit Required)"
|
||
|
|
} else if sm.FalsePositive > sm.Patched && sm.TotalClosed > 0 {
|
||
|
|
sm.StrategicNote = "🔧 Noisy Source (Scanner needs tuning)"
|
||
|
|
} else if sm.TotalClosed > 0 {
|
||
|
|
sm.StrategicNote = "✅ Healthy Resolution Velocity"
|
||
|
|
} else {
|
||
|
|
sm.StrategicNote = "✅ Routine Processing"
|
||
|
|
}
|
||
|
|
|
||
|
|
metrics.SourceHealth = append(metrics.SourceHealth, sm)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sevQuery := `SELECT severity, COUNT(id) FROM tickets WHERE status NOT IN ('Patched', 'Risk Accepted', 'False Positive') GROUP BY severity`
|
||
|
|
rowsSev, err := s.DB.QueryContext(ctx, sevQuery)
|
||
|
|
if err == nil {
|
||
|
|
defer rowsSev.Close()
|
||
|
|
for rowsSev.Next() {
|
||
|
|
var sev string
|
||
|
|
var count int
|
||
|
|
rowsSev.Scan(&sev, &count)
|
||
|
|
metrics.Severity.Total += count
|
||
|
|
switch sev {
|
||
|
|
case "Critical":
|
||
|
|
metrics.Severity.Critical = count
|
||
|
|
case "High":
|
||
|
|
metrics.Severity.High = count
|
||
|
|
case "Medium":
|
||
|
|
metrics.Severity.Medium = count
|
||
|
|
case "Low":
|
||
|
|
metrics.Severity.Low = count
|
||
|
|
case "Info":
|
||
|
|
metrics.Severity.Info = count
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if metrics.Severity.Total > 0 {
|
||
|
|
metrics.Severity.CritPct = int((float64(metrics.Severity.Critical) / float64(metrics.Severity.Total)) * 100)
|
||
|
|
metrics.Severity.HighPct = int((float64(metrics.Severity.High) / float64(metrics.Severity.Total)) * 100)
|
||
|
|
metrics.Severity.MedPct = int((float64(metrics.Severity.Medium) / float64(metrics.Severity.Total)) * 100)
|
||
|
|
metrics.Severity.LowPct = int((float64(metrics.Severity.Low) / float64(metrics.Severity.Total)) * 100)
|
||
|
|
metrics.Severity.InfoPct = int((float64(metrics.Severity.Info) / float64(metrics.Severity.Total)) * 100)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
resQuery := `SELECT status, COUNT(id) FROM tickets WHERE status IN ('Patched', 'Risk Accepted', 'False Positive') GROUP BY status`
|
||
|
|
rowsRes, err := s.DB.QueryContext(ctx, resQuery)
|
||
|
|
if err == nil {
|
||
|
|
defer rowsRes.Close()
|
||
|
|
for rowsRes.Next() {
|
||
|
|
var status string
|
||
|
|
var count int
|
||
|
|
rowsRes.Scan(&status, &count)
|
||
|
|
metrics.Resolution.Total += count
|
||
|
|
|
||
|
|
switch status {
|
||
|
|
case "Patched":
|
||
|
|
metrics.Resolution.Patched = count
|
||
|
|
case "Risk Accepted":
|
||
|
|
metrics.Resolution.RiskAccepted = count
|
||
|
|
case "False Positive":
|
||
|
|
metrics.Resolution.FalsePositive = count
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if metrics.Resolution.Total > 0 {
|
||
|
|
metrics.Resolution.PatchedPct = int((float64(metrics.Resolution.Patched) / float64(metrics.Resolution.Total)) * 100)
|
||
|
|
metrics.Resolution.RiskAccPct = int((float64(metrics.Resolution.RiskAccepted) / float64(metrics.Resolution.Total)) * 100)
|
||
|
|
metrics.Resolution.FalsePosPct = int((float64(metrics.Resolution.FalsePositive) / float64(metrics.Resolution.Total)) * 100)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
assetQuery := `SELECT asset_identifier, COUNT(id) as c FROM tickets WHERE status NOT IN ('Patched', 'Risk Accepted', 'False Positive') GROUP BY asset_identifier ORDER BY c DESC LIMIT 5`
|
||
|
|
rowsAsset, err := s.DB.QueryContext(ctx, assetQuery)
|
||
|
|
if err == nil {
|
||
|
|
defer rowsAsset.Close()
|
||
|
|
var maxAssetCount int
|
||
|
|
for rowsAsset.Next() {
|
||
|
|
var am domain2.AssetMetric
|
||
|
|
rowsAsset.Scan(&am.Asset, &am.Count)
|
||
|
|
if maxAssetCount == 0 {
|
||
|
|
maxAssetCount = am.Count
|
||
|
|
}
|
||
|
|
if maxAssetCount > 0 {
|
||
|
|
am.Percentage = int((float64(am.Count) / float64(maxAssetCount)) * 100)
|
||
|
|
}
|
||
|
|
metrics.TopAssets = append(metrics.TopAssets, am)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return metrics, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *SQLiteStore) GetDashboardTickets(ctx context.Context, tabStatus, filter, assetFilter, userEmail, userRole string, limit, offset int) ([]domain2.Ticket, int, map[string]int, error) {
|
||
|
|
metrics := map[string]int{
|
||
|
|
"critical": 0,
|
||
|
|
"overdue": 0,
|
||
|
|
"mine": 0,
|
||
|
|
"verification": 0,
|
||
|
|
"returned": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
scope := ""
|
||
|
|
var scopeArgs []any
|
||
|
|
|
||
|
|
if userRole == "Wrangler" {
|
||
|
|
scope = ` AND LOWER(t.assignee) = LOWER(?)`
|
||
|
|
scopeArgs = append(scopeArgs, userEmail)
|
||
|
|
}
|
||
|
|
|
||
|
|
if userRole != "Sheriff" {
|
||
|
|
var critCount, overCount, mineCount, verifyCount, returnedCount int
|
||
|
|
|
||
|
|
critQ := "SELECT COUNT(t.id) FROM tickets t WHERE t.severity = 'Critical' AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive')" + scope
|
||
|
|
s.DB.QueryRowContext(ctx, critQ, scopeArgs...).Scan(&critCount)
|
||
|
|
metrics["critical"] = critCount
|
||
|
|
|
||
|
|
overQ := "SELECT COUNT(t.id) FROM tickets t WHERE t.remediation_due_date < CURRENT_TIMESTAMP AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive')" + scope
|
||
|
|
s.DB.QueryRowContext(ctx, overQ, scopeArgs...).Scan(&overCount)
|
||
|
|
metrics["overdue"] = overCount
|
||
|
|
|
||
|
|
mineQ := "SELECT COUNT(t.id) FROM tickets t WHERE LOWER(t.assignee) = LOWER(?) AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive')"
|
||
|
|
s.DB.QueryRowContext(ctx, mineQ, userEmail).Scan(&mineCount)
|
||
|
|
metrics["mine"] = mineCount
|
||
|
|
|
||
|
|
verifyQ := "SELECT COUNT(t.id) FROM tickets t WHERE t.status = 'Pending Verification'" + scope
|
||
|
|
s.DB.QueryRowContext(ctx, verifyQ, scopeArgs...).Scan(&verifyCount)
|
||
|
|
metrics["verification"] = verifyCount
|
||
|
|
|
||
|
|
retQ := "SELECT COUNT(t.id) FROM tickets t WHERE t.status = 'Returned to Security'" + scope
|
||
|
|
s.DB.QueryRowContext(ctx, retQ, scopeArgs...).Scan(&returnedCount)
|
||
|
|
metrics["returned"] = returnedCount
|
||
|
|
}
|
||
|
|
|
||
|
|
baseQ := "FROM tickets t WHERE 1=1" + scope
|
||
|
|
var args []any
|
||
|
|
args = append(args, scopeArgs...)
|
||
|
|
|
||
|
|
if assetFilter != "" {
|
||
|
|
baseQ += " AND t.asset_identifier = ?"
|
||
|
|
args = append(args, assetFilter)
|
||
|
|
}
|
||
|
|
|
||
|
|
if tabStatus == "Waiting to be Triaged" || tabStatus == "holding_pen" {
|
||
|
|
baseQ += " AND t.status IN ('Waiting to be Triaged', 'Returned to Security', 'Triaged')"
|
||
|
|
} else if tabStatus == "Exceptions" {
|
||
|
|
baseQ += " AND t.status NOT IN ('Patched', 'Risk Accepted', 'False Positive')"
|
||
|
|
} else if tabStatus == "archives" {
|
||
|
|
baseQ += " AND t.status IN ('Patched', 'Risk Accepted', 'False Positive')"
|
||
|
|
} else if tabStatus != "" {
|
||
|
|
baseQ += " AND t.status = ?"
|
||
|
|
args = append(args, tabStatus)
|
||
|
|
}
|
||
|
|
|
||
|
|
if filter == "critical" {
|
||
|
|
baseQ += " AND t.severity = 'Critical'"
|
||
|
|
} else if filter == "overdue" {
|
||
|
|
baseQ += " AND t.remediation_due_date < CURRENT_TIMESTAMP"
|
||
|
|
} else if filter == "mine" {
|
||
|
|
baseQ += " AND LOWER(t.assignee) = LOWER(?)"
|
||
|
|
args = append(args, userEmail)
|
||
|
|
} else if tabStatus == "archives" && filter != "" && filter != "all" {
|
||
|
|
baseQ += " AND t.status = ?"
|
||
|
|
args = append(args, filter)
|
||
|
|
}
|
||
|
|
|
||
|
|
var total int
|
||
|
|
s.DB.QueryRowContext(ctx, "SELECT COUNT(t.id) "+baseQ, args...).Scan(&total)
|
||
|
|
|
||
|
|
orderClause := "ORDER BY (CASE WHEN t.status = 'Returned to Security' THEN 0 ELSE 1 END) ASC, t.id DESC"
|
||
|
|
|
||
|
|
query := `
|
||
|
|
WITH PaginatedIDs AS (
|
||
|
|
SELECT t.id ` + baseQ + ` ` + orderClause + ` LIMIT ? OFFSET ?
|
||
|
|
)
|
||
|
|
SELECT
|
||
|
|
t.id, t.source, t.asset_identifier, t.title, COALESCE(t.description, ''), COALESCE(t.recommended_remediation, ''), t.severity, t.status,
|
||
|
|
t.triage_due_date, t.remediation_due_date, COALESCE(t.patch_evidence, ''),
|
||
|
|
t.assignee as current_assignee,
|
||
|
|
t.owner_viewed_at,
|
||
|
|
t.updated_at,
|
||
|
|
CAST(julianday(COALESCE(t.patched_at, t.updated_at)) - julianday(t.created_at) AS INTEGER) as days_to_resolve,
|
||
|
|
COALESCE(t.latest_comment, '') as latest_comment
|
||
|
|
FROM PaginatedIDs p
|
||
|
|
JOIN tickets t ON t.id = p.id
|
||
|
|
` + orderClause
|
||
|
|
|
||
|
|
args = append(args, limit, offset)
|
||
|
|
|
||
|
|
rows, err := s.DB.QueryContext(ctx, query, args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, metrics, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var tickets []domain2.Ticket
|
||
|
|
for rows.Next() {
|
||
|
|
var t domain2.Ticket
|
||
|
|
var assignee string
|
||
|
|
|
||
|
|
err := rows.Scan(
|
||
|
|
&t.ID, &t.Source, &t.AssetIdentifier, &t.Title, &t.Description,
|
||
|
|
&t.RecommendedRemediation, &t.Severity, &t.Status,
|
||
|
|
&t.TriageDueDate, &t.RemediationDueDate, &t.PatchEvidence,
|
||
|
|
&assignee,
|
||
|
|
&t.OwnerViewedAt,
|
||
|
|
&t.UpdatedAt,
|
||
|
|
&t.DaysToResolve,
|
||
|
|
&t.LatestComment,
|
||
|
|
)
|
||
|
|
|
||
|
|
if err == nil {
|
||
|
|
t.Assignee = assignee
|
||
|
|
t.IsOverdue = !t.RemediationDueDate.IsZero() && t.RemediationDueDate.Before(time.Now()) && t.Status != "Patched" && t.Status != "Risk Accepted"
|
||
|
|
|
||
|
|
if tabStatus == "archives" {
|
||
|
|
if t.DaysToResolve != nil {
|
||
|
|
t.SLAString = fmt.Sprintf("%d days", *t.DaysToResolve)
|
||
|
|
} else {
|
||
|
|
t.SLAString = "Unknown"
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
t.SLAString = t.RemediationDueDate.Format("Jan 02, 2006")
|
||
|
|
}
|
||
|
|
|
||
|
|
tickets = append(tickets, t)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return tickets, total, metrics, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *SQLiteStore) GetGlobalActivityFeed(ctx context.Context, limit int) ([]domain2.FeedItem, error) {
|
||
|
|
return []domain2.FeedItem{
|
||
|
|
{
|
||
|
|
Actor: "System",
|
||
|
|
ActivityType: "Info",
|
||
|
|
NewValue: "Detailed Immutable Audit Logging is a RiskRancher Pro feature. Upgrade to track all ticket lifecycle events.",
|
||
|
|
TimeAgo: "Just now",
|
||
|
|
},
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *SQLiteStore) GetAnalyticsSummary(ctx context.Context) (map[string]int, error) {
|
||
|
|
summary := make(map[string]int)
|
||
|
|
|
||
|
|
var total int
|
||
|
|
err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM tickets WHERE status != 'Patched' AND status != 'Risk Accepted'`).Scan(&total)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
summary["Total_Open"] = total
|
||
|
|
|
||
|
|
sourceRows, err := s.DB.QueryContext(ctx, `SELECT source, COUNT(*) FROM tickets WHERE status != 'Patched' AND status != 'Risk Accepted' GROUP BY source`)
|
||
|
|
if err == nil {
|
||
|
|
defer sourceRows.Close()
|
||
|
|
for sourceRows.Next() {
|
||
|
|
var source string
|
||
|
|
var count int
|
||
|
|
if err := sourceRows.Scan(&source, &count); err == nil {
|
||
|
|
summary["Source_"+source+"_Open"] = count
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sevRows, err := s.DB.QueryContext(ctx, `SELECT severity, COUNT(*) FROM tickets WHERE status != 'Patched' AND status != 'Risk Accepted' GROUP BY severity`)
|
||
|
|
if err == nil {
|
||
|
|
defer sevRows.Close()
|
||
|
|
for sevRows.Next() {
|
||
|
|
var sev string
|
||
|
|
var count int
|
||
|
|
if err := sevRows.Scan(&sev, &count); err == nil {
|
||
|
|
summary["Severity_"+sev+"_Open"] = count
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *SQLiteStore) GetPaginatedActivityFeed(ctx context.Context, filter string, limit, offset int) ([]domain2.FeedItem, int, error) {
|
||
|
|
return []domain2.FeedItem{}, 0, nil
|
||
|
|
}
|