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: 2 additions & 0 deletions skills/corgea/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
31 changes: 30 additions & 1 deletion src/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -49,6 +70,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 {
Expand Down
78 changes: 77 additions & 1 deletion src/utils/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,7 +1118,7 @@ pub fn get_scan_report(
}

pub fn get_issue(url: &str, issue: &str) -> Result<FullIssueResponse, Box<dyn std::error::Error>> {
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() {
Expand Down Expand Up @@ -2110,6 +2110,39 @@ pub struct Issue {
pub details: Option<Details>,
pub auto_triage: AutoTriage,
pub auto_fix_suggestion: Option<AutoFixSuggestion>,
/// 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<serde_json::Map<String, Value>>,
/// 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)]
pub metadata: Option<serde_json::Map<String, Value>>,
}

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)]
Expand Down Expand Up @@ -2351,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
Expand Down
74 changes: 74 additions & 0 deletions tests/cloud_commands_e2e/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
31 changes: 31 additions & 0 deletions tests/list_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading