Skip to content
Merged
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.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "corgea"
version = "1.11.0"
version = "1.11.1"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
Expand Down
3 changes: 2 additions & 1 deletion skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ corgea scan --include-image myapp:1.2.3 --include-image ghcr.io/acme/api:latest
corgea scan --project-name my-service # Override project name
corgea scan --skip-if-commit-scanned-recently # Reuse a recent scan of this commit instead of scanning again
corgea scan --skip-if-commit-scanned-recently --scanned-within 4h # Window for "recently" (default 24h)
corgea scan --skip-if-commit-scanned-recently --ignore-dirty-worktree # Reuse even if this tree or the prior scan is dirty
```

Scan types: `blast` (base AI), `policy` (PolicyIQ), `malicious`, `secrets`, `pii`.
Expand All @@ -85,7 +86,7 @@ An included image is enough on its own: when it is combined with `--only-uncommi

`--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 that does not match the commit including files hidden from `git status`, or a failed lookup). 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 that does not match the commit including files hidden from `git status`, 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).

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

Expand Down
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ enum Commands {
help = "How recent a prior scan of the same commit must be for --skip-if-commit-scanned-recently to reuse it, e.g. 90s, 30m, 24h, 7d (a bare number means hours). Defaults to 24h, because unchanged code is still exposed to advisories published since it was last scanned."
)]
scanned_within: Option<String>,

#[arg(
long = "ignore-dirty-worktree",
requires = "skip_if_commit_scanned_recently",
help = "With --skip-if-commit-scanned-recently, reuse a recent scan of this commit even if this worktree is dirty or the prior scan recorded worktree_dirty. A new scan still reports the real dirty status."
)]
ignore_dirty_worktree: bool,
},
/// Wait for the latest in progress scan
Wait {
Expand Down Expand Up @@ -674,6 +681,7 @@ fn main() {
include_image,
skip_if_commit_scanned_recently,
scanned_within,
ignore_dirty_worktree,
}) => {
verify_token_and_exit_when_fail(&corgea_config);
if let Some(level) = fail_on {
Expand Down Expand Up @@ -849,6 +857,7 @@ fn main() {
sbom.clone(),
include_images,
skip_recent,
ignore_dirty_worktree,
),
}
}
Expand Down
9 changes: 8 additions & 1 deletion src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub fn run(
sbom: Option<String>,
include_images: Vec<String>,
skip_recent: Option<crate::skip_scan::SkipRecentScan>,
ignore_dirty_worktree: &bool,
) {
// Validate that only_uncommitted and target are not used together
if *only_uncommitted && target.is_some() {
Expand Down Expand Up @@ -93,7 +94,13 @@ pub fn run(
// the results table, the blocking-rule gate, the report file — runs against
// whichever scan id this resolves to.
let reused_scan = skip_recent.as_ref().and_then(|skip| {
crate::skip_scan::resolve_reusable_scan(config, &project_name, skip, exclude.as_deref())
crate::skip_scan::resolve_reusable_scan(
config,
&project_name,
skip,
exclude.as_deref(),
*ignore_dirty_worktree,
)
});

let (scan_id, project_id) = match reused_scan {
Expand Down
120 changes: 87 additions & 33 deletions src/skip_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
//!
//! Recency is a policy, not a technicality — the same commit scanned last week
//! predates whatever advisories landed since, so a scan is only reusable
//! inside the window (24h by default).
//! inside the window (24h by default). `--ignore-dirty-worktree` is the
//! explicit override for reuse only: a dirty current tree, or a prior scan
//! that recorded `worktree_dirty=true`, can still stand in for this commit.
//! A prior scan that never reported the flag (`None`) is still rejected —
//! unknown scope is not dirtiness. A new scan still reports the real dirty
//! status.
//!
//! One scan may only stand in for another when it answers the same question,
//! which is a stricter test than "same commit". Doghouse already settled what
Expand Down Expand Up @@ -107,14 +112,18 @@ pub fn parse_window(raw: &str) -> Result<Duration, String> {
///
/// Every `None` is a decision to do the more expensive, more correct thing, so
/// a lookup failure, an unreadable timestamp, or a dirty worktree all land
/// here rather than skipping a scan on incomplete information. The one hard
/// here rather than skipping a scan on incomplete information. `--ignore-dirty-worktree`
/// is the exception for known dirtiness: a dirty current tree, or a prior scan
/// that recorded `worktree_dirty=true`, can still be reused. `None` is still
/// rejected. The one hard
/// failure is an unresolvable commit: the flag asks a question about the
/// commit, and without one there is no question to answer.
pub fn resolve_reusable_scan(
config: &Config,
project_name: &str,
skip: &SkipRecentScan,
exclude: Option<&str>,
ignore_dirty_worktree: bool,
) -> Option<ScanResponse> {
// `dirty`, not `status_dirty`: this asks whether the run would upload an
// exact snapshot of the commit, and that is the flag the upload itself
Expand All @@ -135,20 +144,34 @@ pub fn resolve_reusable_scan(
let short = short_sha(&sha);

if worktree_dirty {
println!(
"Working tree does not match commit {} exactly (uncommitted changes, or files the index hides from git status), so no scan of that commit describes what would be scanned here - running a new scan.",
short
);
print_skipped_marker(None);
return None;
if ignore_dirty_worktree {
println!(
"Ignoring dirty worktree (--ignore-dirty-worktree); treating this as a scan of commit {}.",
short
);
} else {
println!(
"Working tree does not match commit {} exactly (uncommitted changes, or files the index hides from git status), so no scan of that commit describes what would be scanned here - running a new scan.",
short
);
print_skipped_marker(None);
return None;
}
}

println!(
"Checking Corgea for a scan of commit {} in project '{}' from the last {}...",
short, project_name, skip.label
);

let found = match find_reusable_scan(config, project_name, &sha, skip.window, Utc::now()) {
let found = match find_reusable_scan(
config,
project_name,
&sha,
skip.window,
Utc::now(),
ignore_dirty_worktree,
) {
Ok(found) => found,
Err(e) => {
log::warn!(
Expand Down Expand Up @@ -208,6 +231,7 @@ fn find_reusable_scan(
sha: &str,
window: Duration,
now: DateTime<Utc>,
ignore_dirty_worktree: bool,
) -> Result<Option<(ScanResponse, String)>, String> {
let mut page = 1;
loop {
Expand All @@ -223,7 +247,9 @@ fn find_reusable_scan(
if scans.is_empty() {
return Ok(None);
}
if let Some(reusable) = select_reusable_scan(&scans, sha, now, window) {
if let Some(reusable) =
select_reusable_scan(&scans, sha, now, window, ignore_dirty_worktree)
{
return Ok(Some((reusable.scan.clone(), reusable.age)));
}
// Newest first, so a page that ends outside the window is the end of the
Expand Down Expand Up @@ -296,9 +322,10 @@ pub fn select_reusable_scan<'a>(
sha: &str,
now: DateTime<Utc>,
window: Duration,
ignore_dirty_worktree: bool,
) -> Option<ReusableScan<'a>> {
for scan in scans {
match scan_age_if_reusable(scan, sha, now, window) {
match scan_age_if_reusable(scan, sha, now, window, ignore_dirty_worktree) {
Ok(age) => {
return Some(ReusableScan {
scan,
Expand All @@ -324,6 +351,7 @@ fn scan_age_if_reusable(
sha: &str,
now: DateTime<Utc>,
window: Duration,
ignore_dirty_worktree: bool,
) -> Result<Duration, String> {
if !scan_matches_commit(scan, sha) {
return match scan.git_sha.as_deref() {
Expand Down Expand Up @@ -351,11 +379,17 @@ fn scan_age_if_reusable(
// platform and scheduled scans do record `false`, and the scans that do not
// include the partial `--target`/`--exclude` uploads of older CLIs — which
// this run has no way to tell apart from whole-commit ones.
if scan.worktree_dirty != Some(false) {
return match scan.worktree_dirty {
Some(true) => Err("it scanned a worktree with uncommitted changes".to_string()),
_ => Err("it did not report whether its worktree was clean".to_string()),
};
// `--ignore-dirty-worktree` may reuse a known-dirty scan (`Some(true)`),
// but not `None`: unknown scope is not dirtiness.
match scan.worktree_dirty {
Some(false) => {}
Some(true) if ignore_dirty_worktree => {}
Some(true) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high: Dirty prior scans cannot safely represent the commit

Accepting worktree_dirty == Some(true) means the scan's uploaded source may differ arbitrarily from its recorded SHA. For example, a prior dirty worktree can delete a vulnerable file from commit C and produce clean results; a later invocation for C then reuses those results and allows --block-on to exit successfully without ever analyzing the vulnerable committed file. The dirty flag provides no proof that the differences were harmless. Keep prior candidates restricted to Some(false); ignoring the current worktree's dirtiness is a separate explicit decision.

Proof or reproduction:

let mut prior = scan("prior", "complete", Some(SHA), "2026-01-01T23:00:00Z");
prior.worktree_dirty = Some(true);
// The prior upload may omit vulnerable content present in SHA.
assert!(select_reusable_scan(&[prior], SHA, now(), DAY, true).is_none());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is expectected

return Err("it scanned a worktree with uncommitted changes".to_string());
}
None => {
return Err("it did not report whether its worktree was clean".to_string());
}
}
let created_at = parse_timestamp(&scan.created_at)
.ok_or_else(|| format!("its timestamp '{}' could not be read", scan.created_at))?;
Expand Down Expand Up @@ -485,7 +519,8 @@ mod tests {
scan("newer", "complete", Some(SHA), "2026-01-01T21:00:00Z"),
scan("older", "complete", Some(SHA), "2026-01-01T12:00:00Z"),
];
let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse");
let reusable =
select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse");
assert_eq!(reusable.scan.id, "newer");
assert_eq!(reusable.age, "3h 0m");
}
Expand All @@ -498,7 +533,8 @@ mod tests {
scan("failed", "incomplete", Some(SHA), "2026-01-01T23:00:00Z"),
scan("good", "complete", Some(SHA), "2026-01-01T22:00:00Z"),
];
let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse");
let reusable =
select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse");
assert_eq!(reusable.scan.id, "good");
}

Expand All @@ -507,7 +543,7 @@ mod tests {
for status in ["processing", "scanning", "incomplete", "failed", ""] {
let scans = vec![scan("s", status, Some(SHA), "2026-01-01T23:00:00Z")];
assert!(
select_reusable_scan(&scans, SHA, now(), DAY).is_none(),
select_reusable_scan(&scans, SHA, now(), DAY, false).is_none(),
"status {status} must not be reused"
);
}
Expand All @@ -527,7 +563,7 @@ mod tests {
),
scan("no-commit", "complete", None, "2026-01-01T23:00:00Z"),
];
assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none());
}

#[test]
Expand All @@ -538,37 +574,54 @@ mod tests {
Some(&SHA.to_uppercase()),
"2026-01-01T23:00:00Z",
)];
assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some());
assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_some());
}

#[test]
fn scans_outside_the_window_are_not_reused() {
// The point of the window: the code is unchanged, but the advisories
// it is scanned against are not.
let scans = vec![scan("stale", "complete", Some(SHA), "2025-12-30T00:00:00Z")];
assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none());
// A shorter window is what makes a scan from this morning stale.
let scans = vec![scan(
"morning",
"complete",
Some(SHA),
"2026-01-01T20:00:00Z",
)];
assert!(select_reusable_scan(&scans, SHA, now(), Duration::from_secs(3_600)).is_none());
assert!(
select_reusable_scan(&scans, SHA, now(), Duration::from_secs(3_600), false).is_none()
);
}

#[test]
fn a_scan_exactly_at_the_window_edge_is_still_reusable() {
let scans = vec![scan("edge", "complete", Some(SHA), "2026-01-01T00:00:00Z")];
assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_some());
assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_some());
}

#[test]
fn scans_of_a_dirty_worktree_are_not_reused() {
// Those results describe someone's uncommitted edits, not this commit.
let mut dirty = scan("dirty", "complete", Some(SHA), "2026-01-01T23:00:00Z");
dirty.worktree_dirty = Some(true);
assert!(select_reusable_scan(&[dirty], SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&[dirty], SHA, now(), DAY, false).is_none());
}

#[test]
fn ignore_dirty_worktree_reuses_a_dirty_prior_scan() {
let mut dirty = scan("dirty", "complete", Some(SHA), "2026-01-01T23:00:00Z");
dirty.worktree_dirty = Some(true);
assert!(select_reusable_scan(&[dirty], SHA, now(), DAY, true).is_some());
}

#[test]
fn ignore_dirty_worktree_still_rejects_a_scan_that_never_reported_dirtiness() {
// `None` is unknown scope (legacy / partial uploads), not known dirty.
let mut unknown = scan("unknown", "complete", Some(SHA), "2026-01-01T23:00:00Z");
unknown.worktree_dirty = None;
assert!(select_reusable_scan(&[unknown], SHA, now(), DAY, true).is_none());
}

#[test]
Expand All @@ -579,7 +632,7 @@ mod tests {
// indistinguishable from whole-commit ones from here.
let mut unknown = scan("unknown", "complete", Some(SHA), "2026-01-01T23:00:00Z");
unknown.worktree_dirty = None;
assert!(select_reusable_scan(&[unknown], SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&[unknown], SHA, now(), DAY, false).is_none());
}

#[test]
Expand All @@ -588,7 +641,7 @@ mod tests {
// to the diff, so it cannot stand in for a branch build of the commit.
let mut pr_scan = scan("pr", "complete", Some(SHA), "2026-01-01T23:00:00Z");
pr_scan.pull_request_id = Some("42".to_string());
assert!(select_reusable_scan(&[pr_scan], SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&[pr_scan], SHA, now(), DAY, false).is_none());
}

#[test]
Expand All @@ -597,17 +650,17 @@ mod tests {
// which is not what `corgea scan blast` was asked to produce.
let mut semgrep = scan("semgrep", "complete", Some(SHA), "2026-01-01T23:00:00Z");
semgrep.engine = "semgrep".to_string();
assert!(select_reusable_scan(&[semgrep], SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&[semgrep], SHA, now(), DAY, false).is_none());
// Every blast scan carries this engine, whoever started it.
let mut blast = scan("blast", "complete", Some(SHA), "2026-01-01T23:00:00Z");
blast.engine = BLAST_ENGINE.to_uppercase();
assert!(select_reusable_scan(&[blast], SHA, now(), DAY).is_some());
assert!(select_reusable_scan(&[blast], SHA, now(), DAY, false).is_some());
}

#[test]
fn unreadable_timestamps_do_not_skip_the_scan() {
let scans = vec![scan("bad-time", "complete", Some(SHA), "not a timestamp")];
assert!(select_reusable_scan(&scans, SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&scans, SHA, now(), DAY, false).is_none());
}

#[test]
Expand All @@ -622,7 +675,7 @@ mod tests {
] {
let scans = vec![scan("s", "complete", Some(SHA), raw)];
assert!(
select_reusable_scan(&scans, SHA, now(), DAY).is_some(),
select_reusable_scan(&scans, SHA, now(), DAY, false).is_some(),
"{raw} should parse"
);
}
Expand All @@ -638,13 +691,14 @@ mod tests {
Some(SHA),
"2026-01-02T01:00:00Z",
)];
let reusable = select_reusable_scan(&scans, SHA, now(), DAY).expect("expected a reuse");
let reusable =
select_reusable_scan(&scans, SHA, now(), DAY, false).expect("expected a reuse");
assert_eq!(reusable.age, "0s");
}

#[test]
fn empty_scan_list_reuses_nothing() {
assert!(select_reusable_scan(&[], SHA, now(), DAY).is_none());
assert!(select_reusable_scan(&[], SHA, now(), DAY, false).is_none());
}

#[test]
Expand Down
Loading
Loading