WIP: Adding support for scanning Docker images

This commit is contained in:
Mick Grove 2025-07-27 12:20:20 -07:00
commit 9a3fabdbf2
16 changed files with 137 additions and 12 deletions

View file

@ -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

View file

@ -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"

View file

@ -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._`)\*

View file

@ -27,4 +27,5 @@ rules:
- type: StatusMatch
status: [200]
- type: WordMatch
words: ['"uuid"', '"user"']
words: ['"uuid"', '"user"']

View file

@ -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<String>,
/// Select how to clone Git repositories
#[arg(long, default_value_t=GitCloneMode::Bare, alias="git-clone-mode")]
pub git_clone: GitCloneMode,

View file

@ -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,

View file

@ -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,

View file

@ -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,

77
src/scanner/docker.rs Normal file
View file

@ -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<Vec<PathBuf>> {
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();
}
}

View file

@ -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;

View file

@ -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");
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,