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
684 changes: 684 additions & 0 deletions src/incremental.rs

Large diffs are not rendered by default.

22 changes: 18 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod authorize;
mod cicd;
mod config;
mod images;
mod incremental;
mod inspect;
mod list;
mod log;
Expand Down Expand Up @@ -89,6 +90,12 @@ enum Commands {
#[arg(long, help = "Only scan uncommitted changes.")]
only_uncommitted: bool,

#[arg(

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 reuse path returns before start_new_scan, so this flag pair skips analysis. should these flags conflict?

long = "disable-incremental",
help = "Analyze every file, even when Corgea could have analyzed only what changed. Scans are incremental by default: the whole project is still uploaded, but only files that changed since this project's last scan are analyzed, and unchanged files keep their existing findings, so the result is a full picture either way. Use this to force a fresh analysis of every file — after changing scanner configuration outside corgea.yaml, for example. Incremental is skipped on its own, with a reason, when there is no git repository or commit to diff from, when the worktree is dirty, when no earlier scan of a clean worktree exists, or when the last scanned commit is missing from a shallow clone; and silently when --only-uncommitted, --target or --exclude already narrow the upload."
)]
disable_incremental: bool,

#[arg(
long = "metadata",
value_name = "KEY=VALUE",
Expand Down Expand Up @@ -173,8 +180,8 @@ enum Commands {

#[arg(
long = "skip-if-commit-scanned-recently",
conflicts_with_all = ["only_uncommitted", "target", "scan_type", "policy", "include_image"],
help = "Do not start a new scan when this commit already has a recent completed scan in the project. That scan then drives the rest of the command — results table, --block-on gate, --out-file report — so the pipeline behaves the same either way. Prints CORGEA_SCAN_SKIPPED=true/false so a pipeline can tell the two apart, and fails if no git commit can be resolved. What can be reused is a scan of the whole commit, and no API tells this run how a past scan was scoped or configured, so the flag is refused with --only-uncommitted, --target, --scan-type, --policy and --include-image; with --exclude it warns instead, since a reused scan covers files this run would have skipped."
conflicts_with_all = ["only_uncommitted", "target", "scan_type", "policy", "include_image", "disable_incremental"],
help = "Do not start a new scan when this commit already has a recent completed scan in the project. That scan then drives the rest of the command — results table, --block-on gate, --out-file report — so the pipeline behaves the same either way. Prints CORGEA_SCAN_SKIPPED=true/false so a pipeline can tell the two apart, and fails if no git commit can be resolved. What can be reused is a scan of the whole commit, and no API tells this run how a past scan was scoped or configured, so the flag is refused with --only-uncommitted, --target, --scan-type, --policy, --include-image and --disable-incremental; with --exclude it warns instead, since a reused scan covers files this run would have skipped."
)]
skip_if_commit_scanned_recently: bool,

