-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_win_malware_analyzer.py
More file actions
2599 lines (2235 loc) · 129 KB
/
Copy pathtest_win_malware_analyzer.py
File metadata and controls
2599 lines (2235 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Tests de non-regression v7 : doublures lief/r2pipe, deux phases, console."""
import hashlib
import io
import json
import os
import struct
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
# --------------------------------------------------------------------------
# Doublures minimalistes de lief / r2pipe (installees AVANT l'import du script)
# --------------------------------------------------------------------------
lief_stub = types.ModuleType("lief")
_logging_stub = types.ModuleType("lief.logging")
_logging_stub.disable = lambda: None
lief_stub.logging = _logging_stub
pe_mod = types.ModuleType("lief.PE")
class _Enum:
def __init__(self, name):
self.name = name
class _DllChar:
DYNAMIC_BASE = "DYNAMIC_BASE"
NX_COMPAT = "NX_COMPAT"
GUARD_CF = "GUARD_CF"
NO_SEH = "NO_SEH"
class _OptHeader:
DLL_CHARACTERISTICS = _DllChar
class _Section:
def __init__(self, name, entropy=6.0, size=1024, offset=0x400):
self.name = name
self.virtual_address = 0x1000
self.virtual_size = size
self.sizeof_raw_data = size
self.entropy = entropy
self.offset = offset
self.size = size
class _Entry:
def __init__(self, name):
self.name = name
class _Import:
def __init__(self, name, entries):
self.name = name
self.entries = [_Entry(e) for e in entries]
class _Header:
machine = _Enum("AMD64")
time_date_stamp = 1600000000
class _Opt:
subsystem = _Enum("WINDOWS_GUI")
dll_characteristics_lists = ["DYNAMIC_BASE", "NX_COMPAT"]
checksum = 0
class _Binary:
def __init__(self):
self.header = _Header()
self.optional_header = _Opt()
self.entrypoint = 0x1400
self.has_signatures = False
self.has_rich_header = False
self.has_imports = True
self.has_tls = False
self.has_resources = False
self.sections = [_Section(".text"), _Section(".rsrc", entropy=7.5)]
self.imports = [
_Import("kernel32.dll", ["VirtualAllocEx", "WriteProcessMemory",
"CreateRemoteThread", "IsDebuggerPresent"]),
_Import("wininet.dll", ["InternetOpenA"]),
]
self.delay_imports = []
self.debug = []
self.overlay = b""
pe_mod.Binary = _Binary
pe_mod.OptionalHeader = _OptHeader
pe_mod.get_imphash = lambda pe: "deadbeefdeadbeefdeadbeefdeadbeef"
lief_stub.PE = pe_mod
lief_stub.parse = lambda path: _Binary()
sys.modules["lief"] = lief_stub
sys.modules["lief.PE"] = pe_mod
class _FakeR2:
"""Journalise toutes les commandes recues pour verifier l'absence
d'injection et le bon ciblage."""
def __init__(self):
self.commands = []
self.closed = False
def cmd(self, c):
self.commands.append(c)
if c == "pdg":
return "void entry0(void) { /* pseudo-code */ }"
if c == "pdc":
return "; pseudo-code natif"
if c.startswith("pd "):
return "0x1400 push rbp"
return ""
def cmdj(self, c):
self.commands.append(c)
if c == "aflj":
return [
{"name": "fcn.00401000", "offset": 0x401000, "size": 400, "nbbs": 12},
{"name": "sym.inject", "offset": 0x401500, "size": 900, "nbbs": 30},
]
if c.startswith("axtj"):
if "VirtualAllocEx" in c or "WriteProcessMemory" in c or "CreateRemoteThread" in c:
return [{"type": "CALL", "from": 0x401510, "fcn_name": "sym.inject"}]
return []
if c.startswith("agfj"):
return [{"blocks": [{"offset": 0x401500, "jump": 0x401520, "fail": 0x401540}]}]
if c == "izzj":
return [{"vaddr": 0x402000, "size": 12, "section": ".rdata", "string": "http://c2.example.test/gate"}]
return None
def quit(self):
self.closed = True
_LAST_R2 = {}
r2pipe_stub = types.ModuleType("r2pipe")
def _r2open(path, flags=None):
r2 = _FakeR2()
_LAST_R2["obj"] = r2
_LAST_R2["path"] = path
return r2
r2pipe_stub.open = _r2open
sys.modules["r2pipe"] = r2pipe_stub
import shutil as _sh
_sh.which = lambda b: "/usr/bin/rizin" if b in ("rizin","radare2","r2") else None
sys.path.insert(0, str(Path(__file__).parent))
import win_malware_analyzer as wma # noqa: E402
def make_pe_file(path: Path, extra: bytes = b"") -> Path:
"""Fabrique un faux PE minimal mais structurellement coherent."""
data = bytearray(b"MZ" + b"\x00" * 0x3E)
struct.pack_into("<I", data, 0x3C, 0x80)
data.extend(b"\x00" * (0x80 - len(data)))
data.extend(b"PE\x00\x00")
data.extend(b"\x00" * 200)
data.extend(b"http://malicious.example.test/panel.php\x00")
data.extend(b"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run\x00")
data.extend(b"C:\\Windows\\System32\\evil.dll\x00")
data.extend(b"kernel32.dll\x00")
data.extend(extra)
path.write_bytes(bytes(data))
return path
class TestPhases(unittest.TestCase):
def setUp(self):
self.tmp = Path("/tmp/wma_tests")
self.tmp.mkdir(exist_ok=True)
self.sample = make_pe_file(self.tmp / "sample.exe")
self.out = self.tmp / "out"
self.out.mkdir(exist_ok=True)
for f in self.out.glob("*"):
f.unlink()
self.logger = wma.setup_logger(0)
self.opts = {
"output_dir": str(self.out), "reverse_mode": "ask", "no_interactive": False,
"max_functions": 5, "no_cfg": False, "yara": True, "vt_key": None,
"timeout": 10, "max_file_size": 50 * 1024 * 1024, "verbosity": 0,
}
# -- phase statique ---------------------------------------------------
def test_static_phase_ok(self):
res = wma.run_static_phase(self.sample, self.opts, self.logger)
self.assertTrue(res["success"], res["error"])
rep = res["report"]
self.assertEqual(rep["report_type"], "static")
self.assertNotIn("reverse_engineering", rep, "le rapport statique ne doit PAS contenir le reverse")
self.assertIn("Process_Injection_Hoisting", rep["pe_structure"]["behavioral_capabilities"])
self.assertIn("suspicion", rep)
self.assertTrue(any("malicious.example.test" in u for u in rep["iocs"]["urls"]))
self.assertIn("VirtualAllocEx", res["flagged_apis"])
def test_static_report_written_with_suffix(self):
res = wma.run_static_phase(self.sample, self.opts, self.logger)
path = wma.write_static_report(res, self.opts, self.logger)
self.assertTrue(path.exists())
self.assertTrue(path.name.endswith("_static.json"), path.name)
self.assertIn(res["sha256"][:8], path.name)
json.loads(path.read_text())
self.assertTrue(path.with_suffix(".yar").exists(), "YARA statique attendu")
def test_static_phase_missing_file(self):
res = wma.run_static_phase(self.tmp / "nope.exe", self.opts, self.logger)
self.assertFalse(res["success"])
self.assertIsNotNone(res["error"])
def test_static_phase_oversized(self):
opts = dict(self.opts, max_file_size=10)
res = wma.run_static_phase(self.sample, opts, self.logger)
self.assertFalse(res["success"])
self.assertIn("volumineux", res["error"])
# -- phase reverse ----------------------------------------------------
def test_reverse_phase_non_interactive(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
static["static_report_path"] = "/tmp/x_static.json"
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, interactive=False)
self.assertTrue(rev["success"], rev["error"])
rep = rev["report"]
self.assertEqual(rep["report_type"], "reverse")
self.assertIsNone(rep["interactive_session"])
self.assertIn("sym.inject", rep["reverse_engineering"]["decompiled_functions"])
self.assertIn("VirtualAllocEx", rep["reverse_engineering"]["suspicious_api_xrefs"])
self.assertIn("sym.inject", rep["reverse_engineering"]["function_graphs"])
self.assertEqual(rep["linked_static_report"], "/tmp/x_static.json")
self.assertIn("suspicion_delta", rep)
self.assertGreaterEqual(rep["suspicion_delta"]["post_reverse_score"],
rep["suspicion_delta"]["static_score"])
self.assertTrue(_LAST_R2["obj"].closed, "la session rizin doit etre fermee")
def test_reverse_report_written_with_suffix(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, interactive=False)
path = wma.write_reverse_report(rev, self.sample, static["sha256"], self.opts, self.logger)
self.assertTrue(path.exists())
self.assertTrue(path.name.endswith("_reverse.json"), path.name)
json.loads(path.read_text())
def test_two_reports_are_distinct_files(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
p1 = wma.write_static_report(static, self.opts, self.logger)
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, interactive=False)
p2 = wma.write_reverse_report(rev, self.sample, static["sha256"], self.opts, self.logger)
self.assertNotEqual(p1, p2)
self.assertTrue(p1.exists() and p2.exists())
# -- securite : injection de commande r2 ------------------------------
def test_symbol_sanitizer_rejects_injection(self):
for bad in ["a; !id", "foo`whoami`", "a|b", "$(id)", "a b", "a\nb", "x" * 300, "", None]:
self.assertIsNone(wma._sanitize_r2_symbol(bad), bad)
for good in ["sym.imp.VirtualAllocEx", "fcn.00401000", "entry0"]:
self.assertEqual(wma._sanitize_r2_symbol(good), good)
def test_location_sanitizer_accepts_hex_only(self):
self.assertEqual(wma._sanitize_r2_location("0x401500"), "0x401500")
self.assertIsNone(wma._sanitize_r2_location("0x401500; !id"))
self.assertIsNone(wma._sanitize_r2_location("0xZZZZ !id"))
def test_session_refuses_unsafe_target(self):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
with self.assertRaises(ValueError):
s.decompile("foo; !id")
with self.assertRaises(ValueError):
s.cfg("$(id)")
with self.assertRaises(ValueError):
s.xrefs_to("a|b")
with self.assertRaises(ValueError):
s.disassemble("`whoami`")
def test_no_injection_reached_r2(self):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
try:
s.decompile("evil; !id")
except ValueError:
pass
for c in _LAST_R2["obj"].commands:
self.assertNotIn("!id", c)
self.assertNotIn(";", c)
# -- console interactive ----------------------------------------------
def _run_console(self, script: str) -> dict:
static = wma.run_static_phase(self.sample, self.opts, self.logger)
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
with mock.patch("builtins.input", side_effect=script.strip().split("\n")):
return wma.interactive_reverse_console(s, static["report"], self.logger)
def test_console_basic_flow(self):
log = self._run_console("""
?
l
l inject
d sym.inject
g sym.inject
x VirtualAllocEx
str 5
note fonction d injection confirmee
pick http://malicious.example.test/panel.php
picks
y
sum
q
""")
self.assertIn("sym.inject", log["decompiled_functions"])
self.assertIn("sym.inject", log["function_graphs"])
self.assertIn("VirtualAllocEx", log["xrefs"])
self.assertEqual(len(log["analyst_notes"]), 1)
self.assertEqual(len(log["selected_strings"]), 1)
self.assertEqual(len(log["yara_rules"]), 1)
self.assertIn("panel.php", log["yara_rules"][0])
self.assertTrue(all(c["ok"] for c in log["commands"]), log["commands"])
def test_console_handles_bad_input(self):
log = self._run_console("""
blahblah
d
d evil; !id
g
x
pick
note
q
""")
# aucune exception ne doit remonter, et les erreurs sont journalisees
self.assertTrue(any(not c["ok"] for c in log["commands"]))
self.assertEqual(log["decompiled_functions"], {})
def test_console_survives_eof(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
with mock.patch("builtins.input", side_effect=EOFError):
log = wma.interactive_reverse_console(s, static["report"], self.logger)
self.assertEqual(log["commands"], [])
def test_console_unclosed_quote_does_not_crash(self):
log = self._run_console("""
note "guillemet non ferme
q
""")
self.assertEqual(len(log["analyst_notes"]), 1)
# -- orchestration / decision -----------------------------------------
def test_resolve_mode_skip(self):
opts = dict(self.opts, reverse_mode="skip")
self.assertEqual(wma.resolve_reverse_mode(opts, False, self.logger), "skip")
def test_resolve_mode_auto(self):
opts = dict(self.opts, reverse_mode="auto")
self.assertEqual(wma.resolve_reverse_mode(opts, True, self.logger), "run")
def test_resolve_mode_ask_in_batch_never_prompts(self):
with mock.patch("builtins.input", side_effect=AssertionError("prompt interdit en batch")):
self.assertEqual(wma.resolve_reverse_mode(self.opts, True, self.logger), "run")
def test_resolve_mode_ask_non_tty_runs(self):
with mock.patch.object(sys, "stdin", io.StringIO("")):
self.assertEqual(wma.resolve_reverse_mode(self.opts, False, self.logger), "run")
def test_prompt_yes_no_non_tty_returns_default(self):
with mock.patch.object(sys, "stdin", io.StringIO("")):
self.assertTrue(wma.prompt_yes_no("?", default=True))
self.assertFalse(wma.prompt_yes_no("?", default=False))
def test_process_one_skip_writes_only_static(self):
opts = dict(self.opts, reverse_mode="skip")
res = wma.process_one(self.sample, opts, self.logger, batch=False)
self.assertTrue(res["success"])
self.assertFalse(res["reverse_done"])
files = sorted(p.name for p in self.out.glob("*.json"))
self.assertEqual(len(files), 1)
self.assertTrue(files[0].endswith("_static.json"))
def test_process_one_auto_writes_both(self):
opts = dict(self.opts, reverse_mode="auto", no_interactive=True)
res = wma.process_one(self.sample, opts, self.logger, batch=False)
self.assertTrue(res["success"])
self.assertTrue(res["reverse_done"])
files = sorted(p.name for p in self.out.glob("*.json"))
self.assertEqual(len(files), 2, files)
self.assertTrue(any(f.endswith("_static.json") for f in files))
self.assertTrue(any(f.endswith("_reverse.json") for f in files))
def test_batch_worker_never_touches_stdin(self):
opts = dict(self.opts, reverse_mode="ask", no_interactive=False)
with mock.patch("builtins.input", side_effect=AssertionError("stdin interdit en batch")):
res = wma.process_one(self.sample, opts, self.logger, batch=True)
self.assertTrue(res["success"])
self.assertTrue(res["reverse_done"])
def test_reverse_failure_does_not_lose_static(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
p1 = wma.write_static_report(static, self.opts, self.logger)
with mock.patch.object(wma.r2pipe, "open", side_effect=RuntimeError("rizin absent")):
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, interactive=False)
self.assertFalse(rev["success"])
self.assertTrue(p1.exists(), "le rapport statique doit survivre a un reverse en echec")
self.assertIsNotNone(rev["report"], "un rapport reverse partiel doit exister")
# -- CLI ---------------------------------------------------------------
def test_cli_skip_reverse_alias(self):
args = wma.build_arg_parser().parse_args(["x.exe", "--skip-reverse"])
self.assertTrue(args.skip_reverse)
args2 = wma.build_arg_parser().parse_args(["x.exe", "--reverse", "auto"])
self.assertEqual(args2.reverse_mode, "auto")
def test_cli_rejects_bad_values(self):
for bad in (["x.exe", "--timeout", "0"], ["x.exe", "--max-functions", "-1"],
["x.exe", "--workers", "0"], ["x.exe", "--max-file-size", "0"]):
with self.assertRaises(SystemExit):
wma.main(bad)
def test_main_end_to_end_skip(self):
rc = wma.main([str(self.sample), "-o", str(self.out), "--reverse", "skip"])
self.assertEqual(rc, 0)
def test_main_end_to_end_auto(self):
rc = wma.main([str(self.sample), "-o", str(self.out), "--reverse", "auto",
"--no-interactive", "--yara"])
self.assertEqual(rc, 0)
self.assertEqual(len(list(self.out.glob("*_static.json"))), 1)
self.assertEqual(len(list(self.out.glob("*_reverse.json"))), 1)
# -- divers ------------------------------------------------------------
def test_truncate(self):
self.assertTrue(wma._truncate("a" * 10, 5).startswith("aaaaa"))
self.assertIn("tronque", wma._truncate("a" * 10, 5))
self.assertEqual(wma._truncate("abc", 10), "abc")
self.assertEqual(wma._truncate(None), "")
def test_yara_extra_strings_priority(self):
rule = wma.build_yara_rule("s.exe", "a" * 64, {"urls": ["http://x.test/a"]},
extra_strings=["MARQUEUR_ANALYSTE"], rule_suffix="interactive")
self.assertIn("MARQUEUR_ANALYSTE", rule)
self.assertIn("auto_s.exe_interactive".replace(".", "_"), rule.replace(".", "_"))
self.assertTrue(rule.index("MARQUEUR_ANALYSTE") < rule.index("http://x.test/a"))
def test_yara_escapes_quotes_and_backslashes(self):
rule = wma.build_yara_rule("s.exe", "b" * 64, {},
extra_strings=['C:\\Windows\\a"b.exe'])
self.assertIn('C:\\\\Windows\\\\a\\"b.exe', rule)
def test_report_path_naming(self):
p_s = wma._report_path(Path("/tmp/My Sample!.exe"), "abcdef1234", self.opts, "static")
p_r = wma._report_path(Path("/tmp/My Sample!.exe"), "abcdef1234", self.opts, "reverse")
self.assertNotEqual(p_s.name, p_r.name)
self.assertNotIn(" ", p_s.name)
self.assertNotIn("!", p_s.name)
self.assertIn("abcdef12", p_s.name)
class TestExtra(unittest.TestCase):
def setUp(self):
self.tmp = Path("/tmp/wma_tests2"); self.tmp.mkdir(exist_ok=True)
self.sample = make_pe_file(self.tmp / "s.exe")
self.out = self.tmp / "o"; self.out.mkdir(exist_ok=True)
for f in self.out.glob("*"): f.unlink()
self.logger = wma.setup_logger(0)
self.opts = {"output_dir": str(self.out), "reverse_mode": "auto", "no_interactive": True,
"max_functions": 5, "no_cfg": False, "yara": False, "vt_key": None,
"timeout": 10, "max_file_size": 50*1024*1024, "verbosity": 0}
def test_ctrl_c_during_reverse_still_writes(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
with mock.patch.object(wma, "run_auto_reverse", side_effect=KeyboardInterrupt):
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, False)
self.assertIsNotNone(rev["report"])
self.assertTrue(rev["report"]["interrupted"])
p = wma.write_reverse_report(rev, self.sample, static["sha256"], self.opts, self.logger)
self.assertTrue(p.exists())
def test_iocs_error_does_not_break_yara(self):
rule = wma.build_yara_rule("s.exe", "c"*64, {"error": "boom"})
self.assertIn("$mz", rule)
def test_unused_imports(self):
import ast
src = Path("win_malware_analyzer.py").read_text()
tree = ast.parse(src)
imported = set()
for n in ast.walk(tree):
if isinstance(n, ast.Import):
for a in n.names: imported.add((a.asname or a.name).split(".")[0])
elif isinstance(n, ast.ImportFrom):
for a in n.names: imported.add(a.asname or a.name)
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
used |= {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
for n in ast.walk(tree):
if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name):
used.add(n.value.id)
unused = imported - used - {"annotations"}
self.assertEqual(unused, set(), f"imports inutilises: {unused}")
class TestCorrectifsV71(unittest.TestCase):
"""Un test par vulnerabilite / bug identifie pendant l'audit v7.0."""
def setUp(self):
self.tmp = Path("/tmp/wma_71"); self.tmp.mkdir(exist_ok=True)
for f in self.tmp.glob("*"):
if f.is_file() or f.is_symlink(): f.unlink()
self.sample = make_pe_file(self.tmp / "s.exe")
self.logger = wma.setup_logger(0)
self.opts = {"output_dir": str(self.tmp/"o"), "reverse_mode": "auto", "no_interactive": True,
"max_functions": 5, "no_cfg": False, "yara": False, "vt_key": None,
"timeout": 10, "max_file_size": 50*1024*1024, "verbosity": 0}
# --- FIX 1 : faux negatif sur les longues chaines imprimables ---
def test_ioc_dans_longue_chaine_desormais_detecte(self):
blob = b"MZ" + b"X"*1500 + b" http://tres-mechant.example.test/gate.php " + b"Y"*1500
iocs = wma.extract_iocs(blob)
self.assertIn("http://tres-mechant.example.test/gate.php", iocs["urls"])
def test_ioc_a_cheval_sur_deux_fenetres(self):
# l'URL est placee pile sur la frontiere de decoupage
pad = wma.MAX_STRING_LEN - 10
blob = b"MZ" + b"X"*pad + b" http://frontiere.example.test/x " + b"Y"*1200
self.assertIn("http://frontiere.example.test/x", wma.extract_iocs(blob)["urls"])
# --- FIX 2 : ReDoS sur la regex domains ---
def test_regex_domains_pas_de_redos(self):
import time as _t
payload = ("a." * 40000) + "!"
t0 = _t.perf_counter()
wma.IOC_PATTERNS["domains"].findall(payload)
self.assertLess(_t.perf_counter() - t0, 0.5, "regex domains toujours quadratique")
def test_regex_domains_toujours_correcte(self):
f = wma.IOC_PATTERNS["domains"].findall
self.assertEqual(f("www.google.com"), ["www.google.com"])
self.assertEqual(f("sub.domain.co.uk"), ["sub.domain.co.uk"])
self.assertEqual(f("api-3.cdn.example.co.jp"), ["api-3.cdn.example.co.jp"])
self.assertIn("evil.test", f("voir http://evil.test/a"))
# --- FIX 3 : metacaracteres r2 @ et $ ---
def test_metacaracteres_r2_rejetes(self):
for bad in ["foo@0x41414141", "sym.$$", "a@@b", "x@eip"]:
self.assertIsNone(wma._sanitize_r2_symbol(bad), bad)
def test_fonction_ciblee_par_adresse_pas_par_nom(self):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
s.decompile("sym.inject")
seeks = [c for c in _LAST_R2["obj"].commands if c.startswith("s ")]
self.assertIn("s 0x401500", seeks, f"attendu un seek par adresse, vu {seeks}")
# --- FIX 4 : detection d'erreur de decompilation ---
def test_pdg_valide_contenant_le_mot_cannot_est_conserve(self):
class R:
def cmd(self, c):
if c.startswith("?v"): return "0x140001420"
return 'void f(void){ puts("Cannot open handle"); }' if c == "pdg" else "PDC PAUVRE"
def cmdj(self, c): return []
s = wma.ReverseSession.__new__(wma.ReverseSession)
s.r2, s.errors, s._functions_cache, s._name_to_offset = R(), [], None, {}
self.assertIn("Cannot open handle", s.decompile("entry0"))
def test_vraie_erreur_declenche_le_repli(self):
class R:
def cmd(self, c):
if c.startswith("?v"): return "0x140001420"
return "Cannot find function at 0x0" if c == "pdg" else "PDC OK"
def cmdj(self, c): return []
s = wma.ReverseSession.__new__(wma.ReverseSession)
s.r2, s.errors, s._functions_cache, s._name_to_offset = R(), [], None, {}
self.assertEqual(s.decompile("entry0"), "PDC OK")
# --- FIX 5 : crash console sur champs None ---
def test_console_ne_crashe_plus_sur_aflj_incomplet(self):
wma._print_functions([{"name": "f", "offset": None, "size": None, "nbbs": None},
{"name": None, "offset": 1, "size": 2, "nbbs": 3},
"pas_un_dict"])
# --- FIX 6 : FIFO / device ---
def test_fifo_refuse_sans_blocage(self):
import signal
fifo = self.tmp / "pipe"
if fifo.exists(): fifo.unlink()
os.mkfifo(fifo)
signal.signal(signal.SIGALRM, lambda *a: (_ for _ in ()).throw(TimeoutError()))
signal.alarm(5)
try:
with self.assertRaises(ValueError):
wma.safe_read_binary(fifo, 1000)
finally:
signal.alarm(0)
def test_repertoire_refuse(self):
with self.assertRaises((ValueError, IsADirectoryError, PermissionError, OSError)):
wma.safe_read_binary(self.tmp, 1000)
# --- FIX 7 : symlink refuse pour LIEF/rizin aussi ---
def test_symlink_refuse_en_phase_statique(self):
link = self.tmp / "lien.exe"
if link.exists() or link.is_symlink(): link.unlink()
link.symlink_to(self.sample)
res = wma.run_static_phase(link, self.opts, self.logger)
self.assertFalse(res["success"])
self.assertIn("symbolique", res["error"])
def test_symlink_ignore_en_batch(self):
link = self.tmp / "lien2.exe"
if link.exists() or link.is_symlink(): link.unlink()
link.symlink_to(self.sample)
found = wma.discover_targets(self.tmp, recursive=False)
self.assertNotIn(link, found)
self.assertIn(self.sample, found)
# --- FIX 8 : ecriture atomique / serialisation defensive ---
def test_objet_non_serialisable_ne_corrompt_pas_le_json(self):
class Weird:
def __str__(self): return "objet-lief-exotique"
out = self.tmp / "o"; out.mkdir(exist_ok=True)
p = wma._dump_json({"a": Weird()}, out / "r.json", self.sample, self.logger)
self.assertIsNotNone(p)
self.assertEqual(json.loads(p.read_text())["a"], "objet-lief-exotique")
def test_surrogates_ne_cassent_pas_l_ecriture(self):
out = self.tmp / "o"; out.mkdir(exist_ok=True)
p = wma._dump_json({"code": "abc\udcff def"}, out / "s.json", self.sample, self.logger)
self.assertIsNotNone(p)
json.loads(p.read_text())
def test_aucun_fichier_temporaire_laisse(self):
out = self.tmp / "o"; out.mkdir(exist_ok=True)
wma._dump_json({"x": 1}, out / "t.json", self.sample, self.logger)
self.assertEqual(list(out.glob(".wma_*")), [])
def test_echec_total_d_ecriture_ne_leve_pas(self):
with mock.patch.object(wma.tempfile, "mkstemp", side_effect=OSError("disque plein")):
r = wma._dump_json({"x": 1}, self.tmp / "o" / "z.json", self.sample, self.logger)
self.assertIsNone(r)
# --- FIX 9 : timeout rizin (nom de variable) ---
def test_timeout_pose_sur_les_deux_moteurs(self):
with wma.ReverseSession(str(self.sample), 42, self.logger):
pass
cmds = " ".join(_LAST_R2["obj"].commands)
self.assertIn("analysis.timeout=42", cmds) # rizin
self.assertIn("anal.timeout=42", cmds) # radare2
self.assertIn("scr.color=0", cmds) # pas d'ANSI dans le JSON
# --- FIX 10 : rizin absent ---
def test_message_clair_si_rizin_absent(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
with mock.patch.object(wma, "_rizin_available", return_value=False):
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, False)
self.assertFalse(rev["success"])
self.assertIn("PATH", rev["error"])
# --- FIX 11 : TOCTOU entre les deux phases ---
def test_modification_du_fichier_entre_les_phases_bloque_le_reverse(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
self.sample.write_bytes(self.sample.read_bytes() + b"MODIFIE_APRES_HASH")
rev = wma.run_reverse_phase(self.sample, static, self.opts, self.logger, False)
self.assertFalse(rev["success"])
self.assertIn("modifie", rev["error"])
# --- FIX 12 : memoire / plafonds ---
def test_plafond_ioc_respecte(self):
blob = b"MZ" + b"\x00".join(f"http://h{i}.example.test/a".encode() for i in range(3000))
iocs = wma.extract_iocs(blob)
self.assertLessEqual(len(iocs["urls"]), wma.MAX_IOCS_PER_TYPE)
self.assertTrue(iocs["extraction_stats"]["truncated"])
def test_stats_extraction_presentes(self):
iocs = wma.extract_iocs(b"MZ\x00 http://a.example.test/x \x00")
self.assertIn("extraction_stats", iocs)
self.assertFalse(iocs["extraction_stats"]["truncated"])
# --- FIX 13 : horodatage UTC ---
def test_horodatage_utc(self):
res = wma.run_static_phase(self.sample, self.opts, self.logger)
self.assertTrue(res["report"]["generated_at"].endswith("Z"))
# --- FIX 14 : batch determine par la nature de la cible ---
def test_dossier_avec_un_seul_fichier_ne_demande_rien(self):
d = self.tmp / "un"; d.mkdir(exist_ok=True)
make_pe_file(d / "seul.exe")
opts_dir = dict(self.opts, output_dir=str(self.tmp/"o2"), reverse_mode="ask")
with mock.patch.object(sys.stdin, "isatty", lambda: True), \
mock.patch("builtins.input", side_effect=AssertionError("invite interdite sur un dossier")):
rc = wma.main([str(d), "-o", str(self.tmp/"o2"), "--reverse", "ask", "--no-interactive"])
self.assertEqual(rc, 0)
# --- FIX 15 : code retour sur interruption ---
def test_code_retour_130_si_interrompu(self):
with mock.patch.object(wma, "run_auto_reverse", side_effect=KeyboardInterrupt):
rc = wma.main([str(self.sample), "-o", str(self.tmp/"o3"), "--reverse", "auto", "--no-interactive"])
self.assertEqual(rc, 130)
# --- FIX 16 : cle VT via l'environnement ---
def test_cle_vt_lue_dans_l_environnement(self):
captured = {}
def fake_static(t, opts, log):
captured["vt"] = opts["vt_key"]
return {"success": False, "report": None, "error": "stop", "sha256": None,
"flagged_apis": [], "target": str(t), "stat": None}
with mock.patch.dict(os.environ, {"VT_API_KEY": "cle-secrete"}), \
mock.patch.object(wma, "run_static_phase", side_effect=fake_static):
wma.main([str(self.sample), "-o", str(self.tmp/"o4")])
self.assertEqual(captured["vt"], "cle-secrete")
# --- FIX 17 : chaines marquees bornees ---
def test_plafond_chaines_marquees(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
script = [f"pick chaine{i}" for i in range(wma.MAX_SELECTED_STRINGS + 20)] + ["q"]
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
with mock.patch("builtins.input", side_effect=script):
log = wma.interactive_reverse_console(s, static["report"], self.logger)
self.assertEqual(len(log["selected_strings"]), wma.MAX_SELECTED_STRINGS)
# --- FIX 18 : commande top ---
def test_commande_top(self):
static = wma.run_static_phase(self.sample, self.opts, self.logger)
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
auto = wma.run_auto_reverse(s, static["flagged_apis"], 5, True, self.logger)
with mock.patch("builtins.input", side_effect=["top", "q"]):
log = wma.interactive_reverse_console(s, static["report"], self.logger, auto_result=auto)
self.assertTrue(log["commands"][0]["ok"])
# --- checksum par blocs ---
def test_checksum_blocs_taille_impaire(self):
d = bytearray(os.urandom(0x501)); d[0:2] = b"MZ"
struct.pack_into("<I", d, 0x3C, 0x80); d[0x80:0x84] = b"PE\x00\x00"
self.assertIsInstance(wma.compute_pe_checksum(bytes(d)), int)
def test_plafond_chaines_signale_dans_le_rapport(self):
# Chaines DISTINCTES : les doublons ne consomment plus le budget (v7.8).
limite = wma.MAX_STRINGS_SCANNED // 3
blob = b"MZ" + b"\x00".join(
f"chaine_unique_{i:08d}".encode() for i in range(limite + 500))
stats = wma.extract_iocs(blob)["extraction_stats"]
self.assertTrue(stats["string_limit_reached"])
self.assertTrue(stats["truncated"], "truncated doit refleter le plafond de chaines")
def test_doublons_ne_consomment_pas_le_budget(self):
"""Un binaire tres repetitif ne doit pas epuiser le budget en doublons :
un IOC place APRES eux doit rester detecte."""
blob = b"MZ" + b"\x00".join(b"AAAA" for _ in range(wma.MAX_STRINGS_SCANNED + 5000))
blob += b"\x00http://c2-en-fin-de-fichier.example.test/gate\x00"
iocs = wma.extract_iocs(blob)
self.assertIn("http://c2-en-fin-de-fichier.example.test/gate", iocs["urls"])
self.assertFalse(iocs["extraction_stats"]["truncated"])
class TestSchemaRizin(unittest.TestCase):
"""Regression : rizin nomme l'adresse `addr` la ou radare2 utilise `offset`.
Constate en production, toutes les fonctions s'affichaient a 0x0."""
def setUp(self):
self.tmp = Path("/tmp/wma_schema"); self.tmp.mkdir(exist_ok=True)
self.sample = make_pe_file(self.tmp / "s.exe")
self.logger = wma.setup_logger(0)
def test_extraction_adresse_multi_schema(self):
self.assertEqual(wma._extract_address({"offset": 0x401000}), 0x401000)
self.assertEqual(wma._extract_address({"addr": 0x401000}), 0x401000)
self.assertEqual(wma._extract_address({"vaddr": 0x401000}), 0x401000)
self.assertEqual(wma._extract_address({"addr": "0x401000"}), 0x401000)
def test_adresse_absente_ou_nulle_nest_pas_zero(self):
for entry in ({}, {"offset": 0}, {"offset": None}, {"addr": False}, "pas_un_dict"):
self.assertIsNone(wma._extract_address(entry), entry)
def test_seek_par_adresse_avec_schema_rizin(self):
"""Le durcissement (ciblage par adresse) doit rester actif sur rizin."""
class RizinR2(_FakeR2):
def cmdj(self, c):
self.commands.append(c)
if c == "aflj":
return [{"name": "sym.cible", "addr": 0x401500, "size": 900, "nbbs": 30}]
return super().cmdj(c) if not c.startswith("aflj") else []
r = RizinR2(); _LAST_R2["obj"] = r
with mock.patch.object(wma.r2pipe, "open", return_value=r):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
s.decompile("sym.cible")
self.assertIn("s 0x401500", r.commands,
"la fonction doit etre ciblee par adresse, pas par nom")
def test_jamais_de_seek_en_0x0(self):
"""Une cible non resolue doit echouer franchement, jamais seeker en 0x0
ni decompiler la position courante sous le nom demande (v7.6)."""
class NoAddrR2(_FakeR2):
def cmd(self, c):
self.commands.append(c)
if c.startswith("?v"):
return "0x0" # symbole inconnu cote rizin
if c == "pdg":
return "void autre_fonction(void) { }"
return ""
def cmdj(self, c):
self.commands.append(c)
if c == "aflj":
return [{"name": "sym.sansadresse", "size": 100, "nbbs": 3}]
return []
r = NoAddrR2(); _LAST_R2["obj"] = r
with mock.patch.object(wma.r2pipe, "open", return_value=r):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
s.analyze()
with self.assertRaises(ValueError):
s.decompile("sym.sansadresse")
self.assertNotIn("s 0x0", r.commands, "seek en 0x0 : la fonction analysee serait fausse")
self.assertNotIn("s sym.sansadresse", r.commands)
def test_affichage_sans_adresse_montre_interrogation(self):
wma._print_functions([{"name": "f", "size": 10, "nbbs": 2}])
class TestRetoursTerrain(unittest.TestCase):
"""Regressions issues des rapports JSON d'une analyse reelle (v7.3)."""
def setUp(self):
self.tmp = Path("/tmp/wma_terrain"); self.tmp.mkdir(exist_ok=True)
self.sample = make_pe_file(self.tmp / "s.exe")
self.logger = wma.setup_logger(0)
# --- xrefs CODE/ICOD ignorees : le bug le plus grave ---
def test_xrefs_de_type_code_et_icod_conservees(self):
class R(_FakeR2):
def cmdj(self, c):
self.commands.append(c)
if c == "aflj":
return [{"name": "sym.appel", "addr": 0x401500, "size": 100, "nbbs": 4}]
if c.startswith("axtj"):
return [{"type": "CODE", "from": 0x401510, "fcn_name": "sym.appel"},
{"type": "ICOD", "from": 0x401520, "fcn_name": "sym.appel"}]
return []
r = R(); _LAST_R2["obj"] = r
with mock.patch.object(wma.r2pipe, "open", return_value=r):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
refs = s.xrefs_to("SetThreadContext")
self.assertEqual(len(refs), 2, "les xrefs CODE/ICOD doivent etre conservees")
self.assertEqual({x["xref_type"] for x in refs}, {"CODE", "ICOD"})
def test_priority_functions_non_vide_avec_xrefs_code(self):
class R(_FakeR2):
def cmdj(self, c):
self.commands.append(c)
if c == "aflj":
return [{"name": "sym.appel", "addr": 0x401500, "size": 100, "nbbs": 4}]
if c.startswith("axtj"):
return [{"type": "CODE", "from": 0x401510, "fcn_name": "sym.appel"}]
if c.startswith("agfj"):
return [{"blocks": [{"addr": 0x401500, "jump": 0x401520}]}]
return []
r = R(); _LAST_R2["obj"] = r
with mock.patch.object(wma.r2pipe, "open", return_value=r):
with wma.ReverseSession(str(self.sample), 10, self.logger) as s:
auto = wma.run_auto_reverse(s, ["SetThreadContext"], 5, True, self.logger)
self.assertTrue(auto["suspicious_api_xrefs"], "xrefs vides malgre une capacite detectee")
self.assertTrue(auto["priority_functions"], "aucune fonction prioritaire selectionnee")
# --- chaines de bruit dans la regle YARA ---
def test_stub_dos_et_prologues_exclus(self):
for bruit in ["!This program cannot be run in DOS mode.", "AWAVAUATUWVSH",
"X[^_]A\\A]A^A_", "_GLOBAL_H9", "UAWAVAUATWVSH"]:
self.assertFalse(wma._is_meaningful_string(bruit), bruit)
def test_vraies_chaines_conservees(self):
for utile in ["SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
"http://c2.example.test/gate",
"C:\\Users\\victim\\AppData\\Roaming\\svc.exe"]:
self.assertTrue(wma._is_meaningful_string(utile), utile)
def test_yara_ne_contient_pas_le_stub_dos(self):
blob = b"MZ" + b"\x00!This program cannot be run in DOS mode.\x00" * 3
blob += b"\x00 http://reel.example.test/gate \x00"
iocs = wma.extract_iocs(blob)
rule = wma.build_yara_rule("s.exe", "a"*64, iocs)
self.assertNotIn("DOS mode", rule, "regle YARA qui matcherait tous les PE")
# --- faux positifs XOR ---
def test_xor_sur_plage_uniforme_ne_produit_pas_de_domaines(self):
# 0x00 ^ 0x6c = 'l' : du padding devient "llllll..."
blob = b"MZ" + b"\x00" * 20000 + b"http://vrai.example.test/x"
res = wma._brute_force_xor_iocs(blob)
for entry in res:
for values in entry["iocs"].values():
for v in values:
self.assertLessEqual(wma._dominant_char_ratio(v), 0.4, v)
# --- faux positifs IOC ---
def test_extension_source_pas_un_domaine(self):
self.assertNotIn("main.cpp", wma._filter_domain_candidates(["main.cpp", "evil.test"]))
self.assertIn("evil.test", wma._filter_domain_candidates(["main.cpp", "evil.test"]))
def test_ipv4_usage_special_ecartee(self):
garde = wma._filter_ipv4_candidates(["3.0.0.0", "127.0.0.1", "0.0.0.0",
"224.0.0.1", "185.220.101.5"])
self.assertEqual(garde, ["185.220.101.5"])
# --- score proportionnel ---
def _rapport(self, caps):
return {"file_metadata": {"is_packed": False},
"pe_structure": {"behavioral_capabilities": caps,
"tls_callbacks": {"count": 0}, "overlay": {"size": 0},
"authenticode": {"has_signature": False}, "resources": {},
"checksum": {}},
"iocs": {}, "reverse_engineering": {}}
def test_une_seule_api_pese_moins_que_plusieurs(self):
faible = wma.compute_suspicion_score(
self._rapport({"Process_Injection_Hoisting": ["SetThreadContext"]}))["score"]
fort = wma.compute_suspicion_score(self._rapport({"Process_Injection_Hoisting": [
"VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread", "QueueUserAPC"]}))["score"]
self.assertLess(faible, fort)
self.assertIn("1/7 API", " ".join(wma.compute_suspicion_score(
self._rapport({"Process_Injection_Hoisting": ["SetThreadContext"]}))["reasons"]))
def test_injecteur_reel_reste_eleve(self):
r = wma.compute_suspicion_score(self._rapport({
"Process_Injection_Hoisting": ["VirtualAllocEx", "WriteProcessMemory",
"CreateRemoteThread", "QueueUserAPC"],
"Anti_Debugging_Evasion": ["IsDebuggerPresent", "CheckRemoteDebuggerPresent",
"NtQueryInformationProcess"],
"Ransomware_Crypto": ["CryptEncrypt", "CryptGenKey", "CryptAcquireContextA"]}))
self.assertEqual(r["level"], "eleve")
def test_elevation_de_privileges_scoree(self):
rap = self._rapport({})
rap["pe_structure"]["resources"] = {"requested_execution_level": "requireAdministrator"}
self.assertIn("elevation", " ".join(wma.compute_suspicion_score(rap)["reasons"]))
# --- divers ---
def test_entropie_nulle_pas_negative(self):
self.assertEqual(str(wma.calculate_entropy(b"\x00" * 100)), "0.0")
def test_version_info_sans_repr_objet(self):
class Items:
items = {"CompanyName": "ACME", "FileVersion": "1.0.0"}
class SFI:
langcode_items = [Items()]
class Ver:
string_file_info = SFI()
class RM:
version = Ver()
out = wma._extract_version_info(RM())
self.assertEqual(out["CompanyName"], "ACME")
self.assertNotIn("object at 0x", str(out), "fuite d'adresse memoire dans le rapport")
def test_version_info_robuste_si_api_absente(self):
class RM:
@property
def version(self): raise AttributeError("indisponible")
self.assertEqual(wma._extract_version_info(RM()), {})
class TestBinaireSainConnu(unittest.TestCase):
"""Regressions v7.4, calees sur un binaire GUI MinGW certifie sain
(source disponible) : aucun de ces faux positifs ne doit revenir."""
def _iocs_vides(self):
d = {k: [] for k in wma.IOC_PATTERNS}
d.update({"obfuscated_b64_iocs": [], "obfuscated_xor_iocs": [],
"notable_strings": [],
"extraction_stats": {"strings_scanned": 10232, "truncated": False}})
return d
def _rapport(self, caps=None, **kw):
return {"file_metadata": {"is_packed": kw.get("packed", False)},
"pe_structure": {"behavioral_capabilities": caps or {},
"tls_callbacks": {"count": kw.get("tls", 0)},
"overlay": kw.get("ov", {"size": 0}),
"authenticode": {"has_signature": False},
"resources": kw.get("res", {}), "checksum": {},
"delay_imported_dlls": []},
"iocs": kw.get("iocs", self._iocs_vides()), "reverse_engineering": {}}
# --- extraction_stats compte comme un IOC ---
def test_extraction_stats_nest_pas_un_ioc(self):
r = wma.compute_suspicion_score(self._rapport())
self.assertNotIn("type(s) d'IOC", " ".join(r["reasons"]),
"un bloc de metadonnees est compte comme un IOC")
def test_ioc_reel_toujours_compte(self):