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
7 changes: 6 additions & 1 deletion skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ corgea scan --target "src/**/*.py" # Glob patterns
corgea scan --target git:diff=origin/main...HEAD # Git diff range
corgea scan --target git:staged,git:modified # Git selectors
corgea scan --target - # File list from stdin
corgea scan --exclude "tests/**,*.md" # Exclude glob patterns (comma-separated)
corgea scan --include src/myProj/MyClass.java # Force a file in that Corgea would skip as vendored
corgea scan --include 'vendor/our-fork/**' --include generated/ # Repeatable; path, directory or glob
corgea scan --scan-type secrets # Single scan type
corgea scan --scan-type blast,policy,secrets,pii # Multiple scan types
corgea scan --scan-type policy --policy 1 # Specific policy ID
Expand Down Expand Up @@ -80,13 +83,15 @@ Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii

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.

`--include` forces files into the scan that Corgea would otherwise skip because it classified them as vendored, third-party, generated or test code. It overrides the CLI's own packaging filters, `.gitignore`, `--exclude`, and the engine's classification, and force-included files are analyzed on every run — including incremental ones, where an unchanged file would normally have its previous findings carried forward. Use it when proprietary code lives somewhere Corgea assumes dependencies live, e.g. a fork checked into `vendor/`. Project-level include rules configured in the web app (scan settings → File Include Rules) are fetched before packaging and applied too; `--include` adds to them for one run.

`--only-uncommitted` and `--target` are mutually exclusive. `--fail-on`, `--fail`, and `--block-on` are mutually exclusive.

`--out-format`/`--out-file` and `--sbom` are honored regardless of the gate: the report and the SBOM are written before `--fail`/`--block-on` are evaluated, so a scan that exits 1 on a blocking rule still leaves the report file behind for the pipeline to ingest.

`--skip-if-commit-scanned-recently` reuses the project's most recent reusable scan of the current commit instead of starting a duplicate, when one ran inside the `--scanned-within` window (default `24h`; accepts `90s`, `30m`, `4h`, `7d`, and a bare number as hours). The reused scan takes the new scan's place for the rest of the command — results table, `--block-on` gate and its exit code, `--out-file` report — so the pipeline behaves the same either way. It prints `CORGEA_SCAN_SKIPPED=true` plus `CORGEA_SCAN_ID=<id>` on a reuse and `CORGEA_SCAN_SKIPPED=false` when a scan runs, so a later step can branch on it.

Reuse requires a candidate that answers the same question: a completed `corgea-blast` scan of that commit, on a branch rather than a pull request, from an explicitly clean worktree, reporting no scanner problems. Anything else runs a real scan (nothing in the window, a failed or still-running scan, a worktree `git status` reports changes in, or a failed lookup). `--ignore-dirty-worktree` (requires `--skip-if-commit-scanned-recently`) overrides the dirty-worktree half of that test: reuse proceeds even if this worktree is dirty or the prior scan recorded `worktree_dirty=true`. A prior scan that never reported the flag is still not reused. A new scan still reports the real dirty status. An unresolvable commit is a hard error (exit 1). Because the API exposes neither a scan's configured scan types and target policies nor whether it bundled a container image, a run that changes what gets scanned cannot be matched against a candidate, so the flag cannot be combined with `--scan-type`, `--policy`, `--include-image`, `--only-uncommitted`, or `--target`. `--exclude` is allowed but warns on a skip: what gets reused is a scan of the whole commit, so the results and the gate can cover files the run would have skipped (over-reporting, never under-reporting).
Reuse requires a candidate that answers the same question: a completed `corgea-blast` scan of that commit, on a branch rather than a pull request, from an explicitly clean worktree, reporting no scanner problems. Anything else runs a real scan (nothing in the window, a failed or still-running scan, a worktree `git status` reports changes in, or a failed lookup). `--ignore-dirty-worktree` (requires `--skip-if-commit-scanned-recently`) overrides the dirty-worktree half of that test: reuse proceeds even if this worktree is dirty or the prior scan recorded `worktree_dirty=true`. A prior scan that never reported the flag is still not reused. A new scan still reports the real dirty status. An unresolvable commit is a hard error (exit 1). Because the API exposes neither a scan's configured scan types and target policies nor whether it bundled a container image, a run that changes what gets scanned cannot be matched against a candidate, so the flag cannot be combined with `--scan-type`, `--policy`, `--include-image`, `--include`, `--only-uncommitted`, or `--target`. `--exclude` is allowed but warns on a skip: what gets reused is a scan of the whole commit, so the results and the gate can cover files the run would have skipped (over-reporting, never under-reporting).

### Upload — `corgea upload [report]`

