Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

=== [Unreleased]

==== Added — KYAML report output

* `+--output-format kyaml+` writes reports in KYAML, the estate YAML dialect
(`+crate::kyaml+`). The output is a fixed point of `+yq -p yaml -o kyaml+`
(v4.54.1), so it passes standards' `+scripts/kyaml-format.sh --check+`. On the
reports checked it is also byte-identical to `+yq -p json -o kyaml+` run on the
JCS report. (Reading JSON, `+yq+` reformats some floats, `+1e-7+` to `+1e-07+`;
the KYAML report keeps the JCS form, which `+yq+` preserves.)
* Keys and numbers are in JCS order and form, so the KYAML and JSON reports of
a scan hold the same data.
* One deliberate difference from `+yq+`: the YAML non-printables U+007F to U+009F,
U+FFFE and U+FFFF are escaped as `+\uXXXX+`. `+yq+` writes them raw, and U+0085
(NEL) then reads back as a line break. `+kyaml-format.sh+` refuses such a file
rather than judging it, because `+yq+`'s own rewrite would change the data.
* `+README.adoc+` now lists the real `+--output-format+` values; it named
`+markdown+` and `+text+`, which never existed.

==== Added — `.kyaml` input (estate YAML policy D280/D307)

* Attack profiles, ambush timelines, amuck specs, `+diff+` reports and
Expand Down
2 changes: 1 addition & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Globally applied flags let automation pipelines control verbosity, output format
| Flag | Default | Description
| `--report-view` | `Accordion` | Controls the presentation mode (`Accordion`, `Summary`, `Timeline`) that the CLI writes and that PanLL mirrors.
| `--expand-sections` | `false` | Expands extra detail blocks in the formatted report.
| `--output-format` | `json` | Chooses how the artifact is serialized (`json`, `markdown`, or `text`); the CLI reuses this when it writes PanLL exports or temporal diffs.
| `--output-format` | `json` | Chooses how saved reports are serialized: `json` (I-JSON/JCS), `yaml`, `kyaml` (the estate YAML dialect, byte-compatible with `yq -o kyaml`), `nickel` or `sarif`. The CLI reuses this when it writes PanLL exports or temporal diffs. `assail` always writes JSON.
| `--pivot` | `false` | Rotates the narrative axis for boards that expect events-first storytelling.
| `--store` | unset | Directory where generated reports, exports, and artifacts should land.
| `--quiet` | `false` | Suppresses informational log lines for automation.
Expand Down
211 changes: 211 additions & 0 deletions src/kyaml.rs
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 == '-')
Comment on lines +94 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote YAML null-like mapping keys

When to_string receives 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 plain null scalars as null (deserialization contract), causing a round trip into HashMap<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 👍 / 👎.

}

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()
);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub mod i18n;
pub mod jit_context;
pub mod json;
pub mod kanren;
pub mod kyaml;
pub mod mass_panic;
pub mod notify;
pub mod panll;
Expand Down
5 changes: 5 additions & 0 deletions src/report/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate checked-in completions for kyaml

Adding this ValueEnum variant without regenerating the tracked shell completions leaves every checked-in Bash, Zsh, Fish, and Nushell completion list at json yaml nickel sarif. Users who install the repository-provided completion files therefore cannot discover or complete the new kyaml value; rerun the Justfile completion-generation target and commit its outputs.

Useful? React with 👍 / 👎.

Nickel,
Sarif,
}
Expand All @@ -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,
Expand All @@ -31,6 +34,7 @@ impl ReportOutputFormat {
match self {
ReportOutputFormat::Json => "json",
ReportOutputFormat::Yaml => "yaml",
ReportOutputFormat::Kyaml => "kyaml",
ReportOutputFormat::Nickel => "ncl",
ReportOutputFormat::Sarif => "sarif",
}
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/kyaml/assail-report.json
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"}]}
Loading
Loading