diff --git a/deploy/upcloud/cloudinit.go b/deploy/upcloud/cloudinit.go index 649c316..cfef47f 100644 --- a/deploy/upcloud/cloudinit.go +++ b/deploy/upcloud/cloudinit.go @@ -16,12 +16,18 @@ var appviewServiceTmpl string //go:embed systemd/hold.service.tmpl var holdServiceTmpl string +//go:embed systemd/scanner.service.tmpl +var scannerServiceTmpl string + //go:embed configs/appview.yaml.tmpl var appviewConfigTmpl string //go:embed configs/hold.yaml.tmpl var holdConfigTmpl string +//go:embed configs/scanner.yaml.tmpl +var scannerConfigTmpl string + //go:embed configs/cloudinit.sh.tmpl var cloudInitTmpl string @@ -41,6 +47,9 @@ type ConfigValues struct { HoldDomain string // e.g. "us-chi1.cove.seamark.dev" HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" BasePath string // e.g. "/var/lib/seamark" + + // Scanner (auto-generated shared secret) + ScannerSecret string // hex-encoded 32-byte secret; empty disables scanning } // renderConfig executes a Go template with the given values. @@ -78,6 +87,30 @@ func renderServiceUnit(tmplStr string, p serviceUnitParams) (string, error) { return buf.String(), nil } +// scannerServiceUnitParams holds values for rendering the scanner systemd unit. +// Extends the standard fields with HoldServiceName for the After= dependency. +type scannerServiceUnitParams struct { + DisplayName string // e.g. "Seamark" + User string // e.g. "seamark" + BinaryPath string // e.g. "/opt/seamark/bin/seamark-scanner" + ConfigPath string // e.g. "/etc/seamark/scanner.yaml" + DataDir string // e.g. "/var/lib/seamark" + ServiceName string // e.g. "seamark-scanner" + HoldServiceName string // e.g. "seamark-hold" (After= dependency) +} + +func renderScannerServiceUnit(p scannerServiceUnitParams) (string, error) { + t, err := template.New("scanner-service").Parse(scannerServiceTmpl) + if err != nil { + return "", fmt.Errorf("parse scanner service template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, p); err != nil { + return "", fmt.Errorf("render scanner service template: %w", err) + } + return buf.String(), nil +} + // generateAppviewCloudInit generates the cloud-init user-data script for the appview server. func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { naming := cfg.Naming() @@ -119,7 +152,9 @@ func generateAppviewCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion st } // generateHoldCloudInit generates the cloud-init user-data script for the hold server. -func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string) (string, error) { +// When withScanner is true, a second phase is appended that builds the scanner binary, +// creates scanner data directories, and installs a scanner systemd service. +func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion string, withScanner bool) (string, error) { naming := cfg.Naming() configYAML, err := renderConfig(holdConfigTmpl, vals) @@ -139,7 +174,7 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin return "", fmt.Errorf("hold service unit: %w", err) } - return generateCloudInit(cloudInitParams{ + script, err := generateCloudInit(cloudInitParams{ GoVersion: goVersion, BinaryName: naming.Hold(), BuildCmd: "hold", @@ -156,6 +191,79 @@ func generateHoldCloudInit(cfg *InfraConfig, vals *ConfigValues, goVersion strin LogFile: naming.LogFile(), DisplayName: naming.DisplayName(), }) + if err != nil { + return "", err + } + + if !withScanner { + return script, nil + } + + // Render scanner config YAML + scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals) + if err != nil { + return "", fmt.Errorf("scanner config: %w", err) + } + + // Append scanner build and setup phase + scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{ + DisplayName: naming.DisplayName(), + User: naming.SystemUser(), + BinaryPath: naming.InstallDir() + "/bin/" + naming.Scanner(), + ConfigPath: naming.ScannerConfigPath(), + DataDir: naming.BasePath(), + ServiceName: naming.Scanner(), + HoldServiceName: naming.Hold(), + }) + if err != nil { + return "", fmt.Errorf("scanner service unit: %w", err) + } + + // Escape single quotes for heredoc embedding + scannerUnit = strings.ReplaceAll(scannerUnit, "'", "'\\''") + scannerConfigYAML = strings.ReplaceAll(scannerConfigYAML, "'", "'\\''") + + scannerPhase := fmt.Sprintf(` +# === Scanner Setup === +echo "Building scanner..." +cd %s/scanner +CGO_ENABLED=1 go build \ + -ldflags="-s -w" \ + -trimpath \ + -o ../bin/%s ./cmd/scanner +cd %s + +# Scanner data dirs +mkdir -p %s/vulndb %s/tmp +chown -R %s:%s %s + +# Scanner config +cat > %s << 'CFGEOF' +%s +CFGEOF + +# Scanner systemd service +cat > /etc/systemd/system/%s.service << 'SVCEOF' +%s +SVCEOF +systemctl daemon-reload +systemctl enable %s + +echo "=== Scanner setup complete ===" +`, + naming.InstallDir(), + naming.Scanner(), + naming.InstallDir(), + naming.ScannerDataDir(), naming.ScannerDataDir(), + naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir(), + naming.ScannerConfigPath(), + scannerConfigYAML, + naming.Scanner(), + scannerUnit, + naming.Scanner(), + ) + + return script + scannerPhase, nil } type cloudInitParams struct { diff --git a/deploy/upcloud/configs/hold.yaml.tmpl b/deploy/upcloud/configs/hold.yaml.tmpl index 53f9169..c7c4440 100644 --- a/deploy/upcloud/configs/hold.yaml.tmpl +++ b/deploy/upcloud/configs/hold.yaml.tmpl @@ -50,5 +50,5 @@ quota: defaults: new_crew_tier: deckhand scanner: - secret: "" + secret: "{{.ScannerSecret}}" diff --git a/deploy/upcloud/configs/scanner.yaml.tmpl b/deploy/upcloud/configs/scanner.yaml.tmpl new file mode 100644 index 0000000..3c2b2f1 --- /dev/null +++ b/deploy/upcloud/configs/scanner.yaml.tmpl @@ -0,0 +1,21 @@ +version: "0.1" +log_level: info +log_shipper: + backend: "" + url: "" + batch_size: 100 + flush_interval: 5s + username: "" + password: "" +server: + addr: :9090 +hold: + url: "ws://localhost:8080" + secret: "{{.ScannerSecret}}" +scanner: + workers: 2 + queue_size: 100 +vuln: + enabled: true + db_path: "{{.BasePath}}/scanner/vulndb" + tmp_dir: "{{.BasePath}}/scanner/tmp" diff --git a/deploy/upcloud/deploy b/deploy/upcloud/deploy new file mode 100755 index 0000000..591c929 Binary files /dev/null and b/deploy/upcloud/deploy differ diff --git a/deploy/upcloud/naming.go b/deploy/upcloud/naming.go index 4246803..4502e4c 100644 --- a/deploy/upcloud/naming.go +++ b/deploy/upcloud/naming.go @@ -48,5 +48,14 @@ func (n Naming) NetworkName() string { return n.ClientName + "-private" } // LBName returns the load balancer name (e.g. "seamark-lb"). func (n Naming) LBName() string { return n.ClientName + "-lb" } +// Scanner returns the scanner binary/service name (e.g. "seamark-scanner"). +func (n Naming) Scanner() string { return n.ClientName + "-scanner" } + +// ScannerConfigPath returns the scanner config file path. +func (n Naming) ScannerConfigPath() string { return n.ConfigDir() + "/scanner.yaml" } + +// ScannerDataDir returns the scanner data directory (e.g. "/var/lib/seamark/scanner"). +func (n Naming) ScannerDataDir() string { return n.BasePath() + "/scanner" } + // S3Name returns the name used for S3 storage, user, and bucket. func (n Naming) S3Name() string { return n.ClientName } diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 4e1bb12..716c861 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -3,8 +3,10 @@ package main import ( "bufio" "context" + crypto_rand "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "os" "strings" @@ -25,7 +27,8 @@ var provisionCmd = &cobra.Command{ plan, _ := cmd.Flags().GetString("plan") sshKey, _ := cmd.Flags().GetString("ssh-key") s3Secret, _ := cmd.Flags().GetString("s3-secret") - return cmdProvision(token, zone, plan, sshKey, s3Secret) + withScanner, _ := cmd.Flags().GetBool("with-scanner") + return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner) }, } @@ -34,11 +37,12 @@ func init() { provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") + provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold") provisionCmd.MarkFlagRequired("ssh-key") rootCmd.AddCommand(provisionCmd) } -func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { +func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bool) error { cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret) if err != nil { return err @@ -79,6 +83,20 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { state.ClientName = cfg.ClientName state.RepoBranch = cfg.RepoBranch + // Scanner setup + if withScanner { + state.ScannerEnabled = true + if state.ScannerSecret == "" { + secret, err := generateScannerSecret() + if err != nil { + return fmt.Errorf("generate scanner secret: %w", err) + } + state.ScannerSecret = secret + fmt.Printf("Generated scanner shared secret\n") + } + saveState(state) + } + goVersion, err := requiredGoVersion() if err != nil { return err @@ -136,15 +154,16 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { // Build config template values vals := &ConfigValues{ - S3Endpoint: state.ObjectStorage.Endpoint, - S3Region: state.ObjectStorage.Region, - S3Bucket: state.ObjectStorage.Bucket, - S3AccessKey: state.ObjectStorage.AccessKeyID, - S3SecretKey: s3SecretKey, - Zone: cfg.Zone, - HoldDomain: holdDomain, - HoldDid: "did:web:" + holdDomain, - BasePath: naming.BasePath(), + S3Endpoint: state.ObjectStorage.Endpoint, + S3Region: state.ObjectStorage.Region, + S3Bucket: state.ObjectStorage.Bucket, + S3AccessKey: state.ObjectStorage.AccessKeyID, + S3SecretKey: s3SecretKey, + Zone: cfg.Zone, + HoldDomain: holdDomain, + HoldDid: "did:web:" + holdDomain, + BasePath: naming.BasePath(), + ScannerSecret: state.ScannerSecret, } // 2. Private network @@ -215,7 +234,7 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { // 4. Hold server if state.Hold.UUID != "" { fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID) - holdScript, err := generateHoldCloudInit(cfg, vals, goVersion) + holdScript, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) if err != nil { return err } @@ -229,9 +248,18 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil { return fmt.Errorf("hold config sync: %w", err) } + if state.ScannerEnabled { + scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals) + if err != nil { + return fmt.Errorf("render scanner config: %w", err) + } + if err := syncConfigKeys("scanner", state.Hold.PublicIP, naming.ScannerConfigPath(), scannerConfigYAML); err != nil { + return fmt.Errorf("scanner config sync: %w", err) + } + } } else { fmt.Println("Creating hold server...") - holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion) + holdUserData, err := generateHoldCloudInit(cfg, vals, goVersion, state.ScannerEnabled) if err != nil { return err } @@ -316,7 +344,11 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string) error { fmt.Println() fmt.Println("Next steps:") fmt.Println(" 1. Wait ~5 min for cloud-init to complete") - fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) + if state.ScannerEnabled { + fmt.Printf(" 2. systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner()) + } else { + fmt.Printf(" 2. systemctl start %s / %s\n", naming.Appview(), naming.Hold()) + } fmt.Println(" 3. Configure DNS records above") return nil @@ -932,6 +964,16 @@ func syncCloudInit(name, ip, localScript string) error { return nil } +// generateScannerSecret generates a random 32-byte hex-encoded shared secret +// for authenticating scanner-to-hold WebSocket connections. +func generateScannerSecret() (string, error) { + b := make([]byte, 32) + if _, err := crypto_rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + // writeRemoteCloudInit writes the local cloud-init script to the remote server // so that subsequent provision runs can accurately detect real changes. // Uses base64 encoding to avoid heredoc nesting issues (the cloud-init script diff --git a/deploy/upcloud/state.go b/deploy/upcloud/state.go index 30195da..3b39a27 100644 --- a/deploy/upcloud/state.go +++ b/deploy/upcloud/state.go @@ -16,8 +16,10 @@ type InfraState struct { Network StateRef `json:"network"` Appview ServerState `json:"appview"` Hold ServerState `json:"hold"` - LB StateRef `json:"loadbalancer"` - ObjectStorage ObjectStorageState `json:"object_storage"` + LB StateRef `json:"loadbalancer"` + ObjectStorage ObjectStorageState `json:"object_storage"` + ScannerEnabled bool `json:"scanner_enabled,omitempty"` + ScannerSecret string `json:"scanner_secret,omitempty"` } // Naming returns a Naming helper, defaulting to "seamark" if ClientName is empty. diff --git a/deploy/upcloud/status.go b/deploy/upcloud/status.go index 9537d7b..84a51f1 100644 --- a/deploy/upcloud/status.go +++ b/deploy/upcloud/status.go @@ -90,6 +90,31 @@ func cmdStatus(token string) error { fmt.Println() } + // Scanner status (runs on hold server) + if state.ScannerEnabled { + fmt.Printf("Scanner (on hold server)\n") + if state.Hold.PublicIP != "" { + output, err := runSSH(state.Hold.PublicIP, fmt.Sprintf( + "systemctl is-active %s 2>/dev/null || echo 'inactive'; curl -sf http://localhost:9090/healthz > /dev/null 2>&1 && echo 'health:ok' || echo 'health:fail'", + naming.Scanner(), + ), false) + if err != nil { + fmt.Printf(" Service: unreachable\n") + } else { + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "active" || line == "inactive" { + fmt.Printf(" Service: %s\n", line) + } else if strings.HasPrefix(line, "health:") { + fmt.Printf(" Health: %s\n", strings.TrimPrefix(line, "health:")) + } + } + } + } + fmt.Println() + } + // LB status if state.LB.UUID != "" { fmt.Printf("Load Balancer: %s\n", state.LB.UUID) diff --git a/deploy/upcloud/systemd/scanner.service.tmpl b/deploy/upcloud/systemd/scanner.service.tmpl new file mode 100644 index 0000000..cd8de5d --- /dev/null +++ b/deploy/upcloud/systemd/scanner.service.tmpl @@ -0,0 +1,25 @@ +[Unit] +Description={{.DisplayName}} Scanner (Vulnerability Scanning) +After=network-online.target {{.HoldServiceName}}.service +Wants=network-online.target + +[Service] +Type=simple +User={{.User}} +Group={{.User}} +ExecStart={{.BinaryPath}} serve --config {{.ConfigPath}} +Restart=on-failure +RestartSec=10 + +ReadWritePaths={{.DataDir}} +ProtectSystem=strict +ProtectHome=yes +NoNewPrivileges=yes +PrivateTmp=yes + +StandardOutput=journal +StandardError=journal +SyslogIdentifier={{.ServiceName}} + +[Install] +WantedBy=multi-user.target diff --git a/deploy/upcloud/update.go b/deploy/upcloud/update.go index fd226fb..14d6c9c 100644 --- a/deploy/upcloud/update.go +++ b/deploy/upcloud/update.go @@ -22,7 +22,8 @@ var updateCmd = &cobra.Command{ if len(args) > 0 { target = args[0] } - return cmdUpdate(target) + withScanner, _ := cmd.Flags().GetBool("with-scanner") + return cmdUpdate(target, withScanner) }, } @@ -37,11 +38,12 @@ var sshCmd = &cobra.Command{ } func init() { + updateCmd.Flags().Bool("with-scanner", false, "Enable and deploy vulnerability scanner alongside hold") rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(sshCmd) } -func cmdUpdate(target string) error { +func cmdUpdate(target string, withScanner bool) error { state, err := loadState() if err != nil { return err @@ -55,6 +57,20 @@ func cmdUpdate(target string) error { return err } + // Enable scanner retroactively via --with-scanner on update + if withScanner && !state.ScannerEnabled { + state.ScannerEnabled = true + if state.ScannerSecret == "" { + secret, err := generateScannerSecret() + if err != nil { + return fmt.Errorf("generate scanner secret: %w", err) + } + state.ScannerSecret = secret + fmt.Printf("Generated scanner shared secret\n") + } + saveState(state) + } + vals := configValsFromState(state) targets := map[string]struct { @@ -134,6 +150,64 @@ func cmdUpdate(target string) error { daemonReload = "systemctl daemon-reload" } + // Scanner additions for hold server + scannerBuild := "" + scannerRestart := "" + scannerHealthCheck := "" + if name == "hold" && state.ScannerEnabled { + // Sync scanner config keys + scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals) + if err != nil { + return fmt.Errorf("render scanner config: %w", err) + } + if err := syncConfigKeys("scanner", t.ip, naming.ScannerConfigPath(), scannerConfigYAML); err != nil { + return fmt.Errorf("scanner config sync: %w", err) + } + + // Sync scanner service unit + scannerUnit, err := renderScannerServiceUnit(scannerServiceUnitParams{ + DisplayName: naming.DisplayName(), + User: naming.SystemUser(), + BinaryPath: naming.InstallDir() + "/bin/" + naming.Scanner(), + ConfigPath: naming.ScannerConfigPath(), + DataDir: naming.BasePath(), + ServiceName: naming.Scanner(), + HoldServiceName: naming.Hold(), + }) + if err != nil { + return fmt.Errorf("render scanner service unit: %w", err) + } + scannerUnitChanged, err := syncServiceUnit("scanner", t.ip, naming.Scanner(), scannerUnit) + if err != nil { + return fmt.Errorf("scanner service unit sync: %w", err) + } + if scannerUnitChanged { + daemonReload = "systemctl daemon-reload" + } + + scannerBuild = fmt.Sprintf(` +# Build scanner +cd %s/scanner +CGO_ENABLED=1 go build \ + -ldflags="-s -w" \ + -trimpath \ + -o ../bin/%s ./cmd/scanner +cd %s + +# Ensure scanner data dirs exist +mkdir -p %s/vulndb %s/tmp +chown -R %s:%s %s +`, naming.InstallDir(), naming.Scanner(), naming.InstallDir(), + naming.ScannerDataDir(), naming.ScannerDataDir(), + naming.SystemUser(), naming.SystemUser(), naming.ScannerDataDir()) + + scannerRestart = fmt.Sprintf("\nsystemctl restart %s", naming.Scanner()) + scannerHealthCheck = fmt.Sprintf(` +sleep 2 +curl -sf http://localhost:9090/healthz > /dev/null && echo "SCANNER_HEALTH_OK" || echo "SCANNER_HEALTH_FAIL" +`) + } + updateScript := fmt.Sprintf(`set -euo pipefail export PATH=$PATH:/usr/local/go/bin export GOTMPDIR=/var/tmp @@ -156,11 +230,15 @@ CGO_ENABLED=1 go build \ -tags sqlite_omit_load_extension -trimpath \ -o bin/%s ./cmd/%s %s +%s systemctl restart %s - +%s sleep 2 curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" -`, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, daemonReload, t.serviceName, t.healthURL) +%s +`, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, + scannerBuild, daemonReload, t.serviceName, scannerRestart, + t.healthURL, scannerHealthCheck) output, err := runSSH(t.ip, updateScript, true) if err != nil { @@ -177,6 +255,16 @@ curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" } else { fmt.Printf(" %s: updated (health check inconclusive)\n", name) } + + // Scanner health reporting + if name == "hold" && state.ScannerEnabled { + if strings.Contains(output, "SCANNER_HEALTH_OK") { + fmt.Printf(" scanner: updated and healthy\n") + } else if strings.Contains(output, "SCANNER_HEALTH_FAIL") { + fmt.Printf(" scanner: updated but health check failed!\n") + fmt.Printf(" Check: ssh root@%s journalctl -u %s -n 50\n", t.ip, naming.Scanner()) + } + } } return nil @@ -191,15 +279,16 @@ func configValsFromState(state *InfraState) *ConfigValues { holdDomain := state.Zone + ".cove." + baseDomain return &ConfigValues{ - S3Endpoint: state.ObjectStorage.Endpoint, - S3Region: state.ObjectStorage.Region, - S3Bucket: state.ObjectStorage.Bucket, - S3AccessKey: state.ObjectStorage.AccessKeyID, - S3SecretKey: "", // not persisted in state; existing value on server is preserved - Zone: state.Zone, - HoldDomain: holdDomain, - HoldDid: "did:web:" + holdDomain, - BasePath: naming.BasePath(), + S3Endpoint: state.ObjectStorage.Endpoint, + S3Region: state.ObjectStorage.Region, + S3Bucket: state.ObjectStorage.Bucket, + S3AccessKey: state.ObjectStorage.AccessKeyID, + S3SecretKey: "", // not persisted in state; existing value on server is preserved + Zone: state.Zone, + HoldDomain: holdDomain, + HoldDid: "did:web:" + holdDomain, + BasePath: naming.BasePath(), + ScannerSecret: state.ScannerSecret, } } diff --git a/go.work.sum b/go.work.sum index ba65c92..c751175 100644 --- a/go.work.sum +++ b/go.work.sum @@ -246,6 +246,7 @@ github.com/brianvoe/gofakeit/v6 v6.25.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7 github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index d006f2b..c0cee52 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -5,6 +5,7 @@ import ( "html/template" "log/slog" "net/http" + "net/url" "strings" "sync" "time" @@ -229,6 +230,24 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request artifactType = manifests[0].ArtifactType } + // Collect digests for batch scan-result request + var scanDigests []string + var scanHoldEndpoint string + for _, m := range manifests { + if !m.IsManifestList && m.Manifest.HoldEndpoint != "" { + if scanHoldEndpoint == "" { + scanHoldEndpoint = m.Manifest.HoldEndpoint + } + if m.Manifest.HoldEndpoint == scanHoldEndpoint { + scanDigests = append(scanDigests, strings.TrimPrefix(m.Manifest.Digest, "sha256:")) + } + } + } + var scanBatchParams string + if len(scanDigests) > 0 { + scanBatchParams = "holdEndpoint=" + url.QueryEscape(scanHoldEndpoint) + "&digests=" + strings.Join(scanDigests, ",") + } + // Build page meta title := owner.Handle + "/" + repository + " - " + h.ClientShortName if repo.Title != "" { @@ -264,21 +283,23 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request PullCount int IsStarred bool IsOwner bool // Whether current user owns this repository - ReadmeHTML template.HTML - ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown + ReadmeHTML template.HTML + ArtifactType string // Dominant artifact type: container-image, helm-chart, unknown + ScanBatchParams template.HTML // Pre-encoded query string for batch scan-result endpoint }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - Owner: owner, - Repository: repo, - Tags: tagsWithPlatforms, - Manifests: manifests, - StarCount: stats.StarCount, - PullCount: stats.PullCount, - IsStarred: isStarred, - IsOwner: isOwner, - ReadmeHTML: readmeHTML, - ArtifactType: artifactType, + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Owner: owner, + Repository: repo, + Tags: tagsWithPlatforms, + Manifests: manifests, + StarCount: stats.StarCount, + PullCount: stats.PullCount, + IsStarred: isStarred, + IsOwner: isOwner, + ReadmeHTML: readmeHTML, + ArtifactType: artifactType, + ScanBatchParams: template.HTML(scanBatchParams), } if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { diff --git a/pkg/appview/handlers/scan_result.go b/pkg/appview/handlers/scan_result.go index 61bf28a..aa48d28 100644 --- a/pkg/appview/handlers/scan_result.go +++ b/pkg/appview/handlers/scan_result.go @@ -1,13 +1,16 @@ package handlers import ( + "bytes" "context" "encoding/json" "fmt" + "html/template" "log/slog" "net/http" "net/url" "strings" + "sync" "time" "atcr.io/pkg/atproto" @@ -123,3 +126,122 @@ func (h *ScanResultHandler) renderBadge(w http.ResponseWriter, data vulnBadgeDat slog.Warn("Failed to render vuln badge", "error", err) } } + +// fetchScanRecord fetches a scan record from a hold's PDS and returns badge data. +func fetchScanRecord(ctx context.Context, holdEndpoint, holdDID, hexDigest string) vulnBadgeData { + rkey := hexDigest + fullDigest := "sha256:" + hexDigest + + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + holdEndpoint, + url.QueryEscape(holdDID), + url.QueryEscape(atproto.ScanCollection), + url.QueryEscape(rkey), + ) + + req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil) + if err != nil { + return vulnBadgeData{Error: true} + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return vulnBadgeData{Error: true} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return vulnBadgeData{Error: true} + } + + var envelope struct { + Value json.RawMessage `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return vulnBadgeData{Error: true} + } + + var scanRecord atproto.ScanRecord + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { + return vulnBadgeData{Error: true} + } + + return vulnBadgeData{ + Critical: scanRecord.Critical, + High: scanRecord.High, + Medium: scanRecord.Medium, + Low: scanRecord.Low, + Total: scanRecord.Total, + ScannedAt: scanRecord.ScannedAt, + Found: true, + Digest: fullDigest, + HoldEndpoint: holdEndpoint, + } +} + +// BatchScanResultHandler handles a single HTMX request that fetches scan results +// for multiple manifests concurrently and returns OOB swap fragments. +type BatchScanResultHandler struct { + BaseUIHandler +} + +func (h *BatchScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + holdEndpoint := r.URL.Query().Get("holdEndpoint") + digestsParam := r.URL.Query().Get("digests") + + if holdEndpoint == "" || digestsParam == "" { + w.Header().Set("Content-Type", "text/html") + return + } + + digests := strings.Split(digestsParam, ",") + if len(digests) > 50 { + digests = digests[:50] + } + + holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) + if holdDID == "" { + // Can't resolve hold — render empty OOB spans + w.Header().Set("Content-Type", "text/html") + for _, d := range digests { + fmt.Fprintf(w, ``, template.HTMLEscapeString(d)) + } + return + } + + // Fetch scan records concurrently with a semaphore to limit parallelism + type result struct { + hexDigest string + data vulnBadgeData + } + results := make([]result, len(digests)) + sem := make(chan struct{}, 10) + + var wg sync.WaitGroup + for i, hexDigest := range digests { + results[i].hexDigest = hexDigest + wg.Add(1) + go func(idx int, hex string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + results[idx].data = fetchScanRecord(ctx, holdEndpoint, holdDID, hex) + }(i, hexDigest) + } + wg.Wait() + + // Render all OOB fragments + w.Header().Set("Content-Type", "text/html") + for _, res := range results { + var buf bytes.Buffer + if err := h.Templates.ExecuteTemplate(&buf, "vuln-badge", res.data); err != nil { + slog.Warn("Failed to render vuln badge in batch", "digest", res.hexDigest, "error", err) + } + fmt.Fprintf(w, `%s`, + template.HTMLEscapeString(res.hexDigest), buf.String()) + } +} diff --git a/pkg/appview/handlers/scan_result_test.go b/pkg/appview/handlers/scan_result_test.go index 3516c3e..815b42f 100644 --- a/pkg/appview/handlers/scan_result_test.go +++ b/pkg/appview/handlers/scan_result_test.go @@ -253,3 +253,150 @@ func TestScanResult_OnlyCriticalShown(t *testing.T) { t.Error("Should not contain 'L:0' for zero low count") } } + +// --- Batch scan result tests --- + +func setupBatchScanResultHandler(t *testing.T) *handlers.BatchScanResultHandler { + t.Helper() + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("Failed to load templates: %v", err) + } + return &handlers.BatchScanResultHandler{ + BaseUIHandler: handlers.BaseUIHandler{ + Templates: templates, + }, + } +} + +func TestBatchScanResult_MultipleDigests(t *testing.T) { + // Mock hold that returns different results based on rkey + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rkey := r.URL.Query().Get("rkey") + w.Header().Set("Content-Type", "application/json") + switch rkey { + case "abc123": + w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20))) + case "def456": + w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0))) + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) + defer hold.Close() + + handler := setupBatchScanResultHandler(t) + + req := httptest.NewRequest("GET", + "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123,def456,unknown789", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) + } + + body := rr.Body.String() + + // All three digests should have OOB spans + if !strings.Contains(body, `id="scan-badge-abc123"`) { + t.Error("Expected OOB span for abc123") + } + if !strings.Contains(body, `id="scan-badge-def456"`) { + t.Error("Expected OOB span for def456") + } + if !strings.Contains(body, `id="scan-badge-unknown789"`) { + t.Error("Expected OOB span for unknown789") + } + + // All should have hx-swap-oob attribute + if !strings.Contains(body, `hx-swap-oob="outerHTML"`) { + t.Error("Expected hx-swap-oob attribute in response") + } + + // abc123 should have vulnerability badges + if !strings.Contains(body, "C:2") { + t.Error("Expected body to contain 'C:2' for abc123") + } + // def456 should have clean badge + if !strings.Contains(body, "Clean") { + t.Error("Expected body to contain 'Clean' for def456") + } +} + +func TestBatchScanResult_EmptyParams(t *testing.T) { + handler := setupBatchScanResultHandler(t) + + // No params + req := httptest.NewRequest("GET", "/api/scan-results", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + body := strings.TrimSpace(rr.Body.String()) + if body != "" { + t.Errorf("Expected empty body for missing params, got: %q", body) + } +} + +func TestBatchScanResult_MissingDigests(t *testing.T) { + handler := setupBatchScanResultHandler(t) + + req := httptest.NewRequest("GET", "/api/scan-results?holdEndpoint=https://hold.example.com", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + body := strings.TrimSpace(rr.Body.String()) + if body != "" { + t.Errorf("Expected empty body for missing digests, got: %q", body) + } +} + +func TestBatchScanResult_HoldUnreachable(t *testing.T) { + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + hold.Close() + + handler := setupBatchScanResultHandler(t) + + req := httptest.NewRequest("GET", + "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123,def456", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + body := rr.Body.String() + + // Should still have OOB spans (empty content since hold is unreachable) + if !strings.Contains(body, `id="scan-badge-abc123"`) { + t.Error("Expected OOB span for abc123 even when hold is unreachable") + } + if !strings.Contains(body, `id="scan-badge-def456"`) { + t.Error("Expected OOB span for def456 even when hold is unreachable") + } + // Should NOT contain vulnerability badges + if strings.Contains(body, "badge-error") || strings.Contains(body, "Clean") { + t.Error("Unreachable hold should not render badge content") + } +} + +func TestBatchScanResult_SingleDigest(t *testing.T) { + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(mockScanRecord(1, 0, 0, 0, 1))) + })) + defer hold.Close() + + handler := setupBatchScanResultHandler(t) + + req := httptest.NewRequest("GET", + "/api/scan-results?holdEndpoint="+hold.URL+"&digests=abc123", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + body := rr.Body.String() + + if !strings.Contains(body, `id="scan-badge-abc123"`) { + t.Error("Expected OOB span for abc123") + } + if !strings.Contains(body, "C:1") { + t.Error("Expected body to contain 'C:1'") + } +} diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 423c63b..a052d78 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -124,6 +124,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { // Vulnerability scan result API endpoints (HTMX lazy loading + modal content) router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP) + router.Get("/api/scan-results", (&uihandlers.BatchScanResultHandler{BaseUIHandler: base}).ServeHTTP) router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP) // Attestation details API endpoint (HTMX modal content) diff --git a/pkg/appview/templates/components/star.html b/pkg/appview/templates/components/star.html index 502e6f1..de2d498 100644 --- a/pkg/appview/templates/components/star.html +++ b/pkg/appview/templates/components/star.html @@ -21,12 +21,12 @@ hx-on::before-request="this.disabled=true" hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'" aria-label="{{ if .IsStarred }}Unstar{{ else }}Star{{ end }} {{ .Handle }}/{{ .Repository }}"> - + {{ .StarCount }} {{ else }} - + {{ .StarCount }} {{ end }} diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 89f332b..59cbbb5 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -220,12 +220,9 @@ {{ else if not .Reachable }} {{ icon "alert-triangle" "size-3" }} Offline {{ end }} - {{/* Vulnerability scan badge (lazy-loaded from hold) */}} + {{/* Vulnerability scan badge placeholder (batch-loaded via OOB swap) */}} {{ if and (not .IsManifestList) .Manifest.HoldEndpoint }} - - + {{ end }}
@@ -270,6 +267,12 @@
{{ end }} + {{ if $.ScanBatchParams }} +
+ {{ end }} {{ else }}

