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        (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            // single-line require
215            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
241/// Query OSV for vulnerabilities. Falls back gracefully on network failure.
242pub 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
339/// Parse manifests and query OSV — returns reports, network failures handled gracefully.
340pub 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}