297 lines
6.4 KiB
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")
|
|
}
|