diff options
Diffstat (limited to 'pylogsentinel/core.py')
| -rw-r--r-- | pylogsentinel/core.py | 81 |
1 files changed, 66 insertions, 15 deletions
diff --git a/pylogsentinel/core.py b/pylogsentinel/core.py index 1b6ce13..5ee033e 100644 --- a/pylogsentinel/core.py +++ b/pylogsentinel/core.py @@ -67,6 +67,8 @@ class MatchEvent: line_number: int context: str matched_text: str + match_count: int = 1 + matched_lines: str = "" def parse_size(value: str) -> int: @@ -516,6 +518,15 @@ def read_new_lines( return lines, new_offset, new_last_line_number +def _rule_applies(rule: Rule, file_path: str) -> bool: + """Check if a rule applies to the given file path.""" + for base in rule.log_base_paths: + b = base.rstrip(os.sep) + if file_path == b or file_path.startswith(b + os.sep): + return True + return False + + def generate_matches( file_path: str, lines: list[str], @@ -524,33 +535,71 @@ def generate_matches( context_radius: int = CONTEXT_RADIUS, ) -> Iterator[MatchEvent]: """ - Iterate over lines and yield MatchEvent objects for each rule match. - A rule is only applied if the file belongs to its configured log set - (file path equal to or under one of the set's base paths). + Iterate over lines and yield one MatchEvent per cluster of nearby matches. + + Matches for the same rule are grouped into clusters: consecutive matches + whose gap (number of non-matching lines between them) is at most + context_radius are merged into one cluster. Each cluster produces a + single MatchEvent whose context spans from context_radius lines before + the first match to context_radius lines after the last match. """ total_lines = len(lines) + + # Collect per-rule match indices in one pass. + # Key: rule_id, Value: list of (line_index, matched_text) + rule_matches: dict[str, list[tuple[int, str]]] = {} + for idx, line in enumerate(lines): - absolute_line_number = initial_line_number + idx + 1 for rule in rules.values(): - applies = False - for base in rule.log_base_paths: - b = base.rstrip(os.sep) - if file_path == b or file_path.startswith(b + os.sep): - applies = True - break - if not applies: + if not _rule_applies(rule, file_path): continue m = rule.compiled.search(line) if not m: continue - start_ctx = max(0, idx - context_radius) - context = "".join(lines[start_ctx : idx + 1]) + rule_matches.setdefault(rule.rule_id, []).append((idx, m.group(0))) + + # Build clusters per rule and yield one MatchEvent per cluster. + for rule in rules.values(): + matches = rule_matches.get(rule.rule_id) + if not matches: + continue + + # matches are already in line order; group into clusters. + clusters: list[list[tuple[int, str]]] = [] + current_cluster: list[tuple[int, str]] = [matches[0]] + + for i in range(1, len(matches)): + prev_idx = current_cluster[-1][0] + curr_idx = matches[i][0] + gap = curr_idx - prev_idx - 1 # non-matching lines between + if gap <= context_radius: + current_cluster.append(matches[i]) + else: + clusters.append(current_cluster) + current_cluster = [matches[i]] + clusters.append(current_cluster) + + for cluster in clusters: + first_idx = cluster[0][0] + last_idx = cluster[-1][0] + + ctx_start = max(0, first_idx - context_radius) + ctx_end = min(total_lines - 1, last_idx + context_radius) + context = "".join(lines[ctx_start : ctx_end + 1]) + + first_line_number = initial_line_number + first_idx + 1 + matched_lines_str = ",".join( + str(initial_line_number + idx + 1) for idx, _ in cluster + ) + yield MatchEvent( rule=rule, file_path=file_path, - line_number=absolute_line_number, + line_number=first_line_number, context=context, - matched_text=m.group(0), + matched_text=cluster[0][1], + match_count=len(cluster), + matched_lines=matched_lines_str, ) @@ -574,6 +623,8 @@ def execute_action( "FILE": event.file_path, "LINE": str(event.line_number), "CONTEXT": event.context, + "MATCH_COUNT": str(event.match_count), + "MATCHED_LINES": event.matched_lines, } ) if env_overrides: |
