| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | mod common; |
| 12 | use common::{BIN, Server, launch_with, pick_ports, tool_on_path, write_config_with}; |
| 13 | use std::fs; |
| 14 | use std::io::{Cursor, Write}; |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | use std::process::{Command, Output}; |
| 17 | use std::time::{Duration, Instant}; |
| 18 | |
| 19 | const ALICE_PW: &str = "alice-password-123"; |
| 20 | const BOB_PW: &str = "bob-password-123"; |
| 21 | const ADVISORIES_TOML: &str = "\n[advisories]\nenabled = true\necosystems = [\"crates.io\"]\n"; |
| 22 | const SCAN_TIMEOUT: Duration = Duration::from_secs(30); |
| 23 | |
| 24 | |
| 25 | const OSV_RECORD: &str = r#"{ |
| 26 | "id": "RUSTSEC-TEST-0001", |
| 27 | "summary": "demo is vulnerable before 2.0.0", |
| 28 | "modified": "2026-01-01T00:00:00Z", |
| 29 | "database_specific": {"severity": "HIGH"}, |
| 30 | "affected": [{ |
| 31 | "package": {"ecosystem": "crates.io", "name": "demo"}, |
| 32 | "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}] |
| 33 | }] |
| 34 | }"#; |
| 35 | |
| 36 | const LOCK_OLD: &str = "version = 4\n\n[[package]]\nname = \"demo\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"; |
| 37 | const LOCK_FIXED: &str = "version = 4\n\n[[package]]\nname = \"demo\"\nversion = \"2.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"; |
| 38 | |
| 39 | struct Resp { |
| 40 | status: u16, |
| 41 | set_cookies: Vec<String>, |
| 42 | body: String, |
| 43 | } |
| 44 | |
| 45 | struct Http { |
| 46 | client: reqwest::Client, |
| 47 | base: String, |
| 48 | } |
| 49 | |
| 50 | impl Http { |
| 51 | fn new(port: u16) -> Self { |
| 52 | let client = reqwest::Client::builder() |
| 53 | .redirect(reqwest::redirect::Policy::none()) |
| 54 | .timeout(Duration::from_secs(60)) |
| 55 | .build() |
| 56 | .expect("http client"); |
| 57 | Self { |
| 58 | client, |
| 59 | base: format!("http://127.0.0.1:{port}"), |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { |
| 64 | let req = self |
| 65 | .client |
| 66 | .get(format!("{}{path}", self.base)) |
| 67 | .header(reqwest::header::COOKIE, cookie); |
| 68 | send(req).await |
| 69 | } |
| 70 | |
| 71 | async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { |
| 72 | let mut req = self.client.post(format!("{}{path}", self.base)).form(form); |
| 73 | if let Some(cookie) = cookie { |
| 74 | req = req.header(reqwest::header::COOKIE, cookie); |
| 75 | } |
| 76 | send(req).await |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | async fn send(req: reqwest::RequestBuilder) -> Resp { |
| 81 | let res = req.send().await.expect("http request"); |
| 82 | let status = res.status().as_u16(); |
| 83 | let set_cookies = res |
| 84 | .headers() |
| 85 | .get_all(reqwest::header::SET_COOKIE) |
| 86 | .iter() |
| 87 | .filter_map(|v| v.to_str().ok().map(str::to_owned)) |
| 88 | .collect(); |
| 89 | let body = res.text().await.unwrap_or_default(); |
| 90 | Resp { |
| 91 | status, |
| 92 | set_cookies, |
| 93 | body, |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | |
| 98 | fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> { |
| 99 | resp.set_cookies.iter().find_map(|set| { |
| 100 | let pair = set.split(';').next()?; |
| 101 | let (name, value) = pair.split_once('=')?; |
| 102 | (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | |
| 107 | fn input_value(body: &str, name: &str) -> String { |
| 108 | let doc = scraper::Html::parse_document(body); |
| 109 | let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); |
| 110 | doc.select(&selector) |
| 111 | .next() |
| 112 | .and_then(|el| el.value().attr("value")) |
| 113 | .unwrap_or_else(|| panic!("no input named {name} on page")) |
| 114 | .to_owned() |
| 115 | } |
| 116 | |
| 117 | |
| 118 | fn git(cwd: &Path, args: &[&str]) -> String { |
| 119 | let out: Output = Command::new("git") |
| 120 | .current_dir(cwd) |
| 121 | .args(["-c", "credential.helper="]) |
| 122 | .args(args) |
| 123 | .env("GIT_TERMINAL_PROMPT", "0") |
| 124 | .env("GIT_AUTHOR_NAME", "alice") |
| 125 | .env("GIT_AUTHOR_EMAIL", "alice@example.test") |
| 126 | .env("GIT_COMMITTER_NAME", "alice") |
| 127 | .env("GIT_COMMITTER_EMAIL", "alice@example.test") |
| 128 | .output() |
| 129 | .expect("run git"); |
| 130 | assert!( |
| 131 | out.status.success(), |
| 132 | "git {args:?} failed:\n{}", |
| 133 | String::from_utf8_lossy(&out.stderr) |
| 134 | ); |
| 135 | String::from_utf8_lossy(&out.stdout).trim().to_owned() |
| 136 | } |
| 137 | |
| 138 | |
| 139 | fn commit_lock(repo: &Path, contents: &str, message: &str) -> String { |
| 140 | fs::write(repo.join("Cargo.lock"), contents).expect("write Cargo.lock"); |
| 141 | git(repo, &["add", "Cargo.lock"]); |
| 142 | git( |
| 143 | repo, |
| 144 | &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], |
| 145 | ); |
| 146 | git(repo, &["rev-parse", "HEAD"]) |
| 147 | } |
| 148 | |
| 149 | |
| 150 | fn dump_zip() -> Vec<u8> { |
| 151 | let mut buf = Cursor::new(Vec::new()); |
| 152 | { |
| 153 | let mut zip = zip::ZipWriter::new(&mut buf); |
| 154 | let options = zip::write::SimpleFileOptions::default(); |
| 155 | zip.start_file("RUSTSEC-TEST-0001.json", options) |
| 156 | .expect("start zip entry"); |
| 157 | zip.write_all(OSV_RECORD.as_bytes()).expect("write record"); |
| 158 | zip.finish().expect("finish zip"); |
| 159 | } |
| 160 | buf.into_inner() |
| 161 | } |
| 162 | |
| 163 | struct Booted { |
| 164 | _server: Server, |
| 165 | _root: tempfile::TempDir, |
| 166 | dir: PathBuf, |
| 167 | http: Http, |
| 168 | port: u16, |
| 169 | } |
| 170 | |
| 171 | async fn boot() -> Booted { |
| 172 | if !tool_on_path("git") { |
| 173 | panic!("`git` was not found on PATH"); |
| 174 | } |
| 175 | |
| 176 | let root = tempfile::Builder::new() |
| 177 | .prefix("adv2e") |
| 178 | .tempdir_in("/tmp") |
| 179 | .expect("temp dir"); |
| 180 | let dir = root.path().to_path_buf(); |
| 181 | |
| 182 | let dump_dir = dir.join("advisories").join("crates.io"); |
| 183 | fs::create_dir_all(&dump_dir).expect("dump dir"); |
| 184 | fs::write(dump_dir.join("all.zip"), dump_zip()).expect("write dump"); |
| 185 | |
| 186 | let ports = pick_ports(); |
| 187 | let config = write_config_with(&dir, ports, ADVISORIES_TOML); |
| 188 | run_admin(&config, "alice", &["--admin"], ALICE_PW); |
| 189 | run_admin(&config, "bob", &[], BOB_PW); |
| 190 | let (server, ports) = launch_with(&dir, ports, ADVISORIES_TOML).await; |
| 191 | Booted { |
| 192 | _server: server, |
| 193 | _root: root, |
| 194 | dir, |
| 195 | http: Http::new(ports.http), |
| 196 | port: ports.http, |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { |
| 201 | let out = Command::new(BIN) |
| 202 | .arg("--config") |
| 203 | .arg(config) |
| 204 | .args(["admin", "create-user", username]) |
| 205 | .args(extra) |
| 206 | .env("FORGE_PASSWORD", password) |
| 207 | .output() |
| 208 | .expect("run admin create-user"); |
| 209 | assert!( |
| 210 | out.status.success(), |
| 211 | "create-user {username} failed:\n{}", |
| 212 | String::from_utf8_lossy(&out.stderr) |
| 213 | ); |
| 214 | } |
| 215 | |
| 216 | async fn login(http: &Http, username: &str, password: &str) -> String { |
| 217 | let page = http.get_cookie("/login", "").await; |
| 218 | assert_eq!(page.status, 200, "GET /login"); |
| 219 | let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); |
| 220 | let precsrf = input_value(&page.body, "precsrf"); |
| 221 | let res = http |
| 222 | .post_form( |
| 223 | "/login", |
| 224 | &[ |
| 225 | ("username", username), |
| 226 | ("password", password), |
| 227 | ("next", "/"), |
| 228 | ("precsrf", &precsrf), |
| 229 | ], |
| 230 | Some(&precsrf_cookie), |
| 231 | ) |
| 232 | .await; |
| 233 | assert_eq!(res.status, 303, "login as {username}: {}", res.body); |
| 234 | cookie_named(&res, "forge_session").expect("session cookie after login") |
| 235 | } |
| 236 | |
| 237 | async fn create_token(http: &Http, session: &str) -> String { |
| 238 | let page = http.get_cookie("/settings/tokens", session).await; |
| 239 | assert_eq!(page.status, 200, "GET /settings/tokens"); |
| 240 | let csrf = input_value(&page.body, "csrf"); |
| 241 | let res = http |
| 242 | .post_form( |
| 243 | "/settings/tokens", |
| 244 | &[ |
| 245 | ("csrf", &csrf), |
| 246 | ("name", "advisory-token"), |
| 247 | ("expires", "30"), |
| 248 | ], |
| 249 | Some(session), |
| 250 | ) |
| 251 | .await; |
| 252 | assert_eq!(res.status, 200, "create token: {}", res.body); |
| 253 | let doc = scraper::Html::parse_document(&res.body); |
| 254 | let selector = scraper::Selector::parse("pre.code code").expect("selector"); |
| 255 | doc.select(&selector) |
| 256 | .next() |
| 257 | .expect("token shown after creation") |
| 258 | .text() |
| 259 | .collect::<String>() |
| 260 | .trim() |
| 261 | .to_owned() |
| 262 | } |
| 263 | |
| 264 | async fn new_repo(http: &Http, session: &str, name: &str) { |
| 265 | let page = http.get_cookie("/new", session).await; |
| 266 | assert_eq!(page.status, 200, "GET /new"); |
| 267 | let csrf = input_value(&page.body, "csrf"); |
| 268 | let res = http |
| 269 | .post_form( |
| 270 | "/new", |
| 271 | &[ |
| 272 | ("csrf", &csrf), |
| 273 | ("name", name), |
| 274 | ("description", ""), |
| 275 | ("visibility", "public"), |
| 276 | ("default_branch", "main"), |
| 277 | ], |
| 278 | Some(session), |
| 279 | ) |
| 280 | .await; |
| 281 | assert_eq!(res.status, 303, "create repo {name}: {}", res.body); |
| 282 | } |
| 283 | |
| 284 | |
| 285 | async fn security_page(http: &Http, session: &str) -> Resp { |
| 286 | http.get_cookie("/alice/app/security", session).await |
| 287 | } |
| 288 | |
| 289 | |
| 290 | async fn wait_for_text(http: &Http, session: &str, text: &str) -> String { |
| 291 | let deadline = Instant::now() + SCAN_TIMEOUT; |
| 292 | loop { |
| 293 | let page = security_page(http, session).await; |
| 294 | if page.status == 200 && page.body.contains(text) { |
| 295 | return page.body; |
| 296 | } |
| 297 | assert!( |
| 298 | Instant::now() < deadline, |
| 299 | "timed out waiting for {text:?} on the security page (last status {})", |
| 300 | page.status |
| 301 | ); |
| 302 | tokio::time::sleep(Duration::from_millis(200)).await; |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | |
| 307 | async fn post_alert_action(http: &Http, session: &str, action: &str) -> Resp { |
| 308 | let page = security_page(http, session).await; |
| 309 | assert_eq!(page.status, 200, "GET security page"); |
| 310 | let csrf = input_value(&page.body, "csrf"); |
| 311 | http.post_form( |
| 312 | &format!("/alice/app/security/{action}"), |
| 313 | &[ |
| 314 | ("csrf", &csrf), |
| 315 | ("advisory", "RUSTSEC-TEST-0001"), |
| 316 | ("ecosystem", "crates.io"), |
| 317 | ("package", "demo"), |
| 318 | ("version", "1.0.0"), |
| 319 | ], |
| 320 | Some(session), |
| 321 | ) |
| 322 | .await |
| 323 | } |
| 324 | |
| 325 | #[tokio::test] |
| 326 | async fn advisory_alert_opens_on_push_and_follows_dismiss_reopen_and_fix() { |
| 327 | let b = boot().await; |
| 328 | let alice = login(&b.http, "alice", ALICE_PW).await; |
| 329 | let token = create_token(&b.http, &alice).await; |
| 330 | new_repo(&b.http, &alice, "app").await; |
| 331 | |
| 332 | let work = b.dir.join("app-work"); |
| 333 | let remote = format!("http://alice:{token}@127.0.0.1:{}/alice/app.git", b.port); |
| 334 | git( |
| 335 | &b.dir, |
| 336 | &["clone", "-q", &remote, work.to_str().expect("utf-8 path")], |
| 337 | ); |
| 338 | commit_lock(&work, LOCK_OLD, "lock demo 1.0.0"); |
| 339 | git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); |
| 340 | |
| 341 | |
| 342 | let page = wait_for_text(&b.http, &alice, "RUSTSEC-TEST-0001").await; |
| 343 | assert!(page.contains(">Open</span>"), "the alert is open"); |
| 344 | assert!(page.contains("demo"), "the vulnerable package is listed"); |
| 345 | |
| 346 | |
| 347 | let dismissed = post_alert_action(&b.http, &alice, "dismiss").await; |
| 348 | assert_eq!(dismissed.status, 303, "dismiss: {}", dismissed.body); |
| 349 | let page = wait_for_text(&b.http, &alice, ">Dismissed</span>").await; |
| 350 | assert!(page.contains("Reopen")); |
| 351 | |
| 352 | let reopened = post_alert_action(&b.http, &alice, "reopen").await; |
| 353 | assert_eq!(reopened.status, 303, "reopen: {}", reopened.body); |
| 354 | wait_for_text(&b.http, &alice, ">Open</span>").await; |
| 355 | |
| 356 | let bob = login(&b.http, "bob", BOB_PW).await; |
| 357 | let refused = security_page(&b.http, &bob).await; |
| 358 | assert_eq!(refused.status, 404, "a reader without Write gets 404"); |
| 359 | |
| 360 | |
| 361 | commit_lock(&work, LOCK_FIXED, "lock demo 2.0.0"); |
| 362 | git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); |
| 363 | wait_for_text(&b.http, &alice, ">Fixed</span>").await; |
| 364 | } |