use std::io::Cursor;
use std::path::Path;
use forensicnomicon::catalog::{ArtifactDescriptor, ArtifactType, Decoder, HiveTarget, CATALOG};
use winreg_core::detect::HiveType;
use winreg_core::hive::Hive;
use winreg_core::key::{filetime_to_datetime, Key};
use winreg_core::value::{decode_multi_sz, decode_utf16le, Value};
use crate::path_expansion::{
expand, resolve_control_sets, Binding, ControlSetResolver, Segment, Wildcard,
};
#[derive(Debug, Clone, serde::Serialize)]
pub struct CatalogHit {
pub catalog_id: &'static str,
pub artifact_name: &'static str,
pub meaning: &'static str,
pub key_path: String,
pub value_name: Option<String>,
pub value_data: String,
pub mitre_techniques: &'static [&'static str],
pub needs_specialized_decoder: bool,
pub user: Option<UserIdentity>,
pub bindings: Vec<Binding>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct UserIdentity {
pub profile: Option<String>,
pub sid: Option<String>,
}
#[must_use]
pub fn scan(hive: &Hive<Cursor<Vec<u8>>>) -> Vec<CatalogHit> {
let Some(target) = hive_target_for(hive.detect_hive_type()) else {
return Vec::new();
};
let mut hits = Vec::new();
let Ok(root) = hive.root_key() else {
return hits;
};
let strip_hive_root = matches!(target, HiveTarget::HklmSoftware | HiveTarget::HklmSystem);
let control_sets = (target == HiveTarget::HklmSystem).then(|| resolve_control_sets(&root));
for descriptor in CATALOG.list() {
if !is_registry(descriptor.artifact_type) {
continue;
}
if descriptor.hive != Some(target) {
continue;
}
resolve_descriptor(
&root,
descriptor,
descriptor.key_path,
strip_hive_root,
control_sets.as_ref(),
&[],
&mut hits,
);
}
hits
}
pub struct UserHive {
pub identity: UserIdentity,
pub hive: Hive<Cursor<Vec<u8>>>,
}
#[must_use]
pub fn scan_users(user_hives: &[UserHive]) -> Vec<CatalogHit> {
let mut hits = Vec::new();
for uh in user_hives {
let target = hive_target_for(uh.hive.detect_hive_type());
let Ok(root) = uh.hive.root_key() else {
continue;
};
let user_binding = user_binding_for(&uh.identity);
for descriptor in CATALOG.list() {
if !is_registry(descriptor.artifact_type) {
continue;
}
let raw_path = if descriptor.hive == target {
Some(descriptor.key_path)
} else if descriptor.hive.is_none() || descriptor.hive == Some(HiveTarget::None) {
strip_user_placeholder_prefix(descriptor.key_path)
} else {
None
};
if let Some(path) = raw_path {
resolve_descriptor(
&root,
descriptor,
path,
false,
None,
user_binding.as_slice(),
&mut hits,
);
}
}
for hit in &mut hits {
if hit.user.is_none() && hit.bindings.iter().any(|b| b.kind == Wildcard::User) {
hit.user = Some(uh.identity.clone());
}
}
}
hits
}
fn user_binding_for(identity: &UserIdentity) -> Vec<Binding> {
let value = identity.sid.clone().or_else(|| identity.profile.clone());
match value {
Some(v) => vec![Binding::new(Wildcard::User, v)],
None => Vec::new(),
}
}
#[must_use]
pub fn discover_user_hives(evidence_root: &Path) -> Vec<UserHive> {
let mut out = Vec::new();
for source in winreg_discover::discover_hives(evidence_root) {
if !matches!(source.hive_type, HiveType::NtUser | HiveType::UsrClass) {
continue;
}
let Ok(hive) = Hive::from_path(&source.path) else {
continue;
};
out.push(UserHive {
identity: UserIdentity {
profile: profile_name_from_path(&source.path),
sid: None,
},
hive,
});
}
out
}
fn profile_name_from_path(path: &Path) -> Option<String> {
let components: Vec<String> = path
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
let idx = components
.iter()
.position(|c| c.eq_ignore_ascii_case("Users"))?;
components.get(idx + 1).cloned()
}
fn strip_user_placeholder_prefix(raw: &str) -> Option<&str> {
let rest = strip_prefix_ci(raw, "HKEY_USERS\\").or_else(|| strip_prefix_ci(raw, "HKU\\"))?;
let (_sid_segment, remainder) = rest.split_once('\\')?;
if remainder.is_empty() {
None
} else {
Some(remainder)
}
}
fn resolve_descriptor(
root: &Key<'_>,
descriptor: &ArtifactDescriptor,
raw_path: &str,
strip_hive_root: bool,
control_sets: Option<&ControlSetResolver>,
prefix_bindings: &[Binding],
hits: &mut Vec<CatalogHit>,
) {
let Some(segments) = template_segments(raw_path, strip_hive_root) else {
return;
};
expand(root, &segments, control_sets, &mut |bindings, path, key| {
let mut all: Vec<Binding> = prefix_bindings.to_vec();
all.extend_from_slice(bindings);
emit_key(descriptor, path, key, &all, hits);
});
}
fn emit_key(
descriptor: &ArtifactDescriptor,
key_path: &str,
key: &Key<'_>,
bindings: &[Binding],
hits: &mut Vec<CatalogHit>,
) {
if let Some(vname) = descriptor.value_name {
if let Ok(Some(val)) = key.value(vname) {
hits.push(make_hit(
descriptor,
key_path,
Some(vname.to_string()),
&val,
bindings,
));
}
} else {
let Ok(values) = key.values() else { return };
for val in values {
hits.push(make_hit(
descriptor,
key_path,
Some(val.name()),
&val,
bindings,
));
}
}
}
fn hive_target_for(hive_type: HiveType) -> Option<HiveTarget> {
match hive_type {
HiveType::Software => Some(HiveTarget::HklmSoftware),
HiveType::System => Some(HiveTarget::HklmSystem),
HiveType::NtUser => Some(HiveTarget::NtUser),
HiveType::UsrClass => Some(HiveTarget::UsrClass),
HiveType::Sam => Some(HiveTarget::HklmSam),
HiveType::Security => Some(HiveTarget::HklmSecurity),
HiveType::Amcache => Some(HiveTarget::Amcache),
_ => None,
}
}
fn is_registry(at: ArtifactType) -> bool {
matches!(at, ArtifactType::RegistryKey | ArtifactType::RegistryValue)
}
fn template_segments(raw: &str, strip_hive_root: bool) -> Option<Vec<Segment>> {
if raw.contains('%') || raw.contains('/') {
return None;
}
let normalized = normalize_path_prefixes(raw, strip_hive_root)?;
let segments: Vec<Segment> = normalized
.split('\\')
.filter(|s| !s.is_empty())
.map(parse_segment)
.collect();
if segments.is_empty() {
None
} else {
Some(segments)
}
}
fn parse_segment(seg: &str) -> Segment {
if seg.eq_ignore_ascii_case("CurrentControlSet") {
Segment::Variable(Wildcard::ControlSet, seg.to_string())
} else if seg.contains('*') {
Segment::Variable(Wildcard::Subkey, seg.to_string())
} else {
Segment::Literal(seg.to_string())
}
}
fn normalize_path_prefixes(raw: &str, strip_hive_root: bool) -> Option<String> {
let collapsed = raw.replace("\\\\", "\\");
let mut path = collapsed.as_str();
for prefix in [
"HKEY_LOCAL_MACHINE\\",
"HKEY_CURRENT_USER\\",
"HKEY_USERS\\",
"HKLM\\",
"HKCU\\",
"HKU\\",
] {
if let Some(stripped) = strip_prefix_ci(path, prefix) {
path = stripped;
}
}
if path.starts_with("HK") && path.contains('\\') && looks_like_hive_root(path) {
return None;
}
if strip_hive_root {
for prefix in ["SOFTWARE\\", "SYSTEM\\"] {
if let Some(stripped) = strip_prefix_ci(path, prefix) {
path = stripped;
}
}
}
if path.is_empty() {
None
} else {
Some(path.to_string())
}
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(&s[prefix.len()..])
} else {
None
}
}
fn looks_like_hive_root(path: &str) -> bool {
path.split('\\')
.next()
.is_some_and(|seg| seg.eq_ignore_ascii_case("HKEY_USERS") || seg.starts_with("HKEY_"))
}
fn make_hit(
descriptor: &ArtifactDescriptor,
key_path: &str,
value_name: Option<String>,
val: &Value<'_>,
bindings: &[Binding],
) -> CatalogHit {
let (value_data, specialized) = render_value(descriptor.decoder, val);
CatalogHit {
catalog_id: descriptor.id,
artifact_name: descriptor.name,
meaning: descriptor.meaning,
key_path: key_path.to_string(),
value_name,
value_data,
mitre_techniques: descriptor.mitre_techniques,
needs_specialized_decoder: specialized,
user: None,
bindings: bindings.to_vec(),
}
}
fn render_value(decoder: Decoder, val: &Value<'_>) -> (String, bool) {
let raw = val.raw_data().unwrap_or_default();
match decoder {
Decoder::Identity | Decoder::Utf16Le => (decode_utf16le(&raw), false),
Decoder::DwordLe => (val.as_u32().unwrap_or(0).to_string(), false),
Decoder::MultiSz => (decode_multi_sz(&raw).join("; "), false),
Decoder::FiletimeAt { offset } => {
let ts = raw
.get(offset..offset + 8)
.map(|b| winreg_core::bytes::le_u64(b, 0))
.and_then(filetime_to_datetime)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
(ts.unwrap_or_default(), false)
}
Decoder::Rot13Name
| Decoder::Rot13NameWithBinaryValue(_)
| Decoder::BinaryRecord(_)
| Decoder::MruListEx
| Decoder::EseDatabase
| Decoder::PipeDelimited { .. } => {
(decode_utf16le(&raw), true)
}
_ => (decode_utf16le(&raw), true),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn literals(segs: &[Segment]) -> Vec<&str> {
segs.iter()
.map(|s| match s {
Segment::Literal(n) => n.as_str(),
Segment::Variable(_, p) => p.as_str(),
})
.collect()
}
#[test]
fn template_strips_redundant_software_prefix() {
let segs =
template_segments(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", true).unwrap();
assert_eq!(
literals(&segs),
vec!["Microsoft", "Windows NT", "CurrentVersion"]
);
assert!(segs.iter().all(|s| matches!(s, Segment::Literal(_))));
}
#[test]
fn template_keeps_software_for_user_hive() {
let segs =
template_segments(r"Software\Microsoft\Windows\CurrentVersion\Run", false).unwrap();
assert_eq!(
literals(&segs),
vec!["Software", "Microsoft", "Windows", "CurrentVersion", "Run"]
);
}
#[test]
fn template_current_control_set_is_a_variable_segment() {
let segs = template_segments(r"CurrentControlSet\Services", true).unwrap();
assert_eq!(
segs,
vec![
Segment::Variable(Wildcard::ControlSet, "CurrentControlSet".into()),
Segment::Literal("Services".into()),
]
);
}
#[test]
fn template_rejects_placeholder() {
assert!(template_segments(r"HKEY_USERS\%%users.sid%%\Software\X", true).is_none());
}
#[test]
fn template_collapses_doubled_backslashes() {
let segs = template_segments(r"Microsoft\\Windows\\CurrentVersion\\Run", true).unwrap();
assert_eq!(
literals(&segs),
vec!["Microsoft", "Windows", "CurrentVersion", "Run"]
);
}
#[test]
fn template_strips_hk_prefix() {
let segs = template_segments(r"HKLM\Microsoft\Foo", true).unwrap();
assert_eq!(literals(&segs), vec!["Microsoft", "Foo"]);
}
#[test]
fn template_parses_wildcard_segments() {
let segs = template_segments(r"Microsoft\Foo\*\Bar\**", true).unwrap();
assert_eq!(
segs,
vec![
Segment::Literal("Microsoft".into()),
Segment::Literal("Foo".into()),
Segment::Variable(Wildcard::Subkey, "*".into()),
Segment::Literal("Bar".into()),
Segment::Variable(Wildcard::Subkey, "**".into()),
]
);
}
#[test]
fn template_rejects_placeholder_in_wildcard_path() {
assert!(template_segments(r"Foo\%%users.sid%%\*", true).is_none());
}
#[test]
fn parse_segment_classifies_double_star_and_control_set() {
assert_eq!(
parse_segment("**5"),
Segment::Variable(Wildcard::Subkey, "**5".into())
);
assert_eq!(
parse_segment("currentcontrolset"),
Segment::Variable(Wildcard::ControlSet, "currentcontrolset".into())
);
}
#[test]
fn strips_hku_and_users_placeholder_prefix() {
assert_eq!(
strip_user_placeholder_prefix(r"HKEY_USERS\%%users.sid%%\Software\X\Y"),
Some(r"Software\X\Y")
);
assert_eq!(
strip_user_placeholder_prefix(r"HKU\*\Software\Run"),
Some(r"Software\Run")
);
assert!(strip_user_placeholder_prefix(r"Software\X").is_none());
assert!(strip_user_placeholder_prefix(r"HKEY_USERS\S-1-5-21").is_none());
}
}