1#![forbid(unsafe_code)]
9
10use forensicnomicon::jumplist::appid_name;
11use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
12use lnk_core::{drive_type, JumpList, ShellLink};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum LnkAnomaly {
17 RemovableMediaTarget {
24 drive_type: u32,
26 drive_serial: u32,
28 path: Option<String>,
30 },
31 NetworkTarget {
34 net_name: Option<String>,
36 },
37 TrackerMachine {
40 machine_id: String,
42 },
43}
44
45impl LnkAnomaly {
46 #[must_use]
48 pub fn code(&self) -> &'static str {
49 match self {
50 Self::RemovableMediaTarget { .. } => "LNK-REMOVABLE-MEDIA-TARGET",
51 Self::NetworkTarget { .. } => "LNK-NETWORK-TARGET",
52 Self::TrackerMachine { .. } => "LNK-TRACKER-MACHINE",
53 }
54 }
55}
56
57impl Observation for LnkAnomaly {
58 fn severity(&self) -> Option<Severity> {
59 Some(match self {
60 Self::RemovableMediaTarget { .. } => Severity::Medium,
61 Self::NetworkTarget { .. } => Severity::Low,
62 Self::TrackerMachine { .. } => Severity::Info,
63 })
64 }
65
66 fn code(&self) -> &'static str {
67 LnkAnomaly::code(self)
68 }
69
70 fn category(&self) -> Category {
71 match self {
72 Self::RemovableMediaTarget { .. } | Self::NetworkTarget { .. } => Category::Threat,
73 Self::TrackerMachine { .. } => Category::Provenance,
74 }
75 }
76
77 fn mitre(&self) -> &'static [&'static str] {
78 match self {
79 Self::RemovableMediaTarget { .. } => &["T1052.001", "T1091"],
80 Self::NetworkTarget { .. } => &["T1021"],
81 Self::TrackerMachine { .. } => &[],
82 }
83 }
84
85 fn note(&self) -> String {
86 match self {
87 Self::RemovableMediaTarget {
88 drive_type,
89 drive_serial,
90 path,
91 } => format!(
92 "the link target resolves to a removable/external volume \
93 (drive_type {drive_type}, drive serial {drive_serial:#010X}{}); consistent with a \
94 file opened from external media (MITRE T1052.001 / T1091). The volume serial is \
95 the join key to a peripheral device connection",
96 path.as_deref()
97 .map_or_else(String::new, |p| format!(", path {p:?}"))
98 ),
99 Self::NetworkTarget { net_name } => format!(
100 "the link carries a network relative link{}; consistent with a file opened from a \
101 network share (MITRE T1021)",
102 net_name
103 .as_deref()
104 .map_or_else(String::new, |n| format!(" to {n}"))
105 ),
106 Self::TrackerMachine { machine_id } => format!(
107 "the tracker block records the origin machine {machine_id:?}; consistent with the \
108 link having been authored on that machine (attribution)"
109 ),
110 }
111 }
112}
113
114#[must_use]
116pub fn audit(link: &ShellLink) -> Vec<LnkAnomaly> {
117 let mut out = Vec::new();
118
119 if let Some(info) = &link.link_info {
120 if let Some(vol) = &info.volume_id {
121 if is_removable_volume(vol.drive_type, vol.drive_serial_number) {
122 out.push(LnkAnomaly::RemovableMediaTarget {
123 drive_type: vol.drive_type,
124 drive_serial: vol.drive_serial_number,
125 path: info.local_base_path.clone(),
126 });
127 }
128 }
129 if let Some(cnrl) = &info.common_network_relative_link {
130 out.push(LnkAnomaly::NetworkTarget {
131 net_name: cnrl.net_name.clone(),
132 });
133 }
134 }
135
136 if let Some(tracker) = &link.tracker {
137 if !tracker.machine_id.is_empty() {
138 out.push(LnkAnomaly::TrackerMachine {
139 machine_id: tracker.machine_id.clone(),
140 });
141 }
142 }
143
144 out
145}
146
147fn is_removable_volume(drive_type: u32, _drive_serial: u32) -> bool {
155 drive_type == drive_type::REMOVABLE
156}
157
158#[must_use]
160pub fn audit_findings(link: &ShellLink, scope: impl Into<String>) -> Vec<Finding> {
161 let src = source(scope);
162 audit(link)
163 .iter()
164 .map(|a| a.to_finding(src.clone()))
165 .collect()
166}
167
168#[must_use]
170pub fn source(scope: impl Into<String>) -> Source {
171 Source {
172 analyzer: "lnk-forensic".to_string(),
173 scope: scope.into(),
174 version: Some(env!("CARGO_PKG_VERSION").to_string()),
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum JumpListAnomaly {
184 PinnedTarget {
187 path: String,
189 },
190 CrossMachine {
194 hostname: String,
196 acquisition_host: String,
198 },
199 MruRecency {
202 path: String,
204 access_count: Option<u32>,
206 last_access: i64,
208 },
209 AppIdIdentified {
212 app_id: String,
214 application: &'static str,
216 },
217}
218
219impl JumpListAnomaly {
220 #[must_use]
222 pub fn code(&self) -> &'static str {
223 match self {
224 Self::PinnedTarget { .. } => "JUMPLIST-PINNED-TARGET",
225 Self::CrossMachine { .. } => "JUMPLIST-CROSS-MACHINE",
226 Self::MruRecency { .. } => "JUMPLIST-MRU-RECENCY",
227 Self::AppIdIdentified { .. } => "JUMPLIST-APPID-IDENTIFIED",
228 }
229 }
230}
231
232impl Observation for JumpListAnomaly {
233 fn severity(&self) -> Option<Severity> {
234 Some(match self {
235 Self::PinnedTarget { .. } | Self::CrossMachine { .. } => Severity::Low,
236 Self::MruRecency { .. } | Self::AppIdIdentified { .. } => Severity::Info,
237 })
238 }
239
240 fn code(&self) -> &'static str {
241 JumpListAnomaly::code(self)
242 }
243
244 fn category(&self) -> Category {
245 match self {
246 Self::MruRecency { .. } => Category::History,
247 Self::PinnedTarget { .. }
248 | Self::CrossMachine { .. }
249 | Self::AppIdIdentified { .. } => Category::Provenance,
250 }
251 }
252
253 fn note(&self) -> String {
254 match self {
255 Self::PinnedTarget { path } => format!(
256 "the Jump List entry for {path:?} is pinned; consistent with the user having \
257 deliberately fixed this target to the application's Jump List"
258 ),
259 Self::CrossMachine {
260 hostname,
261 acquisition_host,
262 } => format!(
263 "the Jump List entry records origin hostname {hostname:?}, which has no match to \
264 the acquisition host {acquisition_host:?}; consistent with the target having been \
265 accessed from, or the artifact having originated on, a different machine"
266 ),
267 Self::MruRecency {
268 path,
269 access_count,
270 last_access,
271 } => format!(
272 "the Jump List records MRU recency for {path:?} (access count {}, last access \
273 {last_access}); consistent with the application's own usage history for this \
274 target",
275 access_count.map_or_else(|| "unknown".to_string(), |c| c.to_string())
276 ),
277 Self::AppIdIdentified {
278 app_id,
279 application,
280 } => format!(
281 "the Jump List AppID {app_id} is consistent with the application {application:?}"
282 ),
283 }
284 }
285}
286
287#[must_use]
296pub fn audit_jumplist(
297 jl: &JumpList,
298 acquisition_host: Option<&str>,
299 scope: impl Into<String>,
300) -> Vec<Finding> {
301 let src = source(scope);
302 let mut out = Vec::new();
303
304 if let Some(app_id) = &jl.app_id {
306 if let Some(application) = appid_name(app_id) {
307 out.push(
308 JumpListAnomaly::AppIdIdentified {
309 app_id: app_id.clone(),
310 application,
311 }
312 .to_finding(src.clone()),
313 );
314 }
315 }
316
317 for entry in &jl.entries {
318 for anomaly in audit(&entry.link) {
320 out.push(anomaly.to_finding(src.clone()));
321 }
322
323 let Some(dl) = &entry.destlist else {
324 continue;
325 };
326
327 if dl.pinned {
328 out.push(
329 JumpListAnomaly::PinnedTarget {
330 path: dl.path.clone(),
331 }
332 .to_finding(src.clone()),
333 );
334 }
335
336 if let Some(host) = acquisition_host {
337 if !dl.hostname.is_empty() && !dl.hostname.eq_ignore_ascii_case(host) {
338 out.push(
339 JumpListAnomaly::CrossMachine {
340 hostname: dl.hostname.clone(),
341 acquisition_host: host.to_string(),
342 }
343 .to_finding(src.clone()),
344 );
345 }
346 }
347
348 if dl.access_count.is_some() || dl.last_access > 0 {
350 out.push(
351 JumpListAnomaly::MruRecency {
352 path: dl.path.clone(),
353 access_count: dl.access_count,
354 last_access: dl.last_access,
355 }
356 .to_finding(src.clone()),
357 );
358 }
359 }
360
361 out
362}
363
364#[cfg(test)]
365mod tests {
366 include!("tests.rs");
367}