Expand Down
228 changes: 228 additions & 0 deletions src/include_rules.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! Force-include rules: files Corgea must scan even though it would skip them.
//!
//! Corgea leaves out vendored, third-party, test and generated code in two
//! places: this CLI's packaging filters (`DEFAULT_EXCLUDE_GLOBS`, `.gitignore`)
//! and the engine's own classification of what it extracted. When either gets
//! that call wrong for proprietary code, an include rule overrides it.
//!
//! Rules come from two places and are unioned: the project's rules on the
//! platform, fetched here before packaging, and `--include` on this command
//! line. Both matter locally — a file the packager leaves out of the zip cannot
//! be scanned whatever the engine later decides — and only the flag values
//! travel with the upload, since the platform already knows its own rules.

use crate::config::Config;
use crate::utils::api;
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use std::path::{Path, PathBuf};

/// Ceiling on files one run may force into the archive. A rule like `**/*.js`
/// would otherwise pull an entire `node_modules` tree into the upload.
const MAX_FORCE_INCLUDED_FILES: usize = 5_000;

/// The force-include rules in effect for one scan.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct IncludeRules {
/// Every pattern in effect: the project's rules plus `--include`.
pub patterns: Vec<String>,
/// Just the `--include` values, the ones the server does not know yet.
pub cli_patterns: Vec<String>,
}

impl IncludeRules {
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}

/// Paths under `root` that the rules match, relative to `root`.
///
/// Walks with the standard ignore filters off, since the point is to reach
/// files `.gitignore` and the default excludes hide. `.git` is still
/// skipped: it holds no source and its object store is large.
pub fn matching_files(&self, root: &Path) -> Vec<PathBuf> {
let Some(glob_set) = build_glob_set(&self.patterns) else {
return Vec::new();
};
let mut matches = Vec::new();
let walker = WalkBuilder::new(root)
.standard_filters(false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

turning off standard filters means .gitignore no longer keeps things like .env, *.pem or key files out of the upload

since project include_paths come straight from the server and are applied automatically, an over-broad rule could sweep secrets into the archive, maybe worth a guard or at least a denylist for sensitive paths?

.filter_entry(|entry| entry.file_name() != ".git")
.build();
for entry in walker.flatten() {
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discarded traversal errors can turn an explicit include into a no-match; could we propagate them or distinguish them from no matches?

let Ok(relative) = entry.path().strip_prefix(root) else {
continue;
};
if glob_set.is_match(relative) {
matches.push(relative.to_path_buf());
}
if matches.len() >= MAX_FORCE_INCLUDED_FILES {
log::warn!(
"Include rules matched more than {} files; only the first {} are forced into this scan.",
MAX_FORCE_INCLUDED_FILES,
MAX_FORCE_INCLUDED_FILES
);
break;
}
Comment on lines +48 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the 5,000-file cap uses walk order; could we sort matches before applying it so equivalent runs select the same paths?

}
matches.sort();
matches
}
}

/// Build a matcher, dropping patterns globset cannot compile.
///
/// One unparseable pattern must not discard the rest: the others are still a
/// clear instruction, and silently scanning less than asked is the failure this
/// whole feature exists to fix.
fn build_glob_set(patterns: &[String]) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
let mut usable = 0;
for pattern in patterns {
match Glob::new(pattern) {
Ok(glob) => {
builder.add(glob);
usable += 1;
}
Err(e) => log::warn!("Ignoring include rule '{pattern}': {e}"),
}
// A bare path or directory prefix should match what is under it, which
// is how the same pattern reads in the platform's ignore rules.
if !pattern.contains('*') {
let descendants = format!("{}/**", pattern.trim_end_matches('/'));
if let Ok(glob) = Glob::new(&descendants) {
builder.add(glob);
usable += 1;
}
}
}
if usable == 0 {
return None;
}
builder.build().ok()
}

/// Collect the rules for this run: the project's, plus `--include`.
///
/// A failed lookup is a warning, not a failure. It leaves the project's rules
/// unapplied for this run, which is the behavior every release before this one
/// had; refusing to scan would be worse.
pub fn resolve(
config: &Config,
project_name: &str,
repo_url: Option<&str>,
cli_include: &[String],
) -> IncludeRules {
let cli_patterns = normalize_patterns(cli_include);
let mut patterns = Vec::new();

match api::query_scan_settings(&config.get_url(), project_name, repo_url) {
Ok(Some(settings)) => {
for pattern in normalize_patterns(&settings.include_paths) {
push_unique(&mut patterns, pattern);
}
if !patterns.is_empty() {
println!(
"Applying {} project include rule(s) from Corgea: {}.",
patterns.len(),
patterns.join(", ")
);
}
}
// A backend without the endpoint has no include rules to apply either.
Ok(None) => {}
Err(e) => log::warn!(
"Could not read the project's include rules, so only --include applies to this run: {e}"
),
}

for pattern in &cli_patterns {
push_unique(&mut patterns, pattern.clone());
}
IncludeRules {
patterns,
cli_patterns,
}
Comment on lines +145 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid CLI globs are skipped locally but uploaded in cli_patterns; could we validate them consistently or reject them during argument handling?

}

