| 87 |
87 |
|
detail: Some("Not an executable binary".to_string()),
|
| 88 |
88 |
|
}
|
| 89 |
89 |
|
}
|
|
90 |
+ |
Err(_) if looks_like_executable(data) => {
|
|
91 |
+ |
// The file CLAIMS to be a binary (executable magic header) but goblin
|
|
92 |
+ |
// refused to parse it — an evasion signal, not "not applicable". Fail
|
|
93 |
+ |
// closed for admin review, which is what this layer's ERROR_POLICY
|
|
94 |
+ |
// already documents (Run #2 Security MINOR: the Skip arm made that
|
|
95 |
+ |
// FailClosed posture unreachable from the buffered path).
|
|
96 |
+ |
LayerResult {
|
|
97 |
+ |
layer: "structural",
|
|
98 |
+ |
verdict: LayerVerdict::Error,
|
|
99 |
+ |
detail: Some("Executable magic header but unparseable binary".to_string()),
|
|
100 |
+ |
}
|
|
101 |
+ |
}
|
| 90 |
102 |
|
Err(_) => {
|
| 91 |
|
- |
// Not a recognized binary format — skip (not an error, just not applicable)
|
|
103 |
+ |
// No executable magic — a genuine non-binary download (zip, pdf,
|
|
104 |
+ |
// audio, sample pack). Structural analysis is not applicable; skip.
|
| 92 |
105 |
|
LayerResult {
|
| 93 |
106 |
|
layer: "structural",
|
| 94 |
107 |
|
verdict: LayerVerdict::Skip,
|
| 98 |
111 |
|
}
|
| 99 |
112 |
|
}
|
| 100 |
113 |
|
|
|
114 |
+ |
/// True if `data` begins with a known executable magic number (PE/ELF/Mach-O,
|
|
115 |
+ |
/// including byte-swapped and fat/universal). Used so the layer fails closed
|
|
116 |
+ |
/// ONLY when a file claims to be a binary but goblin can't parse it — a
|
|
117 |
+ |
/// non-binary download has no such magic and is correctly skipped, so changing
|
|
118 |
+ |
/// the parse-error arm doesn't hold every legitimate non-executable upload.
|
|
119 |
+ |
fn looks_like_executable(data: &[u8]) -> bool {
|
|
120 |
+ |
matches!(
|
|
121 |
+ |
data,
|
|
122 |
+ |
[0x4D, 0x5A, ..] // PE / DOS "MZ"
|
|
123 |
+ |
| [0x7F, b'E', b'L', b'F', ..] // ELF
|
|
124 |
+ |
| [0xFE, 0xED, 0xFA, 0xCE, ..] // Mach-O 32-bit, big-endian
|
|
125 |
+ |
| [0xFE, 0xED, 0xFA, 0xCF, ..] // Mach-O 64-bit, big-endian
|
|
126 |
+ |
| [0xCE, 0xFA, 0xED, 0xFE, ..] // Mach-O 32-bit, little-endian
|
|
127 |
+ |
| [0xCF, 0xFA, 0xED, 0xFE, ..] // Mach-O 64-bit, little-endian
|
|
128 |
+ |
| [0xCA, 0xFE, 0xBA, 0xBE, ..] // fat/universal (big-endian)
|
|
129 |
+ |
| [0xBE, 0xBA, 0xFE, 0xCA, ..] // fat/universal (little-endian)
|
|
130 |
+ |
)
|
|
131 |
+ |
}
|
|
132 |
+ |
|
| 101 |
133 |
|
fn analyze_pe(pe: &goblin::pe::PE) -> LayerResult {
|
| 102 |
134 |
|
let import_names: Vec<&str> = pe.imports.iter().map(|i| i.name.as_ref()).collect();
|
| 103 |
135 |
|
let section_names: Vec<String> = pe
|
| 189 |
221 |
|
}
|
| 190 |
222 |
|
|
| 191 |
223 |
|
fn analyze_mach(mach: &goblin::mach::Mach) -> LayerResult {
|
|
224 |
+ |
// Collect symbols across EVERY Mach-O slice, not just the first — a fat
|
|
225 |
+ |
// binary can hide a malicious arch behind a benign one (Run #2 Security
|
|
226 |
+ |
// MINOR). An import-table parse error is surfaced as Error (fail closed)
|
|
227 |
+ |
// rather than swallowed to an empty symbol set that would Pass.
|
| 192 |
228 |
|
let symbols: Vec<String> = match mach {
|
| 193 |
|
- |
goblin::mach::Mach::Binary(macho) => collect_macho_symbols(macho),
|
|
229 |
+ |
goblin::mach::Mach::Binary(macho) => match collect_macho_symbols(macho) {
|
|
230 |
+ |
Ok(s) => s,
|
|
231 |
+ |
Err(e) => return mach_import_error(e),
|
|
232 |
+ |
},
|
| 194 |
233 |
|
goblin::mach::Mach::Fat(fat) => {
|
| 195 |
|
- |
// Check first Mach-O arch in a fat binary
|
| 196 |
|
- |
fat.into_iter()
|
| 197 |
|
- |
.filter_map(|res| res.ok())
|
| 198 |
|
- |
.find_map(|arch| match arch {
|
| 199 |
|
- |
goblin::mach::SingleArch::MachO(macho) => Some(collect_macho_symbols(&macho)),
|
| 200 |
|
- |
_ => None,
|
| 201 |
|
- |
})
|
| 202 |
|
- |
.unwrap_or_default()
|
|
234 |
+ |
let mut all = Vec::new();
|
|
235 |
+ |
for arch in fat.into_iter().filter_map(|res| res.ok()) {
|
|
236 |
+ |
if let goblin::mach::SingleArch::MachO(macho) = arch {
|
|
237 |
+ |
match collect_macho_symbols(&macho) {
|
|
238 |
+ |
Ok(s) => all.extend(s),
|
|
239 |
+ |
Err(e) => return mach_import_error(e),
|
|
240 |
+ |
}
|
|
241 |
+ |
}
|
|
242 |
+ |
}
|
|
243 |
+ |
all
|
| 203 |
244 |
|
}
|
| 204 |
245 |
|
};
|
| 205 |
246 |
|
|
| 221 |
262 |
|
}
|
| 222 |
263 |
|
}
|
| 223 |
264 |
|
|
| 224 |
|
- |
fn collect_macho_symbols(macho: &goblin::mach::MachO) -> Vec<String> {
|
| 225 |
|
- |
macho
|
| 226 |
|
- |
.imports()
|
| 227 |
|
- |
.unwrap_or_default()
|
|
265 |
+ |
fn mach_import_error(e: goblin::error::Error) -> LayerResult {
|
|
266 |
+ |
LayerResult {
|
|
267 |
+ |
layer: "structural",
|
|
268 |
+ |
verdict: LayerVerdict::Error,
|
|
269 |
+ |
detail: Some(format!("Mach-O import table failed to parse: {e}")),
|
|
270 |
+ |
}
|
|
271 |
+ |
}
|
|
272 |
+ |
|
|
273 |
+ |
fn collect_macho_symbols(macho: &goblin::mach::MachO) -> Result<Vec<String>, goblin::error::Error> {
|
|
274 |
+ |
Ok(macho
|
|
275 |
+ |
.imports()?
|
| 228 |
276 |
|
.iter()
|
| 229 |
277 |
|
.map(|imp| imp.name.to_string())
|
| 230 |
|
- |
.collect()
|
|
278 |
+ |
.collect())
|
| 231 |
279 |
|
}
|
| 232 |
280 |
|
|
| 233 |
281 |
|
/// Check Mach-O import names for suspicious patterns.
|
| 270 |
318 |
|
assert_eq!(result.verdict, LayerVerdict::Skip);
|
| 271 |
319 |
|
}
|
| 272 |
320 |
|
|
|
321 |
+ |
#[test]
|
|
322 |
+ |
fn executable_magic_but_unparseable_fails_closed() {
|
|
323 |
+ |
// "MZ" claims to be a PE but the rest is garbage goblin can't parse.
|
|
324 |
+ |
// The layer's ERROR_POLICY is FailClosed, so this must hold for review,
|
|
325 |
+ |
// not silently Skip (Run #2 Security MINOR).
|
|
326 |
+ |
let mut data = vec![0x4D, 0x5A]; // "MZ"
|
|
327 |
+ |
data.extend_from_slice(&[0xFFu8; 64]);
|
|
328 |
+ |
let result = analyze_binary(&data, FileType::Download);
|
|
329 |
+ |
assert_eq!(result.verdict, LayerVerdict::Error);
|
|
330 |
+ |
}
|
|
331 |
+ |
|
|
332 |
+ |
#[test]
|
|
333 |
+ |
fn elf_magic_but_unparseable_fails_closed() {
|
|
334 |
+ |
let mut data = vec![0x7F, b'E', b'L', b'F'];
|
|
335 |
+ |
data.extend_from_slice(&[0x00u8; 8]); // truncated/garbage ELF
|
|
336 |
+ |
let result = analyze_binary(&data, FileType::Download);
|
|
337 |
+ |
assert_eq!(result.verdict, LayerVerdict::Error);
|
|
338 |
+ |
}
|
|
339 |
+ |
|
|
340 |
+ |
#[test]
|
|
341 |
+ |
fn non_executable_magic_still_skipped() {
|
|
342 |
+ |
// A ZIP (no executable magic) is a legitimate non-binary download and
|
|
343 |
+ |
// must NOT be held for review by the structural layer.
|
|
344 |
+ |
let result = analyze_binary(&[0x50, 0x4B, 0x03, 0x04, 0x00, 0x00], FileType::Download);
|
|
345 |
+ |
assert_eq!(result.verdict, LayerVerdict::Skip);
|
|
346 |
+ |
}
|
|
347 |
+ |
|
| 273 |
348 |
|
// -- PE import detection --
|
| 274 |
349 |
|
|
| 275 |
350 |
|
#[test]
|