/* ©AngelaMos | 2026 scanner.go Main scan orchestrator that builds, runs, filters, and reports all analyzer results Assembles analyzers from config targets, runs them concurrently with an errgroup worker pool and token bucket rate limiter, merges findings, applies severity and CIS control filters, calls the reporter, and checks the fail-on threshold for CI exit codes. ExitError signals to main.go that findings exceeded the configured threshold. Key exports: Scanner - orchestrator with Run() driving the full pipeline New - creates Scanner with Docker client, slog logger, limiter, and reporter ExitError - typed error carrying an exit code for the fail-on feature Connects to: config/config.go - all scan options and filter logic config/constants.go - MaxWorkers, RateLimitPerSecond, MaxTotalFindings docker/client.go - Docker client passed to runtime analyzers analyzer/*.go - constructs and runs each Analyzer report/reporter.go - NewReporter called with output format and file finding.go - Collection filtered and passed to Reporter */ package scanner import ( "context" "fmt" "log/slog" "os" "runtime" "github.com/CarterPerez-dev/docksec/internal/analyzer" "github.com/CarterPerez-dev/docksec/internal/config" "github.com/CarterPerez-dev/docksec/internal/docker" "github.com/CarterPerez-dev/docksec/internal/finding" "github.com/CarterPerez-dev/docksec/internal/report" "golang.org/x/sync/errgroup" "golang.org/x/time/rate" ) type Scanner struct { cfg *config.Config client *docker.Client logger *slog.Logger limiter *rate.Limiter reporter report.Reporter } func New(cfg *config.Config) (*Scanner, error) { client, err := docker.NewClient() if err != nil { return nil, fmt.Errorf("creating docker client: %w", err) } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: getLogLevel(cfg), })) reporter, err := report.NewReporter(cfg.Output, cfg.OutputFile) if err != nil { return nil, fmt.Errorf("creating reporter: %w", err) } workers := cfg.Workers if workers <= 0 { workers = runtime.NumCPU() * 4 } if workers > config.MaxWorkers { workers = config.MaxWorkers } limiter := rate.NewLimiter( rate.Limit(config.RateLimitPerSecond), config.RateLimitBurst, ) return &Scanner{ cfg: cfg, client: client, logger: logger, limiter: limiter, reporter: reporter, }, nil } func (s *Scanner) Close() error { return s.client.Close() } func (s *Scanner) Run(ctx context.Context) error { if err := s.client.Ping(ctx); err != nil { return fmt.Errorf("docker daemon not accessible: %w", err) } s.logger.Info("starting security scan") analyzers := s.buildAnalyzers() if len(analyzers) == 0 { return fmt.Errorf("no analyzers configured") } findings, err := s.runAnalyzers(ctx, analyzers) if err != nil { return err } findings = s.filterFindings(findings) if err := s.reporter.Report(findings); err != nil { return fmt.Errorf("generating report: %w", err) } return s.checkFailThreshold(findings) } func (s *Scanner) buildAnalyzers() []analyzer.Analyzer { var analyzers []analyzer.Analyzer if s.cfg.ShouldScanContainers() { analyzers = append(analyzers, analyzer.NewContainerAnalyzer(s.client)) } if s.cfg.ShouldScanDaemon() { analyzers = append(analyzers, analyzer.NewDaemonAnalyzer(s.client)) } if s.cfg.ShouldScanImages() { analyzers = append(analyzers, analyzer.NewImageAnalyzer(s.client)) } for _, file := range s.cfg.Files { if isDockerfile(file) { analyzers = append( analyzers, analyzer.NewDockerfileAnalyzer(file), ) } else if isComposeFile(file) { analyzers = append(analyzers, analyzer.NewComposeAnalyzer(file)) } } return analyzers } func (s *Scanner) runAnalyzers( ctx context.Context, analyzers []analyzer.Analyzer, ) (finding.Collection, error) { g, ctx := errgroup.WithContext(ctx) g.SetLimit(s.cfg.Workers) results := make(chan finding.Collection, len(analyzers)) for _, a := range analyzers { a := a g.Go(func() error { if err := s.limiter.Wait(ctx); err != nil { return err } s.logger.Debug("running analyzer", "name", a.Name()) findings, err := a.Analyze(ctx) if err != nil { s.logger.Warn( "analyzer failed", "name", a.Name(), "error", err, ) return nil } results <- findings return nil }) } go func() { _ = g.Wait() // error captured by second Wait() on line 164 close(results) }() var allFindings finding.Collection for findings := range results { allFindings = append(allFindings, findings...) } if err := g.Wait(); err != nil { return nil, err } return allFindings, nil } func (s *Scanner) filterFindings( findings finding.Collection, ) finding.Collection { var filtered finding.Collection for _, f := range findings { if len(s.cfg.Severity) > 0 && !s.cfg.ShouldIncludeSeverity(f.Severity) { continue } if len(s.cfg.CISControls) > 0 && !s.matchesCISControl(f) { continue } filtered = append(filtered, f) if len(filtered) >= config.MaxTotalFindings { s.logger.Warn( "maximum total findings reached", "limit", config.MaxTotalFindings, ) break } } return filtered } func (s *Scanner) matchesCISControl(f *finding.Finding) bool { if f.CISControl == nil { return false } for _, c := range s.cfg.CISControls { if f.CISControl.ID == c { return true } } return false } func (s *Scanner) checkFailThreshold(findings finding.Collection) error { threshold, ok := s.cfg.GetFailOnSeverity() if !ok { return nil } if findings.HasSeverityAtOrAbove(threshold) { return &ExitError{ Code: 1, Message: fmt.Sprintf( "findings at or above %s severity", threshold, ), } } return nil } type ExitError struct { Code int Message string } func (e *ExitError) Error() string { return e.Message } func getLogLevel(cfg *config.Config) slog.Level { if cfg.Quiet { return slog.LevelError } if cfg.Verbose { return slog.LevelDebug } return slog.LevelInfo } func isDockerfile(path string) bool { return path == "Dockerfile" || len(path) > 11 && path[len(path)-11:] == "/Dockerfile" || len(path) > 11 && path[:11] == "Dockerfile." } func isComposeFile(path string) bool { return path == "docker-compose.yml" || path == "docker-compose.yaml" || path == "compose.yml" || path == "compose.yaml" || len(path) > 4 && (path[len(path)-4:] == ".yml" || path[len(path)-5:] == ".yaml") }