logler 1.0.2__cp311-cp311-manylinux_2_38_x86_64.whl → 1.0.7__cp311-cp311-manylinux_2_38_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logler/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
  Logler - Beautiful local log viewer with thread tracking and real-time updates.
3
3
  """
4
4
 
5
- __version__ = "1.0.0"
5
+ __version__ = "1.0.7"
6
6
  __author__ = "Logler Contributors"
7
7
 
8
8
  from .parser import LogParser, LogEntry
logler/investigate.py CHANGED
@@ -30,10 +30,13 @@ Example Usage:
30
30
 
31
31
  import json
32
32
  import re
33
+ import warnings
33
34
  from typing import List, Optional, Dict, Any, Tuple
34
35
  from datetime import datetime
35
36
  from collections import defaultdict
36
37
 
38
+ from .safe_regex import try_compile
39
+
37
40
  try:
38
41
  import logler_rs
39
42
 
@@ -48,10 +51,10 @@ except ImportError:
48
51
  RUST_AVAILABLE = True
49
52
  else:
50
53
  RUST_AVAILABLE = False
51
- print("Warning: Rust backend not available. Using Python fallback.")
52
- except Exception:
54
+ warnings.warn("Rust backend not available. Using Python fallback.", stacklevel=2)
55
+ except (ImportError, AttributeError, OSError):
53
56
  RUST_AVAILABLE = False
54
- print("Warning: Rust backend not available. Using Python fallback.")
57
+ warnings.warn("Rust backend not available. Using Python fallback.", stacklevel=2)
55
58
 
56
59
 
57
60
  def _normalize_entry(entry: Dict[str, Any]) -> None:
@@ -97,9 +100,8 @@ def _apply_custom_regex_to_results(result: Dict[str, Any], pattern: Optional[str
97
100
  """Apply a user-provided regex to fill missing fields like timestamp/level."""
98
101
  if not pattern:
99
102
  return
100
- try:
101
- regex = re.compile(pattern)
102
- except re.error:
103
+ regex = try_compile(pattern)
104
+ if regex is None:
103
105
  return
104
106
 
105
107
  for item in result.get("results", []) or []:
@@ -2198,7 +2200,7 @@ def cross_service_timeline(
2198
2200
  e for e in all_entries if e["timestamp"] and start_dt <= e["timestamp"] <= end_dt
2199
2201
  ]
2200
2202
  except Exception as e:
2201
- print(f"Warning: Could not parse time window: {e}")
2203
+ warnings.warn(f"Could not parse time window: {e}", stacklevel=2)
2202
2204
 
2203
2205
  # Sort by timestamp
2204
2206
  all_entries.sort(key=lambda e: e["timestamp"] if e["timestamp"] else datetime.min)
logler/llm_cli.py CHANGED
@@ -18,6 +18,8 @@ from typing import Optional, List, Dict, Any
18
18
  from datetime import datetime, timedelta
19
19
  from collections import defaultdict
20
20
 
21
+ from .safe_regex import safe_compile, RegexTimeoutError, RegexPatternTooLongError
22
+
21
23
  # Exit codes
22
24
  EXIT_SUCCESS = 0 # Success with results
23
25
  EXIT_NO_RESULTS = 1 # Success but no results found
@@ -808,8 +810,8 @@ def verify_pattern(
808
810
  _error_json(f"No files found matching: {files}")
809
811
 
810
812
  try:
811
- regex = re.compile(pattern)
812
- except re.error as e:
813
+ regex = safe_compile(pattern)
814
+ except (re.error, RegexTimeoutError, RegexPatternTooLongError) as e:
813
815
  _error_json(f"Invalid regex pattern: {e}")
814
816
 
815
817
  parser = LogParser()
@@ -950,8 +952,8 @@ def emit(
950
952
  query_regex = None
951
953
  if query:
952
954
  try:
953
- query_regex = re.compile(query, re.IGNORECASE)
954
- except re.error as e:
955
+ query_regex = safe_compile(query, re.IGNORECASE)
956
+ except (re.error, RegexTimeoutError, RegexPatternTooLongError) as e:
955
957
  _error_json(f"Invalid regex pattern: {e}")
956
958
 
957
959
  for file_path in file_list:
logler/safe_regex.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ Safe regex compilation with timeout protection against ReDoS attacks.
3
+
4
+ This module provides a safe_compile function that wraps re.compile with:
5
+ - Pattern length validation
6
+ - Compilation timeout (Unix only, graceful fallback on Windows)
7
+ - Clear error messages
8
+ """
9
+
10
+ import re
11
+ import threading
12
+ from typing import Optional
13
+
14
+
15
+ class RegexTimeoutError(Exception):
16
+ """Raised when regex compilation times out."""
17
+
18
+ pass
19
+
20
+
21
+ class RegexPatternTooLongError(Exception):
22
+ """Raised when regex pattern exceeds maximum allowed length."""
23
+
24
+ pass
25
+
26
+
27
+ # Maximum pattern length to prevent ReDoS via complexity
28
+ MAX_PATTERN_LENGTH = 1000
29
+
30
+ # Timeout for regex compilation in seconds
31
+ COMPILE_TIMEOUT = 2.0
32
+
33
+
34
+ def _compile_with_timeout(
35
+ pattern: str,
36
+ flags: int,
37
+ timeout: float,
38
+ result_container: dict,
39
+ ) -> None:
40
+ """Worker function for threaded compilation."""
41
+ try:
42
+ result_container["result"] = re.compile(pattern, flags)
43
+ except re.error as e:
44
+ result_container["error"] = e
45
+ except Exception as e:
46
+ result_container["error"] = e
47
+
48
+
49
+ def safe_compile(
50
+ pattern: str,
51
+ flags: int = 0,
52
+ timeout: float = COMPILE_TIMEOUT,
53
+ max_length: int = MAX_PATTERN_LENGTH,
54
+ ) -> re.Pattern:
55
+ """
56
+ Safely compile a regex pattern with timeout protection.
57
+
58
+ Args:
59
+ pattern: The regex pattern to compile
60
+ flags: Optional regex flags (e.g., re.IGNORECASE)
61
+ timeout: Maximum time in seconds to allow for compilation
62
+ max_length: Maximum allowed pattern length
63
+
64
+ Returns:
65
+ Compiled regex pattern
66
+
67
+ Raises:
68
+ RegexPatternTooLongError: If pattern exceeds max_length
69
+ RegexTimeoutError: If compilation takes longer than timeout
70
+ re.error: If the pattern is invalid
71
+ """
72
+ # Validate pattern length
73
+ if len(pattern) > max_length:
74
+ raise RegexPatternTooLongError(
75
+ f"Regex pattern length {len(pattern)} exceeds maximum {max_length}"
76
+ )
77
+
78
+ # Use threading for cross-platform timeout support
79
+ result_container: dict = {}
80
+ thread = threading.Thread(
81
+ target=_compile_with_timeout,
82
+ args=(pattern, flags, timeout, result_container),
83
+ )
84
+ thread.start()
85
+ thread.join(timeout=timeout)
86
+
87
+ if thread.is_alive():
88
+ # Thread is still running - compilation timed out
89
+ # Note: We can't actually kill the thread, but we return an error
90
+ raise RegexTimeoutError(
91
+ f"Regex compilation timed out after {timeout}s (pattern may cause catastrophic backtracking)"
92
+ )
93
+
94
+ if "error" in result_container:
95
+ raise result_container["error"]
96
+
97
+ return result_container["result"]
98
+
99
+
100
+ def try_compile(
101
+ pattern: str,
102
+ flags: int = 0,
103
+ timeout: float = COMPILE_TIMEOUT,
104
+ max_length: int = MAX_PATTERN_LENGTH,
105
+ ) -> Optional[re.Pattern]:
106
+ """
107
+ Try to compile a regex pattern safely, returning None on failure.
108
+
109
+ This is a convenience wrapper around safe_compile that catches all
110
+ exceptions and returns None instead.
111
+
112
+ Args:
113
+ pattern: The regex pattern to compile
114
+ flags: Optional regex flags
115
+ timeout: Maximum compilation time
116
+ max_length: Maximum pattern length
117
+
118
+ Returns:
119
+ Compiled pattern or None if compilation fails
120
+ """
121
+ try:
122
+ return safe_compile(pattern, flags, timeout, max_length)
123
+ except (RegexTimeoutError, RegexPatternTooLongError, re.error):
124
+ return None
logler/web/app.py CHANGED
@@ -36,6 +36,22 @@ def _ensure_within_root(path: Path) -> Path:
36
36
  raise HTTPException(status_code=403, detail="Requested path is outside the configured log root")
