Skip to content

Commit 44feed9

Browse files
committed
fix(039): threat-based risk scoring + scanner execution logs
Risk score based on threat level instead of raw CVSS severity. Scanner stdout/stderr/exit_code stored on ScannerJobStatus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 792d234 commit 44feed9

4 files changed

Lines changed: 101 additions & 34 deletions

File tree

internal/security/scanner/engine.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,16 @@ func (e *Engine) executeScan(ctx context.Context, job *ScanJob, scanners []*Scan
196196
callback.OnScannerStarted(job, scanner.ID)
197197
e.updateScannerStatus(job, scanner.ID, ScanJobStatusRunning, time.Now(), time.Time{}, "", 0)
198198

199-
report, err := e.runSingleScanner(ctx, scanner, req)
199+
report, scanLogs, err := e.runSingleScanner(ctx, scanner, req)
200200
if err != nil {
201201
e.logger.Warn("Scanner failed",
202202
zap.String("scanner", scanner.ID),
203203
zap.String("server", req.ServerName),
204204
zap.Error(err),
205205
)
206206
e.updateScannerStatus(job, scanner.ID, ScanJobStatusFailed, time.Time{}, time.Now(), err.Error(), 0)
207+
// Store logs even on failure
208+
e.setScannerLogs(job, scanner.ID, scanLogs)
207209
callback.OnScannerFailed(job, scanner.ID, err)
208210
return
209211
}
@@ -216,6 +218,7 @@ func (e *Engine) executeScan(ctx context.Context, job *ScanJob, scanners []*Scan
216218
mu.Unlock()
217219

218220
e.updateScannerStatus(job, scanner.ID, ScanJobStatusCompleted, time.Time{}, time.Now(), "", len(report.Findings))
221+
e.setScannerLogs(job, scanner.ID, scanLogs)
219222
callback.OnScannerCompleted(job, scanner.ID, report)
220223
}(i, s)
221224
}
@@ -252,8 +255,8 @@ func (e *Engine) executeScan(ctx context.Context, job *ScanJob, scanners []*Scan
252255
callback.OnScanCompleted(job, reports)
253256
}
254257

255-
// runSingleScanner executes one scanner and returns its report
256-
func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req ScanRequest) (*ScanReport, error) {
258+
// runSingleScanner executes one scanner and returns its report plus execution logs
259+
func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req ScanRequest) (*ScanReport, scannerLogs, error) {
257260
// Parse timeout
258261
timeout := 120 * time.Second
259262
if s.Timeout != "" {
@@ -266,7 +269,7 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
266269
jobID := fmt.Sprintf("scan-%s-%d", req.ServerName, time.Now().UnixNano())
267270
reportDir, err := PrepareReportDir(e.dataDir, jobID, s.ID)
268271
if err != nil {
269-
return nil, fmt.Errorf("failed to prepare report directory: %w", err)
272+
return nil, scannerLogs{}, fmt.Errorf("failed to prepare report directory: %w", err)
270273
}
271274

272275
// Build env vars: scanner config + request env
@@ -310,8 +313,9 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
310313
}
311314

312315
stdout, stderr, exitCode, err := e.docker.RunScanner(ctx, cfg)
316+
logs := scannerLogs{Stdout: stdout, Stderr: stderr, ExitCode: exitCode}
313317
if err != nil {
314-
return nil, fmt.Errorf("scanner %s execution failed: %w", s.ID, err)
318+
return nil, logs, fmt.Errorf("scanner %s execution failed: %w", s.ID, err)
315319
}
316320

317321
e.logger.Debug("Scanner finished",
@@ -331,12 +335,13 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
331335
if len(stdout) > 0 {
332336
reportData = []byte(stdout)
333337
} else {
334-
return nil, fmt.Errorf("scanner %s produced no output (exit code: %d, stderr: %s)", s.ID, exitCode, truncate(stderr, 500))
338+
return nil, logs, fmt.Errorf("scanner %s produced no output (exit code: %d, stderr: %s)", s.ID, exitCode, truncate(stderr, 500))
335339
}
336340
}
337341

338342
// Parse results
339-
return e.parseResults(reportData, s.ID)
343+
report, parseErr := e.parseResults(reportData, s.ID)
344+
return report, logs, parseErr
340345
}
341346

342347
// parseResults parses scanner output into a ScanReport
@@ -393,6 +398,27 @@ func (e *Engine) parseResults(data []byte, scannerID string) (*ScanReport, error
393398
return report, nil
394399
}
395400