No manifests available

{{ end }} diff --git a/scanner/cmd/scanner/main.go b/scanner/cmd/scanner/main.go index a203173..a52e0bd 100644 --- a/scanner/cmd/scanner/main.go +++ b/scanner/cmd/scanner/main.go @@ -17,6 +17,8 @@ import ( "atcr.io/scanner/internal/scan" ) +var configFile string + var rootCmd = &cobra.Command{ Use: "atcr-scanner", Short: "ATCR Scanner - container image vulnerability scanner", @@ -31,36 +33,30 @@ The scanner connects to a hold service via WebSocket, receives scan jobs, generates SBOMs with Syft, scans for vulnerabilities with Grype, and sends results back over the same WebSocket connection. -Configuration via environment variables (SCANNER_ prefix): - SCANNER_HOLD_URL Hold service URL (required) - SCANNER_SHARED_SECRET Shared secret for auth (required) - SCANNER_WORKERS Worker count (default: 2) - SCANNER_QUEUE_SIZE Max queue depth (default: 100) - SCANNER_VULN_ENABLED Enable Grype scanning (default: true) - SCANNER_VULN_DB_PATH Grype DB location (default: /var/lib/atcr-scanner/vulndb) - SCANNER_TMP_DIR Temp dir for extraction (default: /var/lib/atcr-scanner/tmp) - SCANNER_ADDR Health endpoint addr (default: :9090)`, +Configuration is loaded in layers: defaults -> YAML file -> environment variables. +Use --config to specify a YAML configuration file. +Environment variables always override file values (SCANNER_ prefix).`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := config.LoadConfig(configFile) if err != nil { return fmt.Errorf("failed to load config: %w", err) } slog.Info("Starting ATCR scanner", - "hold_url", cfg.HoldURL, - "workers", cfg.Workers, - "queue_size", cfg.QueueSize, - "vuln_enabled", cfg.VulnEnabled) + "hold_url", cfg.Hold.URL, + "workers", cfg.Scanner.Workers, + "queue_size", cfg.Scanner.QueueSize, + "vuln_enabled", cfg.Vuln.Enabled) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Create priority queue - q := queue.NewJobQueue(cfg.QueueSize) + q := queue.NewJobQueue(cfg.Scanner.QueueSize) // Create hold WebSocket client - holdClient := client.NewHoldClient(cfg.HoldURL, cfg.SharedSecret, q) + holdClient := client.NewHoldClient(cfg.Hold.URL, cfg.Hold.Secret, q) // Start WebSocket connection (feeds queue) go holdClient.Connect() @@ -75,9 +71,9 @@ Configuration via environment variables (SCANNER_ prefix): w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) - healthServer := &http.Server{Addr: cfg.Addr, Handler: mux} + healthServer := &http.Server{Addr: cfg.Server.Addr, Handler: mux} go func() { - slog.Info("Health endpoint listening", "addr", cfg.Addr) + slog.Info("Health endpoint listening", "addr", cfg.Server.Addr) if err := healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("Health server error", "error", err) } @@ -101,8 +97,41 @@ Configuration via environment variables (SCANNER_ prefix): }, } +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration management commands", +} + +var configInitCmd = &cobra.Command{ + Use: "init [path]", + Short: "Generate an example configuration file", + Long: `Generate an example YAML configuration file with all available options. +If path is provided, writes to that file. Otherwise writes to stdout.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + yamlBytes, err := config.ExampleYAML() + if err != nil { + return fmt.Errorf("failed to generate example config: %w", err) + } + if len(args) == 1 { + if err := os.WriteFile(args[0], yamlBytes, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + fmt.Fprintf(os.Stderr, "Wrote example config to %s\n", args[0]) + return nil + } + fmt.Print(string(yamlBytes)) + return nil + }, +} + func init() { + serveCmd.Flags().StringVarP(&configFile, "config", "c", "", "path to YAML configuration file") + + configCmd.AddCommand(configInitCmd) + rootCmd.AddCommand(serveCmd) + rootCmd.AddCommand(configCmd) } func main() { diff --git a/scanner/go.mod b/scanner/go.mod index 4d2f376..14d0756 100644 --- a/scanner/go.mod +++ b/scanner/go.mod @@ -9,9 +9,12 @@ require ( github.com/spf13/cobra v1.10.2 ) +require go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect + exclude google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a require ( + atcr.io v0.0.0 cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.1 // indirect @@ -248,7 +251,7 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect + github.com/spf13/viper v1.21.0 github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/sylabs/sif/v2 v2.23.0 // indirect @@ -304,3 +307,5 @@ require ( modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.45.0 // indirect ) + +replace atcr.io => ../ diff --git a/scanner/go.sum b/scanner/go.sum index c7c1762..1dc0e00 100644 --- a/scanner/go.sum +++ b/scanner/go.sum @@ -243,8 +243,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -571,8 +571,8 @@ github.com/gpustack/gguf-parser-go v0.24.0 h1:tdJceXYp9e5RhE9RwVYIuUpir72Jz2D68N github.com/gpustack/gguf-parser-go v0.24.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 h1:0HADrxxqaQkGycO1JoUUA+B4FnIkuo8d2bz/hSaTFFQ= @@ -845,8 +845,8 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -1009,12 +1009,12 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= @@ -1024,8 +1024,8 @@ go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2 go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -1033,6 +1033,8 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= +go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/scanner/internal/config/config.go b/scanner/internal/config/config.go index 13c10f0..661982c 100644 --- a/scanner/internal/config/config.go +++ b/scanner/internal/config/config.go @@ -1,83 +1,120 @@ -// Package config provides environment-based configuration for the scanner service. +// Package config provides Viper-based configuration for the scanner service. package config import ( "fmt" - "os" - "strconv" + + "github.com/spf13/viper" + + "atcr.io/pkg/config" ) -// Config holds all scanner configuration +// Config holds all scanner configuration. type Config struct { - // Addr is the HTTP address for the health endpoint - Addr string - - // HoldURL is the WebSocket URL of the hold service - HoldURL string - - // SharedSecret is the shared secret for scanner authentication - SharedSecret string - - // Workers is the number of concurrent scan workers - Workers int - - // QueueSize is the maximum priority queue depth - QueueSize int - - // VulnEnabled enables Grype vulnerability scanning - VulnEnabled bool - - // VulnDBPath is the directory for the Grype vulnerability database - VulnDBPath string - - // TmpDir is the directory for temporary layer extraction - TmpDir string + Version string `yaml:"version" comment:"Configuration format version."` + LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` + LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` + Server ServerConfig `yaml:"server" comment:"Health endpoint settings."` + Hold HoldConfig `yaml:"hold" comment:"Hold service connection settings."` + Scanner ScannerConfig `yaml:"scanner" comment:"Worker pool settings."` + Vuln VulnConfig `yaml:"vuln" comment:"Vulnerability scanning (Grype) settings."` } -// Load reads configuration from environment variables with SCANNER_ prefix -func Load() (*Config, error) { - cfg := &Config{ - Addr: envOr("SCANNER_ADDR", ":9090"), - HoldURL: os.Getenv("SCANNER_HOLD_URL"), - SharedSecret: os.Getenv("SCANNER_SHARED_SECRET"), - Workers: envIntOr("SCANNER_WORKERS", 2), - QueueSize: envIntOr("SCANNER_QUEUE_SIZE", 100), - VulnEnabled: envBoolOr("SCANNER_VULN_ENABLED", true), - VulnDBPath: envOr("SCANNER_VULN_DB_PATH", "/var/lib/atcr-scanner/vulndb"), - TmpDir: envOr("SCANNER_TMP_DIR", "/var/lib/atcr-scanner/tmp"), +// ServerConfig defines the health endpoint settings. +type ServerConfig struct { + // Listen address for the health endpoint. + Addr string `yaml:"addr" comment:"Listen address for the health endpoint, e.g. \":9090\"."` +} + +// HoldConfig defines the hold service connection. +type HoldConfig struct { + // WebSocket URL of the hold service. + URL string `yaml:"url" comment:"WebSocket URL of the hold service (REQUIRED), e.g. \"ws://localhost:8080\"."` + + // Shared secret for scanner authentication. + Secret string `yaml:"secret" comment:"Shared secret for scanner WebSocket auth (REQUIRED)."` +} + +// ScannerConfig defines worker pool settings. +type ScannerConfig struct { + // Number of concurrent scan workers. + Workers int `yaml:"workers" comment:"Number of concurrent scan workers."` + + // Maximum priority queue depth. + QueueSize int `yaml:"queue_size" comment:"Maximum priority queue depth."` +} + +// VulnConfig defines vulnerability scanning settings. +type VulnConfig struct { + // Enable Grype vulnerability scanning. + Enabled bool `yaml:"enabled" comment:"Enable Grype vulnerability scanning."` + + // Directory for the Grype vulnerability database. + DBPath string `yaml:"db_path" comment:"Directory for the Grype vulnerability database."` + + // Directory for temporary layer extraction. + TmpDir string `yaml:"tmp_dir" comment:"Directory for temporary layer extraction."` +} + +// setScannerDefaults registers all default values on the given Viper instance. +func setScannerDefaults(v *viper.Viper) { + v.SetDefault("version", "0.1") + v.SetDefault("log_level", "info") + + // Server defaults + v.SetDefault("server.addr", ":9090") + + // Hold defaults + v.SetDefault("hold.url", "") + v.SetDefault("hold.secret", "") + + // Scanner defaults + v.SetDefault("scanner.workers", 2) + v.SetDefault("scanner.queue_size", 100) + + // Vuln defaults + v.SetDefault("vuln.enabled", true) + v.SetDefault("vuln.db_path", "/var/lib/atcr-scanner/vulndb") + v.SetDefault("vuln.tmp_dir", "/var/lib/atcr-scanner/tmp") + + // Log shipper defaults + v.SetDefault("log_shipper.batch_size", 100) + v.SetDefault("log_shipper.flush_interval", "5s") +} + +// DefaultConfig returns a Config populated with all default values (no validation). +func DefaultConfig() *Config { + v := config.NewViper("SCANNER", "") + setScannerDefaults(v) + + cfg := &Config{} + _ = v.Unmarshal(cfg, config.UnmarshalOption()) + return cfg +} + +// ExampleYAML returns a fully-commented YAML configuration with default values. +func ExampleYAML() ([]byte, error) { + return config.MarshalCommentedYAML("ATCR Scanner Configuration", DefaultConfig()) +} + +// LoadConfig builds a complete configuration using Viper layered loading: +// defaults -> YAML file -> environment variables. +// yamlPath is optional; empty string means env-only (backward compatible). +func LoadConfig(yamlPath string) (*Config, error) { + v := config.NewViper("SCANNER", yamlPath) + setScannerDefaults(v) + + cfg := &Config{} + if err := v.Unmarshal(cfg, config.UnmarshalOption()); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - if cfg.HoldURL == "" { - return nil, fmt.Errorf("SCANNER_HOLD_URL is required") + if cfg.Hold.URL == "" { + return nil, fmt.Errorf("hold.url is required (env: SCANNER_HOLD_URL)") } - if cfg.SharedSecret == "" { - return nil, fmt.Errorf("SCANNER_SHARED_SECRET is required") + if cfg.Hold.Secret == "" { + return nil, fmt.Errorf("hold.secret is required (env: SCANNER_HOLD_SECRET)") } return cfg, nil } - -func envOr(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - return fallback -} - -func envIntOr(key string, fallback int) int { - if v := os.Getenv(key); v != "" { - if n, err := strconv.Atoi(v); err == nil { - return n - } - } - return fallback -} - -func envBoolOr(key string, fallback bool) bool { - if v := os.Getenv(key); v != "" { - if b, err := strconv.ParseBool(v); err == nil { - return b - } - } - return fallback -} diff --git a/scanner/internal/scan/worker.go b/scanner/internal/scan/worker.go index 7a3b476..ec22c85 100644 --- a/scanner/internal/scan/worker.go +++ b/scanner/internal/scan/worker.go @@ -36,21 +36,21 @@ func NewWorkerPool(cfg *config.Config, q *queue.JobQueue, c *client.HoldClient) // Start launches worker goroutines func (wp *WorkerPool) Start(ctx context.Context) { // Initialize vuln database on startup if enabled - if wp.cfg.VulnEnabled { + if wp.cfg.Vuln.Enabled { go func() { - if err := initializeVulnDatabase(wp.cfg.VulnDBPath, wp.cfg.TmpDir); err != nil { + if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath, wp.cfg.Vuln.TmpDir); err != nil { slog.Error("Failed to initialize vulnerability database", "error", err) slog.Warn("Vulnerability scanning will be disabled until database is available") } }() } - for i := 0; i < wp.cfg.Workers; i++ { + for i := 0; i < wp.cfg.Scanner.Workers; i++ { wp.wg.Add(1) go wp.worker(ctx, i) } - slog.Info("Scanner worker pool started", "workers", wp.cfg.Workers) + slog.Info("Scanner worker pool started", "workers", wp.cfg.Scanner.Workers) } // Wait blocks until all workers finish @@ -100,13 +100,13 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc startTime := time.Now() // Ensure tmp dir exists - if err := ensureDir(wp.cfg.TmpDir); err != nil { + if err := ensureDir(wp.cfg.Vuln.TmpDir); err != nil { return nil, fmt.Errorf("failed to create tmp dir: %w", err) } // Step 1: Extract image layers from hold via presigned URLs slog.Info("Extracting image layers", "repository", job.Repository) - imageDir, cleanup, err := extractLayers(job, wp.cfg.TmpDir) + imageDir, cleanup, err := extractLayers(job, wp.cfg.Vuln.TmpDir) if err != nil { return nil, fmt.Errorf("failed to extract layers: %w", err) } @@ -126,9 +126,9 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc } // Step 3: Scan SBOM with Grype (if enabled) - if wp.cfg.VulnEnabled { + if wp.cfg.Vuln.Enabled { slog.Info("Scanning for vulnerabilities", "repository", job.Repository) - vulnJSON, vulnDigest, summary, err := scanVulnerabilities(ctx, sbomResult, wp.cfg.VulnDBPath) + vulnJSON, vulnDigest, summary, err := scanVulnerabilities(ctx, sbomResult, wp.cfg.Vuln.DBPath) if err != nil { return nil, fmt.Errorf("failed to scan vulnerabilities: %w", err) }