From 71538227f9c537e2f6af946b35c67b7249d18fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Botelho=20Gon=C3=A7alves?= Date: Sun, 9 Aug 2026 12:06:41 +0100 Subject: [PATCH 1/3] fix(ghosthound): accept directories for output --- Cargo.lock | 1 + README.md | 16 ++ crates/ghosthound/Cargo.toml | 3 + crates/ghosthound/src/main.rs | 32 ++-- crates/ghosthound/src/output.rs | 321 ++++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 crates/ghosthound/src/output.rs diff --git a/Cargo.lock b/Cargo.lock index 344f10d..203f591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,6 +544,7 @@ dependencies = [ "native-tls", "rpassword", "serde_json", + "tempfile", "tokio", "zeroize", ] diff --git a/README.md b/README.md index df42a8a..f247f5e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,22 @@ you are explicitly authorized to test. ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o tombstones.json ``` +`--output`/`-o` accepts either an explicit file path or an existing directory. When given a +directory, GhostHound writes the default `ghosthound_output.json` inside it: + +```bash +# Write to an explicit path +ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o /tmp/tombstones.json + +# Write /tmp/ghosthound_output.json +ghosthound -d ghost.local --dc-ip 10.0.0.10 -u alice -o /tmp +``` + +Parent directories are not created automatically. GhostHound validates the destination before +connecting to LDAP and reports a contextual error if the parent is missing or is not a directory. +On Unix, a newly-created or reused output file is restricted to mode `0600`; on Windows, its access +is controlled by filesystem ACLs. + Password resolution order: `--password` (avoid — visible via `ps`/`/proc//cmdline` and shell history), then the `LDAP_PASSWORD` environment variable, then an interactive prompt if neither is set. diff --git a/crates/ghosthound/Cargo.toml b/crates/ghosthound/Cargo.toml index 2931914..d7b1d9c 100644 --- a/crates/ghosthound/Cargo.toml +++ b/crates/ghosthound/Cargo.toml @@ -21,3 +21,6 @@ rpassword = "7.4" serde_json = "1.0.150" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros"] } zeroize = "1.8" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index 177d5b8..d10c239 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -5,6 +5,8 @@ #![forbid(unsafe_code)] +mod output; + use ad_tombstone::{ check_reanimate_rights, check_recycle_bin_enabled, fetch_tombstones, resolve_object_sid, with_timeout, @@ -14,14 +16,10 @@ use clap::Parser; use ldap3::{LdapConnAsync, LdapConnSettings}; use serde_json::json; use std::collections::HashMap; -use std::fs::File; -use std::io::Write; +use std::path::PathBuf; use std::time::Duration; use zeroize::Zeroizing; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; - #[derive(Parser)] #[command( version, @@ -65,14 +63,16 @@ struct Args { #[arg(long, default_value_t = 30)] timeout_secs: u64, - /// Output JSON file name - #[arg(short, long, default_value = "ghosthound_output.json")] - output: String, + /// Output JSON file path or existing directory + #[arg(short, long, default_value = output::DEFAULT_OUTPUT_FILENAME)] + output: PathBuf, } #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); + let output_path = output::resolve_output_path(&args.output)?; + println!("[*] Output destination: {}", output_path.display()); if args.ntlm { return Err("NTLM authentication is currently disabled due to upstream dependencies (sspi-rs) failing strict security checks on the latest compiler toolchain. Please use Simple Bind.".into()); @@ -267,16 +267,12 @@ async fn main() -> Result<(), Box> { let graph_data = builder.build("GhostHound"); let json_output = serde_json::to_string_pretty(&graph_data)?; - // The output documents privileged principals and attack paths, so restrict it to the owner - // rather than relying on the process umask (typically 644, world-readable) on Unix. - let mut open_options = File::options(); - open_options.write(true).create(true).truncate(true); - #[cfg(unix)] - open_options.mode(0o600); - let mut file = open_options.open(&args.output)?; - file.write_all(json_output.as_bytes())?; - - println!("[+] Successfully wrote graph data to {}", args.output); + output::write_output(&output_path, json_output.as_bytes())?; + + println!( + "[+] Successfully wrote graph data to {}", + output_path.display() + ); ldap.unbind().await?; Ok(()) diff --git a/crates/ghosthound/src/output.rs b/crates/ghosthound/src/output.rs new file mode 100644 index 0000000..88e4fe2 --- /dev/null +++ b/crates/ghosthound/src/output.rs @@ -0,0 +1,321 @@ +use std::error::Error; +use std::ffi::OsStr; +use std::fmt; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + +pub(crate) const DEFAULT_OUTPUT_FILENAME: &str = "ghosthound_output.json"; + +/// An output-path or output-write failure with enough context to be useful from the CLI. +/// +/// `main` returns a boxed error, whose termination implementation formats errors with `Debug`. +/// Keeping `Debug` identical to `Display` avoids exposing an opaque raw `io::Error` structure to +/// the user while retaining the original error as a source. +pub(crate) struct OutputError { + message: String, + source: Option, +} + +impl OutputError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + source: None, + } + } + + fn with_source(message: impl Into, source: std::io::Error) -> Self { + Self { + message: message.into(), + source: Some(source), + } + } +} + +impl fmt::Display for OutputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message)?; + if let Some(source) = &self.source { + write!(formatter, ": {source}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OutputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl Error for OutputError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_ref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +/// Resolves a user-supplied output argument and validates its directory structure. +/// +/// An existing directory receives [`DEFAULT_OUTPUT_FILENAME`]; every other path is treated as an +/// explicit file path. A trailing separator is retained as a directory hint so a missing `dir/` +/// is rejected instead of being reinterpreted as a file named `dir`. Directory checks use +/// [`Path::is_dir`] and [`fs::metadata`], which deliberately follow symlinks. This function does +/// not create or truncate anything, so it is safe to call before LDAP collection begins. +pub(crate) fn resolve_output_path(requested: &Path) -> Result { + if requested.as_os_str().is_empty() { + return Err(OutputError::new("output path must not be empty")); + } + + if requested.is_dir() { + return Ok(requested.join(DEFAULT_OUTPUT_FILENAME)); + } + + if has_trailing_separator(requested.as_os_str()) { + return Err(OutputError::new(format!( + "output directory '{}' does not exist or is not a directory", + requested.display() + ))); + } + + let parent = requested + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => Ok(requested.to_path_buf()), + Ok(_) => Err(OutputError::new(format!( + "output parent path '{}' is not a directory", + parent.display() + ))), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + Err(OutputError::with_source( + format!( + "output parent directory '{}' does not exist", + parent.display() + ), + source, + )) + } + Err(source) => Err(OutputError::with_source( + format!( + "failed to inspect output parent directory '{}'", + parent.display() + ), + source, + )), + } +} + +/// Writes the completed JSON payload to a previously resolved output path. +/// +/// On Unix, permissions are set to `0600` before an existing file is truncated so data never +/// passes through a world-readable state. Windows retains the ACL behavior of the host filesystem. +pub(crate) fn write_output(path: &Path, contents: &[u8]) -> Result<(), OutputError> { + let mut open_options = File::options(); + open_options.write(true).create(true); + #[cfg(unix)] + open_options.mode(0o600); + + let mut file = open_options.open(path).map_err(|source| { + OutputError::with_source( + format!("failed to open output file '{}'", path.display()), + source, + ) + })?; + + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|source| { + OutputError::with_source( + format!( + "failed to restrict output file '{}' to mode 0600", + path.display() + ), + source, + ) + })?; + + file.set_len(0).map_err(|source| { + OutputError::with_source( + format!("failed to truncate output file '{}'", path.display()), + source, + ) + })?; + file.write_all(contents).map_err(|source| { + OutputError::with_source( + format!("failed to write output file '{}'", path.display()), + source, + ) + })?; + + Ok(()) +} + +#[cfg(unix)] +fn has_trailing_separator(value: &OsStr) -> bool { + value.as_bytes().last() == Some(&b'/') +} + +#[cfg(windows)] +fn has_trailing_separator(value: &OsStr) -> bool { + matches!(value.encode_wide().last(), Some(value) if value == u16::from(b'/') || value == u16::from(b'\\')) +} + +#[cfg(not(any(unix, windows)))] +fn has_trailing_separator(value: &OsStr) -> bool { + value + .to_string_lossy() + .chars() + .last() + .is_some_and(std::path::is_separator) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn with_trailing_separator(path: &Path) -> PathBuf { + let mut value = OsString::from(path.as_os_str()); + value.push(std::path::MAIN_SEPARATOR_STR); + PathBuf::from(value) + } + + #[test] + fn keeps_an_explicit_file_path_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let requested = temp.path().join("report.json"); + + assert_eq!(resolve_output_path(&requested).unwrap(), requested); + } + + #[test] + fn keeps_a_bare_filename_relative_to_the_current_directory() { + let requested = Path::new("report.json"); + + assert_eq!( + resolve_output_path(requested).unwrap(), + PathBuf::from("report.json") + ); + } + + #[test] + fn appends_the_default_filename_to_an_existing_directory() { + let temp = tempfile::tempdir().unwrap(); + + assert_eq!( + resolve_output_path(temp.path()).unwrap(), + temp.path().join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn accepts_an_existing_directory_with_a_trailing_separator() { + let temp = tempfile::tempdir().unwrap(); + let requested = with_trailing_separator(temp.path()); + + assert_eq!( + resolve_output_path(&requested).unwrap(), + temp.path().join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn rejects_a_missing_directory_indicated_by_a_trailing_separator() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing"); + let requested = with_trailing_separator(&missing); + + let error = resolve_output_path(&requested).unwrap_err(); + + assert!(error.to_string().contains("output directory")); + assert!(error.to_string().contains("does not exist")); + assert!(!missing.exists()); + } + + #[test] + fn rejects_a_file_path_whose_parent_does_not_exist() { + let temp = tempfile::tempdir().unwrap(); + let requested = temp.path().join("missing").join("report.json"); + + let error = resolve_output_path(&requested).unwrap_err(); + + assert!(error.to_string().contains("output parent directory")); + assert!(error.to_string().contains("does not exist")); + } + + #[test] + fn rejects_a_parent_path_that_is_a_file() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("not-a-directory"); + fs::write(&parent, b"existing file").unwrap(); + + let error = resolve_output_path(&parent.join("report.json")).unwrap_err(); + + assert!(error.to_string().contains("is not a directory")); + } + + #[cfg(unix)] + #[test] + fn follows_a_symlink_to_an_existing_directory() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + fs::create_dir(&target).unwrap(); + symlink(&target, &link).unwrap(); + + assert_eq!( + resolve_output_path(&link).unwrap(), + link.join(DEFAULT_OUTPUT_FILENAME) + ); + } + + #[test] + fn writes_and_truncates_the_resolved_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + fs::write(&path, b"a longer previous report").unwrap(); + + write_output(&path, b"{}\n").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"{}\n"); + } + + #[test] + fn creates_a_new_output_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + + write_output(&path, b"{}\n").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"{}\n"); + } + + #[cfg(unix)] + #[test] + fn restricts_an_existing_file_to_owner_only_before_reuse() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("report.json"); + fs::write(&path, b"old report").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + write_output(&path, b"new report").unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} From 8755221a68243c8670734385a3e4f6f7b6b65e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Botelho=20Gon=C3=A7alves?= Date: Sun, 9 Aug 2026 15:23:37 +0100 Subject: [PATCH 2/3] refactor(ghosthound): defer output path resolution Move output path resolution and display after the NTLM check. This prevents unnecessary work and output if NTLM authentication is disabled, as the program would exit early. --- crates/ghosthound/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index d10c239..59b646a 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -71,13 +71,14 @@ struct Args { #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); - let output_path = output::resolve_output_path(&args.output)?; - println!("[*] Output destination: {}", output_path.display()); if args.ntlm { return Err("NTLM authentication is currently disabled due to upstream dependencies (sspi-rs) failing strict security checks on the latest compiler toolchain. Please use Simple Bind.".into()); } + let output_path = output::resolve_output_path(&args.output)?; + println!("[*] Output destination: {}", output_path.display()); + let password = Zeroizing::new(match args.password { Some(p) => p, None => rpassword::prompt_password("Password: ")?, From a4db4c285748958fa9c4d30e8a42c6d0c0571c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Victor=20Botelho=20Gon=C3=A7alves?= Date: Sun, 9 Aug 2026 17:09:06 +0100 Subject: [PATCH 3/3] chore: add contributing guide, citation file, and project metadata --- CITATION.cff | 28 ++++++ CONTRIBUTING.md | 127 +++++++++++++++++++++++++ README.md | 17 ++++ crates/ad-secdesc/Cargo.toml | 2 + crates/ad-tombstone/Cargo.toml | 2 + crates/bloodhound-opengraph/Cargo.toml | 2 + crates/ghosthound/Cargo.toml | 2 + crates/ghosthound/src/main.rs | 19 ++++ 8 files changed, 199 insertions(+) create mode 100644 CITATION.cff create mode 100644 CONTRIBUTING.md diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..ec5bfb0 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,28 @@ +cff-version: 1.2.0 +title: GhostHound +message: >- + If you use GhostHound in research, an engagement writeup, a conference talk, or another tool, + please credit it using the metadata below. +type: software +authors: + - given-names: JVBotelho + alias: JVBotelho + website: "https://glitchedcat.com" +abstract: >- + A BloodHound OpenGraph extension that enumerates Active Directory tombstones in + CN=Deleted Objects over LDAP, determines which principals hold the Reanimate-Tombstones + right, and emits an OpenGraph JSON payload so tombstone reanimation appears as a + first-class attack path in BloodHound CE. +keywords: + - active-directory + - bloodhound + - opengraph + - tombstone + - attack-path + - red-team +license: + - MIT + - Apache-2.0 +repository-code: "https://github.com/JVBotelho/ghosthound" +url: "https://glitchedcat.com" +version: 0.1.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ad7531e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ +# Contributing to GhostHound + +Thank you for contributing to GhostHound. Before making a change, review the architecture +decision records in [`docs/adr/`](docs/adr/) for the design context and constraints behind the +workspace. + +## Reporting a vulnerability + +**Do not open a public issue for a security vulnerability**, including one in the binary parsers +(`ad-secdesc`, `ad-tombstone`) or the LDAP client. Those parsers consume untrusted bytes from a +domain controller, so a parsing defect may be exploitable. Follow the private disclosure process in +[`SECURITY.md`](SECURITY.md) instead. + +Ordinary bugs, crashes on your own lab data, and feature requests belong in public issues as usual. + +## Before you start + +- For anything beyond a typo, comment on the issue before you begin so work isn't duplicated. If no + issue exists, open one first and describe the approach you have in mind. +- Every pull request needs a review from a code owner (see [`.github/CODEOWNERS`](.github/CODEOWNERS)). +- Any change to behaviour needs a test that fails without it. Prefer tests that run without a live + domain controller; see the existing unit tests for how parsing and CLI logic are covered offline. +- Keep pull requests focused on one concern. If a change turns out to need an unrelated fix, say so + in the pull request rather than folding it in silently. + +## Toolchain and local checks + +The project uses the stable Rust toolchain and requires the `rustfmt` and `clippy` components. Every +crate is on `edition = "2024"`, so **Rust 1.85 or newer** is required; older stable toolchains fail +with an edition error rather than a clear message. If you manage Rust with `rustup`, install the +toolchain and components with: + +```bash +rustup toolchain install stable --component rustfmt clippy +``` + +From the repository root, build and test the default workspace members with: + +```bash +cargo build +cargo test --all-targets --all-features +``` + +CI enforces formatting and treats every Clippy warning as an error. Run the same checks before +submitting a change: + +```bash +cargo fmt -- --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +Run `cargo fmt` to apply formatting fixes. + +### Platforms + +The tool is used on both Linux and Windows, and `crates/ghosthound/src/output.rs` has +platform-specific path handling behind `#[cfg(unix)]` and `#[cfg(windows)]`. A local +`cargo test` therefore runs a different subset of tests depending on your operating system — +if you touch path or output handling, note which platform you tested on in the pull request. + +## Dependencies and supply chain + +The supply-chain posture is deliberate and enforced in CI; see +[`docs/adr/0005-supply-chain-openssf-posture.md`](docs/adr/0005-supply-chain-openssf-posture.md). +Two rules catch contributors by surprise: + +- **New dependencies are scrutinised.** `cargo-deny` bans copyleft licences in the published crates + and fails on known advisories, and the OSV Scanner runs against `Cargo.lock`. Justify any new + dependency in the pull request, and check it locally before pushing: + + ```bash + cargo install cargo-deny --locked + cargo deny check + ``` + +- **GitHub Actions are pinned by commit SHA**, not by tag. If you edit a workflow, keep that style + and leave the human-readable version in a trailing comment, as the existing entries do. + +## Contribution licensing + +Contributions are accepted under the same terms as the project: dual-licensed +[MIT](LICENSE-MIT) OR [Apache-2.0](LICENSE-APACHE). By submitting a pull request you agree that your +contribution may be distributed under both licences. + +## Default workspace members and the oracle + +The workspace's `default-members` deliberately exclude `ad-secdesc-oracle`. As a result, commands +such as `cargo build` and `cargo test --all-targets --all-features` run the four published crates +(three libraries plus the `ghosthound` binary) but skip the oracle crate. + +`ad-secdesc-oracle` is an unpublished differential-test harness that depends on a GPL-3.0 +implementation. It is kept separate so GPL code never enters the dependency graph of the +permissively licensed, published crates. See +[`docs/adr/0003-security-descriptor-parsing-strategy.md`](docs/adr/0003-security-descriptor-parsing-strategy.md) +for the full rationale. + +When a change affects security-descriptor parsing or the oracle harness, run its tests explicitly: + +```bash +cargo test -p ad-secdesc-oracle +``` + +## Fuzzing + +The fuzz targets require the nightly Rust toolchain and `cargo-fuzz`: + +```bash +rustup toolchain install nightly +cargo install cargo-fuzz --locked +``` + +Run the `ad-secdesc` target from its crate directory: + +```bash +cd crates/ad-secdesc +cargo +nightly fuzz run fuzz_target_1 +``` + +Run the `ad-tombstone` target from its crate directory: + +```bash +cd crates/ad-tombstone +cargo +nightly fuzz run fuzz_target_1 +``` + +Stop a local fuzzing campaign with `Ctrl-C`. For a CI-style 60-second smoke test, append +`-- -max_total_time=60` to either command. diff --git a/README.md b/README.md index f247f5e..8042700 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ you are explicitly authorized to test. - Network reachability to a Domain Controller on LDAP (389) or LDAPS (636). - BloodHound CE v8+ if you want to import the resulting graph (collection and JSON output work standalone without it). +- To build from source: Rust 1.85 or newer (every crate is on `edition = "2024"`). See + [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full toolchain and local check setup. ## Usage @@ -122,6 +124,21 @@ This project prioritizes high-confidence security from day 1 (`docs/adr/0005-sup - `cargo fuzz` for panic safety in parsing raw binary descriptors. - No `unsafe` in foundational libraries. +## Credits + +Built by **JVBotelho** — [glitchedcat.com](https://glitchedcat.com). + +GhostHound is MIT OR Apache-2.0 licensed, so you are free to use, modify, and embed it without +asking. If it turns up something useful on an engagement or in research, a link back is appreciated +but never required. `CITATION.cff` has ready-made citation metadata (GitHub renders it as the +"Cite this repository" button). + +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the toolchain, the local checks CI enforces, and the +dependency rules. Security vulnerabilities go through the private process in +[`SECURITY.md`](SECURITY.md), not public issues. + ## Design Rationale The `docs/adr/` directory records the decisions behind this design (language choice, auth scope, diff --git a/crates/ad-secdesc/Cargo.toml b/crates/ad-secdesc/Cargo.toml index 85dc7ac..33f1fc6 100644 --- a/crates/ad-secdesc/Cargo.toml +++ b/crates/ad-secdesc/Cargo.toml @@ -4,7 +4,9 @@ name = "ad-secdesc" version = "0.1.2" edition = "2024" description = "Binary parser for Active Directory ntSecurityDescriptor and ACEs" +authors = ["JVBotelho"] repository = "https://github.com/JVBotelho/ghosthound" +homepage = "https://glitchedcat.com" [dependencies] byteorder = "1.5.0" diff --git a/crates/ad-tombstone/Cargo.toml b/crates/ad-tombstone/Cargo.toml index 5e58495..b12c1d2 100644 --- a/crates/ad-tombstone/Cargo.toml +++ b/crates/ad-tombstone/Cargo.toml @@ -4,7 +4,9 @@ name = "ad-tombstone" version = "0.1.2" edition = "2024" description = "Active Directory Tombstone and Recycle Bin enumerator" +authors = ["JVBotelho"] repository = "https://github.com/JVBotelho/ghosthound" +homepage = "https://glitchedcat.com" [dependencies] ad-secdesc = { version = "0.1.2", path = "../ad-secdesc" } diff --git a/crates/bloodhound-opengraph/Cargo.toml b/crates/bloodhound-opengraph/Cargo.toml index ca061c3..0e584ec 100644 --- a/crates/bloodhound-opengraph/Cargo.toml +++ b/crates/bloodhound-opengraph/Cargo.toml @@ -4,7 +4,9 @@ name = "bloodhound-opengraph" version = "0.1.2" edition = "2024" description = "BloodHound OpenGraph JSON builder and schema structures" +authors = ["JVBotelho"] repository = "https://github.com/JVBotelho/ghosthound" +homepage = "https://glitchedcat.com" [dependencies] serde = { version = "1.0.228", features = ["derive"] } diff --git a/crates/ghosthound/Cargo.toml b/crates/ghosthound/Cargo.toml index d7b1d9c..5723ef5 100644 --- a/crates/ghosthound/Cargo.toml +++ b/crates/ghosthound/Cargo.toml @@ -4,7 +4,9 @@ name = "ghosthound" version = "0.1.2" edition = "2024" description = "GhostHound CLI: Orchestrates AD Tombstone discovery and OpenGraph generation" +authors = ["JVBotelho"] repository = "https://github.com/JVBotelho/ghosthound" +homepage = "https://glitchedcat.com" readme = "../../README.md" [dependencies] diff --git a/crates/ghosthound/src/main.rs b/crates/ghosthound/src/main.rs index 59b646a..27d0e01 100644 --- a/crates/ghosthound/src/main.rs +++ b/crates/ghosthound/src/main.rs @@ -26,6 +26,10 @@ use zeroize::Zeroizing; about = "GhostHound CLI - Discover AD Tombstones and Reanimation Paths" )] struct Args { + /// Suppress the startup banner (useful when wrapping GhostHound in a script) + #[arg(long, default_value_t = false)] + no_banner: bool, + /// Domain name (e.g. ghost.local) #[arg(short, long)] domain: String, @@ -68,10 +72,25 @@ struct Args { output: PathBuf, } +/// Prints the startup banner on stderr, keeping stdout free for output a caller may want to capture +/// or pipe. +fn print_banner() { + eprintln!( + "GhostHound v{} - AD tombstone reanimation paths for BloodHound", + env!("CARGO_PKG_VERSION") + ); + eprintln!("by JVBotelho - https://glitchedcat.com"); + eprintln!(); +} + #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); + if !args.no_banner { + print_banner(); + } + if args.ntlm { return Err("NTLM authentication is currently disabled due to upstream dependencies (sspi-rs) failing strict security checks on the latest compiler toolchain. Please use Simple Bind.".into()); }