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