From 3651526261c83ca818e502ad4e86612d08a2702d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 11:34:06 +0000 Subject: [PATCH 1/2] Show scanner metadata in corgea inspect --issue Request include_metadata=true from the issue API and keep both the issue's scanner identifiers (metadata) and the fix analysis copy (scanner_metadata) on the Issue model, so --json passes them through and the summary prints a Scanner Metadata section. Co-authored-by: ibrahim --- skills/corgea/SKILL.md | 2 + src/inspect.rs | 8 ++++ src/utils/api.rs | 34 ++++++++++++- tests/cloud_commands_e2e/inspect.rs | 74 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/skills/corgea/SKILL.md b/skills/corgea/SKILL.md index 6539306..f6dd402 100644 --- a/skills/corgea/SKILL.md +++ b/skills/corgea/SKILL.md @@ -159,6 +159,8 @@ corgea inspect --issue --diff ISSUE_ID # Diff only corgea inspect --issue --json ISSUE_ID # JSON output ``` +Issue summaries include a "Scanner Metadata" section when the originating scanner reported identifiers (for Fortify: `instance_id`, `class_id`, `kingdom`, `category`, `analyzer`, `build_id`). With `--json` they appear under `issue.metadata` (the issue's scanner identifiers) and `issue.scanner_metadata` (the fix analysis copy). + | Flag | Short | Description | |------|-------|-------------| | `--issue` | `-i` | Treat ID as issue (default: scan) | diff --git a/src/inspect.rs b/src/inspect.rs index 4ada1e6..f737049 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -49,6 +49,14 @@ pub fn run( "Status", utils::generic::get_status(&issue_details.issue.status), ); + let scanner_metadata = issue_details.issue.combined_scanner_metadata(); + if !scanner_metadata.is_empty() { + println!("Scanner Metadata:"); + for (key, value) in &scanner_metadata { + println!(" {:<13}: {}", key, value); + } + println!("-------------------------"); + } } if let Some(ref details) = issue_details.issue.details { if let Some(ref explanation) = details.explanation { diff --git a/src/utils/api.rs b/src/utils/api.rs index 950bff0..6d2dd3d 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1118,7 +1118,7 @@ pub fn get_scan_report( } pub fn get_issue(url: &str, issue: &str) -> Result> { - let url = format!("{}{}/issue/{}", url, API_BASE, issue,); + let url = format!("{}{}/issue/{}?include_metadata=true", url, API_BASE, issue); let client = http_client(); debug(&format!("Sending request to URL: {}", url)); let response = match client.get(&url).send() { @@ -2110,6 +2110,38 @@ pub struct Issue { pub details: Option
, pub auto_triage: AutoTriage, pub auto_fix_suggestion: Option, + /// Scanner identifiers recorded by the fix analysis. Only the single-issue + /// endpoint sends it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scanner_metadata: Option>, + /// Identifiers the originating scanner reported for the finding (e.g. + /// Fortify `instance_id`, `class_id`, `kingdom`). Sent only when the issue + /// is fetched with `include_metadata=true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +impl Issue { + /// Both metadata sources merged for display; the issue's own scanner + /// identifiers win over the fix analysis copy on key collisions. + pub fn combined_scanner_metadata(&self) -> Vec<(String, String)> { + let mut merged = std::collections::BTreeMap::new(); + for source in [&self.scanner_metadata, &self.metadata] + .into_iter() + .flatten() + { + for (key, value) in source { + let rendered = match value { + Value::Null => continue, + Value::String(text) if text.trim().is_empty() => continue, + Value::String(text) => text.clone(), + other => other.to_string(), + }; + merged.insert(key.clone(), rendered); + } + } + merged.into_iter().collect() + } } #[derive(Serialize, Deserialize, Debug)] diff --git a/tests/cloud_commands_e2e/inspect.rs b/tests/cloud_commands_e2e/inspect.rs index b29b24b..7502134 100644 --- a/tests/cloud_commands_e2e/inspect.rs +++ b/tests/cloud_commands_e2e/inspect.rs @@ -87,6 +87,80 @@ fn inspect_issue_json_returns_requested_issue() { assert_eq!(body["issue"]["location"]["line_number"], 7, "{context}"); } +fn inspect_issue_with_scanner_metadata(args: &[&str]) -> (std::process::Output, String) { + let project = TempDir::new().expect("create inspect project"); + let issue_id = "inspect-fortify-issue"; + let issue_path = format!("/api/v1/issue/{issue_id}"); + let mut issue = regular_issue(issue_id, "inspect-scan-123", "inspect-project", "HI"); + issue["scanner_metadata"] = json!({"scanner": "fortify", "kingdom": "stale"}); + issue["metadata"] = json!({ + "instance_id": "6B481068C7D139B941ADF736E39E0676", + "class_id": "78E0700E-56FE-45A2-A11B-6A560F730576", + "kingdom": "Encapsulation", + "category": "Cross-Site Request Forgery", + "analyzer": "content", + "build_id": "TODS", + "sub_category": null + }); + let api = ApiStub::start(vec![ + verify_request(), + expected_request( + "inspect issue with scanner metadata", + move |request| { + assert_authenticated_request(request, Method::GET, &issue_path)?; + assert_query(request, "include_metadata", "true") + }, + json_response(json!({"status": "ok", "issue": issue})), + ), + ]); + let (mut command, _home) = cloud_command(&api, project.path()); + command + .args(["inspect", "--issue"]) + .args(args) + .arg(issue_id); + + let output = run_with_timeout(command, &api); + let transcript = api.assert_finished(); + let context = output_context(&output, &transcript); + assert_eq!(output.status.code(), Some(0), "{context}"); + (output, transcript) +} + +#[test] +fn inspect_issue_json_includes_scanner_metadata() { + let (output, transcript) = inspect_issue_with_scanner_metadata(&["--json"]); + let body = parse_output_json(&output, &transcript); + let issue = &body["issue"]; + assert_eq!(issue["scanner_metadata"]["scanner"], "fortify"); + assert_eq!( + issue["metadata"]["instance_id"], + "6B481068C7D139B941ADF736E39E0676" + ); + assert_eq!(issue["metadata"]["kingdom"], "Encapsulation"); + assert_eq!(issue["metadata"]["build_id"], "TODS"); +} + +#[test] +fn inspect_issue_summary_prints_scanner_metadata() { + let (output, transcript) = inspect_issue_with_scanner_metadata(&["--summary"]); + let context = output_context(&output, &transcript); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Scanner Metadata:"), "{context}"); + for expected in [ + "instance_id : 6B481068C7D139B941ADF736E39E0676", + "class_id : 78E0700E-56FE-45A2-A11B-6A560F730576", + "kingdom : Encapsulation", + "category : Cross-Site Request Forgery", + "analyzer : content", + "build_id : TODS", + "scanner : fortify", + ] { + assert!(stdout.contains(expected), "missing {expected:?}\n{context}"); + } + assert!(!stdout.contains("stale"), "{context}"); + assert!(!stdout.contains("sub_category"), "{context}"); +} + #[test] fn inspect_scan_exits_one_on_server_error() { let project = TempDir::new().expect("create inspect project"); From 1d0b3391a8c8e7f2bdf316a36cd0eca1a98a504b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 4 Oct 2026 12:02:42 +0000 Subject: [PATCH 2/2] Keep scanner metadata out of list and scan JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue is re-serialized by list --json and scan --out-format json. skip_serializing_if still wrote scanner_metadata when the list API sent it, and the blocking-rules path dropped the field again. inspect --json copies the maps back onto its own payload. Co-authored-by: Juan Gaitán --- src/inspect.rs | 23 +++++++++++++++++- src/utils/api.rs | 52 ++++++++++++++++++++++++++++++++++++---- tests/list_resolution.rs | 31 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/inspect.rs b/src/inspect.rs index f737049..bd4aca6 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -33,7 +33,28 @@ pub fn run( } }; if *json { - println!("{}", serde_json::to_string_pretty(&issue_details).unwrap()); + // `Issue` does not serialize these maps. List and scan-report JSON + // re-emit that struct, and the blocking-rules list path uses a + // second struct that never had the fields. + let mut body = serde_json::to_value(&issue_details).unwrap(); + if let Some(issue) = body + .get_mut("issue") + .and_then(|value| value.as_object_mut()) + { + if let Some(scanner_metadata) = &issue_details.issue.scanner_metadata { + issue.insert( + "scanner_metadata".to_string(), + serde_json::Value::Object(scanner_metadata.clone()), + ); + } + if let Some(metadata) = &issue_details.issue.metadata { + issue.insert( + "metadata".to_string(), + serde_json::Value::Object(metadata.clone()), + ); + } + } + println!("{}", serde_json::to_string_pretty(&body).unwrap()); return; } if *summary || show_everything { diff --git a/src/utils/api.rs b/src/utils/api.rs index 6d2dd3d..1226f36 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -2110,14 +2110,15 @@ pub struct Issue { pub details: Option
, pub auto_triage: AutoTriage, pub auto_fix_suggestion: Option, - /// Scanner identifiers recorded by the fix analysis. Only the single-issue - /// endpoint sends it. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Scanner identifiers recorded by the fix analysis. List and scan-report + /// JSON re-emit this struct, so the maps stay off `Serialize`. `inspect + /// --json` copies them back onto the printed object. + #[serde(default, skip_serializing)] pub scanner_metadata: Option>, /// Identifiers the originating scanner reported for the finding (e.g. /// Fortify `instance_id`, `class_id`, `kingdom`). Sent only when the issue /// is fetched with `include_metadata=true`. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing)] pub metadata: Option>, } @@ -2383,6 +2384,49 @@ mod tests { use super::*; use reqwest::header::{HeaderMap, HeaderValue}; + #[test] + fn issue_json_omits_scanner_metadata_that_list_and_scan_reports_reemit() { + // `corgea list --json` and `corgea scan --out-format json` serialize + // `Issue` directly. The list API sends `scanner_metadata`; writing it + // back would change those outputs, and the blocking-rules list path + // would then drop it again. + let raw = r#"{ + "id": "issue-1", + "scan_id": "scan-1", + "status": "open", + "urgency": "HI", + "created_at": "2026-01-01T00:00:00Z", + "classification": {"id": "c", "name": "n", "description": null}, + "location": { + "file": {"name": "a.py", "language": "python", "path": "a.py"}, + "line_number": 1, + "project": {"name": "p", "branch": null, "git_sha": null} + }, + "details": null, + "auto_triage": {"false_positive_detection": {"status": "pending", "reasoning": null}}, + "auto_fix_suggestion": null, + "scanner_metadata": {"kingdom": "Encapsulation", "scanner": "fortify"}, + "metadata": {"instance_id": "ABC", "kingdom": "Encapsulation"} + }"#; + + let issue: Issue = serde_json::from_str(raw).unwrap(); + let merged = issue.combined_scanner_metadata(); + assert_eq!( + merged + .iter() + .find(|(key, _)| key == "kingdom") + .map(|(_, value)| value.as_str()), + Some("Encapsulation") + ); + assert!(merged.iter().any(|(key, _)| key == "scanner")); + assert!(merged.iter().any(|(key, _)| key == "instance_id")); + + let rendered = serde_json::to_value(&issue).unwrap(); + assert!(rendered.get("scanner_metadata").is_none()); + assert!(rendered.get("metadata").is_none()); + assert_eq!(rendered["id"], "issue-1"); + } + #[test] fn a_scan_from_a_deployment_without_manifests_parses_as_having_none() { // The fields are absent, not null, on a backend predating them. Without diff --git a/tests/list_resolution.rs b/tests/list_resolution.rs index 0f36a35..ee0b70f 100644 --- a/tests/list_resolution.rs +++ b/tests/list_resolution.rs @@ -82,6 +82,37 @@ fn list_uses_canonical_name_from_repo() { ); } +#[test] +fn list_issues_json_omits_scanner_metadata_from_the_list_payload() { + let issues = r#"{"status":"ok","page":1,"total_pages":1,"total_issues":1,"issues":[{"id":"issue-abc","scan_id":"scan-123","status":"open","urgency":"high","created_at":"2026-01-01T00:00:00Z","classification":{"id":"CWE-89","name":"SQL Injection","description":null},"location":{"file":{"name":"app.py","language":"python","path":"src/app.py"},"line_number":42,"project":{"name":"bohappdev/dotnet-azure-web-tsb","branch":null,"git_sha":null}},"details":null,"auto_triage":{"false_positive_detection":{"status":"none","reasoning":null}},"auto_fix_suggestion":null,"scanner_metadata":{"kingdom":"Encapsulation","instance_id":"6B481068"},"metadata":{"build_id":"TODS"}}]}"#; + let (url, _hits) = spawn_stub(projects_match(), scans_one(CANON), issues.to_string()); + let (_tmp, repo) = temp_git_repo("build-123", REMOTE); + let out = run_list(&["--issues", "--json"], &url, &repo); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + let body: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout not JSON ({e}): {stdout}")); + let issue = &body["results"][0]; + assert_eq!(issue["id"], "issue-abc"); + assert!( + issue.get("scanner_metadata").is_none(), + "list JSON must not re-emit scanner_metadata; stdout: {stdout}" + ); + assert!( + issue.get("metadata").is_none(), + "list JSON must not re-emit metadata; stdout: {stdout}" + ); + assert!( + !stdout.contains("Encapsulation"), + "scanner metadata leaked into list JSON; stdout: {stdout}" + ); +} + #[test] fn list_issues_uses_canonical_name_from_repo() { let (url, hits) = spawn_stub(projects_match(), scans_one(CANON), issues_one());