1use std::path::Path;
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum Ecosystem {
8 Npm,
9 Cargo,
10 PyPI,
11 Go,
12}
13
14impl Ecosystem {
15 pub fn as_str(&self) -> &'static str {
16 match self {
17 Ecosystem::Npm => "npm",
18 Ecosystem::Cargo => "crates.io",
19 Ecosystem::PyPI => "PyPI",
20 Ecosystem::Go => "Go",
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Dependency {
27 pub name: String,
28 pub version: String,
29 pub ecosystem: Ecosystem,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DependencyReport {
34 pub name: String,
35 pub version: String,
36 pub ecosystem: String,
37 pub cve_count: i64,
38 pub cve_ids: Vec<String>,
39 pub risk_score: f64,
40}
41
42pub fn parse_manifests(repo_root: &Path) -> anyhow::Result<Vec<Dependency>> {
43 let mut deps: Vec<Dependency> = Vec::new();
44
45 let pkg_json = repo_root.join("package.json");
47 if pkg_json.exists() {
48 if let Ok(content) = std::fs::read_to_string(&pkg_json) {
49 deps.extend(parse_package_json(&content));
50 }
51 }
52
53 let cargo_toml = repo_root.join("Cargo.toml");
55 if cargo_toml.exists() {
56 if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
57 deps.extend(parse_cargo_toml(&content));
58 }
59 }
60
61 let reqs_txt = repo_root.join("requirements.txt");
63 if reqs_txt.exists() {
64 if let Ok(content) = std::fs::read_to_string(&reqs_txt) {
65 deps.extend(parse_requirements_txt(&content));
66 }
67 }
68
69 let pyproject = repo_root.join("pyproject.toml");
71 if pyproject.exists() {
72 if let Ok(content) = std::fs::read_to_string(&pyproject) {
73 deps.extend(parse_pyproject_toml(&content));
74 }
75 }
76
77 let go_mod = repo_root.join("go.mod");
79 if go_mod.exists() {
80 if let Ok(content) = std::fs::read_to_string(&go_mod) {
81 deps.extend(parse_go_mod(&content));
82 }
83 }
84
85 Ok(deps)
86}
87
88fn parse_package_json(content: &str) -> Vec<Dependency> {
89 let Ok(v) = serde_json::from_str::<serde_json::Value>(content) else {
90 return Vec::new();
91 };
92 let mut deps = Vec::new();
93 for section in &["dependencies", "devDependencies"] {
94 if let Some(map) = v.get(section).and_then(|v| v.as_object()) {
95 for (name, ver) in map {
96 let version = ver
97 .as_str()
98 .unwrap_or("*")
99 .trim_start_matches('^')
100 .trim_start_matches('~')
101 .trim_start_matches('>')
102 .trim_start_matches('=')
103 .to_string();
104 deps.push(Dependency {
105 name: name.clone(),
106 version,
107 ecosystem: Ecosystem::Npm,
108 });
109 }
110 }
111 }
112 deps
113}
114
115fn parse_cargo_toml(content: &str) -> Vec<Dependency> {
116 let Ok(v) = content.parse::<toml::Value>() else {
117 return Vec::new();
118 };
119 let mut deps = Vec::new();
120 if let Some(table) = v.get("dependencies").and_then(|v| v.as_table()) {
121 for (name, ver) in table {
122 let version = match ver {
123 toml::Value::String(s) => s.trim_start_matches('^').to_string(),
124 toml::Value::Table(t) => t
125 .get("version")
126 .and_then(|v| v.as_str())
127 .unwrap_or("*")
128 .trim_start_matches('^')
129 .to_string(),
130 _ => "*".to_string(),
131 };
132 deps.push(Dependency {
133 name: name.clone(),
134 version,
135 ecosystem: Ecosystem::Cargo,
136 });
137 }
138 }
139 deps
140}
141
142fn parse_requirements_txt(content: &str) -> Vec<Dependency> {
143 let mut deps = Vec::new();
144 for line in content.lines() {
145 let line = line.trim();
146 if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
147 continue;
148 }
149 let (name, version) = if let Some(pos) = line.find("==") {
150 (&line[..pos], line[pos + 2..].to_string())
151 } else if let Some(pos) = line.find(">=") {
152 (&line[..pos], line[pos + 2..].to_string())
153 } else {
154 (line, "*".to_string())
155 };
156 deps.push(Dependency {
157 name: name.trim().to_string(),
158 version: version.trim().to_string(),
159 ecosystem: Ecosystem::PyPI,
160 });
161 }
162 deps
163}
164
165fn parse_pyproject_toml(content: &str) -> Vec<Dependency> {
166 let Ok(v) = content.parse::<toml::Value>() else {
167 return Vec::new();
168 };
169 let mut deps = Vec::new();
170 if let Some(arr) = v
172 .get("project")
173 .and_then(|p| p.get("dependencies"))
174 .and_then(|d| d.as_array())
175 {
176 for dep in arr {
177 if let Some(s) = dep.as_str() {
178 let (name, version) = parse_pep508(s);
179 deps.push(Dependency {
180 name,
181 version,
182 ecosystem: Ecosystem::PyPI,
183 });
184 }
185 }
186 }
187 deps
188}
189
190fn parse_pep508(spec: &str) -> (String, String) {
191 if let Some(pos) = spec.find(">=") {
192 (
193 spec[..pos].trim().to_string(),
194 spec[pos + 2..].trim().to_string(),
195 )
196 } else if let Some(pos) = spec.find("==") {
197 (
198 spec[..pos].trim().to_string(),
199 spec[pos + 2..].trim().to_string(),
200 )
201 } else {
202 (spec.trim().to_string(), "*".to_string())
203 }
204}
205
206fn parse_go_mod(content: &str) -> Vec<Dependency> {
207 let mut deps = Vec::new();
208 let mut in_require = false;
209 for line in content.lines() {
210 let line = line.trim();
211 if line == "require (" {
212 in_require = true;
213 continue;
214 }
215 if line == ")" {
216 in_require = false;
217 continue;
218 }
219 if line.starts_with("require ") {
220 let rest = line.trim_start_matches("require ").trim();
222 if let Some((name, ver)) = rest.split_once(' ') {
223 deps.push(Dependency {
224 name: name.trim().to_string(),
225 version: ver.trim().trim_start_matches('v').to_string(),
226 ecosystem: Ecosystem::Go,
227 });
228 }
229 continue;
230 }
231 if in_require && !line.is_empty() && !line.starts_with("//") {
232 if let Some((name, ver)) = line.split_once(' ') {
233 let ver_clean = ver.trim().trim_start_matches('v');
234 if !ver_clean.contains("//") {
235 deps.push(Dependency {
236 name: name.trim().to_string(),
237 version: ver_clean.trim().to_string(),
238 ecosystem: Ecosystem::Go,
239 });
240 }
241 }
242 }
243 }
244 deps
245}
246
247pub fn query_osv(deps: &[Dependency]) -> Vec<DependencyReport> {
249 let queries: Vec<serde_json::Value> = deps
250 .iter()
251 .map(|d| {
252 serde_json::json!({
253 "package": {
254 "name": d.name,
255 "ecosystem": d.ecosystem.as_str()
256 },
257 "version": d.version
258 })
259 })
260 .collect();
261
262 let client = match reqwest::blocking::Client::builder()
263 .timeout(std::time::Duration::from_secs(10))
264 .build()
265 {
266 Ok(c) => c,
267 Err(_) => return build_reports_no_cve(deps),
268 };
269
270 let body = serde_json::json!({ "queries": queries });
271 let resp = client
272 .post("https://api.osv.dev/v1/querybatch")
273 .json(&body)
274 .send();
275
276 match resp {
277 Err(_) => {
278 eprintln!(" Warning: CVE data unavailable (network error)");
279 build_reports_no_cve(deps)
280 }
281 Ok(r) => {
282 let Ok(json) = r.json::<serde_json::Value>() else {
283 eprintln!(" Warning: CVE data unavailable (parse error)");
284 return build_reports_no_cve(deps);
285 };
286
287 let results = json
288 .get("results")
289 .and_then(|r| r.as_array())
290 .map(|a| a.as_slice())
291 .unwrap_or(&[]);
292
293 deps.iter()
294 .enumerate()
295 .map(|(i, dep)| {
296 let vulns = results
297 .get(i)
298 .and_then(|r| r.get("vulns"))
299 .and_then(|v| v.as_array());
300
301 let cve_ids: Vec<String> = vulns
302 .map(|v| {
303 v.iter()
304 .filter_map(|vuln| {
305 vuln.get("id")
306 .and_then(|id| id.as_str())
307 .map(|s| s.to_string())
308 })
309 .collect()
310 })
311 .unwrap_or_default();
312
313 let cve_count = cve_ids.len() as i64;
314 let risk_score = if cve_count > 0 {
315 (cve_count as f64).min(5.0) / 5.0
316 } else {
317 0.0
318 };
319
320 DependencyReport {
321 name: dep.name.clone(),
322 version: dep.version.clone(),
323 ecosystem: dep.ecosystem.as_str().to_string(),
324 cve_count,
325 cve_ids,
326 risk_score,
327 }
328 })
329 .collect()
330 }
331 }
332}
333
334fn build_reports_no_cve(deps: &[Dependency]) -> Vec<DependencyReport> {
335 deps.iter()
336 .map(|d| DependencyReport {
337 name: d.name.clone(),
338 version: d.version.clone(),
339 ecosystem: d.ecosystem.as_str().to_string(),
340 cve_count: 0,
341 cve_ids: Vec::new(),
342 risk_score: 0.0,
343 })
344 .collect()
345}
346
347pub fn audit_dependencies(repo_root: &Path) -> anyhow::Result<Vec<DependencyReport>> {
349 let deps = parse_manifests(repo_root).context("Failed to parse manifests")?;
350 if deps.is_empty() {
351 return Ok(Vec::new());
352 }
353 Ok(query_osv(&deps))
354}