fn normalize_patterns(patterns: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for pattern in patterns {
let trimmed = pattern.trim();
if !trimmed.is_empty() {
push_unique(&mut normalized, trimmed.to_string());
}
}
normalized
}

fn push_unique(patterns: &mut Vec<String>, pattern: String) {
if !patterns.contains(&pattern) {
patterns.push(pattern);
}
}

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

fn rules(patterns: &[&str]) -> IncludeRules {
IncludeRules {
patterns: patterns.iter().map(|p| p.to_string()).collect(),
cli_patterns: Vec::new(),
}
}

fn write(root: &TempDir, relative: &str) {
let path = root.path().join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, "class A {}\n").unwrap();
}

#[test]
fn normalize_trims_blanks_and_dedupes() {
let input = ["a/**".to_string(), " a/** ".to_string(), " ".to_string()];
assert_eq!(normalize_patterns(&input), vec!["a/**".to_string()]);
}

#[test]
fn no_patterns_matches_nothing() {
let root = TempDir::new().unwrap();
write(&root, "src/App.java");
assert!(IncludeRules::default()
.matching_files(root.path())
.is_empty());
assert!(build_glob_set(&[]).is_none());
}

#[test]
fn unparseable_pattern_does_not_discard_the_others() {
assert!(build_glob_set(&["src/**".to_string(), "[".to_string()]).is_some());
}

#[test]
fn matching_files_reaches_gitignored_and_vendored_paths() {
let root = TempDir::new().unwrap();
write(&root, "vendor/mylib/Payments.java");
write(&root, "vendor/other/Other.java");
write(&root, "node_modules/pkg/index.js");
write(&root, ".git/objects/blob");
fs::write(root.path().join(".gitignore"), "vendor/\nnode_modules/\n").unwrap();

let matched =
rules(&["vendor/mylib", "node_modules/pkg/index.js"]).matching_files(root.path());

assert_eq!(
matched,
vec![
PathBuf::from("node_modules/pkg/index.js"),
PathBuf::from("vendor/mylib/Payments.java"),
]
);
}
}
80 changes: 80 additions & 0 deletions src/incremental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,41 @@ pub struct IncrementalPlan {
pub covers_worktree: bool,
}

impl IncrementalPlan {
/// Add force-included files to what the server will analyze.
///
/// The server carries findings forward for every file the diff omits, so a
/// force-included file that has not changed would never be looked at — and
/// the reason to add an include rule is precisely that the file was never
/// scanned before, so there is nothing to carry forward. Returns `None`
/// when the combined list outgrows an incremental scan, which falls back to
/// scanning everything.
pub fn including(mut self, forced: &[String]) -> Option<Self> {
let mut listed: BTreeSet<String> = self.changed_files.iter().cloned().collect();
let additions: Vec<String> = forced
.iter()
.filter(|path| listed.insert((*path).clone()))
.cloned()
.collect();
if additions.is_empty() {
return Some(self);
}
if self.changed_files.len() + additions.len() > MAX_CHANGED_FILES {
explain_full_scan(
"the include rules cover more files than an incremental scan is worth",
);
return None;
}
match additions.len() {
1 => println!("Incremental scan: also analyzing 1 force-included file."),
count => println!("Incremental scan: also analyzing {count} force-included files."),
}
self.changed_files.extend(additions);
self.changed_files.sort();
Some(self)
}
}

/// What an incremental scan of this commit would cover, or `None` to scan
/// everything.
pub fn resolve_incremental_plan(
Expand Down Expand Up @@ -442,6 +477,51 @@ mod tests {
assert_eq!(short_sha("ααααααααα"), "ααααααα");
}

fn plan(changed: &[&str]) -> IncrementalPlan {
IncrementalPlan {
base_sha: "abc123".to_string(),
changed_files: changed.iter().map(|f| f.to_string()).collect(),
covers_worktree: false,
}
}

#[test]
fn including_adds_force_included_files_the_diff_left_out() {
let forced = vec![
"src/app.py".to_string(),
"vendor/mylib/Payments.java".to_string(),
];

let widened = plan(&["src/app.py"])
.including(&forced)
.expect("still worth it");

assert_eq!(
widened.changed_files,
vec![
"src/app.py".to_string(),
"vendor/mylib/Payments.java".to_string()
]
);
}

#[test]
fn including_nothing_new_leaves_the_plan_alone() {
let original = plan(&["src/app.py"]);
assert_eq!(
original.clone().including(&["src/app.py".to_string()]),
Some(original)
);
}

#[test]
fn including_too_many_files_falls_back_to_a_full_scan() {
let forced: Vec<String> = (0..=MAX_CHANGED_FILES)
.map(|i| format!("v/{i}.js"))
.collect();
assert_eq!(plan(&["src/app.py"]).including(&forced), None);
}

#[test]
fn a_completed_clean_blast_scan_is_a_baseline() {
assert!(is_usable_baseline(&scan("main", "abc")));
Expand Down
Loading
Loading