forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.4 KiB
1//! Page: security advisories of a repo (`/{owner}/{repo}/security`). Anyone with Write on the repo
2//! sees the alerts raised by the lockfile scans (see `advisories::scan`), and can dismiss an open
3//! alert or reopen a dismissed one. Anyone else gets a 404, so the page does not reveal that a repo
4//! depends on anything. Alerts open, reopen or close on their own after each scan; only dismiss and
5//! reopen are manual.
6
7use forge_core::store::advisories::{Alert, AlertKey, AlertState};
8use forge_core::{Action, AuditAction, authorize};
9use serde::Deserialize;
10use serde_json::json;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
15use topcoat::router::{page, path_param, route};
16use topcoat::view::{View, component, view};
17
18use crate::audit::{self, Actor, Target};
19use crate::auth::{require_csrf, require_user};
20use crate::i18n::misc::{self as g, K};
21use crate::i18n::{self, Key, Lang};
22use crate::pages::repo::repo_head;
23use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
24use crate::pages::repo_mirrors::stamp_in_tz;
25use crate::state::AppState;
26use crate::views::shell;
27
28path_param!(owner);
29path_param!(repo);
30
31/// The form behind the dismiss and reopen buttons. It names one alert by its key.
32#[derive(Deserialize)]
33struct 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/// One alert, as the page shows it.
47struct 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
64fn title_of(ctx: &RepoCtx) -> String {
65 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
66}
67
68fn security_href(title: &str) -> String {
69 format!("/{title}/security")
70}
71
72/// Whether the viewer may manage alerts: Write, Maintain or Admin on the repo, or its owner.
73pub(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
78fn 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]
113async 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/// The security page. Write on the repo or 404.
194#[page("/{owner}/{repo}/security")]
195async 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/// Moves one alert between two states. A stale form (the alert is no longer in `from`) changes
227/// nothing and is not audited.
228async 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")]
281async 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")]
293async 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}