use std::fmt::Write as _;
use serde::Serialize;
use super::ack::{ACK_PHRASE, Acknowledgment, CEILING_KEY, short};
use super::diff::{Finding, Severity};
pub const COMMENT_MARKER: &str = "<!-- autumn-posture-gate -->";
pub const MAX_ROWS: usize = 25;
#[derive(Debug)]
pub struct Report<'a> {
pub findings: &'a [Finding],
pub ack_digest: String,
pub marker: String,
pub head_posture_digest: String,
pub acknowledged: Option<Acknowledgment>,
pub bootstrap: bool,
}
impl Report<'_> {
#[must_use]
pub fn widening(&self) -> Vec<&Finding> {
self.findings
.iter()
.filter(|f| f.severity == Severity::Widening)
.collect()
}
#[must_use]
pub fn blocked(&self) -> bool {
!self.widening().is_empty() && self.acknowledged.is_none()
}
}
#[derive(Debug, Serialize)]
struct JsonReport<'a> {
schema: &'static str,
blocked: bool,
bootstrap: bool,
ack_digest: String,
ack_phrase: Option<String>,
head_posture_digest: &'a str,
acknowledged_by_digest: Option<&'a str>,
acknowledgment_reason: Option<&'a str>,
counts: Counts,
findings: Vec<JsonFinding<'a>>,
}
#[derive(Debug, Serialize)]
struct Counts {
widening: usize,
neutral: usize,
narrowing: usize,
}
#[derive(Debug, Serialize)]
struct JsonFinding<'a> {
kind: &'a str,
severity: &'a str,
method: &'a str,
path: &'a str,
before: &'a str,
after: &'a str,
detail: &'a str,
}
#[must_use]
pub fn markdown(report: &Report<'_>) -> String {
if report.findings.is_empty() && !report.bootstrap {
return String::new();
}
let mut out = String::from("### \u{1F6E1}\u{FE0F} Security posture diff\n\n");
if report.bootstrap {
out.push_str(
"No posture baseline was found for the base branch, so there is nothing to \
compare against yet. Commit the manifest this run produced and the next pull \
request will be diffed against it.\n\n",
);
}
let widening = report.widening();
if !widening.is_empty() {
match &report.acknowledged {
Some(ack) => {
let _ = write!(
out,
"**{} widening change{} \u{2014} acknowledged**",
widening.len(),
plural(widening.len())
);
if let Some(reason) = &ack.reason {
let _ = write!(out, " \u{2014} \u{201C}{}\u{201D}", escape(reason));
}
out.push_str("\n\n");
}
None => {
let _ = write!(
out,
"**{} widening change{} \u{2014} acknowledgment required.** This pull \
request makes part of the app reachable by more callers than before.\n\n",
widening.len(),
plural(widening.len())
);
}
}
table_all(&mut out, &widening);
}
section(
&mut out,
"Other changes",
&by_severity(report.findings, Severity::Neutral),
);
section(
&mut out,
"Narrowing",
&by_severity(report.findings, Severity::Narrowing),
);
if !widening.is_empty() && report.acknowledged.is_none() {
let _ = write!(
out,
"To acknowledge these changes, comment on this pull request with:\n\n\
```\n{ACK_PHRASE} {}\n```\n\n",
report.marker
);
if report.marker.contains(CEILING_KEY) {
let _ = write!(
out,
"This comment approves these findings. It also approves any narrower posture \
on these routes. Unrelated pushes keep it valid. A new widening, or a wider \
route, needs a new comment.\n\n\
For an exact match, use this line instead. Any later change asks again:\n\n\
```\n{ACK_PHRASE} {}\n```\n\n",
short(&report.ack_digest)
);
} else {
let _ = write!(
out,
"The digest names this set of widenings. Unrelated pushes keep it valid. A new \
widening needs a new acknowledgment.\n\n"
);
}
}
let _ = write!(
out,
"<sub>posture digest `{}` \u{2022} generated by `autumn routes posture diff`</sub>\
\n\n{COMMENT_MARKER}\n",
report.head_posture_digest
);
out
}
fn section(out: &mut String, title: &str, findings: &[&Finding]) {
if findings.is_empty() {
return;
}
let _ = write!(out, "**{title} ({})**\n\n", findings.len());
table(out, findings);
}
fn by_severity(findings: &[Finding], severity: Severity) -> Vec<&Finding> {
findings.iter().filter(|f| f.severity == severity).collect()
}
fn table_all(out: &mut String, findings: &[&Finding]) {
table_capped(out, findings, usize::MAX);
}
fn table(out: &mut String, findings: &[&Finding]) {
table_capped(out, findings, MAX_ROWS);
}
fn table_capped(out: &mut String, findings: &[&Finding], cap: usize) {
out.push_str("| Change | Method | Path | Before | After |\n|---|---|---|---|---|\n");
for f in findings.iter().take(cap) {
let _ = writeln!(
out,
"| {} | `{}` | `{}` | {} | {} |",
escape(&f.detail),
escape(&f.method),
escape(&f.path),
code(&f.before),
code(&f.after)
);
}
if findings.len() > cap {
let _ = write!(out, "\n\u{2026} and {} more.\n", findings.len() - cap);
}
out.push('\n');
}
fn code(value: &str) -> String {
if matches!(value, "absent" | "none" | "not emitted") {
format!("_{value}_")
} else {
format!("`{}`", escape(value))
}
}
fn escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('|', "\\|")
.replace(['\r', '\n'], " ")
.replace('`', "'")
}
fn one_line(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
const fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
#[must_use]
pub fn text(report: &Report<'_>) -> String {
if report.findings.is_empty() && !report.bootstrap {
return String::new();
}
let mut out = String::new();
if report.bootstrap {
out.push_str("no posture baseline to compare against yet\n");
}
for f in report.findings {
let _ = writeln!(
out,
"{:<10} {:<7} {} {} \u{2192} {} ({})",
f.severity.as_str(),
one_line(&f.method),
one_line(&f.path),
one_line(&f.before),
one_line(&f.after),
one_line(&f.detail)
);
}
if !report.widening().is_empty() {
out.push('\n');
match &report.acknowledged {
Some(ack) => {
let _ = writeln!(out, "acknowledged: {ACK_PHRASE} {}", ack.digest);
}
None => {
let _ = writeln!(
out,
"blocked: acknowledge with `{ACK_PHRASE} {}`",
report.marker
);
}
}
}
out
}
#[must_use]
pub fn json(report: &Report<'_>) -> String {
let counts = Counts {
widening: by_severity(report.findings, Severity::Widening).len(),
neutral: by_severity(report.findings, Severity::Neutral).len(),
narrowing: by_severity(report.findings, Severity::Narrowing).len(),
};
let document = JsonReport {
schema: "autumn.posture-diff.v1",
blocked: report.blocked(),
bootstrap: report.bootstrap,
ack_digest: report.ack_digest.clone(),
ack_phrase: (counts.widening > 0).then(|| format!("{ACK_PHRASE} {}", report.marker)),
head_posture_digest: &report.head_posture_digest,
acknowledged_by_digest: report.acknowledged.as_ref().map(|a| a.digest.as_str()),
acknowledgment_reason: report
.acknowledged
.as_ref()
.and_then(|a| a.reason.as_deref()),
counts,
findings: report
.findings
.iter()
.map(|f| JsonFinding {
kind: f.kind,
severity: f.severity.as_str(),
method: &f.method,
path: &f.path,
before: &f.before,
after: &f.after,
detail: &f.detail,
})
.collect(),
};
serde_json::to_string_pretty(&document)
.unwrap_or_else(|e| format!("{{\"error\":\"cannot serialize report: {e}\"}}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(kind: &'static str, severity: Severity, path: &str) -> Finding {
Finding {
kind,
severity,
method: "GET".to_owned(),
path: path.to_owned(),
before: "gated (roles: admin)".to_owned(),
after: "public".to_owned(),
fingerprint: "class:gated->public".to_owned(),
detail: "guard removed: gated \u{2192} public".to_owned(),
}
}
fn report(findings: &[Finding], acknowledged: Option<Acknowledgment>) -> Report<'_> {
Report {
findings,
ack_digest: "0123456789abcdef0123".to_owned(),
marker: short("0123456789abcdef0123"),
head_posture_digest: "deadbeef".to_owned(),
acknowledged,
bootstrap: false,
}
}
#[test]
fn no_findings_renders_no_markdown_at_all() {
assert_eq!(markdown(&report(&[], None)), "");
}
#[test]
fn a_widening_finding_names_the_route_and_the_transition() {
let findings = vec![finding(
"classification_downgraded",
Severity::Widening,
"/admin/users",
)];
let md = markdown(&report(&findings, None));
assert!(md.contains("/admin/users"), "{md}");
assert!(md.contains("GET"), "{md}");
assert!(md.contains("gated (roles: admin)"), "{md}");
assert!(md.contains("public"), "{md}");
assert!(
md.contains(COMMENT_MARKER),
"carries the update marker: {md}"
);
}
#[test]
fn a_widening_report_prints_the_exact_acknowledgment_line() {
let findings = vec![finding(
"classification_downgraded",
Severity::Widening,
"/admin/users",
)];
let md = markdown(&report(&findings, None));
assert!(
md.contains(&format!("{ACK_PHRASE} {}", short("0123456789abcdef0123"))),
"the reviewer must be able to paste it verbatim: {md}"
);
}
#[test]
fn an_acknowledged_report_says_so_and_does_not_ask_again() {
let findings = vec![finding(
"classification_downgraded",
Severity::Widening,
"/admin/users",
)];
let ack = Acknowledgment {
digest: short("0123456789abcdef0123"),
reason: Some("launch week".to_owned()),
ceiling: None,
};
let md = markdown(&report(&findings, Some(ack)));
assert!(md.to_lowercase().contains("acknowledged"), "{md}");
assert!(md.contains("launch week"), "{md}");
assert!(
!md.contains("comment on this pull request"),
"no second ask once acknowledged: {md}"
);
}
#[test]
fn narrowing_only_changes_render_without_asking_for_acknowledgment() {
let findings = vec![finding("route_removed", Severity::Narrowing, "/old")];
let md = markdown(&report(&findings, None));
assert!(md.contains("/old"), "{md}");
assert!(
!md.contains(ACK_PHRASE),
"nothing to acknowledge, so nothing is asked: {md}"
);
}
#[test]
fn a_long_annotation_list_is_capped_with_a_tail_count() {
let findings: Vec<Finding> = (0..MAX_ROWS + 7)
.map(|i| finding("route_removed", Severity::Narrowing, &format!("/r{i:03}")))
.collect();
let md = markdown(&report(&findings, None));
assert!(md.contains("7 more"), "{md}");
assert!(md.contains("/r000"), "{md}");
assert!(!md.contains("/r031"), "beyond the cap: {md}");
}
#[test]
fn the_widening_table_is_never_capped() {
let mut findings: Vec<Finding> = (0..MAX_ROWS + 5)
.map(|i| {
finding(
"route_added_open",
Severity::Widening,
&format!("/aaa{i:03}"),
)
})
.collect();
findings.push(finding(
"classification_downgraded",
Severity::Widening,
"/zzz-admin",
));
let md = markdown(&report(&findings, None));
assert!(
md.contains("/zzz-admin"),
"the last widening row must survive: {md}"
);
assert!(!md.contains("more."), "no tail collapse on widening: {md}");
}
#[test]
fn bootstrap_says_there_is_no_baseline_and_asks_for_nothing() {
let mut r = report(&[], None);
r.bootstrap = true;
let md = markdown(&r);
assert!(md.to_lowercase().contains("baseline"), "{md}");
assert!(!md.contains(ACK_PHRASE), "{md}");
}
#[test]
fn manifest_values_cannot_break_out_of_a_table_cell() {
let mut f = finding("route_added_open", Severity::Widening, "/a|b");
f.before = "gated (roles: ad\\min)".to_owned();
f.after = "pub`lic".to_owned();
f.detail = "line one\nline two".to_owned();
let findings = vec![f];
let md = markdown(&report(&findings, None));
for line in md.lines().filter(|l| l.starts_with("| ")) {
assert_eq!(
line.matches('|').count() - line.matches("\\|").count(),
6,
"a value forged a column: {line}"
);
}
assert!(!md.contains("pub`lic"), "backticks are neutralized: {md}");
assert!(!md.contains("line one\nline two"), "no forged row: {md}");
}
#[test]
fn text_rendering_cannot_forge_a_workflow_command() {
let mut f = finding("route_added_open", Severity::Widening, "/a\n::error::pwned");
f.detail = "x\n::add-mask::y".to_owned();
let findings = vec![f];
let out = text(&report(&findings, None));
assert!(
!out.lines().any(|l| l.trim_start().starts_with("::")),
"no line may start a workflow command: {out}"
);
}
#[test]
fn json_report_carries_findings_counts_and_the_ack_digest() {
let findings = vec![
finding("classification_downgraded", Severity::Widening, "/a"),
finding("route_removed", Severity::Narrowing, "/b"),
];
let out = json(&report(&findings, None));
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["blocked"], true);
assert_eq!(v["counts"]["widening"], 1);
assert_eq!(v["counts"]["narrowing"], 1);
assert_eq!(v["findings"][0]["path"], "/a");
assert_eq!(v["findings"][0]["severity"], "widening");
assert_eq!(v["ack_digest"], "0123456789abcdef0123");
assert!(
v["ack_phrase"]
.as_str()
.unwrap()
.starts_with("/ack-posture ")
);
}
#[test]
fn json_report_of_a_clean_run_is_still_valid_json() {
let out = json(&report(&[], None));
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["blocked"], false);
assert_eq!(v["counts"]["widening"], 0);
assert!(v["findings"].as_array().unwrap().is_empty());
assert!(
v["ack_phrase"].is_null(),
"nothing is waiting to be acknowledged, so no marker is advertised"
);
}
#[test]
fn text_rendering_states_the_verdict_in_one_line() {
let findings = vec![finding(
"classification_downgraded",
Severity::Widening,
"/admin",
)];
let out = text(&report(&findings, None));
assert!(out.contains("/admin"), "{out}");
assert!(out.to_lowercase().contains("widening"), "{out}");
assert_eq!(text(&report(&[], None)), "", "silence when nothing changed");
}
#[test]
fn the_report_prints_the_marker_it_was_given() {
let findings = vec![finding(
"classification_downgraded",
Severity::Widening,
"/admin/users",
)];
let mut r = report(&findings, None);
r.marker = "0123456789abcdef ceiling=GET|%2Fa".to_owned();
let line = format!("{ACK_PHRASE} {}", r.marker);
assert!(markdown(&r).contains(&line));
assert!(text(&r).contains(&line));
assert!(json(&r).contains("ceiling=GET|%2Fa"));
}
}