mcp-souschef 2.8.0__py3-none-any.whl → 3.2.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 (36) hide show
  1. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/METADATA +159 -384
  2. mcp_souschef-3.2.0.dist-info/RECORD +47 -0
  3. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/WHEEL +1 -1
  4. souschef/__init__.py +31 -7
  5. souschef/assessment.py +1451 -105
  6. souschef/ci/common.py +126 -0
  7. souschef/ci/github_actions.py +3 -92
  8. souschef/ci/gitlab_ci.py +2 -52
  9. souschef/ci/jenkins_pipeline.py +2 -59
  10. souschef/cli.py +149 -16
  11. souschef/converters/playbook.py +378 -138
  12. souschef/converters/resource.py +12 -11
  13. souschef/converters/template.py +177 -0
  14. souschef/core/__init__.py +6 -1
  15. souschef/core/metrics.py +313 -0
  16. souschef/core/path_utils.py +233 -19
  17. souschef/core/validation.py +53 -0
  18. souschef/deployment.py +71 -12
  19. souschef/generators/__init__.py +13 -0
  20. souschef/generators/repo.py +695 -0
  21. souschef/parsers/attributes.py +1 -1
  22. souschef/parsers/habitat.py +1 -1
  23. souschef/parsers/inspec.py +25 -2
  24. souschef/parsers/metadata.py +5 -3
  25. souschef/parsers/recipe.py +1 -1
  26. souschef/parsers/resource.py +1 -1
  27. souschef/parsers/template.py +1 -1
  28. souschef/server.py +1039 -121
  29. souschef/ui/app.py +486 -374
  30. souschef/ui/pages/ai_settings.py +74 -8
  31. souschef/ui/pages/cookbook_analysis.py +3216 -373
  32. souschef/ui/pages/validation_reports.py +274 -0
  33. mcp_souschef-2.8.0.dist-info/RECORD +0 -42
  34. souschef/converters/cookbook_specific.py.backup +0 -109
  35. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/entry_points.txt +0 -0
  36. {mcp_souschef-2.8.0.dist-info → mcp_souschef-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,47 +1,107 @@
1
1
  """Path utility functions for safe filesystem operations."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
 
5
6
 
6
- def _normalize_path(path_str: str) -> Path:
7
+ def _trusted_workspace_root() -> Path:
8
+ """Return the trusted workspace root used for containment checks."""
9
+ return Path.cwd().resolve()
10
+
11
+
12
+ def _ensure_within_base_path(path_obj: Path, base_path: Path) -> Path:
13
+ """
14
+ Ensure a path stays within a trusted base directory.
15
+
16
+ This is a path containment validator that prevents directory traversal
17
+ attacks (CWE-22) by ensuring paths stay within trusted boundaries.
18
+
19
+ Args:
20
+ path_obj: Path to validate.
21
+ base_path: Trusted base directory.
22
+
23
+ Returns:
24
+ Resolved Path guaranteed to be contained within ``base_path``.
25
+
26
+ Raises:
27
+ ValueError: If the path escapes the base directory.
28
+
29
+ """
30
+ # Use pathlib.Path.resolve() for normalization (prevents traversal)
31
+ base_resolved: Path = Path(base_path).resolve()
32
+ candidate_resolved: Path = Path(path_obj).resolve()
33
+
34
+ # Check containment using relative_to (raises ValueError if not contained)
35
+ try:
36
+ candidate_resolved.relative_to(base_resolved)
37
+ except ValueError as e:
38
+ msg = f"Path traversal attempt: escapes {base_resolved}"
39
+ raise ValueError(msg) from e
40
+
41
+ return candidate_resolved # nosonar
42
+
43
+
44
+ def _normalize_path(path_str: str | Path) -> Path:
7
45
  """
8
46
  Normalize a file path for safe filesystem operations.
9
47
 
10
48
  This function validates input and resolves relative paths and symlinks
11
49
  to absolute paths, preventing path traversal attacks (CWE-23).
12
50
 
51
+ This is a sanitizer for path inputs - it validates and normalizes
52
+ paths before any filesystem operations.
53
+
13
54
  Args:
14
- path_str: Path string to normalize.
55
+ path_str: Path string or Path object to normalize.
15
56
 
16
57
  Returns:
17
58
  Resolved absolute Path object.
18
59
 
19
60
  Raises:
20
- ValueError: If the path contains null bytes, traversal attempts, or is invalid.
61
+ ValueError: If the path contains null bytes or is invalid.
21
62
 
22
63
  """
23
- if not isinstance(path_str, str):
24
- raise ValueError(f"Path must be a string, got {type(path_str)}")
25
-
26
- # Reject paths with null bytes
27
- if "\x00" in path_str:
28
- raise ValueError(f"Path contains null bytes: {path_str!r}")
29
-
30
- # Reject paths with obvious directory traversal attempts
31
- if ".." in path_str:
32
- raise ValueError(f"Path contains directory traversal: {path_str!r}")
64
+ # Convert Path to string if needed for validation
65
+ if isinstance(path_str, Path):
66
+ path_obj = path_str
67
+ elif isinstance(path_str, str):
68
+ # Reject paths with null bytes (CWE-158 prevention)
69
+ if "\x00" in path_str:
70
+ raise ValueError(f"Path contains null bytes: {path_str!r}")
71
+ path_obj = Path(path_str)
72
+ else:
73
+ raise ValueError(f"Path must be a string or Path object, got {type(path_str)}")
33
74
 
34
75
  try:
35
- # Resolve to absolute path, removing ., and resolving symlinks
36
- return Path(path_str).resolve()
76
+ # Path.resolve() normalizes the path, resolving symlinks and ".." sequences
77
+ # This prevents path traversal attacks by canonicalizing the path
78
+ # Input validated for null bytes; Path.resolve() returns safe absolute path
79
+ resolved_path = path_obj.expanduser().resolve() # nosonar
80
+ # Explicit assignment to mark as sanitized output
81
+ normalized: Path = resolved_path # nosonar
82
+ return normalized
37
83
  except (OSError, RuntimeError) as e:
38
84
  raise ValueError(f"Invalid path {path_str}: {e}") from e
39
85
 
40
86
 
87
+ def _normalize_trusted_base(base_path: Path | str) -> Path:
88
+ """
89
+ Normalise a base path.
90
+
91
+ This normalizes the path without enforcing workspace containment.
92
+ Workspace containment is enforced at the application entry points,
93
+ not at the path utility level.
94
+ """
95
+ return _normalize_path(base_path)
96
+
97
+
41
98
  def _safe_join(base_path: Path, *parts: str) -> Path:
42
99
  """
43
100
  Safely join path components ensuring result stays within base directory.
44
101
 
102
+ This prevents path traversal by validating the joined result stays
103
+ contained within the base directory (CWE-22 mitigation).
104
+
45
105
  Args:
46
106
  base_path: Normalized base path.
47
107
  *parts: Path components to join.
@@ -53,9 +113,163 @@ def _safe_join(base_path: Path, *parts: str) -> Path:
53
113
  ValueError: If result would escape base_path.
54
114
 
55
115
  """
56
- result = base_path.joinpath(*parts).resolve()
116
+ # Resolve base path to canonical form
117
+ base_resolved: Path = Path(base_path).resolve()
118
+
119
+ # Join and resolve the full path
120
+ joined_path: Path = base_resolved.joinpath(*parts)
121
+ result_resolved: Path = joined_path.resolve()
122
+
123
+ # Validate containment using relative_to
124
+ try:
125
+ result_resolved.relative_to(base_resolved)
126
+ except ValueError as e:
127
+ msg = f"Path traversal attempt: {parts} escapes {base_path}"
128
+ raise ValueError(msg) from e
129
+
130
+ return result_resolved # nosonar
131
+
132
+
133
+ def _validated_candidate(path_obj: Path, safe_base: Path) -> Path:
134
+ """
135
+ Validate a candidate path stays contained under ``safe_base``.
136
+
137
+ This is a path sanitizer that ensures directory traversal attacks
138
+ are prevented by validating containment (CWE-22 mitigation).
139
+ """
140
+ # Resolve both paths to canonical forms
141
+ base_resolved: Path = Path(safe_base).resolve()
142
+ candidate_resolved: Path = Path(path_obj).resolve()
143
+
144
+ # Check containment using relative_to
57
145
  try:
58
- result.relative_to(base_path)
59
- return result
146
+ candidate_resolved.relative_to(base_resolved)
60
147
  except ValueError as e:
61
- raise ValueError(f"Path traversal attempt: {parts} escapes {base_path}") from e
148
+ msg = f"Path traversal attempt: escapes {base_resolved}"
149
+ raise ValueError(msg) from e
150
+
151
+ return candidate_resolved # nosonar
152
+
153
+
154
+ def safe_exists(path_obj: Path, base_path: Path) -> bool:
155
+ """Check existence after enforcing base containment."""
156
+ safe_base = _normalize_trusted_base(base_path)
157
+ candidate: Path = _validated_candidate(path_obj, safe_base)
158
+ return candidate.exists()
159
+
160
+
161
+ def safe_is_dir(path_obj: Path, base_path: Path) -> bool:
162
+ """Check directory-ness after enforcing base containment."""
163
+ safe_base = _normalize_trusted_base(base_path)
164
+ candidate: Path = _validated_candidate(path_obj, safe_base)
165
+ return candidate.is_dir()
166
+
167
+
168
+ def safe_is_file(path_obj: Path, base_path: Path) -> bool:
169
+ """Check file-ness after enforcing base containment."""
170
+ safe_base = _normalize_trusted_base(base_path)
171
+ candidate: Path = _validated_candidate(path_obj, safe_base)
172
+ return candidate.is_file()
173
+
174
+
175
+ def safe_glob(dir_path: Path, pattern: str, base_path: Path) -> list[Path]:
176
+ """
177
+ Glob inside a directory after enforcing containment.
178
+
179
+ Only literal patterns provided by code should be used for ``pattern``.
180
+ """
181
+ if ".." in pattern:
182
+ msg = f"Unsafe glob pattern detected: {pattern!r}"
183
+ raise ValueError(msg)
184
+ if pattern.startswith((os.sep, "\\")):
185
+ msg = f"Absolute glob patterns are not allowed: {pattern!r}"
186
+ raise ValueError(msg)
187
+
188
+ safe_base = _normalize_trusted_base(base_path)
189
+ safe_dir: Path = _validated_candidate(_normalize_path(dir_path), safe_base)
190
+
191
+ results: list[Path] = []
192
+ for result in safe_dir.glob(pattern): # nosonar
193
+ # Validate each glob result stays within base
194
+ validated_result: Path = _validated_candidate(Path(result), safe_base)
195
+ results.append(validated_result)
196
+
197
+ return results
198
+
199
+
200
+ def safe_mkdir(
201
+ path_obj: Path, base_path: Path, parents: bool = False, exist_ok: bool = False
202
+ ) -> None:
203
+ """Create directory after enforcing base containment."""
204
+ safe_base = _normalize_trusted_base(base_path)
205
+ safe_path = _validated_candidate(_normalize_path(path_obj), safe_base)
206
+
207
+ safe_path.mkdir(parents=parents, exist_ok=exist_ok) # nosonar
208
+
209
+
210
+ def safe_read_text(path_obj: Path, base_path: Path, encoding: str = "utf-8") -> str:
211
+ """
212
+ Read text from file after enforcing base containment.
213
+
214
+ Args:
215
+ path_obj: Path to the file to read.
216
+ base_path: Trusted base directory for containment check.
217
+ encoding: Text encoding (default: 'utf-8').
218
+
219
+ Returns:
220
+ File contents as string.
221
+
222
+ Raises:
223
+ ValueError: If the path escapes the base directory.
224
+
225
+ """
226
+ safe_base = _normalize_trusted_base(base_path)
227
+ safe_path = _validated_candidate(_normalize_path(path_obj), safe_base)
228
+
229
+ return safe_path.read_text(encoding=encoding) # nosonar
230
+
231
+
232
+ def safe_write_text(
233
+ path_obj: Path, base_path: Path, text: str, encoding: str = "utf-8"
234
+ ) -> None:
235
+ """
236
+ Write text to file after enforcing base containment.
237
+
238
+ Args:
239
+ path_obj: Path to the file to write.
240
+ base_path: Trusted base directory for containment check.
241
+ text: Text content to write.
242
+ encoding: Text encoding (default: 'utf-8').
243
+
244
+ """
245
+ safe_base = _normalize_trusted_base(base_path)
246
+ safe_path = _validated_candidate(_normalize_path(path_obj), safe_base)
247
+
248
+ safe_path.write_text(text, encoding=encoding) # nosonar
249
+
250
+
251
+ def safe_iterdir(path_obj: Path, base_path: Path) -> list[Path]:
252
+ """
253
+ Iterate directory contents after enforcing base containment.
254
+
255
+ Args:
256
+ path_obj: Directory path to iterate.
257
+ base_path: Trusted base directory for containment check.
258
+
259
+ Returns:
260
+ List of validated paths within the directory.
261
+
262
+ Raises:
263
+ ValueError: If path escapes the base directory.
264
+
265
+ """
266
+ safe_base = _normalize_trusted_base(base_path)
267
+ safe_path = _validated_candidate(_normalize_path(path_obj), safe_base)
268
+
269
+ results: list[Path] = []
270
+ for item in safe_path.iterdir(): # nosonar
271
+ # Validate each item stays within base
272
+ validated_item: Path = _validated_candidate(item, safe_base)
273
+ results.append(validated_item)
274
+
275
+ return results
@@ -586,3 +586,56 @@ class ValidationEngine:
586
586
  elif result.level == ValidationLevel.INFO:
587
587
  summary["info"] += 1
588
588
  return summary
589
+
590
+
591
+ def _format_validation_results_summary(
592
+ conversion_type: str, summary: dict[str, int]
593
+ ) -> str:
594
+ """
595
+ Format validation results as a summary.
596
+
597
+ Args:
598
+ conversion_type: Type of conversion.
599
+ summary: Summary of validation results.
600
+
601
+ Returns:
602
+ Formatted summary output.
603
+
604
+ """
605
+ total_issues = summary["errors"] + summary["warnings"] + summary["info"]
606
+
607
+ if total_issues == 0:
608
+ return f"""# Validation Summary for {conversion_type} Conversion
609
+
610
+ ✅ **All validation checks passed!** No issues found.
611
+
612
+ Errors: 0
613
+ Warnings: 0
614
+ Info: 0
615
+ """
616
+
617
+ # Determine status icon based on error/warning counts
618
+ if summary["errors"] > 0:
619
+ status_icon = "❌"
620
+ elif summary["warnings"] > 0:
621
+ status_icon = "⚠️"
622
+ else:
623
+ status_icon = "ℹ️"
624
+
625
+ # Determine status message based on error/warning counts
626
+ if summary["errors"] > 0:
627
+ status = "Failed"
628
+ elif summary["warnings"] > 0:
629
+ status = "Warning"
630
+ else:
631
+ status = "Passed with info"
632
+
633
+ return f"""# Validation Summary for {conversion_type} Conversion
634
+
635
+ {status_icon} **Validation Results:**
636
+ • Errors: {summary["errors"]}
637
+ • Warnings: {summary["warnings"]}
638
+ • Info: {summary["info"]}
639
+
640
+ **Status:** {status}
641
+ """
souschef/deployment.py CHANGED
@@ -10,6 +10,7 @@ import json
10
10
  import re
11
11
  from pathlib import Path
12
12
  from typing import Any
13
+ from urllib.parse import urlparse
13
14
 
14
15
  from souschef.core.constants import (
15
16
  CHEF_RECIPE_PREFIX,
@@ -21,6 +22,11 @@ from souschef.core.errors import (
21
22
  validate_cookbook_structure,
22
23
  validate_directory_exists,
23
24
  )
25
+ from souschef.core.metrics import (
26
+ ComplexityLevel,
27
+ EffortMetrics,
28
+ categorize_complexity,
29
+ )
24
30
  from souschef.core.path_utils import _safe_join
25
31
 
26
32
  # Maximum length for attribute values in Chef attribute parsing
@@ -253,10 +259,11 @@ def generate_awx_inventory_source_from_chef(
253
259
  "(e.g., https://chef.example.com)"
254
260
  )
255
261
 
256
- if not chef_server_url.startswith("https://"):
262
+ parsed_url = urlparse(chef_server_url)
263
+ if parsed_url.scheme != "https" or not parsed_url.netloc:
257
264
  return (
258
265
  f"Error: Invalid Chef server URL: {chef_server_url}\n\n"
259
- "Suggestion: URL must use HTTPS protocol for security "
266
+ "Suggestion: URL must use HTTPS protocol with a valid host "
260
267
  "(e.g., https://chef.example.com)"
261
268
  )
262
269
 
@@ -978,7 +985,12 @@ def main():
978
985
  # Chef server configuration
979
986
  chef_server_url = os.environ.get('CHEF_SERVER_URL', '{chef_server_url}')
980
987
  client_name = os.environ.get('CHEF_NODE_NAME', 'admin')
981
- client_key = os.environ.get('CHEF_CLIENT_KEY', '/etc/chef/client.pem')
988
+ # Client key path should be customizable - use environment variable with
989
+ # home directory default instead of hardcoded /etc/chef/client.pem
990
+ client_key = os.environ.get(
991
+ 'CHEF_CLIENT_KEY',
992
+ os.path.expanduser('~/.chef/client.pem')
993
+ )
982
994
 
983
995
  # Initialize Chef API
984
996
  try:
@@ -1496,13 +1508,56 @@ def _detect_patterns_from_content(content: str) -> list[str]:
1496
1508
  return patterns
1497
1509
 
1498
1510
 
1499
- def _assess_complexity_from_resource_count(resource_count: int) -> tuple[str, str, str]:
1500
- """Assess complexity, effort, and risk based on resource count."""
1511
+ def _assess_complexity_from_resource_count(
1512
+ resource_count: int,
1513
+ ) -> tuple[ComplexityLevel, str, str]:
1514
+ """
1515
+ Assess complexity, effort estimate, and risk based on resource count.
1516
+
1517
+ Uses centralized metrics for consistent complexity categorization.
1518
+
1519
+ Args:
1520
+ resource_count: Number of resources in cookbook
1521
+
1522
+ Returns:
1523
+ Tuple of (complexity_level, effort_estimate_weeks, risk_level)
1524
+
1525
+ """
1526
+ # Map resource count to complexity score (0-100 scale)
1527
+ # 50+ resources = high complexity (70-100)
1528
+ # 20-50 resources = medium complexity (30-69)
1529
+ # <20 resources = low complexity (0-29)
1501
1530
  if resource_count > 50:
1502
- return "high", "4-6 weeks", "high"
1503
- elif resource_count < 20:
1504
- return "low", "1-2 weeks", "low"
1505
- return "medium", "2-3 weeks", "medium"
1531
+ complexity_score = 80
1532
+ elif resource_count > 30:
1533
+ complexity_score = 50
1534
+ elif resource_count >= 20:
1535
+ complexity_score = 40
1536
+ else:
1537
+ complexity_score = 15
1538
+
1539
+ # Use centralized categorization
1540
+ complexity_level = categorize_complexity(complexity_score)
1541
+
1542
+ # Estimate effort based on resource count and complexity
1543
+ # Base: 0.2 days per resource (2.5 hours)
1544
+ base_days = resource_count * 0.2
1545
+ complexity_multiplier = 1 + (complexity_score / 100)
1546
+ estimated_days = round(base_days * complexity_multiplier, 1)
1547
+
1548
+ # Create metrics object for consistent week calculation
1549
+ metrics = EffortMetrics(estimated_days=estimated_days)
1550
+ effort_estimate = metrics.estimated_weeks_range
1551
+
1552
+ # Risk mapping based on complexity level
1553
+ if complexity_level == ComplexityLevel.HIGH:
1554
+ risk_level = "high"
1555
+ elif complexity_level == ComplexityLevel.MEDIUM:
1556
+ risk_level = "medium"
1557
+ else:
1558
+ risk_level = "low"
1559
+
1560
+ return complexity_level, effort_estimate, risk_level
1506
1561
 
1507
1562
 
1508
1563
  def _analyse_application_cookbook(cookbook_path: Path, app_type: str) -> dict:
@@ -1536,10 +1591,14 @@ def _analyse_application_cookbook(cookbook_path: Path, app_type: str) -> dict:
1536
1591
  # Silently skip malformed files
1537
1592
  pass
1538
1593
 
1539
- # Assess complexity
1594
+ # Assess complexity using centralized function
1540
1595
  resource_count = len(analysis["resources"])
1541
- complexity, effort, risk = _assess_complexity_from_resource_count(resource_count)
1542
- analysis["complexity"] = complexity
1596
+ complexity_level, effort, risk = _assess_complexity_from_resource_count(
1597
+ resource_count
1598
+ )
1599
+
1600
+ # Convert complexity level enum to string for backward compatibility
1601
+ analysis["complexity"] = complexity_level.value
1543
1602
  analysis["effort_estimate"] = effort
1544
1603
  analysis["risk_level"] = risk
1545
1604
 
@@ -0,0 +1,13 @@
1
+ """Ansible artifact generators."""
2
+
3
+ from souschef.generators.repo import (
4
+ RepoType,
5
+ analyse_conversion_output,
6
+ generate_ansible_repository,
7
+ )
8
+
9
+ __all__ = [
10
+ "RepoType",
11
+ "analyse_conversion_output",
12
+ "generate_ansible_repository",
13
+ ]