//! Page: security advisories of a repo (`/{owner}/{repo}/security`). Anyone with Write on the repo //! sees the alerts raised by the lockfile scans (see `advisories::scan`), and can dismiss an open //! alert or reopen a dismissed one. Anyone else gets a 404, so the page does not reveal that a repo //! depends on anything. Alerts open, reopen or close on their own after each scan; only dismiss and //! reopen are manual. use forge_core::store::advisories::{Alert, AlertKey, AlertState}; use forge_core::{Action, AuditAction, authorize}; use serde::Deserialize; use serde_json::json; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::misc::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::repo_mirrors::stamp_in_tz; use crate::state::AppState; use crate::views::shell; path_param!(owner); path_param!(repo); /// The form behind the dismiss and reopen buttons. It names one alert by its key. #[derive(Deserialize)] struct AlertForm { #[serde(default)] csrf: String, #[serde(default)] advisory: String, #[serde(default)] ecosystem: String, #[serde(default)] package: String, #[serde(default)] version: String, } /// One alert, as the page shows it. struct AlertRow { advisory: String, osv_href: String, package: String, version: String, severity: String, summary: String, state_text: &'static str, state_class: &'static str, created: String, open: bool, dismissed: bool, dismiss_action: String, reopen_action: String, fields: Vec<(&'static str, String)>, } fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn security_href(title: &str) -> String { format!("/{title}/security") } /// Whether the viewer may manage alerts: Write, Maintain or Admin on the repo, or its owner. pub(crate) fn can_write(ctx: &RepoCtx) -> bool { let actor = ctx.viewer.as_ref().map(|(user, _)| user); authorize(actor, &ctx.repo, ctx.role, ctx.org_role, Action::Write).is_ok() } fn row_of(alert: &Alert, title: &str, tz: &str, lang: Lang) -> AlertRow { let (state_text, state_class) = match alert.state { AlertState::Open => (g::t(lang, K::StateOpen), "hook-state fail"), AlertState::Dismissed => (g::t(lang, K::StateDismissed), "hook-state"), AlertState::Fixed => (g::t(lang, K::StateFixed), "hook-state ok"), }; let key = &alert.key; let base = security_href(title); AlertRow { advisory: key.advisory_id.clone(), osv_href: format!( "https://osv.dev/vulnerability/{}", key.advisory_id.replace(['/', '?', '#'], "") ), package: key.package.clone(), version: key.version.clone(), severity: alert.severity.clone(), summary: alert.summary.clone(), state_text, state_class, created: stamp_in_tz(&alert.created_at, tz), open: alert.state == AlertState::Open, dismissed: alert.state == AlertState::Dismissed, dismiss_action: format!("{base}/dismiss"), reopen_action: format!("{base}/reopen"), fields: vec![ ("advisory", key.advisory_id.clone()), ("ecosystem", key.ecosystem.clone()), ("package", key.package.clone()), ("version", key.version.clone()), ], } } #[component] async fn security_body( lang: Lang, head: RepoHead, enabled: bool, dependencies: u64, csrf: String, rows: Vec, ) -> Result { let open_count = rows.iter().filter(|r| r.open).count(); Ok(view! { repo_head(head: head)

(i18n::t(lang, Key::Security))

if !enabled {

(g::t(lang, K::SecurityOff))

} else {

(g::dependencies_summary(lang, dependencies, open_count))

(g::t(lang, K::LockfilesRead))

}
if rows.is_empty() {

(g::t(lang, K::NoAlerts))

} else {
for row in rows.iter() { }
(g::t(lang, K::ColAdvisory)) (g::t(lang, K::ColPackage)) (g::t(lang, K::ColVersion)) (g::t(lang, K::ColSeverity)) (g::t(lang, K::ColSummary)) (g::t(lang, K::ColState)) (g::t(lang, K::ColOpened))
(row.advisory.as_str()) (row.package.as_str()) (row.version.as_str()) (row.severity.as_str()) (row.summary.as_str()) (row.state_text) (row.created.as_str()) if row.open {
} if row.dismissed {
}
}
}) } /// The security page. Write on the repo or 404. #[page("/{owner}/{repo}/security")] async fn security_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; if !can_write(&ctx) { return Err(not_found().into()); } let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let title = title_of(&ctx); let head = ctx.head(state, "security").await?; let dependencies = state.store.dependency_count(&ctx.repo.id).await?; let tz = ctx.viewer_tz().to_owned(); let rows: Vec = state .store .list_alerts(&ctx.repo.id) .await? .iter() .map(|alert| row_of(alert, &title, &tz, lang)) .collect(); let csrf = session.csrf_token.clone(); let enabled = state.config.advisories.enabled; let page_title = format!("{title} \u{b7} {}", i18n::t(lang, Key::Security)); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), security_body(lang: lang, head: head, enabled: enabled, dependencies: dependencies, csrf: csrf, rows: rows)) }) } /// Moves one alert between two states. A stale form (the alert is no longer in `from`) changes /// nothing and is not audited. async fn change_alert( cx: &Cx, form: AlertForm, from: AlertState, to: AlertState, action: AuditAction, ) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !can_write(&ctx) { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let key = AlertKey { advisory_id: form.advisory.clone(), ecosystem: form.ecosystem.clone(), package: form.package.clone(), version: form.version.clone(), }; let title = title_of(&ctx); let changed = state .store .set_alert_state(&ctx.repo.id, &key, from, to) .await?; if changed { audit::record( &state.store, Some(cx), Actor::User(&user), action, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, json!({ "advisory": key.advisory_id, "ecosystem": key.ecosystem, "package": key.package, "version": key.version, }), ) .await; } Ok(see_other(security_href(&title))) } #[route(POST "/{owner}/{repo}/security/dismiss")] async fn alert_dismiss(cx: &Cx, Form(form): Form) -> Result { change_alert( cx, form, AlertState::Open, AlertState::Dismissed, AuditAction::AdvisoryAlertDismissed, ) .await } #[route(POST "/{owner}/{repo}/security/reopen")] async fn alert_reopen(cx: &Cx, Form(form): Form) -> Result { change_alert( cx, form, AlertState::Dismissed, AlertState::Open, AuditAction::AdvisoryAlertReopened, ) .await }