mcp-souschef 3.0.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.
souschef/assessment.py CHANGED
@@ -6,11 +6,17 @@ generating migration plans, analyzing dependencies, and validating conversions.
6
6
  """
7
7
 
8
8
  import json
9
+ import os
9
10
  import re
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
13
- from souschef.core import METADATA_FILENAME, _normalize_path, _safe_join
14
+ from souschef.core import (
15
+ METADATA_FILENAME,
16
+ _ensure_within_base_path,
17
+ _normalize_path,
18
+ _safe_join,
19
+ )
14
20
  from souschef.core.errors import format_error_with_context
15
21
  from souschef.core.metrics import (
16
22
  ComplexityLevel,
@@ -18,6 +24,7 @@ from souschef.core.metrics import (
18
24
  categorize_complexity,
19
25
  estimate_effort_for_complexity,
20
26
  )
27
+ from souschef.core.path_utils import _validated_candidate, safe_glob
21
28
  from souschef.core.validation import (
22
29
  ValidationEngine,
23
30
  ValidationLevel,
@@ -32,13 +39,31 @@ except ImportError:
32
39
  requests = None
33
40
 
34
41
  try:
35
- from ibm_watsonx_ai import ( # type: ignore[import-not-found]
36
- APIClient,
37
- )
42
+ from ibm_watsonx_ai import APIClient # type: ignore[import-not-found]
38
43
  except ImportError:
39
44
  APIClient = None
40
45
 
41
46
 
47
+ def _normalize_cookbook_root(cookbook_path: Path | str) -> Path:
48
+ """
49
+ Normalise cookbook paths.
50
+
51
+ Args:
52
+ cookbook_path: User-provided cookbook path.
53
+
54
+ Returns:
55
+ A resolved Path.
56
+
57
+ Raises:
58
+ ValueError: If the path cannot be normalised.
59
+
60
+ """
61
+ # Normalise the path (resolves symlinks, expands ~, etc.)
62
+ # Safety for accessing files within this cookbook is enforced per-operation
63
+ # using _ensure_within_base_path with the cookbook dir as the base
64
+ return _normalize_path(cookbook_path)
65
+
66
+
42
67
  # Optimised patterns to avoid catastrophic backtracking in resource parsing
43
68
  RESOURCE_BLOCK_PATTERN = re.compile(r"\w{1,100}\s+['\"]([^'\"\r\n]{0,200})['\"]\s+do")
44
69
 
@@ -227,7 +252,6 @@ def _parse_and_assess_cookbooks(cookbook_paths: str) -> tuple[list, str | None]:
227
252
 
228
253
  cookbook_assessments = []
229
254
  for cookbook_path in valid_paths:
230
- # deepcode ignore PT: path normalized via _normalize_path
231
255
  assessment = _assess_single_cookbook(cookbook_path)
232
256
  cookbook_assessments.append(assessment)
233
257
 
@@ -330,7 +354,8 @@ def analyse_cookbook_dependencies(
330
354
  dependency_depth: Analysis depth (direct, transitive, full)
331
355
 
332
356
  Returns:
333
- Dependency analysis with migration order recommendations
357
+ Dependency analysis with migration order recommendations.
358
+
334
359
 
335
360
  """
336
361
  try:
@@ -342,15 +367,21 @@ def analyse_cookbook_dependencies(
342
367
  f"Suggestion: Use one of {', '.join(valid_depths)}"
343
368
  )
344
369
 
345
- cookbook_path_obj = _normalize_path(cookbook_path)
346
- if not cookbook_path_obj.exists():
370
+ # Validate and normalise user-provided path
371
+ # Containment is enforced at filesystem operation level
372
+ try:
373
+ normalized_input: Path = _normalize_path(cookbook_path)
374
+ except (ValueError, OSError) as e:
375
+ return f"Error: Invalid cookbook path '{cookbook_path}': {e}"
376
+
377
+ if not normalized_input.exists():
347
378
  return (
348
379
  f"Error: Cookbook path not found: {cookbook_path}\n\n"
349
380
  "Suggestion: Check that the path exists and points to a cookbook directory"
350
381
  )
351
382
 
352
- # Analyze dependencies
353
- dependency_analysis = _analyse_cookbook_dependencies_detailed(cookbook_path_obj)
383
+ # Analyze dependencies using normalized path
384
+ dependency_analysis = _analyse_cookbook_dependencies_detailed(normalized_input)
354
385
 
355
386
  # Determine migration order
