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.
- ostruct/cli/base_errors.py +183 -0
- ostruct/cli/cli.py +830 -585
- ostruct/cli/click_options.py +338 -211
- ostruct/cli/errors.py +214 -227
- ostruct/cli/exit_codes.py +18 -0
- ostruct/cli/file_info.py +126 -69
- ostruct/cli/file_list.py +191 -72
- ostruct/cli/file_utils.py +132 -97
- ostruct/cli/path_utils.py +86 -77
- ostruct/cli/security/__init__.py +32 -0
- ostruct/cli/security/allowed_checker.py +55 -0
- ostruct/cli/security/base.py +46 -0
- ostruct/cli/security/case_manager.py +75 -0
- ostruct/cli/security/errors.py +164 -0
- ostruct/cli/security/normalization.py +161 -0
- ostruct/cli/security/safe_joiner.py +211 -0
- ostruct/cli/security/security_manager.py +366 -0
- ostruct/cli/security/symlink_resolver.py +483 -0
- ostruct/cli/security/types.py +108 -0
- ostruct/cli/security/windows_paths.py +404 -0
- ostruct/cli/serialization.py +25 -0
- ostruct/cli/template_filters.py +13 -8
- ostruct/cli/template_rendering.py +46 -22
- ostruct/cli/template_utils.py +12 -4
- ostruct/cli/template_validation.py +26 -8
- ostruct/cli/token_utils.py +43 -0
- ostruct/cli/validators.py +109 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/METADATA +64 -24
- ostruct_cli-0.5.0.dist-info/RECORD +42 -0
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/WHEEL +1 -1
- ostruct/cli/security.py +0 -964
- ostruct/cli/security_types.py +0 -46
- ostruct_cli-0.3.0.dist-info/RECORD +0 -28
- {ostruct_cli-0.3.0.dist-info → ostruct_cli-0.5.0.dist-info}/LICENSE +0 -0
- {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()
|