Repository navigation
feat(report): add --output-format kyaml #248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
| // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> | ||
|
|
||
| //! KYAML output: the estate's YAML dialect (`hyperpolymath/standards` | ||
| //! `3-practice/YAML-POLICY.adoc`, D280/D307). | ||
| //! | ||
| //! The emitter follows the KYAML encoder in mikefarah `yq` v4 (the tool | ||
| //! behind the estate's `scripts/kyaml-format.sh`): flow style, two-space | ||
| //! indent, a trailing comma after every entry, bare keys only when they | ||
| //! match `^[A-Za-z_][A-Za-z0-9_-]*$`, and every string double-quoted. Its | ||
| //! output is a fixed point of `yq -p yaml -o kyaml`, which is what | ||
| //! `kyaml-format.sh --check` tests. | ||
| //! | ||
| //! The data is the same as the JCS report's: keys in JCS order (UTF-16 | ||
| //! code units) and numbers in JCS form, so a KYAML report and a JSON report | ||
| //! of the same scan differ only in syntax. | ||
| //! | ||
| //! One deliberate difference from `yq`: YAML's non-printable characters | ||
| //! (U+007F to U+009F, U+FFFE, U+FFFF) are escaped as `\uXXXX`. `yq` writes | ||
| //! them raw, which YAML 1.2 does not allow, and U+0085 (NEL) written raw | ||
| //! reads back as a line break, corrupting the string. Strings holding one | ||
| //! are therefore not a `yq` fixed point; `kyaml-format.sh` refuses such a | ||
| //! file instead of judging it. | ||
|
|
||
| use anyhow::{Context, Result}; | ||
| use serde::Serialize; | ||
| use serde_json::Value; | ||
|
|
||
| const INDENT: usize = 2; | ||
|
|
||
| /// Serialise `value` as a KYAML document followed by one line feed. | ||
| /// | ||
| /// Fails if `value` is not I-JSON, for example an integer beyond ±2^53. | ||
| pub fn to_string<T: Serialize + ?Sized>(value: &T) -> Result<String> { | ||
| let tree = serde_json::to_value(value).context("serializing to JSON")?; | ||
| let mut out = String::new(); | ||
| write_node(&mut out, &tree, 0)?; | ||
| out.push('\n'); | ||
| Ok(out) | ||
| } | ||
|
|
||
| fn write_node(out: &mut String, node: &Value, indent: usize) -> Result<()> { | ||
| match node { | ||
| Value::Null => out.push_str("null"), | ||
| Value::Bool(true) => out.push_str("true"), | ||
| Value::Bool(false) => out.push_str("false"), | ||
| Value::Number(_) => { | ||
| out.push_str(&ijson_jcs::to_jcs_string(node).context("canonicalizing number (JCS)")?) | ||
| } | ||
| Value::String(text) => write_quoted(out, text), | ||
| Value::Array(items) if items.is_empty() => out.push_str("[]"), | ||
| Value::Array(items) => { | ||
| out.push_str("[\n"); | ||
| for item in items { | ||
| pad(out, indent + INDENT); | ||
| write_node(out, item, indent + INDENT)?; | ||
| out.push_str(",\n"); | ||
| } | ||
| pad(out, indent); | ||
| out.push(']'); | ||
| } | ||
| Value::Object(map) if map.is_empty() => out.push_str("{}"), | ||
| Value::Object(map) => { | ||
| let mut entries: Vec<_> = map.iter().collect(); | ||
| entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16())); | ||
| out.push_str("{\n"); | ||
| for (key, value) in entries { | ||
| pad(out, indent + INDENT); | ||
| if is_bare_key(key) { | ||
| out.push_str(key); | ||
| } else { | ||
| write_quoted(out, key); | ||
| } | ||
| out.push_str(": "); | ||
| write_node(out, value, indent + INDENT)?; | ||
| out.push_str(",\n"); | ||
| } | ||
| pad(out, indent); | ||
| out.push('}'); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn pad(out: &mut String, indent: usize) { | ||
| out.extend(std::iter::repeat_n(' ', indent)); | ||
| } | ||
|
|
||
| /// `^[A-Za-z_][A-Za-z0-9_-]*$`, as in `yq`. | ||
| fn is_bare_key(key: &str) -> bool { | ||
| let mut chars = key.chars(); | ||
| chars | ||
| .next() | ||
| .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') | ||
| && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') | ||
| } | ||
|
|
||
| fn write_quoted(out: &mut String, text: &str) { | ||
| out.push('"'); | ||
| for c in text.chars() { | ||
| match c { | ||
| '\\' => out.push_str("\\\\"), | ||
| '"' => out.push_str("\\\""), | ||
| '\n' => out.push_str("\\n"), | ||
| '\r' => out.push_str("\\r"), | ||
| '\t' => out.push_str("\\t"), | ||
| '\0'..='\u{1F}' | '\u{7F}'..='\u{9F}' | '\u{FFFE}' | '\u{FFFF}' => { | ||
| out.push_str(&format!("\\u{:04X}", c as u32)) | ||
| } | ||
| _ => out.push(c), | ||
| } | ||
| } | ||
| out.push('"'); | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use serde_json::json; | ||
|
|
||
| #[test] | ||
| fn matches_yq_layout() { | ||
| // Expected text is `yq -p json -o kyaml` (v4.54.1) output for the | ||
| // JCS form of `value`. | ||
| let value = json!({ | ||
| "a": {}, "b": [], "c": [{}], "d": null, "e": false, | ||
| "k-1": 1, "_x": 2, "9a": 3, "é": 4, "a.b": 5, "": 6, | ||
| "nested": {"list": [1, 0.5, 1e21, "s"]}, | ||
| }); | ||
| assert_eq!( | ||
| to_string(&value).unwrap(), | ||
| "{\n \"\": 6,\n \"9a\": 3,\n _x: 2,\n a: {},\n \"a.b\": 5,\n b: [],\n c: [\n {},\n ],\n d: null,\n e: false,\n k-1: 1,\n nested: {\n list: [\n 1,\n 0.5,\n 1e+21,\n \"s\",\n ],\n },\n \"é\": 4,\n}\n" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn top_level_scalars_and_lists() { | ||
| assert_eq!(to_string("top").unwrap(), "\"top\"\n"); | ||
| assert_eq!(to_string(&[1]).unwrap(), "[\n 1,\n]\n"); | ||
| assert_eq!(to_string(&()).unwrap(), "null\n"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn escapes_like_yq_for_c0_controls() { | ||
| assert_eq!( | ||
| to_string("\u{1}\u{1F}\t\r\n\\\"").unwrap(), | ||
| "\"\\u0001\\u001F\\t\\r\\n\\\\\\\"\"\n" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn escapes_non_printables_that_yq_writes_raw() { | ||
| let text = "a\u{7F}\u{85}\u{9F}\u{FFFE}\u{FFFF}\u{A0}\u{2028}z"; | ||
| let out = to_string(text).unwrap(); | ||
| assert_eq!( | ||
| out, | ||
| "\"a\\u007F\\u0085\\u009F\\uFFFE\\uFFFF\u{A0}\u{2028}z\"\n" | ||
| ); | ||
| // ...and they read back intact, NEL included. | ||
| let back: String = crate::yaml::from_str(&out).unwrap(); | ||
| assert_eq!(back, text); | ||
| } | ||
|
|
||
| #[test] | ||
| fn keys_follow_jcs_order() { | ||
| // UTF-16 order puts U+10000 (a surrogate pair) before U+FF61; | ||
| // UTF-8 byte order would not. | ||
| let value = json!({"\u{FF61}": 1, "\u{10000}": 2}); | ||
| let out = to_string(&value).unwrap(); | ||
| assert!(out.find('\u{10000}') < out.find('\u{FF61}'), "{out}"); | ||
| let jcs = crate::json::to_canonical_string(&value).unwrap(); | ||
| assert!(jcs.find('\u{10000}') < jcs.find('\u{FF61}'), "{jcs}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn numbers_are_jcs_and_i_json() { | ||
| assert_eq!( | ||
| to_string(&[-0.0, 1e-7, 0.1]).unwrap(), | ||
| "[\n 0,\n 1e-7,\n 0.1,\n]\n" | ||
| ); | ||
| assert!(to_string(&(1u64 << 53)).is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn real_report_is_byte_identical_to_yq() { | ||
| // `assail-report.kyaml` is `yq -p json -o kyaml` (v4.54.1) of the | ||
| // JCS report next to it, and passes standards' | ||
| // `kyaml-format.sh --check`. | ||
| let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/kyaml"); | ||
| let json = std::fs::read_to_string(dir.join("assail-report.json")).unwrap(); | ||
| let golden = std::fs::read_to_string(dir.join("assail-report.kyaml")).unwrap(); | ||
| let report: serde_json::Value = serde_json::from_str(&json).unwrap(); | ||
| assert_eq!(to_string(&report).unwrap(), golden); | ||
| let back: crate::types::AssailReport = crate::yaml::from_report_str(&golden).unwrap(); | ||
| assert_eq!(crate::json::to_canonical_string(&back).unwrap(), json); | ||
| } | ||
|
|
||
| #[test] | ||
| fn reports_round_trip_through_the_yaml_loader() { | ||
| let report = crate::assail::analyze( | ||
| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/vulnerable_program.rs"), | ||
| ) | ||
| .expect("analysis should succeed"); | ||
| let out = to_string(&report).unwrap(); | ||
| let back: crate::types::AssailReport = crate::yaml::from_report_str(&out).unwrap(); | ||
| assert_eq!( | ||
| serde_json::to_value(&back).unwrap(), | ||
| serde_json::to_value(&report).unwrap() | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,8 @@ use clap::ValueEnum; | |
| pub enum ReportOutputFormat { | ||
| Json, | ||
| Yaml, | ||
| /// KYAML, the estate YAML dialect (D280/D307). | ||
| Kyaml, | ||
|
Comment on lines
+15
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding this Useful? React with 👍 / 👎. |
||
| Nickel, | ||
| Sarif, | ||
| } | ||
|
|
@@ -21,6 +23,7 @@ impl ReportOutputFormat { | |
| match value.to_ascii_lowercase().as_str() { | ||
| "json" => Some(ReportOutputFormat::Json), | ||
| "yaml" | "yml" => Some(ReportOutputFormat::Yaml), | ||
| "kyaml" => Some(ReportOutputFormat::Kyaml), | ||
| "nickel" | "ncl" => Some(ReportOutputFormat::Nickel), | ||
| "sarif" => Some(ReportOutputFormat::Sarif), | ||
| _ => None, | ||
|
|
@@ -31,6 +34,7 @@ impl ReportOutputFormat { | |
| match self { | ||
| ReportOutputFormat::Json => "json", | ||
| ReportOutputFormat::Yaml => "yaml", | ||
| ReportOutputFormat::Kyaml => "kyaml", | ||
| ReportOutputFormat::Nickel => "ncl", | ||
| ReportOutputFormat::Sarif => "sarif", | ||
| } | ||
|
|
@@ -40,6 +44,7 @@ impl ReportOutputFormat { | |
| match self { | ||
| ReportOutputFormat::Json => crate::json::to_canonical_string(report), | ||
| ReportOutputFormat::Yaml => Ok(crate::yaml::to_string(report)?), | ||
| ReportOutputFormat::Kyaml => crate::kyaml::to_string(report), | ||
| // Nickel output is a compact projection for config-centric consumers. | ||
| ReportOutputFormat::Nickel => Ok(format_report_as_nickel(report)), | ||
| // SARIF output targets GitHub Security tab and other SARIF consumers. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"coverage":{"analyzed_files":2,"candidate_files":2,"inputs":[{"analysis":"language and cross-language heuristics; no parser coverage claim","content_blake3":"a442016141b9cd6cfcd89dc5a22c56ff3855ed4a5b7cf9d4d8482c604a7cd8f2","kind":"file","language":"rust","path":"examples/attack_harness.rs","reason":null,"status":"analyzed"},{"analysis":"language and cross-language heuristics; no parser coverage claim","content_blake3":"d989ad2f477e667e9bb477c1749959f0b6b7f0d7494ce5623a03ea0d13badc16","kind":"file","language":"rust","path":"examples/vulnerable_program.rs","reason":null,"status":"analyzed"}],"mode":"static_assail","scope":"Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Unknown source/config formats are unsupported; other extensions are outside declared scope. Non-PHP adapters are heuristic, not parse or dataflow proofs. No dynamic attacks executed.","status":"complete"},"dependency_graph":{"edges":[{"from":"attack_harness.rs","relation":"shared_dir:","to":"vulnerable_program.rs","weight":1}]},"file_statistics":[{"allocation_sites":4,"file_path":"attack_harness.rs","io_operations":2,"lines":163,"panic_sites":0,"threading_constructs":0,"unsafe_blocks":0,"unwrap_calls":1},{"allocation_sites":2,"file_path":"vulnerable_program.rs","io_operations":0,"lines":119,"panic_sites":0,"threading_constructs":1,"unsafe_blocks":2,"unwrap_calls":9}],"frameworks":[],"language":"rust","program_path":"examples","recommended_attacks":["memory","disk","concurrency","cpu"],"schema_version":"2.6","statistics":{"allocation_sites":6,"io_operations":2,"panic_sites":0,"threading_constructs":1,"total_lines":282,"unsafe_blocks":2,"unwrap_calls":10},"taint_matrix":{"rows":[{"files":["attack_harness.rs","vulnerable_program.rs"],"frameworks":[],"relation":"PanicPath->Disk","severity_value":2.5,"sink_axis":"disk","source_category":"PanicPath"},{"files":["vulnerable_program.rs"],"frameworks":[],"relation":"UnsafeCode->Concurrency","severity_value":3.5,"sink_axis":"concurrency","source_category":"UnsafeCode"},{"files":["vulnerable_program.rs"],"frameworks":[],"relation":"UnsafeCode->Memory","severity_value":3.5,"sink_axis":"memory","source_category":"UnsafeCode"},{"files":["attack_harness.rs","vulnerable_program.rs"],"frameworks":[],"relation":"PanicPath->Memory","severity_value":2.5,"sink_axis":"memory","source_category":"PanicPath"}]},"weak_points":[{"category":"PanicPath","description":"0 panic sites and 1 unwrap/expect calls in attack_harness.rs","file":"attack_harness.rs","location":"attack_harness.rs","recommended_attack":["memory","disk"],"severity":"Medium","test_context":"production"},{"category":"UnsafeCode","description":"2 unsafe blocks in vulnerable_program.rs","file":"vulnerable_program.rs","location":"vulnerable_program.rs","recommended_attack":["memory","concurrency"],"severity":"High","test_context":"production"},{"category":"PanicPath","description":"0 panic sites and 9 unwrap/expect calls in vulnerable_program.rs","file":"vulnerable_program.rs","location":"vulnerable_program.rs","recommended_attack":["memory","disk"],"severity":"Medium","test_context":"production"}]} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
to_stringreceives an object with a key such as"null","Null", or"NULL", this predicate emits it bare, so YAML resolves the key as null rather than preserving the JSON string key. The configured serde-saphyr loader explicitly treats case-insensitive plainnullscalars as null (deserialization contract), causing a round trip intoHashMap<String, _>to fail or changing the key's type for untyped consumers. Exclude null-like spellings from bare keys so the advertised JSON/KYAML data equivalence holds.Useful? React with 👍 / 👎.