github.com/seilagamo/poc-lava-release@v0.3.3-rc3/internal/engine/engine.go (about) 1 // Copyright 2023 Adevinta 2 3 // Package engine runs Vulcan checks and retrieves the generated 4 // reports. 5 package engine 6 7 import ( 8 "context" 9 "fmt" 10 "log/slog" 11 "net" 12 "strings" 13 "time" 14 15 "github.com/adevinta/vulcan-agent/agent" 16 "github.com/adevinta/vulcan-agent/backend" 17 "github.com/adevinta/vulcan-agent/backend/docker" 18 agentconfig "github.com/adevinta/vulcan-agent/config" 19 "github.com/adevinta/vulcan-agent/jobrunner" 20 "github.com/adevinta/vulcan-agent/queue" 21 "github.com/adevinta/vulcan-agent/queue/chanqueue" 22 report "github.com/adevinta/vulcan-report" 23 types "github.com/adevinta/vulcan-types" 24 25 "github.com/seilagamo/poc-lava-release/internal/checktypes" 26 "github.com/seilagamo/poc-lava-release/internal/config" 27 "github.com/seilagamo/poc-lava-release/internal/dockerutil" 28 "github.com/seilagamo/poc-lava-release/internal/metrics" 29 ) 30 31 // dockerInternalHost is the host used by the containers to access the 32 // services exposed by the Docker host. 33 const dockerInternalHost = "host.lava.internal" 34 35 // Report is a collection of reports returned by Vulcan checks and 36 // indexed by check ID. 37 type Report map[string]report.Report 38 39 // Run runs vulcan checks and returns the generated report. The check 40 // list is based on the provided checktypes and targets. These checks 41 // are run by a Vulcan agent, which is configured using the specified 42 // configuration. 43 func Run(checktypeURLs []string, targets []config.Target, cfg config.AgentConfig) (Report, error) { 44 srv, err := newTargetServer() 45 if err != nil { 46 return nil, fmt.Errorf("new server: %w", err) 47 } 48 defer srv.Close() 49 50 catalog, err := checktypes.NewCatalog(checktypeURLs) 51 if err != nil { 52 return nil, fmt.Errorf("get checkype catalog: %w", err) 53 } 54 55 metrics.Collect("checktypes", catalog) 56 57 jl, err := generateJobs(catalog, targets) 58 if err != nil { 59 return nil, fmt.Errorf("create job list: %w", err) 60 } 61 62 if len(jl) == 0 { 63 return nil, nil 64 } 65 66 return runAgent(jl, srv, cfg) 67 } 68 69 // summaryInterval is the time between summary logs. 70 const summaryInterval = 15 * time.Second 71 72 // runAgent creates a Vulcan agent using the specified config and uses 73 // it to run the provided jobs. 74 func runAgent(jobs []jobrunner.Job, srv *targetServer, cfg config.AgentConfig) (Report, error) { 75 alogger := newAgentLogger(slog.Default()) 76 77 agentConfig, err := newAgentConfig(cfg) 78 if err != nil { 79 return nil, fmt.Errorf("get agent config: %w", err) 80 } 81 82 br := func(params backend.RunParams, rc *docker.RunConfig) error { 83 return beforeRun(params, rc, srv) 84 } 85 86 backend, err := docker.NewBackend(alogger, agentConfig, br) 87 if err != nil { 88 return nil, fmt.Errorf("new Docker backend: %w", err) 89 } 90 91 // Create a state queue and discard all messages. 92 stateQueue := chanqueue.New(queue.Discard()) 93 stateQueue.StartReading(context.Background()) 94 95 jobsQueue := chanqueue.New(nil) 96 if err := sendJobs(jobs, jobsQueue); err != nil { 97 return nil, fmt.Errorf("send jobs: %w", err) 98 } 99 100 rs := &reportStore{} 101 102 done := make(chan bool) 103 go func() { 104 for { 105 select { 106 case <-done: 107 return 108 case <-time.After(summaryInterval): 109 sums := rs.Summary() 110 if len(sums) == 0 { 111 slog.Info("waiting for updates") 112 break 113 } 114 for _, s := range sums { 115 slog.Info(s) 116 } 117 } 118 } 119 }() 120 121 exitCode := agent.RunWithQueues(agentConfig, rs, backend, stateQueue, jobsQueue, alogger) 122 if exitCode != 0 { 123 return nil, fmt.Errorf("run agent: exit code %v", exitCode) 124 } 125 126 done <- true 127 128 report, err := mkReport(rs, srv) 129 if err != nil { 130 return nil, fmt.Errorf("restore targets: %w", err) 131 } 132 133 return report, nil 134 } 135 136 // mkReport generates a report from the information stored in the 137 // provided [reportStore]. It uses the provided [targetServer] to 138 // replace the targets sent to the checks with the original targets. 139 func mkReport(rs *reportStore, srv *targetServer) (Report, error) { 140 rep := make(Report) 141 for checkID, r := range rs.Reports() { 142 tm, ok := srv.TargetMap(checkID) 143 if !ok { 144 rep[checkID] = r 145 continue 146 } 147 148 tmAddrs := tm.Addrs() 149 150 slog.Info("applying target map", "check", checkID, "tm", tm, "tmAddr", tmAddrs) 151 152 r.Target = tm.OldIdentifier 153 154 var vulns []report.Vulnerability 155 for _, vuln := range r.Vulnerabilities { 156 vuln = vulnReplaceAll(vuln, tm.NewIdentifier, tm.OldIdentifier) 157 vuln = vulnReplaceAll(vuln, tmAddrs.NewIdentifier, tmAddrs.OldIdentifier) 158 vulns = append(vulns, vuln) 159 } 160 r.Vulnerabilities = vulns 161 162 rep[checkID] = r 163 } 164 return rep, nil 165 } 166 167 // vulnReplaceAll returns a copy of the vulnerability vuln with all 168 // non-overlapping instances of old replaced by new. 169 func vulnReplaceAll(vuln report.Vulnerability, old, new string) report.Vulnerability { 170 vuln.Summary = strings.ReplaceAll(vuln.Summary, old, new) 171 vuln.AffectedResource = strings.ReplaceAll(vuln.AffectedResource, old, new) 172 vuln.AffectedResourceString = strings.ReplaceAll(vuln.AffectedResourceString, old, new) 173 vuln.Description = strings.ReplaceAll(vuln.Description, old, new) 174 vuln.Details = strings.ReplaceAll(vuln.Details, old, new) 175 vuln.ImpactDetails = strings.ReplaceAll(vuln.ImpactDetails, old, new) 176 177 var labels []string 178 for _, label := range vuln.Labels { 179 labels = append(labels, strings.ReplaceAll(label, old, new)) 180 } 181 vuln.Labels = labels 182 183 var recs []string 184 for _, rec := range vuln.Recommendations { 185 recs = append(recs, strings.ReplaceAll(rec, old, new)) 186 } 187 vuln.Recommendations = recs 188 189 var refs []string 190 for _, ref := range vuln.References { 191 refs = append(refs, strings.ReplaceAll(ref, old, new)) 192 } 193 vuln.References = refs 194 195 var rscs []report.ResourcesGroup 196 for _, rsc := range vuln.Resources { 197 rscs = append(rscs, rscReplaceAll(rsc, old, new)) 198 } 199 vuln.Resources = rscs 200 201 var vulns []report.Vulnerability 202 for _, vuln := range vuln.Vulnerabilities { 203 vulns = append(vulns, vulnReplaceAll(vuln, old, new)) 204 } 205 vuln.Vulnerabilities = vulns 206 207 return vuln 208 } 209 210 // rscReplaceAll returns a copy of the resource group rsc with all 211 // non-overlapping instances of old replaced by new. 212 func rscReplaceAll(rsc report.ResourcesGroup, old, new string) report.ResourcesGroup { 213 rsc.Name = strings.ReplaceAll(rsc.Name, old, new) 214 215 var hdrs []string 216 for _, hdr := range rsc.Header { 217 hdrs = append(hdrs, strings.ReplaceAll(hdr, old, new)) 218 } 219 rsc.Header = hdrs 220 221 var rows []map[string]string 222 for _, r := range rsc.Rows { 223 row := make(map[string]string) 224 for k, v := range r { 225 k = strings.ReplaceAll(k, old, new) 226 v = strings.ReplaceAll(v, old, new) 227 row[k] = v 228 } 229 rows = append(rows, row) 230 } 231 rsc.Rows = rows 232 233 return rsc 234 } 235 236 // newAgentConfig creates a new [agentconfig.Config] based on the 237 // provided Lava configuration. 238 func newAgentConfig(cfg config.AgentConfig) (agentconfig.Config, error) { 239 listenHost, err := bridgeHost() 240 if err != nil { 241 return agentconfig.Config{}, fmt.Errorf("get listen host: %w", err) 242 } 243 244 parallel := cfg.Parallel 245 if parallel == 0 { 246 parallel = 1 247 } 248 249 ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0")) 250 if err != nil { 251 return agentconfig.Config{}, fmt.Errorf("listen: %w", err) 252 } 253 254 auths := []agentconfig.Auth{} 255 for _, r := range cfg.RegistryAuths { 256 auths = append(auths, agentconfig.Auth{ 257 Server: r.Server, 258 User: r.Username, 259 Pass: r.Password, 260 }) 261 } 262 263 acfg := agentconfig.Config{ 264 Agent: agentconfig.AgentConfig{ 265 ConcurrentJobs: parallel, 266 MaxNoMsgsInterval: 5, // Low as all the messages will be in the queue before starting the agent. 267 MaxProcessMessageTimes: 1, // No retry. 268 Timeout: 180, // Default timeout of 3 minutes. 269 }, 270 API: agentconfig.APIConfig{ 271 Host: dockerInternalHost, 272 Listener: ln, 273 }, 274 Check: agentconfig.CheckConfig{ 275 Vars: cfg.Vars, 276 }, 277 Runtime: agentconfig.RuntimeConfig{ 278 Docker: agentconfig.DockerConfig{ 279 Registry: agentconfig.RegistryConfig{ 280 PullPolicy: cfg.PullPolicy, 281 BackoffMaxRetries: 5, 282 BackoffInterval: 5, 283 BackoffJitterFactor: 0.5, 284 Auths: auths, 285 }, 286 }, 287 }, 288 } 289 return acfg, nil 290 } 291 292 // beforeRun is called by the agent before creating each check 293 // container. 294 func beforeRun(params backend.RunParams, rc *docker.RunConfig, srv *targetServer) error { 295 // Register a host pointing to the host gateway. 296 rc.HostConfig.ExtraHosts = []string{dockerInternalHost + ":host-gateway"} 297 298 // Allow all checks to scan local assets. 299 rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_ALLOW_PRIVATE_IPS", "true") 300 301 if params.AssetType == string(types.DockerImage) { 302 // Due to how reachability is defined by the Vulcan 303 // check SDK, local Docker images would be identified 304 // as unreachable. So, we disable reachability checks 305 // for this type of assets. 306 rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_SKIP_REACHABILITY", "true") 307 308 // Tools like trivy require access to the Docker 309 // daemon to scan local Docker images. So, we share 310 // the Docker socket with them. 311 dockerHost, err := daemonHost() 312 if err != nil { 313 return fmt.Errorf("get Docker client: %w", err) 314 } 315 316 // Remote Docker daemons are not supported. 317 if dockerVol, found := strings.CutPrefix(dockerHost, "unix://"); found { 318 rc.HostConfig.Binds = append(rc.HostConfig.Binds, dockerVol+":/var/run/docker.sock") 319 } 320 } 321 322 // Proxy local targets and serve Git repositories. 323 target := config.Target{ 324 Identifier: params.Target, 325 AssetType: types.AssetType(params.AssetType), 326 } 327 if tm, err := srv.Handle(params.CheckID, target); err == nil { 328 if !tm.IsZero() { 329 rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_CHECK_TARGET", tm.NewIdentifier) 330 rc.ContainerConfig.Env = setenv(rc.ContainerConfig.Env, "VULCAN_CHECK_ASSET_TYPE", string(tm.NewAssetType)) 331 } 332 } else { 333 slog.Warn("could not handle target", "target", target, "err", err) 334 } 335 336 return nil 337 } 338 339 // daemonHost returns the Docker daemon host. 340 func daemonHost() (string, error) { 341 cli, err := dockerutil.NewAPIClient() 342 if err != nil { 343 return "", fmt.Errorf("get Docker client: %w", err) 344 } 345 defer cli.Close() 346 347 return cli.DaemonHost(), nil 348 } 349 350 // bridgeHost returns a host that points to the Docker host and is 351 // reachable from the containers running in the default bridge. 352 func bridgeHost() (string, error) { 353 cli, err := dockerutil.NewAPIClient() 354 if err != nil { 355 return "", fmt.Errorf("get Docker client: %w", err) 356 } 357 defer cli.Close() 358 359 host, err := dockerutil.BridgeHost(cli) 360 if err != nil { 361 return "", fmt.Errorf("get bridge host: %w", err) 362 } 363 364 return host, nil 365 } 366 367 // setenv sets the value of the variable named by the key in the 368 // provided environment. An environment consists on a slice of strings 369 // with the format "key=value". 370 func setenv(env []string, key, value string) []string { 371 for i, ev := range env { 372 if strings.HasPrefix(ev, key+"=") { 373 env[i] = fmt.Sprintf("%s=%s", key, value) 374 return env 375 } 376 } 377 return append(env, fmt.Sprintf("%s=%s", key, value)) 378 }