| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use forge_core::store::advisories::{Alert, AlertKey, AlertState}; |
| 8 | use forge_core::{Action, AuditAction, authorize}; |
| 9 | use serde::Deserialize; |
| 10 | use serde_json::json; |
| 11 | use topcoat::Result; |
| 12 | use topcoat::context::{Cx, app_context}; |
| 13 | use topcoat::router::content::Form; |
| 14 | use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; |
| 15 | use topcoat::router::{page, path_param, route}; |
| 16 | use topcoat::view::{View, component, view}; |
| 17 | |
| 18 | use crate::audit::{self, Actor, Target}; |
| 19 | use crate::auth::{require_csrf, require_user}; |
| 20 | use crate::i18n::misc::{self as g, K}; |
| 21 | use crate::i18n::{self, Key, Lang}; |
| 22 | use crate::pages::repo::repo_head; |
| 23 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 24 | use crate::pages::repo_mirrors::stamp_in_tz; |
| 25 | use crate::state::AppState; |
| 26 | use crate::views::shell; |
| 27 | |
| 28 | path_param!(owner); |
| 29 | path_param!(repo); |
| 30 | |
| 31 | |
| 32 | #[derive(Deserialize)] |
| 33 | struct AlertForm { |
| 34 | #[serde(default)] |
| 35 | csrf: String, |
| 36 | #[serde(default)] |
| 37 | advisory: String, |
| 38 | #[serde(default)] |
| 39 | ecosystem: String, |
| 40 | #[serde(default)] |
| 41 | package: String, |
| 42 | #[serde(default)] |
| 43 | version: String, |
| 44 | } |
| 45 | |
| 46 | |
| 47 | struct AlertRow { |
| 48 | advisory: String, |
| 49 | osv_href: String, |
| 50 | package: String, |
| 51 | version: String, |
| 52 | severity: String, |
| 53 | summary: String, |
| 54 | state_text: &'static str, |
| 55 | state_class: &'static str, |
| 56 | created: String, |
| 57 | open: bool, |
| 58 | dismissed: bool, |
| 59 | dismiss_action: String, |
| 60 | reopen_action: String, |
| 61 | fields: Vec<(&'static str, String)>, |
| 62 | } |
| 63 | |
| 64 | fn title_of(ctx: &RepoCtx) -> String { |
| 65 | format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) |
| 66 | } |
| 67 | |
| 68 | fn security_href(title: &str) -> String { |
| 69 | format!("/{title}/security") |
| 70 | } |
| 71 | |
| 72 | |
| 73 | pub(crate) fn can_write(ctx: &RepoCtx) -> bool { |
| 74 | let actor = ctx.viewer.as_ref().map(|(user, _)| user); |
| 75 | authorize(actor, &ctx.repo, ctx.role, ctx.org_role, Action::Write).is_ok() |
| 76 | } |
| 77 | |
| 78 | fn row_of(alert: &Alert, title: &str, tz: &str, lang: Lang) -> AlertRow { |
| 79 | let (state_text, state_class) = match alert.state { |
| 80 | AlertState::Open => (g::t(lang, K::StateOpen), "hook-state fail"), |
| 81 | AlertState::Dismissed => (g::t(lang, K::StateDismissed), "hook-state"), |
| 82 | AlertState::Fixed => (g::t(lang, K::StateFixed), "hook-state ok"), |
| 83 | }; |
| 84 | let key = &alert.key; |
| 85 | let base = security_href(title); |
| 86 | AlertRow { |
| 87 | advisory: key.advisory_id.clone(), |
| 88 | osv_href: format!( |
| 89 | "https://osv.dev/vulnerability/{}", |
| 90 | key.advisory_id.replace(['/', '?', '#'], "") |
| 91 | ), |
| 92 | package: key.package.clone(), |
| 93 | version: key.version.clone(), |
| 94 | severity: alert.severity.clone(), |
| 95 | summary: alert.summary.clone(), |
| 96 | state_text, |
| 97 | state_class, |
| 98 | created: stamp_in_tz(&alert.created_at, tz), |
| 99 | open: alert.state == AlertState::Open, |
| 100 | dismissed: alert.state == AlertState::Dismissed, |
| 101 | dismiss_action: format!("{base}/dismiss"), |
| 102 | reopen_action: format!("{base}/reopen"), |
| 103 | fields: vec![ |
| 104 | ("advisory", key.advisory_id.clone()), |
| 105 | ("ecosystem", key.ecosystem.clone()), |
| 106 | ("package", key.package.clone()), |
| 107 | ("version", key.version.clone()), |
| 108 | ], |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | #[component] |
| 113 | async fn security_body( |
| 114 | lang: Lang, |
| 115 | head: RepoHead, |
| 116 | enabled: bool, |
| 117 | dependencies: u64, |
| 118 | csrf: String, |
| 119 | rows: Vec<AlertRow>, |
| 120 | ) -> Result<impl View> { |
| 121 | let open_count = rows.iter().filter(|r| r.open).count(); |
| 122 | Ok(view! { |
| 123 | repo_head(head: head) |
| 124 | <section class="card"> |
| 125 | <h2>(i18n::t(lang, Key::Security))</h2> |
| 126 | if !enabled { |
| 127 | <p class="muted small">(g::t(lang, K::SecurityOff))</p> |
| 128 | } else { |
| 129 | <p class="muted small">(g::dependencies_summary(lang, dependencies, open_count))</p> |
| 130 | <p class="muted small">(g::t(lang, K::LockfilesRead))</p> |
| 131 | } |
| 132 | </section> |
| 133 | <section class="card"> |
| 134 | if rows.is_empty() { |
| 135 | <p class="muted">(g::t(lang, K::NoAlerts))</p> |
| 136 | } else { |
| 137 | <div class="table-wrap"> |
| 138 | <table class="table security-alerts"> |
| 139 | <thead> |
| 140 | <tr> |
| 141 | <th>(g::t(lang, K::ColAdvisory))</th> |
| 142 | <th>(g::t(lang, K::ColPackage))</th> |
| 143 | <th>(g::t(lang, K::ColVersion))</th> |
| 144 | <th>(g::t(lang, K::ColSeverity))</th> |
| 145 | <th>(g::t(lang, K::ColSummary))</th> |
| 146 | <th>(g::t(lang, K::ColState))</th> |
| 147 | <th>(g::t(lang, K::ColOpened))</th> |
| 148 | <th></th> |
| 149 | </tr> |
| 150 | </thead> |
| 151 | <tbody> |
| 152 | for row in rows.iter() { |
| 153 | <tr> |
| 154 | <td><a href=(row.osv_href.as_str()) rel="noopener noreferrer"><code>(row.advisory.as_str())</code></a></td> |
| 155 | <td><code>(row.package.as_str())</code></td> |
| 156 | <td><code>(row.version.as_str())</code></td> |
| 157 | <td>(row.severity.as_str())</td> |
| 158 | <td>(row.summary.as_str())</td> |
| 159 | <td><span class=(row.state_class)>(row.state_text)</span></td> |
| 160 | <td>(row.created.as_str())</td> |
| 161 | <td> |
| 162 | if row.open { |
| 163 | <form method="post" action=(row.dismiss_action.as_str()) class="inline-form"> |
| 164 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 165 | <input type="hidden" name="advisory" value=(row.fields[0].1.as_str())> |
| 166 | <input type="hidden" name="ecosystem" value=(row.fields[1].1.as_str())> |
| 167 | <input type="hidden" name="package" value=(row.fields[2].1.as_str())> |
| 168 | <input type="hidden" name="version" value=(row.fields[3].1.as_str())> |
| 169 | <button type="submit">(g::t(lang, K::Dismiss))</button> |
| 170 | </form> |
| 171 | } |
| 172 | if row.dismissed { |
| 173 | <form method="post" action=(row.reopen_action.as_str()) class="inline-form"> |
| 174 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 175 | <input type="hidden" name="advisory" value=(row.fields[0].1.as_str())> |
| 176 | <input type="hidden" name="ecosystem" value=(row.fields[1].1.as_str())> |
| 177 | <input type="hidden" name="package" value=(row.fields[2].1.as_str())> |
| 178 | <input type="hidden" name="version" value=(row.fields[3].1.as_str())> |
| 179 | <button type="submit">(g::t(lang, K::Reopen))</button> |
| 180 | </form> |
| 181 | } |
| 182 | </td> |
| 183 | </tr> |
| 184 | } |
| 185 | </tbody> |
| 186 | </table> |
| 187 | </div> |
| 188 | } |
| 189 | </section> |
| 190 | }) |
| 191 | } |
| 192 | |
| 193 | |
| 194 | #[page("/{owner}/{repo}/security")] |
| 195 | async fn security_page(cx: &Cx) -> Result<impl View> { |
| 196 | let owner = path_param::<Owner>(cx); |
| 197 | let name = path_param::<Repo>(cx); |
| 198 | let ctx = load_repo(cx, owner, name).await?; |
| 199 | let (user, session) = require_user(cx).await?; |
| 200 | if !can_write(&ctx) { |
| 201 | return Err(not_found().into()); |
| 202 | } |
| 203 | let lang = i18n::lang(cx, Some(&user)); |
| 204 | let state = app_context::<AppState>(cx); |
| 205 | let title = title_of(&ctx); |
| 206 | let head = ctx.head(state, "security").await?; |
| 207 | let dependencies = state.store.dependency_count(&ctx.repo.id).await?; |
| 208 | let tz = ctx.viewer_tz().to_owned(); |
| 209 | let rows: Vec<AlertRow> = state |
| 210 | .store |
| 211 | .list_alerts(&ctx.repo.id) |
| 212 | .await? |
| 213 | .iter() |
| 214 | .map(|alert| row_of(alert, &title, &tz, lang)) |
| 215 | .collect(); |
| 216 | let csrf = session.csrf_token.clone(); |
| 217 | let enabled = state.config.advisories.enabled; |
| 218 | let page_title = format!("{title} \u{b7} {}", i18n::t(lang, Key::Security)); |
| 219 | Ok(view! { |
| 220 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 221 | security_body(lang: lang, head: head, enabled: enabled, dependencies: dependencies, |
| 222 | csrf: csrf, rows: rows)) |
| 223 | }) |
| 224 | } |
| 225 | |
| 226 | |
| 227 | |
| 228 | async fn change_alert( |
| 229 | cx: &Cx, |
| 230 | form: AlertForm, |
| 231 | from: AlertState, |
| 232 | to: AlertState, |
| 233 | action: AuditAction, |
| 234 | ) -> Result<SeeOther> { |
| 235 | let owner = path_param::<Owner>(cx); |
| 236 | let name = path_param::<Repo>(cx); |
| 237 | let ctx = load_repo(cx, owner, name).await?; |
| 238 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 239 | if !can_write(&ctx) { |
| 240 | return Err(not_found().into()); |
| 241 | } |
| 242 | if ctx.repo.archived { |
| 243 | return Err(forbidden().into()); |
| 244 | } |
| 245 | let state = app_context::<AppState>(cx); |
| 246 | let key = AlertKey { |
| 247 | advisory_id: form.advisory.clone(), |
| 248 | ecosystem: form.ecosystem.clone(), |
| 249 | package: form.package.clone(), |
| 250 | version: form.version.clone(), |
| 251 | }; |
| 252 | let title = title_of(&ctx); |
| 253 | let changed = state |
| 254 | .store |
| 255 | .set_alert_state(&ctx.repo.id, &key, from, to) |
| 256 | .await?; |
| 257 | if changed { |
| 258 | audit::record( |
| 259 | &state.store, |
| 260 | Some(cx), |
| 261 | Actor::User(&user), |
| 262 | action, |
| 263 | Target { |
| 264 | kind: "repo", |
| 265 | id: &ctx.repo.id, |
| 266 | name: &title, |
| 267 | }, |
| 268 | json!({ |
| 269 | "advisory": key.advisory_id, |
| 270 | "ecosystem": key.ecosystem, |
| 271 | "package": key.package, |
| 272 | "version": key.version, |
| 273 | }), |
| 274 | ) |
| 275 | .await; |
| 276 | } |
| 277 | Ok(see_other(security_href(&title))) |
| 278 | } |
| 279 | |
| 280 | #[route(POST "/{owner}/{repo}/security/dismiss")] |
| 281 | async fn alert_dismiss(cx: &Cx, Form(form): Form<AlertForm>) -> Result<SeeOther> { |
| 282 | change_alert( |
| 283 | cx, |
| 284 | form, |
| 285 | AlertState::Open, |
| 286 | AlertState::Dismissed, |
| 287 | AuditAction::AdvisoryAlertDismissed, |
| 288 | ) |
| 289 | .await |
| 290 | } |
| 291 | |
| 292 | #[route(POST "/{owner}/{repo}/security/reopen")] |
| 293 | async fn alert_reopen(cx: &Cx, Form(form): Form<AlertForm>) -> Result<SeeOther> { |
| 294 | change_alert( |
| 295 | cx, |
| 296 | form, |
| 297 | AlertState::Dismissed, |
| 298 | AlertState::Open, |
| 299 | AuditAction::AdvisoryAlertReopened, |
| 300 | ) |
| 301 | .await |
| 302 | } |