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
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ rustinel capture [--output <PATH>]

| Option | Description |
| --- | --- |
| `--output <PATH>` | Recording path. Defaults to &lt;capture.directory&gt;/rustinel-capture-&lt;UTC timestamp&gt;.ndjson. |
| `--output <PATH>` | Recording path. Defaults to &lt;capture.directory&gt;/rustinel-capture-&lt;UTC timestamp&gt;.ndjson. Refuses to start if the recording or its manifest already exists. |

## `rustinel replay`

Expand All @@ -89,7 +89,7 @@ rustinel replay <RECORDING> [--output <PATH>]
| Option | Description |
| --- | --- |
| `<RECORDING>` | Recording to replay, as written by `rustinel capture`. Its manifest sidecar must sit next to it. |
| `--output <PATH>` | Write ECS NDJSON alerts here instead of a console alert list. |
| `--output <PATH>` | Write ECS NDJSON alerts here instead of a console alert list. Refuses an existing file, and the recording itself. |

## `rustinel doctor`

Expand Down
1 change: 1 addition & 0 deletions docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ On Linux and macOS, SIGINT, SIGTERM, and SIGHUP all finalize the recording and i
It stays `incomplete` when capture was killed, the writer fell behind (`lost`), or the OS dropped events first (`source_lost`).
Replay rejects incomplete recordings, and any payload whose checksum does not match.
Never edit a recording by hand.
Capture never overwrites: it refuses to start when either file already exists, naming the one in the way, and leaves both untouched.

## Replay results

