From 53f7fc46b0bce0da4139c14657f682dc57f0964e Mon Sep 17 00:00:00 2001 From: Tyler Dammann Date: Thu, 29 Oct 2026 16:07:26 -0700 Subject: [PATCH 1/3] feat(vulns): add read-only detail vulns command for security scan findings Co-Authored-By: Claude Opus 5.5 --- .claude/skills/detail-bugs/SKILL.md | 29 +- docs/HELP.md | 120 +++++- openapi.json | 448 +++++++++++++++++++++- src/api/client.rs | 58 ++- src/api/types.rs | 252 +++++++++++- src/commands/bugs.rs | 4 +- src/commands/mod.rs | 1 + src/commands/vulns.rs | 572 ++++++++++++++++++++++++++++ src/lib.rs | 99 ++++- tests/integration.rs | 4 + 10 files changed, 1561 insertions(+), 26 deletions(-) create mode 100644 src/commands/vulns.rs diff --git a/.claude/skills/detail-bugs/SKILL.md b/.claude/skills/detail-bugs/SKILL.md index db97f12..ceb3266 100644 --- a/.claude/skills/detail-bugs/SKILL.md +++ b/.claude/skills/detail-bugs/SKILL.md @@ -1,11 +1,11 @@ --- name: detail-bugs -description: Interact with Detail bugs for a repository via the CLI — list and filter bugs, inspect reports, close as resolved or dismissed, reopen previously closed bugs, and override a bug's priority. +description: Interact with Detail bugs for a repository via the CLI — list and filter bugs, inspect reports, close as resolved or dismissed, reopen previously closed bugs, and override a bug's priority. Also covers read-only access to security scan findings (`detail vulns`). --- # Detail Bugs -The Detail CLI exposes per-repository bugs through five subcommands: `list`, `show`, `close`, `reopen`, and `prioritize`. This skill describes that surface so you can pick the right command for whatever the user is trying to do. +The Detail CLI exposes per-repository bugs through five subcommands: `list`, `show`, `close`, `reopen`, and `prioritize`. Findings from Detail's security scans live separately under `detail vulns` (read-only). This skill describes both surfaces so you can pick the right command for whatever the user is trying to do. ## Prerequisites @@ -27,7 +27,7 @@ The Detail CLI infers the repository from the git or jj remote; if the user spec Lists bugs for the inferred or specified repository. - `--status pending|resolved|dismissed` — default `pending`; comma-separate or repeat the flag to combine (e.g. `--status resolved,dismissed`). -- `--vulns` — only security vulnerabilities. +- `--vulns` — only bugs the bug finder flagged as security vulnerabilities. For findings from security scans, use `detail vulns list` instead. - `--priority p1|p2|p3|none` — only bugs at these priorities; comma-separate or repeat the flag (e.g. `--priority p1,p2`). `none` selects bugs Detail never scored — most bugs found before priority scoring shipped, so prefer `--priority p1,p2,p3` over `--priority p1` when the user asks for "prioritized" bugs. Default: every priority. - `--sort newest|oldest|priority` — default `newest`. `priority` puts the most severe first and unscored last. - `--introduced-by ` — filter by authors (comma-separated or repeated). @@ -67,3 +67,26 @@ Overrides Detail's priority for a bug and records the change on its timeline. Th - `--format table|json`. Setting the priority a bug already has is a no-op — the CLI reports "no change" rather than recording a second identical entry. + +## Security scan findings (`detail vulns`) + +Security scans produce findings ("vulns") that are separate from bugs: they have a severity (`critical`, `high`, `medium`, `low`) instead of a priority, and no review state — there is no close, reopen, or prioritize for them. Vuln IDs look like `ssf_…`. When the user asks about security scan results or vulnerabilities from a security scan, use these commands rather than `detail bugs list --vulns`. + +### `detail vulns list [REPO]` + +- `--severity critical|high|medium|low` — comma-separate or repeat the flag (e.g. `--severity critical,high`). Default: every severity. +- `--sort newest|oldest|severity` — default `newest`. `severity` puts the most severe first. +- `--scan-id ` — limit to a specific security scan. Workflow IDs come from `detail vulns scans`. +- `--since` / `--until`, `--all`, `--limit <1-100>` (default 50), `--page `, `--format table|json` — same as `detail bugs list`. + +### `detail vulns show ` + +Shows the full finding: severity, file, commit, scan, linked issues, the fix PR if any, and a markdown description of the flaw, how it is reached, and the fix. + +- `--format table|json`. + +### `detail vulns scans [REPO]` + +Lists recent security scans with per-severity finding counts. + +- `--status in-progress|complete|failed|dlq`, `--since` / `--until`, `--limit`, `--page`, `--format table|json`. diff --git a/docs/HELP.md b/docs/HELP.md index 546d770..b71b471 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -34,6 +34,10 @@ This document contains the help content for the `detail` command-line program. * [`detail skill rules`↴](#detail-skill-rules) * [`detail update`↴](#detail-update) * [`detail version`↴](#detail-version) +* [`detail vulns`↴](#detail-vulns) +* [`detail vulns list`↴](#detail-vulns-list) +* [`detail vulns show`↴](#detail-vulns-show) +* [`detail vulns scans`↴](#detail-vulns-scans) ## `detail` @@ -56,9 +60,10 @@ Common workflow: * `satisfying-sort` — Run a fun animation. Humans only * `repos` — Manage repos tracked with Detail * `scans` — List and inspect scans -* `skill` — Install Detail skills (default: detail-bugs) +* `skill` — Install Detail skills (default: detail-bugs, which also covers vulns) * `update` — Update immediately (auto-update also runs in the background) * `version` — Show version information +* `vulns` — List and inspect findings from security scans @@ -138,7 +143,7 @@ List bugs for a given repository Possible values: `pending`, `resolved`, `dismissed` -* `--vulns` — Only show security vulnerabilities +* `--vulns` — Only show bugs that Detail's bug finder flagged as security vulnerabilities. For findings from security scans, use `detail vulns list` * `--priority ` — Only show bugs at these priorities — repeat the flag or comma-separate values (e.g. `--priority p1,p2`). Use `none` for bugs Detail never assigned a priority. Default: all priorities Possible values: `p1`, `p2`, `p3`, `none` @@ -523,7 +528,7 @@ List recent scans for a repository ## `detail skill` -Install Detail skills (default: detail-bugs) +Install Detail skills (default: detail-bugs, which also covers vulns) **Usage:** `detail skill [OPTIONS] [COMMAND]` @@ -561,6 +566,115 @@ Show version information +## `detail vulns` + +List and inspect findings from security scans + +**Usage:** `detail vulns ` + +###### **Subcommands:** + +* `list` — List security scan findings for a given repository +* `show` — Show the report for a security scan finding +* `scans` — List recent security scans for a repository + + + +## `detail vulns list` + +List security scan findings for a given repository + +**Usage:** `detail vulns list [OPTIONS] [REPO]` + +###### **Arguments:** + +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo (e.g., cli). If omitted, inferred from the git or jj remote (origin) + +###### **Options:** + +* `--severity ` — Only show findings at these severities — repeat the flag or comma-separate values (e.g. `--severity critical,high`). Default: all severities + + Possible values: `critical`, `high`, `medium`, `low` + +* `--sort ` — Result ordering. `severity` puts the most severe findings first + + Default value: `newest` + + Possible values: `newest`, `oldest`, `severity` + +* `--scan-id ` — Filter findings to a specific security scan by workflow request ID +* `--since ` — Only show findings created at or after this point. Accepts a duration (e.g. 1d, 24h, 30m) interpreted as "now minus this", an ISO date (YYYY-MM-DD), or an RFC3339 timestamp +* `--until ` — Only show findings created at or before this point. Same forms as --since +* `--all` — Auto-paginate: fetch every matching finding instead of a single page +* `--limit ` — Maximum number of results per page + + Default value: `50` +* `--page ` — Page number (starts at 1) + + Default value: `1` +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + + + +## `detail vulns show` + +Show the report for a security scan finding + +**Usage:** `detail vulns show [OPTIONS] ` + +###### **Arguments:** + +* `` — Vuln ID + +###### **Options:** + +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + + + +## `detail vulns scans` + +List recent security scans for a repository + +**Usage:** `detail vulns scans [OPTIONS] [REPO]` + +###### **Arguments:** + +* `` — Repository in owner/repo format or just repo name. If omitted, inferred from the git or jj remote (origin) + +###### **Options:** + +* `--status ` — Filter by workflow status (e.g. failed scans in the last day) + + Possible values: `in-progress`, `complete`, `failed`, `dlq` + +* `--since ` — Only show scans created at or after this point. Accepts a duration (e.g. 1d, 24h, 30m), an ISO date (YYYY-MM-DD), or an RFC3339 timestamp +* `--until ` — Only show scans created at or before this point. Same forms as --since +* `--limit ` — Maximum number of results per page + + Default value: `50` +* `--page ` — Page number (starts at 1) + + Default value: `1` +* `--format ` — Output format + + Default value: `table` + + Possible values: `table`, `json` + + + +
diff --git a/openapi.json b/openapi.json index 64e3149..fdf0570 100644 --- a/openapi.json +++ b/openapi.json @@ -569,6 +569,189 @@ ], "type": "string" }, + "Vuln": { + "description": "A vulnerability found by a Detail security scan.", + "properties": { + "commitSha": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "type": "integer" + }, + "description": { + "description": "Markdown: the flaw, how an attacker reaches it, and the fix.", + "type": "string" + }, + "filePath": { + "type": "string" + }, + "fixPr": { + "$ref": "#/components/schemas/FixPr" + }, + "id": { + "$ref": "#/components/schemas/VulnId" + }, + "linkedIssues": { + "items": { + "$ref": "#/components/schemas/VulnLinkedIssue" + }, + "type": "array" + }, + "repoId": { + "$ref": "#/components/schemas/RepoId" + }, + "severity": { + "$ref": "#/components/schemas/VulnSeverity" + }, + "title": { + "type": "string" + }, + "workflowRequestId": { + "nullable": true, + "pattern": "^wr_.*", + "type": "string" + } + }, + "required": [ + "id", + "title", + "description", + "severity", + "filePath", + "createdAt", + "repoId", + "commitSha", + "workflowRequestId", + "linkedIssues" + ], + "type": "object" + }, + "VulnCounts": { + "properties": { + "critical": { + "type": "integer" + }, + "high": { + "type": "integer" + }, + "low": { + "type": "integer" + }, + "medium": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "critical", + "high", + "medium", + "low", + "total" + ], + "type": "object" + }, + "VulnId": { + "pattern": "^ssf_.*", + "type": "string" + }, + "VulnLinkedIssue": { + "properties": { + "issueId": { + "type": "string" + }, + "tracker": { + "enum": [ + "linear", + "jira", + "github", + "asana" + ], + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "tracker", + "issueId", + "url" + ], + "type": "object" + }, + "VulnScan": { + "properties": { + "commitSha": { + "nullable": true, + "type": "string" + }, + "completedAt": { + "nullable": true, + "type": "integer" + }, + "createdAt": { + "type": "integer" + }, + "initiator": { + "$ref": "#/components/schemas/ScanInitiator" + }, + "ownerName": { + "type": "string" + }, + "repoId": { + "$ref": "#/components/schemas/RepoId" + }, + "repoName": { + "type": "string" + }, + "vulnCounts": { + "$ref": "#/components/schemas/VulnCounts" + }, + "workflowRequestId": { + "pattern": "^wr_.*", + "type": "string" + }, + "workflowStatus": { + "$ref": "#/components/schemas/WorkflowStatus", + "nullable": true + } + }, + "required": [ + "repoId", + "repoName", + "ownerName", + "createdAt", + "completedAt", + "initiator", + "workflowRequestId", + "commitSha", + "vulnCounts" + ], + "type": "object" + }, + "VulnSeverity": { + "description": "Severity of a security scan finding, most severe first.", + "enum": [ + "critical", + "high", + "medium", + "low" + ], + "type": "string" + }, + "VulnSortOrder": { + "enum": [ + "newest", + "oldest", + "severity" + ], + "type": "string" + }, "WorkflowStatus": { "enum": [ "in_progress", @@ -588,7 +771,7 @@ } }, "info": { - "description": "Public API for programmatic access to Detail bug findings.", + "description": "Public API for programmatic access to Detail bug and vulnerability findings.", "title": "Detail Public API", "version": "1.0.0" }, @@ -1482,6 +1665,269 @@ ], "summary": "Get current user" } + }, + "/public/v1/vuln-scans": { + "get": { + "description": "Returns a paginated list of security scans for a given repository.", + "operationId": "listPublicVulnScans", + "parameters": [ + { + "in": "query", + "name": "repo_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/RepoId" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "scans": { + "items": { + "$ref": "#/components/schemas/VulnScan" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "scans", + "total" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Client error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Server error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List security scans" + } + }, + "/public/v1/vulns": { + "get": { + "description": "Returns a paginated list of vulnerabilities that Detail security scans found in a given repository.", + "operationId": "listPublicVulns", + "parameters": [ + { + "in": "query", + "name": "repo_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/RepoId" + } + }, + { + "description": "Comma-separated severities to include, e.g. \"critical,high\". Omit to include every severity.", + "example": "critical,high", + "in": "query", + "name": "severity", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Result ordering. `severity` puts the most severe vulnerabilities first.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "$ref": "#/components/schemas/VulnSortOrder", + "default": "newest" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "type": "integer" + } + }, + { + "in": "query", + "name": "workflow_request_id", + "required": false, + "schema": { + "pattern": "^wr_.*", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "total": { + "type": "integer" + }, + "vulns": { + "items": { + "$ref": "#/components/schemas/Vuln" + }, + "type": "array" + } + }, + "required": [ + "vulns", + "total" + ], + "type": "object" + } + } + }, + "description": "OK" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Client error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Server error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "List vulnerabilities" + } + }, + "/public/v1/vulns/{vuln_id}": { + "get": { + "description": "Returns a single vulnerability by its ID.", + "operationId": "getPublicVuln", + "parameters": [ + { + "in": "path", + "name": "vuln_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/VulnId" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Vuln" + } + } + }, + "description": "OK" + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Client error" + }, + "5XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + }, + "description": "Server error" + } + }, + "security": [ + { + "BearerAuth": [] + } + ], + "summary": "Get a vulnerability" + } } }, "servers": [ diff --git a/src/api/client.rs b/src/api/client.rs index 248d7f3..f678e41 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -12,9 +12,10 @@ use super::generated::types::CreateRuleBody; use super::types::{ Bug, BugDismissalReason, BugId, BugReview, BugReviewState, BugSortOrder, BugsResponse, CreatePublicBugReviewBody, CreateRuleInput, CreateRuleResponse, - ListPublicBugsWorkflowRequestId, Priority, PriorityUpdate, RepoId, ReposResponse, Rule, - RuleCreationRequestId, RuleId, RuleRequestStatus, RuleRequestsResponse, RulesResponse, - ScansResponse, UpdatePublicBugPriorityBody, UserInfo, + ListPublicBugsWorkflowRequestId, ListPublicVulnsWorkflowRequestId, Priority, PriorityUpdate, + RepoId, ReposResponse, Rule, RuleCreationRequestId, RuleId, RuleRequestStatus, + RuleRequestsResponse, RulesResponse, ScansResponse, UpdatePublicBugPriorityBody, UserInfo, + Vuln, VulnId, VulnScansResponse, VulnSortOrder, VulnsResponse, }; /// Server-side knobs for `GET /public/v1/bugs` beyond repo, status, and paging. @@ -28,6 +29,15 @@ pub struct BugListQuery<'a> { pub scan_id: Option<&'a ListPublicBugsWorkflowRequestId>, } +/// Server-side knobs for `GET /public/v1/vulns` beyond repo and paging. +#[derive(Clone, Copy, Default)] +pub struct VulnListQuery<'a> { + /// Comma-separated severity filter, already in the API's wire form. + pub severity: Option<&'a str>, + pub sort: Option, + pub scan_id: Option<&'a ListPublicVulnsWorkflowRequestId>, +} + /// Convert a progenitor client error into a concise anyhow error. /// /// progenitor's own `Display` for `ErrorResponse` dumps headers and the typed @@ -199,6 +209,48 @@ impl ApiClient { .map_err(api_error) } + pub async fn list_vulns( + &self, + repo_id: &RepoId, + limit: u32, + offset: u32, + query: VulnListQuery<'_>, + ) -> Result { + self.inner + .list_public_vulns( + NonZeroU64::new(limit.into()), + Some(offset.into()), + repo_id, + query.severity, + query.sort, + query.scan_id, + ) + .await + .map(ResponseValue::into_inner) + .map_err(api_error) + } + + pub async fn get_vuln(&self, vuln_id: &VulnId) -> Result { + self.inner + .get_public_vuln(vuln_id) + .await + .map(ResponseValue::into_inner) + .map_err(api_error) + } + + pub async fn list_vuln_scans( + &self, + repo_id: &RepoId, + limit: u32, + offset: u32, + ) -> Result { + self.inner + .list_public_vuln_scans(NonZeroU64::new(limit.into()), Some(offset.into()), repo_id) + .await + .map(ResponseValue::into_inner) + .map_err(api_error) + } + pub async fn list_repos(&self, limit: u32, offset: u32) -> Result { self.inner .list_public_repos(NonZeroU64::new(limit.into()), Some(offset.into())) diff --git a/src/api/types.rs b/src/api/types.rs index 26560b2..752dc38 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -12,6 +12,10 @@ pub use super::generated::types::{ RuleRequestResult, RuleRequestStatus, RuleStatus, Scan, ScanInitiator, ScanType, UpdatePublicBugPriorityBody, WorkflowStatus, }; +pub use super::generated::types::{ + ListPublicVulnsWorkflowRequestId, Vuln, VulnCounts, VulnId, VulnLinkedIssue, + VulnLinkedIssueTracker, VulnScan, VulnSeverity, VulnSortOrder, +}; // Friendlier aliases for the generated response-wrapper names. pub type UserInfo = super::generated::types::GetPublicUserResponse; @@ -21,6 +25,8 @@ pub type ScansResponse = super::generated::types::ListPublicScansResponse; pub type RulesResponse = super::generated::types::ListRulesResponse; pub type RuleRequestsResponse = super::generated::types::ListRuleRequestsResponse; pub type PriorityUpdate = super::generated::types::UpdatePublicBugPriorityResponse; +pub type VulnsResponse = super::generated::types::ListPublicVulnsResponse; +pub type VulnScansResponse = super::generated::types::ListPublicVulnScansResponse; // ── Display helpers ────────────────────────────────────────────────── // progenitor already implements Display for the generated enums, so we @@ -53,6 +59,15 @@ pub const fn priority_label(p: &Priority) -> &'static str { } } +pub const fn severity_label(s: &VulnSeverity) -> &'static str { + match s { + VulnSeverity::Critical => "Critical", + VulnSeverity::High => "High", + VulnSeverity::Medium => "Medium", + VulnSeverity::Low => "Low", + } +} + pub const fn rule_status_label(s: &RuleStatus) -> &'static str { match s { RuleStatus::Pending => "Pending", @@ -94,6 +109,31 @@ pub fn format_linked_issue(issue: &LinkedIssue) -> String { } } +/// Format linked issues compactly for list cards, e.g. "linear: ENG-42, jira: PROJ-9". +/// URLs are left to the show view. +fn format_linked_issues_short(issues: &[LinkedIssue]) -> String { + issues + .iter() + .map(|i| match i.tracker { + LinkedIssueTracker::Slack => i.tracker.to_string(), + LinkedIssueTracker::Linear + | LinkedIssueTracker::Jira + | LinkedIssueTracker::Github + | LinkedIssueTracker::Asana => format!("{}: {}", i.tracker, i.issue_id), + }) + .collect::>() + .join(", ") +} + +/// Format a security-scan finding's linked issue for the detail/show view. +/// Same shape as `format_linked_issue`; vuln trackers never include Slack. +pub fn format_vuln_linked_issue(issue: &VulnLinkedIssue) -> String { + issue.url.as_ref().map_or_else( + || format!("{}: {}", issue.tracker, issue.issue_id), + |url| format!("{}: {} \u{2014} {url}", issue.tracker, issue.issue_id), + ) +} + /// Format the Detail-generated fix PR for the detail/show view. /// /// Rendered as `# () — `. The URL and its separator are @@ -168,6 +208,35 @@ impl clap::ValueEnum for BugSortOrder { } } +impl clap::ValueEnum for VulnSeverity { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Critical, Self::High, Self::Medium, Self::Low] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Critical => Some(PossibleValue::new("critical")), + Self::High => Some(PossibleValue::new("high")), + Self::Medium => Some(PossibleValue::new("medium")), + Self::Low => Some(PossibleValue::new("low")), + } + } +} + +impl clap::ValueEnum for VulnSortOrder { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Newest, Self::Oldest, Self::Severity] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Newest => Some(PossibleValue::new("newest")), + Self::Oldest => Some(PossibleValue::new("oldest")), + Self::Severity => Some(PossibleValue::new("severity")), + } + } +} + impl clap::ValueEnum for WorkflowStatus { fn value_variants<'a>() -> &'a [Self] { &[Self::InProgress, Self::Complete, Self::Failed, Self::Dlq] @@ -221,19 +290,10 @@ impl Formattable for Bug { pairs.push(("Introduced", format_introduced_in(intro))); } if !self.linked_issues.is_empty() { - let formatted = self - .linked_issues - .iter() - .map(|i| match i.tracker { - LinkedIssueTracker::Slack => i.tracker.to_string(), - LinkedIssueTracker::Linear - | LinkedIssueTracker::Jira - | LinkedIssueTracker::Github - | LinkedIssueTracker::Asana => format!("{}: {}", i.tracker, i.issue_id), - }) - .collect::>() - .join(", "); - pairs.push(("Linked Issues", formatted)); + pairs.push(( + "Linked Issues", + format_linked_issues_short(&self.linked_issues), + )); } if let Some(fix_pr) = &self.fix_pr { pairs.push(( @@ -291,6 +351,78 @@ impl Formattable for Scan { } } +impl Formattable for Vuln { + fn to_card(&self) -> (String, Vec<(&'static str, String)>) { + let mut pairs = vec![ + ("Vuln ID", self.id.to_string()), + ("Severity", severity_label(&self.severity).to_string()), + ("Created", format_date(self.created_at)), + ]; + if !self.file_path.is_empty() { + pairs.push(("File", self.file_path.clone())); + } + if !self.linked_issues.is_empty() { + let formatted = self + .linked_issues + .iter() + .map(|i| format!("{}: {}", i.tracker, i.issue_id)) + .collect::>() + .join(", "); + pairs.push(("Linked Issues", formatted)); + } + if let Some(fix_pr) = &self.fix_pr { + pairs.push(( + "Fix PR", + format!("#{} ({})", fix_pr.pr_number, fix_pr.state), + )); + } + (self.title.clone(), pairs) + } +} + +/// Per-severity breakdown for a scan card, most severe first, skipping +/// severities with no findings, e.g. "1 Critical, 3 Low". +fn format_vuln_counts(counts: &VulnCounts) -> String { + [ + (counts.critical, "Critical"), + (counts.high, "High"), + (counts.medium, "Medium"), + (counts.low, "Low"), + ] + .iter() + .filter(|(n, _)| *n > 0) + .map(|(n, label)| format!("{n} {label}")) + .collect::>() + .join(", ") +} + +impl Formattable for VulnScan { + fn to_card(&self) -> (String, Vec<(&'static str, String)>) { + let repo = format!("{}/{}", self.owner_name, self.repo_name); + let header = format!("{repo} {} Vulns Found", self.vuln_counts.total); + let mut pairs = Vec::new(); + // Omitted on a clean scan: an empty breakdown says nothing the + // "0 Vulns Found" header doesn't. + if self.vuln_counts.total > 0 { + pairs.push(("Severity", format_vuln_counts(&self.vuln_counts))); + } + pairs.push(( + "Status", + self.workflow_status + .as_ref() + .map_or_else(|| "-".to_string(), ToString::to_string), + )); + pairs.push(("Initiator", self.initiator.to_string())); + if let Some(sha) = self.commit_sha.as_deref() { + let short = sha.get(..7).unwrap_or(sha); + pairs.push(("Commit", short.to_string())); + } + pairs.push(("Workflow ID", self.workflow_request_id.to_string())); + pairs.push(("Created", format_datetime(self.created_at))); + (header, pairs) + } +} + impl Formattable for Repo { fn to_card(&self) -> (String, Vec<(&'static str, String)>) { ( @@ -891,4 +1023,98 @@ mod tests { Some(&"wr_abc123".to_string()) ); } + + // ── Vuln / VulnScan Formattable ────────────────────────────────── + + fn sample_vuln() -> Vuln { + serde_json::from_value(serde_json::json!({ + "id": "ssf_abc", "title": "SQL injection in search", + "description": "...", "severity": "critical", + "filePath": "src/search.rs", "createdAt": 1_736_899_200_000_i64, + "repoId": "repo_xyz", "commitSha": null, "workflowRequestId": null, + "linkedIssues": [] + })) + .expect("valid Vuln JSON") + } + + fn sample_vuln_scan(counts: serde_json::Value) -> VulnScan { + serde_json::from_value(serde_json::json!({ + "repoId": "repo_xyz", "repoName": "cli", "ownerName": "usedetail", + "workflowStatus": "complete", "createdAt": 1_736_899_200_000_i64, + "completedAt": null, "initiator": "scheduler", + "workflowRequestId": "wr_1", "commitSha": "abcdef1234567", + "vulnCounts": counts + })) + .expect("valid VulnScan JSON") + } + + #[test] + fn severity_labels() { + assert_eq!(severity_label(&VulnSeverity::Critical), "Critical"); + assert_eq!(severity_label(&VulnSeverity::Low), "Low"); + } + + #[test] + fn severity_possible_values() { + let values: Vec = VulnSeverity::value_variants() + .iter() + .map(|v| v.to_possible_value().unwrap().get_name().to_string()) + .collect(); + assert_eq!(values, ["critical", "high", "medium", "low"]); + } + + #[test] + fn vuln_card_minimal() { + let (header, pairs) = sample_vuln().to_card(); + assert_eq!(header, "SQL injection in search"); + let keys: Vec<&str> = pairs.iter().map(|(k, _)| *k).collect(); + assert_eq!(keys, ["Vuln ID", "Severity", "Created", "File"]); + assert_eq!(pairs[1].1, "Critical"); + } + + #[test] + fn vuln_card_shows_issues_and_fix_pr() { + let mut vuln = sample_vuln(); + vuln.linked_issues = vec![serde_json::from_value( + serde_json::json!({ "tracker": "jira", "issueId": "SEC-9", "url": null }), + ) + .unwrap()]; + vuln.fix_pr = Some( + serde_json::from_value( + serde_json::json!({ "prNumber": 12, "url": "https://x", "state": "merged" }), + ) + .unwrap(), + ); + let (_, pairs) = vuln.to_card(); + let get = |k: &str| { + pairs + .iter() + .find(|(key, _)| *key == k) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("Linked Issues"), Some("jira: SEC-9")); + assert_eq!(get("Fix PR"), Some("#12 (merged)")); + } + + #[test] + fn vuln_scan_card_shows_nonzero_severities() { + let scan = sample_vuln_scan( + serde_json::json!({ "critical": 1, "high": 0, "medium": 2, "low": 0, "total": 3 }), + ); + let (header, pairs) = scan.to_card(); + assert_eq!(header, "usedetail/cli 3 Vulns Found"); + assert_eq!(pairs[0], ("Severity", "1 Critical, 2 Medium".to_string())); + assert!(pairs.contains(&("Commit", "abcdef1".to_string()))); + assert!(pairs.contains(&("Workflow ID", "wr_1".to_string()))); + } + + #[test] + fn vuln_scan_card_omits_severity_when_clean() { + let scan = sample_vuln_scan( + serde_json::json!({ "critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0 }), + ); + let (header, pairs) = scan.to_card(); + assert_eq!(header, "usedetail/cli 0 Vulns Found"); + assert!(!pairs.iter().any(|(k, _)| *k == "Severity")); + } } diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index b9ba731..77f319a 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -234,7 +234,9 @@ pub enum BugCommands { #[arg(long, value_enum, value_delimiter = ',', default_value = "pending")] status: Vec, - /// Only show security vulnerabilities + /// Only show bugs that Detail's bug finder flagged as security + /// vulnerabilities. For findings from security scans, use + /// `detail vulns list`. #[arg(long)] vulns: bool, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a255c68..e7743e8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,3 +7,4 @@ pub mod satisfying_sort; pub mod scans; pub mod skill; pub mod update; +pub mod vulns; diff --git a/src/commands/vulns.rs b/src/commands/vulns.rs new file mode 100644 index 0000000..f12591c --- /dev/null +++ b/src/commands/vulns.rs @@ -0,0 +1,572 @@ +use std::convert::TryInto; + +use anyhow::{Context, Result}; +use clap::Subcommand; +use console::Term; + +use crate::api::client::{ApiClient, VulnListQuery}; +use crate::api::types::{ + format_fix_pr, format_vuln_linked_issue, severity_label, ListPublicVulnsWorkflowRequestId, + RepoId, Vuln, VulnId, VulnScan, VulnSeverity, VulnSortOrder, WorkflowStatus, +}; +use crate::output::{clamp_page, output_list, SectionRenderer}; +use crate::utils::datetime::{format_datetime, parse_time_spec}; +use crate::utils::pagination::page_to_offset; +use crate::utils::repos::resolve_repo_id; +use crate::utils::vcs::resolve_repo_arg; + +#[derive(Subcommand)] +pub enum VulnCommands { + /// List security scan findings for a given repository + List { + /// Repository by owner/repo (e.g., usedetail/cli) or repo (e.g., cli). + /// If omitted, inferred from the git or jj remote (origin). + repo: Option, + + /// Only show findings at these severities — repeat the flag or + /// comma-separate values (e.g. `--severity critical,high`). + /// Default: all severities. + #[arg(long, value_enum, value_delimiter = ',')] + severity: Vec, + + /// Result ordering. `severity` puts the most severe findings first. + #[arg(long, value_enum, default_value = "newest")] + sort: VulnSortOrder, + + /// Filter findings to a specific security scan by workflow request ID + #[arg(long)] + scan_id: Option, + + /// Only show findings created at or after this point. + /// Accepts a duration (e.g. 1d, 24h, 30m) interpreted as "now minus + /// this", an ISO date (YYYY-MM-DD), or an RFC3339 timestamp. + #[arg(long)] + since: Option, + + /// Only show findings created at or before this point. Same forms as --since. + #[arg(long)] + until: Option, + + /// Auto-paginate: fetch every matching finding instead of a single page. + #[arg(long, conflicts_with_all = ["page", "limit"])] + all: bool, + + /// Maximum number of results per page + #[arg(long, default_value = "50", value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + + /// Page number (starts at 1) + #[arg(long, default_value = "1", value_parser = clap::value_parser!(u32).range(1..))] + page: u32, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, + }, + + /// Show the report for a security scan finding + Show { + /// Vuln ID + vuln_id: String, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, + }, + + /// List recent security scans for a repository + Scans { + /// Repository in owner/repo format or just repo name. + /// If omitted, inferred from the git or jj remote (origin). + repo: Option, + + /// Filter by workflow status (e.g. failed scans in the last day). + #[arg(long, value_enum)] + status: Option, + + /// Only show scans created at or after this point. + /// Accepts a duration (e.g. 1d, 24h, 30m), an ISO date + /// (YYYY-MM-DD), or an RFC3339 timestamp. + #[arg(long)] + since: Option, + + /// Only show scans created at or before this point. Same forms as --since. + #[arg(long)] + until: Option, + + /// Maximum number of results per page + #[arg(long, default_value = "50", value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + + /// Page number (starts at 1) + #[arg(long, default_value = "1", value_parser = clap::value_parser!(u32).range(1..))] + page: u32, + + /// Output format + #[arg(long, value_enum, default_value = "table")] + format: crate::OutputFormat, + }, +} + +/// Join `--severity` values into the comma-separated form the API takes. +/// +/// Preserves first-seen order and drops repeats. Returns `None` for an empty +/// selection so the query param is omitted rather than sent empty. +fn severity_query(severities: &[VulnSeverity]) -> Option { + if severities.is_empty() { + return None; + } + let mut deduped: Vec = Vec::with_capacity(severities.len()); + for severity in severities { + if !deduped.contains(severity) { + deduped.push(*severity); + } + } + Some( + deduped + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + ) +} + +/// Resolve a `--since` / `--until` flag value to epoch millis, flattening +/// `parse_time_spec`'s error into the top-level message. +fn resolve_time_flag( + name: &str, + value: Option<&str>, + now: chrono::DateTime, +) -> Result> { + value.map_or(Ok(None), |s| { + parse_time_spec(s, now) + .map(|dt| Some(dt.timestamp_millis())) + .map_err(|e| anyhow::anyhow!("invalid {name} value: {e}")) + }) +} + +/// Return only findings whose `createdAt` falls within the given inclusive bounds. +fn filter_by_time_range(vulns: &[Vuln], since_ms: Option, until_ms: Option) -> Vec { + vulns + .iter() + .filter(|v| { + since_ms.is_none_or(|s| v.created_at >= s) && until_ms.is_none_or(|u| v.created_at <= u) + }) + .cloned() + .collect() +} + +fn filter_scans( + scans: &[VulnScan], + status: Option<&WorkflowStatus>, + since_ms: Option, + until_ms: Option, +) -> Vec { + scans + .iter() + .filter(|s| status.is_none_or(|want| s.workflow_status.as_ref() == Some(want))) + .filter(|s| since_ms.is_none_or(|m| s.created_at >= m)) + .filter(|s| until_ms.is_none_or(|m| s.created_at <= m)) + .cloned() + .collect() +} + +fn paginate_items(items: &[T], page: u32, limit: u32) -> Vec { + let offset = usize::try_from(page_to_offset(page, limit)).unwrap_or(0); + items + .iter() + .skip(offset) + .take(usize::try_from(limit).unwrap_or(0)) + .cloned() + .collect() +} + +/// Key-value rows for the human-readable `vulns show` view. The markdown +/// description is rendered beneath them. +fn vuln_show_pairs(vuln: &Vuln) -> Vec<(&'static str, String)> { + let mut pairs: Vec<(&'static str, String)> = vec![ + ("ID", vuln.id.to_string()), + ("Title", vuln.title.clone()), + ("Severity", severity_label(&vuln.severity).to_string()), + ( + "File", + if vuln.file_path.is_empty() { + "-".to_string() + } else { + vuln.file_path.clone() + }, + ), + ("Created", format_datetime(vuln.created_at)), + ]; + if let Some(sha) = vuln.commit_sha.as_deref() { + let short = sha.get(..7).unwrap_or(sha); + pairs.push(("Commit", short.to_string())); + } + if let Some(wr) = &vuln.workflow_request_id { + pairs.push(("Scan", wr.to_string())); + } + for issue in &vuln.linked_issues { + pairs.push(("Issue", format_vuln_linked_issue(issue))); + } + if let Some(fix_pr) = &vuln.fix_pr { + pairs.push(("Fix PR", format_fix_pr(fix_pr))); + } + pairs +} + +/// Page size used when fetching every finding or scan for client-side filtering. +const PAGE_SIZE: u32 = 100; + +/// Fetch every finding for a repo by paginating through all pages. +async fn fetch_all_vulns( + client: &ApiClient, + repo_id: &RepoId, + query: VulnListQuery<'_>, +) -> Result> { + let mut all = Vec::new(); + let mut offset = 0; + + loop { + let response = client + .list_vulns(repo_id, PAGE_SIZE, offset, query) + .await + .context("Failed to fetch vulns from repository")?; + + let total = usize::try_from(response.total.max(0)).unwrap_or(0); + let page_len = response.vulns.len(); + all.extend(response.vulns); + + if page_len == 0 || (usize::try_from(offset).unwrap_or(0) + page_len) >= total { + break; + } + offset += u32::try_from(page_len).unwrap_or(u32::MAX); + } + + Ok(all) +} + +/// Fetch every security scan for a repo by paginating through all pages. +async fn fetch_all_scans(client: &ApiClient, repo_id: &RepoId) -> Result> { + let mut all = Vec::new(); + let mut offset = 0; + + loop { + let response = client + .list_vuln_scans(repo_id, PAGE_SIZE, offset) + .await + .context("Failed to fetch security scans")?; + + let total = usize::try_from(response.total.max(0)).unwrap_or(0); + let page_len = response.scans.len(); + all.extend(response.scans); + + if page_len == 0 || (usize::try_from(offset).unwrap_or(0) + page_len) >= total { + break; + } + offset += u32::try_from(page_len).unwrap_or(u32::MAX); + } + + Ok(all) +} + +pub async fn handle(command: &VulnCommands, cli: &crate::Cli) -> Result<()> { + let client = cli.create_client()?; + + match command { + VulnCommands::List { + repo, + severity, + sort, + scan_id, + since, + until, + all, + limit, + page, + format, + } => { + let repo = resolve_repo_arg(repo.as_deref())?; + let resolved_repo_id = resolve_repo_id(&client, &repo) + .await + .context("Failed to resolve repository identifier")?; + + let scan_id: Option = scan_id + .as_deref() + .map(TryInto::try_into) + .transpose() + .context("Invalid scan ID format (expected wr_...)")?; + + let now = chrono::Utc::now(); + let since_ms = resolve_time_flag("--since", since.as_deref(), now)?; + let until_ms = resolve_time_flag("--until", until.as_deref(), now)?; + + // `--severity`, `--sort` and `--scan-id` are applied by the API; + // only `--all` and the time window force a full fetch. + let severity_filter = severity_query(severity); + let query = VulnListQuery { + severity: severity_filter.as_deref(), + sort: Some(*sort), + scan_id: scan_id.as_ref(), + }; + + let needs_full_fetch = *all || since_ms.is_some() || until_ms.is_some(); + + if needs_full_fetch { + let mut filtered = fetch_all_vulns(&client, &resolved_repo_id, query).await?; + if since_ms.is_some() || until_ms.is_some() { + filtered = filter_by_time_range(&filtered, since_ms, until_ms); + } + if filtered.is_empty() && matches!(format, crate::OutputFormat::Table) { + Term::stdout().write_line( + "No security vulnerabilities found with the current filters.", + )?; + } + let total = filtered.len(); + if *all { + let effective_limit = u32::try_from(total.max(1)).unwrap_or(u32::MAX); + return output_list(&filtered, total, 1, effective_limit, format); + } + let page = clamp_page(*page, total, *limit); + let page_items = paginate_items(&filtered, page, *limit); + output_list(&page_items, total, page, *limit, format) + } else { + let offset = page_to_offset(*page, *limit); + let vulns = client + .list_vulns(&resolved_repo_id, *limit, offset, query) + .await + .context("Failed to fetch vulns from repository")?; + + output_list( + &vulns.vulns, + usize::try_from(vulns.total.max(0)).unwrap_or(0), + *page, + *limit, + format, + ) + } + } + + VulnCommands::Show { vuln_id, format } => { + let vuln_id: VulnId = vuln_id + .as_str() + .try_into() + .context("Invalid vuln ID format (expected ssf_...)")?; + let vuln = client + .get_vuln(&vuln_id) + .await + .context("Failed to fetch vuln details")?; + + if matches!(format, crate::OutputFormat::Json) { + Term::stdout().write_line(&serde_json::to_string_pretty(&vuln)?)?; + return Ok(()); + } + SectionRenderer::new() + .key_value("", &vuln_show_pairs(&vuln)) + .markdown("", &vuln.description) + .print() + } + + VulnCommands::Scans { + repo, + status, + since, + until, + limit, + page, + format, + } => { + let repo = resolve_repo_arg(repo.as_deref())?; + let repo_id = resolve_repo_id(&client, &repo) + .await + .context("Failed to resolve repository identifier")?; + + let now = chrono::Utc::now(); + let since_ms = resolve_time_flag("--since", since.as_deref(), now)?; + let until_ms = resolve_time_flag("--until", until.as_deref(), now)?; + + // The security scans API has no server-side filter, so any + // active filter forces the all-fetch path. + let needs_full_fetch = status.is_some() || since_ms.is_some() || until_ms.is_some(); + + if needs_full_fetch { + let all = fetch_all_scans(&client, &repo_id).await?; + let filtered = filter_scans(&all, status.as_ref(), since_ms, until_ms); + let total = filtered.len(); + let page = clamp_page(*page, total, *limit); + let page_items = paginate_items(&filtered, page, *limit); + output_list(&page_items, total, page, *limit, format) + } else { + let offset = page_to_offset(*page, *limit); + let scans = client + .list_vuln_scans(&repo_id, *limit, offset) + .await + .context("Failed to fetch security scans")?; + + output_list( + &scans.scans, + usize::try_from(scans.total.max(0)).unwrap_or(0), + *page, + *limit, + format, + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_vuln(id: &str, created_at: i64) -> Vuln { + serde_json::from_value(serde_json::json!({ + "id": format!("ssf_{id}"), + "title": id, + "description": "## Flaw\n...", + "severity": "high", + "filePath": "src/a.rs", + "createdAt": created_at, + "repoId": "repo_1", + "commitSha": null, + "workflowRequestId": null, + "linkedIssues": [] + })) + .unwrap() + } + + fn make_scan(wr: &str, created_at: i64, status: &str) -> VulnScan { + serde_json::from_value(serde_json::json!({ + "repoId": "repo_1", "repoName": "c", "ownerName": "u", + "workflowStatus": status, "createdAt": created_at, + "completedAt": null, "initiator": "scheduler", + "workflowRequestId": wr, "commitSha": null, + "vulnCounts": { "critical": 0, "high": 1, "medium": 0, "low": 0, "total": 1 } + })) + .unwrap() + } + + fn ids(vulns: &[Vuln]) -> Vec { + vulns.iter().map(|v| v.id.to_string()).collect() + } + + // ── severity_query ─────────────────────────────────────────────── + + #[test] + fn severity_query_omits_empty_selection() { + assert!(severity_query(&[]).is_none()); + } + + #[test] + fn severity_query_joins_lowercase() { + assert_eq!( + severity_query(&[VulnSeverity::Critical, VulnSeverity::Low]).as_deref(), + Some("critical,low") + ); + } + + #[test] + fn severity_query_dedupes_preserving_order() { + assert_eq!( + severity_query(&[ + VulnSeverity::High, + VulnSeverity::Critical, + VulnSeverity::High + ]) + .as_deref(), + Some("high,critical") + ); + } + + // ── filter_by_time_range ───────────────────────────────────────── + + #[test] + fn time_range_is_inclusive() { + let vulns = vec![ + make_vuln("old", 1_000), + make_vuln("mid", 2_000), + make_vuln("new", 3_000), + ]; + assert_eq!(filter_by_time_range(&vulns, None, None).len(), 3); + assert_eq!( + ids(&filter_by_time_range(&vulns, Some(2_000), None)), + ["ssf_mid", "ssf_new"] + ); + assert_eq!( + ids(&filter_by_time_range(&vulns, None, Some(2_000))), + ["ssf_old", "ssf_mid"] + ); + assert!(filter_by_time_range(&vulns, Some(3_000), Some(1_000)).is_empty()); + } + + // ── filter_scans ───────────────────────────────────────────────── + + #[test] + fn filter_scans_by_status_and_window() { + let scans = vec![ + make_scan("wr_a", 1_000, "complete"), + make_scan("wr_b", 2_000, "failed"), + make_scan("wr_c", 3_000, "complete"), + ]; + assert_eq!(filter_scans(&scans, None, None, None).len(), 3); + let failed = filter_scans(&scans, Some(&WorkflowStatus::Failed), None, None); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].workflow_request_id.as_str(), "wr_b"); + let recent_complete = + filter_scans(&scans, Some(&WorkflowStatus::Complete), Some(2_000), None); + assert_eq!(recent_complete.len(), 1); + assert_eq!(recent_complete[0].workflow_request_id.as_str(), "wr_c"); + } + + // ── show view ──────────────────────────────────────────────────── + + #[test] + fn show_pairs_minimal() { + let pairs = vuln_show_pairs(&make_vuln("x", 1)); + let keys: Vec<&str> = pairs.iter().map(|(k, _)| *k).collect(); + assert_eq!(keys, ["ID", "Title", "Severity", "File", "Created"]); + assert_eq!(pairs[2].1, "High"); + } + + #[test] + fn show_pairs_full() { + let vuln: Vuln = serde_json::from_value(serde_json::json!({ + "id": "ssf_full", "title": "SSRF in fetcher", "description": "...", + "severity": "critical", "filePath": "src/fetch.rs", "createdAt": 1, + "repoId": "repo_1", "commitSha": "abcdef1234567", + "workflowRequestId": "wr_123", + "linkedIssues": [{ "tracker": "linear", "issueId": "SEC-1", "url": "https://l/SEC-1" }], + "fixPr": { "prNumber": 7, "url": "https://gh/pr/7", "state": "open" } + })) + .unwrap(); + let pairs = vuln_show_pairs(&vuln); + let get = |k: &str| { + pairs + .iter() + .find(|(key, _)| *key == k) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("Severity"), Some("Critical")); + assert_eq!(get("Commit"), Some("abcdef1")); + assert_eq!(get("Scan"), Some("wr_123")); + assert_eq!(get("Issue"), Some("linear: SEC-1 \u{2014} https://l/SEC-1")); + assert_eq!(get("Fix PR"), Some("#7 (open) \u{2014} https://gh/pr/7")); + } + + #[test] + fn show_pairs_dash_for_empty_file() { + let mut vuln = make_vuln("x", 1); + vuln.file_path = String::new(); + let pairs = vuln_show_pairs(&vuln); + assert_eq!(pairs.iter().find(|(k, _)| *k == "File").unwrap().1, "-"); + } + + // ── paginate_items ─────────────────────────────────────────────── + + #[test] + fn paginate_items_pages() { + let items = vec![1, 2, 3, 4, 5]; + assert_eq!(paginate_items(&items, 1, 2), vec![1, 2]); + assert_eq!(paginate_items(&items, 3, 2), vec![5]); + assert!(paginate_items(&items, 4, 2).is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 12b181c..6387a71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,6 +100,11 @@ impl Cli { Commands::Scans { command } => match command { commands::scans::ScanCommands::List { format, .. } => Self::is_json(format), }, + Commands::Vulns { command } => match command { + commands::vulns::VulnCommands::List { format, .. } + | commands::vulns::VulnCommands::Show { format, .. } + | commands::vulns::VulnCommands::Scans { format, .. } => Self::is_json(format), + }, Commands::Rules { command } => match command { commands::rules::RuleCommands::List { format, .. } | commands::rules::RuleCommands::Requests( @@ -151,6 +156,7 @@ impl Cli { Commands::Scans { command } => commands::scans::handle(command, &self).await, Commands::Skill { user, command } => commands::skill::handle(command.as_ref(), *user), Commands::Update => commands::update::handle().await, + Commands::Vulns { command } => commands::vulns::handle(command, &self).await, Commands::Version => { console::Term::stdout().write_line(&format!("detail-cli v{VERSION}"))?; Ok(()) @@ -208,7 +214,7 @@ enum Commands { command: commands::scans::ScanCommands, }, - /// Install Detail skills (default: detail-bugs) + /// Install Detail skills (default: detail-bugs, which also covers vulns) Skill { /// Install to user-level ~/.claude/skills instead of the current repo #[arg(long, global = true)] @@ -223,12 +229,18 @@ enum Commands { /// Show version information Version, + + /// List and inspect findings from security scans + Vulns { + #[command(subcommand)] + command: commands::vulns::VulnCommands, + }, } #[cfg(test)] mod tests { use super::*; - use crate::api::types::BugReviewState; + use crate::api::types::{BugReviewState, VulnSeverity, VulnSortOrder}; #[test] fn silent_when_bugs_list_json() { @@ -370,6 +382,89 @@ mod tests { assert!(cli.is_silent()); } + #[test] + fn silent_when_vulns_json() { + for args in [ + &["detail", "vulns", "list", "owner/repo", "--format", "json"][..], + &["detail", "vulns", "show", "ssf_1", "--format", "json"], + &["detail", "vulns", "scans", "owner/repo", "--format", "json"], + ] { + assert!(Cli::try_parse_from(args).unwrap().is_silent(), "{args:?}"); + } + } + + #[test] + fn not_silent_when_vulns_table() { + let cli = Cli::try_parse_from(["detail", "vulns", "list"]).unwrap(); + assert!(!cli.is_silent()); + } + + #[test] + fn vulns_list_parses_filters() { + let cli = Cli::try_parse_from([ + "detail", + "vulns", + "list", + "--severity", + "critical,high", + "--severity", + "low", + "--sort", + "severity", + "--scan-id", + "wr_1", + ]) + .unwrap(); + if let Commands::Vulns { + command: + commands::vulns::VulnCommands::List { + repo, + severity, + sort, + scan_id, + .. + }, + } = &cli.command + { + assert!(repo.is_none()); + assert_eq!( + severity, + &[ + VulnSeverity::Critical, + VulnSeverity::High, + VulnSeverity::Low + ] + ); + assert_eq!(*sort, VulnSortOrder::Severity); + assert_eq!(scan_id.as_deref(), Some("wr_1")); + } else { + panic!("expected vulns list command"); + } + } + + #[test] + fn vulns_list_rejects_unknown_severity() { + assert!(Cli::try_parse_from(["detail", "vulns", "list", "--severity", "p1"]).is_err()); + } + + #[test] + fn vulns_list_all_conflicts_with_page() { + assert!(Cli::try_parse_from(["detail", "vulns", "list", "--all", "--page", "2"]).is_err()); + } + + #[test] + fn vulns_list_limit_bounds() { + assert!(Cli::try_parse_from(["detail", "vulns", "list", "--limit", "0"]).is_err()); + assert!(Cli::try_parse_from(["detail", "vulns", "list", "--limit", "101"]).is_err()); + } + + #[test] + fn vulns_has_no_mutating_subcommands() { + for sub in ["close", "reopen", "prioritize"] { + assert!(Cli::try_parse_from(["detail", "vulns", sub, "ssf_1"]).is_err()); + } + } + #[test] fn not_silent_for_auth_status() { let cli = Cli::try_parse_from(["detail", "auth", "status"]).unwrap(); diff --git a/tests/integration.rs b/tests/integration.rs index 26733d0..1858c6c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -366,6 +366,7 @@ fn subcommand_help_succeeds() { "skill", "update", "version", + "vulns", ] { let out = env.run(&[subcmd, "--help"]); // Some subcommands may not support --help directly if they require subcommands, @@ -391,6 +392,9 @@ fn commands_fail_without_auth() { let out = env.run(&["scans", "list", REPO, "--format", "json"]); assert!(!out.success, "scans list should fail without auth"); + + let out = env.run(&["vulns", "list", REPO, "--format", "json"]); + assert!(!out.success, "vulns list should fail without auth"); } #[test] From 48a292d259bc36e45c6693897ccb75db7c7117fd Mon Sep 17 00:00:00 2001 From: Tyler Dammann Date: Thu, 29 Oct 2026 20:40:36 -0700 Subject: [PATCH 2/3] fix(vulns): don't present unfinished scans as clean; share card formatters VulnScan cards now show the vuln count only for completed scans or scans that already recorded findings; in-progress, failed and DLQ'd scans with zero counts fall back to the bare repo name, as the bugs Scan card does. The Vuln card and show view reuse the bug linked-issue and fix-PR formatters (via a From<&VulnLinkedIssue> for LinkedIssue conversion), and the severity breakdown uses severity_label. Co-Authored-By: Claude Opus 5.5 --- src/api/types.rs | 119 ++++++++++++++++++++++++++++-------------- src/commands/vulns.rs | 6 +-- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/src/api/types.rs b/src/api/types.rs index 752dc38..72b9a97 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -111,9 +111,9 @@ pub fn format_linked_issue(issue: &LinkedIssue) -> String { /// Format linked issues compactly for list cards, e.g. "linear: ENG-42, jira: PROJ-9". /// URLs are left to the show view. -fn format_linked_issues_short(issues: &[LinkedIssue]) -> String { +fn format_linked_issues_short<'a>(issues: impl IntoIterator) -> String { issues - .iter() + .into_iter() .map(|i| match i.tracker { LinkedIssueTracker::Slack => i.tracker.to_string(), LinkedIssueTracker::Linear @@ -125,13 +125,26 @@ fn format_linked_issues_short(issues: &[LinkedIssue]) -> String { .join(", ") } -/// Format a security-scan finding's linked issue for the detail/show view. -/// Same shape as `format_linked_issue`; vuln trackers never include Slack. -pub fn format_vuln_linked_issue(issue: &VulnLinkedIssue) -> String { - issue.url.as_ref().map_or_else( - || format!("{}: {}", issue.tracker, issue.issue_id), - |url| format!("{}: {} \u{2014} {url}", issue.tracker, issue.issue_id), - ) +/// Vuln linked issues are a Slack-less subset of bug linked issues, so they +/// convert losslessly and share the bug formatters. +impl From<&VulnLinkedIssue> for LinkedIssue { + fn from(issue: &VulnLinkedIssue) -> Self { + Self { + tracker: match issue.tracker { + VulnLinkedIssueTracker::Linear => LinkedIssueTracker::Linear, + VulnLinkedIssueTracker::Jira => LinkedIssueTracker::Jira, + VulnLinkedIssueTracker::Github => LinkedIssueTracker::Github, + VulnLinkedIssueTracker::Asana => LinkedIssueTracker::Asana, + }, + issue_id: issue.issue_id.clone(), + url: issue.url.clone(), + } + } +} + +/// Format a fix PR compactly for list cards, e.g. "#42 (open)". +fn format_fix_pr_short(fix_pr: &FixPr) -> String { + format!("#{} ({})", fix_pr.pr_number, fix_pr.state) } /// Format the Detail-generated fix PR for the detail/show view. @@ -296,10 +309,7 @@ impl Formattable for Bug { )); } if let Some(fix_pr) = &self.fix_pr { - pairs.push(( - "Fix PR", - format!("#{} ({})", fix_pr.pr_number, fix_pr.state), - )); + pairs.push(("Fix PR", format_fix_pr_short(fix_pr))); } (self.title.clone(), pairs) } @@ -362,19 +372,11 @@ impl Formattable for Vuln { pairs.push(("File", self.file_path.clone())); } if !self.linked_issues.is_empty() { - let formatted = self - .linked_issues - .iter() - .map(|i| format!("{}: {}", i.tracker, i.issue_id)) - .collect::>() - .join(", "); - pairs.push(("Linked Issues", formatted)); + let issues: Vec = self.linked_issues.iter().map(Into::into).collect(); + pairs.push(("Linked Issues", format_linked_issues_short(&issues))); } if let Some(fix_pr) = &self.fix_pr { - pairs.push(( - "Fix PR", - format!("#{} ({})", fix_pr.pr_number, fix_pr.state), - )); + pairs.push(("Fix PR", format_fix_pr_short(fix_pr))); } (self.title.clone(), pairs) } @@ -384,14 +386,14 @@ impl Formattable for Vuln { /// severities with no findings, e.g. "1 Critical, 3 Low". fn format_vuln_counts(counts: &VulnCounts) -> String { [ - (counts.critical, "Critical"), - (counts.high, "High"), - (counts.medium, "Medium"), - (counts.low, "Low"), + (counts.critical, VulnSeverity::Critical), + (counts.high, VulnSeverity::High), + (counts.medium, VulnSeverity::Medium), + (counts.low, VulnSeverity::Low), ] .iter() .filter(|(n, _)| *n > 0) - .map(|(n, label)| format!("{n} {label}")) + .map(|(n, severity)| format!("{n} {}", severity_label(severity))) .collect::>() .join(", ") } @@ -399,11 +401,19 @@ fn format_vuln_counts(counts: &VulnCounts) -> String { impl Formattable for VulnScan { fn to_card(&self) -> (String, Vec<(&'static str, String)>) { let repo = format!("{}/{}", self.owner_name, self.repo_name); - let header = format!("{repo} {} Vulns Found", self.vuln_counts.total); + // `vulnCounts` is always present, but until a scan completes its + // zeros mean "not counted", not "clean". Like the bugs Scan card with + // no counts, fall back to the bare repo — unless findings were + // already recorded, which are worth surfacing even on a failed scan. + let total = self.vuln_counts.total; + let header = if total > 0 || self.workflow_status == Some(WorkflowStatus::Complete) { + format!("{repo} {total} Vulns Found") + } else { + repo + }; let mut pairs = Vec::new(); - // Omitted on a clean scan: an empty breakdown says nothing the - // "0 Vulns Found" header doesn't. - if self.vuln_counts.total > 0 { + // Only a non-empty breakdown says anything the header doesn't. + if total > 0 { pairs.push(("Severity", format_vuln_counts(&self.vuln_counts))); } pairs.push(( @@ -1037,10 +1047,10 @@ mod tests { .expect("valid Vuln JSON") } - fn sample_vuln_scan(counts: serde_json::Value) -> VulnScan { + fn sample_vuln_scan(status: &str, counts: &serde_json::Value) -> VulnScan { serde_json::from_value(serde_json::json!({ "repoId": "repo_xyz", "repoName": "cli", "ownerName": "usedetail", - "workflowStatus": "complete", "createdAt": 1_736_899_200_000_i64, + "workflowStatus": status, "createdAt": 1_736_899_200_000_i64, "completedAt": null, "initiator": "scheduler", "workflowRequestId": "wr_1", "commitSha": "abcdef1234567", "vulnCounts": counts @@ -1048,6 +1058,10 @@ mod tests { .expect("valid VulnScan JSON") } + fn zero_counts() -> serde_json::Value { + serde_json::json!({ "critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0 }) + } + #[test] fn severity_labels() { assert_eq!(severity_label(&VulnSeverity::Critical), "Critical"); @@ -1099,7 +1113,8 @@ mod tests { #[test] fn vuln_scan_card_shows_nonzero_severities() { let scan = sample_vuln_scan( - serde_json::json!({ "critical": 1, "high": 0, "medium": 2, "low": 0, "total": 3 }), + "complete", + &serde_json::json!({ "critical": 1, "high": 0, "medium": 2, "low": 0, "total": 3 }), ); let (header, pairs) = scan.to_card(); assert_eq!(header, "usedetail/cli 3 Vulns Found"); @@ -1109,12 +1124,36 @@ mod tests { } #[test] - fn vuln_scan_card_omits_severity_when_clean() { + fn vuln_scan_card_complete_with_zero_is_clean() { + let (header, pairs) = sample_vuln_scan("complete", &zero_counts()).to_card(); + assert_eq!(header, "usedetail/cli 0 Vulns Found"); + assert!(!pairs.iter().any(|(k, _)| *k == "Severity")); + } + + #[test] + fn vuln_scan_card_in_progress_omits_counts() { + let (header, pairs) = sample_vuln_scan("in_progress", &zero_counts()).to_card(); + assert_eq!(header, "usedetail/cli"); + assert!(!pairs.iter().any(|(k, _)| *k == "Severity")); + } + + #[test] + fn vuln_scan_card_failed_omits_counts() { + for status in ["failed", "dlq"] { + let (header, pairs) = sample_vuln_scan(status, &zero_counts()).to_card(); + assert_eq!(header, "usedetail/cli", "{status}"); + assert!(!pairs.iter().any(|(k, _)| *k == "Severity"), "{status}"); + } + } + + #[test] + fn vuln_scan_card_failed_with_findings_still_shows_counts() { let scan = sample_vuln_scan( - serde_json::json!({ "critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0 }), + "dlq", + &serde_json::json!({ "critical": 0, "high": 2, "medium": 0, "low": 1, "total": 3 }), ); let (header, pairs) = scan.to_card(); - assert_eq!(header, "usedetail/cli 0 Vulns Found"); - assert!(!pairs.iter().any(|(k, _)| *k == "Severity")); + assert_eq!(header, "usedetail/cli 3 Vulns Found"); + assert_eq!(pairs[0], ("Severity", "2 High, 1 Low".to_string())); } } diff --git a/src/commands/vulns.rs b/src/commands/vulns.rs index f12591c..6e2c503 100644 --- a/src/commands/vulns.rs +++ b/src/commands/vulns.rs @@ -6,8 +6,8 @@ use console::Term; use crate::api::client::{ApiClient, VulnListQuery}; use crate::api::types::{ - format_fix_pr, format_vuln_linked_issue, severity_label, ListPublicVulnsWorkflowRequestId, - RepoId, Vuln, VulnId, VulnScan, VulnSeverity, VulnSortOrder, WorkflowStatus, + format_fix_pr, format_linked_issue, severity_label, ListPublicVulnsWorkflowRequestId, RepoId, + Vuln, VulnId, VulnScan, VulnSeverity, VulnSortOrder, WorkflowStatus, }; use crate::output::{clamp_page, output_list, SectionRenderer}; use crate::utils::datetime::{format_datetime, parse_time_spec}; @@ -206,7 +206,7 @@ fn vuln_show_pairs(vuln: &Vuln) -> Vec<(&'static str, String)> { pairs.push(("Scan", wr.to_string())); } for issue in &vuln.linked_issues { - pairs.push(("Issue", format_vuln_linked_issue(issue))); + pairs.push(("Issue", format_linked_issue(&issue.into()))); } if let Some(fix_pr) = &vuln.fix_pr { pairs.push(("Fix PR", format_fix_pr(fix_pr))); From d94413bc1e76b57a6f12e9b8924facffb378f4a0 Mon Sep 17 00:00:00 2001 From: Tyler Dammann Date: Fri, 30 Oct 2026 13:29:25 -0700 Subject: [PATCH 3/3] fix(deps): bump rustls, quinn-proto and webpki crates past RustSec advisories Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 056681e..ffd06b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,9 +105,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "zeroize", @@ -115,9 +115,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -779,7 +779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1835,9 +1835,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", @@ -1867,7 +1867,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2118,14 +2118,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", @@ -2175,7 +2175,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2186,9 +2186,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2618,7 +2618,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3150,9 +3150,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -3185,7 +3185,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]]