use csaf_core::export::export_document;
use csaf_core::validation;
use csaf_models::csaf_document::{
Branch, CsafDocument, CvssV3, CvssV4, Cwe, Distribution, Document, Engine, FullProductName,
Generator, Metric, MetricContent, Note, ProductStatus, ProductTree, Publisher, Revision, Tlp,
Tracking, Vulnerability,
};
use http::{Response, StatusCode};
use serde::Deserialize;
use crate::app_state::AppState;
use crate::i18n::{self, Lang};
use crate::router::{
Body, form_field, html_response, json_response, parse_form_body, path_param, redirect,
};
use crate::routes::layout::{Nav, PageContext, html_escape, wrap_page};
fn format_score(score: Option<f64>) -> String {
match score {
Some(s) => {
let class = if s >= 9.0 {
"sev-critical"
} else if s >= 7.0 {
"sev-high"
} else if s >= 4.0 {
"sev-medium"
} else if s > 0.0 {
"sev-low"
} else {
"sev-none"
};
format!(r#"<span class="{class}">{s:.1}</span>"#)
},
None => "-".to_owned(),
}
}
fn error_page(
title: &str,
ctx: PageContext<'_>,
status: StatusCode,
message: &str,
back_url: Option<&str>,
) -> Response<Body> {
let back = back_url
.map(|u| {
format!(
r#"<a class="btn secondary" href="{u}">{back_label}</a>"#,
u = html_escape(u),
back_label = i18n::t(ctx.lang, "csaf.err_back"),
)
})
.unwrap_or_default();
let content = format!(
r#"<h1>{title_h}</h1>
<div class="flash error">{msg}</div>
{back}"#,
title_h = html_escape(title),
msg = message, );
html_response(status, wrap_page(title, ctx, Nav::Csaf, &content))
}
fn required_field<'a>(form: &'a [(String, String)], name: &str) -> Result<&'a str, String> {
form_field(form, name)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("Field '{name}' is required"))
}
fn optional_field<'a>(form: &'a [(String, String)], name: &str) -> Option<&'a str> {
form_field(form, name)
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn parse_f64(form: &[(String, String)], name: &str) -> Result<Option<f64>, String> {
match optional_field(form, name) {
None => Ok(None),
Some(s) => s
.parse::<f64>()
.map(Some)
.map_err(|e| format!("Field '{name}' must be a number: {e}")),
}
}
const CLASSIFICATION_TEXT_SEPARATOR: &str = " | ";
#[must_use]
pub fn extract_classification(
doc: &CsafDocument,
title_key: &str,
text_prefix: &str,
default: &str,
) -> String {
if let Some(dist) = doc.document.distribution.as_ref()
&& let Some(text) = dist.text.as_deref()
{
for part in text.split(CLASSIFICATION_TEXT_SEPARATOR) {
let trimmed = part.trim();
if let Some(rest) = trimmed.strip_prefix(text_prefix) {
let value = rest.trim();
if !value.is_empty() {
return value.to_owned();
}
}
}
}
for note in &doc.document.notes {
if note.title.as_deref() == Some(title_key) {
let value = note.text.trim();
if !value.is_empty() {
return value.to_owned();
}
}
}
default.to_owned()
}
struct Classification {
tlp_label: String,
verschlusssache: Option<String>,
nato: Option<String>,
}
fn read_classification(
form: &[(String, String)],
settings: &csaf_models::settings::Settings,
) -> Result<Classification, String> {
let tlp_label = required_field(form, "tlp_label")?.to_owned();
if !csaf_models::settings::is_valid_tlp(&tlp_label) {
return Err(format!("Field 'tlp_label' has invalid value: {tlp_label}"));
}
let verschlusssache = if settings.verschlusssache_enabled {
optional_field(form, "verschlusssache").map(str::to_owned)
} else {
None
};
if let Some(vs) = verschlusssache.as_deref()
&& !csaf_models::settings::is_valid_verschlusssache(vs)
{
return Err(format!("Field 'verschlusssache' has invalid value: {vs}"));
}
let nato = if settings.nato_enabled {
optional_field(form, "nato").map(str::to_owned)
} else {
None
};
if let Some(n) = nato.as_deref()
&& !csaf_models::settings::is_valid_nato(n)
{
return Err(format!("Field 'nato' has invalid value: {n}"));
}
Ok(Classification {
tlp_label,
verschlusssache,
nato,
})
}
fn build_classification_payload(
settings: &csaf_models::settings::Settings,
verschlusssache: Option<&str>,
nato: Option<&str>,
summary_text: String,
) -> (Option<String>, Vec<Note>) {
let mode = settings.classification_storage_mode.as_str();
let write_text = matches!(mode, "distribution_text" | "both");
let write_notes = matches!(mode, "notes" | "both");
let mut text_parts: Vec<String> = Vec::new();
if let Some(vs) = verschlusssache {
text_parts.push(format!("Verschlusssache: {vs}"));
}
if let Some(n) = nato {
text_parts.push(format!("NATO: {n}"));
}
let distribution_text = if write_text && !text_parts.is_empty() {
Some(text_parts.join(CLASSIFICATION_TEXT_SEPARATOR))
} else {
None
};
let mut notes = vec![Note {
category: "summary".to_owned(),
text: summary_text,
title: Some("Summary".to_owned()),
audience: None,
}];
if write_notes {
if let Some(vs) = verschlusssache {
notes.push(Note {
category: "other".to_owned(),
text: vs.to_owned(),
title: Some("Verschlusssache".to_owned()),
audience: None,
});
}
if let Some(n) = nato {
notes.push(Note {
category: "other".to_owned(),
text: n.to_owned(),
title: Some("NATO Classification".to_owned()),
audience: None,
});
}
}
(distribution_text, notes)
}
pub fn build_document_from_form(
form: &[(String, String)],
csaf_mode: &str,
settings: &csaf_models::settings::Settings,
) -> Result<CsafDocument, String> {
let tracking_id = required_field(form, "tracking_id")?.to_owned();
let title = required_field(form, "title")?.to_owned();
let category = required_field(form, "category")?.to_owned();
let status = required_field(form, "status")?.to_owned();
let doc_version = required_field(form, "doc_version")?.to_owned();
let initial_release_date = required_field(form, "initial_release_date")?.to_owned();
let current_release_date = required_field(form, "current_release_date")?.to_owned();
let publisher_name = required_field(form, "publisher_name")?.to_owned();
let publisher_namespace = required_field(form, "publisher_namespace")?.to_owned();
let publisher_category = required_field(form, "publisher_category")?.to_owned();
let publisher_contact = optional_field(form, "publisher_contact").map(str::to_owned);
let Classification {
tlp_label,
verschlusssache,
nato,
} = read_classification(form, settings)?;
let summary_text = required_field(form, "summary_text")?.to_owned();
let vendor_name = required_field(form, "vendor_name")?.to_owned();
let product_id = required_field(form, "product_id")?.to_owned();
let product_name = required_field(form, "product_name")?.to_owned();
let vuln_required = category == "csaf_security_advisory";
let cve = optional_field(form, "cve").map(str::to_owned);
let vuln_title = optional_field(form, "vuln_title").map(str::to_owned);
let vuln_desc = optional_field(form, "vuln_description").map(str::to_owned);
let cwe_id = optional_field(form, "cwe_id").map(str::to_owned);
let cwe_name = optional_field(form, "cwe_name").map(str::to_owned);
let v3_score = parse_f64(form, "cvss_v3_score")?;
let v3_vector = optional_field(form, "cvss_v3_vector").map(str::to_owned);
let v3_severity = optional_field(form, "cvss_v3_severity").map(str::to_owned);
let v4_score = parse_f64(form, "cvss_v4_score")?;
let v4_vector = optional_field(form, "cvss_v4_vector").map(str::to_owned);
let v4_severity = optional_field(form, "cvss_v4_severity").map(str::to_owned);
let product_status_choice = required_field(form, "product_status")?;
let product_tree = ProductTree {
branches: vec![Branch {
category: "vendor".to_owned(),
name: vendor_name.clone(),
branches: vec![Branch {
category: "product_name".to_owned(),
name: product_name.clone(),
branches: Vec::new(),
product: Some(FullProductName {
name: format!("{vendor_name} {product_name}"),
product_id: product_id.clone(),
cpe: None,
purl: None,
}),
}],
product: None,
}],
full_product_names: Vec::new(),
product_groups: Vec::new(),
relationships: Vec::new(),
};
let product_status = Some(ProductStatus {
known_affected: if product_status_choice == "known_affected" {
vec![product_id.clone()]
} else {
Vec::new()
},
known_not_affected: if product_status_choice == "known_not_affected" {
vec![product_id.clone()]
} else {
Vec::new()
},
fixed: if product_status_choice == "fixed" {
vec![product_id.clone()]
} else {
Vec::new()
},
under_investigation: if product_status_choice == "under_investigation" {
vec![product_id.clone()]
} else {
Vec::new()
},
first_affected: Vec::new(),
first_fixed: Vec::new(),
last_affected: Vec::new(),
recommended: Vec::new(),
});
let mut vuln_notes = Vec::new();
if let Some(desc) = vuln_desc {
vuln_notes.push(Note {
category: "description".to_owned(),
text: desc,
title: Some("Vulnerability Description".to_owned()),
audience: None,
});
}
let metrics = if v3_score.is_some() || v4_score.is_some() {
vec![Metric {
content: MetricContent {
cvss_v3: v3_score.map(|score| CvssV3 {
version: "3.1".to_owned(),
vector_string: v3_vector.clone().unwrap_or_default(),
base_score: score,
base_severity: v3_severity.clone().unwrap_or_else(|| "NONE".to_owned()),
attack_vector: None,
attack_complexity: None,
privileges_required: None,
user_interaction: None,
scope: None,
confidentiality_impact: None,
integrity_impact: None,
availability_impact: None,
}),
cvss_v4: v4_score.map(|score| CvssV4 {
version: "4.0".to_owned(),
vector_string: v4_vector.clone().unwrap_or_default(),
base_score: score,
base_severity: v4_severity.clone().unwrap_or_else(|| "NONE".to_owned()),
attack_vector: None,
attack_complexity: None,
attack_requirements: None,
privileges_required: None,
user_interaction: None,
confidentiality_impact: None,
integrity_impact: None,
availability_impact: None,
sub_confidentiality_impact: None,
sub_integrity_impact: None,
sub_availability_impact: None,
}),
},
products: vec![product_id],
source: None,
}]
} else {
Vec::new()
};
let vulnerability = Vulnerability {
cve,
cwe: cwe_id.and_then(|id| cwe_name.map(|name| Cwe { id, name })),
discovery_date: None,
ids: Vec::new(),
notes: vuln_notes,
product_status,
remediations: Vec::new(),
metrics,
threats: Vec::new(),
title: vuln_title,
release_date: None,
references: Vec::new(),
involvements: Vec::new(),
flags: Vec::new(),
};
let vulnerabilities = if vuln_required
|| vulnerability.cve.is_some()
|| vulnerability.title.is_some()
|| !vulnerability.metrics.is_empty()
{
vec![vulnerability]
} else {
Vec::new()
};
let (distribution_text, notes) = build_classification_payload(
settings,
verschlusssache.as_deref(),
nato.as_deref(),
summary_text,
);
let document = Document {
category,
csaf_version: csaf_mode.to_owned(),
distribution: Some(Distribution {
tlp: Some(Tlp {
label: tlp_label,
url: None,
}),
text: distribution_text,
}),
lang: Some("en".to_owned()),
notes,
publisher: Publisher {
category: publisher_category,
contact_details: publisher_contact,
issuing_authority: None,
name: publisher_name,
namespace: publisher_namespace,
},
references: Vec::new(),
title,
tracking: Tracking {
current_release_date: current_release_date.clone(),
generator: Some(Generator {
engine: Engine {
name: "ndaal CSAF".to_owned(),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
},
date: None,
}),
id: tracking_id,
initial_release_date,
revision_history: vec![Revision {
date: current_release_date,
number: doc_version.clone(),
summary: "Submitted via web form".to_owned(),
}],
status,
version: doc_version,
aliases: Vec::new(),
},
};
Ok(CsafDocument {
schema: Some(format!(
"https://docs.oasis-open.org/csaf/csaf/v{csaf_mode}/schema/csaf.json"
)),
document,
product_tree,
vulnerabilities,
})
}
#[derive(Debug, Default)]
struct FormValues {
tracking_id: String,
title: String,
category: String,
status: String,
doc_version: String,
initial_release_date: String,
current_release_date: String,
publisher_name: String,
publisher_namespace: String,
publisher_category: String,
publisher_contact: String,
tlp_label: String,
verschlusssache: String,
nato: String,
summary_text: String,
vendor_name: String,
product_id: String,
product_name: String,
product_status: String,
cve: String,
vuln_title: String,
vuln_description: String,
cwe_id: String,
cwe_name: String,
cvss_v3_vector: String,
cvss_v3_score: String,
cvss_v3_severity: String,
cvss_v4_vector: String,
cvss_v4_score: String,
cvss_v4_severity: String,
}
impl FormValues {
fn from_document(doc: &CsafDocument, settings: &csaf_models::settings::Settings) -> Self {
let mut values = Self {
tracking_id: doc.document.tracking.id.clone(),
title: doc.document.title.clone(),
category: doc.document.category.clone(),
status: doc.document.tracking.status.clone(),
doc_version: doc.document.tracking.version.clone(),
initial_release_date: doc.document.tracking.initial_release_date.clone(),
current_release_date: doc.document.tracking.current_release_date.clone(),
publisher_name: doc.document.publisher.name.clone(),
publisher_namespace: doc.document.publisher.namespace.clone(),
publisher_category: doc.document.publisher.category.clone(),
publisher_contact: doc
.document
.publisher
.contact_details
.clone()
.unwrap_or_default(),
tlp_label: doc
.document
.distribution
.as_ref()
.and_then(|d| d.tlp.as_ref())
.map(|t| t.label.clone())
.unwrap_or_else(|| settings.tlp_default.clone()),
verschlusssache: extract_classification(
doc,
"Verschlusssache",
"Verschlusssache: ",
&settings.verschlusssache_default,
),
nato: extract_classification(
doc,
"NATO Classification",
"NATO: ",
&settings.nato_default,
),
summary_text: doc
.document
.notes
.iter()
.find(|n| n.category == "summary")
.map(|n| n.text.clone())
.unwrap_or_default(),
..Self::default()
};
"known_affected".clone_into(&mut values.product_status);
values.fill_from_product_tree(doc);
values.fill_from_first_vulnerability(doc);
values
}
fn fill_from_product_tree(&mut self, doc: &CsafDocument) {
let Some(vendor) = doc.product_tree.branches.first() else {
return;
};
self.vendor_name.clone_from(&vendor.name);
let Some(pname) = vendor.branches.first() else {
return;
};
self.product_name.clone_from(&pname.name);
if let Some(p) = &pname.product {
self.product_id.clone_from(&p.product_id);
} else if let Some(inner) = pname.branches.first()
&& let Some(p) = &inner.product
{
self.product_id.clone_from(&p.product_id);
}
}
fn fill_from_first_vulnerability(&mut self, doc: &CsafDocument) {
let Some(vuln) = doc.vulnerabilities.first() else {
return;
};
self.cve = vuln.cve.clone().unwrap_or_default();
self.vuln_title = vuln.title.clone().unwrap_or_default();
self.vuln_description = vuln
.notes
.iter()
.find(|n| n.category == "description")
.map(|n| n.text.clone())
.unwrap_or_default();
if let Some(cwe) = &vuln.cwe {
self.cwe_id.clone_from(&cwe.id);
self.cwe_name.clone_from(&cwe.name);
}
if let Some(status) = &vuln.product_status {
self.product_status = if !status.fixed.is_empty() {
"fixed".to_owned()
} else if !status.known_not_affected.is_empty() {
"known_not_affected".to_owned()
} else if !status.under_investigation.is_empty() {
"under_investigation".to_owned()
} else {
"known_affected".to_owned()
};
}
let Some(metric) = vuln.metrics.first() else {
return;
};
if let Some(v3) = &metric.content.cvss_v3 {
self.cvss_v3_vector.clone_from(&v3.vector_string);
self.cvss_v3_score = format!("{:.1}", v3.base_score);
self.cvss_v3_severity.clone_from(&v3.base_severity);
}
if let Some(v4) = &metric.content.cvss_v4 {
self.cvss_v4_vector.clone_from(&v4.vector_string);
self.cvss_v4_score = format!("{:.1}", v4.base_score);
self.cvss_v4_severity.clone_from(&v4.base_severity);
}
}
fn defaults_for_new(settings: &csaf_models::settings::Settings) -> Self {
let now = chrono_now_iso();
Self {
tracking_id: String::new(),
title: String::new(),
category: "csaf_security_advisory".to_owned(),
status: "draft".to_owned(),
doc_version: "1.0.0".to_owned(),
initial_release_date: now.clone(),
current_release_date: now,
publisher_name: settings.publisher_name.clone(),
publisher_namespace: settings.publisher_namespace.clone(),
publisher_category: settings.publisher_category.clone(),
publisher_contact: settings.publisher_contact_details.clone(),
tlp_label: settings.tlp_default.clone(),
verschlusssache: settings.verschlusssache_default.clone(),
nato: settings.nato_default.clone(),
summary_text: String::new(),
vendor_name: String::new(),
product_id: "CSAFPID-0001".to_owned(),
product_name: String::new(),
product_status: "known_affected".to_owned(),
cve: String::new(),
vuln_title: String::new(),
vuln_description: String::new(),
cwe_id: String::new(),
cwe_name: String::new(),
cvss_v3_vector: String::new(),
cvss_v3_score: String::new(),
cvss_v3_severity: String::new(),
cvss_v4_vector: String::new(),
cvss_v4_score: String::new(),
cvss_v4_severity: String::new(),
}
}
}
fn chrono_now_iso() -> String {
let now = time::OffsetDateTime::now_utc();
format!(
"{year:04}-{month:02}-{day:02}T00:00:00.000Z",
year = now.year(),
month = u8::from(now.month()),
day = now.day(),
)
}
fn selected(value: &str, candidate: &str) -> &'static str {
if value == candidate { "selected" } else { "" }
}
fn build_classification_row(
lang: Lang,
vs_value: &str,
nato_value: &str,
settings: &csaf_models::settings::Settings,
) -> String {
use std::fmt::Write as _;
let vs_options = csaf_models::settings::VERSCHLUSSSACHE_LABELS.iter().fold(
String::new(),
|mut out, label| {
let _ = write!(
out,
r#"<option value="{esc}" {sel}>{esc}</option>"#,
esc = html_escape(label),
sel = selected(vs_value, label),
);
out
},
);
let nato_options =
csaf_models::settings::NATO_LABELS
.iter()
.fold(String::new(), |mut out, label| {
let _ = write!(
out,
r#"<option value="{esc}" {sel}>{esc}</option>"#,
esc = html_escape(label),
sel = selected(nato_value, label),
);
out
});
let vs_label = i18n::t(lang, "csaf.field_verschlusssache");
let nato_label = i18n::t(lang, "csaf.field_nato");
let vs_block = if settings.verschlusssache_enabled {
format!(
r#"<div>
<label for="verschlusssache">{vs_label}</label>
<select id="verschlusssache" name="verschlusssache">
{vs_options}
</select>
</div>"#
)
} else {
r#"<div><input type="hidden" name="verschlusssache" value=""></div>"#.to_owned()
};
let nato_block = if settings.nato_enabled {
format!(
r#"<div>
<label for="nato">{nato_label}</label>
<select id="nato" name="nato">
{nato_options}
</select>
</div>"#
)
} else {
r#"<div><input type="hidden" name="nato" value=""></div>"#.to_owned()
};
if !settings.verschlusssache_enabled && !settings.nato_enabled {
return format!(r#"{vs_block}{nato_block}"#);
}
format!(
r#"<div class="form-row">
{vs_block}
{nato_block}
</div>"#
)
}
#[allow(
clippy::too_many_arguments,
reason = "renders one HTML form; every argument is an independent field, and \
grouping them into a struct would only move the same list"
)]
fn render_form(
ctx: PageContext<'_>,
title: &str,
heading: &str,
action: &str,
submit_label: &str,
values: &FormValues,
extra_fields: &str,
error: Option<&str>,
settings: &csaf_models::settings::Settings,
) -> Response<Body> {
let lang = ctx.lang;
let error_html = error
.map(|e| format!(r#"<div class="flash error">{}</div>"#, html_escape(e)))
.unwrap_or_default();
let classification_row =
build_classification_row(lang, &values.verschlusssache, &values.nato, settings);
let t = |key: &'static str| i18n::t(lang, key);
let content = format!(
r##"<h1>{heading}</h1>
{error_html}
<div class="card">
<form method="POST" action="{action}">
{extra_fields}
<h2>{h2_document_metadata}</h2>
<div class="form-row">
<div>
<label for="tracking_id">{lbl_tracking_id}</label>
<input type="text" id="tracking_id" name="tracking_id" required
value="{tracking_id}" placeholder="ndaal-sa-2026-001">
</div>
<div>
<label for="title">{lbl_title}</label>
<input type="text" id="title" name="title" required value="{title_v}">
</div>
</div>
<div class="form-row">
<div>
<label for="category">{lbl_category}</label>
<select id="category" name="category" required>
<option value="csaf_security_advisory" {cat_sa}>{opt_cat_sa}</option>
<option value="csaf_vex" {cat_vex}>{opt_cat_vex}</option>
<option value="csaf_informational_advisory" {cat_info}>{opt_cat_info}</option>
</select>
</div>
<div>
<label for="status">{lbl_status}</label>
<select id="status" name="status" required>
<option value="draft" {st_draft}>{opt_st_draft}</option>
<option value="interim" {st_interim}>{opt_st_interim}</option>
<option value="final" {st_final}>{opt_st_final}</option>
</select>
</div>
</div>
<div class="form-row">
<div>
<label for="doc_version">{lbl_doc_version}</label>
<input type="text" id="doc_version" name="doc_version" required value="{doc_version}" placeholder="1.0.0">
</div>
<div>
<label for="tlp_label">{lbl_tlp}</label>
<select id="tlp_label" name="tlp_label" class="tlp-select" required>
<option value="CLEAR" {tlp_clear}>TLP:CLEAR</option>
<option value="GREEN" {tlp_green}>TLP:GREEN</option>
<option value="AMBER" {tlp_amber}>TLP:AMBER</option>
<option value="AMBER+STRICT" {tlp_amber_strict}>TLP:AMBER+STRICT</option>
<option value="RED" {tlp_red}>TLP:RED</option>
</select>
</div>
</div>
{classification_row}
<div class="form-row">
<div>
<label for="initial_release_date">{lbl_initial_date}</label>
<input type="text" id="initial_release_date" name="initial_release_date" required
value="{initial_date}" placeholder="2026-04-14T00:00:00.000Z">
</div>
<div>
<label for="current_release_date">{lbl_current_date}</label>
<input type="text" id="current_release_date" name="current_release_date" required
value="{current_date}" placeholder="2026-04-14T00:00:00.000Z">
</div>
</div>
<div class="form-row single">
<div>
<label for="summary_text">{lbl_summary}</label>
<textarea id="summary_text" name="summary_text" required rows="4">{summary_text}</textarea>
</div>
</div>
<h2>{h2_publisher}</h2>
<div class="form-row">
<div>
<label for="publisher_name">{lbl_pub_name}</label>
<input type="text" id="publisher_name" name="publisher_name" required value="{publisher_name}">
</div>
<div>
<label for="publisher_namespace">{lbl_pub_namespace}</label>
<input type="url" id="publisher_namespace" name="publisher_namespace" required value="{publisher_namespace}">
</div>
</div>
<div class="form-row">
<div>
<label for="publisher_category">{lbl_category}</label>
<select id="publisher_category" name="publisher_category" required>
<option value="vendor" {pc_vendor}>{opt_pc_vendor}</option>
<option value="discoverer" {pc_disc}>{opt_pc_disc}</option>
<option value="coordinator" {pc_coord}>{opt_pc_coord}</option>
<option value="user" {pc_user}>{opt_pc_user}</option>
<option value="translator" {pc_trans}>{opt_pc_trans}</option>
<option value="other" {pc_other}>{opt_pc_other}</option>
</select>
</div>
<div>
<label for="publisher_contact">{lbl_pub_contact}</label>
<input type="text" id="publisher_contact" name="publisher_contact" value="{publisher_contact}">
</div>
</div>
<h2>{h2_affected_product}</h2>
<div class="form-row">
<div>
<label for="vendor_name">{lbl_vendor}</label>
<input type="text" id="vendor_name" name="vendor_name" required value="{vendor_name}">
</div>
<div>
<label for="product_name">{lbl_product_name}</label>
<input type="text" id="product_name" name="product_name" required value="{product_name}">
</div>
</div>
<div class="form-row">
<div>
<label for="product_id">{lbl_product_id}</label>
<input type="text" id="product_id" name="product_id" required value="{product_id}" placeholder="CSAFPID-0001">
</div>
<div>
<label for="product_status">{lbl_product_status}</label>
<select id="product_status" name="product_status" required>
<option value="known_affected" {ps_ka}>{opt_ps_ka}</option>
<option value="known_not_affected" {ps_kna}>{opt_ps_kna}</option>
<option value="fixed" {ps_fixed}>{opt_ps_fixed}</option>
<option value="under_investigation" {ps_ui}>{opt_ps_ui}</option>
</select>
</div>
</div>
<h2>{h2_vulnerability}</h2>
<p class="muted">{vuln_hint}</p>
<div class="form-row">
<div>
<label for="cve">{lbl_cve}</label>
<input type="text" id="cve" name="cve" value="{cve}" placeholder="CVE-2024-0001">
</div>
<div>
<label for="vuln_title">{lbl_vuln_title}</label>
<input type="text" id="vuln_title" name="vuln_title" value="{vuln_title}">
</div>
</div>
<div class="form-row single">
<div>
<label for="vuln_description">{lbl_vuln_description}</label>
<textarea id="vuln_description" name="vuln_description" rows="4">{vuln_description}</textarea>
</div>
</div>
<div class="form-row">
<div>
<label for="cwe_id">{lbl_cwe_id}</label>
<input type="text" id="cwe_id" name="cwe_id" value="{cwe_id}" placeholder="CWE-79">
</div>
<div>
<label for="cwe_name">{lbl_cwe_name}</label>
<input type="text" id="cwe_name" name="cwe_name" value="{cwe_name}">
</div>
</div>
<h2>{h2_cvss_v3}</h2>
<div class="form-row">
<div>
<label for="cvss_v3_score">{lbl_base_score}</label>
<input type="number" step="0.1" min="0" max="10" id="cvss_v3_score" name="cvss_v3_score" value="{v3_score}">
</div>
<div>
<label for="cvss_v3_severity">{lbl_severity}</label>
<select id="cvss_v3_severity" name="cvss_v3_severity">
<option value="">—</option>
<option value="NONE" {v3_none}>NONE</option>
<option value="LOW" {v3_low}>LOW</option>
<option value="MEDIUM" {v3_med}>MEDIUM</option>
<option value="HIGH" {v3_high}>HIGH</option>
<option value="CRITICAL" {v3_crit}>CRITICAL</option>
</select>
</div>
</div>
<div class="form-row single">
<div>
<label for="cvss_v3_vector">{lbl_cvss_v3_vector}</label>
<input type="text" id="cvss_v3_vector" name="cvss_v3_vector" value="{v3_vector}"
placeholder="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H">
</div>
</div>
<h2>{h2_cvss_v4}</h2>
<div class="form-row">
<div>
<label for="cvss_v4_score">{lbl_base_score}</label>
<input type="number" step="0.1" min="0" max="10" id="cvss_v4_score" name="cvss_v4_score" value="{v4_score}">
</div>
<div>
<label for="cvss_v4_severity">{lbl_severity}</label>
<select id="cvss_v4_severity" name="cvss_v4_severity">
<option value="">—</option>
<option value="NONE" {v4_none}>NONE</option>
<option value="LOW" {v4_low}>LOW</option>
<option value="MEDIUM" {v4_med}>MEDIUM</option>
<option value="HIGH" {v4_high}>HIGH</option>
<option value="CRITICAL" {v4_crit}>CRITICAL</option>
</select>
</div>
</div>
<div class="form-row single">
<div>
<label for="cvss_v4_vector">{lbl_cvss_v4_vector}</label>
<input type="text" id="cvss_v4_vector" name="cvss_v4_vector" value="{v4_vector}"
placeholder="CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N">
</div>
</div>
<div style="margin-top:1.5rem;">
<button type="submit" class="btn">{submit_label}</button>
<a class="btn secondary" href="/csaf" style="margin-left:0.5rem;">{btn_cancel}</a>
</div>
</form>
</div>"##,
heading = html_escape(heading),
action = html_escape(action),
extra_fields = extra_fields,
submit_label = html_escape(submit_label),
h2_document_metadata = t("csaf.form_section_document_metadata"),
lbl_tracking_id = t("csaf.field_tracking_id"),
tracking_id = html_escape(&values.tracking_id),
lbl_title = t("csaf.field_title"),
title_v = html_escape(&values.title),
lbl_category = t("csaf.field_category"),
opt_cat_sa = t("csaf.opt_category_security_advisory"),
opt_cat_vex = t("csaf.opt_category_vex"),
opt_cat_info = t("csaf.opt_category_informational_advisory"),
cat_sa = selected(&values.category, "csaf_security_advisory"),
cat_vex = selected(&values.category, "csaf_vex"),
cat_info = selected(&values.category, "csaf_informational_advisory"),
lbl_status = t("csaf.field_status"),
opt_st_draft = t("csaf.opt_status_draft"),
opt_st_interim = t("csaf.opt_status_interim"),
opt_st_final = t("csaf.opt_status_final"),
st_draft = selected(&values.status, "draft"),
st_interim = selected(&values.status, "interim"),
st_final = selected(&values.status, "final"),
lbl_doc_version = t("csaf.field_doc_version"),
doc_version = html_escape(&values.doc_version),
lbl_tlp = t("csaf.field_tlp"),
tlp_clear = selected(&values.tlp_label, "CLEAR"),
tlp_green = selected(&values.tlp_label, "GREEN"),
tlp_amber = selected(&values.tlp_label, "AMBER"),
tlp_amber_strict = selected(&values.tlp_label, "AMBER+STRICT"),
tlp_red = selected(&values.tlp_label, "RED"),
lbl_initial_date = t("csaf.field_initial_release_date"),
initial_date = html_escape(&values.initial_release_date),
lbl_current_date = t("csaf.field_current_release_date"),
current_date = html_escape(&values.current_release_date),
lbl_summary = t("csaf.field_summary"),
summary_text = html_escape(&values.summary_text),
h2_publisher = t("csaf.form_section_publisher"),
lbl_pub_name = t("csaf.field_publisher_name"),
publisher_name = html_escape(&values.publisher_name),
lbl_pub_namespace = t("csaf.field_publisher_namespace"),
publisher_namespace = html_escape(&values.publisher_namespace),
opt_pc_vendor = t("csaf.opt_publisher_category_vendor"),
opt_pc_disc = t("csaf.opt_publisher_category_discoverer"),
opt_pc_coord = t("csaf.opt_publisher_category_coordinator"),
opt_pc_user = t("csaf.opt_publisher_category_user"),
opt_pc_trans = t("csaf.opt_publisher_category_translator"),
opt_pc_other = t("csaf.opt_publisher_category_other"),
pc_vendor = selected(&values.publisher_category, "vendor"),
pc_disc = selected(&values.publisher_category, "discoverer"),
pc_coord = selected(&values.publisher_category, "coordinator"),
pc_user = selected(&values.publisher_category, "user"),
pc_trans = selected(&values.publisher_category, "translator"),
pc_other = selected(&values.publisher_category, "other"),
lbl_pub_contact = t("csaf.field_publisher_contact"),
publisher_contact = html_escape(&values.publisher_contact),
h2_affected_product = t("csaf.form_section_affected_product"),
lbl_vendor = t("csaf.field_vendor"),
vendor_name = html_escape(&values.vendor_name),
lbl_product_name = t("csaf.field_product_name"),
product_name = html_escape(&values.product_name),
lbl_product_id = t("csaf.field_product_id"),
product_id = html_escape(&values.product_id),
lbl_product_status = t("csaf.field_product_status"),
opt_ps_ka = t("csaf.opt_product_status_known_affected"),
opt_ps_kna = t("csaf.opt_product_status_known_not_affected"),
opt_ps_fixed = t("csaf.opt_product_status_fixed"),
opt_ps_ui = t("csaf.opt_product_status_under_investigation"),
ps_ka = selected(&values.product_status, "known_affected"),
ps_kna = selected(&values.product_status, "known_not_affected"),
ps_fixed = selected(&values.product_status, "fixed"),
ps_ui = selected(&values.product_status, "under_investigation"),
h2_vulnerability = t("csaf.form_section_vulnerability"),
vuln_hint = t("csaf.form_vulnerability_hint"),
lbl_cve = t("csaf.field_cve"),
cve = html_escape(&values.cve),
lbl_vuln_title = t("csaf.field_vuln_title"),
vuln_title = html_escape(&values.vuln_title),
lbl_vuln_description = t("csaf.field_vuln_description"),
vuln_description = html_escape(&values.vuln_description),
lbl_cwe_id = t("csaf.field_cwe_id"),
cwe_id = html_escape(&values.cwe_id),
lbl_cwe_name = t("csaf.field_cwe_name"),
cwe_name = html_escape(&values.cwe_name),
h2_cvss_v3 = t("csaf.form_section_cvss_v3"),
lbl_base_score = t("csaf.field_cvss_base_score"),
v3_score = html_escape(&values.cvss_v3_score),
lbl_severity = t("csaf.field_cvss_severity"),
v3_vector = html_escape(&values.cvss_v3_vector),
v3_none = selected(&values.cvss_v3_severity, "NONE"),
v3_low = selected(&values.cvss_v3_severity, "LOW"),
v3_med = selected(&values.cvss_v3_severity, "MEDIUM"),
v3_high = selected(&values.cvss_v3_severity, "HIGH"),
v3_crit = selected(&values.cvss_v3_severity, "CRITICAL"),
lbl_cvss_v3_vector = t("csaf.field_cvss_v3_vector"),
h2_cvss_v4 = t("csaf.form_section_cvss_v4"),
v4_score = html_escape(&values.cvss_v4_score),
v4_vector = html_escape(&values.cvss_v4_vector),
v4_none = selected(&values.cvss_v4_severity, "NONE"),
v4_low = selected(&values.cvss_v4_severity, "LOW"),
v4_med = selected(&values.cvss_v4_severity, "MEDIUM"),
v4_high = selected(&values.cvss_v4_severity, "HIGH"),
v4_crit = selected(&values.cvss_v4_severity, "CRITICAL"),
lbl_cvss_v4_vector = t("csaf.field_cvss_v4_vector"),
btn_cancel = t("csaf.form_cancel"),
);
html_response(StatusCode::OK, wrap_page(title, ctx, Nav::Csaf, &content))
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
const fn default_limit() -> usize {
50
}
pub async fn list(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let query: ListQuery = crate::router::parse_query(&parts.uri).unwrap_or(ListQuery {
limit: 50,
offset: 0,
});
let metas = state
.csaf_storage()
.list_meta(query.limit, query.offset)
.unwrap_or_default();
let total = state.csaf_storage().count_documents().unwrap_or(0);
let settings = state.settings();
let mut rows = String::new();
if metas.is_empty() {
rows.push_str(&format!(
r#"<tr><td colspan="7" class="muted" style="text-align:center;">{empty} <a href="/csaf/new">{create_one}</a>.</td></tr>"#,
empty = i18n::t(lang, "csaf.list_empty"),
create_one = i18n::t(lang, "csaf.list_create_one"),
));
} else {
for meta in &metas {
rows.push_str(&format!(
r#"<tr>
<td><a href="/csaf/{tid}">{tid}</a></td>
<td>{title}</td>
<td>{category}</td>
<td>{status}</td>
<td>{v3}</td>
<td>{v4}</td>
<td>{date}</td>
</tr>"#,
tid = html_escape(&meta.tracking_id),
title = html_escape(&meta.title),
category = html_escape(&meta.category),
status = html_escape(&meta.status),
v3 = format_score(meta.max_cvss_v3_score),
v4 = format_score(meta.max_cvss_v4_score),
date = html_escape(&meta.current_release_date),
));
}
}
let prev_offset = query.offset.saturating_sub(query.limit);
let next_offset = query.offset + query.limit;
let showing = i18n::t(lang, "csaf.list_pagination_showing");
let pagination = if total > query.limit {
format!(
r#"<div style="margin-top:1rem;display:flex;gap:1rem;align-items:center;">
<a class="btn secondary" href="/csaf?limit={limit}&offset={prev}">{previous}</a>
<span class="muted">{showing} {start}-{end} {of} {total}</span>
<a class="btn secondary" href="/csaf?limit={limit}&offset={next}">{next_label}</a>
</div>"#,
limit = query.limit,
prev = prev_offset,
previous = i18n::t(lang, "csaf.list_pagination_previous"),
of = i18n::t(lang, "csaf.list_pagination_of"),
next = next_offset,
next_label = i18n::t(lang, "csaf.list_pagination_next"),
start = query.offset + 1,
end = (query.offset + metas.len()).min(total),
)
} else {
format!(
r#"<p class="muted">{showing} {total} {docs}</p>"#,
docs = i18n::t(lang, "csaf.list_documents_plural"),
)
};
let content = format!(
r#"<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>{heading}</h1>
<a class="btn" href="/csaf/new">{new_document}</a>
</div>
<div class="card">
<table>
<thead><tr>
<th>{col_tracking_id}</th><th>{col_title}</th><th>{col_category}</th><th>{col_status}</th>
<th>{col_cvss_v3}</th><th>{col_cvss_v4}</th><th>{col_release_date}</th>
</tr></thead>
<tbody>{rows}</tbody>
</table>
{pagination}
</div>"#,
heading = i18n::t(lang, "csaf.list_heading"),
new_document = i18n::t(lang, "csaf.list_new_document"),
col_tracking_id = i18n::t(lang, "csaf.list_col_tracking_id"),
col_title = i18n::t(lang, "csaf.list_col_title"),
col_category = i18n::t(lang, "csaf.list_col_category"),
col_status = i18n::t(lang, "csaf.list_col_status"),
col_cvss_v3 = i18n::t(lang, "csaf.list_col_cvss_v3"),
col_cvss_v4 = i18n::t(lang, "csaf.list_col_cvss_v4"),
col_release_date = i18n::t(lang, "csaf.list_col_release_date"),
);
html_response(
StatusCode::OK,
wrap_page(
i18n::t(lang, "csaf.list_heading"),
PageContext {
theme: &settings.theme,
lang,
},
Nav::Csaf,
&content,
),
)
}
fn validation_error_summary(lang: Lang, hard_errors: &[&validation::ValidationError]) -> String {
let mut msg = i18n::t(lang, "csaf.err_validation_prefix").to_owned();
for e in hard_errors {
msg.push_str(&format!(" {}: {};", e.path, e.message));
}
msg
}
pub async fn new_form(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let values = FormValues::defaults_for_new(&settings);
let title = i18n::t(lang, "csaf.form_new_title");
render_form(
PageContext {
theme: &settings.theme,
lang,
},
title,
title,
"/csaf",
i18n::t(lang, "csaf.form_submit_create"),
&values,
"",
None,
&settings,
)
}
pub async fn create(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
let form = match parse_form_body(&parts) {
Ok(f) => f,
Err(err) => {
return error_page(
i18n::t(lang, "csaf.err_title_create_failed"),
ctx,
StatusCode::BAD_REQUEST,
&format!(
"<strong>{}</strong> {}",
i18n::t(lang, "csaf.err_form_error_prefix"),
html_escape(&err)
),
Some("/csaf/new"),
);
},
};
let doc = match build_document_from_form(&form, &settings.csaf_mode, &settings) {
Ok(d) => d,
Err(err) => {
let values = values_from_form(&form);
let title = i18n::t(lang, "csaf.form_new_title");
return render_form(
ctx,
title,
title,
"/csaf",
i18n::t(lang, "csaf.form_submit_create"),
&values,
"",
Some(&err),
&settings,
);
},
};
let errors = validation::validate(&doc);
let hard_errors: Vec<_> = errors
.iter()
.filter(|e| e.severity == validation::Severity::Error)
.collect();
if !hard_errors.is_empty() {
let msg = validation_error_summary(lang, &hard_errors);
let values = values_from_form(&form);
return render_form(
ctx,
i18n::t(lang, "csaf.err_title_validation_failed"),
i18n::t(lang, "csaf.form_new_title"),
"/csaf",
i18n::t(lang, "csaf.form_submit_create"),
&values,
"",
Some(&msg),
&settings,
);
}
let tracking_id = doc.tracking_id().to_owned();
if state
.csaf_storage()
.document_exists(&tracking_id)
.unwrap_or(false)
{
return error_page(
i18n::t(lang, "csaf.err_title_duplicate"),
ctx,
StatusCode::CONFLICT,
&i18n::t(lang, "csaf.err_document_exists").replace("{tid}", &html_escape(&tracking_id)),
Some(&format!("/csaf/{tracking_id}")),
);
}
if let Err(err) = state.csaf_storage().put_document(&doc) {
return error_page(
i18n::t(lang, "csaf.err_title_storage_error"),
ctx,
StatusCode::INTERNAL_SERVER_ERROR,
&html_escape(&err.to_string()),
Some("/csaf/new"),
);
}
let _ = state
.db_pool()
.with_conn(|conn| csaf_models::audit_log::record(conn, "create", &tracking_id, None, None));
if let Err(err) = export_document(&doc, &settings) {
tracing::warn!(
tracking_id = tracking_id.as_str(),
error = %err,
"Export failed after create"
);
}
redirect(&format!("/csaf/{tracking_id}"))
}
fn values_from_form(form: &[(String, String)]) -> FormValues {
let g = |name: &str| form_field(form, name).unwrap_or("").to_owned();
FormValues {
tracking_id: g("tracking_id"),
title: g("title"),
category: g("category"),
status: g("status"),
doc_version: g("doc_version"),
initial_release_date: g("initial_release_date"),
current_release_date: g("current_release_date"),
publisher_name: g("publisher_name"),
publisher_namespace: g("publisher_namespace"),
publisher_category: g("publisher_category"),
publisher_contact: g("publisher_contact"),
tlp_label: g("tlp_label"),
verschlusssache: g("verschlusssache"),
nato: g("nato"),
summary_text: g("summary_text"),
vendor_name: g("vendor_name"),
product_id: g("product_id"),
product_name: g("product_name"),
product_status: g("product_status"),
cve: g("cve"),
vuln_title: g("vuln_title"),
vuln_description: g("vuln_description"),
cwe_id: g("cwe_id"),
cwe_name: g("cwe_name"),
cvss_v3_vector: g("cvss_v3_vector"),
cvss_v3_score: g("cvss_v3_score"),
cvss_v3_severity: g("cvss_v3_severity"),
cvss_v4_vector: g("cvss_v4_vector"),
cvss_v4_score: g("cvss_v4_score"),
cvss_v4_severity: g("cvss_v4_severity"),
}
}
pub async fn view(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => {
return html_response(
StatusCode::BAD_REQUEST,
i18n::t(lang, "csaf.err_missing_document_id"),
);
},
};
let doc = match state.csaf_storage().get_document(&tracking_id) {
Ok(Some(d)) => d,
Ok(None) => {
return error_page(
i18n::t(lang, "csaf.err_title_not_found"),
ctx,
StatusCode::NOT_FOUND,
&i18n::t(lang, "csaf.err_document_not_found")
.replace("{tid}", &html_escape(&tracking_id)),
Some("/csaf"),
);
},
Err(err) => {
return error_page(
i18n::t(lang, "csaf.err_title_error"),
ctx,
StatusCode::INTERNAL_SERVER_ERROR,
&html_escape(&err.to_string()),
Some("/csaf"),
);
},
};
let json_pretty = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_owned());
let mut vuln_rows = String::new();
for vuln in &doc.vulnerabilities {
let cve = vuln.cve.as_deref().unwrap_or("-");
let title = vuln.title.as_deref().unwrap_or("-");
let mut v3_score = None;
let mut v4_score = None;
for metric in &vuln.metrics {
if let Some(v3) = &metric.content.cvss_v3 {
v3_score = Some(v3.base_score);
}
if let Some(v4) = &metric.content.cvss_v4 {
v4_score = Some(v4.base_score);
}
}
vuln_rows.push_str(&format!(
"<tr><td>{cve}</td><td>{title}</td><td>{v3}</td><td>{v4}</td></tr>",
cve = html_escape(cve),
title = html_escape(title),
v3 = format_score(v3_score),
v4 = format_score(v4_score),
));
}
let vuln_section = if doc.vulnerabilities.is_empty() {
format!(
r#"<p class="muted">{}</p>"#,
i18n::t(lang, "csaf.view_no_vulnerabilities")
)
} else {
format!(
r#"<table>
<thead><tr><th>{col_cve}</th><th>{col_title}</th><th>{col_cvss_v3}</th><th>{col_cvss_v4}</th></tr></thead>
<tbody>{vuln_rows}</tbody>
</table>"#,
col_cve = i18n::t(lang, "csaf.view_vuln_col_cve"),
col_title = i18n::t(lang, "csaf.list_col_title"),
col_cvss_v3 = i18n::t(lang, "csaf.list_col_cvss_v3"),
col_cvss_v4 = i18n::t(lang, "csaf.list_col_cvss_v4"),
)
};
let tlp = doc
.document
.distribution
.as_ref()
.and_then(|d| d.tlp.as_ref())
.map(|t| t.label.as_str())
.unwrap_or("-");
let content = format!(
r##"<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>{title}</h1>
<div>
<a class="btn" href="/csaf/{tid}/edit">{edit_btn}</a>
<a class="btn secondary" href="/csaf/{tid}/json">{json_btn}</a>
<form method="POST" action="/csaf/{tid}/delete" style="display:inline;margin-left:0.5rem;"
onsubmit="return confirm('{delete_confirm}');">
<button type="submit" class="btn danger">{delete_btn}</button>
</form>
</div>
</div>
<div class="card">
<h2>{h2_metadata}</h2>
<table>
<tr><th>{row_tracking_id}</th><td>{tid}</td></tr>
<tr><th>{row_category}</th><td>{category}</td></tr>
<tr><th>{row_csaf_version}</th><td>{csaf_version}</td></tr>
<tr><th>{row_status}</th><td>{status}</td></tr>
<tr><th>{row_version}</th><td>{version}</td></tr>
<tr><th>{row_publisher}</th><td>{publisher}</td></tr>
<tr><th>{row_tlp}</th><td>{tlp}</td></tr>
<tr><th>{row_initial_release}</th><td>{initial_date}</td></tr>
<tr><th>{row_current_release}</th><td>{current_date}</td></tr>
</table>
</div>
<div class="card">
<h2>{h2_vulnerabilities} ({vuln_count})</h2>
{vuln_section}
</div>
<div class="card">
<h2>{h2_raw_json}</h2>
<pre style="max-height:400px;overflow:auto;background:var(--input-bg);padding:1rem;border-radius:4px;font-size:0.8rem;">{json}</pre>
</div>"##,
title = html_escape(&doc.document.title),
tid = html_escape(&tracking_id),
edit_btn = i18n::t(lang, "csaf.view_edit_button"),
json_btn = i18n::t(lang, "csaf.view_json_button"),
delete_confirm = i18n::t(lang, "csaf.view_delete_confirm"),
delete_btn = i18n::t(lang, "csaf.view_delete_button"),
h2_metadata = i18n::t(lang, "csaf.view_section_metadata"),
row_tracking_id = i18n::t(lang, "csaf.list_col_tracking_id"),
row_category = i18n::t(lang, "csaf.list_col_category"),
category = html_escape(&doc.document.category),
row_csaf_version = i18n::t(lang, "csaf.view_row_csaf_version"),
csaf_version = html_escape(&doc.document.csaf_version),
row_status = i18n::t(lang, "csaf.list_col_status"),
status = html_escape(&doc.document.tracking.status),
row_version = i18n::t(lang, "csaf.view_row_version"),
version = html_escape(&doc.document.tracking.version),
row_publisher = i18n::t(lang, "csaf.form_section_publisher"),
publisher = html_escape(&doc.document.publisher.name),
row_tlp = i18n::t(lang, "csaf.view_row_tlp"),
tlp = html_escape(tlp),
row_initial_release = i18n::t(lang, "csaf.view_row_initial_release"),
initial_date = html_escape(&doc.document.tracking.initial_release_date),
row_current_release = i18n::t(lang, "csaf.view_row_current_release"),
current_date = html_escape(&doc.document.tracking.current_release_date),
h2_vulnerabilities = i18n::t(lang, "csaf.view_section_vulnerabilities"),
vuln_count = doc.vulnerabilities.len(),
h2_raw_json = i18n::t(lang, "csaf.view_section_raw_json"),
json = html_escape(&json_pretty),
);
html_response(
StatusCode::OK,
wrap_page(&tracking_id, ctx, Nav::Csaf, &content),
)
}
pub async fn edit_form(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => {
return html_response(
StatusCode::BAD_REQUEST,
i18n::t(lang, "csaf.err_missing_document_id"),
);
},
};
let doc = match state.csaf_storage().get_document(&tracking_id) {
Ok(Some(d)) => d,
Ok(None) => {
return error_page(
i18n::t(lang, "csaf.err_title_not_found"),
ctx,
StatusCode::NOT_FOUND,
&i18n::t(lang, "csaf.err_document_not_found")
.replace("{tid}", &html_escape(&tracking_id)),
Some("/csaf"),
);
},
Err(err) => {
return error_page(
i18n::t(lang, "csaf.err_title_error"),
ctx,
StatusCode::INTERNAL_SERVER_ERROR,
&html_escape(&err.to_string()),
Some("/csaf"),
);
},
};
let values = FormValues::from_document(&doc, &settings);
let action = format!("/csaf/{tracking_id}/update");
let edit_heading = format!(
"{} {}",
i18n::t(lang, "csaf.form_edit_heading_prefix"),
html_escape(&tracking_id)
);
render_form(
ctx,
i18n::t(lang, "csaf.form_edit_title"),
&edit_heading,
&action,
i18n::t(lang, "csaf.form_submit_save"),
&values,
"",
None,
&settings,
)
}
pub async fn update(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => {
return html_response(
StatusCode::BAD_REQUEST,
i18n::t(lang, "csaf.err_missing_document_id"),
);
},
};
let form = match parse_form_body(&parts) {
Ok(f) => f,
Err(err) => {
return error_page(
i18n::t(lang, "csaf.err_title_update_failed"),
ctx,
StatusCode::BAD_REQUEST,
&format!(
"<strong>{}</strong> {}",
i18n::t(lang, "csaf.err_form_error_prefix"),
html_escape(&err)
),
Some(&format!("/csaf/{tracking_id}/edit")),
);
},
};
let edit_heading = format!(
"{} {}",
i18n::t(lang, "csaf.form_edit_heading_prefix"),
html_escape(&tracking_id)
);
let doc = match build_document_from_form(&form, &settings.csaf_mode, &settings) {
Ok(d) => d,
Err(err) => {
let values = values_from_form(&form);
return render_form(
ctx,
i18n::t(lang, "csaf.form_edit_title"),
&edit_heading,
&format!("/csaf/{tracking_id}/update"),
i18n::t(lang, "csaf.form_submit_save"),
&values,
"",
Some(&err),
&settings,
);
},
};
if doc.tracking_id() != tracking_id {
return error_page(
i18n::t(lang, "csaf.err_title_tracking_id_mismatch"),
ctx,
StatusCode::BAD_REQUEST,
&i18n::t(lang, "csaf.err_tracking_id_mismatch")
.replace("{url_id}", &html_escape(&tracking_id))
.replace("{doc_id}", &html_escape(doc.tracking_id())),
Some(&format!("/csaf/{tracking_id}/edit")),
);
}
let errors = validation::validate(&doc);
let hard_errors: Vec<_> = errors
.iter()
.filter(|e| e.severity == validation::Severity::Error)
.collect();
if !hard_errors.is_empty() {
let msg = validation_error_summary(lang, &hard_errors);
let values = values_from_form(&form);
return render_form(
ctx,
i18n::t(lang, "csaf.err_title_validation_failed"),
&edit_heading,
&format!("/csaf/{tracking_id}/update"),
i18n::t(lang, "csaf.form_submit_save"),
&values,
"",
Some(&msg),
&settings,
);
}
if let Err(err) = state.csaf_storage().put_document(&doc) {
return error_page(
i18n::t(lang, "csaf.err_title_storage_error"),
ctx,
StatusCode::INTERNAL_SERVER_ERROR,
&html_escape(&err.to_string()),
Some(&format!("/csaf/{tracking_id}/edit")),
);
}
let _ = state
.db_pool()
.with_conn(|conn| csaf_models::audit_log::record(conn, "update", &tracking_id, None, None));
if let Err(err) = export_document(&doc, &settings) {
tracing::warn!(
tracking_id = tracking_id.as_str(),
error = %err,
"Export failed after update"
);
}
redirect(&format!("/csaf/{tracking_id}"))
}
pub async fn delete(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let settings = state.settings();
let ctx = PageContext {
theme: &settings.theme,
lang,
};
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => {
return html_response(
StatusCode::BAD_REQUEST,
i18n::t(lang, "csaf.err_missing_document_id"),
);
},
};
match state.csaf_storage().delete_document(&tracking_id) {
Ok(true) => {
let _ = state.db_pool().with_conn(|conn| {
csaf_models::audit_log::record(conn, "delete", &tracking_id, None, None)
});
redirect("/csaf")
},
Ok(false) => error_page(
i18n::t(lang, "csaf.err_title_not_found"),
ctx,
StatusCode::NOT_FOUND,
&i18n::t(lang, "csaf.err_document_not_found")
.replace("{tid}", &html_escape(&tracking_id)),
Some("/csaf"),
),
Err(err) => error_page(
i18n::t(lang, "csaf.err_title_delete_error"),
ctx,
StatusCode::INTERNAL_SERVER_ERROR,
&html_escape(&err.to_string()),
Some("/csaf"),
),
}
}
pub async fn json_download(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let lang = i18n::from_parts(&parts);
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => {
return html_response(
StatusCode::BAD_REQUEST,
i18n::t(lang, "csaf.err_missing_document_id"),
);
},
};
match state.csaf_storage().get_document_json(&tracking_id) {
Ok(Some(value)) => json_response(StatusCode::OK, &value),
Ok(None) => {
let body = serde_json::json!({"error": "Document not found"});
json_response(StatusCode::NOT_FOUND, &body)
},
Err(err) => {
let body = serde_json::json!({"error": err.to_string()});
json_response(StatusCode::INTERNAL_SERVER_ERROR, &body)
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_error_summary_names_every_failing_field() {
let errors = vec![
validation::ValidationError {
path: "/document/tracking/id".to_owned(),
severity: validation::Severity::Error,
message: "tracking id is empty".to_owned(),
},
validation::ValidationError {
path: "/vulnerabilities/0/cvss_v3/base_score".to_owned(),
severity: validation::Severity::Error,
message: "score out of range".to_owned(),
},
];
let refs: Vec<&validation::ValidationError> = errors.iter().collect();
let msg = validation_error_summary(Lang::En, &refs);
assert!(msg.contains("/document/tracking/id"), "{msg}");
assert!(msg.contains("tracking id is empty"), "{msg}");
assert!(
msg.contains("/vulnerabilities/0/cvss_v3/base_score"),
"{msg}"
);
assert!(msg.contains("score out of range"), "{msg}");
}
#[test]
fn validation_error_summary_is_translated() {
let errors = vec![validation::ValidationError {
path: "/document/tracking/id".to_owned(),
severity: validation::Severity::Error,
message: "tracking id is empty".to_owned(),
}];
let refs: Vec<&validation::ValidationError> = errors.iter().collect();
let en = validation_error_summary(Lang::En, &refs);
let de = validation_error_summary(Lang::De, &refs);
assert!(en.starts_with("Validation errors:"), "{en}");
assert!(de.starts_with("Validierungsfehler:"), "{de}");
}
#[test]
fn test_format_score_critical() {
let result = format_score(Some(9.8));
assert!(result.contains("sev-critical"));
assert!(result.contains("9.8"));
}
#[test]
fn test_format_score_none() {
assert_eq!(format_score(None), "-");
}
#[test]
fn test_format_score_low() {
let result = format_score(Some(2.5));
assert!(result.contains("sev-low"));
}
#[test]
fn test_default_limit() {
assert_eq!(default_limit(), 50);
}
#[test]
fn test_build_document_from_form_minimal_security_advisory() {
let now = chrono_now_iso();
let form: Vec<(String, String)> = vec![
("tracking_id", "ndaal-sa-2026-099"),
("title", "Test Advisory"),
("category", "csaf_security_advisory"),
("status", "final"),
("doc_version", "1.0.0"),
("initial_release_date", &now),
("current_release_date", &now),
("publisher_name", "ndaal GmbH"),
("publisher_namespace", "https://ndaal.eu/csaf"),
("publisher_category", "vendor"),
("publisher_contact", "security@ndaal.eu"),
("tlp_label", "CLEAR"),
("summary_text", "Short summary of the advisory."),
("vendor_name", "ndaal"),
("product_name", "Demo Product"),
("product_id", "CSAFPID-0001"),
("product_status", "fixed"),
("cve", "CVE-0000-0042"),
("vuln_title", "Demo RCE"),
("vuln_description", "A demo description of the RCE."),
("cwe_id", "CWE-79"),
("cwe_name", "XSS"),
("cvss_v3_score", "9.8"),
("cvss_v3_severity", "CRITICAL"),
(
"cvss_v3_vector",
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
),
("cvss_v4_score", "9.3"),
("cvss_v4_severity", "CRITICAL"),
(
"cvss_v4_vector",
"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let settings = csaf_models::settings::Settings::default();
let doc = build_document_from_form(&form, "2.1", &settings).expect("should build");
assert_eq!(doc.tracking_id(), "ndaal-sa-2026-099");
assert_eq!(doc.document.csaf_version, "2.1");
assert_eq!(doc.document.category, "csaf_security_advisory");
assert_eq!(doc.vulnerabilities.len(), 1);
assert_eq!(doc.vulnerabilities[0].cve.as_deref(), Some("CVE-0000-0042"));
let errors = validation::validate(&doc);
let hard: Vec<_> = errors
.iter()
.filter(|e| e.severity == validation::Severity::Error)
.collect();
assert!(hard.is_empty(), "unexpected validation errors: {hard:?}");
}
#[test]
fn test_build_document_from_form_missing_required() {
let settings = csaf_models::settings::Settings::default();
let form: Vec<(String, String)> =
vec![("title".to_owned(), "Missing tracking id".to_owned())];
let result = build_document_from_form(&form, "2.1", &settings);
assert!(result.is_err());
assert!(result.unwrap_err().contains("tracking_id"));
}
#[test]
fn test_form_values_from_document_roundtrip() {
let json = include_str!("../../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
let doc: CsafDocument = serde_json::from_str(json).expect("parse");
let settings = csaf_models::settings::Settings::default();
let values = FormValues::from_document(&doc, &settings);
assert_eq!(values.tracking_id, "ndaal-sa-2026-003");
assert_eq!(values.title, doc.document.title);
assert_eq!(values.category, "csaf_security_advisory");
assert_eq!(values.status, "final");
assert_eq!(values.tlp_label, "CLEAR");
assert!(!values.vendor_name.is_empty());
assert!(!values.product_id.is_empty());
}
#[test]
fn test_defaults_for_new_uses_settings_publisher() {
let settings = csaf_models::settings::Settings {
publisher_name: "Acme PSIRT".to_owned(),
publisher_namespace: "https://acme.example/csaf".to_owned(),
publisher_category: "coordinator".to_owned(),
publisher_contact_details: "psirt@acme.example".to_owned(),
..csaf_models::settings::Settings::default()
};
let values = FormValues::defaults_for_new(&settings);
assert_eq!(values.publisher_name, "Acme PSIRT");
assert_eq!(values.publisher_namespace, "https://acme.example/csaf");
assert_eq!(values.publisher_category, "coordinator");
assert_eq!(values.publisher_contact, "psirt@acme.example");
}
#[test]
fn test_defaults_for_new_uses_default_ndaal_publisher() {
let settings = csaf_models::settings::Settings::default();
let values = FormValues::defaults_for_new(&settings);
assert!(
values.publisher_name.starts_with("ndaal Gesellschaft"),
"got: {}",
values.publisher_name
);
assert_eq!(values.publisher_namespace, "https://ndaal.eu/csaf");
assert_eq!(values.publisher_category, "vendor");
assert_eq!(values.publisher_contact, "security@ndaal.eu");
}
fn classification_form(
vs: Option<&str>,
nato: Option<&str>,
tlp: &str,
) -> Vec<(String, String)> {
let now = chrono_now_iso();
let mut form: Vec<(String, String)> = vec![
("tracking_id", "ndaal-sa-2026-200"),
("title", "Classification Test"),
("category", "csaf_security_advisory"),
("status", "final"),
("doc_version", "1.0.0"),
("initial_release_date", &now),
("current_release_date", &now),
("publisher_name", "ndaal GmbH"),
("publisher_namespace", "https://ndaal.eu/csaf"),
("publisher_category", "vendor"),
("publisher_contact", "security@ndaal.eu"),
("tlp_label", tlp),
("summary_text", "summary"),
("vendor_name", "ndaal"),
("product_name", "Demo"),
("product_id", "CSAFPID-0001"),
("product_status", "fixed"),
("cve", "CVE-2026-0001"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
if let Some(v) = vs {
form.push(("verschlusssache".to_owned(), v.to_owned()));
}
if let Some(n) = nato {
form.push(("nato".to_owned(), n.to_owned()));
}
form
}
#[test]
fn test_build_document_tlp_20_renames() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(None, None, "AMBER+STRICT");
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
let label = &doc
.document
.distribution
.as_ref()
.unwrap()
.tlp
.as_ref()
.unwrap()
.label;
assert_eq!(label, "AMBER+STRICT");
}
#[test]
fn test_build_document_storage_mode_distribution_text_only() {
let settings = csaf_models::settings::Settings {
classification_storage_mode: "distribution_text".to_owned(),
..csaf_models::settings::Settings::default()
};
let form = classification_form(
Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
Some("NR (NATO RESTRICTED)"),
"AMBER",
);
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
let text = doc.document.distribution.as_ref().unwrap().text.as_deref();
assert_eq!(
text,
Some(
"Verschlusssache: VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH) | \
NATO: NR (NATO RESTRICTED)"
)
);
let notes_with_vs_or_nato_title = doc
.document
.notes
.iter()
.filter(|n| {
matches!(
n.title.as_deref(),
Some("Verschlusssache" | "NATO Classification")
)
})
.count();
assert_eq!(notes_with_vs_or_nato_title, 0);
}
#[test]
fn test_build_document_storage_mode_notes_only() {
let settings = csaf_models::settings::Settings {
classification_storage_mode: "notes".to_owned(),
..csaf_models::settings::Settings::default()
};
let form = classification_form(Some("Geh. (GEHEIM)"), Some("NS (NATO SECRET)"), "RED");
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
assert!(doc.document.distribution.as_ref().unwrap().text.is_none());
let vs_note = doc
.document
.notes
.iter()
.find(|n| n.title.as_deref() == Some("Verschlusssache"))
.expect("Verschlusssache note");
assert_eq!(vs_note.text, "Geh. (GEHEIM)");
let nato_note = doc
.document
.notes
.iter()
.find(|n| n.title.as_deref() == Some("NATO Classification"))
.expect("NATO note");
assert_eq!(nato_note.text, "NS (NATO SECRET)");
}
#[test]
fn test_build_document_storage_mode_both() {
let settings = csaf_models::settings::Settings::default(); let form = classification_form(
Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
Some("NR (NATO RESTRICTED)"),
"AMBER",
);
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
assert!(doc.document.distribution.as_ref().unwrap().text.is_some());
let n_titled: usize = doc
.document
.notes
.iter()
.filter(|n| {
matches!(
n.title.as_deref(),
Some("Verschlusssache" | "NATO Classification")
)
})
.count();
assert_eq!(n_titled, 2);
}
#[test]
fn test_build_document_disabled_vs_empty_text() {
let settings = csaf_models::settings::Settings {
verschlusssache_enabled: false,
nato_enabled: false,
classification_storage_mode: "both".to_owned(),
..csaf_models::settings::Settings::default()
};
let form = classification_form(Some("ignored"), Some("ignored"), "CLEAR");
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
assert!(doc.document.distribution.as_ref().unwrap().text.is_none());
assert!(doc.document.notes.iter().all(|n| !matches!(
n.title.as_deref(),
Some("Verschlusssache" | "NATO Classification")
)));
}
#[test]
fn test_build_document_rejects_invalid_tlp() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(None, None, "WHITE");
let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
assert!(err.contains("tlp_label"), "got: {err}");
}
#[test]
fn test_build_document_rejects_invalid_verschlusssache() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(Some("hacker-vs"), None, "AMBER");
let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
assert!(err.contains("verschlusssache"), "got: {err}");
}
#[test]
fn test_build_document_rejects_invalid_nato() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(None, Some("hacker-nato"), "AMBER");
let err = build_document_from_form(&form, "2.1", &settings).unwrap_err();
assert!(err.contains("nato"), "got: {err}");
}
#[test]
fn test_extract_classification_from_distribution_text() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(
Some("VS-Vertr. (VS-VERTRAULICH)"),
Some("NC (NATO CONFIDENTIAL)"),
"AMBER",
);
let settings_text_only = csaf_models::settings::Settings {
classification_storage_mode: "distribution_text".to_owned(),
..settings.clone()
};
let doc = build_document_from_form(&form, "2.1", &settings_text_only).expect("build");
let values = FormValues::from_document(&doc, &settings);
assert_eq!(values.verschlusssache, "VS-Vertr. (VS-VERTRAULICH)");
assert_eq!(values.nato, "NC (NATO CONFIDENTIAL)");
}
#[test]
fn test_extract_classification_from_notes() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(
Some("Str. Geh. (STRENG GEHEIM)"),
Some("CTS (COSMIC TOP SECRET)"),
"RED",
);
let settings_notes_only = csaf_models::settings::Settings {
classification_storage_mode: "notes".to_owned(),
..settings.clone()
};
let doc = build_document_from_form(&form, "2.1", &settings_notes_only).expect("build");
let values = FormValues::from_document(&doc, &settings);
assert_eq!(values.verschlusssache, "Str. Geh. (STRENG GEHEIM)");
assert_eq!(values.nato, "CTS (COSMIC TOP SECRET)");
}
#[test]
fn test_extract_classification_falls_back_to_settings_defaults() {
let settings = csaf_models::settings::Settings::default();
let form = classification_form(None, None, "CLEAR");
let doc = build_document_from_form(&form, "2.1", &settings).expect("build");
let values = FormValues::from_document(&doc, &settings);
assert_eq!(values.verschlusssache, settings.verschlusssache_default);
assert_eq!(values.nato, settings.nato_default);
}
#[test]
fn test_classification_roundtrip_all_modes() {
for mode in csaf_models::settings::CLASSIFICATION_STORAGE_MODES {
let settings = csaf_models::settings::Settings {
classification_storage_mode: (*mode).to_owned(),
..csaf_models::settings::Settings::default()
};
let form = classification_form(
Some("VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)"),
Some("NR (NATO RESTRICTED)"),
"AMBER",
);
let doc = build_document_from_form(&form, "2.1", &settings)
.unwrap_or_else(|e| panic!("mode={mode}: {e}"));
let values = FormValues::from_document(&doc, &settings);
assert_eq!(
values.verschlusssache, "VS-NfD (VS-NUR FÜR DEN DIENSTGEBRAUCH)",
"mode={mode}"
);
assert_eq!(values.nato, "NR (NATO RESTRICTED)", "mode={mode}");
assert_eq!(values.tlp_label, "AMBER", "mode={mode}");
}
}
}