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
26 changes: 14 additions & 12 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

=== [Unreleased]

==== Added — `+attest verify --report+` and `+--require-bound+`

* `+attest verify --report <REPORT>+` rehashes the report file, streaming it
through a fixed-size buffer, and fails if it
does not match the seal's `+report_hash+`. Without it, verification only checks
that the stored digests agree with each other, so a report edited after sealing
still verified.
* `+attest verify --require-bound+` fails on legacy seals (no `+hash_scheme+`),
whose intent and evidence cannot be rehashed.
* The output now says whether the report was checked. Library callers use
`+VerifyOptions+` (carrying the report digest from `+sha256_reader+`) with
`+verify_attestation_file_with+` / `+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`.
==== Added — `+assail --output-format kyaml+` writes a KYAML copy

* With `+--output-format kyaml+` and `+--output report.json+`, `+assail+` writes
`+report.kyaml+` beside the JSON report. The JSON stays the report of record: it
is what `+--attest+` seals. `+--output+` must end in `+.json+` (any letter case),
and no two of the files written (report, copy, and the `+--attest+` sidecar)
may be the same file: a symbolic link at any of their paths, or two existing
paths that are hard links to one file, is refused before anything is written.
So is a path that exists but is not a regular file (a directory, say) or that
cannot be looked up, such as a derived name over the filesystem's length limit.
These checks run before the writes, so a link swapped in between a check and
its write is not caught, and a crash can leave a partial file: the planned
generation-directory publish is meant to close both.
Other formats are unchanged for `+assail+`.

==== Fixed — KYAML keys holding a line separator

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ sha2 = "0.11"
hex = "0.4"
getrandom = "0.4"
walkdir = "2.5"
toml_edit = { version = "0.25", default-features = false, features = ["parse"] }
same-file = "1.0" # assail: refuse a KYAML copy that aliases the sealed JSON report
toml_edit = { version = "0.23", default-features = false, features = ["parse"], optional = true }
dirs = "7.0"
ed25519-dalek = { version = "3.0", optional = true }
ureq = { version = "3.3", optional = true }
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 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.
| `--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 the JSON report (the one `--attest` seals); with `kyaml` it also writes a `.kyaml` copy beside it, so `--output` must name a `.json` file.
| `--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
132 changes: 123 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,108 @@ fn parse_axis(value: &str) -> Option<AttackAxis> {
}
}

/// Where `assail --attest` writes its attestation sidecar: beside the report
/// at `output` as `<stem>.attestation.json`, or in the working directory when
/// the report goes to stdout. The writer and the alias check below both use
/// this, so the two cannot drift apart.
fn attestation_sidecar_path(output: Option<&Path>) -> PathBuf {
match output {
Some(out) => {
let stem = out.file_stem().unwrap_or_default().to_string_lossy();
let parent = out.parent().unwrap_or(Path::new("."));
parent.join(format!("{}.attestation.json", stem))
}
None => PathBuf::from("assail-report.attestation.json"),
}
}