356
387
  migration_order = _determine_migration_order(dependency_analysis)
@@ -359,7 +390,7 @@ def analyse_cookbook_dependencies(
359
390
  circular_deps = _identify_circular_dependencies(dependency_analysis)
360
391
 
361
392
  return f"""# Cookbook Dependency Analysis
362
- # Cookbook: {cookbook_path_obj.name}
393
+ # Cookbook: {normalized_input.name}
363
394
  # Analysis Depth: {dependency_depth}
364
395
 
365
396
  ## Dependency Overview:
@@ -559,7 +590,9 @@ def _parse_cookbook_paths(cookbook_paths: str) -> list[Any]:
559
590
  List of valid Path objects (may be empty)
560
591
 
561
592
  """
562
- paths = [_normalize_path(path.strip()) for path in cookbook_paths.split(",")]
593
+ paths = [
594
+ _normalize_cookbook_root(path.strip()) for path in cookbook_paths.split(",")
595
+ ]
563
596
  valid_paths = [p for p in paths if p.exists()]
564
597
  return valid_paths
565
598
 
@@ -587,7 +620,6 @@ def _analyse_cookbook_metrics(
587
620
  }
588
621
 
589
622
  for cookbook_path in valid_paths:
590
- # deepcode ignore PT: path normalized via _normalize_path
591
623
  assessment = _assess_single_cookbook(cookbook_path)
592
624
  cookbook_assessments.append(assessment)
593
625
 
@@ -657,35 +689,70 @@ def _format_assessment_report(
657
689
  """
658
690
 
659
691
 
660
- def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]:
692
+ def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]: # noqa: C901
661
693
  """Count comprehensive cookbook artifacts including all Chef components."""
662
- # deepcode ignore PT: path normalized via _normalize_path in caller
663
- cookbook_path = (
664
- Path(cookbook_path) if not isinstance(cookbook_path, Path) else cookbook_path
665
- )
694
+ # Note: cookbook_path is expected to be pre-validated
695
+ base = cookbook_path
696
+
697
+ # Helper function to safely glob within a directory
698
+ def _glob_safe(directory: Path, pattern: str) -> int:
699
+ """Count files matching a glob pattern within a directory."""
700
+ if not directory.exists() or not directory.is_dir():
701
+ return 0
702
+ try:
703
+ return len(list(directory.glob(pattern)))
704
+ except (OSError, ValueError):
705
+ return 0
666
706
 
667
- # Basic directory counts
668
- # cookbook_path already normalized by caller
669
- recipes_dir = (
670
- cookbook_path / "recipes"
671
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
672
- recipe_count = len(list(recipes_dir.glob("*.rb"))) if recipes_dir.exists() else 0
673
-
674
- templates_dir = (
675
- cookbook_path / "templates"
676
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
677
- template_count = (
678
- len(list(templates_dir.glob("**/*.erb"))) if templates_dir.exists() else 0
679
- )
707
+ # Helper function to check existence safely
708
+ def _exists_safe(path: Path) -> bool:
709
+ """Check if a path exists."""
710
+ try:
711
+ return path.exists()
712
+ except (OSError, ValueError):
713
+ return False
680
714
 
681
- files_dir = cookbook_path / "files"
682
- file_count = len(list(files_dir.glob("**/*"))) if files_dir.exists() else 0
715
+ # All paths are safe-joined to the validated base
716
+ recipes_dir: Path = _safe_join(base, "recipes")
717
+ recipe_count: int = _glob_safe(recipes_dir, "*.rb")
683
718
 
684
- # Additional Chef components
685
- attributes_dir = cookbook_path / "attributes"
686
- attributes_count = (
687
- len(list(attributes_dir.glob("*.rb"))) if attributes_dir.exists() else 0
688
- )
719
+ templates_dir: Path = _safe_join(base, "templates")
720
+ template_count: int = _glob_safe(templates_dir, "**/*.erb")
721
+
722
+ files_dir: Path = _safe_join(base, "files")
723
+ file_count: int = _glob_safe(files_dir, "**/*")
724
+
725
+ attributes_dir: Path = _safe_join(base, "attributes")
726
+ attributes_count: int = _glob_safe(attributes_dir, "*.rb")
727
+
728
+ libraries_dir: Path = _safe_join(base, "libraries")
729
+ libraries_count: int = _glob_safe(libraries_dir, "*.rb")
730
+
731
+ definitions_dir: Path = _safe_join(base, "definitions")
732
+ definitions_count: int = _glob_safe(definitions_dir, "*.rb")
733
+
734
+ resources_dir: Path = _safe_join(base, "resources")
735
+ resources_count: int = _glob_safe(resources_dir, "*.rb")
736
+
737
+ providers_dir: Path = _safe_join(base, "providers")
738
+ providers_count: int = _glob_safe(providers_dir, "*.rb")
739
+
740
+ berksfile: Path = _safe_join(base, "Berksfile")
741
+ has_berksfile: bool = _exists_safe(berksfile)
742
+
743
+ chefignore: Path = _safe_join(base, "chefignore")
744
+ has_chefignore: bool = _exists_safe(chefignore)
745
+
746
+ thorfile: Path = _safe_join(base, "Thorfile")
747
+ has_thorfile: bool = _exists_safe(thorfile)
748
+
749
+ kitchen_yml: Path = _safe_join(base, ".kitchen.yml")
750
+ kitchen_yaml: Path = _safe_join(base, "kitchen.yml")
751
+ has_kitchen_yml: bool = _exists_safe(kitchen_yml) or _exists_safe(kitchen_yaml)
752
+
753
+ test_dir: Path = _safe_join(base, "test")
754
+ spec_dir: Path = _safe_join(base, "spec")
755
+ has_test_dir: bool = _exists_safe(test_dir) or _exists_safe(spec_dir)
689
756
 
690
757
  libraries_dir = cookbook_path / "libraries"
691
758
  libraries_count = (
@@ -737,10 +804,8 @@ def _count_cookbook_artifacts(cookbook_path: Path) -> dict[str, int]:
737
804
 
738
805
  def _analyse_recipe_complexity(cookbook_path: Path) -> dict[str, int]:
739
806
  """Analyse recipe files and other cookbook components for resource counts, Ruby blocks, and custom resources."""
740
- # deepcode ignore PT: path normalized via _normalize_path in caller
741
- cookbook_path = (
742
- Path(cookbook_path) if not isinstance(cookbook_path, Path) else cookbook_path
743
- )
807
+ # Note: cookbook_path is expected to be pre-validated at function entry points
808
+ # Do not call _normalize_cookbook_root here as it's already a validated Path
744
809
 
745
810
  resource_count = 0
746
811
  custom_resources = 0
@@ -774,56 +839,75 @@ def _analyze_recipes(cookbook_path: Path) -> tuple[int, int, int]:
774
839
  ruby_blocks = 0
775
840
  custom_resources = 0
776
841
 
777
- # cookbook_path already normalized by caller
778
- recipes_dir = (
779
- cookbook_path / "recipes"
780
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
781
- if recipes_dir.exists():
782
- for recipe_file in recipes_dir.glob("*.rb"):
783
- try:
784
- content = recipe_file.read_text(encoding="utf-8", errors="ignore")
785
- # Count Chef resources
786
- resources = len(RESOURCE_BLOCK_PATTERN.findall(content))
787
- ruby_blocks += len(
788
- re.findall(
789
- r"ruby_block|execute|bash|script", content, re.IGNORECASE
790
- )
791
- )
792
- custom_resources += len(
793
- re.findall(
794
- r"custom_resource|provides|use_inline_resources|lwrp_resource",
795
- content,
796
- )
842
+ # Note: cookbook_path is expected to be pre-validated
843
+ # Use it directly with _safe_join to access recipes directory
844
+ recipes_dir: Path = _safe_join(cookbook_path, "recipes")
845
+ try:
846
+ recipe_files: list[Path] = (
847
+ list(recipes_dir.glob("*.rb")) if recipes_dir.exists() else []
848
+ )
849
+ except (OSError, ValueError):
850
+ recipe_files = []
851
+
852
+ for recipe_file in recipe_files:
853
+ try:
854
+ # Validate each glob result
855
+ validated_file: Path = _validated_candidate(recipe_file, cookbook_path)
856
+ except ValueError:
857
+ continue
858
+ try:
859
+ content = validated_file.read_text(encoding="utf-8", errors="ignore")
860
+ resources = len(RESOURCE_BLOCK_PATTERN.findall(content))
861
+ ruby_blocks += len(
862
+ re.findall(r"ruby_block|execute|bash|script", content, re.IGNORECASE)
863
+ )
864
+ custom_resources += len(
865
+ re.findall(
866
+ r"custom_resource|provides|use_inline_resources|lwrp_resource",
867
+ content,
797
868
  )
798
- resource_count += resources
799
- except Exception:
800
- continue
869
+ )
870
+ resource_count += resources
871
+ except Exception:
872
+ continue
801
873
 
802
874
  return resource_count, ruby_blocks, custom_resources
803
875
 
804
876
 
805
877
  def _analyze_attributes(cookbook_path: Path) -> int:
806
- """Analyze attribute files for complexity."""
878
+ """Analyse attribute files for complexity."""
807
879
  attribute_complexity = 0
808
880
 
809
- attributes_dir = (
810
- cookbook_path / "attributes"
811
- ) # deepcode ignore PT: path normalized via _normalize_path
812
- if attributes_dir.exists():
813
- for attr_file in attributes_dir.glob("*.rb"):
814
- try:
815
- content = attr_file.read_text(encoding="utf-8", errors="ignore")
816
- # Count attribute assignments and complex expressions
817
- # Use simpler regex patterns to avoid ReDoS vulnerabilities
818
- assignments = len(
819
- re.findall(r"^\s*\w+\s*(?:\[\w*\])?\s*=", content, re.MULTILINE)
820
- )
821
- complex_expressions = len(
822
- re.findall(r"(?:node|default|override)\[", content)
881
+ # Note: cookbook_path is expected to be pre-validated
882
+ attributes_dir: Path = _safe_join(cookbook_path, "attributes")
883
+ try:
884
+ attr_files: list[Path] = (
885
+ list(attributes_dir.glob("*.rb")) if attributes_dir.exists() else []
886
+ )
887
+ except (OSError, ValueError):
888
+ attr_files = []
889
+
890
+ for attr_file in attr_files:
891
+ try:
892
+ # Validate each glob result
893
+ validated_file: Path = _validated_candidate(attr_file, cookbook_path)
894
+ except ValueError:
895
+ continue
896
+ try:
897
+ content = validated_file.read_text(encoding="utf-8", errors="ignore")
898
+ assignments = len(
899
+ re.findall(
900
+ r"^[ \t]{0,20}\w+[ \t]{0,10}(?:\[\w*\])?[ \t]{0,10}=",
901
+ content,
902
+ re.MULTILINE,
823
903
  )
824
- attribute_complexity += assignments + complex_expressions
825
- except Exception:
826
- continue
904
+ )
905
+ complex_expressions = len(
906
+ re.findall(r"(?:node|default|override)\[", content)
907
+ )
908
+ attribute_complexity += assignments + complex_expressions
909
+ except Exception:
910
+ continue
827
911
 
828
912
  return attribute_complexity
829
913
 
@@ -832,77 +916,91 @@ def _analyze_templates(cookbook_path: Path) -> int:
832
916
  """Analyze template files for ERB complexity."""
833
917
  erb_templates = 0
834
918
 
835
- # cookbook_path already normalized by caller
836
- templates_dir = (
837
- cookbook_path / "templates"
838
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
839
- if templates_dir.exists():
840
- for template_file in templates_dir.glob("**/*.erb"):
841
- try:
842
- content = template_file.read_text(encoding="utf-8", errors="ignore")
843
- # Count ERB expressions and complex logic
844
- erb_expressions = len(re.findall(r"<%.*?%>", content))
845
- erb_templates += erb_expressions
846
- except Exception:
847
- continue
919
+ # Note: cookbook_path is expected to be pre-validated
920
+ templates_dir: Path = _safe_join(cookbook_path, "templates")
921
+ try:
922
+ template_files: list[Path] = (
923
+ list(templates_dir.glob("**/*.erb")) if templates_dir.exists() else []
924
+ )
925
+ except (OSError, ValueError):
926
+ template_files = []
927
+
928
+ for template_file in template_files:
929
+ try:
930
+ # Validate each glob result
931
+ validated_file: Path = _validated_candidate(template_file, cookbook_path)
932
+ except ValueError:
933
+ continue
934
+
935
+ try:
936
+ content = validated_file.read_text(encoding="utf-8", errors="ignore")
937
+ erb_expressions = len(re.findall(r"<%.*?%>", content))
938
+ erb_templates += erb_expressions
939
+ except Exception:
940
+ continue
848
941
 
849
942
  return erb_templates
850
943
 
851
944
 
852
945
  def _analyze_libraries(cookbook_path: Path) -> int:
853
- """Analyze library files for complexity."""
946
+ """Analyse library files for complexity."""
854
947
  library_complexity = 0
855
948
 
856
- # cookbook_path already normalized by caller
857
- libraries_dir = (
858
- cookbook_path / "libraries"
859
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
860
- if libraries_dir.exists():
861
- for lib_file in libraries_dir.glob("*.rb"):
862
- try:
863
- content = lib_file.read_text(encoding="utf-8", errors="ignore")
864
- # Count class definitions, methods, and complex Ruby constructs
865
- classes = len(re.findall(r"class\s+\w+", content))
866
- methods = len(re.findall(r"def\s+\w+", content))
867
- library_complexity += classes * 2 + methods
868
- except Exception:
869
- continue
949
+ # Note: cookbook_path is expected to be pre-validated
950
+ libraries_dir: Path = _safe_join(cookbook_path, "libraries")
951
+ try:
952
+ lib_files: list[Path] = (
953
+ safe_glob(libraries_dir, "*.rb", cookbook_path)
954
+ if libraries_dir.exists()
955
+ else []
956
+ )
957
+ except (OSError, ValueError):
958
+ lib_files = []
959
+
960
+ for lib_file in lib_files:
961
+ try:
962
+ # lib_file is already validated by safe_glob
963
+ content = lib_file.read_text(encoding="utf-8", errors="ignore")
964
+ classes = len(re.findall(r"class\s+\w+", content))
965
+ methods = len(re.findall(r"def\s+\w+", content))
966
+ library_complexity += classes * 2 + methods
967
+ except Exception:
968
+ continue
870
969
 
871
970
  return library_complexity
872
971
 
873
972
 
874
973
  def _count_definitions(cookbook_path: Path) -> int:
875
974
  """Count definition files."""
876
- # cookbook_path already normalized by caller
877
- definitions_dir = (
878
- cookbook_path / "definitions"
879
- ) # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected
880
- if definitions_dir.exists():
881
- return len(list(definitions_dir.glob("*.rb")))
882
- return 0
975
+ # Note: cookbook_path is expected to be pre-validated
976
+ definitions_dir: Path = _safe_join(cookbook_path, "definitions")
977
+ try:
978
+ def_files: list[Path] = (
979
+ safe_glob(definitions_dir, "*.rb", cookbook_path)
980
+ if definitions_dir.exists()
981
+ else []
982
+ )
983
+ except (OSError, ValueError):
984
+ def_files = []
985
+ return len(def_files)
883
986
 
884
987
 
885
988
  def _parse_berksfile(cookbook_path: Path) -> dict[str, Any]:
886
989
  """Parse Berksfile for dependency information."""
887
- # deepcode ignore PT: path normalized via _normalize_path in caller
888
- cookbook_path = (
889
- Path(cookbook_path) if not isinstance(cookbook_path, Path) else cookbook_path
890
- )
891
- berksfile = cookbook_path / "Berksfile"
990
+ base = _normalize_cookbook_root(cookbook_path)
991
+ berksfile_path = _safe_join(base, "Berksfile")
892
992
 
893
- if not berksfile.exists():
993
+ if not berksfile_path.exists():
894
994
  return {"dependencies": [], "external_cookbooks": [], "complexity": 0}
895
995
 
896
996
  try:
897
- content = berksfile.read_text(encoding="utf-8", errors="ignore")
997
+ content = berksfile_path.read_text(encoding="utf-8", errors="ignore")
898
998
 
899
- # Parse cookbook dependencies
900
999
  cookbook_deps = re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]', content)
901
1000
  external_deps = re.findall(
902
1001
  r'cookbook\s+[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]', content
903
1002
  )
904
1003
 
905
- # Count complex dependency specifications (with version constraints, git sources, etc.)
906
1004
  complex_deps = len(re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]\s*,', content))
907
1005
  git_sources = len(re.findall(r"git:", content))
908
1006
  path_sources = len(re.findall(r"path:", content))
@@ -920,21 +1018,20 @@ def _parse_berksfile(cookbook_path: Path) -> dict[str, Any]:
920
1018
 
921
1019
  def _parse_chefignore(cookbook_path) -> dict[str, Any]:
922
1020
  """Parse chefignore file for ignore patterns."""
923
- cookbook_path = Path(cookbook_path)
924
- chefignore = cookbook_path / "chefignore"
1021
+ base = _normalize_cookbook_root(cookbook_path)
1022
+ chefignore_path = _ensure_within_base_path(_safe_join(base, "chefignore"), base)
925
1023
 
926
- if not chefignore.exists():
1024
+ if not chefignore_path.exists():
927
1025
  return {"patterns": [], "complexity": 0}
928
1026
 
929
1027
  try:
930
- content = chefignore.read_text(encoding="utf-8", errors="ignore")
1028
+ content = chefignore_path.read_text(encoding="utf-8", errors="ignore")
931
1029
  lines = [
932
1030
  line.strip()
933
1031
  for line in content.split("\n")
934
1032
  if line.strip() and not line.startswith("#")
935
1033
  ]
936
1034
 
937
- # Count complex patterns (wildcards, directories, etc.)
938
1035
  wildcard_patterns = len([p for p in lines if "*" in p or "?" in p])
939
1036
  directory_patterns = len([p for p in lines if p.endswith("/") or "/" in p])
940
1037
 
@@ -950,16 +1047,15 @@ def _parse_chefignore(cookbook_path) -> dict[str, Any]:
950
1047
 
951
1048
  def _parse_thorfile(cookbook_path) -> dict[str, Any]:
952
1049
  """Parse Thorfile for Thor tasks."""
953
- cookbook_path = Path(cookbook_path)
954
- thorfile = cookbook_path / "Thorfile"
1050
+ base = _normalize_cookbook_root(cookbook_path)
1051
+ thorfile_path = _ensure_within_base_path(_safe_join(base, "Thorfile"), base)
955
1052
 
956
- if not thorfile.exists():
1053
+ if not thorfile_path.exists():
957
1054
  return {"tasks": [], "complexity": 0}
958
1055
 
959
1056
  try:
960
- content = thorfile.read_text(encoding="utf-8", errors="ignore")
1057
+ content = thorfile_path.read_text(encoding="utf-8", errors="ignore")
961
1058
 
962
- # Count Thor tasks and methods
963
1059
  tasks = len(re.findall(r'desc\s+[\'"]([^\'"]+)[\'"]', content))
964
1060
  methods = len(re.findall(r"def\s+\w+", content))
965
1061
 
@@ -975,10 +1071,10 @@ def _parse_thorfile(cookbook_path) -> dict[str, Any]:
975
1071
 
976
1072
  def _parse_metadata_file(cookbook_path) -> dict[str, Any]:
977
1073
  """Parse metadata.rb for cookbook information."""
978
- cookbook_path = Path(cookbook_path)
979
- metadata_file = cookbook_path / "metadata.rb"
1074
+ base = _normalize_cookbook_root(cookbook_path)
1075
+ metadata_path = _ensure_within_base_path(_safe_join(base, "metadata.rb"), base)
980
1076
 
981
- if not metadata_file.exists():
1077
+ if not metadata_path.exists():
982
1078
  return {
983
1079
  "name": "",
984
1080
  "version": "",
@@ -988,9 +1084,8 @@ def _parse_metadata_file(cookbook_path) -> dict[str, Any]:
988
1084
  }
989
1085
 
990
1086
  try:
991
- content = metadata_file.read_text(encoding="utf-8", errors="ignore")
1087
+ content = metadata_path.read_text(encoding="utf-8", errors="ignore")
992
1088
 
993
- # Extract basic metadata
994
1089
  name_match = re.search(r'name\s+[\'"]([^\'"]+)[\'"]', content)
995
1090
  version_match = re.search(r'version\s+[\'"]([^\'"]+)[\'"]', content)
996
1091
 
@@ -1074,8 +1169,7 @@ def _determine_migration_priority(complexity_score: int) -> str:
1074
1169
 
1075
1170
  def _assess_single_cookbook(cookbook_path: Path) -> dict:
1076
1171
  """Assess complexity of a single cookbook."""
1077
- # cookbook_path is already normalized to a Path object
1078
- cookbook = cookbook_path
1172
+ cookbook = _normalize_cookbook_root(cookbook_path)
1079
1173
 
1080
1174
  # Collect metrics
1081
1175
  artifact_counts = _count_cookbook_artifacts(cookbook)
@@ -1373,38 +1467,92 @@ def _estimate_resource_requirements(metrics: dict, target_platform: str) -> str:
1373
1467
  • **Training:** 2-3 days Ansible/AWX training for team"""
1374
1468
 
1375
1469
 
1376
- def _analyse_cookbook_dependencies_detailed(cookbook_path) -> dict:
1377
- """Analyze cookbook dependencies in detail."""
1378
- analysis = {
1379
- "cookbook_name": cookbook_path.name,
1380
- "direct_dependencies": [],
1470
+ def _analyse_cookbook_dependencies_detailed(cookbook_path: Path | str) -> dict:
1471
+ """
1472
+ Analyse cookbook dependencies in detail.
1473
+
1474
+ Args:
1475
+ cookbook_path: Path to the cookbook (may be string or Path).
1476
+
1477
+ Returns:
1478
+ Dictionary with dependency information.
1479
+
1480
+ Raises:
1481
+ ValueError: If the path is invalid.
1482
+
1483
+ """
1484
+ # Normalize the input path
1485
+ base_path: Path = _normalize_path(cookbook_path)
1486
+
1487
+ # Validate basic accessibility
1488
+ if not base_path.exists():
1489
+ msg = f"Cookbook path does not exist: {cookbook_path}"
1490
+ raise ValueError(msg)
1491
+ if not base_path.is_dir():
1492
+ msg = f"Cookbook path is not a directory: {cookbook_path}"
1493
+ raise ValueError(msg)
1494
+
1495
+ # Collect dependencies from metadata and Berksfile
1496
+ direct_dependencies = _collect_metadata_dependencies(base_path)
1497
+ external_dependencies = _collect_berks_dependencies(base_path)
1498
+ community_cookbooks = _identify_community_cookbooks_from_list(
1499
+ direct_dependencies + external_dependencies
1500
+ )
1501
+
1502
+ return {
1503
+ "cookbook_name": base_path.name,
1504
+ "direct_dependencies": direct_dependencies,
1381
1505
  "transitive_dependencies": [],
1382
- "external_dependencies": [],
1383
- "community_cookbooks": [],
1506
+ "external_dependencies": external_dependencies,
1507
+ "community_cookbooks": community_cookbooks,
1384
1508
  "circular_dependencies": [],
1385
1509
  }
1386
1510
 
1387
- # Read metadata.rb for dependencies
1388
- metadata_file = _safe_join(cookbook_path, METADATA_FILENAME)
1389
- if metadata_file.exists():
1390
- with metadata_file.open("r", encoding="utf-8", errors="ignore") as f:
1391
- content = f.read()
1392
1511
 
1393
- # Parse dependencies
1512
+ def _collect_metadata_dependencies(base_path: Path) -> list[str]:
1513
+ """Collect dependency declarations from metadata.rb with containment checks."""
1514
+ # Build metadata path safely within the cookbook
1515
+ metadata_path: Path = _safe_join(base_path, METADATA_FILENAME)
1394
1516
 
1395
- depends_matches = re.findall(r'depends\s+[\'"]([^\'"]+)[\'"]', content)
1396
- analysis["direct_dependencies"] = depends_matches
1517
+ if not metadata_path.is_file():
1518
+ return []
1519
+
1520
+ try:
1521
+ # Validate metadata_path is within base_path
1522
+ _validated_candidate(metadata_path, base_path)
1523
+ except ValueError:
1524
+ # metadata.rb is outside cookbook root
1525
+ return []
1397
1526
 
1398
- # Read Berksfile for additional dependencies
1399
- berksfile = _safe_join(cookbook_path, "Berksfile")
1400
- if berksfile.exists():
1401
- with berksfile.open("r", encoding="utf-8", errors="ignore") as f:
1402
- content = f.read()
1527
+ with metadata_path.open(encoding="utf-8", errors="ignore") as f:
1528
+ content = f.read()
1403
1529
 
1404
- cookbook_matches = re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]', content)
1405
- analysis["external_dependencies"].extend(cookbook_matches)
1530
+ return re.findall(r'depends\s+[\'"]([^\'"]+)[\'"]', content)
1406
1531
 
1407
- # Identify community cookbooks (common ones)
1532
+
1533
+ def _collect_berks_dependencies(base_path: Path) -> list[str]:
1534
+ """Collect dependency declarations from Berksfile with containment checks."""
1535
+ # Build Berksfile path safely within the cookbook
1536
+ berksfile_path: Path = _safe_join(base_path, "Berksfile")
1537
+
1538
+ if not berksfile_path.is_file():
1539
+ return []
1540
+
1541
+ try:
1542
+ # Validate berksfile_path is within base_path
1543
+ _validated_candidate(berksfile_path, base_path)
1544
+ except ValueError:
1545
+ # Berksfile is outside cookbook root
1546
+ return []
1547
+
1548
+ with berksfile_path.open(encoding="utf-8", errors="ignore") as f:
1549
+ content = f.read()
1550
+
1551
+ return re.findall(r'cookbook\s+[\'"]([^\'"]+)[\'"]', content)
1552
+
1553
+
1554
+ def _identify_community_cookbooks_from_list(dependencies: list[str]) -> list[str]:
1555
+ """Return dependencies considered community cookbooks based on patterns."""
1408
1556
  community_cookbook_patterns = [
1409
1557
  "apache2",
1410
1558
  "nginx",
@@ -1421,12 +1569,11 @@ def _analyse_cookbook_dependencies_detailed(cookbook_path) -> dict:
1421
1569
  "users",
1422
1570
  ]
1423
1571
 
1424
- all_deps = analysis["direct_dependencies"] + analysis["external_dependencies"]
1425
- for dep in all_deps:
1426
- if any(pattern in dep.lower() for pattern in community_cookbook_patterns):
1427
- analysis["community_cookbooks"].append(dep)
1428
-
1429
- return analysis
1572
+ return [
1573
+ dep
1574
+ for dep in dependencies
1575
+ if any(pattern in dep.lower() for pattern in community_cookbook_patterns)
1576
+ ]
1430
1577
 
1431
1578
 
1432
1579
  def _determine_migration_order(dependency_analysis: dict) -> list:
@@ -1933,7 +2080,7 @@ def assess_single_cookbook_with_ai(
1933
2080
  """
1934
2081
  try:
1935
2082
  cookbook_path_obj = _normalize_path(cookbook_path)
1936
- if not cookbook_path_obj.exists():
2083
+ if not cookbook_path_obj.exists(): # Read-only check on normalized path
1937
2084
  return {"error": f"Cookbook path not found: {cookbook_path}"}
1938
2085
 
1939
2086
  # Check if AI is available
@@ -2203,8 +2350,7 @@ def _assess_single_cookbook_with_ai(
2203
2350
  base_url: str = "",
2204
2351
  ) -> dict:
2205
2352
  """Assess complexity of a single cookbook using AI analysis."""
2206
- # cookbook_path is already normalized to a Path object
2207
- cookbook = cookbook_path
2353
+ cookbook = _normalize_cookbook_root(cookbook_path)
2208
2354
 
2209
2355
  # Collect basic metrics (same as rule-based)
2210
2356
  artifact_counts = _count_cookbook_artifacts(cookbook)
@@ -2331,10 +2477,15 @@ Provide your analysis in JSON format with keys: complexity_score, estimated_effo
2331
2477
 
2332
2478
  def _get_recipe_content_sample(cookbook_path: Path) -> str:
2333
2479
  """Get a sample of ALL recipe content for AI analysis."""
2334
- recipes_dir = _safe_join(cookbook_path, "recipes")
2335
- if not recipes_dir.exists():
2480
+ # Inline guard directly adjacent to sink
2481
+ base = os.path.realpath(str(cookbook_path)) # noqa: PTH111
2482
+ recipes_dir_str = os.path.realpath(os.path.join(base, "recipes")) # noqa: PTH111, PTH118
2483
+ if os.path.commonpath([base, recipes_dir_str]) != base:
2484
+ raise RuntimeError("Path traversal")
2485
+ if not os.path.exists(recipes_dir_str): # noqa: PTH110
2336
2486
  return "No recipes directory found"
2337
2487
 
2488
+ recipes_dir = Path(recipes_dir_str)
2338
2489
  recipe_files = list(recipes_dir.glob("*.rb"))
2339
2490
  if not recipe_files:
2340
2491
  return "No recipe files found"
@@ -2372,12 +2523,16 @@ def _get_recipe_content_sample(cookbook_path: Path) -> str:
2372
2523
 
2373
2524
  def _get_metadata_content(cookbook_path: Path) -> str:
2374
2525
  """Get metadata content for AI analysis."""
2375
- metadata_file = _safe_join(cookbook_path, METADATA_FILENAME)
2376
- if not metadata_file.exists():
2526
+ # Inline guard directly adjacent to sink
2527
+ base = os.path.realpath(str(cookbook_path)) # noqa: PTH111
2528
+ metadata_file_str = os.path.realpath(os.path.join(base, METADATA_FILENAME)) # noqa: PTH111, PTH118
2529
+ if os.path.commonpath([base, metadata_file_str]) != base:
2530
+ raise RuntimeError("Path traversal")
2531
+ if not os.path.exists(metadata_file_str): # noqa: PTH110
2377
2532
  return "No metadata.rb found"
2378
2533
 
2379
2534
  try:
2380
- return metadata_file.read_text(encoding="utf-8", errors="ignore")
2535
+ return Path(metadata_file_str).read_text(encoding="utf-8", errors="ignore")
2381
2536
  except Exception:
2382
2537
  return "Could not read metadata"
2383
2538