diff --git a/Cargo.toml b/Cargo.toml index 86d0c45..72677cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ test-stub = [] [dev-dependencies] corgea = { path = ".", features = ["test-stub"] } -tempfile = "3.12.0" +tempfile = "3.20.0" [dependencies] clap = { version = "4.4.13", features = ["derive"] } @@ -40,7 +40,7 @@ serde_derive = "1.0.195" uuid = { version = "1.7.0", features = ["v4"] } which = "6.0.0" zip = "2.3.0" -tempfile = "3.12.0" +tempfile = "3.20.0" quick-xml = "0.41" ignore = "0.4" globset = "0.4" diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index 21712e1..02be37f 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -41,6 +41,8 @@ corgea scan --fail # Deprecated: exit 1 based on eve corgea scan --out-format json --out-file r.json # Export (json, html, sarif, markdown) corgea scan --sbom # Also write a CycloneDX SBOM to bom.json corgea scan --sbom sbom.cdx.json # SBOM to a custom file +corgea scan --include-image myapp:1.2.3 # Also scan a fully built container image +corgea scan --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest # Repeatable corgea scan --project-name my-service # Override project name ``` @@ -52,6 +54,10 @@ Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii `--fail` is deprecated. It evaluates every active blocking rule regardless of what it applies to; use `--block-on` to name the CI rules a pipeline should enforce. +`--include-image` scans the image you actually ship. Without it, container scanning discovers the base images referenced by Dockerfiles and Compose files in the repo and scans those. With it, each image is exported to a tar archive with `docker save` (or `podman save`), bundled with the project, and scanned as a whole — base-image discovery is skipped for that scan. Images that aren't available locally are pulled first, so build (or pull) the image before the scan and stay logged in to its registry. Set `CORGEA_CONTAINER_ENGINE` to choose a specific container CLI. Container scanning must be enabled for your account. + +An included image is enough on its own: when it is combined with `--only-uncommitted` or `--target` and no source files match (a clean working tree, for example), the scan warns and covers just the image rather than failing. An archive named `corgea-image-scanning-*.tar` that is committed to the repository is ignored — only images passed on the command line are scanned. + `--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive. ### Upload — `corgea upload [report]` @@ -353,6 +359,13 @@ corgea scan corgea scan --only-uncommitted --fail-on HI ``` +### Scan the container image a build produced + +```bash +docker build -t myapp:1.2.3 . +corgea scan --include-image myapp:1.2.3 +``` + ### Scan a PR diff ```bash diff --git a/src/images.rs b/src/images.rs new file mode 100644 index 0000000..6f7bc30 --- /dev/null +++ b/src/images.rs @@ -0,0 +1,513 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Corgea greps the uploaded bundle for archives named with this prefix. When it +/// finds any, it scans those images instead of resolving base images from the +/// source tree, so the name is part of the contract with the backend. +pub const IMAGE_ARCHIVE_PREFIX: &str = "corgea-image-scanning-"; +const IMAGE_ARCHIVE_EXTENSION: &str = ".tar"; +/// Stay well inside the 255 byte file name limit that common filesystems enforce. +const MAX_ARCHIVE_NAME_LEN: usize = 200; +/// Overrides the container CLI used to export images. +const ENGINE_ENV: &str = "CORGEA_CONTAINER_ENGINE"; +const ENGINE_CANDIDATES: &[&str] = &["docker", "podman"]; + +/// An image exported to a tar archive that is ready to be bundled with the scan. +#[derive(Debug)] +pub struct SavedImage { + pub image: String, + pub archive_name: String, + pub path: PathBuf, + pub size_bytes: u64, +} + +impl SavedImage { + /// One line for the scan output: what was exported, and how big it is. + pub fn description(&self) -> String { + format!( + "{} -> {} ({})", + self.image, + self.archive_name, + human_size(self.size_bytes) + ) + } +} + +/// Trim, validate and de-duplicate `--include-image` values. +pub fn normalize_image_refs(images: &[String]) -> Result, String> { + let mut normalized: Vec = Vec::new(); + + for raw in images { + let image = raw.trim(); + + if image.is_empty() { + return Err("--include-image was given an empty image reference. Pass a full image name with a tag, e.g. --include-image myapp:1.2.3.".to_string()); + } + if image.starts_with('-') { + return Err(format!( + "Invalid image reference '{}': an image name can't start with '-'.", + image + )); + } + if image.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(format!( + "Invalid image reference '{}': an image name can't contain whitespace.", + image + )); + } + + if !normalized.iter().any(|existing| existing == image) { + normalized.push(image.to_string()); + } + } + + Ok(normalized) +} + +/// The archive name Corgea looks for, derived from the image reference. +pub fn archive_name(image: &str) -> String { + let (repository, reference) = split_reference(image); + let mut name = format!( + "{}{}-{}", + IMAGE_ARCHIVE_PREFIX, + sanitize(repository), + sanitize(&reference) + ); + + // sanitize() only emits ASCII, so truncating by bytes stays on a char boundary. + let max_len = MAX_ARCHIVE_NAME_LEN - IMAGE_ARCHIVE_EXTENSION.len(); + if name.len() > max_len { + name.truncate(max_len); + } + + format!("{}{}", name.trim_end_matches('-'), IMAGE_ARCHIVE_EXTENSION) +} + +/// Export every image to `out_dir`, pulling images that aren't available locally. +pub fn save_images(images: &[String], out_dir: &Path) -> Result, String> { + let engine = detect_engine()?; + save_images_with_engine(&engine, images, out_dir) +} + +fn save_images_with_engine( + engine: &str, + images: &[String], + out_dir: &Path, +) -> Result, String> { + fs::create_dir_all(out_dir).map_err(|e| { + format!( + "Failed to create the image staging directory '{}': {}", + out_dir.display(), + e + ) + })?; + + let mut saved: Vec = Vec::with_capacity(images.len()); + + for image in images { + let archive_name = unique_archive_name(image, &saved); + let path = out_dir.join(&archive_name); + + println!("Exporting container image {}...", image); + ensure_image_present(engine, image)?; + save_image(engine, image, &path)?; + + let size_bytes = fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + + saved.push(SavedImage { + image: image.clone(), + archive_name, + path, + size_bytes, + }); + } + + Ok(saved) +} + +/// Split `repository[:tag][@digest]` into the repository and the reference that +/// identifies the version. Untagged references are exported as `latest`, which +/// is the tag the container CLI resolves them to. +fn split_reference(image: &str) -> (&str, String) { + let (name, digest) = match image.split_once('@') { + Some((name, digest)) => (name, Some(digest)), + None => (image, None), + }; + + // A colon inside the registry host (`registry:5000/team/app`) is a port, not a tag. + let tag_separator = name + .rfind(':') + .filter(|index| !name[*index + 1..].contains('/')); + let repository = match tag_separator { + Some(index) => &name[..index], + None => name, + }; + let tag = tag_separator.map(|index| &name[index + 1..]); + + let reference = match (tag, digest) { + (Some(tag), Some(digest)) => format!("{}-{}", tag, digest), + (Some(tag), None) => tag.to_string(), + (None, Some(digest)) => digest.to_string(), + (None, None) => "latest".to_string(), + }; + + (repository, reference) +} + +fn sanitize(value: &str) -> String { + let mut sanitized = String::with_capacity(value.len()); + + for c in value.chars() { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { + sanitized.push(c); + } else if !sanitized.ends_with('-') { + sanitized.push('-'); + } + } + + sanitized.trim_matches('-').to_string() +} + +/// Sanitizing and truncating can map two different references onto one name, so +/// suffix duplicates instead of overwriting an archive that was already written. +/// +/// Names are compared case-insensitively: image tags may carry uppercase, and on +/// a case-insensitive filesystem `myapp:V1` and `myapp:v1` would otherwise be +/// exported over each other and scanned as the same image. +fn unique_archive_name(image: &str, saved: &[SavedImage]) -> String { + let is_taken = |candidate: &str| { + saved + .iter() + .any(|entry| entry.archive_name.eq_ignore_ascii_case(candidate)) + }; + + let candidate = archive_name(image); + if !is_taken(&candidate) { + return candidate; + } + + let stem = candidate + .strip_suffix(IMAGE_ARCHIVE_EXTENSION) + .unwrap_or(&candidate); + for suffix in 2.. { + let candidate = format!("{}-{}{}", stem, suffix, IMAGE_ARCHIVE_EXTENSION); + if !is_taken(&candidate) { + return candidate; + } + } + + unreachable!("suffixed archive names are unbounded") +} + +fn detect_engine() -> Result { + if let Some(engine) = crate::utils::generic::get_env_var_if_exists(ENGINE_ENV) { + return Ok(engine); + } + + for engine in ENGINE_CANDIDATES { + if which::which(engine).is_ok() { + return Ok((*engine).to_string()); + } + } + + Err(format!( + "--include-image needs a container CLI to export images, but neither docker nor podman was found on your PATH.\nInstall one of them, or set {} to the CLI Corgea should use.", + ENGINE_ENV + )) +} + +fn ensure_image_present(engine: &str, image: &str) -> Result<(), String> { + let inspect = run_quiet(engine, &["image", "inspect", image])?; + if inspect.status.success() { + return Ok(()); + } + + println!(" {} isn't available locally, pulling it...", image); + let status = Command::new(engine) + .args(["pull", image]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map_err(|e| format!("Failed to run `{} pull {}`: {}", engine, image, e))?; + + if !status.success() { + return Err(format!( + "Couldn't find or pull the container image '{}'.\nBuild or pull it before running the scan, and make sure you're logged in to its registry.", + image + )); + } + + Ok(()) +} + +fn save_image(engine: &str, image: &str, path: &Path) -> Result<(), String> { + let path_arg = path.to_string_lossy().to_string(); + let output = run_quiet(engine, &["save", "-o", &path_arg, image])?; + + let failure = if !output.status.success() { + Some(failure_details(&output)) + } else if fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) + == 0 + { + Some("the container CLI produced an empty archive".to_string()) + } else { + None + }; + + if let Some(details) = failure { + let _ = fs::remove_file(path); + return Err(format!( + "Failed to export the container image '{}'.\nError details:\n{}", + image, details + )); + } + + Ok(()) +} + +fn run_quiet(engine: &str, args: &[&str]) -> Result { + Command::new(engine).args(args).output().map_err(|e| { + format!( + "Failed to run `{} {}`: {}. Is it installed and on your PATH?", + engine, + args.join(" "), + e + ) + }) +} + +fn failure_details(output: &std::process::Output) -> String { + for stream in [&output.stderr, &output.stdout] { + let details = String::from_utf8_lossy(stream).trim().to_string(); + if !details.is_empty() { + return details; + } + } + "the container CLI exited with an error".to_string() +} + +fn human_size(size_bytes: u64) -> String { + let megabytes = size_bytes as f64 / (1024.0 * 1024.0); + if megabytes >= 1024.0 { + format!("{:.2} GB", megabytes / 1024.0) + } else { + format!("{:.2} MB", megabytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn archive_name_uses_repository_and_tag() { + assert_eq!( + archive_name("alpine:3.19"), + "corgea-image-scanning-alpine-3.19.tar" + ); + assert_eq!( + archive_name("ghcr.io/acme/api:1.2.3"), + "corgea-image-scanning-ghcr.io-acme-api-1.2.3.tar" + ); + } + + #[test] + fn archive_name_defaults_untagged_images_to_latest() { + assert_eq!( + archive_name("myapp"), + "corgea-image-scanning-myapp-latest.tar" + ); + } + + #[test] + fn archive_name_treats_registry_port_as_part_of_the_repository() { + assert_eq!( + archive_name("registry:5000/team/app"), + "corgea-image-scanning-registry-5000-team-app-latest.tar" + ); + } + + #[test] + fn archive_name_keeps_digest_references() { + assert_eq!( + archive_name("alpine@sha256:abc123"), + "corgea-image-scanning-alpine-sha256-abc123.tar" + ); + assert_eq!( + archive_name("alpine:3.19@sha256:abc123"), + "corgea-image-scanning-alpine-3.19-sha256-abc123.tar" + ); + } + + #[test] + fn archive_name_stays_within_the_file_name_limit() { + let name = archive_name(&format!("acme/{}:1.0", "a".repeat(500))); + assert!(name.len() <= MAX_ARCHIVE_NAME_LEN); + assert!(name.starts_with(IMAGE_ARCHIVE_PREFIX)); + assert!(name.ends_with(IMAGE_ARCHIVE_EXTENSION)); + } + + #[test] + fn normalize_image_refs_trims_and_deduplicates() { + let images = vec![ + " alpine:3.19 ".to_string(), + "alpine:3.19".to_string(), + "myapp:1.0".to_string(), + ]; + assert_eq!( + normalize_image_refs(&images).unwrap(), + vec!["alpine:3.19".to_string(), "myapp:1.0".to_string()] + ); + } + + #[test] + fn normalize_image_refs_rejects_unusable_references() { + assert!(normalize_image_refs(&[" ".to_string()]).is_err()); + assert!(normalize_image_refs(&["--output=/tmp/x".to_string()]).is_err()); + assert!(normalize_image_refs(&["alpine 3.19".to_string()]).is_err()); + } + + /// Stub container CLI: reports every image as present locally and writes the + /// archive `save -o ` asks for. POSIX shell, so every test that runs it + /// is Unix-only — Windows can't execute the script through `Command::new`. + #[cfg(unix)] + fn stub_engine(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = dir.join("stub-engine.sh"); + let mut file = fs::File::create(&path).unwrap(); + writeln!( + file, + r#"#!/bin/sh +if [ "$1" = "image" ]; then + exit 0 +fi +if [ "$1" = "save" ]; then + printf 'archive of %s' "$4" > "$3" + exit 0 +fi +exit 1 +"# + ) + .unwrap(); + drop(file); + + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + + path + } + + #[cfg(unix)] + #[test] + fn save_images_writes_one_archive_per_image() { + let temp_dir = env_temp_dir("save-images"); + let engine = stub_engine(&temp_dir); + let out_dir = temp_dir.join("images"); + + let saved = save_images_with_engine( + engine.to_str().unwrap(), + &["alpine:3.19".to_string(), "myapp:1.0".to_string()], + &out_dir, + ) + .unwrap(); + + assert_eq!(saved.len(), 2); + for entry in &saved { + assert!(entry.path.exists()); + assert!(entry.size_bytes > 0); + assert!(entry.archive_name.starts_with(IMAGE_ARCHIVE_PREFIX)); + assert!(entry.archive_name.ends_with(IMAGE_ARCHIVE_EXTENSION)); + } + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(unix)] + #[test] + fn save_images_suffixes_colliding_archive_names() { + let temp_dir = env_temp_dir("colliding-images"); + let engine = stub_engine(&temp_dir); + let out_dir = temp_dir.join("images"); + + // Both references sanitize to the same archive name. + let saved = save_images_with_engine( + engine.to_str().unwrap(), + &["acme/app:1.0".to_string(), "acme:app-1.0".to_string()], + &out_dir, + ) + .unwrap(); + + assert_eq!( + saved[0].archive_name, + "corgea-image-scanning-acme-app-1.0.tar" + ); + assert_eq!( + saved[1].archive_name, + "corgea-image-scanning-acme-app-1.0-2.tar" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + + /// Tags may carry uppercase, and a case-insensitive filesystem would export + /// `myapp:V1` over `myapp:v1` — both images would then scan as one. + #[cfg(unix)] + #[test] + fn save_images_separates_references_differing_only_in_case() { + let temp_dir = env_temp_dir("case-collision"); + let engine = stub_engine(&temp_dir); + let out_dir = temp_dir.join("images"); + + let saved = save_images_with_engine( + engine.to_str().unwrap(), + &["myapp:v1".to_string(), "myapp:V1".to_string()], + &out_dir, + ) + .unwrap(); + + assert_eq!(saved[0].archive_name, "corgea-image-scanning-myapp-v1.tar"); + assert_eq!( + saved[1].archive_name, + "corgea-image-scanning-myapp-V1-2.tar" + ); + assert_ne!(saved[0].path, saved[1].path); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[test] + fn save_images_reports_a_failing_container_cli() { + let temp_dir = env_temp_dir("failing-engine"); + let out_dir = temp_dir.join("images"); + + let error = save_images_with_engine( + "corgea-nonexistent-container-cli", + &["alpine:3.19".to_string()], + &out_dir, + ) + .unwrap_err(); + + assert!(error.contains("corgea-nonexistent-container-cli")); + + let _ = fs::remove_dir_all(&temp_dir); + } + + fn env_temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "corgea-images-test-{}-{}", + name, + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } +} diff --git a/src/main.rs b/src/main.rs index 57cac78..964739b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod authorize; mod cicd; mod config; +mod images; mod inspect; mod list; mod log; @@ -161,6 +162,13 @@ enum Commands { help = "Generate a CycloneDX SBOM of the project after the scan completes, alongside any report. Optionally specify the output file. Defaults to bom.json." )] sbom: Option, + + #[arg( + long = "include-image", + value_name = "IMAGE:TAG", + help = "Scan a fully built container image (repeatable), e.g. --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest. Each image is exported with docker (or podman), pulled first if it isn't available locally, and uploaded with your project. Corgea scans the images you pass instead of searching your code for base images. Requires container scanning to be enabled for your account." + )] + include_image: Vec, }, /// Wait for the latest in progress scan Wait { @@ -639,6 +647,7 @@ fn main() { exclude, project_name, sbom, + include_image, }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { @@ -762,6 +771,19 @@ fn main() { std::process::exit(1); } + if !include_image.is_empty() && *scanner != Scanner::Blast { + ::log::error!("--include-image is only supported with the blast scanner."); + std::process::exit(1); + } + + let include_images = match images::normalize_image_refs(include_image) { + Ok(refs) => refs, + Err(e) => { + ::log::error!("{}", e); + std::process::exit(1); + } + }; + match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config, project_name.clone()), Scanner::Semgrep => scan::run_semgrep(&corgea_config, project_name.clone()), @@ -780,6 +802,7 @@ fn main() { exclude.clone(), project_name.clone(), sbom.clone(), + include_images, ), } } diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index dd7f0a0..4caa7f4 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::images; use crate::scan::build_scan_url; use crate::targets; use crate::utils; @@ -6,10 +7,10 @@ use crate::utils::api::SCAIssue; use std::collections::HashMap; use std::env; use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use uuid::Uuid; /// Overrides how long `wait_for_scan` polls before giving up. const SCAN_TIMEOUT_ENV: &str = "CORGEA_SCAN_TIMEOUT_SECONDS"; @@ -18,6 +19,33 @@ const DEFAULT_SCAN_TIMEOUT: Duration = Duration::from_secs(10 * 60 * 60); /// Overrides how long the CI gate waits for blocking rules to be evaluated. const BLOCKING_RULES_TIMEOUT_ENV: &str = "CORGEA_BLOCKING_RULES_TIMEOUT_SECONDS"; +/// Export every `--include-image` reference into the scan bundle. Corgea scans +/// the archives it finds there instead of resolving base images from the source +/// tree, so a fully built image is scanned as it ships. +fn export_included_images( + images: &[String], + temp_dir: &Path, +) -> Result, String> { + if images.is_empty() { + return Ok(Vec::new()); + } + + println!( + "Including {} container image(s) in this scan. Corgea will scan them instead of the base images referenced in your code.\n", + images.len() + ); + + let archives = images::save_images(images, &temp_dir.join("images"))?; + + println!("\nContainer images bundled with this scan:"); + for archive in &archives { + println!(" {}", archive.description()); + } + println!(); + + Ok(archives) +} + #[allow(clippy::too_many_arguments)] pub fn run( config: &Config, @@ -34,6 +62,7 @@ pub fn run( exclude: Option, project_name: Option, sbom: Option, + include_images: Vec, ) { // Validate that only_uncommitted and target are not used together if *only_uncommitted && target.is_some() { @@ -68,20 +97,32 @@ pub fn run( ); } println!("\n\n"); - let temp_dir = env::temp_dir().join(format!("corgea/tmp/{}", Uuid::new_v4())); - fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); - let project_name = utils::generic::determine_project_name(project_name.as_deref()); - let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); - match utils::generic::create_path_if_not_exists(&temp_dir) { - Ok(_) => (), + let temp_dir = match utils::generic::create_private_temp_dir("corgea-scan-") { + Ok(dir) => dir, Err(e) => { log::error!( - "\n\nOops! Something went wrong while creating the directory at '{}'.\nPlease check if you have the necessary permissions or if the path is valid.\nError details:\n{}\n\n", - temp_dir.display(), e + "\n\nOops! Something went wrong while creating a temporary directory in '{}'.\nPlease check if you have the necessary permissions and enough free disk space.\nError details:\n{}\n\n", + env::temp_dir().display(), + e ); std::process::exit(1); } - } + }; + let project_name = utils::generic::determine_project_name(project_name.as_deref()); + let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); + + let image_archives = match export_included_images(&include_images, &temp_dir) { + Ok(archives) => archives, + Err(message) => { + log::error!("\n\n{}\n", message); + let _ = utils::generic::delete_directory(&temp_dir); + std::process::exit(1); + } + }; + let extra_zip_files: Vec<(PathBuf, String)> = image_archives + .iter() + .map(|archive| (archive.path.clone(), archive.archive_name.clone())) + .collect(); let stop_signal = Arc::new(Mutex::new(false)); let stop_signal_clone = Arc::clone(&stop_signal); @@ -109,60 +150,76 @@ pub fn run( match targets::resolve_targets_with_exclude(target_value, exclude.as_deref()) { Ok(result) => { if result.files.is_empty() { - *stop_signal.lock().unwrap() = true; - let _ = packaging_thread.join(); - print!( - "\r{}", - utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) - ); - log::error!("\n\nError: target resolved to zero files.\n"); - log::error!("Target value: {}\n", target_value); - log::error!("Segment results:"); - for segment_result in &result.segments { - if let Some(ref error) = segment_result.error { - log::error!(" {}: ERROR - {}", segment_result.segment, error); - } else { - log::error!( - " {}: {} matches", - segment_result.segment, - segment_result.matches - ); + // An exported image is a complete payload on its own, so a + // target that matches nothing is only fatal without one. + if image_archives.is_empty() { + *stop_signal.lock().unwrap() = true; + let _ = packaging_thread.join(); + print!( + "\r{}", + utils::terminal::set_text_color( + "", + utils::terminal::TerminalColor::Reset + ) + ); + log::error!("\n\nError: target resolved to zero files.\n"); + log::error!("Target value: {}\n", target_value); + log::error!("Segment results:"); + for segment_result in &result.segments { + if let Some(ref error) = segment_result.error { + log::error!(" {}: ERROR - {}", segment_result.segment, error); + } else { + log::error!( + " {}: {} matches", + segment_result.segment, + segment_result.matches + ); + } } + log::error!("\nPlease check your target specification and try again.\n"); + let _ = utils::generic::delete_directory(&temp_dir); + std::process::exit(1); } - log::error!("\nPlease check your target specification and try again.\n"); - std::process::exit(1); - } - let file_count = result.files.len(); - if *only_uncommitted { - println!("\rFiles to be submitted for partial scan:\n"); - for (index, file) in result.files.iter().enumerate() { - if let Ok(relative) = - file.strip_prefix(std::env::current_dir().unwrap_or_default()) - { - println!("{}: {}", index + 1, relative.display()); - } else { - println!("{}: {}", index + 1, file.display()); - } - } - println!(); + log::warn!( + "\n{}", + utils::terminal::set_text_color( + "⚠️ No scannable files matched your target, so this scan covers only the included container image(s).", + utils::terminal::TerminalColor::Yellow + ) + ); } else { - println!("Scanning {} files (target mode)", file_count); - - let display_count = std::cmp::min(20, file_count); - for file in result.files.iter().take(display_count) { - if let Ok(relative) = - file.strip_prefix(std::env::current_dir().unwrap_or_default()) - { - println!(" {}", relative.display()); - } else { - println!(" {}", file.display()); + let file_count = result.files.len(); + if *only_uncommitted { + println!("\rFiles to be submitted for partial scan:\n"); + for (index, file) in result.files.iter().enumerate() { + if let Ok(relative) = + file.strip_prefix(std::env::current_dir().unwrap_or_default()) + { + println!("{}: {}", index + 1, relative.display()); + } else { + println!("{}: {}", index + 1, file.display()); + } } + println!(); + } else { + println!("Scanning {} files (target mode)", file_count); + + let display_count = std::cmp::min(20, file_count); + for file in result.files.iter().take(display_count) { + if let Ok(relative) = + file.strip_prefix(std::env::current_dir().unwrap_or_default()) + { + println!(" {}", relative.display()); + } else { + println!(" {}", file.display()); + } + } + if file_count > display_count { + println!(" (+{} more)", file_count - display_count); + } + println!(); } - if file_count > display_count { - println!(" (+{} more)", file_count - display_count); - } - println!(); } } Err(e) => { @@ -173,12 +230,19 @@ pub fn run( utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset) ); log::error!("\n\nError resolving targets: {}\n", e); + let _ = utils::generic::delete_directory(&temp_dir); std::process::exit(1); } } } - match utils::generic::create_zip_from_target(target_str, &zip_path, None, exclude.as_deref()) { + match utils::generic::create_zip_from_target( + target_str, + &zip_path, + None, + exclude.as_deref(), + &extra_zip_files, + ) { Ok(added_files) => { if added_files.is_empty() { *stop_signal.lock().unwrap() = true; @@ -194,6 +258,7 @@ pub fn run( } else { log::error!("\n\nOops! No valid files found to scan after filtering.\n\n"); } + let _ = utils::generic::delete_directory(&temp_dir); std::process::exit(1); } } @@ -208,6 +273,7 @@ pub fn run( "\n\nUh-oh! We couldn't package your project at '{}'.\nThis might be due to insufficient permissions, invalid file paths, or a file system error.\nPlease check the directory and try again.\nError details:\n{}\n\n", zip_path, e ); + let _ = utils::generic::delete_directory(&temp_dir); std::process::exit(1); } } @@ -270,6 +336,8 @@ pub fn run( config.get_url(), e ); + // Exported image archives can be gigabytes; don't leave them behind. + let _ = utils::generic::delete_directory(&temp_dir); std::process::exit(1); } }; diff --git a/src/utils/generic.rs b/src/utils/generic.rs index f9300fe..82ab212 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -29,6 +29,11 @@ const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ "**/.vs/**", "**/.vscode/**", "**/.idea/**", + // A copy of an exported image archive that lives in the repository would put + // the backend into image-scanning mode on every scan, whether or not + // `--include-image` was passed. Only the archives this run staged (passed as + // `extra_files`) are meant to do that. + "**/corgea-image-scanning-*.tar", ]; /// Create a zip file from a target specification or full repository scan. @@ -37,11 +42,15 @@ const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ /// - If `target` is `Some(target_str)`, resolves the target using the targets module and creates zip from those files. /// The target string can be a comma-separated list of files, directories, globs, or git selectors. /// - `user_exclude` is an optional comma-separated list of glob patterns from `--exclude`. +/// - `extra_files` are staged files added to the root of the zip as +/// `(source path, zip entry name)`. They come from explicit flags such as +/// `--include-image`, so exclude rules don't apply to them. pub fn create_zip_from_target>( target: Option<&str>, output_zip: P, exclude_globs: Option<&[&str]>, user_exclude: Option<&str>, + extra_files: &[(PathBuf, String)], ) -> Result, Box> { let exclude_globs = exclude_globs.unwrap_or(DEFAULT_EXCLUDE_GLOBS); @@ -126,6 +135,17 @@ pub fn create_zip_from_target>( } } + // Exported container images routinely pass 4 GiB, which a zip entry can only + // hold with ZIP64 headers; without `large_file` the writer aborts the entry. + let large_file_options: FileOptions<()> = options.large_file(true); + + for (path, entry_name) in extra_files { + zip.start_file(entry_name.as_str(), large_file_options)?; + let mut file = File::open(path)?; + io::copy(&mut file, &mut zip)?; + added_files.push(path.clone()); + } + // Print warnings for excluded files if !excluded_files.is_empty() { log::warn!( @@ -156,6 +176,30 @@ pub fn create_zip_from_target>( Ok(added_files) } +/// Create a staging directory under the system temp directory, readable only by +/// its owner. +/// +/// Staging holds the project zip and any exported container images, so other users +/// on a shared host must not be able to read it. `tempfile` creates directories +/// with default permissions — world-readable under a 0022 umask — so owner-only is +/// requested explicitly. The random name matters too: a fixed parent such as +/// `/tmp/corgea` can be pre-created, or pointed elsewhere by a symlink, by another +/// user first. +pub fn create_private_temp_dir(prefix: &str) -> io::Result { + let mut builder = tempfile::Builder::new(); + builder.prefix(prefix); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(fs::Permissions::from_mode(0o700)); + } + + // Keep the path rather than the guard: callers end the process through + // `std::process::exit`, which skips destructors, so cleanup stays explicit. + Ok(builder.tempdir()?.keep()) +} + pub fn create_path_if_not_exists>(path: P) -> io::Result<()> { let path = path.as_ref(); if !path.exists() { @@ -791,7 +835,7 @@ mod tests { // which would exclude *everything*. The filter + warn path under test // is identical either way. let excludes: &[&str] = &["**/node_modules/**"]; - let added = create_zip_from_target(Some(&target), &output_zip, Some(excludes), None) + let added = create_zip_from_target(Some(&target), &output_zip, Some(excludes), None, &[]) .expect("zip creation should succeed"); assert!( @@ -806,6 +850,89 @@ mod tests { ); } + /// The staging directory holds the project zip and exported images, so other + /// local users must not be able to read it. + #[cfg(unix)] + #[test] + fn create_private_temp_dir_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = create_private_temp_dir("corgea-test-").expect("create staging dir"); + let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + + assert_eq!(mode, 0o700, "staging dir must be owner-only, got {mode:o}"); + let _ = delete_directory(&dir); + } + + #[test] + fn create_zip_from_target_adds_extra_files_at_the_zip_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let source = root.join("main.py"); + fs::write(&source, "print(1)").unwrap(); + + let staged = root.join("staged").join("image.tar"); + fs::create_dir_all(staged.parent().unwrap()).unwrap(); + fs::write(&staged, "image archive").unwrap(); + + let output_zip = root.join("out.zip"); + let entry_name = "corgea-image-scanning-myapp-1.0.tar".to_string(); + let extra_files = vec![(staged.clone(), entry_name.clone())]; + let added = create_zip_from_target( + Some(&source.display().to_string()), + &output_zip, + Some(&[]), + None, + &extra_files, + ) + .expect("zip creation should succeed"); + + assert!(added.contains(&staged), "staged archive should be added"); + + let mut archive = zip::ZipArchive::new(File::open(&output_zip).unwrap()).unwrap(); + let names: Vec = (0..archive.len()) + .map(|i| archive.by_index(i).unwrap().name().to_string()) + .collect(); + assert!(names.contains(&entry_name), "zip entries: {:?}", names); + } + + /// Exported images pass 4 GiB routinely, which a zip entry can only hold with + /// ZIP64 headers. Reads a sparse 4 GiB file, so it is too slow for every run: + /// `cargo test -- --ignored zip64`. + #[test] + #[ignore = "slow: writes and reads a sparse 4 GiB file"] + fn create_zip_from_target_writes_extras_larger_than_four_gib() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let source = root.join("main.py"); + fs::write(&source, "print(1)").unwrap(); + + // Sparse: set_len reserves the length without writing 4 GiB of blocks. + let staged = root.join("huge.tar"); + File::create(&staged) + .unwrap() + .set_len(4 * 1024 * 1024 * 1024 + 1) + .unwrap(); + + let output_zip = root.join("out.zip"); + let extra_files = vec![( + staged.clone(), + "corgea-image-scanning-huge-1.0.tar".to_string(), + )]; + let added = create_zip_from_target( + Some(&source.display().to_string()), + &output_zip, + Some(&[]), + None, + &extra_files, + ) + .expect("a >4 GiB entry needs ZIP64, not an error"); + + assert!(added.contains(&staged)); + } + #[test] fn default_exclude_globs_match_abs_tmp_but_not_repo_relative_paths() { // Abs `/tmp/...` hits `**/tmp/**`; repo-relative paths must not. diff --git a/tests/cli_scan_include_image.rs b/tests/cli_scan_include_image.rs new file mode 100644 index 0000000..5073fa1 --- /dev/null +++ b/tests/cli_scan_include_image.rs @@ -0,0 +1,310 @@ +//! End-to-end coverage for `corgea scan --include-image`: drives the real +//! binary through the blast scan flow against a stubbed HTTP server with a +//! stubbed container CLI, and asserts the exported image archive is bundled +//! into the uploaded project zip under the name the backend greps for +//! (`corgea-image-scanning-*.tar`). + +mod common; + +use common::{corgea_isolated, write_script}; +use std::fs; +use std::io::Write; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; + +/// Raw bodies of the chunk uploads the CLI sent. +type Uploads = Arc>>>; + +/// Stub container CLI: reports every image as available locally and writes the +/// archive that `save -o ` asks for. +const STUB_ENGINE: &str = r#"#!/bin/sh +if [ "$1" = "image" ]; then + exit 0 +fi +if [ "$1" = "save" ]; then + printf 'archive of %s' "$4" > "$3" + exit 0 +fi +exit 1 +"#; + +/// Stub container CLI that has nothing and can't pull. +const BROKEN_ENGINE: &str = "#!/bin/sh\necho 'no such image' 1>&2\nexit 1\n"; + +/// The blast scan route table (verify -> upload -> poll -> issues), with the +/// upload chunk bodies captured so a test can inspect what was bundled. +fn spawn_recording_scan_stub(scan_id: &'static str) -> (String, Uploads) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub"); + let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let uploads: Uploads = Default::default(); + let recorder = Arc::clone(&uploads); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let request = corgea::vuln_api_stub::read_http_request(&mut stream); + let request_line = String::from_utf8_lossy(&request[..request.len().min(1024)]) + .lines() + .next() + .unwrap_or("") + .to_string(); + let target = request_line.split_whitespace().nth(1).unwrap_or(""); + let path = target.split('?').next().unwrap_or(target); + + let (status, body) = if path == "/api/v1/verify" { + ("200 OK", r#"{"status":"ok"}"#.to_string()) + } else if path == "/api/v1/start-scan" { + ("200 OK", r#"{"transfer_id":"transfer-1"}"#.to_string()) + } else if path == "/api/v1/start-scan/transfer-1/" { + recorder.lock().unwrap().push(request.clone()); + ( + "200 OK", + format!(r#"{{"scan_id":"{}","project_id":"1"}}"#, scan_id), + ) + } else if path == format!("/api/v1/scan/{}", scan_id) { + ( + "200 OK", + format!( + r#"{{"id":"{}","project":"proj","repo":null,"branch":null,"status":"complete","engine":"blast","created_at":"2026-01-01T00:00:00Z"}}"#, + scan_id + ), + ) + } else if path == format!("/api/v1/scan/{}/issues", scan_id) { + ( + "200 OK", + r#"{"status":"ok","issues":[],"page":1,"total_pages":1,"total_issues":0}"# + .to_string(), + ) + } else { + ("404 Not Found", r#"{"message":"not found"}"#.to_string()) + }; + + let response = corgea::vuln_api_stub::http_response(status, "", &body); + let _ = stream.write_all(response.as_bytes()); + } + }); + + (base_url, uploads) +} + +/// Everything the CLI uploaded, as lossy text. Zip entry names are stored +/// verbatim in each local file header, so searching for an archive name here +/// proves it was bundled. +fn uploaded_text(uploads: &Uploads) -> String { + let uploads = uploads.lock().expect("upload log"); + assert!(!uploads.is_empty(), "no chunk upload was recorded"); + uploads + .iter() + .map(|chunk| String::from_utf8_lossy(chunk).into_owned()) + .collect() +} + +fn stub_project() -> TempDir { + let project = TempDir::new().expect("project dir"); + fs::write(project.path().join("main.py"), "print(1)\n").expect("write source file"); + project +} + +/// Commit everything in `dir` so the working tree is clean, which is what makes +/// `--only-uncommitted` resolve to zero files. +fn commit_everything(dir: &std::path::Path) { + let repo = git2::Repository::init(dir).expect("git init"); + let mut index = repo.index().expect("index"); + index + .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None) + .expect("stage files"); + index.write().expect("write index"); + let tree = repo + .find_tree(index.write_tree().expect("write tree")) + .expect("find tree"); + let signature = git2::Signature::now("Corgea Test", "test@corgea.app").expect("signature"); + repo.commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[]) + .expect("commit"); +} + +#[cfg(unix)] +#[test] +fn scan_include_image_bundles_every_exported_archive() { + let (base_url, uploads) = spawn_recording_scan_stub("scan-images"); + let project = stub_project(); + let bin = TempDir::new().expect("engine dir"); + write_script(bin.path(), "stub-engine", STUB_ENGINE); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .env("CORGEA_CONTAINER_ENGINE", bin.path().join("stub-engine")) + .args([ + "scan", + "--include-image", + "myapp:1.0", + "--include-image", + "ghcr.io/acme/api:2.0", + ]); + + let output = cmd.output().expect("run corgea scan --include-image"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + stdout.contains("corgea-image-scanning-myapp-1.0.tar") + && stdout.contains("corgea-image-scanning-ghcr.io-acme-api-2.0.tar"), + "scan output should name both archives, got:\n{stdout}" + ); + + let uploaded = uploaded_text(&uploads); + for archive in [ + "corgea-image-scanning-myapp-1.0.tar", + "corgea-image-scanning-ghcr.io-acme-api-2.0.tar", + ] { + assert!( + uploaded.contains(archive), + "uploaded zip should contain {archive}" + ); + } +} + +/// An explicit image is a complete scan payload: a clean working tree makes +/// `--only-uncommitted` resolve to zero files, and the scan must still upload the +/// exported archive instead of failing on the empty target. +#[cfg(unix)] +#[test] +fn scan_only_uncommitted_with_include_image_uploads_the_archive() { + let (base_url, uploads) = spawn_recording_scan_stub("scan-clean-tree"); + let project = stub_project(); + commit_everything(project.path()); + let bin = TempDir::new().expect("engine dir"); + write_script(bin.path(), "stub-engine", STUB_ENGINE); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .env("CORGEA_CONTAINER_ENGINE", bin.path().join("stub-engine")) + .args(["scan", "--only-uncommitted", "--include-image", "myapp:1.0"]); + + let output = cmd.output().expect("run corgea scan --only-uncommitted"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "image-only scan should succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("only the included container image"), + "should say the scan covers only the image, got:\n{stderr}" + ); + assert!(uploaded_text(&uploads).contains("corgea-image-scanning-myapp-1.0.tar")); +} + +/// A copy of an archive living in the repository must not ride along: it would put +/// the backend into image-scanning mode on scans that never asked for it. +#[test] +fn scan_excludes_an_archive_checked_into_the_project() { + let (base_url, uploads) = spawn_recording_scan_stub("scan-stale-archive"); + let project = stub_project(); + fs::write( + project.path().join("corgea-image-scanning-stale-1.0.tar"), + "a stale export somebody committed", + ) + .expect("write stale archive"); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .args(["scan"]); + + let output = cmd.output().expect("run corgea scan"); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let uploaded = uploaded_text(&uploads); + assert!( + !uploaded.contains("corgea-image-scanning-stale-1.0.tar"), + "a checked-in archive should not be bundled" + ); + assert!(uploaded.contains("main.py"), "source files still upload"); +} + +#[cfg(unix)] +#[test] +fn scan_without_include_image_bundles_no_archive() { + let (base_url, uploads) = spawn_recording_scan_stub("scan-noimages"); + let project = stub_project(); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .args(["scan"]); + + let output = cmd.output().expect("run corgea scan"); + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!(!uploaded_text(&uploads).contains("corgea-image-scanning-")); +} + +#[cfg(unix)] +#[test] +fn scan_include_image_fails_before_uploading_when_the_image_is_unavailable() { + let (base_url, uploads) = spawn_recording_scan_stub("scan-missing-image"); + let project = stub_project(); + let bin = TempDir::new().expect("engine dir"); + write_script(bin.path(), "broken-engine", BROKEN_ENGINE); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .env("CORGEA_CONTAINER_ENGINE", bin.path().join("broken-engine")) + .args(["scan", "--include-image", "myapp:1.0"]); + + let output = cmd.output().expect("run corgea scan --include-image"); + assert_eq!( + output.status.code(), + Some(1), + "clean exit 1, not a panic (101)" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("myapp:1.0"), + "stderr should name the image, got:\n{stderr}" + ); + assert!( + uploads.lock().unwrap().is_empty(), + "nothing should be uploaded when the image can't be exported" + ); +} + +#[test] +fn scan_include_image_rejects_an_unusable_reference() { + let (base_url, _uploads) = spawn_recording_scan_stub("scan-bad-ref"); + let project = stub_project(); + + let (mut cmd, _home) = corgea_isolated(); + cmd.current_dir(project.path()) + .env("CORGEA_URL", &base_url) + .env("CORGEA_TOKEN", "test-token") + .args(["scan", "--include-image", " "]); + + let output = cmd.output().expect("run corgea scan --include-image"); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("--include-image")); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8b144a1..b807a55 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -37,6 +37,7 @@ pub fn corgea_isolated() -> (Command, TempDir) { .env_remove("CORGEA_PYPI_REGISTRY") .env_remove("CORGEA_VULN_API_URL") .env_remove("CORGEA_VULN_API_SEND_TOKEN_TO_CUSTOM_URL") + .env_remove("CORGEA_CONTAINER_ENGINE") .env_remove("AI_AGENT") .env_remove("CODEX_SANDBOX") .env_remove("CLAUDECODE")