mirror of
https://github.com/s-b-repo/rustsploit
synced 2026-06-27 09:54:12 +00:00
8a8b7e8e5f
Modules: - Refactor wpair (WhisperPair / CVE-2025-36911) into a module directory (crypto/db/gatt/protocol + model_ids.csv), replacing the single wpair.rs - Add h3c_bmc suite (firewall/ipmi-hash/kvm-probe/redfish dumps), fortinet SSLVPN/magic-token, sharepoint_doc_harvest, m365_activesync_spray, ldap_anon_spray, php/git/tapestry webapp modules, h3c_cloudos_api_enum, etc. Framework hardening / feature fixes: - Background jobs capture module output (drainer) + wire progress counters (ScanCounters); tenant jobs record terminal status on their own JobManager - SSRF: ssrf_gate distinguishes SSRF_BLOCKED vs TARGET_ERROR; fail-closed REST/WS/MCP dispatch; tenant-tagged PQ lifecycle events - Scheduler: exclusions + service-port precheck on CIDR/file fan-out; accurate considered/skipped counters; cancellation-safe permit acquisition - Mass scan works via shell/CLI/API/MCP (added MCP run_module background-job option so long scans don't hit the tool-call timeout) - Bruteforce workers re-scope OUTPUT_BUFFER; cross-product combo default - Stores: cred dedup/cap/scrub, loot scrub, workspace protocol scrub, spool explicit-owner + write_raw (no-newline spooling) - Error handling: removed let _/discarded-error patterns; failures are distinguishable from negatives - Removed dead check/CheckResult subsystem, OutputAccumulator, and the no-op --output-format flag; CredEntry.valid wired (creds invalidate/validate); typed HostUp/ServiceDetected events - Add .gitignore (build artifacts, local config, engagement data) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
13 KiB
Rust
343 lines
13 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use colored::*;
|
|
use once_cell::sync::Lazy;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::RwLock;
|
|
|
|
/// Type of credential stored.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum CredType {
|
|
Password,
|
|
Hash,
|
|
Key,
|
|
Token,
|
|
}
|
|
|
|
impl std::fmt::Display for CredType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
CredType::Password => write!(f, "password"),
|
|
CredType::Hash => write!(f, "hash"),
|
|
CredType::Key => write!(f, "key"),
|
|
CredType::Token => write!(f, "token"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A single credential entry.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CredEntry {
|
|
pub id: String,
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub service: String,
|
|
pub username: String,
|
|
pub secret: String,
|
|
pub cred_type: CredType,
|
|
pub source_module: String,
|
|
pub timestamp: String,
|
|
pub valid: bool,
|
|
}
|
|
|
|
/// Parameters for adding a new credential.
|
|
pub struct NewCred<'a> {
|
|
pub host: &'a str,
|
|
pub port: u16,
|
|
pub service: &'a str,
|
|
pub username: &'a str,
|
|
pub secret: &'a str,
|
|
pub cred_type: CredType,
|
|
pub source_module: &'a str,
|
|
}
|
|
|
|
/// Credential store backed by a JSON file.
|
|
pub struct CredStore {
|
|
entries: RwLock<Vec<CredEntry>>,
|
|
file_path: PathBuf,
|
|
}
|
|
|
|
impl CredStore {
|
|
fn new() -> Self {
|
|
let base = home::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".rustsploit");
|
|
Self::with_base_dir(base)
|
|
}
|
|
|
|
/// Create a credential store under a custom base directory.
|
|
pub(crate) fn with_base_dir(base: PathBuf) -> Self {
|
|
let file_path = base.join("creds.json");
|
|
|
|
// Synchronous load at init time
|
|
let entries = if file_path.exists() {
|
|
match std::fs::read_to_string(&file_path) {
|
|
Ok(contents) => match serde_json::from_str(&contents) {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
eprintln!("[!] Warning: creds.json is corrupted ({}). Starting fresh.", e);
|
|
let backup = file_path.with_extension("json.bak");
|
|
if let Err(e) = std::fs::copy(&file_path, &backup) {
|
|
eprintln!("[!] Failed to backup corrupted creds.json: {}", e);
|
|
}
|
|
Vec::new()
|
|
}
|
|
},
|
|
Err(e) => {
|
|
// Read errors here are typically EACCES/EIO — back up the
|
|
// unreadable file so we don't silently overwrite a user's
|
|
// creds with an empty store.
|
|
eprintln!("[!] Failed to read creds.json: {}. Preserving original.", e);
|
|
let backup = file_path.with_extension("json.unreadable");
|
|
if let Err(e) = std::fs::rename(&file_path, &backup) {
|
|
eprintln!("[!] Rename failed: {}", e);
|
|
}
|
|
Vec::new()
|
|
}
|
|
}
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Self {
|
|
entries: RwLock::new(entries),
|
|
file_path,
|
|
}
|
|
}
|
|
|
|
/// Maximum length for credential fields to prevent memory abuse.
|
|
const MAX_FIELD_LEN: usize = 4096;
|
|
|
|
/// Runaway-entry cap (mirrors loot's `MAX_LOOT_ENTRIES`) so a buggy loop
|
|
/// can't grow the store unbounded.
|
|
const MAX_CRED_ENTRIES: usize = 100_000;
|
|
|
|
/// Add a credential. Returns `Some(id)` on success, `None` on validation failure.
|
|
pub async fn add(&self, cred: NewCred<'_>) -> Option<String> {
|
|
// Input validation. Bound EVERY free-text field, not just host/secret/
|
|
// username — `service` and `source_module` can carry attacker-influenced
|
|
// banner text, and leaving them unbounded defeats the memory-abuse guard
|
|
// this cap is here to provide.
|
|
if cred.host.is_empty() || cred.host.len() > Self::MAX_FIELD_LEN {
|
|
return None;
|
|
}
|
|
if cred.secret.len() > Self::MAX_FIELD_LEN
|
|
|| cred.username.len() > Self::MAX_FIELD_LEN
|
|
|| cred.service.len() > Self::MAX_FIELD_LEN
|
|
|| cred.source_module.len() > Self::MAX_FIELD_LEN
|
|
{
|
|
return None;
|
|
}
|
|
// Scrub control/ANSI bytes from host + non-secret metadata before
|
|
// storage so a credential captured from a hostile banner can't inject
|
|
// terminal escapes on `creds list` / CSV export — matching loot and the
|
|
// workspace. `username`/`secret` are kept verbatim so the stored
|
|
// credential stays an exact match for the real one.
|
|
let host = crate::utils::scrub_stored_text(cred.host);
|
|
let service = crate::utils::scrub_stored_text(cred.service);
|
|
let source_module = crate::utils::scrub_stored_text(cred.source_module);
|
|
let username = cred.username.to_string();
|
|
let secret = cred.secret.to_string();
|
|
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
// P1-1: hold the write lock across the disk save. Previous code
|
|
// released the lock before calling `save_locked`, letting two
|
|
// concurrent writers land disk writes in the wrong order — disk
|
|
// and in-memory state could diverge after restart.
|
|
{
|
|
let mut entries = self.entries.write().await;
|
|
// Dedup: re-running a brute against the same target shouldn't append
|
|
// an identical row each time. If the exact same credential tuple
|
|
// already exists, refresh its timestamp + valid flag and return its
|
|
// id instead of pushing a duplicate.
|
|
if let Some(existing) = entries.iter_mut().find(|e| {
|
|
e.host == host
|
|
&& e.port == cred.port
|
|
&& e.service == service
|
|
&& e.username == username
|
|
&& e.secret == secret
|
|
}) {
|
|
existing.timestamp = now;
|
|
existing.valid = true;
|
|
let id = existing.id.clone();
|
|
let snapshot = entries.clone();
|
|
self.save_locked(&snapshot).await;
|
|
return Some(id);
|
|
}
|
|
// Runaway-entry cap, re-checked under the write lock.
|
|
if entries.len() >= Self::MAX_CRED_ENTRIES {
|
|
eprintln!(
|
|
"[!] Credential store full ({} entries) — clear creds before adding more",
|
|
Self::MAX_CRED_ENTRIES
|
|
);
|
|
return None;
|
|
}
|
|
let id = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
|
|
entries.push(CredEntry {
|
|
id: id.clone(),
|
|
host,
|
|
port: cred.port,
|
|
service,
|
|
username,
|
|
secret,
|
|
cred_type: cred.cred_type,
|
|
source_module,
|
|
timestamp: now,
|
|
valid: true,
|
|
});
|
|
let snapshot = entries.clone();
|
|
self.save_locked(&snapshot).await;
|
|
Some(id)
|
|
}
|
|
}
|
|
|
|
/// List all credentials.
|
|
pub async fn list(&self) -> Vec<CredEntry> {
|
|
self.entries.read().await.clone()
|
|
}
|
|
|
|
/// Search credentials by host.
|
|
pub async fn search(&self, query: &str) -> Vec<CredEntry> {
|
|
let q = query.to_lowercase();
|
|
self.list().await.into_iter().filter(|e| {
|
|
e.host.to_lowercase().contains(&q)
|
|
|| e.service.to_lowercase().contains(&q)
|
|
|| e.username.to_lowercase().contains(&q)
|
|
}).collect()
|
|
}
|
|
|
|
/// Delete a credential by ID.
|
|
pub async fn delete(&self, id: &str) -> bool {
|
|
// P1-1: same lock-during-save fix as `add()`. Holding the write
|
|
// lock through the disk write keeps in-memory and on-disk state in
|
|
// agreement under concurrent ops.
|
|
let mut entries = self.entries.write().await;
|
|
let before = entries.len();
|
|
entries.retain(|e| e.id != id);
|
|
if entries.len() < before {
|
|
let snapshot = entries.clone();
|
|
self.save_locked(&snapshot).await;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Mark a credential valid/invalid by id (e.g. after a re-test shows the
|
|
/// password was rotated). Returns true if the id was found. This is what
|
|
/// makes the valid/invalid column shown by `display`/CSV actually settable.
|
|
pub async fn set_valid(&self, id: &str, valid: bool) -> bool {
|
|
let mut entries = self.entries.write().await;
|
|
if let Some(e) = entries.iter_mut().find(|e| e.id == id) {
|
|
e.valid = valid;
|
|
let snapshot = entries.clone();
|
|
self.save_locked(&snapshot).await;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Clear all credentials.
|
|
pub async fn clear(&self) {
|
|
let mut entries = self.entries.write().await;
|
|
entries.clear();
|
|
self.save_locked(&entries).await;
|
|
}
|
|
|
|
async fn save_locked(&self, entries: &[CredEntry]) {
|
|
if let Some(parent) = self.file_path.parent()
|
|
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
|
|
eprintln!("[!] Failed to create creds directory: {}", e);
|
|
return;
|
|
}
|
|
let tmp = self.file_path.with_extension("json.tmp");
|
|
let json = match serde_json::to_string_pretty(entries) {
|
|
Ok(j) => j,
|
|
Err(e) => {
|
|
eprintln!("[!] Failed to serialize credentials: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
{
|
|
// P1-7: O_NOFOLLOW + create-with-mode atomically. A symlink raced
|
|
// into place between create and open would otherwise redirect the
|
|
// write to a privileged path with the credentials' contents.
|
|
let mut opts = tokio::fs::OpenOptions::new();
|
|
opts.write(true).create(true).truncate(true).mode(0o600);
|
|
#[cfg(unix)]
|
|
{
|
|
opts.custom_flags(libc::O_NOFOLLOW);
|
|
}
|
|
let file = match opts.open(&tmp).await {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
eprintln!("[!] Failed to write temp creds file: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
let mut file = file;
|
|
if let Err(e) = tokio::io::AsyncWriteExt::write_all(&mut file, json.as_bytes()).await {
|
|
eprintln!("[!] Failed to write temp creds file: {}", e);
|
|
return;
|
|
}
|
|
if let Err(e) = file.sync_all().await {
|
|
eprintln!("[!] Failed to fsync temp creds file: {}", e);
|
|
return;
|
|
}
|
|
}
|
|
if let Err(e) = tokio::fs::rename(&tmp, &self.file_path).await {
|
|
eprintln!("[!] Failed to rename creds file: {}", e);
|
|
}
|
|
}
|
|
|
|
/// Display all credentials in a formatted table.
|
|
pub async fn display(&self) {
|
|
let entries = self.list().await;
|
|
if entries.is_empty() {
|
|
println!("{}", "No credentials stored. Use 'creds add' to add one.".dimmed());
|
|
return;
|
|
}
|
|
println!();
|
|
println!("{}", format!("Credentials ({} total):", entries.len()).bold().underline());
|
|
println!();
|
|
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
|
"ID".bold(), "Host".bold(), "Port".bold(), "Service".bold(),
|
|
"Username".bold(), "Secret".bold(), "Type".bold(), "Valid".bold());
|
|
println!(" {}", "-".repeat(100).dimmed());
|
|
for e in &entries {
|
|
let valid_str = if e.valid { "yes".green() } else { "no".red() };
|
|
println!(" {:<10} {:<18} {:<6} {:<10} {:<16} {:<20} {:<10} {}",
|
|
e.id, e.host, e.port, e.service, e.username,
|
|
if e.secret.chars().count() > 18 { format!("{}...", e.secret.chars().take(15).collect::<String>()) } else { e.secret.clone() },
|
|
e.cred_type, valid_str);
|
|
}
|
|
println!();
|
|
}
|
|
|
|
/// Display search results.
|
|
pub fn display_results(&self, results: &[CredEntry]) {
|
|
if results.is_empty() {
|
|
println!("{}", "No matching credentials found.".dimmed());
|
|
return;
|
|
}
|
|
println!();
|
|
println!("{}", format!("Found {} credential(s):", results.len()).bold());
|
|
println!();
|
|
for e in results {
|
|
println!(" [{}] {}@{}:{} ({}) - {} [{}]",
|
|
e.id.yellow(), e.username.green(), e.host, e.port,
|
|
e.service, e.cred_type,
|
|
if e.valid { "valid".green() } else { "invalid".red() });
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
|
|
pub static CRED_STORE: Lazy<CredStore> = Lazy::new(CredStore::new);
|
|
|
|
/// Convenience function for modules to store a discovered credential.
|
|
/// Routes through the tenant registry when in a tenant context (API mode).
|
|
pub async fn store_credential(cred: NewCred<'_>) -> Option<String> {
|
|
let s = crate::tenant::resolve();
|
|
s.cred_store().add(cred).await
|
|
}
|