Add --skip-if-commit-scanned-recently to reuse a recent scan of the commit - #157
Add --skip-if-commit-scanned-recently to reuse a recent scan of the commit#157Ibrahimrahhal wants to merge 1 commit into
Conversation
| } | ||
|
|
||
| impl SkipRecentScan { | ||
| /// Build from the raw `--scanned-within` value, defaulting when absent. |
There was a problem hiding this comment.
🧹 Quality - The code comments mention the flag "--scanned-within", but module docs say "--skip-if-commit-scanned-recently". This inconsistency confuses what flag controls the behavior. View in Corgea ↗
More Details
🎟️Issue Explanation: The code comments mention the flag "--scanned-within", but module docs say "--skip-if-commit-scanned-recently". This inconsistency confuses what flag controls the behavior.
- Inconsistent flag names in "/// Build from the raw --scanned-within value" vs module docs cause unclear interface understanding.
- Developers may misinterpret or misuse flags, increasing onboarding time and errors in CLI interactions.
- Maintenance is harder as flag name mismatches require extra effort to track actual supported flags in code vs docs.
We could not generate a fix for this.
| } | ||
| } | ||
|
|
||
| /// Parse a `--scanned-within` value: `90s`, `30m`, `24h`, `7d`. A bare number |
There was a problem hiding this comment.
🧹 Quality - The code uses inconsistent flag names: module docs mention "--skip-if-commit-scanned-recently", but comments and errors use "--scanned-within". This mismatch confuses users and developers alike. View in Corgea ↗
More Details
🎟️Issue Explanation: The code uses inconsistent flag names: module docs mention "--skip-if-commit-scanned-recently", but comments and errors use "--scanned-within". This mismatch confuses users and developers alike.
- Inconsistent flag names in "--skip-if-commit-scanned-recently" docs vs "--scanned-within" comments make the CLI interface unclear.
- Developers maintaining or extending flag logic must guess which name is accurate, raising error risk and slowing updates.
- Users reading docs or errors might attempt unsupported flags due to conflicting descriptions, hurting usability and trust.
We could not generate a fix for this.
| /// Returns the new scan's id and, when the server reported one, its project id. | ||
| #[allow(clippy::too_many_arguments)] | ||
| fn start_new_scan( | ||
| config: &Config, | ||
| project_name: &str, | ||
| only_uncommitted: &bool, | ||
| metadata: Option<String>, | ||
| scan_type: Option<String>, | ||
| policy: Option<String>, | ||
| target: Option<String>, | ||
| exclude: Option<String>, | ||
| ) -> (String, Option<String>) { |
There was a problem hiding this comment.
🧹 Quality - The "start_new_scan" function has many parameters, including multiple optional flags like "target", "exclude", and "only_uncommitted". This makes the signature complex and hard to manage. View in Corgea ↗
More Details
🎟️Issue Explanation: The "start_new_scan" function has many parameters, including multiple optional flags like "target", "exclude", and "only_uncommitted". This makes the signature complex and hard to manage.
- Large parameter lists reduce code readability and make the function harder to use correctly, seen in "start_new_scan" with 8 parameters.
- Optional and related parameters increase complexity in managing function logic and increase the chance of errors.
- Maintenance becomes expensive as every new option requires changing the "start_new_scan" signature, impacting scalability and team velocity.
🪄Fix Explanation: Groups scan-related parameters into "StartNewScanOptions", reducing "start_new_scan" from many arguments to one cohesive options value. This removes the lint suppression and makes the API easier to read and extend.
<bullet_point>"StartNewScanOptions" groups related optional settings, making the scan configuration explicit and easier to understand. <bullet_point>The function now accepts only "config", "project_name", and "options", reducing call-site complexity and preventing argument-order mistakes. <bullet_point>Destructuring "options" at the function boundary keeps the existing implementation readable while preserving direct access to each setting. <bullet_point>Passing "only_uncommitted" as a "bool" avoids the unnecessary "&bool" reference and removes the need for "#[allow(clippy::too_many_arguments)]".</bullet_point>
💡Important Instructions: Update every caller of
start_new_scan to construct a StartNewScanOptions value, keeping option initialization grouped and consistently ordered.
| /// Returns the new scan's id and, when the server reported one, its project id. | |
| #[allow(clippy::too_many_arguments)] | |
| fn start_new_scan( | |
| config: &Config, | |
| project_name: &str, | |
| only_uncommitted: &bool, | |
| metadata: Option<String>, | |
| scan_type: Option<String>, | |
| policy: Option<String>, | |
| target: Option<String>, | |
| exclude: Option<String>, | |
| ) -> (String, Option<String>) { | |
| struct StartNewScanOptions { | |
| only_uncommitted: bool, | |
| metadata: Option<String>, | |
| scan_type: Option<String>, | |
| policy: Option<String>, | |
| target: Option<String>, | |
| exclude: Option<String>, | |
| } | |
| /// Returns the new scan's id and, when the server reported one, its project id. | |
| fn start_new_scan( | |
| config: &Config, | |
| project_name: &str, | |
| options: StartNewScanOptions, | |
| ) -> (String, Option<String>) { | |
| let StartNewScanOptions { | |
| only_uncommitted, | |
| metadata, | |
| scan_type, | |
| policy, | |
| target, | |
| exclude, | |
| } = options; |
| out_format: Option<String>, | ||
| out_file: Option<String>, | ||
| ) { | ||
| if let Some(out_file) = out_file { |
There was a problem hiding this comment.
🧹 Quality - The "write_scan_report" function silently skips output when only one of "out_file" or "out_format" is set, or when "out_format" is invalid, misleading users. This breaks expected control flow and UX. View in Corgea ↗
More Details
🎟️Issue Explanation: The "write_scan_report" function silently skips output when only one of "out_file" or "out_format" is set, or when "out_format" is invalid, misleading users. This breaks expected control flow and UX.
- Silent skipping in "if let Some(out_file)" with missing "out_format" causes misleading success perception.
- Unknown "out_format" values bypass writing logic, confusing users and breaking output expectations.
- Control flow lacks explicit validation/error handling, increasing debugging complexity and risking silent pipeline failures.
🪄Fix Explanation: The update aligns control flow with the output-generation algorithm by validating options before processing them. It prevents incomplete output configuration and unsupported formats from reaching the report-writing logic.
""out_file.is_some() != out_format.is_some()" detects when only one output option is supplied, preventing invalid partial configuration."
""matches!(out_format, "json" | "html" | "sarif")" restricts formats to supported values and avoids undefined downstream behavior."
"Immediate error logging and process termination provide fail-fast behavior, making configuration errors clear and easier to diagnose."
"Validation occurs before "if let Some(out_file)", keeping the main output path focused on valid report generation."
| if let Some(out_file) = out_file { | |
| if out_file.is_some() != out_format.is_some() { | |
| log::error!("--out-file and --out-format must be provided together."); | |
| std::process::exit(1); | |
| } | |
| if let Some(out_format) = out_format.as_deref() { | |
| if !matches!(out_format, "json" | "html" | "sarif") { | |
| log::error!("Unsupported output format '{}'. Supported formats are: json, html, sarif.", out_format); | |
| std::process::exit(1); | |
| } | |
| } | |
| if let Some(out_file) = out_file { |
| let classifications = match report_scan_status(&config.get_url(), &project_name, &scan_id) { | ||
| Ok(issues_classes) => { | ||
| *stop_signal.lock().unwrap() = true; | ||
| let _ = results_thread.join(); | ||
| println!( | ||
| "\n\nYou can view the scan results at the following link:\n{}", | ||
| utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green) | ||
| ); | ||
| issues_classes | ||
| } | ||
| Err(e) => { | ||
| *stop_signal.lock().unwrap() = true; | ||
| let _ = results_thread.join(); | ||
| log::error!( | ||
| "\r{}\n\n{}\n\n\ | ||
| However, the scan results may still be accessible at the following link:\n\n\ | ||
| {}\n\n\ |
There was a problem hiding this comment.
🧹 Quality - The code duplicates cleanup logic like "*stop_signal.lock().unwrap() = true;" and "results_thread.join()" in multiple branches. This repetition risks inconsistencies and harder updates. View in Corgea ↗
More Details
🎟️Issue Explanation: The code duplicates cleanup logic like `"*stop_signal.lock().unwrap() = true;"` and `"results_thread.join()"` in multiple branches. This repetition risks inconsistencies and harder updates.
- Duplicated `"stop_signal.lock().unwrap() = true;"` risks forgetting to stop the spinner, causing UI glitches or resource leaks.
- Multiple `"results_thread.join()"` calls increasing chance of missing thread joins, leading to potential deadlocks or thread panics.
- Scattered cleanup logic makes it hard to maintain and extend, especially with early returns that could skip essential terminal resets.
🪄Fix Explanation: The repeated spinner shutdown logic is extracted into the "stop_spinner" closure and reused across success and error paths. This reduces duplication, improves consistency, and makes future changes easier.
- "stop_spinner" centralizes setting "stop_signal" and joining "results_thread" in one location.
- Both "Ok" and "Err" branches now call "stop_spinner()", ensuring identical cleanup behavior.
- Removing duplicated synchronization and thread-joining code improves readability and reduces maintenance risk.
- Future changes to spinner shutdown only need to be made inside the closure.
diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs
index 03515aa..839df2c 100644
--- a/src/scanners/blast.rs
+++ b/src/scanners/blast.rs
@@ -97,10 +97,13 @@ pub fn run(
);
});
+ let stop_spinner = || {
+ *stop_signal.lock().unwrap() = true;
+ let _ = results_thread.join();
+ };
let classifications = match report_scan_status(&config.get_url(), &project_name, &scan_id) {
Ok(issues_classes) => {
- *stop_signal.lock().unwrap() = true;
- let _ = results_thread.join();
+ stop_spinner();
println!(
"\n\nYou can view the scan results at the following link:\n{}",
utils::terminal::set_text_color(&scan_url, utils::terminal::TerminalColor::Green)
@@ -108,8 +111,7 @@ pub fn run(
issues_classes
}
Err(e) => {
- *stop_signal.lock().unwrap() = true;
- let _ = results_thread.join();
+ stop_spinner();
log::error!(
"\r{}\n\n{}\n\n\
However, the scan results may still be accessible at the following link:\n\n\
To apply the fix, Download .patch.
| match corgea::deps::report::sbom(std::path::Path::new(".")) { | ||
| Ok(doc) => { | ||
| let json = serde_json::to_string_pretty(&doc).expect("serialize SBOM"); | ||
| if let Err(e) = fs::write(&sbom_file, json) { |
There was a problem hiding this comment.
Path Traversal (🔒 Security, 🔴 High) - The code uses a file path from external input without properly cleaning it, which can allow accessing files outside the intended directory. View in Corgea ↗
More Details
🎟️Issue Explanation: The code uses a file path from external input without properly cleaning it, which can allow accessing files outside the intended directory.
- Using "sbom_file" directly lets attackers add "../" to access parent folders, escaping the restricted directory.
- This can lead to overwriting or reading sensitive files, since "fs::write" writes to any resolved path.
- An attacker could exploit this by giving a path like "../../etc/passwd" to break directory limits and affect critical files.
🪄Fix Explanation: The fix prevents SBOM writes outside the current working directory by rejecting traversal and absolute path components, requiring a JSON filename, and validating the canonical target before writing.
This blocks attacker-controlled paths from escaping the intended output directory.
- "requested_path.components()" rejects "ParentDir" and "RootDir", blocking ".." traversal and absolute paths.
- "file_name()" limits the output to a single filename, while the extension check requires a ".json" file.
- The current directory is canonicalized as "canonical_root", and the requested filename is joined beneath it.
- Existing targets are canonicalized to resolve symlinks; "starts_with(&canonical_root)" rejects paths that escape the output directory.
- The write uses "canonical_target" rather than the untrusted "sbom_file" path.
| if let Err(e) = fs::write(&sbom_file, json) { | |
| let requested_path = std::path::Path::new(&sbom_file); | |
| if requested_path.components().any(|component| matches!(component, std::path::Component::ParentDir | std::path::Component::RootDir)) { | |
| log::error!("拒Invalid SBOM output path '{}': path traversal is not allowed", sbom_file); | |
| std::process::exit(1); | |
| } | |
| let file_name = match requested_path.file_name() { | |
| Some(file_name) if requested_path.extension().and_then(|extension| extension.to_str()) == Some("json") => file_name, | |
| _ => { | |
| log::error!("Invalid SBOM output file '{}': a JSON file name is required", sbom_file); | |
| std::process::exit(1); | |
| } | |
| }; | |
| let canonical_root = match env::current_dir().and_then(|path| fs::canonicalize(path)) { | |
| Ok(path) => path, | |
| Err(e) => { | |
| log::error!("Failed to determine the SBOM output directory: {}", e); | |
| std::process::exit(1); | |
| } | |
| }; | |
| let joined_path = canonical_root.join(file_name); | |
| let canonical_target = match fs::canonicalize(&joined_path) { | |
| Ok(path) => path, | |
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => joined_path, | |
| Err(e) => { | |
| log::error!("Failed to resolve SBOM output path '{}': {}", sbom_file, e); | |
| std::process::exit(1); | |
| } | |
| }; | |
| if !canonical_target.starts_with(&canonical_root) { | |
| log::error!("Invalid SBOM output path '{}': path escapes the output directory", sbom_file); | |
| std::process::exit(1); | |
| } | |
| if let Err(e) = fs::write(&canonical_target, json) { |
| let commit = utils::generic::get_repo_info_for_scan("./") | ||
| .ok() | ||
| .flatten() | ||
| .and_then(|info| Some((info.sha?, info.status_dirty))); |
There was a problem hiding this comment.
RepoInfo deliberately has two dirtiness signals, but reuse checks the weaker one. status_dirty only reflects git status; dirty also covers assume-unchanged/skip-worktree files, dirty submodules, and index-read failures (worktree_dirty_flags), and that broader value is what a real upload sends. The existing generic tests even prove dirty == true && status_dirty == false for assume-unchanged/skip-worktree. In those states this branch can reuse a clean prior scan and let --block-on pass even though a fresh upload would contain different files. Gate reuse on the upload signal and add the hidden-index case to the skip E2E test.
| .and_then(|info| Some((info.sha?, info.status_dirty))); | |
| .and_then(|info| Some((info.sha?, info.dirty))); |
There was a problem hiding this comment.
I agree with this finding and think it should be addressed.
high: Reuse checks the weaker worktree dirtiness signal
status_dirty only reflects normal git-status changes, while the upload path's dirty signal also detects assume-unchanged/skip-worktree files, dirty submodules, and index-read failures. Reuse can therefore select an old clean scan although a fresh upload would contain different content.
Proof or reproduction:
Create a tracked modified file marked assume-unchanged so `info.status_dirty == false` and `info.dirty == true`; the current code proceeds to lookup and can reuse a prior clean scan. Use `info.dirty` for this decision.
| ("page", "1".to_string()), | ||
| ("page_size", page_size.to_string()), | ||
| ("project", project.to_string()), | ||
| ("sha", sha.to_string()), |
There was a problem hiding this comment.
This lookup does not constrain file scope. GET /scans supports scan_type=full|partial, but a partial --target/--exclude scan of this SHA is returned here too. Modern CLI partial scans happen to carry worktree_dirty=true, but older/platform scans have null, and scan_age_if_reusable intentionally accepts None; such a scan can therefore stand in for a whole-commit run and produce a false-clean gate/report. Request only full scans (and assert the parameter in commit_lookup); if legacy backends may ignore this filter, the response also needs enough scope evidence to reject partial candidates client-side.
| ("sha", sha.to_string()), | |
| ("sha", sha.to_string()), | |
| ("scan_type", "full".to_string()), |
There was a problem hiding this comment.
I agree with this finding and think it should be addressed.
high: Partial scans can replace whole-commit scans
The lookup filters only by project and SHA. A completed partial scan produced with --target or --exclude can therefore be selected for a whole-commit run. Candidates with missing worktree_dirty are explicitly accepted, so legacy or platform partial scans can yield incomplete reports and false-clean gates.
Proof or reproduction:
Return a completed partial scan with the requested SHA and `worktree_dirty: null` from `/api/v1/scans`; `select_reusable_scan` accepts it. The request should constrain `scan_type=full`, and candidates must be rejected client-side if older servers can ignore that filter.
| // 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)); |
There was a problem hiding this comment.
Reuse is not equivalent to the requested scan configuration. scan_type and policy are only passed to start_new_scan, so on a reuse hit the current --scan-type/--policy is silently ignored; conversely, the selected prior scan may itself have been secrets-only or used different target policies because selection checks only SHA/status/age/dirty. For example, a recent secrets-only scan can be reused by a default --block-on CI run and let base/policy findings go unevaluated. The file-scope GET /scans?scan_type=full filter does not solve this: CLI --scan-type is uploaded separately as scan_configs. Require an exact normalized scan_configs/target_policies match in the lookup/response before reuse. Until the API exposes that identity, reject reuse with current custom config and restrict candidates server-side to default-config scans.
There was a problem hiding this comment.
I agree with this finding and think it should be addressed.
high: Reuse ignores scanner and policy configuration
scan_type and policy are passed only to start_new_scan, which is bypassed on a reuse hit. Candidate selection also does not check engine, scan configuration, or target policies. A completed secrets-only or differently configured scan of the same SHA can therefore replace the requested default/policy scan and omit findings used by --block-on.
Proof or reproduction:
Given a recent completed scan with the same SHA but `engine` or scan configuration limited to secrets, `resolve_reusable_scan` returns it because selection checks only SHA, status, dirtiness, and age; `start_new_scan(..., scan_type, policy, ...)` is never called.
…ommit A pipeline that re-runs on an unchanged commit currently pays for a full scan that can only reproduce the previous run's findings. Teams work around it by querying `corgea list --json` for the last scan's SHA and re-deriving a verdict from issue counts, which has no idea which blocking rules apply or which findings carry exceptions. Skipping the scan is only half the job: the run still has to gate on --block-on and still has to write --out-file. So the reused scan takes the new scan's place for the rest of the command rather than short-circuiting it, and the blocking rules are evaluated against it exactly as they would be against a fresh scan. Recency is a policy, not a technicality: unchanged code is still exposed to advisories published since it was last scanned, so --scanned-within (24h by default) bounds how old a reusable scan may be. Reuse is refused whenever the evidence is incomplete — a failed or running scan, a scan of a dirty worktree, an unreadable timestamp, a lookup the platform could not answer — since scanning again is the safe outcome. The one hard failure is an unresolvable commit, because the flag asks a question about the commit and quietly scanning would hide that the pipeline is not getting the behavior it asked for. The scan list is re-checked client-side against the requested SHA: a backend predating the `sha` filter answers with every scan of the project, and acting on that would skip one commit's scan because a different commit was scanned. CORGEA_SCAN_SKIPPED=true/false (plus CORGEA_SCAN_ID on a reuse) is printed once per run so a later pipeline step can branch on whether a scan happened. Co-authored-by: ibrahim <ibrahim@corgea.com>
| if scan.worktree_dirty == Some(true) { | ||
| return Err("it scanned a worktree with uncommitted changes".to_string()); | ||
| } | ||
| let created_at = parse_timestamp(&scan.created_at) |
There was a problem hiding this comment.
high: Unknown historical dirtiness is treated as clean
The code rejects only Some(true) and deliberately accepts None, although None means the client did not report dirtiness, not that the tree was clean. An older dirty-worktree scan can thus be reused, contradicting the documented guarantee and potentially gating against findings from uncommitted historical content.
Proof or reproduction:
let mut legacy = scan("legacy", "complete", Some(SHA), "2026-01-01T23:00:00Z");
legacy.worktree_dirty = None;
assert!(select_reusable_scan(&[legacy], SHA, now(), DAY).is_none()); // currently returns Some
There was a problem hiding this comment.
Automated review risk: 4/5.
High risk: reuse can substitute scans that do not represent the current worktree, requested scope, scanner configuration, or policy, allowing security gates to pass on incomplete results.
Critical or high-priority changes must be addressed.
Automatic approval was not submitted: automated review found critical or high-priority findings.
d403c24 to
2d8162f
Compare
| let (scan_id, project_id) = match reused_scan { | ||
| Some(scan) => (scan.id, None), | ||
| None => start_new_scan( | ||
| config, | ||
| &project_name, | ||
| only_uncommitted, | ||
| metadata, | ||
| scan_type, | ||
| policy, | ||
| target, | ||
| exclude, | ||
| ), | ||
| }; |
There was a problem hiding this comment.
reused scan-list records omit scanner errors, so degraded findings can be gated without fresh-scan warnings; could we fetch the scan by ID and reject it or surface those warnings?
| short, project_name, skip.label | ||
| ); | ||
|
|
||
| let scans = match utils::api::query_scans_for_commit( |
There was a problem hiding this comment.
a reusable completed scan beyond the first 30 records is missed, causing a duplicate scan; should we paginate until we find a candidate or exhaust the relevant pages?
yhoztak
left a comment
There was a problem hiding this comment.
LGTM
once some comments are addressed
What
Two new flags on
corgea scan:--skip-if-commit-scanned-recently— do not start a new scan when the project already has a completed scan of the current commit inside the window.--scanned-within <DURATION>— what "recently" means (90s,30m,24h,7d; a bare number is hours). Defaults to 24h, because unchanged code is still exposed to advisories published since it was last scanned.Why
This replaces the duplicate-scan skip logic a customer hand-rolled in their Harness pipeline:
corgea list --json, comparegit_shaagainstCI_COMMIT_SHA, apply a 24h staleness window, paginatelist --issuesfor prior counts, then re-derive a pass/fail from those counts. That last step is the broken part — it has no idea which blocking rules apply to CI or which findings had valid exceptions, and with--block-ondriving enforcement there was no way to replay the prior scan's verdict.How it behaves
Skipping the scan is only half the job, so the reused scan takes the new scan's place for the rest of the command rather than short-circuiting it:
--block-ongate and its exit code,--out-file/--out-format,--fail-on, and--sbomall run against the reused scanTwo lines are printed once per run so a later pipeline step (e.g. an ingest) can branch on the outcome:
…or
CORGEA_SCAN_SKIPPED=falsewhen a scan actually ran.When reuse is refused
Every one of these runs a normal scan, because scanning again is the safe outcome when the evidence is incomplete:
The one hard failure is an unresolvable commit (not a git repo, no commits): that exits 1, because the flag asks a question about the commit and quietly scanning would hide that the pipeline is not getting the behavior it asked for.
Reuse is for whole-commit scans, so the flag is rejected alongside
--only-uncommitted,--target, and--excluderather than silently gating on a superset of the requested files.Implementation notes
GET /api/v1/scans?project=…&sha=…, which doghouse already filters server-side. No backend change is needed.git_shais re-checked client-side — otherwise one commit's scan would be skipped because a different commit was scanned.ScanResponsegainsworktree_dirty(tri-state). Only a known-dirty scan is disqualified; a scan that never reported the flag (older CLI, platform integration) is not evidence of local edits.blast::run(results, report, SBOM, gates) is now shared by both paths; packaging/upload/wait moved intostart_new_scan. The moved block is byte-for-byte the one Write the scan report and SBOM before the blocking-rule gates exit #156 left behind, ordering included — no behavior change on the normal path.Testing
./harness ci— strict clippy, format, dep audit, 710 tests, coverage gate.src/skip_scan.rscover window parsing (including the values that would silently disable the check), newest-first selection, fall-through past a failed scan, the wrong-commit guard, dirty/unknown worktree state, every timestamp shape the API emits, window edges, and clock skew.tests/cloud_commands_e2e/scan_skip.rsassert the exact request sequence, so "no new scan was started" is proven by the absence of the upload calls: a skipped scan still exits 1 on the prior scan's blocking rules and still writes its SARIF report first, a stale scan and a shorter--scanned-withinboth fall back to a real scan, a dirty worktree never even performs the lookup, an unresolvable commit fails before any upload, and--scanned-withinalone is rejected.