Cybersecurity-Projects/PROJECTS/intermediate/docker-security-audit/internal/scanner/scanner.go

297 lines
6.4 KiB
Go

/*
©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")
}