Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "corgea"
version = "1.8.7"
version = "1.8.8"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
Expand Down Expand Up @@ -42,3 +42,6 @@ urlencoding = "2.1"

[target.'cfg(not(target_os = "windows"))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] }

[dev-dependencies]
tempfile = "3.12.0"
15 changes: 13 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ enum Commands {
)]
target: Option<String>,

#[arg(
long,
help = "Exclude files matching glob patterns from the scan. Accepts comma-separated glob patterns. Examples: 'tests/**', 'src/**/*.test.ts,**/*.spec.js', '*.md'."
)]
exclude: Option<String>,

#[arg(
long,
help = "The name of the Corgea project. Defaults to git repository name if found, otherwise to the current directory name."
Expand Down Expand Up @@ -259,7 +265,7 @@ fn main() {
}
}
}
Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file, target, project_name }) => {
Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file, target, exclude, project_name }) => {
verify_token_and_exit_when_fail(&corgea_config);
if let Some(level) = fail_on {
if *scanner != Scanner::Blast {
Expand Down Expand Up @@ -339,10 +345,15 @@ fn main() {
eprintln!("\nWarning: you didn't specify an only policy scan, so all other types of scans will run as well.");
}
}
if exclude.is_some() && *scanner != Scanner::Blast {
eprintln!("exclude is only supported with blast scanner.");
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()),
Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone(), target.clone(), project_name.clone())
Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone(), target.clone(), exclude.clone(), project_name.clone())
}
}
Some(Commands::Wait { scan_id }) => {
Expand Down
9 changes: 7 additions & 2 deletions src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub fn run(
out_format: Option<String>,
out_file: Option<String>,
target: Option<String>,
exclude: Option<String>,
project_name: Option<String>,
) {
// Validate that only_uncommitted and target are not used together
Expand Down Expand Up @@ -88,8 +89,12 @@ pub fn run(
target.as_deref()
};

if target_str.is_none() && exclude.is_some() {
println!("Excluding files matching: {}", exclude.as_deref().unwrap());
}

if let Some(target_value) = target_str {
match targets::resolve_targets(target_value) {
match targets::resolve_targets_with_exclude(target_value, exclude.as_deref()) {
Ok(result) => {
if result.files.is_empty() {
*stop_signal.lock().unwrap() = true;
Expand Down Expand Up @@ -147,7 +152,7 @@ pub fn run(
}
}

match utils::generic::create_zip_from_target(target_str, &zip_path, None) {
match utils::generic::create_zip_from_target(target_str, &zip_path, None, exclude.as_deref()) {
Ok(added_files) => {
if added_files.is_empty() {
*stop_signal.lock().unwrap() = true;
Expand Down
163 changes: 162 additions & 1 deletion src/targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub struct TargetSegmentResult {
pub error: Option<String>,
}

pub fn resolve_targets(target_value: &str) -> Result<TargetResolutionResult, String> {
pub fn resolve_targets_with_exclude(target_value: &str, exclude: Option<&str>) -> Result<TargetResolutionResult, String> {
let segments: Vec<String> = target_value
.split(',')
.map(|s| s.trim().to_string())
Expand All @@ -40,6 +40,8 @@ pub fn resolve_targets(target_value: &str) -> Result<TargetResolutionResult, Str
}
}

let exclude_glob_set = build_exclude_glob_set(exclude)?;

let mut all_files = Vec::new();
let mut seen_files = HashSet::new();
let mut segment_results = Vec::new();
Expand All @@ -58,6 +60,9 @@ pub fn resolve_targets(target_value: &str) -> Result<TargetResolutionResult, Str
for file in result {
match normalize_path(&file, &repo_root) {
Ok(normalized) => {
if is_excluded_by_glob(&normalized, &repo_root, &exclude_glob_set) {
continue;
}
if seen_files.insert(normalized.clone()) {
all_files.push(normalized);
}
Expand Down Expand Up @@ -100,6 +105,48 @@ pub fn resolve_targets(target_value: &str) -> Result<TargetResolutionResult, Str
})
}

fn build_exclude_glob_set(exclude: Option<&str>) -> Result<Option<globset::GlobSet>, String> {
let exclude_str = match exclude {
Some(s) if !s.trim().is_empty() => s,
_ => return Ok(None),
};

let patterns: Vec<&str> = exclude_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if patterns.is_empty() {
return Ok(None);
}

let mut builder = GlobSetBuilder::new();
for pattern in &patterns {
let glob = Glob::new(pattern)
.map_err(|e| format!("Invalid exclude glob pattern '{}': {}", pattern, e))?;
builder.add(glob);
}
let glob_set = builder.build()
.map_err(|e| format!("Failed to build exclude glob set: {}", e))?;
Ok(Some(glob_set))
}

fn is_excluded_by_glob(file: &Path, repo_root: &Path, exclude_glob_set: &Option<globset::GlobSet>) -> bool {
let glob_set = match exclude_glob_set {
Some(gs) => gs,
None => return false,
};

if let Ok(relative) = file.strip_prefix(repo_root) {
return glob_set.is_match(relative);
}
glob_set.is_match(file)
}

pub fn build_user_exclude_glob_set(exclude: Option<&str>) -> Result<Option<globset::GlobSet>, String> {
build_exclude_glob_set(exclude)
}

pub fn is_file_excluded(file: &Path, base_dir: &Path, exclude_glob_set: &Option<globset::GlobSet>) -> bool {
is_excluded_by_glob(file, base_dir, exclude_glob_set)
}

fn resolve_segment(segment: &str, repo_root: &Path) -> Result<Vec<PathBuf>, String> {
if segment == "-" {
return read_stdin_files(false);
Expand Down Expand Up @@ -479,3 +526,117 @@ fn is_git_repo(dir: &Path) -> bool {
Repository::discover(dir).is_ok()
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;

fn setup_test_dir() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();

Repository::init(base).unwrap();

fs::create_dir_all(base.join("src")).unwrap();
fs::create_dir_all(base.join("tests")).unwrap();
fs::create_dir_all(base.join("docs")).unwrap();

fs::write(base.join("src/main.rs"), "fn main() {}").unwrap();
fs::write(base.join("src/lib.rs"), "pub fn hello() {}").unwrap();
fs::write(base.join("tests/test_main.rs"), "// test").unwrap();
fs::write(base.join("docs/readme.md"), "# readme").unwrap();
fs::write(base.join("config.toml"), "[config]").unwrap();

dir
}

#[test]
fn build_exclude_glob_set_returns_none_for_none() {
let result = build_exclude_glob_set(None).unwrap();
assert!(result.is_none());
}

#[test]
fn build_exclude_glob_set_returns_none_for_empty() {
let result = build_exclude_glob_set(Some("")).unwrap();
assert!(result.is_none());
}

#[test]
fn build_exclude_glob_set_returns_some_for_valid_pattern() {
let result = build_exclude_glob_set(Some("tests/**")).unwrap();
assert!(result.is_some());
}

#[test]
fn build_exclude_glob_set_handles_comma_separated() {
let result = build_exclude_glob_set(Some("tests/**,docs/**")).unwrap();
assert!(result.is_some());
let gs = result.unwrap();
assert!(gs.is_match("tests/foo.rs"));
assert!(gs.is_match("docs/readme.md"));
assert!(!gs.is_match("src/main.rs"));
}

#[test]
fn build_exclude_glob_set_returns_error_for_invalid() {
let result = build_exclude_glob_set(Some("[invalid"));
assert!(result.is_err());
}

#[test]
fn is_excluded_by_glob_matches_relative_path() {
let gs = build_exclude_glob_set(Some("tests/**")).unwrap();
let repo_root = Path::new("/repo");
let file = Path::new("/repo/tests/test_main.rs");
assert!(is_excluded_by_glob(file, repo_root, &gs));
}

#[test]
fn is_excluded_by_glob_does_not_match_non_excluded() {
let gs = build_exclude_glob_set(Some("tests/**")).unwrap();
let repo_root = Path::new("/repo");
let file = Path::new("/repo/src/main.rs");
assert!(!is_excluded_by_glob(file, repo_root, &gs));
}

#[test]
fn is_excluded_by_glob_returns_false_for_none() {
let gs: Option<globset::GlobSet> = None;
let file = Path::new("/repo/tests/test_main.rs");
assert!(!is_excluded_by_glob(file, Path::new("/repo"), &gs));
}

#[test]
fn is_excluded_by_glob_wildcard_extension() {
let gs = build_exclude_glob_set(Some("**/*.md")).unwrap();
let repo_root = Path::new("/repo");
assert!(is_excluded_by_glob(Path::new("/repo/docs/readme.md"), repo_root, &gs));
assert!(!is_excluded_by_glob(Path::new("/repo/src/main.rs"), repo_root, &gs));
}

#[test]
fn is_excluded_filters_directory_files_correctly() {
let dir = setup_test_dir();
let base = dir.path();
let gs = build_exclude_glob_set(Some("tests/**,**/*.md")).unwrap();

assert!(!is_excluded_by_glob(&base.join("src/main.rs"), base, &gs));
assert!(!is_excluded_by_glob(&base.join("src/lib.rs"), base, &gs));
assert!(!is_excluded_by_glob(&base.join("config.toml"), base, &gs));
assert!(is_excluded_by_glob(&base.join("tests/test_main.rs"), base, &gs));
assert!(is_excluded_by_glob(&base.join("docs/readme.md"), base, &gs));
}

#[test]
fn is_excluded_with_none_includes_all() {
let dir = setup_test_dir();
let base = dir.path();
let gs: Option<globset::GlobSet> = None;

assert!(!is_excluded_by_glob(&base.join("src/main.rs"), base, &gs));
assert!(!is_excluded_by_glob(&base.join("tests/test_main.rs"), base, &gs));
assert!(!is_excluded_by_glob(&base.join("docs/readme.md"), base, &gs));
}
}

10 changes: 9 additions & 1 deletion src/utils/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[
/// - If `target` is `None`, performs a full repository scan (equivalent to scanning all files).
/// - 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`.
pub fn create_zip_from_target<P: AsRef<Path>>(
target: Option<&str>,
output_zip: P,
exclude_globs: Option<&[&str]>,
user_exclude: Option<&str>,
) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let exclude_globs = exclude_globs.unwrap_or(DEFAULT_EXCLUDE_GLOBS);

Expand All @@ -49,9 +51,12 @@ pub fn create_zip_from_target<P: AsRef<Path>>(
}
let glob_set = glob_builder.build()?;

let user_exclude_glob_set = crate::targets::build_user_exclude_glob_set(user_exclude)
.map_err(|e| format!("Failed to build exclude patterns: {}", e))?;

let files_to_zip: Vec<(PathBuf, PathBuf)> = if let Some(target_str) = target {
let current_dir = env::current_dir()?;
let result = crate::targets::resolve_targets(target_str)
let result = crate::targets::resolve_targets_with_exclude(target_str, user_exclude)
.map_err(|e| format!("Failed to resolve targets: {}", e))?;

result.files
Expand Down Expand Up @@ -81,6 +86,9 @@ pub fn create_zip_from_target<P: AsRef<Path>>(

if path.is_file() || path.is_dir() {
let relative_path = path.strip_prefix(directory)?;
if path.is_file() && crate::targets::is_file_excluded(&relative_path.to_path_buf(), Path::new(""), &user_exclude_glob_set) {
continue;
}
files.push((path.to_path_buf(), relative_path.to_path_buf()));
}
}
Expand Down
Loading