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
22 changes: 22 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ enum Commands {
#[arg(long, help = "Only scan uncommitted changes.")]
only_uncommitted: bool,

#[arg(
long = "metadata",
value_name = "KEY=VALUE",
help = "Attach scan-level metadata (repeatable), e.g. --metadata pipeline_url=... --metadata artifact_version=1.2.3"
)]
metadata: Vec<String>,

#[arg(
short,
long,
Expand Down Expand Up @@ -529,6 +536,7 @@ fn main() {
fail_on,
fail,
only_uncommitted,
metadata,
scan_type,
policy,
out_format,
Expand Down Expand Up @@ -559,6 +567,19 @@ fn main() {
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);
}

let metadata_json = match scanners::blast::metadata_json_from_pairs(metadata) {
Ok(json) => json,
Err(e) => {
::log::error!("{}", e);
std::process::exit(1);
}
};

if out_file.is_some() && *scanner != Scanner::Blast {
::log::error!("out_file is only supported with blast scanner.");
std::process::exit(1);
Expand Down Expand Up @@ -631,6 +652,7 @@ fn main() {
fail_on.clone(),
fail,
only_uncommitted,
metadata_json,
scan_type.clone(),
policy.clone(),
out_format.clone(),
Expand Down
74 changes: 74 additions & 0 deletions src/scanners/blast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub fn run(
fail_on: Option<String>,
fail: &bool,
only_uncommitted: &bool,
metadata: Option<String>,
scan_type: Option<String>,
policy: Option<String>,
out_format: Option<String>,
Expand Down Expand Up @@ -212,6 +213,7 @@ pub fn run(
repo_info,
scan_type,
policy,
metadata,
) {
Ok(result) => result,
Err(e) => {
Expand Down Expand Up @@ -651,6 +653,32 @@ pub fn report_scan_status(
Ok(classification_counts)
}

/// Parse `--metadata KEY=VALUE` flags into a JSON object string.
/// Splits on the first `=`; duplicate keys last-wins. Empty input -> `Ok(None)`.
pub fn metadata_json_from_pairs(pairs: &[String]) -> Result<Option<String>, String> {
let mut map = serde_json::Map::new();
for entry in pairs {
match entry.split_once('=') {
Some((key, value)) if !key.is_empty() => {
map.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
_ => {
return Err(
"Invalid --metadata value. Use KEY=VALUE with a non-empty key, e.g. --metadata pipeline_url=https://...".to_string(),
);
}
}
}
if map.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::Value::Object(map).to_string()))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -773,4 +801,50 @@ mod tests {
&[sca_issue(Some("malicious"))]
));
}

#[test]
fn parse_metadata_valid_pairs() {
let pairs = vec![
"pipeline_url=https://ci.example/run/1".to_string(),
"artifact_version=1.2.3".to_string(),
"note=a=b=c".to_string(),
];
let json = metadata_json_from_pairs(&pairs).unwrap().unwrap();
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(map.len(), 3);
assert_eq!(
map.get("pipeline_url").and_then(|v| v.as_str()),
Some("https://ci.example/run/1")
);
assert_eq!(
map.get("artifact_version").and_then(|v| v.as_str()),
Some("1.2.3")
);
assert_eq!(map.get("note").and_then(|v| v.as_str()), Some("a=b=c"));
}

#[test]
fn parse_metadata_rejects_missing_eq_or_empty_key() {
assert!(metadata_json_from_pairs(&["novalue".to_string()]).is_err());
assert!(metadata_json_from_pairs(&["=value".to_string()]).is_err());
assert!(metadata_json_from_pairs(&["".to_string()]).is_err());
}

#[test]
fn parse_metadata_duplicate_keys_last_wins() {
let pairs = vec!["k=first".to_string(), "k=second".to_string()];
let json = metadata_json_from_pairs(&pairs).unwrap().unwrap();
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(map.get("k").and_then(|v| v.as_str()), Some("second"));
}

#[test]
fn parse_metadata_empty_value_and_empty_list_ok() {
let json = metadata_json_from_pairs(&["k=".to_string()])
.unwrap()
.unwrap();
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(map.get("k").and_then(|v| v.as_str()), Some(""));
assert!(metadata_json_from_pairs(&[]).unwrap().is_none());
}
}
6 changes: 6 additions & 0 deletions src/utils/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ pub fn upload_zip(
repo_info: Option<utils::generic::RepoInfo>,
scan_type: Option<String>,
policy: Option<String>,
metadata: Option<String>,
) -> Result<UploadZipResult, Box<dyn std::error::Error>> {
let client = http_client();
let file_size = std::fs::metadata(file_path)?.len();
Expand Down Expand Up @@ -347,6 +348,9 @@ pub fn upload_zip(
if let Some(policy) = policy.clone() {
form = form.part("target_policies", multipart::Part::text(policy.to_string()));
}
if let Some(meta) = &metadata {
form = form.part("metadata", multipart::Part::text(meta.clone()));
}

let response = match client
.patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id))
Expand Down Expand Up @@ -942,6 +946,8 @@ pub struct ScanResponse {
pub created_at: String,
#[serde(default)]
pub git_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
Comment thread
leenk7991 marked this conversation as resolved.
}

#[derive(Serialize, Deserialize, Debug)]
Expand Down
Loading