401+
// scannerLogs captures stdout/stderr from a scanner execution
402+
type scannerLogs struct {
403+
Stdout string
404+
Stderr string
405+
ExitCode int
406+
}
407+
408+
// setScannerLogs stores stdout/stderr on a scanner's job status
409+
func (e *Engine) setScannerLogs(job *ScanJob, scannerID string, logs scannerLogs) {
410+
e.mu.Lock()
411+
defer e.mu.Unlock()
412+
for i := range job.ScannerStatuses {
413+
if job.ScannerStatuses[i].ScannerID == scannerID {
414+
job.ScannerStatuses[i].Stdout = truncate(logs.Stdout, 50000)
415+
job.ScannerStatuses[i].Stderr = truncate(logs.Stderr, 50000)
416+
job.ScannerStatuses[i].ExitCode = logs.ExitCode
417+
return
418+
}
419+
}
420+
}
421+
396422
// updateScannerStatus updates a specific scanner's status within a job
397423
func (e *Engine) updateScannerStatus(job *ScanJob, scannerID, status string, startedAt, completedAt time.Time, errMsg string, findingsCount int) {
398424
e.mu.Lock()

internal/security/scanner/sarif.go

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -247,29 +247,54 @@ func categorizeFromRule(ruleID string, props map[string]any, rules map[string]SA
247247
return "security"
248248
}
249249

250-
// CalculateRiskScore computes a 0-100 risk score from findings
250+
// CalculateRiskScore computes a 0-100 risk score from findings.
251+
// Scoring is based on user-facing threat levels, not raw CVSS:
252+
// - Dangerous (tool poisoning, prompt injection): 30 pts each, max contribution 90
253+
// - Warning (rug pull, high CVEs): 5 pts each, max contribution 40
254+
// - Info (low CVEs): 1 pt each, max contribution 10
255+
//
256+
// This prevents a pile of CVE warnings from scoring as high as actual tool poisoning.
251257
func CalculateRiskScore(findings []ScanFinding) int {
252258
if len(findings) == 0 {
253259
return 0
254260
}
255261

256-
score := 0
262+
var dangerousScore, warningScore, infoScore int
257263
for _, f := range findings {
258-
switch f.Severity {
259-
case SeverityCritical:
260-
score += 25
261-
case SeverityHigh:
262-
score += 15
263-
case SeverityMedium:
264-
score += 5
265-
case SeverityLow:
266-
score += 2
267-
case SeverityInfo:
268-
score += 1
264+
switch f.ThreatLevel {
265+
case ThreatLevelDangerous:
266+
dangerousScore += 30
267+
case ThreatLevelWarning:
268+
warningScore += 5
269+
case ThreatLevelInfo:
270+
infoScore += 1
271+
default:
272+
// Unclassified: use severity as fallback
273+
switch f.Severity {
274+
case SeverityCritical:
275+
dangerousScore += 30
276+
case SeverityHigh:
277+
warningScore += 5
278+
case SeverityMedium:
279+
warningScore += 3
280+
case SeverityLow:
281+
infoScore += 1
282+
}
269283
}
270284
}
271285

272-
// Cap at 100
286+
// Cap each category
287+
if dangerousScore > 90 {
288+
dangerousScore = 90
289+
}
290+
if warningScore > 40 {
291+
warningScore = 40
292+
}
293+
if infoScore > 10 {
294+
infoScore = 10
295+
}
296+
297+
score := dangerousScore + warningScore + infoScore
273298
if score > 100 {
274299
score = 100
275300
}

internal/security/scanner/sarif_test.go

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -233,22 +233,35 @@ func TestCalculateRiskScore(t *testing.T) {
233233
want int
234234
}{
235235
{"no findings", nil, 0},
236-
{"one low", []ScanFinding{{Severity: SeverityLow}}, 2},
237-
{"one high", []ScanFinding{{Severity: SeverityHigh}}, 15},
238-
{"one critical", []ScanFinding{{Severity: SeverityCritical}}, 25},
239-
{"mixed", []ScanFinding{
236+
{"one info", []ScanFinding{{ThreatLevel: ThreatLevelInfo}}, 1},
237+
{"one warning", []ScanFinding{{ThreatLevel: ThreatLevelWarning}}, 5},
238+
{"one dangerous", []ScanFinding{{ThreatLevel: ThreatLevelDangerous}}, 30},
239+
{"6 warnings + 1 info", []ScanFinding{
240+
{ThreatLevel: ThreatLevelWarning},
241+
{ThreatLevel: ThreatLevelWarning},
242+
{ThreatLevel: ThreatLevelWarning},
243+
{ThreatLevel: ThreatLevelWarning},
244+
{ThreatLevel: ThreatLevelWarning},
245+
{ThreatLevel: ThreatLevelWarning},
246+
{ThreatLevel: ThreatLevelInfo},
247+
}, 31}, // 6*5=30 + 1 = 31
248+
{"dangerous + warnings", []ScanFinding{
249+
{ThreatLevel: ThreatLevelDangerous},
250+
{ThreatLevel: ThreatLevelWarning},
251+
{ThreatLevel: ThreatLevelWarning},
252+
{ThreatLevel: ThreatLevelInfo},
253+
}, 41}, // 30 + 10 + 1 = 41
254+
{"capped dangerous", []ScanFinding{
255+
{ThreatLevel: ThreatLevelDangerous},
256+
{ThreatLevel: ThreatLevelDangerous},
257+
{ThreatLevel: ThreatLevelDangerous},
258+
{ThreatLevel: ThreatLevelDangerous},
259+
}, 90}, // 4*30=120 capped to 90 (dangerous max)
260+
{"unclassified fallback", []ScanFinding{
240261
{Severity: SeverityCritical},
241262
{Severity: SeverityHigh},
242-
{Severity: SeverityMedium},
243263
{Severity: SeverityLow},
244-
}, 47},
245-
{"capped at 100", []ScanFinding{
246-
{Severity: SeverityCritical},
247-
{Severity: SeverityCritical},
248-
{Severity: SeverityCritical},
249-
{Severity: SeverityCritical},
250-
{Severity: SeverityCritical},
251-
}, 100},
264+
}, 36}, // 30 + 5 + 1 = 36
252265
}
253266

254267
for _, tt := range tests {

internal/security/scanner/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ type ScannerJobStatus struct {
8585
CompletedAt time.Time `json:"completed_at,omitempty"`
8686
Error string `json:"error,omitempty"`
8787
FindingsCount int `json:"findings_count"`
88+
Stdout string `json:"stdout,omitempty"` // Scanner stdout (for log viewing)
89+
Stderr string `json:"stderr,omitempty"` // Scanner stderr (for log viewing)
90+
ExitCode int `json:"exit_code"`
8891
}
8992

9093
// User-facing threat category constants

0 commit comments

Comments
 (0)