ostruct-cli 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.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.
@@ -0,0 +1,353 @@
1
+ """Security manager module.
2
+
3
+ This module provides a high-level SecurityManager class that uses the other modules to:
4
+ - Normalize paths
5
+ - Safely join paths
6
+ - Validate that paths are within allowed directories
7
+ - Resolve symlinks securely with depth and loop checking
8
+ - Manage case differences on case-insensitive systems
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import tempfile
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Generator, List, Optional, Union
17
+
18
+ from .allowed_checker import is_path_in_allowed_dirs
19
+ from .case_manager import CaseManager
20
+ from .errors import (
21
+ DirectoryNotFoundError,
22
+ PathSecurityError,
23
+ SecurityErrorReasons,
24
+ )
25
+ from .normalization import normalize_path
26
+ from .symlink_resolver import _resolve_symlink
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class SecurityManager:
32
+ """Manages security for file access.
33
+
34
+ Validates all file access against a base directory and optional
35
+ allowed directories. Prevents unauthorized access and directory
36
+ traversal attacks.
37
+
38
+ The security model is based on:
39
+ 1. A base directory that serves as the root for all file operations
40
+ 2. A set of explicitly allowed directories that can be accessed outside the base directory
41
+ 3. Special handling for temporary directories that are always allowed
42
+ 4. Case-sensitive or case-insensitive path handling based on platform
43
+
44
+ Example:
45
+ >>> sm = SecurityManager("/base/dir")
46
+ >>> sm.add_allowed_directory("/tmp")
47
+ >>> sm.validate_path("/base/dir/file.txt") # OK
48
+ >>> sm.validate_path("/etc/passwd") # Raises PathSecurityError
49
+ """
50
+
51
+ MAX_SYMLINK_DEPTH = 16
52
+
53
+ def __init__(
54
+ self,
55
+ base_dir: Union[str, Path],
56
+ allowed_dirs: Optional[List[Union[str, Path]]] = None,
57
+ allow_temp_paths: bool = False,
58
+ max_symlink_depth: int = MAX_SYMLINK_DEPTH,
59
+ ):
60
+ """Initialize the SecurityManager.
61
+
62
+ Args:
63
+ base_dir: The root directory for file operations.
64
+ allowed_dirs: Additional directories allowed for access.
65
+ allow_temp_paths: Whether to allow temporary directory paths.
66
+ max_symlink_depth: Maximum depth for symlink resolution.
67
+
68
+ Raises:
69
+ DirectoryNotFoundError: If base_dir or any allowed directory doesn't exist.
70
+ """
71
+ # Normalize and verify base directory
72
+ self._base_dir = normalize_path(base_dir)
73
+ if not self._base_dir.is_dir():
74
+ raise DirectoryNotFoundError(
75
+ f"Base directory not found: {base_dir}",
76
+ path=str(base_dir),
77
+ )
78
+
79
+ # Initialize allowed directories with the base directory
80
+ self._allowed_dirs: List[Path] = [self._base_dir]
81
+ if allowed_dirs:
82
+ for d in allowed_dirs:
83
+ self.add_allowed_directory(d)
84
+
85
+ self._allow_temp_paths = allow_temp_paths
86
+ self._max_symlink_depth = max_symlink_depth
87
+ self._temp_dir = (
88
+ normalize_path(tempfile.gettempdir()) if allow_temp_paths else None
89
+ )
90
+
91
+ logger.debug(
92
+ "\n=== Initialized SecurityManager ===\n"
93
+ "Base dir: %s\n"
94
+ "Allowed dirs: %s\n"
95
+ "Allow temp: %s\n"
96
+ "Temp dir: %s\n"
97
+ "Max symlink depth: %d",
98
+ self._base_dir,
99
+ self._allowed_dirs,
100
+ self._allow_temp_paths,
101
+ self._temp_dir,
102
+ self._max_symlink_depth,
103
+ )
104
+
105
+ @property
106
+ def base_dir(self) -> Path:
107
+ """Return the base directory."""
108
+ return self._base_dir
109
+
110
+ @property
111
+ def allowed_dirs(self) -> List[Path]:
112
+ """Return the list of allowed directories."""
113
+ return self._allowed_dirs.copy()
114
+
115
+ def add_allowed_directory(self, directory: Union[str, Path]) -> None:
116
+ """Add a new directory to the allowed directories list.
117
+
118
+ Args:
119
+ directory: The directory to add.
120
+
121
+ Raises:
122
+ DirectoryNotFoundError: If the directory doesn't exist.
123
+ """
124
+ norm_dir = normalize_path(directory)
125
+ if not norm_dir.is_dir():
126
+ raise DirectoryNotFoundError(
127
+ f"Allowed directory not found: {directory}",
128
+ path=str(directory),
129
+ )
130
+ if norm_dir not in self._allowed_dirs:
131
+ self._allowed_dirs.append(norm_dir)
132
+
133
+ def is_temp_path(self, path: Union[str, Path]) -> bool:
134
+ """Check if a path is in the system's temporary directory.
135
+
136
+ Args:
137
+ path: The path to check.
138
+
139
+ Returns:
140
+ True if the path is a temporary path; False otherwise.
141
+
142
+ Raises:
143
+ PathSecurityError: If there's an error checking the path.
144
+ """
145
+ if not self._allow_temp_paths or not self._temp_dir:
146
+ return False
147
+
148
+ try:
149
+ # Use string-based comparison instead of resolving
150
+ norm_path = normalize_path(path)
151
+ temp_path_str = str(self._temp_dir)
152
+ norm_path_str = str(norm_path)
153
+ return norm_path_str.startswith(temp_path_str)
154
+ except Exception as e:
155
+ raise PathSecurityError(
156
+ f"Error checking temporary path: {e}",
157
+ path=str(path),
158
+ ) from e
159
+
160
+ def is_path_allowed(self, path: Union[str, Path]) -> bool:
161
+ """Check if a path is allowed based on security rules.
162
+
163
+ Args:
164
+ path: The path to check.
165
+
166
+ Returns:
167
+ True if the path is allowed; False otherwise.
168
+ """
169
+ try:
170
+ norm_path = normalize_path(path)
171
+ except PathSecurityError:
172
+ return False
173
+
174
+ # Check if the path is within one of the allowed directories
175
+ if is_path_in_allowed_dirs(norm_path, self._allowed_dirs):
176
+ return True
177
+
178
+ # Allow temp paths if configured
179
+ if self._allow_temp_paths and self.is_temp_path(norm_path):
180
+ return True
181
+
182
+ return False
183
+
184
+ def validate_path(self, path: Union[str, Path]) -> Path:
185
+ """Validate a path against security rules.
186
+
187
+ This method:
188
+ 1. Checks if it's a symlink first
189
+ 2. Normalizes the input
190
+ 3. Validates against security rules
191
+ 4. Checks existence (only after security validation)
192
+
193
+ Args:
194
+ path: The path to validate.
195
+
196
+ Returns:
197
+ A validated and resolved Path object.
198
+
199
+ Raises:
200
+ PathSecurityError: If the path fails security validation
201
+ FileNotFoundError: If the file doesn't exist (only checked after security validation)
202
+ """
203
+ # First normalize the path
204
+ norm_path = normalize_path(path)
205
+
206
+ # Handle symlinks first - delegate to symlink_resolver
207
+ if norm_path.is_symlink():
208
+ try:
209
+ return _resolve_symlink(
210
+ norm_path,
211
+ self._max_symlink_depth,
212
+ self._allowed_dirs,
213
+ )
214
+ except RuntimeError as e:
215
+ if "Symlink loop" in str(e):
216
+ raise PathSecurityError(
217
+ "Symlink security violation: loop detected",
218
+ path=str(path),
219
+ context={"reason": SecurityErrorReasons.SYMLINK_LOOP},
220
+ ) from e
221
+ raise PathSecurityError(
222
+ f"Symlink security violation: failed to resolve symlink - {e}",
223
+ path=str(path),
224
+ context={"reason": SecurityErrorReasons.SYMLINK_ERROR},
225
+ ) from e
226
+
227
+ # For non-symlinks, just check if the normalized path is allowed
228
+ if not self.is_path_allowed(norm_path):
229
+ logger.error(
230
+ "Security violation: Path %s is outside allowed directories",
231
+ path,
232
+ )
233
+ raise PathSecurityError(
234
+ (
235
+ f"Access denied: {os.path.basename(str(path))} is outside "
236
+ "base directory and not in allowed directories"
237
+ ),
238
+ path=str(path),
239
+ context={
240
+ "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
241
+ "base_dir": str(self._base_dir),
242
+ "allowed_dirs": [str(d) for d in self._allowed_dirs],
243
+ },
244
+ )
245
+
246
+ # Only check existence after security validation passes
247
+ if not norm_path.exists():
248
+ logger.debug("Path allowed but not found: %s", norm_path)
249
+ raise FileNotFoundError(
250
+ f"File not found: {os.path.basename(str(path))}"
251
+ )
252
+
253
+ return norm_path
254
+
255
+ def resolve_path(self, path: Union[str, Path]) -> Path:
256
+ """Resolve a path with security checks.
257
+
258
+ This method maintains backward compatibility by translating
259
+ internal security errors to standard filesystem errors where appropriate.
260
+
261
+ Args:
262
+ path: Path to resolve
263
+
264
+ Returns:
265
+ Resolved Path object
266
+
267
+ Raises:
268
+ FileNotFoundError: If path doesn't exist or is a broken symlink
269
+ PathSecurityError: For other security violations
270
+ """
271
+ try:
272
+ norm_path = normalize_path(path)
273
+
274
+ # Early return for allowed temp paths
275
+ if self._allow_temp_paths and self.is_temp_path(norm_path):
276
+ logger.debug("Allowing temp path: %s", norm_path)
277
+ if not norm_path.exists():
278
+ raise FileNotFoundError(f"File not found: {path}")
279
+ return norm_path
280
+
281
+ # Handle symlinks with security checks
282
+ if norm_path.is_symlink():
283
+ try:
284
+ return _resolve_symlink(
285
+ norm_path, self._max_symlink_depth, self._allowed_dirs
286
+ )
287
+ except PathSecurityError as e:
288
+ reason = e.context.get("reason")
289
+ # First check for loop errors (highest precedence)
290
+ if reason == SecurityErrorReasons.SYMLINK_LOOP:
291
+ raise # Propagate loop errors unchanged
292
+ # Then check for max depth errors
293
+ elif reason == SecurityErrorReasons.SYMLINK_MAX_DEPTH:
294
+ raise # Propagate max depth errors unchanged
295
+ # Finally handle broken links (lowest precedence)
296
+ elif reason == SecurityErrorReasons.SYMLINK_BROKEN:
297
+ msg = f"Broken symlink: {e.context['source']} -> {e.context['target']}"
298
+ logger.debug(msg)
299
+ raise FileNotFoundError(msg) from e
300
+ # Any other security errors propagate unchanged
301
+ raise
302
+
303
+ # For non-symlinks, check if the normalized path is allowed
304
+ if not self.is_path_allowed(norm_path):
305
+ logger.error(
306
+ "Security violation: Path %s is outside allowed directories",
307
+ path,
308
+ )
309
+ raise PathSecurityError(
310
+ f"Access denied: {os.path.basename(str(path))} is outside base directory",
311
+ path=str(path),
312
+ context={
313
+ "reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
314
+ "base_dir": str(self._base_dir),
315
+ "allowed_dirs": [str(d) for d in self._allowed_dirs],
316
+ },
317
+ )
318
+
319
+ # Only check existence after security validation
320
+ if not norm_path.exists():
321
+ raise FileNotFoundError(f"File not found: {path}")
322
+
323
+ return norm_path
324
+
325
+ except OSError as e:
326
+ if isinstance(e, FileNotFoundError):
327
+ raise
328
+ logger.error("Error resolving path: %s - %s", path, e)
329
+ raise PathSecurityError(
330
+ f"Failed to resolve path: {e}",
331
+ path=str(path),
332
+ context={
333
+ "reason": SecurityErrorReasons.SYMLINK_ERROR,
334
+ "error": str(e),
335
+ },
336
+ ) from e
337
+
338
+ @contextmanager
339
+ def symlink_context(self) -> Generator[None, None, None]:
340
+ """Context manager for symlink resolution.
341
+
342
+ This context manager ensures that symlink resolution state is properly
343
+ cleaned up, even if an error occurs during resolution.
344
+
345
+ Example:
346
+ >>> with security_manager.symlink_context():
347
+ ... resolved = security_manager.resolve_path("/path/to/symlink")
348
+ """
349
+ try:
350
+ yield
351
+ finally:
352
+ # Clean up any case mappings that were created during symlink resolution
353
+ CaseManager.clear()