Skip to main content

cgx_engine/
deps.rs

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    // package.json — npm
46    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    // Cargo.toml — Rust
54    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    // requirements.txt — Python
62    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    // pyproject.toml — Python
70    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    // go.mod — Go
78    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    // PEP 621 style
171    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            // single-line require
221            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
247/// Query OSV for vulnerabilities. Falls back gracefully on network failure.
248pub 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
347/// Parse manifests and query OSV — returns reports, network failures handled gracefully.
348pub 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}