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 (spec[..pos].trim().to_string(), spec[pos + 2..].trim().to_string())
193 } else if let Some(pos) = spec.find("==") {
194 (spec[..pos].trim().to_string(), spec[pos + 2..].trim().to_string())
195 } else {
196 (spec.trim().to_string(), "*".to_string())
197 }
198}
199
200fn parse_go_mod(content: &str) -> Vec<Dependency> {
201 let mut deps = Vec::new();
202 let mut in_require = false;
203 for line in content.lines() {
204 let line = line.trim();
205 if line == "require (" {
206 in_require = true;
207 continue;
208 }
209 if line == ")" {
210 in_require = false;
211 continue;
212 }
213 if line.starts_with("require ") {
214 let rest = line.trim_start_matches("require ").trim();
216 if let Some((name, ver)) = rest.split_once(' ') {
217 deps.push(Dependency {
218 name: name.trim().to_string(),
219 version: ver.trim().trim_start_matches('v').to_string(),
220 ecosystem: Ecosystem::Go,
221 });
222 }
223 continue;
224 }
225 if in_require && !line.is_empty() && !line.starts_with("//") {
226 if let Some((name, ver)) = line.split_once(' ') {
227 let ver_clean = ver.trim().trim_start_matches('v');
228 if !ver_clean.contains("//") {
229 deps.push(Dependency {
230 name: name.trim().to_string(),
231 version: ver_clean.trim().to_string(),
232 ecosystem: Ecosystem::Go,
233 });
234 }
235 }
236 }
237 }
238 deps
239}
240
241pub fn query_osv(deps: &[Dependency]) -> Vec<DependencyReport> {
243 let queries: Vec<serde_json::Value> = deps
244 .iter()
245 .map(|d| {
246 serde_json::json!({
247 "package": {
248 "name": d.name,
249 "ecosystem": d.ecosystem.as_str()
250 },
251 "version": d.version
252 })
253 })
254 .collect();
255
256 let client = match reqwest::blocking::Client::builder()
257 .timeout(std::time::Duration::from_secs(10))
258 .build()
259 {
260 Ok(c) => c,
261 Err(_) => return build_reports_no_cve(deps),
262 };
263
264 let body = serde_json::json!({ "queries": queries });
265 let resp = client
266 .post("https://api.osv.dev/v1/querybatch")
267 .json(&body)
268 .send();
269
270 match resp {
271 Err(_) => {
272 eprintln!(" Warning: CVE data unavailable (network error)");
273 build_reports_no_cve(deps)
274 }
275 Ok(r) => {
276 let Ok(json) = r.json::<serde_json::Value>() else {
277 eprintln!(" Warning: CVE data unavailable (parse error)");
278 return build_reports_no_cve(deps);
279 };
280
281 let results = json
282 .get("results")
283 .and_then(|r| r.as_array())
284 .map(|a| a.as_slice())
285 .unwrap_or(&[]);
286
287 deps.iter()
288 .enumerate()
289 .map(|(i, dep)| {
290 let vulns = results
291 .get(i)
292 .and_then(|r| r.get("vulns"))
293 .and_then(|v| v.as_array());
294
295 let cve_ids: Vec<String> = vulns
296 .map(|v| {
297 v.iter()
298 .filter_map(|vuln| {
299 vuln.get("id").and_then(|id| id.as_str()).map(|s| s.to_string())
300 })
301 .collect()
302 })
303 .unwrap_or_default();
304
305 let cve_count = cve_ids.len() as i64;
306 let risk_score = if cve_count > 0 {
307 (cve_count as f64).min(5.0) / 5.0
308 } else {
309 0.0
310 };
311
312 DependencyReport {
313 name: dep.name.clone(),
314 version: dep.version.clone(),
315 ecosystem: dep.ecosystem.as_str().to_string(),
316 cve_count,
317 cve_ids,
318 risk_score,
319 }
320 })
321 .collect()
322 }
323 }
324}
325
326fn build_reports_no_cve(deps: &[Dependency]) -> Vec<DependencyReport> {
327 deps.iter()
328 .map(|d| DependencyReport {
329 name: d.name.clone(),
330 version: d.version.clone(),
331 ecosystem: d.ecosystem.as_str().to_string(),
332 cve_count: 0,
333 cve_ids: Vec::new(),
334 risk_score: 0.0,
335 })
336 .collect()
337}
338
339pub fn audit_dependencies(repo_root: &Path) -> anyhow::Result<Vec<DependencyReport>> {
341 let deps = parse_manifests(repo_root).context("Failed to parse manifests")?;
342 if deps.is_empty() {
343 return Ok(Vec::new());
344 }
345 Ok(query_osv(&deps))
346}