From 9a3fabdbf27c78795acc2bb87aa3a4f0a5cd2732 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Sun, 27 Jul 2025 12:20:20 -0700 Subject: [PATCH] WIP: Adding support for scanning Docker images --- CHANGELOG.md | 1 + Cargo.toml | 1 + README.md | 8 ++++ data/rules/buildkite.yml | 3 +- src/cli/commands/inputs.rs | 8 +++- src/main.rs | 4 +- src/reporter/json_format.rs | 4 +- src/reporter/pretty_format.rs | 4 +- src/scanner/docker.rs | 77 +++++++++++++++++++++++++++++++++++ src/scanner/mod.rs | 2 + src/scanner/runner.rs | 13 +++++- tests/int_dedup.rs | 4 +- tests/int_github.rs | 4 +- tests/int_gitlab.rs | 4 +- tests/int_validation_cache.rs | 4 +- tests/int_vulnerable_files.rs | 8 +++- 16 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 src/scanner/docker.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a23a7ae..5556a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [1.27.0] - Added Buildkite rule +- Added support for scanning Docker images via `--docker-image` ## [1.26.0] - Added rule for ElevenLabs diff --git a/Cargo.toml b/Cargo.toml index 28422bd..14e4cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,6 +186,7 @@ globset = "0.4.16" jsonwebtoken = "9.3.1" ipnet = "2.11.0" jira_query = "1.6.0" +oci-distribution = "0.11.0" [dependencies.tikv-jemallocator] version = "0.6" diff --git a/README.md b/README.md index 8d398fa..b14ee12 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,14 @@ kingfisher scan /path/to/repo --format sarif --output findings.sarif cat /path/to/file.py | kingfisher scan - ``` +### Scan a Docker image (without Docker installed) + +```bash +kingfisher scan --docker-image ubuntu:latest +``` + +### Sc + ### Scan using a rule _family_ with one flag _(prefix matching: `--rule kingfisher.aws` loads `kingfisher.aws._`)\* diff --git a/data/rules/buildkite.yml b/data/rules/buildkite.yml index 5405c59..3728e98 100644 --- a/data/rules/buildkite.yml +++ b/data/rules/buildkite.yml @@ -27,4 +27,5 @@ rules: - type: StatusMatch status: [200] - type: WordMatch - words: ['"uuid"', '"user"'] \ No newline at end of file + words: ['"uuid"', '"user"'] + \ No newline at end of file diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs index c7a59bc..f698d87 100644 --- a/src/cli/commands/inputs.rs +++ b/src/cli/commands/inputs.rs @@ -26,7 +26,8 @@ pub struct InputSpecifierArgs { "git_url", "all_github_organizations", "all_gitlab_groups", - "jira_url" + "jira_url", + "docker_image" ]), value_hint = ValueHint::AnyPath )] @@ -97,6 +98,11 @@ pub struct InputSpecifierArgs { #[arg(long, default_value_t = 100)] pub max_results: usize, + /// Docker/OCI images to scan (no local Docker required) + #[arg(long = "docker-image")] + pub docker_image: Vec, + + /// Select how to clone Git repositories #[arg(long, default_value_t=GitCloneMode::Bare, alias="git-clone-mode")] pub git_clone: GitCloneMode, diff --git a/src/main.rs b/src/main.rs index 3b0d444..a85fb48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -281,7 +281,9 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index 31123b6..4132def 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -428,7 +428,9 @@ mod tests { // Jira options jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/src/reporter/pretty_format.rs b/src/reporter/pretty_format.rs index 0b4a46c..5960538 100644 --- a/src/reporter/pretty_format.rs +++ b/src/reporter/pretty_format.rs @@ -344,7 +344,9 @@ fn test_pretty_format_with_nan_entropy_panics() { // Jira options jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/src/scanner/docker.rs b/src/scanner/docker.rs new file mode 100644 index 0000000..f636e91 --- /dev/null +++ b/src/scanner/docker.rs @@ -0,0 +1,77 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use oci_distribution::client::{linux_amd64_resolver, Client, ClientConfig}; +use oci_distribution::{secrets::RegistryAuth, Reference}; +use tracing::debug; + +use crate::decompress::decompress_file; + +pub struct Docker; + +impl Docker { + pub fn new() -> Self { + Docker + } + + pub async fn save_image_to_dir(&self, image: &str, out_dir: &Path) -> Result<()> { + let reference: Reference = + image.parse().with_context(|| format!("invalid image reference {image}"))?; + debug!("Pulling {image}"); + let mut client = Client::new(ClientConfig { + platform_resolver: Some(Box::new(linux_amd64_resolver)), + ..Default::default() + }); + let auth = RegistryAuth::Anonymous; + let accepted = vec![ + oci_distribution::manifest::IMAGE_LAYER_MEDIA_TYPE, + oci_distribution::manifest::IMAGE_LAYER_GZIP_MEDIA_TYPE, + oci_distribution::manifest::IMAGE_DOCKER_LAYER_TAR_MEDIA_TYPE, + oci_distribution::manifest::IMAGE_DOCKER_LAYER_GZIP_MEDIA_TYPE, + ]; + let image = client.pull(&reference, &auth, accepted).await?; + + std::fs::create_dir_all(out_dir)?; + for (idx, layer) in image.layers.into_iter().enumerate() { + let ext = match layer.media_type.as_str() { + oci_distribution::manifest::IMAGE_LAYER_GZIP_MEDIA_TYPE + | oci_distribution::manifest::IMAGE_DOCKER_LAYER_GZIP_MEDIA_TYPE => "tar.gz", + oci_distribution::manifest::IMAGE_LAYER_MEDIA_TYPE + | oci_distribution::manifest::IMAGE_DOCKER_LAYER_TAR_MEDIA_TYPE => "tar", + _ => "bin", + }; + let file_name = format!("layer_{idx}.{ext}"); + let tmp_path = out_dir.join(file_name); + let mut tmp = std::fs::File::create(&tmp_path)?; + tmp.write_all(&layer.data)?; + decompress_file(&tmp_path, Some(out_dir))?; + } + Ok(()) + } +} + +pub async fn save_docker_images(images: &[String], clone_root: &Path) -> Result> { + let docker = Docker::new(); + let mut dirs = Vec::new(); + for image in images { + let dir_name = image.replace(['/', ':'], "_"); + let out_dir = clone_root.join(format!("docker_{dir_name}")); + docker + .save_image_to_dir(image, &out_dir) + .await + .with_context(|| format!("saving image {image}"))?; + dirs.push(out_dir); + } + Ok(dirs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_struct_new() { + let _ = Docker::new(); + } +} \ No newline at end of file diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 0b4423f..fff9440 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -3,7 +3,9 @@ pub(crate) use enumerate::enumerate_filesystem_inputs; pub(crate) use repos::{clone_or_update_git_repos, enumerate_github_repos}; pub use runner::{load_and_record_rules, run_async_scan, run_scan}; pub(crate) use validation::run_secret_validation; +pub(crate) use docker::save_docker_images; +mod docker; mod enumerate; mod processing; mod repos; diff --git a/src/scanner/runner.rs b/src/scanner/runner.rs index 99ad466..568b498 100644 --- a/src/scanner/runner.rs +++ b/src/scanner/runner.rs @@ -19,7 +19,7 @@ use crate::{ scanner::{ clone_or_update_git_repos, enumerate_filesystem_inputs, enumerate_github_repos, repos::{enumerate_gitlab_repos, fetch_jira_issues}, - run_secret_validation, + run_secret_validation, save_docker_images, summary::print_scan_summary, }, }; @@ -68,6 +68,17 @@ pub async fn run_async_scan( let jira_dirs = fetch_jira_issues(args, global_args, &datastore).await?; input_roots.extend(jira_dirs); + // Save Docker images if specified + if !args.input_specifier_args.docker_image.is_empty() { + let clone_root = { + let ds = datastore.lock().unwrap(); + ds.clone_root() + }; + let docker_dirs = + save_docker_images(&args.input_specifier_args.docker_image, &clone_root).await?; + input_roots.extend(docker_dirs); + } + if input_roots.is_empty() { bail!("No inputs to scan"); } diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index 4c4975c..2763ebd 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -81,7 +81,9 @@ rules: jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_github.rs b/tests/int_github.rs index c8256c8..d4f7f25 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -68,7 +68,9 @@ fn test_github_remote_scan() -> Result<()> { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index 6ec6e19..67b1bc3 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -67,7 +67,9 @@ fn test_gitlab_remote_scan() -> Result<()> { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, scan_nested_repos: true, diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index 933c068..3e21947 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -124,7 +124,9 @@ async fn test_validation_cache_and_depvars() -> Result<()> { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index 0da7868..ad78192 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -67,7 +67,9 @@ impl TestContext { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full, @@ -135,7 +137,9 @@ impl TestContext { jira_url: None, jql: None, - max_results: 50, + max_results: 100, + // Docker image scanning + docker_image: Vec::new(), // git clone / history options git_clone: GitCloneMode::Bare, git_history: GitHistoryMode::Full,