| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | use std::collections::VecDeque; |
| 6 | use std::path::Path; |
| 7 | |
| 8 | use forge_core::advisories::{Dependency, parse_cargo_lock, parse_go_sum, parse_package_lock}; |
| 9 | use forge_core::store::advisories::ScanOutcome; |
| 10 | use forge_core::{Error, Repo, Result}; |
| 11 | use forge_git::browse::{EntryKind, default_branch_commit, read_text_blob, read_tree}; |
| 12 | |
| 13 | use crate::state::AppState; |
| 14 | |
| 15 | |
| 16 | const MAX_DEPTH: usize = 3; |
| 17 | |
| 18 | const MAX_DIRS: usize = 500; |
| 19 | |
| 20 | const MAX_LOCKFILE_BYTES: usize = 32 * 1024 * 1024; |
| 21 | |
| 22 | |
| 23 | |
| 24 | pub fn spawn_repo_scan(state: &AppState, repo: &Repo) { |
| 25 | if !state.config.advisories.enabled { |
| 26 | return; |
| 27 | } |
| 28 | let state = state.clone(); |
| 29 | let repo = repo.clone(); |
| 30 | tokio::spawn(async move { |
| 31 | if let Err(e) = scan_repo(&state, &repo).await { |
| 32 | tracing::error!(error = %e, repo_id = %repo.id, "advisories: scan failed"); |
| 33 | } |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | |
| 38 | pub async fn scan_all(state: &AppState) { |
| 39 | let ids = match state.store.all_repo_ids().await { |
| 40 | Ok(ids) => ids, |
| 41 | Err(e) => { |
| 42 | tracing::error!(error = %e, "advisories: could not list repos"); |
| 43 | return; |
| 44 | } |
| 45 | }; |
| 46 | for id in ids { |
| 47 | match state.store.repo_by_id(&id).await { |
| 48 | Ok(Some(repo)) => { |
| 49 | if let Err(e) = scan_repo(state, &repo).await { |
| 50 | tracing::error!(error = %e, repo_id = %id, "advisories: scan failed"); |
| 51 | } |
| 52 | } |
| 53 | Ok(None) => {} |
| 54 | Err(e) => tracing::error!(error = %e, repo_id = %id, "advisories: could not load repo"), |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | |
| 60 | |
| 61 | pub async fn scan_repo(state: &AppState, repo: &Repo) -> Result<ScanOutcome> { |
| 62 | let dir = forge_git::repo::repo_path(&state.git.data_dir, &repo.id) |
| 63 | .map_err(|e| Error::Git(e.to_string()))?; |
| 64 | let branch = repo.default_branch.clone(); |
| 65 | let deps = |
| 66 | tokio::task::spawn_blocking(move || -> std::result::Result<Vec<Dependency>, String> { |
| 67 | match default_branch_commit(&dir, &branch).map_err(|e| e.to_string())? { |
| 68 | Some(commit) => collect_dependencies(&dir, &commit), |
| 69 | None => Ok(Vec::new()), |
| 70 | } |
| 71 | }) |
| 72 | .await |
| 73 | .map_err(|e| Error::Git(format!("scan task failed: {e}")))? |
| 74 | .map_err(Error::Git)?; |
| 75 | let outcome = state.store.scan_repo(&repo.id, &deps).await?; |
| 76 | if outcome.opened > 0 { |
| 77 | tracing::info!( |
| 78 | repo_id = %repo.id, |
| 79 | opened = outcome.opened, |
| 80 | fixed = outcome.fixed, |
| 81 | "advisories: new alerts" |
| 82 | ); |
| 83 | crate::notify::advisories_opened(state, repo, outcome.opened).await; |
| 84 | } |
| 85 | Ok(outcome) |
| 86 | } |
| 87 | |
| 88 | |
| 89 | pub fn collect_dependencies( |
| 90 | repo: &Path, |
| 91 | commit: &str, |
| 92 | ) -> std::result::Result<Vec<Dependency>, String> { |
| 93 | let mut found = Vec::new(); |
| 94 | let mut queue: VecDeque<(String, usize)> = VecDeque::from([(String::new(), 0)]); |
| 95 | let mut visited = 0; |
| 96 | while let Some((dir, depth)) = queue.pop_front() { |
| 97 | if visited >= MAX_DIRS { |
| 98 | break; |
| 99 | } |
| 100 | visited += 1; |
| 101 | let Some(entries) = read_tree(repo, commit, &dir).map_err(|e| e.to_string())? else { |
| 102 | continue; |
| 103 | }; |
| 104 | for entry in entries { |
| 105 | let path = if dir.is_empty() { |
| 106 | entry.name.clone() |
| 107 | } else { |
| 108 | format!("{dir}/{}", entry.name) |
| 109 | }; |
| 110 | match entry.kind { |
| 111 | EntryKind::Tree => { |
| 112 | if depth < MAX_DEPTH { |
| 113 | queue.push_back((path, depth + 1)); |
| 114 | } |
| 115 | } |
| 116 | EntryKind::Blob | EntryKind::Executable => { |
| 117 | if !matches!( |
| 118 | entry.name.as_str(), |
| 119 | "Cargo.lock" | "package-lock.json" | "go.sum" |
| 120 | ) { |
| 121 | continue; |
| 122 | } |
| 123 | let text = match read_text_blob(repo, commit, &path, MAX_LOCKFILE_BYTES) { |
| 124 | Ok(Some(text)) => text, |
| 125 | Ok(None) => continue, |
| 126 | Err(e) => { |
| 127 | tracing::warn!(error = %e, path = %path, "advisories: lockfile unreadable"); |
| 128 | continue; |
| 129 | } |
| 130 | }; |
| 131 | let parsed = match entry.name.as_str() { |
| 132 | "Cargo.lock" => parse_cargo_lock(&text, &path), |
| 133 | "package-lock.json" => parse_package_lock(&text, &path), |
| 134 | _ => Ok(parse_go_sum(&text, &path)), |
| 135 | }; |
| 136 | match parsed { |
| 137 | Ok(deps) => found.extend(deps), |
| 138 | Err(e) => { |
| 139 | tracing::warn!(error = %e, path = %path, "advisories: lockfile skipped") |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | EntryKind::Symlink | EntryKind::Submodule => {} |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | found.sort(); |
| 148 | found.dedup(); |
| 149 | Ok(found) |
| 150 | } |