patcherly-connector 2.2.0__tar.gz
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.
- patcherly_connector-2.2.0/LICENSE +21 -0
- patcherly_connector-2.2.0/PKG-INFO +49 -0
- patcherly_connector-2.2.0/README.md +33 -0
- patcherly_connector-2.2.0/backup_manager.py +521 -0
- patcherly_connector-2.2.0/context_collector.py +292 -0
- patcherly_connector-2.2.0/credential_store.py +83 -0
- patcherly_connector-2.2.0/lib/__init__.py +1 -0
- patcherly_connector-2.2.0/lib/api_paths.py +27 -0
- patcherly_connector-2.2.0/lib/ingest_severity.py +54 -0
- patcherly_connector-2.2.0/oauth_client.py +179 -0
- patcherly_connector-2.2.0/patch_applicator.py +380 -0
- patcherly_connector-2.2.0/patcherly_cli.py +434 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/PKG-INFO +49 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/SOURCES.txt +21 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/dependency_links.txt +1 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/entry_points.txt +2 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/requires.txt +2 -0
- patcherly_connector-2.2.0/patcherly_connector.egg-info/top_level.txt +10 -0
- patcherly_connector-2.2.0/pyproject.toml +42 -0
- patcherly_connector-2.2.0/python_agent.py +2200 -0
- patcherly_connector-2.2.0/queue_manager.py +430 -0
- patcherly_connector-2.2.0/sanitizer.py +360 -0
- patcherly_connector-2.2.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Shambix (Patcherly)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: patcherly-connector
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Patcherly Python connector — monitors logs and applies approved fixes via OAuth.
|
|
5
|
+
Author-email: Patcherly <support@patcherly.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://patcherly.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Patcherly-Official/patcherly-connector-packages
|
|
9
|
+
Project-URL: Documentation, https://help.patcherly.com/connectors/python/
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: python-dotenv>=1.0
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Patcherly Python connector
|
|
18
|
+
|
|
19
|
+
Monitors application logs and applies approved fixes via OAuth pairing with [Patcherly](https://patcherly.com).
|
|
20
|
+
|
|
21
|
+
## Recommended install
|
|
22
|
+
|
|
23
|
+
Use the [universal installer](https://help.patcherly.com/getting-started/installing-connector/) (one command, auto-pairs).
|
|
24
|
+
|
|
25
|
+
## Package install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install patcherly-connector
|
|
29
|
+
patcherly login
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Pairing
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
patcherly login
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Documentation
|
|
39
|
+
|
|
40
|
+
- [Python connector guide](https://help.patcherly.com/connectors/python/)
|
|
41
|
+
- [All connectors (releases & source)](https://github.com/Patcherly-Official/patcherly-connector-packages#readme)
|
|
42
|
+
|
|
43
|
+
## Support
|
|
44
|
+
|
|
45
|
+
- [Report a bug](https://github.com/Patcherly-Official/patcherly-connector-packages/issues)
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Patcherly Python connector
|
|
2
|
+
|
|
3
|
+
Monitors application logs and applies approved fixes via OAuth pairing with [Patcherly](https://patcherly.com).
|
|
4
|
+
|
|
5
|
+
## Recommended install
|
|
6
|
+
|
|
7
|
+
Use the [universal installer](https://help.patcherly.com/getting-started/installing-connector/) (one command, auto-pairs).
|
|
8
|
+
|
|
9
|
+
## Package install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install patcherly-connector
|
|
13
|
+
patcherly login
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Pairing
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
patcherly login
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Documentation
|
|
23
|
+
|
|
24
|
+
- [Python connector guide](https://help.patcherly.com/connectors/python/)
|
|
25
|
+
- [All connectors (releases & source)](https://github.com/Patcherly-Official/patcherly-connector-packages#readme)
|
|
26
|
+
|
|
27
|
+
## Support
|
|
28
|
+
|
|
29
|
+
- [Report a bug](https://github.com/Patcherly-Official/patcherly-connector-packages/issues)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent-Side Backup Manager
|
|
3
|
+
Manages versioned backups with checksums, compression, and integrity verification.
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import gzip
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Dict, Optional, Any
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BackupMetadata:
|
|
20
|
+
"""Metadata for a backup operation."""
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
error_id: str,
|
|
24
|
+
backup_dir: str,
|
|
25
|
+
files: List[str],
|
|
26
|
+
manifest: Dict[str, Any],
|
|
27
|
+
created_at: str,
|
|
28
|
+
verified: bool = True
|
|
29
|
+
):
|
|
30
|
+
self.error_id = error_id
|
|
31
|
+
self.backup_dir = backup_dir
|
|
32
|
+
self.files = files
|
|
33
|
+
self.manifest = manifest
|
|
34
|
+
self.created_at = created_at
|
|
35
|
+
self.verified = verified
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
38
|
+
"""Convert to dictionary for JSON serialization."""
|
|
39
|
+
return {
|
|
40
|
+
"error_id": self.error_id,
|
|
41
|
+
"backup_dir": self.backup_dir,
|
|
42
|
+
"files": self.files,
|
|
43
|
+
"manifest": self.manifest,
|
|
44
|
+
"created_at": self.created_at,
|
|
45
|
+
"verified": self.verified
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AgentBackupManager:
|
|
50
|
+
"""
|
|
51
|
+
Manages backups locally on target environment.
|
|
52
|
+
|
|
53
|
+
Backup structure (default dir: .patcherly_backups or PATCHERLY_BACKUP_ROOT):
|
|
54
|
+
.patcherly_backups/ or custom path
|
|
55
|
+
{error_id}/
|
|
56
|
+
{timestamp}/
|
|
57
|
+
manifest.json
|
|
58
|
+
{file_name}
|
|
59
|
+
...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, backup_root: str | None = None):
|
|
63
|
+
"""
|
|
64
|
+
Initialize backup manager.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
backup_root: Root directory for backups. If None, uses:
|
|
68
|
+
- PATCHERLY_BACKUP_ROOT environment variable
|
|
69
|
+
- ../backups/ (outside webroot, default)
|
|
70
|
+
"""
|
|
71
|
+
if backup_root is None:
|
|
72
|
+
backup_root = os.getenv('PATCHERLY_BACKUP_ROOT') or '../backups'
|
|
73
|
+
|
|
74
|
+
# Validate and resolve backup root path
|
|
75
|
+
backup_root_path = Path(backup_root)
|
|
76
|
+
if not backup_root_path.is_absolute():
|
|
77
|
+
backup_root_path = backup_root_path.resolve()
|
|
78
|
+
|
|
79
|
+
# Security: Ensure path is absolute and doesn't contain dangerous patterns
|
|
80
|
+
self.backup_root = backup_root_path.resolve()
|
|
81
|
+
|
|
82
|
+
# Additional security: Validate path doesn't contain dangerous patterns
|
|
83
|
+
# Note: After resolve(), '..' is normalized, but we check the original input
|
|
84
|
+
backup_root_str = str(backup_root)
|
|
85
|
+
if '..' in backup_root_str and not self.backup_root.exists():
|
|
86
|
+
# If original had '..' and resolved path doesn't exist, it might be traversal
|
|
87
|
+
logger.warning(f"Backup root path contains '..' and resolved path doesn't exist: {backup_root}")
|
|
88
|
+
|
|
89
|
+
self.backup_root.mkdir(parents=True, exist_ok=True, mode=0o700) # Restrictive permissions
|
|
90
|
+
logger.info(f"Initialized AgentBackupManager with root: {self.backup_root}")
|
|
91
|
+
|
|
92
|
+
# Ensure backup directory is protected from direct web access
|
|
93
|
+
self._ensure_backup_protection()
|
|
94
|
+
|
|
95
|
+
async def create_backup(
|
|
96
|
+
self,
|
|
97
|
+
error_id: str,
|
|
98
|
+
files: List[str],
|
|
99
|
+
compress: bool = True,
|
|
100
|
+
verify: bool = True
|
|
101
|
+
) -> BackupMetadata:
|
|
102
|
+
"""
|
|
103
|
+
Create a versioned backup with checksums.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
error_id: Unique error identifier
|
|
107
|
+
files: List of file paths to backup
|
|
108
|
+
compress: Whether to compress backup files
|
|
109
|
+
verify: Whether to verify backup integrity after creation
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
BackupMetadata object
|
|
113
|
+
"""
|
|
114
|
+
timestamp = datetime.now(timezone.utc).isoformat().replace(':', '-')
|
|
115
|
+
backup_dir = self.backup_root / error_id / timestamp
|
|
116
|
+
backup_dir.mkdir(parents=True, exist_ok=True, mode=0o755)
|
|
117
|
+
|
|
118
|
+
logger.info(f"Creating backup in {backup_dir} for {len(files)} file(s)")
|
|
119
|
+
|
|
120
|
+
# Backup files with checksums
|
|
121
|
+
backup_manifest = {}
|
|
122
|
+
|
|
123
|
+
for file_path in files:
|
|
124
|
+
try:
|
|
125
|
+
file_path_obj = Path(file_path)
|
|
126
|
+
|
|
127
|
+
# Security: Validate file path to prevent directory traversal
|
|
128
|
+
if not self._validate_file_path(file_path_obj):
|
|
129
|
+
logger.warning(f"Invalid or unsafe file path, skipping: {file_path}")
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# Check if file exists
|
|
133
|
+
if not file_path_obj.exists():
|
|
134
|
+
logger.warning(f"File not found, skipping: {file_path}")
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Read file content
|
|
138
|
+
content = await self._read_file_async(file_path_obj)
|
|
139
|
+
|
|
140
|
+
# Calculate checksum
|
|
141
|
+
checksum = hashlib.sha256(content).hexdigest()
|
|
142
|
+
file_size = len(content)
|
|
143
|
+
|
|
144
|
+
# Determine backup filename (preserve relative path structure)
|
|
145
|
+
# For simplicity, use just the filename, but could preserve path structure
|
|
146
|
+
backup_file_name = file_path_obj.name
|
|
147
|
+
|
|
148
|
+
# If file is in a subdirectory, preserve structure
|
|
149
|
+
if file_path_obj.parent != Path('.'):
|
|
150
|
+
# Create subdirectory structure in backup
|
|
151
|
+
rel_path = file_path_obj.relative_to(file_path_obj.anchor)
|
|
152
|
+
backup_file_name = str(rel_path).replace(os.sep, '_')
|
|
153
|
+
|
|
154
|
+
backup_file = backup_dir / backup_file_name
|
|
155
|
+
|
|
156
|
+
# Write backup file
|
|
157
|
+
await self._write_file_async(backup_file, content)
|
|
158
|
+
|
|
159
|
+
# Compress if requested
|
|
160
|
+
if compress and file_size > 0:
|
|
161
|
+
compressed_file = backup_file.with_suffix(backup_file.suffix + '.gz')
|
|
162
|
+
await self._compress_file(backup_file, compressed_file)
|
|
163
|
+
# Remove uncompressed file
|
|
164
|
+
backup_file.unlink()
|
|
165
|
+
backup_file = compressed_file
|
|
166
|
+
file_size = compressed_file.stat().st_size
|
|
167
|
+
|
|
168
|
+
backup_manifest[file_path] = {
|
|
169
|
+
'checksum': checksum,
|
|
170
|
+
'size': file_size,
|
|
171
|
+
'backup_path': str(backup_file),
|
|
172
|
+
'original_size': len(content),
|
|
173
|
+
'compressed': compress and file_size > 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
logger.debug(f"Backed up {file_path} -> {backup_file} (checksum: {checksum[:16]}...)")
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Failed to backup file {file_path}: {e}")
|
|
180
|
+
# Continue with other files
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if not backup_manifest:
|
|
184
|
+
raise ValueError("No files were successfully backed up")
|
|
185
|
+
|
|
186
|
+
# Write manifest
|
|
187
|
+
manifest_path = backup_dir / 'manifest.json'
|
|
188
|
+
manifest_data = {
|
|
189
|
+
'error_id': error_id,
|
|
190
|
+
'created_at': timestamp,
|
|
191
|
+
'files': backup_manifest,
|
|
192
|
+
'backup_version': 1
|
|
193
|
+
}
|
|
194
|
+
await self._write_file_async(manifest_path, json.dumps(manifest_data, indent=2).encode('utf-8'))
|
|
195
|
+
|
|
196
|
+
# Verify backup integrity if requested
|
|
197
|
+
verified = True
|
|
198
|
+
if verify:
|
|
199
|
+
verified = await self._verify_backup_integrity(backup_dir, backup_manifest)
|
|
200
|
+
|
|
201
|
+
metadata = BackupMetadata(
|
|
202
|
+
error_id=error_id,
|
|
203
|
+
backup_dir=str(backup_dir),
|
|
204
|
+
files=list(backup_manifest.keys()),
|
|
205
|
+
manifest=backup_manifest,
|
|
206
|
+
created_at=timestamp,
|
|
207
|
+
verified=verified
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
logger.info(f"Backup created successfully: {backup_dir} (verified: {verified})")
|
|
211
|
+
return metadata
|
|
212
|
+
|
|
213
|
+
async def _read_file_async(self, file_path: Path) -> bytes:
|
|
214
|
+
"""Read file asynchronously."""
|
|
215
|
+
loop = asyncio.get_event_loop()
|
|
216
|
+
with open(file_path, 'rb') as f:
|
|
217
|
+
return await loop.run_in_executor(None, f.read)
|
|
218
|
+
|
|
219
|
+
async def _write_file_async(self, file_path: Path, content: bytes):
|
|
220
|
+
"""Write file asynchronously."""
|
|
221
|
+
loop = asyncio.get_event_loop()
|
|
222
|
+
# Ensure parent directory exists
|
|
223
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
with open(file_path, 'wb') as f:
|
|
225
|
+
await loop.run_in_executor(None, f.write, content)
|
|
226
|
+
|
|
227
|
+
async def _compress_file(self, source: Path, dest: Path):
|
|
228
|
+
"""Compress a file using gzip."""
|
|
229
|
+
loop = asyncio.get_event_loop()
|
|
230
|
+
with open(source, 'rb') as f_in:
|
|
231
|
+
with gzip.open(dest, 'wb') as f_out:
|
|
232
|
+
await loop.run_in_executor(
|
|
233
|
+
None,
|
|
234
|
+
shutil.copyfileobj,
|
|
235
|
+
f_in,
|
|
236
|
+
f_out
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def _verify_backup_integrity(
|
|
240
|
+
self,
|
|
241
|
+
backup_dir: Path,
|
|
242
|
+
manifest: Dict[str, Any]
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Verify backup integrity by checking checksums.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if all checksums match, False otherwise
|
|
249
|
+
"""
|
|
250
|
+
logger.debug(f"Verifying backup integrity in {backup_dir}")
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
for file_path, file_info in manifest.items():
|
|
254
|
+
backup_file_path = Path(file_info['backup_path'])
|
|
255
|
+
expected_checksum = file_info['checksum']
|
|
256
|
+
|
|
257
|
+
if not backup_file_path.exists():
|
|
258
|
+
logger.error(f"Backup file not found: {backup_file_path}")
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# Read and decompress if needed
|
|
262
|
+
if file_info.get('compressed', False):
|
|
263
|
+
with gzip.open(backup_file_path, 'rb') as f:
|
|
264
|
+
content = f.read()
|
|
265
|
+
else:
|
|
266
|
+
content = await self._read_file_async(backup_file_path)
|
|
267
|
+
|
|
268
|
+
# Verify checksum
|
|
269
|
+
actual_checksum = hashlib.sha256(content).hexdigest()
|
|
270
|
+
|
|
271
|
+
if actual_checksum != expected_checksum:
|
|
272
|
+
logger.error(
|
|
273
|
+
f"Checksum mismatch for {file_path}: "
|
|
274
|
+
f"expected {expected_checksum[:16]}..., got {actual_checksum[:16]}..."
|
|
275
|
+
)
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
logger.debug(f"Verified {file_path} (checksum: {expected_checksum[:16]}...)")
|
|
279
|
+
|
|
280
|
+
logger.info("Backup integrity verification passed")
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"Backup integrity verification failed: {e}")
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
async def restore_backup(
|
|
288
|
+
self,
|
|
289
|
+
backup_dir: str,
|
|
290
|
+
target_files: Optional[Dict[str, str]] = None,
|
|
291
|
+
max_age_days: Optional[int] = None
|
|
292
|
+
) -> bool:
|
|
293
|
+
"""
|
|
294
|
+
Restore files from a backup.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
backup_dir: Path to backup directory
|
|
298
|
+
target_files: Optional mapping of backup file paths to restore targets
|
|
299
|
+
max_age_days: Optional maximum age of backup in days (security: reject old backups)
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
True if restore was successful, False otherwise
|
|
303
|
+
"""
|
|
304
|
+
backup_path = Path(backup_dir)
|
|
305
|
+
|
|
306
|
+
# Security: Validate backup path is within backup root
|
|
307
|
+
try:
|
|
308
|
+
backup_path = backup_path.resolve()
|
|
309
|
+
if not str(backup_path).startswith(str(self.backup_root.resolve())):
|
|
310
|
+
logger.error(f"Backup path outside backup root: {backup_dir}")
|
|
311
|
+
return False
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Invalid backup path: {backup_dir}: {e}")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
if not backup_path.exists():
|
|
317
|
+
logger.error(f"Backup directory not found: {backup_dir}")
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
manifest_path = backup_path / 'manifest.json'
|
|
321
|
+
if not manifest_path.exists():
|
|
322
|
+
logger.error(f"Manifest not found in backup: {manifest_path}")
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
# Load manifest
|
|
327
|
+
manifest_content = await self._read_file_async(manifest_path)
|
|
328
|
+
manifest_data = json.loads(manifest_content.decode('utf-8'))
|
|
329
|
+
files = manifest_data.get('files', {})
|
|
330
|
+
|
|
331
|
+
# Security: Check backup age if max_age_days is specified
|
|
332
|
+
if max_age_days is not None:
|
|
333
|
+
created_at_str = manifest_data.get('created_at', '')
|
|
334
|
+
try:
|
|
335
|
+
created_at = datetime.fromisoformat(created_at_str.replace(':', '-', 2))
|
|
336
|
+
age_days = (datetime.now(timezone.utc) - created_at).days
|
|
337
|
+
if age_days > max_age_days:
|
|
338
|
+
logger.error(
|
|
339
|
+
f"Backup too old to restore: {age_days} days old "
|
|
340
|
+
f"(max allowed: {max_age_days} days)"
|
|
341
|
+
)
|
|
342
|
+
return False
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.warning(f"Could not determine backup age: {e}")
|
|
345
|
+
# If we can't determine age, we could be strict and reject, or allow
|
|
346
|
+
# For now, we'll allow but log a warning
|
|
347
|
+
|
|
348
|
+
logger.info(f"Restoring backup from {backup_dir}")
|
|
349
|
+
|
|
350
|
+
# Restore each file
|
|
351
|
+
for original_path, file_info in files.items():
|
|
352
|
+
backup_file_path = Path(file_info['backup_path'])
|
|
353
|
+
|
|
354
|
+
# Determine target file path
|
|
355
|
+
if target_files and original_path in target_files:
|
|
356
|
+
target_path = Path(target_files[original_path])
|
|
357
|
+
else:
|
|
358
|
+
target_path = Path(original_path)
|
|
359
|
+
|
|
360
|
+
# Security: Validate target path to prevent directory traversal
|
|
361
|
+
if not self._validate_file_path(target_path):
|
|
362
|
+
logger.error(f"Invalid or unsafe target path, skipping: {target_path}")
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
# Ensure target directory exists
|
|
366
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
|
|
368
|
+
# Read and decompress if needed
|
|
369
|
+
if file_info.get('compressed', False):
|
|
370
|
+
with gzip.open(backup_file_path, 'rb') as f_in:
|
|
371
|
+
content = f_in.read()
|
|
372
|
+
else:
|
|
373
|
+
content = await self._read_file_async(backup_file_path)
|
|
374
|
+
|
|
375
|
+
# Write restored file
|
|
376
|
+
await self._write_file_async(target_path, content)
|
|
377
|
+
|
|
378
|
+
# Verify restored file checksum
|
|
379
|
+
restored_checksum = hashlib.sha256(content).hexdigest()
|
|
380
|
+
expected_checksum = file_info['checksum']
|
|
381
|
+
|
|
382
|
+
if restored_checksum != expected_checksum:
|
|
383
|
+
logger.error(
|
|
384
|
+
f"Restored file checksum mismatch for {original_path}: "
|
|
385
|
+
f"expected {expected_checksum[:16]}..., got {restored_checksum[:16]}..."
|
|
386
|
+
)
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
logger.debug(f"Restored {original_path} -> {target_path}")
|
|
390
|
+
|
|
391
|
+
logger.info("Backup restore completed successfully")
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
except Exception as e:
|
|
395
|
+
logger.error(f"Backup restore failed: {e}")
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
def list_backups(self, error_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
399
|
+
"""
|
|
400
|
+
List available backups.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
error_id: Optional filter by error_id
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
List of backup metadata dictionaries
|
|
407
|
+
"""
|
|
408
|
+
backups = []
|
|
409
|
+
|
|
410
|
+
if error_id:
|
|
411
|
+
error_dir = self.backup_root / error_id
|
|
412
|
+
if error_dir.exists():
|
|
413
|
+
backup_dirs = [error_dir]
|
|
414
|
+
else:
|
|
415
|
+
return []
|
|
416
|
+
else:
|
|
417
|
+
backup_dirs = [d for d in self.backup_root.iterdir() if d.is_dir()]
|
|
418
|
+
|
|
419
|
+
for error_dir in backup_dirs:
|
|
420
|
+
for backup_dir in error_dir.iterdir():
|
|
421
|
+
if backup_dir.is_dir():
|
|
422
|
+
manifest_path = backup_dir / 'manifest.json'
|
|
423
|
+
if manifest_path.exists():
|
|
424
|
+
try:
|
|
425
|
+
with open(manifest_path, 'r') as f:
|
|
426
|
+
manifest_data = json.load(f)
|
|
427
|
+
backups.append({
|
|
428
|
+
'error_id': manifest_data.get('error_id'),
|
|
429
|
+
'backup_dir': str(backup_dir),
|
|
430
|
+
'created_at': manifest_data.get('created_at'),
|
|
431
|
+
'files_count': len(manifest_data.get('files', {}))
|
|
432
|
+
})
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.warning(f"Failed to read manifest from {manifest_path}: {e}")
|
|
435
|
+
|
|
436
|
+
return backups
|
|
437
|
+
|
|
438
|
+
# Note: cleanup_old_backups() was removed in v1.44. Connector pre-apply
|
|
439
|
+
# backups are intentionally customer-managed with indefinite retention
|
|
440
|
+
# (see help/connectors/python.md and help/error-management/rollback.md).
|
|
441
|
+
# The Patcherly app's own DB-backup retention
|
|
442
|
+
# (server/app/services/db_backup.py) is a separate workflow and is
|
|
443
|
+
# unaffected. Reintroduce only if a tenant or auditor requirement makes
|
|
444
|
+
# connector-side pruning concretely necessary.
|
|
445
|
+
|
|
446
|
+
def _validate_file_path(self, file_path: Path) -> bool:
|
|
447
|
+
"""
|
|
448
|
+
Validate file path for security (prevent directory traversal).
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
file_path: Path to validate
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
True if path is safe, False otherwise
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
# Resolve to absolute path to normalize it
|
|
458
|
+
resolved = file_path.resolve()
|
|
459
|
+
path_str = str(resolved)
|
|
460
|
+
|
|
461
|
+
# Reject paths that are too long (potential DoS)
|
|
462
|
+
if len(path_str) > 4096: # Common filesystem limit
|
|
463
|
+
logger.warning(f"Path too long: {len(path_str)} characters")
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
# Check for null bytes (potential injection)
|
|
467
|
+
if '\x00' in path_str:
|
|
468
|
+
logger.warning(f"Path contains null byte: {file_path}")
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
# Additional validation: ensure resolved path doesn't have suspicious patterns
|
|
472
|
+
# Note: '..' in the original string is dangerous, but after resolve() it's normalized
|
|
473
|
+
# We check the original path string for '..' before resolution
|
|
474
|
+
original_str = str(file_path)
|
|
475
|
+
if '..' in original_str and not resolved.exists():
|
|
476
|
+
# If original had '..' and resolved path doesn't exist, it might be traversal
|
|
477
|
+
logger.debug(f"Path validation: checking for traversal in {original_str}")
|
|
478
|
+
|
|
479
|
+
return True
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.error(f"Path validation failed for {file_path}: {e}")
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
def _ensure_backup_protection(self):
|
|
485
|
+
"""Ensure backup directory is protected from direct web access."""
|
|
486
|
+
# Create .htaccess for Apache
|
|
487
|
+
htaccess_file = self.backup_root / '.htaccess'
|
|
488
|
+
if not htaccess_file.exists():
|
|
489
|
+
try:
|
|
490
|
+
with open(htaccess_file, 'w') as f:
|
|
491
|
+
f.write("# Deny all direct access to backup files\n")
|
|
492
|
+
f.write("Order Deny,Allow\n")
|
|
493
|
+
f.write("Deny from all\n")
|
|
494
|
+
f.write("\n# Prevent directory listing\n")
|
|
495
|
+
f.write("Options -Indexes\n")
|
|
496
|
+
except Exception:
|
|
497
|
+
pass # May not have write permissions or not Apache
|
|
498
|
+
|
|
499
|
+
# Create .nginx for Nginx (if using Nginx)
|
|
500
|
+
nginx_file = self.backup_root / '.nginx'
|
|
501
|
+
if not nginx_file.exists():
|
|
502
|
+
try:
|
|
503
|
+
with open(nginx_file, 'w') as f:
|
|
504
|
+
f.write("# Nginx configuration snippet\n")
|
|
505
|
+
f.write("# Add to your Nginx server block:\n")
|
|
506
|
+
f.write("# location ~ ^/(.+\\.patcherly_backups)/ {\n")
|
|
507
|
+
f.write("# deny all;\n")
|
|
508
|
+
f.write("# return 403;\n")
|
|
509
|
+
f.write("# }\n")
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
# Create index.html to prevent directory listing
|
|
514
|
+
index_file = self.backup_root / 'index.html'
|
|
515
|
+
if not index_file.exists():
|
|
516
|
+
try:
|
|
517
|
+
with open(index_file, 'w') as f:
|
|
518
|
+
f.write("<!-- Silence is golden. -->\n")
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|