11#!/usr/bin/env python3
22
3- # Copyright (c) 2025 Battelle Energy Alliance, LLC. All rights reserved.
3+ # Copyright (c) 2026 Battelle Energy Alliance, LLC. All rights reserved.
44
55import fcntl
66import logging
77import magic
88import os
9+ import re
910import subprocess
1011import sys
1112import time
2122
2223lock_filename = os .path .join (gettempdir (), f'{ os .path .basename (__file__ )} .lock' )
2324
24- filebeat_registry_filename = os .getenv (
25- 'FILEBEAT_REGISTRY_FILE' , "/usr/share/filebeat-logs/data/registry/filebeat/log.json"
26- )
25+ filebeat_registry_filenames = [
26+ os .getenv ('FILEBEAT_REGISTRY_FILE' , "/usr/share/filebeat-logs/data/registry/filebeat/log.json" ),
27+ os .getenv ('FILEBEAT_REDIS_REGISTRY_FILE' , "/usr/share/filebeat-zeek-files-logs/data/registry/filebeat/log.json" ),
28+ ]
2729
2830zeek_dir = os .path .join (os .getenv ('FILEBEAT_ZEEK_DIR' , "/zeek/" ), '' )
2931zeek_live_dir = os .path .join (zeek_dir , "live/logs/" )
3234
3335suricata_dir = os .path .join (os .getenv ('FILEBEAT_SURICATA_LOG_PATH' , "/suricata/" ), '' )
3436
37+ filescan_dir = os .path .join (os .getenv ('FILEBEAT_FILESCAN_LOG_PATH' , "/filescan/" ), '' )
38+
3539# We're only able to do this pruning because we're forwarding the logs along to Logstash
3640# so they're not needed here anymore. If we're *not* forwarding, we can't delete them
3741# based on age like that.
4751_LOG_MIME_TYPES = (
4852 "application/json" ,
4953 "application/x-ndjson" ,
54+ "text/html" ,
5055 "text/plain" ,
5156 "text/x-file" ,
5257)
6671 "application/zip" ,
6772)
6873
74+ _LOG_FILE_TYPE_PATTERNS = [
75+ re .compile (r'^MS\s+Windows.*Event\s+Log' , re .IGNORECASE ),
76+ re .compile (r'\b(JSON|ASCII)\s+text\b' , re .IGNORECASE ),
77+ ]
78+
79+ _ARCHIVE_FILE_TYPE_PATTERNS = [
80+ re .compile (r'\b(archive|compressed)\s+data\b' , re .IGNORECASE ),
81+ ]
82+
6983# --------------------------------------------------------------------
7084# Helper functions
7185# --------------------------------------------------------------------
@@ -109,20 +123,34 @@ def check_file(
109123 return
110124
111125 # get the file type (treat zero-length files as log files)
112- file_type = magic .from_file (filename , mime = True )
113- if check_logs and clean_log_seconds > 0 and (file_stat .st_size == 0 or file_type in _LOG_MIME_TYPES ):
126+ clean_seconds = 0
127+ do_log_check = check_logs and clean_log_seconds > 0
128+ do_archive_check = check_archives and clean_zip_seconds > 0
129+ file_mime_type = magic .from_file (filename , mime = True )
130+ file_type = None
131+ if do_log_check and ((file_stat .st_size == 0 ) or (file_mime_type in _LOG_MIME_TYPES )):
114132 clean_seconds = clean_log_seconds
115- elif check_archives and clean_zip_seconds > 0 and file_type in _ARCHIVE_MIME_TYPES :
133+ elif do_archive_check and ( file_mime_type in _ARCHIVE_MIME_TYPES ) :
116134 clean_seconds = clean_zip_seconds
117135 else :
118- # not a file we're going to be messing with
119- logging .debug (f"Ignoring { filename } due to { file_type = } " )
120- return
121-
122- if clean_seconds > 0 and last_use_time >= clean_seconds :
136+ # mime type didn't match, fall back to the non-MIME file magic description
137+ # e.g., "application/octet-stream" vs "MS Windows Vista Event Log""
138+ file_type = magic .from_file (filename , mime = False )
139+ if do_log_check and any (p .search (file_type ) for p in _LOG_FILE_TYPE_PATTERNS ):
140+ clean_seconds = clean_log_seconds
141+ elif do_archive_check and any (p .search (file_type ) for p in _ARCHIVE_FILE_TYPE_PATTERNS ):
142+ clean_seconds = clean_zip_seconds
143+ else :
144+ # not a file we're going to be messing with
145+ logging .debug (f"Ignoring { filename } of type { file_type } ({ file_mime_type } )" )
146+ return
147+
148+ if (clean_seconds > 0 ) and (last_use_time >= clean_seconds ):
123149 # this is a closed file that is old, so delete it
124150 silent_remove (filename )
125- logging .info (f'Removed old file "{ filename } " ({ file_type } , used { last_use_time :.0f} seconds ago)' )
151+ logging .info (
152+ f'Removed old file "{ filename } " ({ file_type or file_mime_type } , used { last_use_time :.0f} seconds ago)'
153+ )
126154
127155 except FileNotFoundError :
128156 # file's already gone, oh well
@@ -132,30 +160,35 @@ def check_file(
132160 logging .error (f"{ type (e ).__name__ } for '{ filename } ': { e } " )
133161
134162
135- def list_files_in_dir (base_dir : str ) -> List [str ]:
163+ def list_files_in_dir (base_dir : str , sort_by_age : bool = False ) -> List [str ]:
136164 """Recursively list all files in a directory."""
137165 if not os .path .isdir (base_dir ):
138166 return []
139- return [os .path .join (root , f ) for root , _ , files in os .walk (base_dir ) for f in files ]
140-
167+ files = [os .path .join (root , f ) for root , _ , filenames in os .walk (base_dir ) for f in filenames ]
168+ return sorted ( files , key = os . path . getmtime ) if sort_by_age else files
141169
142- def load_filebeat_registry (registry_path : str ) -> List [Tuple [int , int ]]:
143- """Load the filebeat registry file and extract (device, inode) tuples."""
144- if not os .path .isfile (registry_path ):
145- return []
146- try :
147- with open (registry_path ) as f :
148- fb_reg = LoadFileIfJson (f , attemptLines = True )
149- except Exception as e :
150- logging .error (f"Failed to load filebeat registry: { e } " )
151- return []
152170
171+ def load_filebeat_registries (registry_paths : List [str ]) -> List [Tuple [int , int ]]:
172+ """Load the filebeat registry file(s) and extract (device, inode) tuples."""
153173 fb_files = []
154- for entry in fb_reg :
155- device = deep_get (entry , ['v' , 'FileStateOS' , 'device' ])
156- inode = deep_get (entry , ['v' , 'FileStateOS' , 'inode' ])
157- if device is not None and inode is not None :
158- fb_files .append ((int (device ), int (inode )))
174+
175+ for registry_path in registry_paths :
176+ if not os .path .isfile (registry_path ):
177+ continue
178+ try :
179+ with open (registry_path ) as f :
180+ fb_reg = LoadFileIfJson (f , attemptLines = True )
181+ except Exception as e :
182+ logging .error (f"Failed to load filebeat registry: { e } " )
183+ continue
184+
185+ if fb_reg :
186+ for entry in fb_reg :
187+ device = deep_get (entry , ['v' , 'FileStateOS' , 'device' ])
188+ inode = deep_get (entry , ['v' , 'FileStateOS' , 'inode' ])
189+ if device is not None and inode is not None :
190+ fb_files .append ((int (device ), int (inode )))
191+
159192 return fb_files
160193
161194
@@ -219,7 +252,7 @@ def prune_files() -> None:
219252 if (clean_log_seconds <= 0 ) and (clean_zip_seconds <= 0 ):
220253 return
221254
222- fb_files = load_filebeat_registry ( filebeat_registry_filename )
255+ fb_files = load_filebeat_registries ( filebeat_registry_filenames )
223256
224257 # look for regular Zeek files in the processed/ directory
225258 zeek_found = list_files_in_dir (zeek_processed_dir )
@@ -240,9 +273,17 @@ def prune_files() -> None:
240273 logging .debug (f"Found { len (suricata_files )} Suricata files to consider." )
241274 process_files (suricata_files , fb_files , check_logs = True , check_archives = False , label = "Suricata" )
242275
276+ # check the filescan logs
277+ filescan_files = list_files_in_dir (filescan_dir , sort_by_age = True )
278+ if filescan_files :
279+ # filescan_files is sorted sorted oldest to newest; don't consider the newest file for deletion
280+ filescan_files .pop ()
281+ logging .debug (f"Found { len (filescan_files )} filescan files to consider." )
282+ process_files (filescan_files , fb_files , check_logs = True , check_archives = False , label = "Filescan" )
283+
243284 # clean up any old and empty directories in Zeek processed/ and suricata non-live directories
244285 clean_dir_seconds = min (i for i in (clean_log_seconds , clean_zip_seconds ) if i > 0 )
245- cleanup_empty_dirs ([zeek_processed_dir , suricata_dir ], clean_dir_seconds )
286+ cleanup_empty_dirs ([zeek_processed_dir , filescan_dir , suricata_dir ], clean_dir_seconds )
246287
247288 logging .debug ("Finished pruning files." )
248289
0 commit comments