Expand Down
4 changes: 4 additions & 0 deletions docs/replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ rustinel replay ~/captures/session.ndjson --config candidate.toml
rustinel replay ~/captures/session.ndjson --output results.ndjson
```

`--output` never overwrites.
Replay refuses an existing file, and an output that aliases the recording or its manifest, before it reads any event.
Pick a new name or remove the old report yourself.

## What replay skips

- YARA and hash indicators, because a recording holds events, not files.
Expand Down
67 changes: 64 additions & 3 deletions src/capture/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ use crate::capture::manifest::{
};
use crate::models::CanonicalEvent;
use crate::sensor::Platform;
use crate::utils::fs::{ensure_output_directory, open_output_file};
use crate::utils::fs::{
create_new_output_file, ensure_output_directory, open_output_file, path_is_occupied,
};
use crate::utils::{now_timestamp_string, LogRateLimiter};

/// Target name for capture operational logs.
Expand Down Expand Up @@ -169,7 +171,12 @@ impl CaptureRecorder {
let file = create_payload_file(&payload_path)?;

let manifest = CaptureManifest::started(&payload_path, platform, now_timestamp_string());
write_manifest(&manifest_path, &manifest)?;
if let Err(err) = create_manifest_file(&manifest_path, &manifest) {
// Only the payload this call just created is removed; the
// manifest path was never ours when creation failed.
let _ = std::fs::remove_file(&payload_path);
return Err(err);
}

let counters = Arc::new(CaptureCounters::default());
let (tx, rx) = mpsc::channel::<String>(QUEUE_CAPACITY);
Expand Down Expand Up @@ -294,10 +301,39 @@ fn create_payload_file(payload_path: &Path) -> anyhow::Result<File> {
)
})?;

open_output_file(payload_path, false)
// Both halves of the pair are checked before either is created, so a
// payload-only or manifest-only collision leaves everything untouched.
let manifest_path = manifest_path_for(payload_path);
for existing in [payload_path, manifest_path.as_path()] {
if path_is_occupied(existing)
.with_context(|| format!("failed to inspect {}", existing.display()))?
{
anyhow::bail!(
"refusing to overwrite existing file {}; choose a new --output path or remove it first",
existing.display()
);
}
}

create_new_output_file(payload_path)
.with_context(|| format!("failed to create recording {}", payload_path.display()))
}

/// Create the manifest sidecar exclusively and write its initial contents.
fn create_manifest_file(manifest_path: &Path, manifest: &CaptureManifest) -> anyhow::Result<()> {
let body = serde_json::to_string_pretty(manifest).context("failed to serialize manifest")?;
let mut file = create_new_output_file(manifest_path)
.with_context(|| format!("failed to create manifest {}", manifest_path.display()))?;
let written = file.write_all(format!("{body}\n").as_bytes());
if let Err(err) = written {
drop(file);
let _ = std::fs::remove_file(manifest_path);
return Err(err)
.with_context(|| format!("failed to write manifest {}", manifest_path.display()));
}
Ok(())
}

fn write_manifest(manifest_path: &Path, manifest: &CaptureManifest) -> anyhow::Result<()> {
let body = serde_json::to_string_pretty(manifest).context("failed to serialize manifest")?;
let mut file = open_output_file(manifest_path, false)
Expand Down Expand Up @@ -641,4 +677,29 @@ mod tests {
assert!(create_payload_file(&temp.path().join("capture.ndjson")).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"safe");
}

#[test]
fn existing_payload_or_manifest_is_refused_untouched() {
let temp = tempfile::tempdir().unwrap();
let payload = temp.path().join("session.ndjson");
let manifest = manifest_path_for(&payload);

for (occupied, free) in [(&payload, &manifest), (&manifest, &payload)] {
std::fs::write(occupied, b"evidence").unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let _guard = runtime.enter();
let err = CaptureRecorder::start(payload.clone(), Platform::MacOS)
.err()
.expect("collision refused");
assert!(
err.to_string().contains(&occupied.display().to_string()),
"{err}"
);
assert_eq!(std::fs::read(occupied).unwrap(), b"evidence");
assert!(!free.exists(), "no misleading half pair");
std::fs::remove_file(occupied).unwrap();
}
}
}
6 changes: 4 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ pub enum Commands {
/// you want to record, and press Ctrl-C when the session is complete.
Capture {
/// Recording path.
/// Defaults to <capture.directory>/rustinel-capture-<UTC timestamp>.ndjson
/// Defaults to <capture.directory>/rustinel-capture-<UTC timestamp>.ndjson.
/// Refuses to start if the recording or its manifest already exists.
#[arg(long, value_name = "PATH")]
output: Option<std::path::PathBuf>,
},
Expand All @@ -88,7 +89,8 @@ pub enum Commands {
/// Its manifest sidecar must sit next to it.
#[arg(value_name = "RECORDING")]
recording: std::path::PathBuf,
/// Write ECS NDJSON alerts here instead of a console alert list
/// Write ECS NDJSON alerts here instead of a console alert list.
/// Refuses an existing file, and the recording itself.
#[arg(long, value_name = "PATH")]
output: Option<std::path::PathBuf>,
},
Expand Down
60 changes: 51 additions & 9 deletions src/replay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ use crate::engine::{Engine, EventDetectors};
use crate::ioc::IocEngine;
use crate::models::ecs::ReplayProvenance;
use crate::models::DetectionEngine;
use crate::utils::fs::restrict_file_permissions;
use crate::utils::fs::{
create_new_output_file, path_is_occupied, restrict_file_permissions, same_file,
};

pub use output::Format;
pub use recording::Recording;
Expand Down Expand Up @@ -100,6 +102,7 @@ impl Replay {

if let Some(output) = options.output.as_deref() {
ensure_output_is_not_the_alert_log(&config, output)?;
ensure_output_is_new(&recording, output)?;
}

// The recording carries its own platform, so Sigma logsource routing
Expand Down Expand Up @@ -263,15 +266,31 @@ pub fn run_cli(options: ReplayOptions) -> anyhow::Result<()> {

match options.output.as_deref() {
Some(path) => {
let mut file = std::fs::File::create(path)
// Exclusive creation also refuses a destination that appeared
// after the earlier check.
let mut file = create_new_output_file(path)
.with_context(|| format!("failed to create {}", path.display()))?;
// Replay results describe endpoint activity in the same detail as
// alerts, so they get the same owner-only permissions.
restrict_file_permissions(path)
.with_context(|| format!("failed to restrict permissions on {}", path.display()))?;
let report = replay.run(Format::Ecs, &mut file)?;
file.flush()
.with_context(|| format!("failed to write {}", path.display()))?;
// From here the file is ours, so a failed run removes it rather
// than leaving a truncated report that looks complete.
let written = (|| -> anyhow::Result<ReplayReport> {
// Replay results describe endpoint activity in the same detail
// as alerts, so they get the same owner-only permissions.
restrict_file_permissions(path).with_context(|| {
format!("failed to restrict permissions on {}", path.display())
})?;
let report = replay.run(Format::Ecs, &mut file)?;
file.flush()
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(report)
})();
let report = match written {
Ok(report) => report,
Err(err) => {
drop(file);
let _ = std::fs::remove_file(path);
return Err(err);
}
};

for line in replay.header() {
eprintln!("{line}");
Expand Down Expand Up @@ -322,6 +341,29 @@ fn ensure_output_is_not_the_alert_log(config: &AppConfig, output: &Path) -> anyh
Ok(())
}

/// Refuse an output that is, or aliases, the recording being replayed, or that
/// already exists. Replay never overwrites: results are evidence too.
fn ensure_output_is_new(recording: &Recording, output: &Path) -> anyhow::Result<()> {
for input in [recording.payload_path(), recording.manifest_path()] {
if same_file(input, output) {
bail!(
"replay output {} is the same file as the recording input {}",
output.display(),
input.display()
);
}
}
if path_is_occupied(output)
.with_context(|| format!("failed to inspect {}", output.display()))?
{
bail!(
"refusing to overwrite existing file {}; choose a new --output path or remove it first",
output.display()
);
}
Ok(())
}

fn absolute(path: &Path) -> PathBuf {
if let Ok(canonical) = std::fs::canonicalize(path) {
return canonical;
Expand Down
80 changes: 78 additions & 2 deletions src/utils/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,25 @@ pub fn ensure_output_directory(directory: &Path) -> io::Result<()> {

/// Open a regular owner-controlled file without following its final symlink.
pub fn open_output_file(path: &Path, append: bool) -> io::Result<fs::File> {
open_owned_file(path, append, false)
}

/// Create a new regular file, failing with [`io::ErrorKind::AlreadyExists`]
/// when anything, including a dangling symlink, already occupies the path.
/// The existence check and the creation are one atomic operation, so a
/// destination that appears after an earlier validation is still refused.
pub fn create_new_output_file(path: &Path) -> io::Result<fs::File> {
open_owned_file(path, false, true)
}

fn open_owned_file(path: &Path, append: bool, exclusive: bool) -> io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true);
options.write(true);
if exclusive {
options.create_new(true);
} else {
options.create(true);
}
if append {
options.append(true);
}
Expand Down Expand Up @@ -112,7 +129,7 @@ pub fn open_output_file(path: &Path, append: bool) -> io::Result<fs::File> {
}
#[cfg(windows)]
check_windows_owner(&file, path)?;
if !append {
if !append && !exclusive {
file.set_len(0)?;
}
Ok(file)
Expand Down Expand Up @@ -178,11 +195,70 @@ pub fn restrict_file_permissions(_path: &Path) -> io::Result<()> {
Ok(())
}

/// Whether anything, including a dangling symlink, occupies `path`.
pub fn path_is_occupied(path: &Path) -> io::Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}

/// Whether two paths name the same file: the same canonical path, or on Unix
/// the same device and inode, which also catches hard links.
pub fn same_file(a: &Path, b: &Path) -> bool {
if let (Ok(a), Ok(b)) = (fs::canonicalize(a), fs::canonicalize(b)) {
if a == b {
return true;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(a), Ok(b)) = (fs::metadata(a), fs::metadata(b)) {
return a.dev() == b.dev() && a.ino() == b.ino();
}
}
false
}

#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;

#[test]
fn exclusive_creation_refuses_existing_files_and_dangling_links() {
let temp = tempfile::tempdir().expect("tempdir");
let existing = temp.path().join("existing");
fs::write(&existing, b"evidence").expect("write");
let err = create_new_output_file(&existing).expect_err("existing file refused");
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read(&existing).expect("read"), b"evidence");

let link = temp.path().join("dangling");
std::os::unix::fs::symlink(temp.path().join("missing"), &link).expect("symlink");
assert!(create_new_output_file(&link).is_err());
assert!(!temp.path().join("missing").exists());
}

#[test]
fn same_file_sees_symlinks_and_hard_links() {
let temp = tempfile::tempdir().expect("tempdir");
let original = temp.path().join("a");
fs::write(&original, b"x").expect("write");
let soft = temp.path().join("soft");
let hard = temp.path().join("hard");
std::os::unix::fs::symlink(&original, &soft).expect("symlink");
fs::hard_link(&original, &hard).expect("hard link");
let other = temp.path().join("other");
fs::write(&other, b"x").expect("write");

assert!(same_file(&original, &soft));
assert!(same_file(&original, &hard));
assert!(!same_file(&original, &other));
}

#[test]
fn restricts_file_to_the_owner() {
let temp = tempfile::tempdir().expect("tempdir");
Expand Down
Loading
Loading