Skip to main content

lnk_forensic/
lib.rs

1//! `lnk-forensic` — graded anomaly auditor over Windows Shell Link (`.lnk`) files.
2//!
3//! Consumes a [`lnk_core::ShellLink`] and emits
4//! [`forensicnomicon::report::Finding`]s. Every anomaly is an **observation**
5//! ("consistent with …"); the examiner draws the conclusions. MITRE techniques
6//! are narrated as consistency, never as a verdict.
7
8#![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/// A graded Shell Link anomaly.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum LnkAnomaly {
17    /// The link's `VolumeID` describes a removable/external volume — the target
18    /// file was opened from external media. MITRE T1052.001 / T1091.
19    ///
20    /// `drive_serial` is the **join key** to a peripheral `DeviceConnection`:
21    /// the same volume serial recorded against a USB mass-storage connection ties
22    /// this opened file to that physical device.
23    RemovableMediaTarget {
24        /// The `VolumeID.DriveType` value.
25        drive_type: u32,
26        /// The `VolumeID.DriveSerialNumber` — the join key to peripheral-forensic.
27        drive_serial: u32,
28        /// The local base path on the removable volume, when known.
29        path: Option<String>,
30    },
31    /// The link carries a `CommonNetworkRelativeLink` — the target was opened
32    /// from a network share. MITRE T1021.
33    NetworkTarget {
34        /// The UNC / network share name, when known.
35        net_name: Option<String>,
36    },
37    /// The `TrackerDataBlock` records the origin machine's NetBIOS name —
38    /// attribution evidence tying the link to the machine it was authored on.
39    TrackerMachine {
40        /// The recorded origin machine NetBIOS name.
41        machine_id: String,
42    },
43}
44
45impl LnkAnomaly {
46    /// The stable, published anomaly code (scheme-prefixed SCREAMING-KEBAB).
47    #[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/// Audit a [`ShellLink`] into a typed [`LnkAnomaly`] stream.
115#[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
147/// Whether a `VolumeID` describes external/removable media.
148///
149/// A `DRIVE_REMOVABLE` drive type is the explicit signal; some links record a
150/// `DRIVE_FIXED`/unknown type for a removable volume but still carry a non-zero
151/// drive serial, which by itself does not prove removability — so the removable
152/// finding fires only on the explicit `DRIVE_REMOVABLE` type. The serial is
153/// always surfaced as the cross-artifact join key regardless of type.
154fn is_removable_volume(drive_type: u32, _drive_serial: u32) -> bool {
155    drive_type == drive_type::REMOVABLE
156}
157
158/// Audit and convert directly to graded [`Finding`]s.
159#[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/// The [`Source`] stamp for findings this analyzer emits.
169#[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// ── Jump List anomalies ───────────────────────────────────────────────────────
179
180/// A graded Jump List anomaly, layered on top of the per-link [`LnkAnomaly`]
181/// findings (each embedded shell link is audited with [`audit`] for free).
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum JumpListAnomaly {
184    /// A `DestList` entry is pinned — the user deliberately fixed this target to
185    /// the application's Jump List. Provenance, not suspicious on its own.
186    PinnedTarget {
187        /// The pinned target path recorded in the `DestList`.
188        path: String,
189    },
190    /// A `DestList` entry's origin hostname has no match to the acquisition host
191    /// — consistent with the artifact (or the target) having originated on a
192    /// different machine. We state only "no match to the acquisition host".
193    CrossMachine {
194        /// The origin hostname recorded in the `DestList`.
195        hostname: String,
196        /// The acquisition host the hostname was compared against.
197        acquisition_host: String,
198    },
199    /// A `DestList` entry records MRU recency: a last-access time and an access
200    /// count — the application's own usage history for this target.
201    MruRecency {
202        /// The target path.
203        path: String,
204        /// Access count (Windows 10/11 `DestList`), when present.
205        access_count: Option<u32>,
206        /// Last-access time, Unix epoch seconds.
207        last_access: i64,
208    },
209    /// The Jump List's `AppID` resolves to a known application — provenance for
210    /// which program owns this MRU history.
211    AppIdIdentified {
212        /// The `AppID` (lowercase hex).
213        app_id: String,
214        /// The resolved application name.
215        application: &'static str,
216    },
217}
218
219impl JumpListAnomaly {
220    /// The stable, published anomaly code.
221    #[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/// Audit a [`JumpList`] into graded [`Finding`]s.
288///
289/// Runs the existing per-link [`audit`] over **every** embedded shell link
290/// (so removable-media / network / tracker findings come for free), then adds
291/// the Jump-List-level findings: pinned targets, cross-machine origin
292/// hostnames (compared against `acquisition_host`), MRU recency, and a resolved
293/// `AppID`. `acquisition_host` is the examiner's host for the cross-machine
294/// comparison; pass `None` to skip that check.
295#[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    // The AppID is a property of the whole list — emit it once.
305    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        // Reuse the per-link LNK audit — removable/network/tracker for free.
319        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        // MRU recency: emit when the entry carries usage history.
349        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}