diff --git a/CLAUDE.md b/CLAUDE.md index 79505cc..398a01d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,10 @@ erDiagram IssueStatuses statuses "active, ignored counts" String severity "critical, high, medium, low" String cve "CVE identifier (vulns only)" + String url "deep link into the FOSSA UI (all categories)" + Vec_String affected_version_ranges "e.g. <5.0.52 (vulns only)" + Vec_String references "upstream advisory/commit URLs (vulns only)" + Vec_IssueMetric metrics "CVSS vector decomposed: Attack Vector = Network" DateTime created_at } @@ -137,7 +141,7 @@ Project (top-level container) - **Project** - Top-level container, implements Get/List/Update - **Revision** - Snapshot at point in time, implements Get/List - **Dependency** - Package dependency, implements List only (via revision) -- **Issue** - Vulnerability/licensing/quality issue, implements Get/List +- **Issue** - Vulnerability/licensing/quality issue, implements Get/List. `Issue` has no `deny_unknown_fields`, so any API key not declared on the struct is silently dropped — when the API grows a field, add it here or callers never see it. `cpes` is deliberately unmodeled (empty on all 80 sampled issues); `patchedVersionRanges` is modeled but rarely populated (1/80) — prefer `remediation` for upgrade targets. `IssueProject` entries carry the revision the issue was found in (`revision_id`, `latest`, `first_found_at`), not just the project. - **Snippet** - Third-party (OSS) code matched into first-party files, implements List only (via revision). Read-only; reached through the `get_snippet_*` convenience functions. Quirks: `id` is a string, `matchDetails.matchPercentage` is 0-100 (other percentages are 0-1), and whole-file matches highlight a trailing blank EOF line that is excluded from the reported range. - **LicenseInfo** - Can be simple string ("MIT") or full object @@ -147,9 +151,11 @@ Issues come in three categories with different fields: | Category | Key Fields | Description | |----------|------------|-------------| -| `vulnerability` | cve, cvss, severity, remediation, epss | Security vulnerabilities | +| `vulnerability` | cve, cvss, severity, remediation, epss, affectedVersionRanges, references, metrics, cveStatus | Security vulnerabilities | | `licensing` | license | License compliance issues | -| `quality` | qualityRule | Code quality concerns | +| `quality` | qualityRule, latestVersion | Code quality concerns | + +All three categories also carry `url` (deep link into the FOSSA UI). ## Future Work diff --git a/src/lib.rs b/src/lib.rs index f11d2bd..9fdf6e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,6 +100,7 @@ pub use models::{ IssueDepths, IssueEpss, IssueListQuery, + IssueMetric, IssueProject, IssueRemediation, IssueSource, diff --git a/src/mock_server/fixtures.rs b/src/mock_server/fixtures.rs index 3f6bc4f..27bd8b6 100644 --- a/src/mock_server/fixtures.rs +++ b/src/mock_server/fixtures.rs @@ -3,8 +3,8 @@ //! Provides factory functions for creating realistic test data. use crate::{ - Dependency, Issue, IssueDepths, IssueSource, IssueStatuses, LatestRevision, Project, - ProjectIssues, Revision, + Dependency, Issue, IssueDepths, IssueMetric, IssueSource, IssueStatuses, LatestRevision, + Project, ProjectIssues, Revision, }; /// Collection of fixture factories for test data. @@ -165,6 +165,7 @@ impl Fixtures { }, projects: vec![], created_at: None, + url: Some(format!("https://app.fossa.com/issues/vulnerability/{id}")), cve: Some(cve.to_string()), cvss: Some(7.5), cvss_vector: None, @@ -175,10 +176,25 @@ impl Fixtures { published: None, exploitability: None, epss: None, + affected_version_ranges: vec!["<1.0.0".to_string()], + patched_version_ranges: vec![], + references: vec![format!("https://nvd.nist.gov/vuln/detail/{}", cve)], + metrics: vec![ + IssueMetric { + name: "Attack Vector".to_string(), + value: Some("Network".to_string()), + }, + IssueMetric { + name: "Attack Complexity".to_string(), + value: Some("Low".to_string()), + }, + ], + cve_status: Some("COMPLETED".to_string()), vuln_id: Some(format!("{}_{}", cve, package_locator)), title: Some(format!("{} Vulnerability", cve)), license: None, quality_rule: None, + latest_version: None, } } @@ -201,6 +217,7 @@ impl Fixtures { }, projects: vec![], created_at: None, + url: Some(format!("https://app.fossa.com/issues/licensing/{id}")), cve: None, cvss: None, cvss_vector: None, @@ -211,10 +228,16 @@ impl Fixtures { published: None, exploitability: None, epss: None, + affected_version_ranges: vec![], + patched_version_ranges: vec![], + references: vec![], + metrics: vec![], + cve_status: None, vuln_id: None, title: None, license: Some(license.to_string()), quality_rule: None, + latest_version: None, } } diff --git a/src/models/issue.rs b/src/models/issue.rs index 6ceb950..6fb2c48 100644 --- a/src/models/issue.rs +++ b/src/models/issue.rs @@ -60,7 +60,20 @@ mod tests { "cwes": ["CWE-254"], "published": "2018-09-04T00:00:00.000Z", "exploitability": "MATURE", - "epss": {"score": 0.1234, "percentile": 0.42} + "epss": {"score": 0.1234, "percentile": 0.42}, + "url": "https://app.fossa.com/issues/vulnerability/27", + "cveStatus": "COMPLETED", + "affectedVersionRanges": ["<5.0.52", ">=5.1.0-beta.0,<5.1.0-beta.9"], + "patchedVersionRanges": [], + "cpes": [], + "references": [ + "https://github.com/vercel/ai/commit/930399bb9839a8baf3d349614106d78268775eed", + "https://vercel.com/changelog/cve-2025-48985-input-validation-bypass-on-ai-sdk" + ], + "metrics": [ + {"name": "Attack Vector", "value": "Network"}, + {"name": "Attack Complexity", "value": "High"} + ] }"#; let issue: Issue = serde_json::from_str(json).expect("Failed to deserialize vulnerability issue"); @@ -81,6 +94,21 @@ mod tests { assert_eq!(issue.exploitability.as_deref(), Some("MATURE")); assert!(issue.epss.is_some()); assert_eq!(issue.cwes, vec!["CWE-254"]); + assert_eq!( + issue.url.as_deref(), + Some("https://app.fossa.com/issues/vulnerability/27") + ); + assert_eq!(issue.cve_status.as_deref(), Some("COMPLETED")); + assert_eq!( + issue.affected_version_ranges, + vec!["<5.0.52", ">=5.1.0-beta.0,<5.1.0-beta.9"] + ); + assert!(issue.patched_version_ranges.is_empty()); + assert_eq!(issue.references.len(), 2); + assert!(issue.references[0].contains("github.com/vercel/ai/commit")); + assert_eq!(issue.metrics.len(), 2); + assert_eq!(issue.metrics[0].name, "Attack Vector"); + assert_eq!(issue.metrics[0].value.as_deref(), Some("Network")); let remediation = issue .remediation @@ -128,6 +156,20 @@ mod tests { assert!(issue.remediation.is_none()); } + /// A metric with no `value` must not fail the whole issue's deserialization. + #[test] + fn test_issue_metric_deserialize() { + let metric = + serde_json::from_str::(r#"{"name": "Attack Vector", "value": "Network"}"#) + .expect("Failed to deserialize metric"); + assert_eq!(metric.name, "Attack Vector"); + assert_eq!(metric.value.as_deref(), Some("Network")); + + let valueless = serde_json::from_str::(r#"{"name": "Scope"}"#) + .expect("Failed to deserialize valueless metric"); + assert!(valueless.value.is_none()); + } + #[test] fn test_issue_deserialize_licensing() { let json = r#"{ @@ -143,7 +185,8 @@ mod tests { "statuses": {"active": 1, "ignored": 0}, "projects": [], "type": "licensing", - "license": "GPL-3.0" + "license": "GPL-3.0", + "url": "https://app.fossa.com/issues/licensing/42" }"#; let issue: Issue = serde_json::from_str(json).expect("Failed to deserialize licensing issue"); @@ -153,6 +196,14 @@ mod tests { assert_eq!(issue.license.as_deref(), Some("GPL-3.0")); assert!(issue.cve.is_none()); assert!(issue.cvss.is_none()); + assert_eq!( + issue.url.as_deref(), + Some("https://app.fossa.com/issues/licensing/42") + ); + assert!(issue.affected_version_ranges.is_empty()); + assert!(issue.references.is_empty()); + assert!(issue.metrics.is_empty()); + assert!(issue.cve_status.is_none()); } #[test] @@ -170,7 +221,9 @@ mod tests { "statuses": {"active": 1, "ignored": 0}, "projects": [], "type": "quality", - "qualityRule": {"name": "outdated", "threshold": 365} + "qualityRule": {"name": "outdated", "threshold": 365}, + "latestVersion": "npm+old-package$2.0.0", + "url": "https://app.fossa.com/issues/quality/100" }"#; let issue: Issue = serde_json::from_str(json).expect("Failed to deserialize quality issue"); @@ -180,6 +233,62 @@ mod tests { assert!(issue.quality_rule.is_some()); assert!(issue.license.is_none()); assert!(issue.cve.is_none()); + assert_eq!( + issue.latest_version.as_deref(), + Some("npm+old-package$2.0.0") + ); + assert_eq!( + issue.url.as_deref(), + Some("https://app.fossa.com/issues/quality/100") + ); + } + + /// Every key the API sends on a project entry, including its three timestamp formats. + #[test] + fn test_issue_project_deserialize_full() { + let json = r#"{ + "id": "custom+58216/testproject/withslash", + "title": "testproject/withslash", + "status": "active", + "depth": 1, + "url": "https://app.fossa.com/projects/custom%2B58216%2Ftestproject%2Fwithslash", + "revisionId": "custom+58216/testproject/withslash$2026-04-10T16:08:51Z", + "revisionScanId": 114469956, + "defaultBranch": "master", + "latest": true, + "firstFoundAt": "2026-04-10T16:19:48.2+00:00", + "scannedAt": "2026-04-10T16:19:50.611168+00:00", + "analyzedAt": "2026-04-10T16:09:30.488Z" + }"#; + + let project = + serde_json::from_str::(json).expect("Failed to deserialize issue project"); + + assert_eq!(project.id, "custom+58216/testproject/withslash"); + assert_eq!(project.status.as_deref(), Some("active")); + assert!(project.url.is_some()); + assert_eq!( + project.revision_id.as_deref(), + Some("custom+58216/testproject/withslash$2026-04-10T16:08:51Z") + ); + assert_eq!(project.revision_scan_id, Some(114469956)); + assert_eq!(project.default_branch.as_deref(), Some("master")); + assert_eq!(project.latest, Some(true)); + assert!(project.first_found_at.is_some()); + assert!(project.scanned_at.is_some()); + assert!(project.analyzed_at.is_some()); + } + + /// Fields absent on a minimal project entry default rather than erroring. + #[test] + fn test_issue_project_deserialize_minimal() { + let project = serde_json::from_str::(r#"{"id": "custom+1/TEST"}"#) + .expect("Failed to deserialize minimal issue project"); + + assert_eq!(project.id, "custom+1/TEST"); + assert!(project.url.is_none()); + assert!(project.latest.is_none()); + assert!(project.first_found_at.is_none()); } #[test] @@ -294,6 +403,7 @@ mod tests { depths: IssueDepths::default(), statuses: IssueStatuses { active: 3, ignored: 1 }, projects: vec![], + url: None, vuln_id: None, title: None, cve: Some("CVE-2023-1234".to_string()), @@ -306,8 +416,14 @@ mod tests { published: None, exploitability: None, epss: None, + affected_version_ranges: vec![], + patched_version_ranges: vec![], + references: vec![], + metrics: vec![], + cve_status: None, license: None, quality_rule: None, + latest_version: None, } } @@ -475,6 +591,10 @@ pub struct Issue { #[serde(default)] pub projects: Vec, + /// Deep link to this issue in the FOSSA UI. Present on all three categories. + #[serde(default)] + pub url: Option, + // --- Vulnerability-specific fields --- /// Vulnerability ID (e.g., "CVE-2018-16487_npm+lodash"). @@ -525,6 +645,28 @@ pub struct Issue { #[serde(default)] pub epss: Option, + /// Version ranges known to be vulnerable (e.g. `["<5.0.52", ">=5.1.0-beta.0,<5.1.0-beta.9"]`). + #[serde(default)] + pub affected_version_ranges: Vec, + + /// Version ranges carrying the fix. Populated far less often than + /// [`Issue::affected_version_ranges`]; prefer [`Issue::remediation`] for upgrade targets. + #[serde(default)] + pub patched_version_ranges: Vec, + + /// Upstream advisory links: fix commits, vendor changelogs, CVE records. + #[serde(default)] + pub references: Vec, + + /// CVSS vector decomposed into readable name/value pairs. + #[serde(default)] + pub metrics: Vec, + + /// State of FOSSA's CVE enrichment (e.g. "COMPLETED"). Anything other than + /// completed explains missing `cvss`/`severity`. + #[serde(default)] + pub cve_status: Option, + // --- Licensing-specific fields --- /// License identifier (e.g., "GPL-3.0"). @@ -536,6 +678,10 @@ pub struct Issue { /// Quality rule details. #[serde(default)] pub quality_rule: Option, + + /// Locator of the newest published version of the package (e.g. "npm+abab$2.0.6"). + #[serde(default)] + pub latest_version: Option, } impl Issue { @@ -679,6 +825,38 @@ pub struct IssueProject { /// Project title. #[serde(default)] pub title: Option, + + /// Deep link to the project in the FOSSA UI. + #[serde(default)] + pub url: Option, + + /// Locator of the revision where the issue was found. + #[serde(default)] + pub revision_id: Option, + + /// Whether that revision is the project's latest. + #[serde(default)] + pub latest: Option, + + /// Numeric ID of the scan that produced the revision. + #[serde(default)] + pub revision_scan_id: Option, + + /// The project's default branch. + #[serde(default)] + pub default_branch: Option, + + /// When the issue was first seen in this project. + #[serde(default)] + pub first_found_at: Option>, + + /// When the revision was scanned. + #[serde(default)] + pub scanned_at: Option>, + + /// When the revision finished analysis. + #[serde(default)] + pub analyzed_at: Option>, } /// Remediation information for a vulnerability. @@ -714,6 +892,17 @@ pub struct IssueEpss { pub percentile: Option, } +/// One decomposed CVSS metric, e.g. name "Attack Vector", value "Network". +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IssueMetric { + /// Metric name (e.g. "Attack Vector", "Privileges Required"). + pub name: String, + + /// Metric value (e.g. "Network", "None"). + #[serde(default)] + pub value: Option, +} + /// Issue category for filtering. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum)] #[serde(rename_all = "lowercase")] diff --git a/src/output.rs b/src/output.rs index 04f3e60..700858b 100644 --- a/src/output.rs +++ b/src/output.rs @@ -5,7 +5,9 @@ use std::collections::BTreeSet; -use crate::{CodeLine, Issue, Project, Revision, Snippet, SnippetKind, SnippetMatchDetails}; +use crate::{ + CodeLine, Issue, IssueMetric, Project, Revision, Snippet, SnippetKind, SnippetMatchDetails, +}; /// Trait for human-readable key-value output. /// @@ -122,10 +124,55 @@ impl PrettyPrint for Issue { lines.push(format!("License: {license}")); } + if let Some(ref latest) = self.latest_version { + lines.push(format!("Latest version: {latest}")); + } + + if !self.affected_version_ranges.is_empty() { + lines.push(format!( + "Affected: {}", + self.affected_version_ranges.join(", ") + )); + } + + if !self.patched_version_ranges.is_empty() { + lines.push(format!( + "Patched: {}", + self.patched_version_ranges.join(", ") + )); + } + + if let Some(ref status) = self.cve_status { + lines.push(format!("CVE status: {status}")); + } + + if let Some(ref url) = self.url { + lines.push(format!("URL: {url}")); + } + + if !self.references.is_empty() { + lines.push("References:".to_string()); + lines.extend(self.references.iter().map(|r| format!(" {r}"))); + } + + let metrics = metric_lines(&self.metrics); + if !metrics.is_empty() { + lines.push("Metrics:".to_string()); + lines.extend(metrics); + } + lines.join("\n") } } +/// Render each CVSS metric that carries a value as an indented `name: value` line. +fn metric_lines(metrics: &[IssueMetric]) -> Vec { + metrics + .iter() + .filter_map(|m| m.value.as_ref().map(|v| format!(" {}: {v}", m.name))) + .collect() +} + fn snippet_kind_label(kind: SnippetKind) -> &'static str { match kind { SnippetKind::File => "file (whole-file match)", diff --git a/tests/cli_output.rs b/tests/cli_output.rs index c137dda..69a708a 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -119,6 +119,32 @@ fn test_issue_pretty_print_shows_severity() { assert!(output.contains("vulnerability"), "Should show issue type"); } +#[test] +fn test_issue_pretty_print_shows_references_and_metrics() { + let issue = make_test_issue(); + let output = issue.pretty_print(); + + assert!( + output.contains("https://app.fossa.com/issues/vulnerability/12345"), + "Should show the FOSSA deep link" + ); + assert!(output.contains("COMPLETED"), "Should show CVE status"); + assert!(output.contains("<4.17.21"), "Should show affected ranges"); + assert!(output.contains("Patched:"), "Should show patched ranges"); + assert!( + output.contains("https://nvd.nist.gov/vuln/detail/CVE-2021-1234"), + "Should list reference URLs" + ); + assert!( + output.contains("Attack Vector: Network"), + "Should show CVSS metrics" + ); + assert!( + !output.contains("Scope"), + "Should skip metrics that carry no value" + ); +} + #[test] fn test_revision_pretty_print_shows_key_fields() { // Revision pretty-print must show: Locator, Resolved, Source @@ -165,7 +191,16 @@ fn make_test_issue() -> Issue { "statuses": { "active": 1, "ignored": 0 }, "projects": [], "severity": "high", - "cve": "CVE-2021-1234" + "cve": "CVE-2021-1234", + "url": "https://app.fossa.com/issues/vulnerability/12345", + "cveStatus": "COMPLETED", + "affectedVersionRanges": ["<4.17.21"], + "patchedVersionRanges": ["4.17.21"], + "references": ["https://nvd.nist.gov/vuln/detail/CVE-2021-1234"], + "metrics": [ + {"name": "Attack Vector", "value": "Network"}, + {"name": "Scope"} + ] })) .unwrap() }