use http::{Response, StatusCode};
use serde::Deserialize;
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 crate::app_state::AppState;
use crate::router::{
Body, form_field, html_response, json_response, parse_form_body, path_param, redirect,
};
use crate::routes::layout::{Nav, 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,
theme: &str,
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</a>"#,
u = html_escape(u)
)
})
.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, theme, 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()
}
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 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}"));
}
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 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.as_deref() {
text_parts.push(format!("Verschlusssache: {vs}"));
}
if let Some(n) = nato.as_deref() {
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.as_deref() {
notes.push(Note {
category: "other".to_owned(),
text: vs.to_owned(),
title: Some("Verschlusssache".to_owned()),
audience: None,
});
}
if let Some(n) = nato.as_deref() {
notes.push(Note {
category: "other".to_owned(),
text: n.to_owned(),
title: Some("NATO Classification".to_owned()),
audience: None,
});
}
}
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()
};
if let Some(vendor) = doc.product_tree.branches.first() {
values.vendor_name.clone_from(&vendor.name);
if let Some(pname) = vendor.branches.first() {
values.product_name.clone_from(&pname.name);
if let Some(p) = &pname.product {
values.product_id.clone_from(&p.product_id);
} else if let Some(inner) = pname.branches.first()
&& let Some(p) = &inner.product
{
values.product_id.clone_from(&p.product_id);
}
}
}
if let Some(vuln) = doc.vulnerabilities.first() {
values.cve = vuln.cve.clone().unwrap_or_default();
values.vuln_title = vuln.title.clone().unwrap_or_default();
values.vuln_description = vuln
.notes
.iter()
.find(|n| n.category == "description")
.map(|n| n.text.clone())
.unwrap_or_default();
if let Some(cwe) = &vuln.cwe {
values.cwe_id.clone_from(&cwe.id);
values.cwe_name.clone_from(&cwe.name);
}
if let Some(status) = &vuln.product_status {
values.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()
};
} else {
"known_affected".clone_into(&mut values.product_status);
}
if let Some(metric) = vuln.metrics.first() {
if let Some(v3) = &metric.content.cvss_v3 {
values.cvss_v3_vector.clone_from(&v3.vector_string);
values.cvss_v3_score = format!("{:.1}", v3.base_score);
values.cvss_v3_severity.clone_from(&v3.base_severity);
}
if let Some(v4) = &metric.content.cvss_v4 {
values.cvss_v4_vector.clone_from(&v4.vector_string);
values.cvss_v4_score = format!("{:.1}", v4.base_score);
values.cvss_v4_severity.clone_from(&v4.base_severity);
}
}
} else {
"known_affected".clone_into(&mut values.product_status);
}
values
}
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(
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_block = if settings.verschlusssache_enabled {
format!(
r#"<div>
<label for="verschlusssache">Verschlusssache</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>
<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)]
fn render_form(
theme: &str,
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 error_html = error
.map(|e| format!(r#"<div class="flash error">{}</div>"#, html_escape(e)))
.unwrap_or_default();
let classification_row =
build_classification_row(&values.verschlusssache, &values.nato, settings);
let content = format!(
r##"<h1>{heading}</h1>
{error_html}
<div class="card">
<form method="POST" action="{action}">
{extra_fields}
<h2>Document Metadata</h2>
<div class="form-row">
<div>
<label for="tracking_id">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">Title *</label>
<input type="text" id="title" name="title" required value="{title_v}">
</div>
</div>
<div class="form-row">
<div>
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="csaf_security_advisory" {cat_sa}>Security Advisory</option>
<option value="csaf_vex" {cat_vex}>VEX</option>
<option value="csaf_informational_advisory" {cat_info}>Informational Advisory</option>
</select>
</div>
<div>
<label for="status">Status *</label>
<select id="status" name="status" required>
<option value="draft" {st_draft}>draft</option>
<option value="interim" {st_interim}>interim</option>
<option value="final" {st_final}>final</option>
</select>
</div>
</div>
<div class="form-row">
<div>
<label for="doc_version">Document 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">TLP 2.0 *</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">Initial Release 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">Current Release 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">Summary *</label>
<textarea id="summary_text" name="summary_text" required rows="4">{summary_text}</textarea>
</div>
</div>
<h2>Publisher</h2>
<div class="form-row">
<div>
<label for="publisher_name">Name *</label>
<input type="text" id="publisher_name" name="publisher_name" required value="{publisher_name}">
</div>
<div>
<label for="publisher_namespace">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">Category *</label>
<select id="publisher_category" name="publisher_category" required>
<option value="vendor" {pc_vendor}>vendor</option>
<option value="discoverer" {pc_disc}>discoverer</option>
<option value="coordinator" {pc_coord}>coordinator</option>
<option value="user" {pc_user}>user</option>
<option value="translator" {pc_trans}>translator</option>
<option value="other" {pc_other}>other</option>
</select>
</div>
<div>
<label for="publisher_contact">Contact details</label>
<input type="text" id="publisher_contact" name="publisher_contact" value="{publisher_contact}">
</div>
</div>
<h2>Affected Product</h2>
<div class="form-row">
<div>
<label for="vendor_name">Vendor *</label>
<input type="text" id="vendor_name" name="vendor_name" required value="{vendor_name}">
</div>
<div>
<label for="product_name">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">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">Product Status *</label>
<select id="product_status" name="product_status" required>
<option value="known_affected" {ps_ka}>known_affected</option>
<option value="known_not_affected" {ps_kna}>known_not_affected</option>
<option value="fixed" {ps_fixed}>fixed</option>
<option value="under_investigation" {ps_ui}>under_investigation</option>
</select>
</div>
</div>
<h2>Vulnerability</h2>
<p class="muted">Required for Security Advisories, optional for Informational / VEX.</p>
<div class="form-row">
<div>
<label for="cve">CVE ID</label>
<input type="text" id="cve" name="cve" value="{cve}" placeholder="CVE-2024-0001">
</div>
<div>
<label for="vuln_title">Vulnerability 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">Vulnerability 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">CWE ID</label>
<input type="text" id="cwe_id" name="cwe_id" value="{cwe_id}" placeholder="CWE-79">
</div>
<div>
<label for="cwe_name">CWE Name</label>
<input type="text" id="cwe_name" name="cwe_name" value="{cwe_name}">
</div>
</div>
<h2>CVSS v3.1</h2>
<div class="form-row">
<div>
<label for="cvss_v3_score">Base Score (0.0 – 10.0)</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">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">CVSS v3.1 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>CVSS v4.0</h2>
<div class="form-row">
<div>
<label for="cvss_v4_score">Base Score (0.0 – 10.0)</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">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">CVSS v4.0 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;">Cancel</a>
</div>
</form>
</div>"##,
heading = html_escape(heading),
action = html_escape(action),
extra_fields = extra_fields,
submit_label = html_escape(submit_label),
tracking_id = html_escape(&values.tracking_id),
title_v = html_escape(&values.title),
cat_sa = selected(&values.category, "csaf_security_advisory"),
cat_vex = selected(&values.category, "csaf_vex"),
cat_info = selected(&values.category, "csaf_informational_advisory"),
st_draft = selected(&values.status, "draft"),
st_interim = selected(&values.status, "interim"),
st_final = selected(&values.status, "final"),
doc_version = html_escape(&values.doc_version),
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"),
initial_date = html_escape(&values.initial_release_date),
current_date = html_escape(&values.current_release_date),
summary_text = html_escape(&values.summary_text),
publisher_name = html_escape(&values.publisher_name),
publisher_namespace = html_escape(&values.publisher_namespace),
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"),
publisher_contact = html_escape(&values.publisher_contact),
vendor_name = html_escape(&values.vendor_name),
product_name = html_escape(&values.product_name),
product_id = html_escape(&values.product_id),
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"),
cve = html_escape(&values.cve),
vuln_title = html_escape(&values.vuln_title),
vuln_description = html_escape(&values.vuln_description),
cwe_id = html_escape(&values.cwe_id),
cwe_name = html_escape(&values.cwe_name),
v3_score = html_escape(&values.cvss_v3_score),
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"),
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"),
);
html_response(StatusCode::OK, wrap_page(title, theme, 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 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(
r#"<tr><td colspan="7" class="muted" style="text-align:center;">No documents found. <a href="/csaf/new">Create one</a>.</td></tr>"#,
);
} 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 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</a>
</div>"#,
limit = query.limit,
prev = prev_offset,
next = next_offset,
start = query.offset + 1,
end = (query.offset + metas.len()).min(total),
)
} else {
format!(r#"<p class="muted">Showing {total} document(s)</p>"#)
};
let content = format!(
r#"<div style="display:flex;justify-content:space-between;align-items:center;">
<h1>CSAF Documents</h1>
<a class="btn" href="/csaf/new">New Document</a>
</div>
<div class="card">
<table>
<thead><tr>
<th>Tracking ID</th><th>Title</th><th>Category</th><th>Status</th>
<th>CVSS v3</th><th>CVSS v4</th><th>Release Date</th>
</tr></thead>
<tbody>{rows}</tbody>
</table>
{pagination}
</div>"#,
);
html_response(
StatusCode::OK,
wrap_page("CSAF Documents", &settings.theme, Nav::Csaf, &content),
)
}
pub async fn new_form(
state: AppState,
_parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let values = FormValues::defaults_for_new(&settings);
render_form(
&settings.theme,
"New CSAF Document",
"New CSAF Document",
"/csaf",
"Create Document",
&values,
"",
None,
&settings,
)
}
pub async fn create(
state: AppState,
parts: http::request::Parts,
_params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let form = match parse_form_body(&parts) {
Ok(f) => f,
Err(err) => {
return error_page(
"Create Failed",
&settings.theme,
StatusCode::BAD_REQUEST,
&format!("<strong>Form error:</strong> {}", 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);
return render_form(
&settings.theme,
"New CSAF Document",
"New CSAF Document",
"/csaf",
"Create Document",
&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 mut error_list = String::new();
for e in &hard_errors {
error_list.push_str(&format!(
"<li><code>{path}</code>: {msg}</li>",
path = html_escape(&e.path),
msg = html_escape(&e.message),
));
}
let values = values_from_form(&form);
let err_msg = format!(
"<strong>{count} validation error(s):</strong><ul>{error_list}</ul>",
count = hard_errors.len(),
);
let content_values = values;
return render_form(
&settings.theme,
"Validation Failed",
"New CSAF Document",
"/csaf",
"Create Document",
&content_values,
"",
None,
&settings,
)
.map(|b| b) .into_error_inject(&err_msg, &settings.theme);
}
let tracking_id = doc.tracking_id().to_owned();
if state
.csaf_storage()
.document_exists(&tracking_id)
.unwrap_or(false)
{
return error_page(
"Duplicate Document",
&settings.theme,
StatusCode::CONFLICT,
&format!(
"A document with tracking ID <strong>{}</strong> already exists.",
html_escape(&tracking_id)
),
Some(&format!("/csaf/{tracking_id}")),
);
}
if let Err(err) = state.csaf_storage().put_document(&doc) {
return error_page(
"Storage Error",
&settings.theme,
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 settings = state.settings();
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
};
let doc = match state.csaf_storage().get_document(&tracking_id) {
Ok(Some(d)) => d,
Ok(None) => {
return error_page(
"Not Found",
&settings.theme,
StatusCode::NOT_FOUND,
&format!(
"Document <strong>{}</strong> does not exist.",
html_escape(&tracking_id)
),
Some("/csaf"),
);
},
Err(err) => {
return error_page(
"Error",
&settings.theme,
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() {
r#"<p class="muted">No vulnerabilities.</p>"#.to_owned()
} else {
format!(
r#"<table>
<thead><tr><th>CVE</th><th>Title</th><th>CVSS v3</th><th>CVSS v4</th></tr></thead>
<tbody>{vuln_rows}</tbody>
</table>"#,
)
};
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</a>
<a class="btn secondary" href="/csaf/{tid}/json">JSON</a>
<form method="POST" action="/csaf/{tid}/delete" style="display:inline;margin-left:0.5rem;"
onsubmit="return confirm('Delete this document?');">
<button type="submit" class="btn danger">Delete</button>
</form>
</div>
</div>
<div class="card">
<h2>Metadata</h2>
<table>
<tr><th>Tracking ID</th><td>{tid}</td></tr>
<tr><th>Category</th><td>{category}</td></tr>
<tr><th>CSAF Version</th><td>{csaf_version}</td></tr>
<tr><th>Status</th><td>{status}</td></tr>
<tr><th>Version</th><td>{version}</td></tr>
<tr><th>Publisher</th><td>{publisher}</td></tr>
<tr><th>TLP</th><td>{tlp}</td></tr>
<tr><th>Initial Release</th><td>{initial_date}</td></tr>
<tr><th>Current Release</th><td>{current_date}</td></tr>
</table>
</div>
<div class="card">
<h2>Vulnerabilities ({vuln_count})</h2>
{vuln_section}
</div>
<div class="card">
<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),
category = html_escape(&doc.document.category),
csaf_version = html_escape(&doc.document.csaf_version),
status = html_escape(&doc.document.tracking.status),
version = html_escape(&doc.document.tracking.version),
publisher = html_escape(&doc.document.publisher.name),
tlp = html_escape(tlp),
initial_date = html_escape(&doc.document.tracking.initial_release_date),
current_date = html_escape(&doc.document.tracking.current_release_date),
vuln_count = doc.vulnerabilities.len(),
json = html_escape(&json_pretty),
);
html_response(
StatusCode::OK,
wrap_page(&tracking_id, &settings.theme, Nav::Csaf, &content),
)
}
pub async fn edit_form(
state: AppState,
_parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
};
let doc = match state.csaf_storage().get_document(&tracking_id) {
Ok(Some(d)) => d,
Ok(None) => {
return error_page(
"Not Found",
&settings.theme,
StatusCode::NOT_FOUND,
&format!(
"Document <strong>{}</strong> does not exist.",
html_escape(&tracking_id)
),
Some("/csaf"),
);
},
Err(err) => {
return error_page(
"Error",
&settings.theme,
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");
render_form(
&settings.theme,
"Edit CSAF Document",
&format!("Edit: {}", html_escape(&tracking_id)),
&action,
"Save Changes",
&values,
"",
None,
&settings,
)
}
pub async fn update(
state: AppState,
parts: http::request::Parts,
params: Vec<(String, String)>,
) -> Response<Body> {
let settings = state.settings();
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => return html_response(StatusCode::BAD_REQUEST, "Missing document ID"),
};
let form = match parse_form_body(&parts) {
Ok(f) => f,
Err(err) => {
return error_page(
"Update Failed",
&settings.theme,
StatusCode::BAD_REQUEST,
&format!("<strong>Form error:</strong> {}", html_escape(&err)),
Some(&format!("/csaf/{tracking_id}/edit")),
);
},
};
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(
&settings.theme,
"Edit CSAF Document",
&format!("Edit: {}", html_escape(&tracking_id)),
&format!("/csaf/{tracking_id}/update"),
"Save Changes",
&values,
"",
Some(&err),
&settings,
);
},
};
if doc.tracking_id() != tracking_id {
return error_page(
"Tracking ID Mismatch",
&settings.theme,
StatusCode::BAD_REQUEST,
&format!(
"URL tracking ID <strong>{}</strong> does not match document tracking ID <strong>{}</strong>.",
html_escape(&tracking_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 mut msg = String::from("Validation errors:");
for e in &hard_errors {
msg.push_str(&format!(" {}: {};", e.path, e.message));
}
let values = values_from_form(&form);
return render_form(
&settings.theme,
"Validation Failed",
&format!("Edit: {}", html_escape(&tracking_id)),
&format!("/csaf/{tracking_id}/update"),
"Save Changes",
&values,
"",
Some(&msg),
&settings,
);
}
if let Err(err) = state.csaf_storage().put_document(&doc) {
return error_page(
"Storage Error",
&settings.theme,
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 settings = state.settings();
let tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => return html_response(StatusCode::BAD_REQUEST, "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(
"Not Found",
&settings.theme,
StatusCode::NOT_FOUND,
&format!(
"Document <strong>{}</strong> does not exist.",
html_escape(&tracking_id)
),
Some("/csaf"),
),
Err(err) => error_page(
"Delete Error",
&settings.theme,
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 tracking_id = match path_param(¶ms, "id") {
Some(id) => id,
None => return html_response(StatusCode::BAD_REQUEST, "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)
},
}
}
trait ResponseExt {
fn into_error_inject(self, _msg: &str, _theme: &str) -> Response<Body>;
}
impl ResponseExt for Response<Body> {
fn into_error_inject(self, _msg: &str, _theme: &str) -> Response<Body> {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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}");
}
}
}