/// Where `assail --output-format kyaml` writes its KYAML copy of the report
/// at `out`: beside it, with a `.kyaml` extension. `sidecar` is the
/// attestation sidecar when `--attest` is on.
///
/// The JSON at `out` is the report of record and the attestation seals its
/// bytes, so no two of the files written here may be the same file. They
/// share a directory and are named `S.json`, `S.kyaml` and
/// `S.attestation.json`, so they can alias in only three ways, and each is
/// refused before anything is written:
/// - equal names ignoring letter case: `--output` must end in `.json` (in any
/// case), which keeps the three names distinct on case-insensitive
/// filesystems too;
/// - a symbolic link at any of the paths, which could point at another, even
/// a dangling link that an earlier write would bring into existence;
/// - two existing paths that are already one file, such as hard links.
///
/// These checks run before the writes, so a link swapped in between a check
/// and its write (a time-of-check/time-of-use race) still slips past them.
/// Closing that race, and keeping a crash from leaving a partial report, is
/// the job of the generation-directory publish planned as a follow-up.
fn kyaml_copy_path(out: &Path, sidecar: Option<&Path>) -> Result<PathBuf> {
let path = out.with_extension("kyaml");
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
let named_json = out
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
anyhow::ensure!(
named_json,
"with --output-format kyaml, assail --output names the JSON report and \
must end in .json; the KYAML copy is written beside it as {}",
path.display()
);
let mut outputs = vec![("report", out), ("KYAML copy", path.as_path())];
if let Some(sidecar) = sidecar {
outputs.push(("attestation sidecar", sidecar));
}
// Look every path up before writing anything. Only "not found" counts as
// absent: any other failure (a name over the filesystem's length limit,
// a permission error) would make a later write fail after an earlier one
// had already landed, so it stops the run here instead.
let mut exists = Vec::with_capacity(outputs.len());
for &(role, candidate) in &outputs {
match fs::symlink_metadata(candidate) {
Ok(meta) => {
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
anyhow::ensure!(
!meta.file_type().is_symlink(),
"refusing to write the {role} through the symbolic link {} \
(it could alias another output)",
candidate.display()
);
// A directory or other special entry can't be written as a
// file, so the write would fail after earlier ones landed.
anyhow::ensure!(
meta.is_file(),
"the {role} path {} exists and is not a regular file; \
nothing was written",
candidate.display()
);
exists.push(true);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => exists.push(false),
Err(err) => anyhow::bail!(
"cannot use {} as the {role}: {err}; nothing was written",
candidate.display()
),
}
}
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
// No path is a symlink now, so only a hard link can join two of them.
for (i, &(role_a, a)) in outputs.iter().enumerate() {
for (j, &(role_b, b)) in outputs.iter().enumerate().skip(i + 1) {
if exists[i] && exists[j] {
anyhow::ensure!(
!same_file::is_same_file(a, b).with_context(|| format!(
"comparing {} with {}",
a.display(),
b.display()
))?,
"the {role_b} {} is the same file as the {role_a} {}; \
refusing to overwrite it",
b.display(),
a.display()
);
}
}
}
Ok(path)
}

fn default_amuck_report_path() -> PathBuf {
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
PathBuf::from(format!("reports/amuck-{}.json", ts))
Expand Down Expand Up @@ -1494,13 +1596,33 @@ fn run_main() -> Result<()> {
// exactly these bytes, so any verifier can recompute them.
let report_json = panic_attack::json::to_canonical_string(&report)?;

// `--output-format kyaml` adds a KYAML copy beside the JSON report.
// The JSON stays the report of record: the seal covers its bytes.
let sidecar_path = chain_builder
.is_some()
.then(|| attestation_sidecar_path(output.as_deref()));
let kyaml_path = match &output {
Some(out) if cli.output_format == ReportOutputFormat::Kyaml => {
Some(kyaml_copy_path(out, sidecar_path.as_deref())?)
}
_ => None,
};

if let Some(output_path) = &output {
fs::write(output_path, &report_json)?;
qprintln!(
cli.quiet || headless,
"Report saved to: {}",
output_path.display()
);
if let Some(kyaml_path) = &kyaml_path {
fs::write(kyaml_path, panic_attack::kyaml::to_string(&report)?)?;

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 Validate companion writability before replacing the report

When an existing report.kyaml (or attestation sidecar) is a regular file but is not writable by the caller—for example, mode 0444—the symlink_metadata preflight accepts it because metadata lookup does not test write access. This write then fails only after report.json has already been replaced at line 1603, leaving the new JSON paired with a stale companion despite the preflight's all-or-nothing intent; validate or open every companion for writing before modifying the report.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed. This PR merged before the finding was handled, so the fix is #274. The preflight now opens every output for writing, without truncating it; an absent output is created and then removed. The kernel makes the decision, so ACLs count too. That PR adds two tests, a read-only copy and an unwritable directory. Run unprivileged, both fail without the fix and pass with it.


Generated by Claude Code

qprintln!(
cli.quiet || headless,
"KYAML copy saved to: {}",
kyaml_path.display()
);
}
} else if cli.quiet || headless {
// Machine-readable mode: emit JSON to stdout for pipeline consumers.
// `--headless` makes this explicit for CI callers that cannot pass --quiet.
Expand All @@ -1519,18 +1641,10 @@ fn run_main() -> Result<()> {
}

// Seal and write attestation sidecar
if let Some(builder) = chain_builder {
if let (Some(builder), Some(sidecar_path)) = (chain_builder, sidecar_path) {
let envelope = builder.seal(report_json.as_bytes(), signing_key.as_deref())?;
let attestation_json = panic_attack::json::to_canonical_string(&envelope)?;

let sidecar_path = if let Some(out) = &output {
let stem = out.file_stem().unwrap_or_default().to_string_lossy();
let parent = out.parent().unwrap_or(Path::new("."));
parent.join(format!("{}.attestation.json", stem))
} else {
PathBuf::from("assail-report.attestation.json")
};

fs::write(&sidecar_path, attestation_json)?;
qprintln!(
cli.quiet || headless,
Expand Down
Loading
Loading