|
39 | 39 | # Known execution keys |
40 | 40 | _EXECUTION_KEYS = {"backend", "registry", "pull_policy"} |
41 | 41 |
|
| 42 | +# Top-level containers block keys |
| 43 | +_CONTAINERS_KEYS = {"images", "discover", "search_paths", "scanners"} |
| 44 | + |
| 45 | +# Per-image entry keys (under containers.images[*]) |
| 46 | +_CONTAINER_IMAGE_KEYS = {"image", "dockerfile", "context", "name"} |
| 47 | + |
| 48 | +# Sub-scanners argus scan container can dispatch to |
| 49 | +_CONTAINER_SUB_SCANNERS = {"trivy", "grype", "syft"} |
| 50 | + |
42 | 51 |
|
43 | 52 | class ConfigError: |
44 | 53 | """A single configuration issue.""" |
@@ -103,6 +112,11 @@ def validate_config(data: dict) -> list[ConfigError]: |
103 | 112 | if execution is not None: |
104 | 113 | errors.extend(_validate_execution("execution", execution)) |
105 | 114 |
|
| 115 | + # Containers (top-level lifecycle targets for ``argus scan container``) |
| 116 | + containers = data.get("containers") |
| 117 | + if containers is not None: |
| 118 | + errors.extend(_validate_containers("containers", containers)) |
| 119 | + |
106 | 120 | return errors |
107 | 121 |
|
108 | 122 |
|
@@ -240,6 +254,133 @@ def _validate_execution(path: str, data: Any) -> list[ConfigError]: |
240 | 254 | return errors |
241 | 255 |
|
242 | 256 |
|
| 257 | +def _validate_containers(path: str, data: Any) -> list[ConfigError]: |
| 258 | + """Validate the top-level ``containers:`` block. |
| 259 | +
|
| 260 | + Catches the common authoring mistakes that previously only surfaced |
| 261 | + at scan time (or got silently ignored): typo'd image-entry keys, |
| 262 | + discover without search_paths, an empty images list, sub-scanner |
| 263 | + names that aren't trivy/grype/syft, and image entries that name |
| 264 | + neither a registry ref nor a Dockerfile. |
| 265 | + """ |
| 266 | + errors: list[ConfigError] = [] |
| 267 | + |
| 268 | + if not isinstance(data, dict): |
| 269 | + errors.append(ConfigError( |
| 270 | + path, f"Must be a mapping, got {type(data).__name__}", |
| 271 | + )) |
| 272 | + return errors |
| 273 | + |
| 274 | + # Unknown keys |
| 275 | + for key in data: |
| 276 | + if key not in _CONTAINERS_KEYS: |
| 277 | + errors.append(ConfigError( |
| 278 | + f"{path}.{key}", |
| 279 | + f"Unknown containers key '{key}'. " |
| 280 | + f"Valid keys: {', '.join(sorted(_CONTAINERS_KEYS))}", |
| 281 | + level="warning", |
| 282 | + )) |
| 283 | + |
| 284 | + images = data.get("images") |
| 285 | + discover = data.get("discover", False) |
| 286 | + |
| 287 | + # At least one source of targets must be configured. |
| 288 | + if not images and not discover: |
| 289 | + errors.append(ConfigError( |
| 290 | + path, |
| 291 | + "containers: must declare at least one of `images:` (a list) " |
| 292 | + "or `discover: true` — otherwise `argus scan container --config` " |
| 293 | + "has no targets to scan.", |
| 294 | + )) |
| 295 | + |
| 296 | + # images: list of mappings |
| 297 | + if images is not None: |
| 298 | + if not isinstance(images, list): |
| 299 | + errors.append(ConfigError( |
| 300 | + f"{path}.images", "Must be a list of image entries", |
| 301 | + )) |
| 302 | + elif len(images) == 0: |
| 303 | + errors.append(ConfigError( |
| 304 | + f"{path}.images", |
| 305 | + "Empty images list — drop the key entirely or add at least one entry.", |
| 306 | + level="warning", |
| 307 | + )) |
| 308 | + else: |
| 309 | + for i, entry in enumerate(images): |
| 310 | + errors.extend( |
| 311 | + _validate_container_image_entry(f"{path}.images[{i}]", entry) |
| 312 | + ) |
| 313 | + |
| 314 | + # discover requires search_paths (or defaults to ["."]) |
| 315 | + if "search_paths" in data: |
| 316 | + sp = data["search_paths"] |
| 317 | + if not isinstance(sp, list) or not all(isinstance(p, str) for p in sp): |
| 318 | + errors.append(ConfigError( |
| 319 | + f"{path}.search_paths", |
| 320 | + "Must be a list of path strings", |
| 321 | + )) |
| 322 | + |
| 323 | + # scanners: must be a list of valid sub-scanner names |
| 324 | + if "scanners" in data: |
| 325 | + sc = data["scanners"] |
| 326 | + if not isinstance(sc, list): |
| 327 | + errors.append(ConfigError( |
| 328 | + f"{path}.scanners", |
| 329 | + f"Must be a list. Valid values: " |
| 330 | + f"{', '.join(sorted(_CONTAINER_SUB_SCANNERS))}", |
| 331 | + )) |
| 332 | + else: |
| 333 | + for i, s in enumerate(sc): |
| 334 | + if s not in _CONTAINER_SUB_SCANNERS: |
| 335 | + errors.append(ConfigError( |
| 336 | + f"{path}.scanners[{i}]", |
| 337 | + f"Unknown container sub-scanner '{s}'. " |
| 338 | + f"Valid values: {', '.join(sorted(_CONTAINER_SUB_SCANNERS))}", |
| 339 | + )) |
| 340 | + |
| 341 | + return errors |
| 342 | + |
| 343 | + |
| 344 | +def _validate_container_image_entry(path: str, entry: Any) -> list[ConfigError]: |
| 345 | + """Validate a single ``containers.images[*]`` entry.""" |
| 346 | + errors: list[ConfigError] = [] |
| 347 | + |
| 348 | + if not isinstance(entry, dict): |
| 349 | + errors.append(ConfigError( |
| 350 | + path, |
| 351 | + f"Must be a mapping with at least an 'image:' field, " |
| 352 | + f"got {type(entry).__name__}", |
| 353 | + )) |
| 354 | + return errors |
| 355 | + |
| 356 | + # Unknown keys |
| 357 | + for key in entry: |
| 358 | + if key not in _CONTAINER_IMAGE_KEYS: |
| 359 | + errors.append(ConfigError( |
| 360 | + f"{path}.{key}", |
| 361 | + f"Unknown image-entry key '{key}'. " |
| 362 | + f"Valid keys: {', '.join(sorted(_CONTAINER_IMAGE_KEYS))}", |
| 363 | + level="warning", |
| 364 | + )) |
| 365 | + |
| 366 | + # An entry must declare either an image ref or a dockerfile to build. |
| 367 | + if "image" not in entry and "dockerfile" not in entry: |
| 368 | + errors.append(ConfigError( |
| 369 | + path, |
| 370 | + "Image entry must have either 'image:' (registry reference) " |
| 371 | + "or 'dockerfile:' (build-then-scan) set.", |
| 372 | + )) |
| 373 | + |
| 374 | + # Type checks for present fields |
| 375 | + for field in ("image", "dockerfile", "context", "name"): |
| 376 | + if field in entry and not isinstance(entry[field], str): |
| 377 | + errors.append(ConfigError( |
| 378 | + f"{path}.{field}", "Must be a string", |
| 379 | + )) |
| 380 | + |
| 381 | + return errors |
| 382 | + |
| 383 | + |
243 | 384 | def report_validation(errors: list[ConfigError]) -> bool: |
244 | 385 | """Log validation errors/warnings and return True if config is valid. |
245 | 386 |
|
|
0 commit comments