rustsec_admin/
web.rs

1//! Code relating to the generation of the <https://rustsec.org> web site.
2
3use crate::prelude::*;
4use askama::Template;
5use atom_syndication::{
6    CategoryBuilder, ContentBuilder, Entry, EntryBuilder, FeedBuilder, FixedDateTime, LinkBuilder,
7    PersonBuilder, Text,
8};
9use chrono::{Duration, NaiveDate, Utc};
10use comrak::{markdown_to_html, ComrakOptions};
11use rust_embed::RustEmbed;
12use rustsec::advisory::Id;
13use rustsec::repository::git::GitModificationTimes;
14use rustsec::repository::git::GitPath;
15use rustsec::{advisory, Repository};
16use std::collections::{HashMap, HashSet};
17use std::str::FromStr;
18use std::{
19    fs::{self, File},
20    iter,
21    path::{Path, PathBuf},
22};
23use xml::escape::escape_str_attribute;
24
25// TODO(tarcieri): replace with `DateTime`
26#[allow(deprecated)]
27use chrono::Date;
28
29#[derive(Template)]
30#[template(path = "index.html")]
31struct IndexTemplate;
32
33#[derive(Template)]
34#[template(path = "search.html")]
35struct SearchTemplate;
36
37#[derive(Template)]
38#[template(path = "static.html")]
39struct StaticTemplate {
40    title: String,
41    content: String,
42}
43
44#[derive(Template)]
45#[template(path = "advisories.html")]
46struct AdvisoriesListTemplate {
47    /// `Vec<(advisory, publication_date, rendered_title, advisory_title_type)>`
48    advisories: Vec<(rustsec::Advisory, advisory::Date, String, String)>,
49}
50
51#[derive(Template)]
52#[template(path = "advisories-sublist.html")]
53struct AdvisoriesSubList {
54    title: String,
55    group_by: String,
56    /// `Vec<(advisory, publication_date, rendered_title, advisory_title_type)>`
57    advisories: Vec<(rustsec::Advisory, advisory::Date, String, String)>,
58}
59
60#[derive(Template)]
61#[template(path = "advisory.html")]
62struct AdvisoryTemplate<'a> {
63    advisory: &'a rustsec::Advisory,
64    rendered_description: String,
65    rendered_title: String,
66    cdate: advisory::Date,
67    mdate: advisory::Date,
68}
69
70// Used for feed and included by `AdvisoryTemplate`
71#[derive(Template)]
72#[template(path = "advisory-content.html")]
73struct AdvisoryContentTemplate<'a> {
74    advisory: &'a rustsec::Advisory,
75    rendered_description: String,
76    rendered_title: String,
77    cdate: advisory::Date,
78    mdate: advisory::Date,
79}
80
81#[derive(Template)]
82#[template(path = "sublist-index.html")]
83struct ItemsList {
84    title: String,
85    /// `Vec<(name, url, option(count))>`
86    items: Vec<(String, String, Option<usize>)>,
87}
88
89fn render_list_index(title: &str, mut items: Vec<(String, String, Option<usize>)>, folder: &Path) {
90    items.sort_by(|a, b| a.0.to_lowercase().partial_cmp(&b.0.to_lowercase()).unwrap());
91    let index_data = ItemsList {
92        title: title.to_owned(),
93        items,
94    };
95    let index_path = folder.join("index.html");
96    fs::write(&index_path, index_data.render().unwrap()).unwrap();
97    status_ok!("Rendered", "{}", index_path.display());
98}
99
100/// Render all advisories using the Markdown template
101pub fn render_advisories(output_folder: PathBuf) {
102    // Create dest
103    fs::create_dir_all(&output_folder).unwrap();
104
105    // Get static pages from repository
106    let repo = Repository::fetch_default_repo().unwrap();
107    let contributing_path = repo.path().join("CONTRIBUTING.md");
108
109    // Get publication and latest modification dates
110    let mod_times = GitModificationTimes::new(&repo).unwrap();
111
112    // Get advisories
113    let db = rustsec::Database::fetch().unwrap();
114    let mut advisories: Vec<(rustsec::Advisory, advisory::Date, advisory::Date)> = db
115        .into_iter()
116        .map(|a| {
117            let (cdate, mdate) = advisory_dates(&a, &repo, &mod_times);
118            (a, cdate, mdate)
119        })
120        .collect();
121
122    // Render static pages from repository
123    let contributing_md = fs::read_to_string(contributing_path).unwrap();
124    let static_template = StaticTemplate {
125        title: "Reporting Vulnerabilities".to_string(),
126        content: markdown_to_html(&contributing_md, &ComrakOptions::default()),
127    };
128    let contributing_page = static_template.render().unwrap();
129    fs::write(output_folder.join("contributing.html"), contributing_page).unwrap();
130
131    // Render individual advisory pages (/advisories/${id}.html)
132    let advisories_folder = output_folder.join("advisories");
133    fs::create_dir_all(&advisories_folder).unwrap();
134
135    for (advisory, cdate, mdate) in &advisories {
136        let output_path = advisories_folder.join(advisory.id().as_str().to_owned() + ".html");
137
138        let rendered_description =
139            markdown_to_html(advisory.description(), &ComrakOptions::default());
140        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
141
142        let advisory_tmpl = AdvisoryTemplate {
143            advisory,
144            rendered_description,
145            rendered_title,
146            cdate: cdate.clone(),
147            mdate: mdate.clone(),
148        };
149        fs::write(&output_path, advisory_tmpl.render().unwrap()).unwrap();
150
151        status_ok!("Rendered", "{}", output_path.display());
152    }
153
154    // Copy all the static assets.
155    copy_static_assets(&output_folder);
156
157    // Render the index.html (/) page.
158    let index_template = IndexTemplate;
159    let index_page = index_template.render().unwrap();
160    fs::write(output_folder.join("index.html"), index_page).unwrap();
161
162    // Render the search.html page.
163    let search_page = SearchTemplate.render().unwrap();
164    fs::write(output_folder.join("search.html"), search_page).unwrap();
165
166    // Render the advisories.html (/advisories) page.
167
168    // Sort the advisories by date in descending order for the big listing.
169    #[allow(clippy::unnecessary_sort_by)]
170    advisories.sort_by(|(_, a, _), (_, b, _)| b.cmp(a));
171
172    let mut advisories_index = vec![];
173    for (advisory, cdate, _) in advisories.clone() {
174        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
175        let advisory_title_type = title_type(&advisory);
176        advisories_index.push((advisory, cdate, rendered_title, advisory_title_type));
177    }
178
179    let advisories_page_tmpl = AdvisoriesListTemplate {
180        advisories: advisories_index,
181    };
182    let advisories_page = advisories_page_tmpl.render().unwrap();
183    fs::write(advisories_folder.join("index.html"), advisories_page).unwrap();
184
185    status_ok!(
186        "Completed",
187        "{} advisories rendered as HTML",
188        advisories.len()
189    );
190
191    // Render the per-package pages (/packages/${package}.html).
192    let mut advisories_per_package = Vec::<AdvisoriesSubList>::new();
193    let mut packages = Vec::<(String, String, Option<usize>)>::new();
194    for (advisory, cdate, _) in advisories.clone() {
195        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
196        let advisory_title_type = title_type(&advisory);
197        let package = advisory.metadata.package.to_string();
198
199        match packages.iter_mut().find(|(n, _, _)| *n == package) {
200            Some(p) => p.2 = Some(p.2.unwrap() + 1),
201            None => packages.push((
202                package.clone(),
203                format!("/packages/{}.html", package.clone()),
204                Some(1),
205            )),
206        }
207        match advisories_per_package
208            .iter_mut()
209            .find(|advisories| advisories.group_by == advisory.metadata.package.to_string())
210        {
211            Some(advisories) => advisories.advisories.push((
212                advisory,
213                cdate.clone(),
214                rendered_title,
215                advisory_title_type,
216            )),
217            None => advisories_per_package.push(AdvisoriesSubList {
218                title: format!("Advisories for package '{}'", advisory.metadata.package),
219                group_by: advisory.metadata.package.to_string(),
220                advisories: vec![(advisory, cdate, rendered_title, advisory_title_type)],
221            }),
222        }
223    }
224    let folder = output_folder.join("packages");
225    fs::create_dir_all(&folder).unwrap();
226    // index
227    render_list_index("Packages", packages, folder.as_ref());
228    // per package page
229    for tpl in &advisories_per_package {
230        let output_path = folder.join(tpl.group_by.clone() + ".html");
231        fs::write(&output_path, tpl.render().unwrap()).unwrap();
232        status_ok!("Rendered", "{}", output_path.display());
233    }
234    status_ok!(
235        "Completed",
236        "{} packages rendered as HTML",
237        advisories_per_package.len()
238    );
239
240    // Render the per-keyword pages (/keywords/${keyword}.html).
241    let mut advisories_per_keyword = Vec::<AdvisoriesSubList>::new();
242    let mut keywords = Vec::<(String, String, Option<usize>)>::new();
243    for (advisory, cdate, _) in advisories.clone() {
244        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
245        let advisory_title_type = title_type(&advisory);
246
247        // merge keywords with the same slug
248        let mut slug_keywords = advisory
249            .metadata
250            .keywords
251            .as_slice()
252            .iter()
253            .map(|k| filters::safe_keyword(k.as_str()).unwrap())
254            .collect::<Vec<String>>();
255        slug_keywords.sort();
256        slug_keywords.dedup();
257
258        for keyword in slug_keywords {
259            if !keywords.iter().any(|(n, _, _)| *n == keyword) {
260                keywords.push((
261                    keyword.clone(),
262                    format!("/keywords/{}.html", keyword.clone()),
263                    None,
264                ));
265            }
266
267            match advisories_per_keyword
268                .iter_mut()
269                .find(|advisories| advisories.group_by == keyword.as_str())
270            {
271                Some(advisories) => advisories.advisories.push((
272                    advisory.clone(),
273                    cdate.clone(),
274                    rendered_title.clone(),
275                    advisory_title_type.clone(),
276                )),
277                None => advisories_per_keyword.push(AdvisoriesSubList {
278                    title: format!("Advisories with keyword '{}'", keyword.as_str()),
279                    group_by: keyword.as_str().to_string(),
280                    advisories: vec![(
281                        advisory.clone(),
282                        cdate.clone(),
283                        rendered_title.clone(),
284                        advisory_title_type.clone(),
285                    )],
286                }),
287            }
288        }
289    }
290    let folder = output_folder.join("keywords");
291    fs::create_dir_all(&folder).unwrap();
292    render_list_index("Keywords", keywords, folder.as_ref());
293    for tpl in &advisories_per_keyword {
294        let output_path = folder.join(tpl.group_by.clone() + ".html");
295        fs::write(&output_path, tpl.render().unwrap()).unwrap();
296        status_ok!("Rendered", "{}", output_path.display());
297    }
298    status_ok!(
299        "Completed",
300        "{} packages rendered as HTML",
301        advisories_per_keyword.len()
302    );
303
304    // Render the per-category pages (/categories/${category}.html).
305    let mut advisories_per_category = Vec::<AdvisoriesSubList>::new();
306    let mut categories = Vec::<(String, String, Option<usize>)>::new();
307    for (advisory, cdate, _) in advisories.clone() {
308        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
309        let advisory_title_type = title_type(&advisory);
310
311        for category in advisory.metadata.categories.as_slice() {
312            if !categories.iter().any(|(n, _, _)| n == category.name()) {
313                categories.push((
314                    category.name().to_owned(),
315                    format!("/categories/{}.html", category.name()),
316                    None,
317                ));
318            }
319
320            match advisories_per_category
321                .iter_mut()
322                .find(|advisories| advisories.group_by == category.name())
323            {
324                Some(advisories) => advisories.advisories.push((
325                    advisory.clone(),
326                    cdate.clone(),
327                    rendered_title.clone(),
328                    advisory_title_type.clone(),
329                )),
330                None => advisories_per_category.push(AdvisoriesSubList {
331                    title: format!("Advisories in category '{}'", category.name()),
332                    group_by: category.name().to_string(),
333                    advisories: vec![(
334                        advisory.clone(),
335                        cdate.clone(),
336                        rendered_title.clone(),
337                        advisory_title_type.clone(),
338                    )],
339                }),
340            }
341        }
342    }
343    let folder = output_folder.join("categories");
344    fs::create_dir_all(&folder).unwrap();
345
346    // index
347    render_list_index("Categories", categories, folder.as_ref());
348    // par value page
349    for tpl in &advisories_per_category {
350        let output_path = folder.join(tpl.group_by.clone() + ".html");
351        fs::write(&output_path, tpl.render().unwrap()).unwrap();
352        status_ok!("Rendered", "{}", output_path.display());
353    }
354    status_ok!(
355        "Completed",
356        "{} packages rendered as HTML",
357        advisories_per_category.len() + 1
358    );
359
360    // Index
361    let index_path = output_folder.join("js").join("index.js");
362    render_index(&index_path, &advisories);
363    status_ok!("Rendered", "{}", index_path.display());
364    status_ok!(
365        "Completed",
366        "{} advisories rendered in search index as JS",
367        advisories.len()
368    );
369
370    // Feed
371    let feed_path = output_folder.join("feed.xml");
372    let min_feed_len = 10;
373
374    // TODO(tarcieri): replace with `DateTime`
375    #[allow(deprecated)]
376    let last_week_len = advisories
377        .iter()
378        .take_while(|(_, c, _)| {
379            Date::from_utc(
380                NaiveDate::parse_from_str(c.as_str(), "%Y-%m-%d").unwrap(),
381                Utc,
382            ) > Utc::today() - Duration::days(8)
383        })
384        .count();
385
386    // include max(latest week of advisories, 10 latest advisories)
387    // the goal is not to miss a vulnerability in case of burst
388    // and to never have an empty feed.
389    let len = if advisories.len() < min_feed_len {
390        advisories.len()
391    } else if last_week_len > min_feed_len {
392        last_week_len
393    } else {
394        min_feed_len
395    };
396    render_feed(&feed_path, &advisories[..len]);
397    status_ok!("Rendered", "{}", feed_path.display());
398    status_ok!("Completed", "{} advisories rendered in atom feed", len);
399}
400
401/// Title with the id, the package name and the advisory type
402fn title_type(advisory: &rustsec::Advisory) -> String {
403    use rustsec::advisory::Informational;
404
405    let id = advisory.id().as_str();
406    let package = advisory.metadata.package.as_str();
407
408    match &advisory.metadata.informational {
409        Some(Informational::Notice) => format!("{}: Security notice about {}", id, package),
410        Some(Informational::Unmaintained) => format!("{}: {} is unmaintained", id, package),
411        Some(Informational::Unsound) => format!("{}: Unsoundness in {}", id, package),
412        Some(Informational::Other(s)) => format!("{}: {} is {}", id, package, s),
413        Some(_) => format!("{}: Advisory for {}", id, package),
414        // Not informational => vulnerability
415        None => format!("{}: Vulnerability in {}", id, package),
416    }
417}
418
419/// Renders the local search index
420fn render_index(
421    output_path: &Path,
422    advisories: &[(rustsec::Advisory, advisory::Date, advisory::Date)],
423) {
424    // Map of `lowercase(ID) -> related IDs` (including self, avoid redirecting to non-existent IDs)
425    let mut ids: HashMap<String, Vec<Id>> = HashMap::new();
426    // List of packages
427    let mut packages = HashSet::new();
428
429    for (advisory, _, _) in advisories {
430        let id = advisory.id().to_owned();
431        for alias in advisory
432            .metadata
433            .aliases
434            .iter()
435            .chain(advisory.metadata.related.iter())
436            .chain(iter::once(&id))
437        {
438            // allows case-insensitive access
439            let alias = alias.to_string().to_lowercase();
440            ids.entry(alias)
441                .and_modify(|v| v.push(id.clone()))
442                .or_insert_with(|| vec![id.clone()]);
443        }
444        packages.insert(advisory.metadata.package.to_string().to_lowercase());
445    }
446    let ids_json = serde_json::to_string(&ids).unwrap();
447    let package_json = serde_json::to_string(&packages).unwrap();
448
449    let js = format!("var ids = {}\nvar packages = {}\n", ids_json, package_json);
450    fs::write(output_path, js).unwrap();
451}
452
453/// Renders an Atom feed of advisories
454fn render_feed(
455    output_path: &Path,
456    advisories: &[(rustsec::Advisory, advisory::Date, advisory::Date)],
457) {
458    let mut entries: Vec<Entry> = vec![];
459    let author = PersonBuilder::default().name("RustSec").build();
460
461    // Used as latest update to feed
462    let latest_advisory_date =
463        advisories.first().unwrap().1.as_str().to_owned() + "T12:00:00+00:00";
464
465    for (advisory, cdate, mdate) in advisories {
466        let escaped_title_type = escape_str_attribute(&title_type(advisory)).into_owned();
467        let escaped_title = escape_str_attribute(advisory.title()).into_owned();
468        let cdate_time = cdate.as_str().to_owned() + "T12:00:00+00:00";
469        let mdate_time = mdate.as_str().to_owned() + "T12:00:00+00:00";
470        let url = "https://rustsec.org/advisories/".to_owned() + advisory.id().as_str() + ".html";
471
472        let link = LinkBuilder::default()
473            .rel("alternate")
474            .mime_type(Some("text/html".to_owned()))
475            .title(escaped_title_type.clone())
476            .href(url.clone())
477            .build();
478
479        let mut categories = vec![];
480        for category in &advisory.metadata.categories {
481            categories.push(
482                CategoryBuilder::default()
483                    .term(category.to_string())
484                    .build(),
485            );
486        }
487
488        let rendered_description =
489            markdown_to_html(advisory.description(), &ComrakOptions::default());
490        let rendered_title = markdown_to_html(advisory.title(), &ComrakOptions::default());
491
492        let advisory_tmpl = AdvisoryContentTemplate {
493            advisory,
494            rendered_description,
495            rendered_title,
496            cdate: cdate.clone(),
497            mdate: mdate.clone(),
498        };
499        let html = advisory_tmpl.render().unwrap();
500        let content = ContentBuilder::default()
501            .content_type(Some("html".to_owned()))
502            .lang("en".to_owned())
503            .value(Some(html))
504            .build();
505
506        let mut summary = Text::plain(escaped_title);
507        summary.lang = Some("en".to_owned());
508
509        let item = EntryBuilder::default()
510            .id(url)
511            .title(escaped_title_type)
512            .summary(Some(summary))
513            .links(vec![link])
514            .categories(categories)
515            .published(Some(FixedDateTime::from_str(&cdate_time).unwrap()))
516            // required but we don't have precise data here
517            .updated(FixedDateTime::from_str(&mdate_time).unwrap())
518            .content(Some(content))
519            .build();
520        entries.push(item);
521    }
522
523    let self_url = "https://rustsec.org/feed.xml";
524    let alternate_link = LinkBuilder::default()
525        .href("https://rustsec.org/")
526        .rel("alternate")
527        .mime_type(Some("text/html".to_owned()))
528        .build();
529    let self_link = LinkBuilder::default()
530        .href(self_url)
531        .rel("self")
532        .mime_type(Some("application/atom+xml".to_owned()))
533        .build();
534
535    let mut subtitle = Text::plain("Security advisories filed against Rust crates".to_owned());
536    subtitle.lang = Some("en".to_owned());
537
538    let feed = FeedBuilder::default()
539        .id(self_url)
540        .title("RustSec Advisories")
541        .subtitle(Some(subtitle))
542        .links(vec![self_link, alternate_link])
543        .icon("https://rustsec.org/favicon.ico".to_owned())
544        .entries(entries)
545        .updated(FixedDateTime::from_str(&latest_advisory_date).unwrap())
546        .authors(vec![author])
547        .build();
548
549    let file = File::create(output_path).unwrap();
550    feed.write_to(file).unwrap();
551}
552
553#[derive(RustEmbed)]
554#[folder = "src/web/static/"]
555struct StaticAsset;
556
557fn copy_static_assets(output_folder: &Path) {
558    for file in StaticAsset::iter() {
559        let asset_path = PathBuf::from(file.as_ref());
560
561        // If the asset is in a folder, e.g. css/. Make the directory first.
562        if let Some(containing_folder) = asset_path.parent() {
563            fs::create_dir_all(output_folder.join(containing_folder)).unwrap();
564        }
565
566        let asset = StaticAsset::get(file.as_ref()).unwrap();
567        fs::write(output_folder.join(file.as_ref()), asset.data).unwrap();
568    }
569}
570
571mod filters {
572    use chrono::NaiveDate;
573    use rustsec::advisory;
574    use std::borrow::Borrow;
575
576    pub fn friendly_date<T: Borrow<advisory::Date>>(date: T) -> ::askama::Result<String> {
577        let date = date.borrow();
578
579        // TODO(tarcieri): fix deprecation of `NaiveDate::from_ymd`
580        #[allow(deprecated)]
581        let date = NaiveDate::from_ymd(date.year().try_into().unwrap(), date.month(), date.day())
582            .format("%B %e, %Y")
583            .to_string();
584
585        Ok(date)
586    }
587
588    pub fn safe_keyword(s: &str) -> ::askama::Result<String> {
589        Ok(s.chars()
590            .map(|c| {
591                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
592                    c
593                } else {
594                    '-'
595                }
596            })
597            .collect())
598    }
599}
600
601fn advisory_dates(
602    advisory: &rustsec::Advisory,
603    repo: &Repository,
604    mod_times: &GitModificationTimes,
605) -> (advisory::Date, advisory::Date) {
606    let relative_path = format!(
607        "{}/{}/{}.md",
608        advisory.metadata.collection.unwrap(),
609        advisory.metadata.package,
610        advisory.id()
611    );
612    let relative_path = Path::new(&relative_path);
613    let mdate = mod_times.mdate_for_path(GitPath::new(repo, relative_path).unwrap());
614    let cdate = mod_times.cdate_for_path(GitPath::new(repo, relative_path).unwrap());
615    (cdate, mdate)
616}