37
37
 
38
38
 
39
+ def _sanitize_glob_pattern(pattern: str) -> str:
40
+ """Remove path traversal sequences from glob patterns."""
41
+ import re as _re
42
+
43
+ # Remove any ../ or ..\ sequences that could escape the root
44
+ # Use a loop to handle multiple consecutive traversal attempts like ../../
45
+ while ".." in pattern:
46
+ old_pattern = pattern
47
+ pattern = _re.sub(r"\.\.[\\/]", "", pattern)
48
+ pattern = _re.sub(r"[\\/]\.\.", "", pattern)
49
+ pattern = _re.sub(r"^\.\.", "", pattern) # Leading ..
50
+ if pattern == old_pattern:
51
+ break # No more changes possible
52
+ return pattern
53
+
54
+
39
55
  def _glob_within_root(pattern: str) -> List[Path]:
40
56
  """
41
57
  Run a glob pattern scoped to LOG_ROOT, returning file paths only.
@@ -43,6 +59,9 @@ def _glob_within_root(pattern: str) -> List[Path]:
43
59
  if not pattern:
44
60
  return []
45
61
 
62
+ # Sanitize the pattern to prevent path traversal
63
+ pattern = _sanitize_glob_pattern(pattern)
64
+
46
65
  # Normalize relative patterns to LOG_ROOT
47
66
  raw_pattern = pattern
48
67
  if not Path(pattern).is_absolute():
@@ -168,14 +187,14 @@ def _rust_filter(
168
187
  ) -> Optional[List[Dict[str, Any]]]:
169
188
  try:
170
189
  import logler_rs # type: ignore
171
- except Exception:
190
+ except ImportError:
172
191
  return None
173
192
 
174
193
  try:
175
194
  from ..cache import get_cached_investigator
176
195
 
177
196
  inv = get_cached_investigator(files)
178
- except Exception:
197
+ except (ImportError, AttributeError, TypeError):
179
198
  inv = logler_rs.PyInvestigator()
180
199
  inv.load_files(files)
181
200
 
@@ -226,7 +245,8 @@ def _rust_filter(
226
245
  if track:
227
246
  _track_entries(entries)
228
247
  return entries
229
- except Exception:
248
+ except (json.JSONDecodeError, KeyError, ValueError, AttributeError, RuntimeError):
249
+ # Rust filter failed - fall back to Python implementation
230
250
  return None
231
251
 
232
252
 
@@ -646,7 +666,10 @@ async def websocket_endpoint(websocket: WebSocket):
646
666
  await follow_file(websocket, file_path, current_filters, drop_count)
647
667
 
648
668
  except WebSocketDisconnect:
649
- websocket_clients.remove(websocket)
669
+ pass # Normal disconnect
670
+ finally:
671
+ if websocket in websocket_clients:
672
+ websocket_clients.remove(websocket)
650
673
 
651
674
 
652
675
  async def follow_file(
@@ -667,7 +690,8 @@ async def follow_file(
667
690
  with open(path, "r") as f:
668
691
  f.seek(0, 2)
669
692
  position = f.tell()
670
- line_number = sum(1 for _ in open(path))
693
+ with open(path, "r") as f:
694
+ line_number = sum(1 for _ in f)
671
695
 
672
696
  # Follow file
673
697
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logler
3
- Version: 1.0.2
3
+ Version: 1.0.7
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,27 +1,28 @@
1
- logler/__init__.py,sha256=MqBmjqDvmhdbyNw8RPc-yod-AteuPaRnEj7-cHyfFxk,513
1
+ logler/__init__.py,sha256=0WHiHld8WxfO6VAlSVrrguLyPJy3dYsVWs4RIAB5rK8,513
2
2
  logler/bootstrap.py,sha256=mpc3VUHDPXbMYj1xlZuGmx4I6vSRjL880TuJpBkZj4k,1289
3
3
  logler/cache.py,sha256=HxJZ_s2yzhMgz77QpSKgdC8UZextS0PKgtmi0yHnfu8,2324
4
4
  logler/cli.py,sha256=87E4V9Ow8A26y4ymyZANLuHkL3bXXteQ3nRAl0y2wcA,22816
5
5
  logler/helpers.py,sha256=lhv_a6RQ97JcOp8SVhwVEdFhT-rfMSxf4fqe9a_Xsfk,9182
6
- logler/investigate.py,sha256=7ZqsF3LPyHdt9rk6293oo0oHjho9sqETlr0nh9NNWqQ,136351
7
- logler/llm_cli.py,sha256=4HN24xHeOH28zto7PiQEmajK3g_FO90OMSYAxkDmpg0,49087
6
+ logler/investigate.py,sha256=0VCqkTB_U6oc2BfCdLZox94QZo1UliMHrg-2-4TBSQw,136461
7
+ logler/llm_cli.py,sha256=JryWimoTHkvOv1F0IyJmRhoDR0rei48toXrUlLgvo8s,49268
8
8
  logler/log_reader.py,sha256=VCnvlj90peG-jFPJujyV7WzSk-fFdefLs_xAhON6lhA,8545
9
9
  logler/parser.py,sha256=GyOP0WnpkvaLP-ZZ3ufmyUb3XRM02SDkt6R5msR7vXc,6484
10
+ logler/safe_regex.py,sha256=K7ryQSvUrTZG8bynJ2K1s7TdBup9hW3DJvQlG9yJ3vA,3431
10
11
  logler/terminal.py,sha256=46iQAxU7mX5OVC8GhWi_aSs1hRGCNS3Ub_XmguMPWS8,8103
11
12
  logler/tracker.py,sha256=zlVFr3H7ev1A7klziVwNwpZ9_PH9eShyJYU4AI7wXaw,4656
12
13
  logler/tree_formatter.py,sha256=ZIG12bt7MfbTk4vLwtvvE9MUGIKBjBl1wn57jpjbmhg,27576
13
14
  logler/watcher.py,sha256=p5HyCjVy7O14CA-RI8_HoZbZXdXHaNy04_gs60vqQt4,1526
14
15
  logler/web/__init__.py,sha256=5UhDXVGSD9f7hGVVgsU1cJha1lHeZvP5tAYr3oUf09Q,34
15
- logler/web/app.py,sha256=3fPVQf75O2x6lJGtYL7kF3TkkH87gdvOD66h-k30stI,24942
16
+ logler/web/app.py,sha256=vY8HZPaul5PLuHR-cIZ7l6pYWv7K0ORDyN5-2FH3Ui4,25927
16
17
  logler/web/static/css/tailwind.css,sha256=mUKXSL_ROgO2A1hhOcPxQhvUmdRl72RNRZUGTQSKZkU,12743
17
18
  logler/web/static/css/tailwind.input.css,sha256=zBp60NAZ3bHTLQ7LWIugrCbOQdhiXdbDZjSLJfg6KOw,59
18
19
  logler/web/static/logler-logo.png,sha256=5S0tVP1HqU2VniFmfTWISJfUHkJSKlww_cilBwWm-uU,1430687
19
20
  logler/web/tailwind.config.cjs,sha256=aDOQf-5Fq4l73hN4BgkkjRMbR9EF2toT2rhW2Z5a_Mo,130
20
21
  logler/web/templates/index.html,sha256=ymQIQDQGctv3tuW6ig7mIoQRjtbed5Den0xxeF72RVI,77047
21
- logler-1.0.2.dist-info/METADATA,sha256=NyH7UUNQU7rRVMTZ9Uy1l1Zcj7t3km73ILcSkXFyzz4,20835
22
- logler-1.0.2.dist-info/WHEEL,sha256=yOXEotj6jig1MGTeOjL9PYNG7IpztVvHxNhmm3wo1LQ,109
23
- logler-1.0.2.dist-info/entry_points.txt,sha256=KAtycgnrhm85NgD_TCx2QslE9oClYHWKPM9NG598RNs,41
24
- logler-1.0.2.dist-info/licenses/LICENSE,sha256=SaPvdtwQLl1BA-6s4Exl0M3X7L_6gcnOGzBD86t7dIo,1061
22
+ logler-1.0.7.dist-info/METADATA,sha256=07N_AgpYnPpC5eGFSjD9E4rKOinix6Xmj56pRkTaN0U,20835
23
+ logler-1.0.7.dist-info/WHEEL,sha256=yOXEotj6jig1MGTeOjL9PYNG7IpztVvHxNhmm3wo1LQ,109
24
+ logler-1.0.7.dist-info/entry_points.txt,sha256=KAtycgnrhm85NgD_TCx2QslE9oClYHWKPM9NG598RNs,41
25
+ logler-1.0.7.dist-info/licenses/LICENSE,sha256=SaPvdtwQLl1BA-6s4Exl0M3X7L_6gcnOGzBD86t7dIo,1061
25
26
  logler_rs/__init__.py,sha256=y2ULUqMIhS92vwz6utTdkCn5l_T1Kso3YLGqCIZUxKY,119
26
- logler_rs/logler_rs.cpython-311-x86_64-linux-gnu.so,sha256=UU4fGAp-2OZxWMKWF7swL_2jX4B-ozFjHu51LVmTBrU,48988960
27
- logler-1.0.2.dist-info/RECORD,,
27
+ logler_rs/logler_rs.cpython-311-x86_64-linux-gnu.so,sha256=PpoObvCVQNVWpNeWZxWkbCrOea0zkLbZGoB2doBwZHE,48948480
28
+ logler-1.0.7.dist-info/RECORD,,
File without changes