Surface syft stream errors; classify scan failures

Improve SBOM generation and scan error handling:

- Add syftStreamResult type and syftStreamFailureCause helper to detect stream read errors or non-empty stderr.
- Update runSBOMGeneration to concurrently wait for the syft log stream and container exit, surface stream failures as primary cause, remove partial files on failure, and mark jobs accordingly.
- Add unit tests for syftStreamFailureCause (home/internal/scanner/sbom_test.go).
- Introduce classifyScanFailure to centralize mapping of context/scan errors to job status/messages and use it in runScan; add tests for its behavior.
- Minor import and formatting tweaks.

These changes ensure streaming errors from syft are reported reliably and scan failures are classified consistently.
This commit is contained in:
hhftechnologies 2026-04-08 14:21:50 +05:30
parent 4c21ff4b95
commit 9903934ca1
4 changed files with 144 additions and 37 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/docker/docker/api/types/container"
@ -15,6 +16,11 @@ import (
const sbomDir = "/data/sbom"
type syftStreamResult struct {
stderr string
err error
}
// StartSBOMGeneration starts SBOM generation for an image.
func (s *ScannerService) StartSBOMGeneration(imageRef, host string, format models.SBOMFormat) (*models.SBOMJob, error) {
job := &models.SBOMJob{
@ -93,44 +99,45 @@ func (s *ScannerService) runSBOMGeneration(job *models.SBOMJob) {
}
filePath := filepath.Join(sbomDir, job.ID+".json")
streamDone := make(chan struct {
stderr string
err error
}, 1)
streamDone := make(chan syftStreamResult, 1)
go func() {
stderr, err := StreamContainerStdoutToFile(ctx, apiClient, containerID, filePath, nil)
streamDone <- struct {
stderr string
err error
}{stderr, err}
streamDone <- syftStreamResult{stderr: stderr, err: err}
}()
statusCh, errCh := apiClient.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
var exitCode int64
select {
case err := <-errCh:
if err != nil {
var streamResult syftStreamResult
waitStatusCh := statusCh
waitErrCh := errCh
waitStreamCh := streamDone
for waitStatusCh != nil || waitErrCh != nil || waitStreamCh != nil {
select {
case err := <-waitErrCh:
waitErrCh = nil
waitStatusCh = nil
if err != nil {
os.Remove(filePath)
s.updateSBOMStatus(job, models.ScanJobFailed, fmt.Sprintf("error waiting for syft: %v", err))
return
}
case status := <-waitStatusCh:
waitStatusCh = nil
waitErrCh = nil
exitCode = status.StatusCode
case streamResult = <-waitStreamCh:
waitStreamCh = nil
if streamCause := syftStreamFailureCause(streamResult); streamCause != nil {
os.Remove(filePath)
s.updateSBOMStatus(job, models.ScanJobFailed, fmt.Sprintf("error streaming syft output: %v", streamCause))
return
}
case <-ctx.Done():
os.Remove(filePath)
s.updateSBOMStatus(job, models.ScanJobFailed, fmt.Sprintf("error waiting for syft: %v", err))
s.updateSBOMStatus(job, models.ScanJobCancelled, "cancelled")
return
}
case status := <-statusCh:
exitCode = status.StatusCode
case <-ctx.Done():
os.Remove(filePath)
s.updateSBOMStatus(job, models.ScanJobCancelled, "cancelled")
return
}
streamResult := <-streamDone
// Surface stream errors as the primary cause before falling through to the
// exit-code branch — otherwise a broken Docker log stream gets reported
// only as "syft exited with code N" and the real failure is lost.
if streamResult.err != nil {
os.Remove(filePath)
s.updateSBOMStatus(job, models.ScanJobFailed, fmt.Sprintf("failed to read syft output: %v", streamResult.err))
return
}
if exitCode != 0 {
@ -172,3 +179,13 @@ func buildSBOMCmd(imageRef string, format models.SBOMFormat) []string {
}
return []string{imageRef, "-o", outputFormat}
}
func syftStreamFailureCause(result syftStreamResult) any {
if result.err != nil {
return result.err
}
if stderr := strings.TrimSpace(result.stderr); stderr != "" {
return stderr
}
return nil
}

View file

@ -0,0 +1,46 @@
package scanner
import (
"errors"
"testing"
)
func TestSyftStreamFailureCauseReturnsError(t *testing.T) {
cause := syftStreamFailureCause(syftStreamResult{
stderr: "warning on stderr",
err: errors.New("read failure"),
})
if cause == nil {
t.Fatal("expected non-nil stream failure cause")
}
if got := cause.(error).Error(); got != "read failure" {
t.Fatalf("expected error cause, got %q", got)
}
}
func TestSyftStreamFailureCauseReturnsTrimmedStderr(t *testing.T) {
cause := syftStreamFailureCause(syftStreamResult{
stderr: " syft reported an error \n",
err: nil,
})
if cause == nil {
t.Fatal("expected non-nil stream failure cause")
}
got, ok := cause.(string)
if !ok {
t.Fatalf("expected string cause from stderr, got %T", cause)
}
if got != "syft reported an error" {
t.Fatalf("expected trimmed stderr cause, got %q", got)
}
}
func TestSyftStreamFailureCauseReturnsNilOnCleanStream(t *testing.T) {
cause := syftStreamFailureCause(syftStreamResult{
stderr: " \n\t ",
err: nil,
})
if cause != nil {
t.Fatalf("expected nil cause for clean stream, got %v", cause)
}
}

View file

@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"fmt"
"log"
"sync"
@ -46,9 +47,9 @@ func NewScannerService(registry *services.Registry, cfg *models.ScannerConfig, d
cancels: make(map[string]context.CancelFunc),
}
s.config.Store(cfg)
go s.gcWorker()
return s
}
@ -391,11 +392,8 @@ func (s *ScannerService) runScan(ctx context.Context, job *models.ScanJob, cance
completedAt := time.Now()
if err != nil {
if ctx.Err() != nil {
s.updateJobStatus(job, models.ScanJobCancelled, "scan cancelled")
} else {
s.updateJobStatus(job, models.ScanJobFailed, err.Error())
}
status, message := classifyScanFailure(ctx.Err(), err)
s.updateJobStatus(job, status, message)
return
}
@ -648,6 +646,19 @@ func (s *ScannerService) sendBulkNotification(bulkJob *models.BulkScanJob) {
}
}
func classifyScanFailure(ctxErr, scanErr error) (models.ScanJobStatus, string) {
if ctxErr != nil {
if errors.Is(ctxErr, context.DeadlineExceeded) {
return models.ScanJobFailed, "scan timed out"
}
return models.ScanJobCancelled, "scan cancelled"
}
if scanErr == nil {
return models.ScanJobFailed, "scan failed"
}
return models.ScanJobFailed, scanErr.Error()
}
func computeSummary(vulns []models.Vulnerability) models.SeveritySummary {
summary := models.SeveritySummary{Total: len(vulns)}
for _, v := range vulns {

View file

@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
@ -425,4 +426,36 @@ func TestHeartbeatDoesNotOverwriteNonScanningStatus(t *testing.T) {
if progress != "original" {
t.Fatalf("expected progress to remain 'original', got %q", progress)
}
}
}
// ─── classifyScanFailure ──────────────────────────────────────────────────────
func TestClassifyScanFailureDeadlineExceededIsFailed(t *testing.T) {
status, msg := classifyScanFailure(context.DeadlineExceeded, errors.New("scan runner error"))
if status != models.ScanJobFailed {
t.Fatalf("expected status %q, got %q", models.ScanJobFailed, status)
}
if msg != "scan timed out" {
t.Fatalf("expected timeout message, got %q", msg)
}
}
func TestClassifyScanFailureCanceledIsCancelled(t *testing.T) {
status, msg := classifyScanFailure(context.Canceled, errors.New("scan runner error"))
if status != models.ScanJobCancelled {
t.Fatalf("expected status %q, got %q", models.ScanJobCancelled, status)
}
if msg != "scan cancelled" {
t.Fatalf("expected cancellation message, got %q", msg)
}
}
func TestClassifyScanFailureNoContextUsesScanError(t *testing.T) {
status, msg := classifyScanFailure(nil, errors.New("scanner exploded"))
if status != models.ScanJobFailed {
t.Fatalf("expected status %q, got %q", models.ScanJobFailed, status)
}
if msg != "scanner exploded" {
t.Fatalf("expected scanner error message, got %q", msg)
}
}