Expand All @@ -188,8 +195,7 @@ enum Commands {

#[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."
help = "Do not let uncommitted changes stop this run from taking a shortcut. For an incremental scan, the diff is measured against the working tree instead of the last commit, so edited and untracked files are analyzed rather than skipped. With --skip-if-commit-scanned-recently, a recent scan of this commit may be reused even though this worktree is dirty or the prior scan recorded worktree_dirty. A new scan still reports the real dirty status."
)]
ignore_dirty_worktree: bool,
},
Expand Down Expand Up @@ -669,6 +675,7 @@ fn main() {
fail,
block_on,
only_uncommitted,
disable_incremental,
metadata,
scan_type,
policy,
Expand Down Expand Up @@ -710,6 +717,11 @@ fn main() {
std::process::exit(1);
}

if *disable_incremental && *scanner != Scanner::Blast {
::log::error!("--disable-incremental is only supported with blast scanner.");
std::process::exit(1);
}

if !metadata.is_empty() && *scanner != Scanner::Blast {
::log::error!("--metadata is only supported with the blast scanner.");
std::process::exit(1);
Expand Down Expand Up @@ -846,6 +858,8 @@ fn main() {
fail,
block_on,
only_uncommitted,
disable_incremental,
ignore_dirty_worktree,
metadata_json,
scan_type.clone(),
policy.clone(),
Expand Down
37 changes: 34 additions & 3 deletions src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub fn run(
fail: &bool,
block_on: Option<String>,
only_uncommitted: &bool,
disable_incremental: &bool,
ignore_dirty_worktree_for_run: &bool,
metadata: Option<String>,
scan_type: Option<String>,
policy: Option<String>,
Expand Down Expand Up @@ -109,6 +111,8 @@ pub fn run(
config,
&project_name,
only_uncommitted,
disable_incremental,
ignore_dirty_worktree_for_run,
metadata,
scan_type,
policy,
Expand Down Expand Up @@ -281,6 +285,8 @@ fn start_new_scan(
config: &Config,
project_name: &str,
only_uncommitted: &bool,
disable_incremental: &bool,
ignore_dirty_worktree: &bool,
metadata: Option<String>,
scan_type: Option<String>,
policy: Option<String>,
Expand Down Expand Up @@ -514,15 +520,40 @@ fn start_new_scan(
info.dirty = true;
}
}
// Incremental is the default, so this asks what took it off the table. A
// narrowed archive is the silent case: carrying findings forward for files
// the archive no longer holds would be wrong, but those runs are not
// "scanning every file" either, so no message is honest.
let narrowed_archive = target_str.is_some() || exclude.is_some();
let incremental_plan = if *disable_incremental || narrowed_archive {
None
} else {
// Reconciled repo info, so a tree that turned out dirty — or a HEAD
// that moved mid-packaging — refuses rather than diffing against a
// commit this upload is not a snapshot of.
crate::incremental::resolve_incremental_plan(
config,
project_name,
repo_info.as_ref().and_then(|info| info.branch.as_deref()),
repo_info.as_ref().and_then(|info| info.sha.as_deref()),
// Missing repo info is not dirtiness; it is the missing
// branch/commit the resolver reports next, by its real name.
repo_info.as_ref().is_some_and(|info| info.dirty),
*ignore_dirty_worktree,
)
};
println!("\n\nSubmitting scan to Corgea:");
let upload_result = match utils::api::upload_zip(
&zip_path,
&config.get_url(),
project_name,
repo_info,
scan_type,
policy,
metadata,
utils::api::UploadOptions {
scan_type,
policy,
metadata,
incremental: incremental_plan,
},
) {
Ok(result) => result,
Err(e) => {
Expand Down
7 changes: 6 additions & 1 deletion src/skip_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,13 @@ fn format_age(age: Duration) -> String {
format!("{}s", seconds)
}

/// First 7 characters, by char boundary rather than byte index. The value comes
/// from the API, so a non-ASCII one must shorten, not panic mid-scan.
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(7)]
match sha.char_indices().nth(7) {
Some((byte, _)) => &sha[..byte],
None => sha,
}
}

#[cfg(test)]
Expand Down
81 changes: 78 additions & 3 deletions src/utils/api.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::incremental::IncrementalPlan;
use crate::log::debug;
use crate::utils;
use corgea::vuln_api::{auth_header, source};
Expand Down Expand Up @@ -233,15 +234,30 @@ pub struct UploadZipResult {
pub project_id: Option<String>,
}

/// Per-scan settings travelling with the archive without being part of it.
#[derive(Debug, Default)]
pub struct UploadOptions {
pub scan_type: Option<String>,
pub policy: Option<String>,
pub metadata: Option<String>,
/// Set when this run resolved a diff for the server to analyze instead of
/// the whole project.
pub incremental: Option<IncrementalPlan>,
}

pub fn upload_zip(
file_path: &str,
url: &str,
project_name: &str,
repo_info: Option<utils::generic::RepoInfo>,
scan_type: Option<String>,
policy: Option<String>,
metadata: Option<String>,
options: UploadOptions,
) -> Result<UploadZipResult, Box<dyn std::error::Error>> {
let UploadOptions {
scan_type,
policy,
metadata,
incremental,
} = options;
let client = http_client();
let file_size = std::fs::metadata(file_path)?.len();
let file_name = Path::new(file_path).file_name().unwrap().to_str().unwrap();
Expand Down Expand Up @@ -370,6 +386,34 @@ pub fn upload_zip(
if let Some(meta) = &metadata {
form = form.part("metadata", multipart::Part::text(meta.clone()));
}
// Both fields or neither: the list is only safe next to the commit it
// was measured from, and a server seeing one without the other would
// guess a baseline. A list that will not serialize drops both, leaving
// a full scan.
if let Some(plan) = &incremental {
match serde_json::to_string(&plan.changed_files) {
Ok(changed_files) => {
form = form.part(
"incremental_base_sha",
multipart::Part::text(plan.base_sha.clone()),
);
form = form.part(
"incremental_changed_files",
multipart::Part::text(changed_files),
);
// Tells the server the list describes the working tree, not
// just a commit range, which is the only way it can accept a
// diff from a dirty upload.
if plan.covers_worktree {
form =
form.part("incremental_covers_worktree", multipart::Part::text("true"));
}
}
Err(e) => debug(&format!(
"Could not serialize the incremental file list, scanning every file: {e}"
)),
}
}

let response = match client
.patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id))
Expand Down Expand Up @@ -819,6 +863,37 @@ pub fn query_scan_list(
request_scan_list(url, query_params)
}

/// One page of the project's scans that could be diffed against, newest first.
///
/// Filters are server-side, so the answer is usually the first entry of page
/// one. A backend predating them ignores the unknown parameters and returns
/// scans of every kind, so the caller must still re-check each scan it acts on
/// — see `incremental::is_usable_baseline`.
pub fn query_baseline_scans(
url: &str,
project: &str,
engine: &str,
branch: &str,
page: u16,
page_size: u16,
) -> Result<ScansResponse, Box<dyn Error>> {
request_scan_list(
url,
vec![
("page", page.to_string()),
("page_size", page_size.to_string()),
("project", project.to_string()),
("engine", engine.to_string()),
("branch", branch.to_string()),
("status", "complete".to_string()),
("exclude_pull_requests", "true".to_string()),
// Explicitly clean only. A scan that never reported the flag is
// unknown scope, which the server rejects as a baseline.
("worktree_dirty", "false".to_string()),
],
)
}

/// One page of the project's scans at exactly `sha`, newest first.
///
/// The `sha` filter is server-side, but a backend that predates it ignores the
Expand Down
56 changes: 53 additions & 3 deletions tests/cloud_commands_e2e/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,25 @@ pub(crate) fn assert_scan_list_request(
assert_query(request, "project", project)
}

/// One baseline lookup an incremental scan makes before uploading.
///
/// Asserting the filters is the point: they keep this to one request per trunk
/// branch instead of a page walk, and a server dropping them silently returns
/// pull-request and dirty scans for the client to reject. `branch` is asserted
/// because a baseline may only come from trunk.
pub(crate) fn assert_baseline_lookup_request(
request: &CapturedRequest,
project: &str,
branch: &str,
) -> Result<(), String> {
assert_scan_list_request(request, project)?;
assert_query(request, "engine", "corgea-blast")?;
assert_query(request, "status", "complete")?;
assert_query(request, "exclude_pull_requests", "true")?;
assert_query(request, "worktree_dirty", "false")?;
assert_query(request, "branch", branch)
}

pub(crate) fn query_value(request: &CapturedRequest, key: &str) -> Result<String, String> {
let (_, query) = target_path_and_query(&request.target);
query
Expand Down Expand Up @@ -381,6 +400,23 @@ pub(crate) fn assert_multipart_text_field(
}
}

/// Proves a field was left off the form entirely. Some fields are only safe in
/// pairs, so "absent" is as much the contract as any value.
pub(crate) fn assert_no_multipart_field(
request: &CapturedRequest,
name: &str,
) -> Result<(), String> {
let needle = format!("name=\"{name}\"");
if request
.body
.windows(needle.len())
.any(|window| window == needle.as_bytes())
{
return Err(format!("unexpected multipart field {name}"));
}
Ok(())
}

pub(crate) fn format_transcript(requests: &[CapturedRequest]) -> String {
if requests.is_empty() {
return "<no requests>".to_string();
Expand Down Expand Up @@ -777,8 +813,22 @@ pub(crate) fn blast_upload_plan(sha: &str, dirty: bool, include_sca: bool) -> Ve
let patch_path = "/api/v1/start-scan/transfer-123/".to_string();
let detail_path = "/api/v1/scan/blast-scan-123".to_string();
let issue_path = "/api/v1/scan/blast-scan-123/issues".to_string();
let mut plan = vec![
verify_request(),
let mut plan = vec![verify_request()];
// Scans are incremental by default, so every clean-tree run looks for a
// baseline before uploading -- once per trunk branch, since the fixture
// records no origin/HEAD. Answering with no scans keeps this the full-scan
// contract: nothing to diff from, no incremental fields on the upload. A
// dirty tree never asks.
if !dirty {
for branch in ["main", "master"] {
plan.push(expected_request(
"look up a baseline scan to diff against",
move |request| assert_baseline_lookup_request(request, "cloud-e2e", branch),
json_response(scans_response(Vec::new())),
));
}
}
plan.extend([
expected_request(
"start BLAST upload",
|request| {
Expand Down Expand Up @@ -835,7 +885,7 @@ pub(crate) fn blast_upload_plan(sha: &str, dirty: bool, include_sca: bool) -> Ve
},
json_response(empty_issue_page()),
),
];
]);
if include_sca {
let sca_path = "/api/v1/scan/blast-scan-123/issues/sca".to_string();
plan.push(expected_request(
Expand Down
1 change: 1 addition & 0 deletions tests/cloud_commands_e2e/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod repo_common;
mod block_on_report;
mod common;
mod inspect;
mod scan_incremental;
mod scan_list;
mod scan_skip;
mod upload_wait;
Loading
Loading