use std::fmt::Write as _;
pub mod ack;
pub mod diff;
pub mod model;
pub mod render;
pub mod verify;
use ack::Acknowledgment;
use diff::Finding;
use model::{ManifestError, PostureManifest};
use render::Report;
pub const EXIT_BLOCKED: i32 = 1;
pub const EXIT_USAGE: i32 = 2;
#[derive(Debug, Clone)]
pub struct DiffOptions<'a> {
pub base: &'a str,
pub head: &'a str,
pub format: &'a str,
pub output: Option<&'a str>,
pub acks: &'a [String],
pub ack_file: Option<&'a str>,
pub allow_missing_base: bool,
}
#[derive(Debug)]
pub struct Evaluation {
pub findings: Vec<Finding>,
pub ack_digest: String,
pub head_posture_digest: String,
pub acknowledged: Option<Acknowledgment>,
pub bootstrap: bool,
}
impl Evaluation {
#[must_use]
pub fn blocked(&self) -> bool {
!diff::widening(&self.findings).is_empty() && self.acknowledged.is_none()
}
#[must_use]
pub fn exit_code(&self) -> i32 {
i32::from(self.blocked()) * EXIT_BLOCKED
}
fn report(&self) -> Report<'_> {
Report {
findings: &self.findings,
ack_digest: self.ack_digest.clone(),
head_posture_digest: self.head_posture_digest.clone(),
acknowledged: self.acknowledged.clone(),
bootstrap: self.bootstrap,
}
}
}
#[must_use]
pub fn evaluate(
base: Option<&PostureManifest>,
head: &PostureManifest,
ack_text: &str,
) -> Evaluation {
let findings = base.map(|b| diff::diff(b, head)).unwrap_or_default();
let widening = diff::widening(&findings);
let ack_digest = ack::ack_digest(&widening);
let acknowledged = if widening.is_empty() {
None
} else {
ack::matching(&ack::parse_acks(ack_text), &ack_digest).cloned()
};
Evaluation {
findings,
ack_digest,
head_posture_digest: head.posture_digest(),
acknowledged,
bootstrap: base.is_none(),
}
}
pub fn render(evaluation: &Evaluation, format: &str) -> Result<String, String> {
let report = evaluation.report();
match format {
"markdown" | "md" => Ok(render::markdown(&report)),
"text" => Ok(render::text(&report)),
"json" => Ok(render::json(&report)),
other => Err(format!(
"unknown --format `{other}` (expected `markdown`, `text`, or `json`)"
)),
}
}
fn harvested_ack_text(opts: &DiffOptions<'_>) -> Result<String, String> {
let mut text = String::new();
for ack in opts.acks {
let _ = writeln!(text, "{} {}", ack::ACK_PHRASE, ack.trim());
}
if let Some(path) = opts.ack_file {
let harvested = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read acknowledgment file {path}: {e}"))?;
text.push_str(&harvested);
text.push('\n');
}
Ok(text)
}
#[must_use]
pub fn run_diff(opts: &DiffOptions<'_>) -> i32 {
let head = match PostureManifest::read(opts.head) {
Ok(m) => m,
Err(e) => return fail(&e.to_string()),
};
let base = match PostureManifest::read(opts.base) {
Ok(m) => Some(m),
Err(ManifestError::Io { ref source, .. })
if opts.allow_missing_base && source.kind() == std::io::ErrorKind::NotFound =>
{
None
}
Err(e) => return fail(&e.to_string()),
};
let ack_text = match harvested_ack_text(opts) {
Ok(t) => t,
Err(e) => return fail(&e),
};
if let Some(base_manifest) = &base
&& !base_manifest.dimensions.routes.entries.is_empty()
&& head.dimensions.routes.entries.is_empty()
{
return fail(&format!(
"{} lists no routes at all while {} lists {} \u{2014} refusing to score that as a \
narrowing. Rebuild the manifest with `autumn routes audit --manifest {}`.",
opts.head,
opts.base,
base_manifest.dimensions.routes.entries.len(),
opts.head
));
}
let evaluation = evaluate(base.as_ref(), &head, &ack_text);
let rendered = match render(&evaluation, opts.format) {
Ok(r) => r,
Err(e) => return fail(&e),
};
if let Some(path) = opts.output
&& let Err(e) = std::fs::write(path, &rendered)
{
return fail(&format!("cannot write report to {path}: {e}"));
}
if !rendered.is_empty() {
println!("{rendered}");
}
if evaluation.blocked() {
eprintln!(
"\n\u{2717} security posture widened and is not acknowledged.\n \
Acknowledge it with this line \u{2014} as a pull-request comment, or \
locally as `--ack <digest>`:\n\n {} {}\n",
ack::ACK_PHRASE,
ack::short(&evaluation.ack_digest)
);
}
evaluation.exit_code()
}
#[must_use]
pub fn run_digest(manifest: &str, format: &str) -> i32 {
let digest = match verify::manifest_digest(manifest) {
Ok(d) => d,
Err(e) => return fail(&e.to_string()),
};
match format {
"text" => println!("{digest}"),
"json" => println!(
"{}",
serde_json::json!({ "manifest": manifest, "posture_digest": digest })
),
other => {
return fail(&format!(
"unknown --format `{other}` (expected `text` or `json`)"
));
}
}
0
}
#[must_use]
pub fn run_verify(opts: &verify::VerifyOptions<'_>) -> i32 {
let report = match verify::verify_with(opts, &verify::GhAttestationVerifier) {
Ok(r) => r,
Err(e) => return fail(&e.to_string()),
};
println!("\u{1F342} autumn routes posture verify\n");
println!(" manifest {}", report.manifest);
println!(" posture digest {}", report.posture_digest);
for check in &report.checks {
let mark = if check.waived {
"\u{26A0}"
} else if check.passed {
"\u{2713}"
} else {
"\u{2717}"
};
println!(" {mark} {:<22} {}", check.name, check.detail);
}
if report.passed() { 0 } else { EXIT_BLOCKED }
}
fn fail(message: &str) -> i32 {
eprintln!("\u{2717} {message}");
EXIT_USAGE
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> PostureManifest {
PostureManifest::parse(json, "test.json").expect("fixture parses")
}
fn manifest(routes: &str) -> PostureManifest {
parse(&format!(
r#"{{"schema_version":3,"dimensions":{{
"routes":{{"provenance":"provable","source":"m","entries":[{routes}]}},
"csrf":{{"provenance":"declared","source":"c","exempt_paths":[],"entries":[]}},
"security_headers":{{"provenance":"declared","source":"c","entries":[]}},
"authorization_policies":{{"provenance":"provable","source":"m","runtime_caveat":"x","entries":[]}}
}},"excluded":[]}}"#
))
}
fn route(path: &str, classification: &str, roles: &[&str]) -> String {
let roles = roles
.iter()
.map(|r| format!("\"{r}\""))
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"path":"{path}","method":"GET","name":"h","classification":"{classification}",
"roles":[{roles}],"scopes":[],"policy":false,"source":"user","provenance":"provable"}}"#
)
}
#[test]
fn an_unchanged_posture_neither_blocks_nor_says_anything() {
let base = manifest(&route("/admin", "gated", &["admin"]));
let head = manifest(&route("/admin", "gated", &["admin"]));
let e = evaluate(Some(&base), &head, "");
assert!(!e.blocked());
assert_eq!(e.exit_code(), 0);
assert!(e.findings.is_empty());
assert_eq!(render(&e, "markdown").unwrap(), "");
}
#[test]
fn a_widening_blocks_until_the_matching_marker_is_present() {
let base = manifest(&route("/admin", "gated", &["admin"]));
let head = manifest(&route("/admin", "public", &[]));
let blocked = evaluate(Some(&base), &head, "");
assert!(blocked.blocked());
assert_eq!(blocked.exit_code(), EXIT_BLOCKED);
let comment = format!("{} {}", ack::ACK_PHRASE, ack::short(&blocked.ack_digest));
let acknowledged = evaluate(Some(&base), &head, &comment);
assert!(!acknowledged.blocked());
assert_eq!(acknowledged.exit_code(), 0);
assert!(acknowledged.acknowledged.is_some());
}
#[test]
fn re_widening_after_an_acknowledgment_blocks_again() {
let base = manifest(&route("/admin", "gated", &["admin"]));
let first = manifest(&route("/admin", "public", &[]));
let comment = format!(
"{} {}",
ack::ACK_PHRASE,
ack::short(&evaluate(Some(&base), &first, "").ack_digest)
);
assert!(!evaluate(Some(&base), &first, &comment).blocked());
let second = manifest(&format!(
"{},{}",
route("/admin", "public", &[]),
route("/internal", "public", &[])
));
assert!(
evaluate(Some(&base), &second, &comment).blocked(),
"a new widening must not inherit the old acknowledgment"
);
}
#[test]
fn an_unrelated_push_keeps_the_acknowledgment_valid() {
let base = manifest(&route("/admin", "gated", &["admin"]));
let first = manifest(&route("/admin", "public", &[]));
let comment = format!(
"{} {}",
ack::ACK_PHRASE,
ack::short(&evaluate(Some(&base), &first, "").ack_digest)
);
let later = manifest(&format!(
"{},{}",
route("/admin", "public", &[]),
route("/reports", "gated", &["admin"])
));
let e = evaluate(Some(&base), &later, &comment);
assert!(!e.blocked());
assert!(
e.acknowledged.is_some(),
"it passes *because* the acknowledgment still matches, not because \
the widening stopped being detected"
);
assert_eq!(diff::widening(&e.findings).len(), 1);
}
#[test]
fn narrowing_only_changes_never_block() {
let base = manifest(&route("/admin", "public", &[]));
let head = manifest(&route("/admin", "gated", &["admin"]));
let e = evaluate(Some(&base), &head, "");
assert!(!e.blocked());
assert!(!e.findings.is_empty(), "but they are still reported");
}
#[test]
fn with_no_baseline_nothing_blocks() {
let head = manifest(&route("/admin", "public", &[]));
let e = evaluate(None, &head, "");
assert!(e.bootstrap);
assert!(!e.blocked());
assert!(e.findings.is_empty());
assert_eq!(e.exit_code(), 0);
}
#[test]
fn the_head_posture_digest_is_the_manifests_own() {
let head = manifest(&route("/admin", "gated", &["admin"]));
let e = evaluate(None, &head, "");
assert_eq!(e.head_posture_digest, head.posture_digest());
}
#[test]
fn an_unknown_format_is_a_usage_error_not_a_silent_fallback() {
let head = manifest(&route("/a", "gated", &["admin"]));
let e = evaluate(None, &head, "");
assert!(render(&e, "markdwon").is_err());
}
#[test]
fn an_unreadable_base_is_a_usage_error_even_with_allow_missing_base() {
let dir = tempfile::tempdir().unwrap();
let head = dir.path().join("head.json");
std::fs::write(
&head,
serde_json::to_string(&serde_json::json!({
"schema_version": 3,
"dimensions": {"routes": {"entries": [
{"path": "/a", "method": "GET", "classification": "public"}
]}}
}))
.unwrap(),
)
.unwrap();
let base = dir.path().join("base-dir");
std::fs::create_dir(&base).unwrap();
let code = run_diff(&DiffOptions {
base: base.to_str().unwrap(),
head: head.to_str().unwrap(),
format: "json",
output: None,
acks: &[],
ack_file: None,
allow_missing_base: true,
});
assert_eq!(code, EXIT_USAGE, "must not be scored as a clean bootstrap");
}
#[test]
fn an_empty_head_manifest_against_a_populated_base_is_a_usage_error() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base.json");
let head = dir.path().join("head.json");
std::fs::write(
&base,
serde_json::to_string(&serde_json::json!({
"schema_version": 3,
"dimensions": {"routes": {"entries": [
{"path": "/a", "method": "GET", "classification": "gated", "roles": ["admin"]}
]}}
}))
.unwrap(),
)
.unwrap();
std::fs::write(&head, r#"{"schema_version":3}"#).unwrap();
let code = run_diff(&DiffOptions {
base: base.to_str().unwrap(),
head: head.to_str().unwrap(),
format: "json",
output: None,
acks: &[],
ack_file: None,
allow_missing_base: true,
});
assert_eq!(code, EXIT_USAGE);
}
#[test]
fn the_output_flag_writes_the_report_and_writes_nothing_when_clean() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("base.json");
let head = dir.path().join("head.json");
let report = dir.path().join("report.md");
let write = |path: &std::path::Path, classification: &str, roles: &str| {
std::fs::write(
path,
format!(
r#"{{"schema_version":3,"dimensions":{{"routes":{{"entries":[
{{"path":"/admin","method":"GET","classification":"{classification}","roles":[{roles}]}}]}}}}}}"#
),
)
.unwrap();
};
write(&base, "gated", "\"admin\"");
write(&head, "public", "");
let opts = DiffOptions {
base: base.to_str().unwrap(),
head: head.to_str().unwrap(),
format: "markdown",
output: Some(report.to_str().unwrap()),
acks: &[],
ack_file: None,
allow_missing_base: false,
};
assert_eq!(run_diff(&opts), EXIT_BLOCKED);
let written = std::fs::read_to_string(&report).unwrap();
assert!(written.contains("/admin"), "{written}");
assert!(written.contains(render::COMMENT_MARKER), "{written}");
write(&head, "gated", "\"admin\"");
assert_eq!(run_diff(&opts), 0);
assert_eq!(
std::fs::read_to_string(&report).unwrap(),
"",
"a clean run leaves nothing for the workflow to post"
);
}
#[test]
fn an_acknowledgment_for_a_different_digest_does_not_unblock() {
let base = manifest(&route("/admin", "gated", &["admin"]));
let head = manifest(&route("/admin", "public", &[]));
let e = evaluate(Some(&base), &head, "/ack-posture 0000000000000000");
assert!(e.blocked());
assert!(e.acknowledged.is_none());
}
}