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
@@ -7,6 +7,7 @@ inventory scripts.
7
7
  """
8
8
 
9
9
  import json
10
+ import os
10
11
  import re
11
12
  import shutil
12
13
  import subprocess
@@ -31,7 +32,13 @@ from souschef.core.constants import (
31
32
  REGEX_WHITESPACE_QUOTE,
32
33
  VALUE_PREFIX,
33
34
  )
34
- from souschef.core.path_utils import _normalize_path, _safe_join
35
+ from souschef.core.path_utils import (
36
+ _normalize_path,
37
+ _safe_join,
38
+ safe_exists,
39
+ safe_glob,
40
+ safe_read_text,
41
+ )
35
42
  from souschef.parsers.attributes import parse_attributes
36
43
  from souschef.parsers.recipe import parse_recipe
37
44
 
@@ -42,9 +49,7 @@ except ImportError:
42
49
  requests = None
43
50
 
44
51
  try:
45
- from ibm_watsonx_ai import ( # type: ignore[import-not-found]
46
- APIClient,
47
- )
52
+ from ibm_watsonx_ai import APIClient # type: ignore[import-not-found]
48
53
  except ImportError:
49
54
  APIClient = None
50
55
 
@@ -52,12 +57,13 @@ except ImportError:
52
57
  MAX_GUARD_LENGTH = 500
53
58
 
54
59
 
55
- def generate_playbook_from_recipe(recipe_path: str) -> str:
60
+ def generate_playbook_from_recipe(recipe_path: str, cookbook_path: str = "") -> str:
56
61
  """
57
62
  Generate a complete Ansible playbook from a Chef recipe.
58
63
 
59
64
  Args:
60
65
  recipe_path: Path to the Chef recipe (.rb) file.
66
+ cookbook_path: Optional path to the cookbook root for path validation.
61
67
 
62
68
  Returns:
63
69
  Complete Ansible playbook in YAML format with tasks, handlers, and
@@ -73,10 +79,18 @@ def generate_playbook_from_recipe(recipe_path: str) -> str:
73
79
 
74
80
  # Parse the raw recipe file for advanced features
75
81
  recipe_file = _normalize_path(recipe_path)
76
- if not recipe_file.exists():
77
- return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
78
82
 
79
- raw_content = recipe_file.read_text()
83
+ # Validate path if cookbook_path provided
84
+ base_path = (
85
+ Path(cookbook_path).resolve() if cookbook_path else recipe_file.parent
86
+ )
87
+
88
+ try:
89
+ if not safe_exists(recipe_file, base_path):
90
+ return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
91
+ raw_content = safe_read_text(recipe_file, base_path)
92
+ except ValueError:
93
+ return f"{ERROR_PREFIX} Path traversal attempt detected: {recipe_path}"
80
94
 
81
95
  # Generate playbook structure
82
96
  playbook: str = _generate_playbook_structure(
@@ -98,6 +112,8 @@ def generate_playbook_from_recipe_with_ai(
98
112
  max_tokens: int = 4000,
99
113
  project_id: str = "",
100
114
  base_url: str = "",
115
+ project_recommendations: dict | None = None,
116
+ cookbook_path: str = "",
101
117
  ) -> str:
102
118
  """
103
119
  Generate an AI-enhanced Ansible playbook from a Chef recipe.
@@ -116,6 +132,9 @@ def generate_playbook_from_recipe_with_ai(
116
132
  max_tokens: Maximum tokens to generate.
117
133
  project_id: Project ID for IBM Watsonx (required for watson provider).
118
134
  base_url: Custom base URL for the AI provider.
135
+ project_recommendations: Dictionary containing project-level analysis
136
+ and recommendations from cookbook assessment.
137
+ cookbook_path: Optional path to the cookbook root for path validation.
119
138
 
120
139
  Returns:
121
140
  AI-generated Ansible playbook in YAML format.
@@ -124,10 +143,18 @@ def generate_playbook_from_recipe_with_ai(
124
143
  try:
125
144
  # Parse the recipe file
126
145
  recipe_file = _normalize_path(recipe_path)
127
- if not recipe_file.exists():
128
- return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
129
146
 
130
- raw_content = recipe_file.read_text()
147
+ # Validate path if cookbook_path provided
148
+ base_path = (
149
+ Path(cookbook_path).resolve() if cookbook_path else recipe_file.parent
150
+ )
151
+
152
+ try:
153
+ if not safe_exists(recipe_file, base_path):
154
+ return f"{ERROR_PREFIX} Recipe file does not exist: {recipe_path}"
155
+ raw_content = safe_read_text(recipe_file, base_path)
156
+ except ValueError:
157
+ return f"{ERROR_PREFIX} Path traversal attempt detected: {recipe_path}"
131
158
 
132
159
  # Get basic recipe parsing for context
133
160
  parsed_content = parse_recipe(recipe_path)
@@ -146,6 +173,7 @@ def generate_playbook_from_recipe_with_ai(
146
173
  max_tokens,
147
174
  project_id,
148
175
  base_url,
176
+ project_recommendations,
149
177
  )
150
178
 
151
179
  return ai_playbook
@@ -165,6 +193,7 @@ def _generate_playbook_with_ai(
165
193
  max_tokens: int,
166
194
  project_id: str = "",
167
195
  base_url: str = "",
196
+ project_recommendations: dict | None = None,
168
197
  ) -> str:
169
198
  """Generate Ansible playbook using AI for intelligent conversion."""
170
199
  try:
@@ -174,7 +203,9 @@ def _generate_playbook_with_ai(
174
203
  return client
175
204
 
176
205
  # Create the AI prompt
177
- prompt = _create_ai_conversion_prompt(raw_content, parsed_content, recipe_name)
206
+ prompt = _create_ai_conversion_prompt(
207
+ raw_content, parsed_content, recipe_name, project_recommendations
208
+ )
178
209
 
179
210
  # Call the AI API and get response
180
211
  ai_response = _call_ai_api(
@@ -333,96 +364,257 @@ def _call_ai_api(
333
364
 
334
365
 
335
366
  def _create_ai_conversion_prompt(
336
- raw_content: str, parsed_content: str, recipe_name: str
367
+ raw_content: str,
368
+ parsed_content: str,
369
+ recipe_name: str,
370
+ project_recommendations: dict | None = None,
337
371
  ) -> str:
338
372
  """Create a comprehensive prompt for AI conversion."""
339
- return f"""You are an expert at converting Chef recipes to Ansible playbooks.
340
- Your task is to convert the following Chef recipe into a high-quality,
341
- production-ready Ansible playbook.
342
-
343
- CHEF RECIPE CONTENT:
344
- {raw_content}
345
-
346
- PARSED RECIPE ANALYSIS:
347
- {parsed_content}
348
-
349
- RECIPE NAME: {recipe_name}
350
-
351
- CONVERSION REQUIREMENTS:
352
-
353
- 1. **Understand the Intent**: Analyze what this Chef recipe is trying to
354
- accomplish. Look at the resources, their properties, and the overall
355
- workflow.
356
-
357
- 2. **Best Practices**: Generate Ansible code that follows Ansible best
358
- practices:
359
- - Use appropriate modules (ansible.builtin.* when possible)
360
- - Include proper error handling and idempotency
361
- - Use meaningful variable names
362
- - Include comments explaining complex logic
363
- - Handle edge cases and failure scenarios
364
-
365
- 3. **Resource Mapping**: Convert Chef resources to appropriate Ansible
366
- modules:
367
- - package → ansible.builtin.package or specific package managers
368
- - service → ansible.builtin.service
369
- - file/directory → ansible.builtin.file
370
- - template → ansible.builtin.template
371
- - execute → ansible.builtin.command/shell
372
- - user/group → ansible.builtin.user/group
373
- - mount → ansible.builtin.mount
374
-
375
- 4. **Variables and Facts**: Convert Chef node attributes to Ansible
376
- variables/facts appropriately.
377
-
378
- 5. **Conditionals**: Convert Chef guards (only_if/not_if) to Ansible when
379
- conditions.
380
-
381
- 6. **Notifications**: Convert Chef notifications to Ansible handlers where
382
- appropriate.
383
-
384
- 7. **Idempotency**: Ensure the playbook is idempotent and can be run
385
- multiple times safely.
386
-
387
- 8. **Error Handling**: Include proper error handling and rollback
388
- considerations.
389
-
390
- 9. **Task Ordering**: CRITICAL: Ensure tasks are ordered logically.
391
- - Install packages BEFORE configuring them.
392
- - create users/groups BEFORE using them in file permissions.
393
- - Place configuration files BEFORE starting/restarting services.
394
- - Ensure directories exist BEFORE creating files in them.
395
-
396
- 10. **Handlers**: Verify that all notified handlers are actually defined
397
- in the handlers section.
398
-
399
- OUTPUT FORMAT:
400
- Return ONLY a valid YAML Ansible playbook. Do not include any explanation,
401
- markdown formatting, or code blocks. The output should be pure YAML that can
402
- be directly used as an Ansible playbook.
403
-
404
- The playbook should include:
405
- - A proper name
406
- - Appropriate hosts (default to 'all')
407
- - Variables section if needed
408
- - Tasks section with all converted resources
409
- - Handlers section if notifications are used
410
- - Any necessary pre_tasks or post_tasks
411
-
412
- Example structure:
413
- ---
414
- - name: Convert of {recipe_name}
415
- hosts: all
416
- become: true
417
- vars:
418
- # Variables here
419
- tasks:
420
- # Tasks here
421
- handlers:
422
- # Handlers here
423
-
424
- Focus on creating a functional, well-structured Ansible playbook that achieves
425
- the same outcome as the Chef recipe."""
373
+ prompt_parts = _build_base_prompt_parts(raw_content, parsed_content, recipe_name)
374
+
375
+ # Add project context if available
376
+ if project_recommendations:
377
+ prompt_parts.extend(
378
+ _build_project_context_parts(project_recommendations, recipe_name)
379
+ )
380
+
381
+ prompt_parts.extend(_build_conversion_requirements_parts())
382
+
383
+ # Add project-specific guidance if available
384
+ if project_recommendations:
385
+ prompt_parts.extend(_build_project_guidance_parts(project_recommendations))
386
+
387
+ prompt_parts.extend(_build_output_format_parts())
388
+
389
+ return "\n".join(prompt_parts)
390
+
391
+
392
+ def _build_base_prompt_parts(
393
+ raw_content: str, parsed_content: str, recipe_name: str
394
+ ) -> list[str]:
395
+ """Build the base prompt parts."""
396
+ return [
397
+ "You are an expert at converting Chef recipes to Ansible playbooks.",
398
+ "Your task is to convert the following Chef recipe into a high-quality,",
399
+ "production-ready Ansible playbook.",
400
+ "",
401
+ "CHEF RECIPE CONTENT:",
402
+ raw_content,
403
+ "",
404
+ "PARSED RECIPE ANALYSIS:",
405
+ parsed_content,
406
+ "",
407
+ f"RECIPE NAME: {recipe_name}",
408
+ ]
409
+
410
+
411
+ def _build_project_context_parts(
412
+ project_recommendations: dict, recipe_name: str
413
+ ) -> list[str]:
414
+ """Build project context parts."""
415
+ # Extract values to shorten f-strings
416
+ complexity = project_recommendations.get("project_complexity", "Unknown")
417
+ strategy = project_recommendations.get("migration_strategy", "Unknown")
418
+ effort_days = project_recommendations.get("project_effort_days", 0)
419
+ density = project_recommendations.get("dependency_density", 0)
420
+
421
+ parts = [
422
+ "",
423
+ "PROJECT CONTEXT:",
424
+ f"Project Complexity: {complexity}",
425
+ f"Migration Strategy: {strategy}",
426
+ f"Total Project Effort: {effort_days:.1f} days",
427
+ f"Dependency Density: {density:.2f}",
428
+ ]
429
+
430
+ # Add migration recommendations
431
+ recommendations = project_recommendations.get("recommendations", [])
432
+ if recommendations:
433
+ parts.extend(
434
+ [
435
+ "",
436
+ "PROJECT MIGRATION RECOMMENDATIONS:",
437
+ ]
438
+ )
439
+ for rec in recommendations[:5]: # Limit to first 5 recommendations
440
+ parts.append(f"- {rec}")
441
+
442
+ # Add dependency information
443
+ migration_order = project_recommendations.get("migration_order", [])
444
+ if migration_order:
445
+ recipe_position = _find_recipe_position_in_migration_order(
446
+ migration_order, recipe_name
447
+ )
448
+ if recipe_position:
449
+ dependencies = ", ".join(recipe_position.get("dependencies", [])) or "None"
450
+ parts.extend(
451
+ [
452
+ "",
453
+ "MIGRATION CONTEXT FOR THIS RECIPE:",
454
+ f"Phase: {recipe_position.get('phase', 'Unknown')}",
455
+ f"Complexity: {recipe_position.get('complexity', 'Unknown')}",
456
+ f"Dependencies: {dependencies}",
457
+ f"Migration Reason: {recipe_position.get('reason', 'Unknown')}",
458
+ ]
459
+ )
460
+
461
+ return parts
462
+
463
+
464
+ def _find_recipe_position_in_migration_order(
465
+ migration_order: list[dict[str, Any]], recipe_name: str
466
+ ) -> dict[str, Any] | None:
467
+ """Find this recipe's position in migration order."""
468
+ for item in migration_order:
469
+ cookbook_name = recipe_name.replace(".rb", "").replace("recipes/", "")
470
+ if item.get("cookbook") == cookbook_name:
471
+ return item
472
+ return None
473
+
474
+
475
+ def _build_conversion_requirements_parts() -> list[str]:
476
+ """Build conversion requirements parts."""
477
+ return [
478
+ "",
479
+ "CONVERSION REQUIREMENTS:",
480
+ "",
481
+ "1. **Understand the Intent**: Analyze what this Chef recipe is trying to",
482
+ " accomplish. Look at the resources, their properties, and the overall",
483
+ " workflow.",
484
+ "",
485
+ "2. **Best Practices**: Generate Ansible code that follows Ansible best",
486
+ " practices:",
487
+ " - Use appropriate modules (ansible.builtin.* when possible)",
488
+ " - Include proper error handling and idempotency",
489
+ " - Use meaningful variable names",
490
+ " - Include comments explaining complex logic",
491
+ " - Handle edge cases and failure scenarios",
492
+ "",
493
+ "3. **Resource Mapping**: Convert Chef resources to appropriate Ansible",
494
+ " modules:",
495
+ " - package → ansible.builtin.package or specific package managers",
496
+ " - service → ansible.builtin.service",
497
+ " - file/directory → ansible.builtin.file",
498
+ " - template → ansible.builtin.template (CHANGE .erb to .j2 extension)",
499
+ " - execute → ansible.builtin.command/shell",
500
+ " - user/group → ansible.builtin.user/group",
501
+ " - mount → ansible.builtin.mount",
502
+ " - include_recipe → ansible.builtin.include_role (for unknown cookbooks)",
503
+ " - include_recipe → specific role imports (for known cookbooks)",
504
+ "",
505
+ "4. **Template Conversions**: CRITICAL for template resources:",
506
+ " - Change file extension from .erb to .j2 in the 'src' parameter",
507
+ " - Example: 'config.erb' becomes 'config.j2'",
508
+ " - Add note: Templates need manual ERB→Jinja2 conversion",
509
+ " - ERB syntax: <%= variable %> → Jinja2: {{ variable }}",
510
+ " - ERB blocks: <% code %> → Jinja2: {% code %}",
511
+ "",
512
+ "5. **Chef Data Bags to Ansible Vault**: Convert data bag lookups:",
513
+ " - Chef::EncryptedDataBagItem.load('bag', 'item') →",
514
+ " - Ansible: Use group_vars/ or host_vars/ with ansible-vault",
515
+ " - Store sensitive data in encrypted YAML files, not inline lookups",
516
+ " - Example: Define ssh_key in group_vars/all/vault.yml (encrypted)",
517
+ "",
518
+ "6. **Variables and Facts**: Convert Chef node attributes to Ansible",
519
+ " variables/facts appropriately:",
520
+ " - node['attribute'] → {{ ansible_facts.attribute }} or vars",
521
+ " - node['platform'] → {{ ansible_distribution }}",
522
+ " - node['platform_version'] → {{ ansible_distribution_version }}",
523
+ "",
524
+ "7. **Conditionals**: Convert Chef guards (only_if/not_if) to Ansible when",
525
+ " conditions.",
526
+ "",
527
+ "8. **Notifications**: Convert Chef notifications to Ansible handlers",
528
+ " where appropriate.",
529
+ "",
530
+ "9. **Idempotency**: Ensure the playbook is idempotent and can be run",
531
+ " multiple times safely.",
532
+ "",
533
+ "10. **Error Handling**: Include proper error handling and rollback",
534
+ " considerations.",
535
+ "",
536
+ "11. **Task Ordering**: CRITICAL: Ensure tasks are ordered logically.",
537
+ " - Install packages BEFORE configuring them.",
538
+ " - create users/groups BEFORE using them in file permissions.",
539
+ " - Place configuration files BEFORE starting/restarting services.",
540
+ " - Ensure directories exist BEFORE creating files in them.",
541
+ "",
542
+ "12. **Handlers**: Verify that all notified handlers are actually defined",
543
+ " in the handlers section.",
544
+ ]
545
+
546
+
547
+ def _build_project_guidance_parts(project_recommendations: dict) -> list[str]:
548
+ """Build project-specific guidance parts."""
549
+ strategy = project_recommendations.get("migration_strategy", "").lower()
550
+ parts = []
551
+
552
+ if "parallel" in strategy:
553
+ parallel_tracks = project_recommendations.get("parallel_tracks", 2)
554
+ parts.extend(
555
+ [
556
+ "",
557
+ "11. **Parallel Migration Context**: This recipe is part of a",
558
+ f" parallel migration with {parallel_tracks} tracks.",
559
+ " Ensure this playbook can run independently without",
560
+ " dependencies on other cookbooks in the project.",
561
+ ]
562
+ )
563
+ elif "phased" in strategy:
564
+ parts.extend(
565
+ [
566
+ "",
567
+ "11. **Phased Migration Context**: This recipe is part of a phased",
568
+ " migration approach. Consider dependencies and ensure proper",
569
+ " ordering within the broader project migration plan.",
570
+ ]
571
+ )
572
+
573
+ return parts
574
+
575
+
576
+ def _build_output_format_parts() -> list[str]:
577
+ """Build output format parts."""
578
+ return [
579
+ "",
580
+ "OUTPUT FORMAT:",
581
+ "Return ONLY a valid YAML Ansible playbook. Do not include any",
582
+ " explanation, markdown formatting, or code blocks. The output should",
583
+ " be pure YAML that can be directly used as an Ansible playbook.",
584
+ "",
585
+ "CRITICAL YAML SYNTAX RULES:",
586
+ "- Use block mapping style (one key per line) NOT flow mapping style",
587
+ "- NEVER use { } for mappings with conditionals or complex expressions",
588
+ "- Correct: Use multi-line format:",
589
+ " - name: Include role conditionally",
590
+ " ansible.builtin.import_role:",
591
+ " name: my_role",
592
+ " when: condition_here",
593
+ "- WRONG: { role: my_role, when: condition } # This is INVALID",
594
+ "",
595
+ "The playbook should include:",
596
+ "- A proper name",
597
+ "- Appropriate hosts (default to 'all')",
598
+ "- Variables section if needed",
599
+ "- Tasks section with all converted resources",
600
+ "- Handlers section if notifications are used",
601
+ "- Any necessary pre_tasks or post_tasks",
602
+ "",
603
+ "Example structure:",
604
+ "---",
605
+ "- name: Convert of <recipe_name>",
606
+ " hosts: all",
607
+ " become: true",
608
+ " vars:",
609
+ " # Variables here",
610
+ " tasks:",
611
+ " # Tasks here",
612
+ " handlers:",
613
+ " # Handlers here",
614
+ "",
615
+ "Focus on creating a functional, well-structured Ansible playbook that",
616
+ "achieves the same outcome as the Chef recipe.",
617
+ ]
426
618
 
427
619
 
428
620
  def _clean_ai_playbook_response(ai_response: str) -> str:
@@ -507,10 +699,18 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
507
699
  if shutil.which("ansible-lint") is None:
508
700
  return None
509
701
 
702
+ tmp_path = None
510
703
  try:
511
- with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tmp:
512
- tmp.write(playbook_content)
513
- tmp_path = tmp.name
704
+ # Create temp file with secure permissions (0o600 = rw-------)
705
+ # Use os.open with secure flags instead of NamedTemporaryFile for better control
706
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".yml", text=True)
707
+ try:
708
+ # Write content to file descriptor (atomic operation)
709
+ with os.fdopen(tmp_fd, "w") as tmp:
710
+ tmp.write(playbook_content)
711
+ except Exception:
712
+ os.close(tmp_fd)
713
+ raise
514
714
 
515
715
  # Run ansible-lint
516
716
  # We ignore return code because we want to capture output even on failure
@@ -528,7 +728,7 @@ def _run_ansible_lint(playbook_content: str) -> str | None:
528
728
  except Exception:
529
729
  return None
530
730
  finally:
531
- if "tmp_path" in locals() and Path(tmp_path).exists():
731
+ if tmp_path is not None and Path(tmp_path).exists():
532
732
  Path(tmp_path).unlink()
533
733
 
534
734
 
@@ -599,8 +799,9 @@ def analyse_chef_search_patterns(recipe_or_cookbook_path: str) -> str:
599
799
  path_obj = _normalize_path(recipe_or_cookbook_path)
600
800
 
601
801
  if path_obj.is_file():
602
- # Single recipe file
603
- search_patterns = _extract_search_patterns_from_file(path_obj)
802
+ # Single recipe file - use parent directory as base path
803
+ base_path = path_obj.parent
804
+ search_patterns = _extract_search_patterns_from_file(path_obj, base_path)
604
805
  elif path_obj.is_dir():
605
806
  # Cookbook directory
606
807
  search_patterns = _extract_search_patterns_from_cookbook(path_obj)
@@ -996,9 +1197,8 @@ def main():
996
1197
  if __name__ == "__main__":
997
1198
  main()
998
1199
  '''
999
-
1000
1200
  # Convert queries_data to JSON string for embedding
1001
- queries_json = json.dumps(
1201
+ queries_json = json.dumps( # nosonar
1002
1202
  {
1003
1203
  item.get("group_name", f"group_{i}"): item.get("search_query", "")
1004
1204
  for i, item in enumerate(queries_data)
@@ -1012,39 +1212,66 @@ if __name__ == "__main__":
1012
1212
  # Search pattern extraction
1013
1213
 
1014
1214
 
1015
- def _extract_search_patterns_from_file(file_path: Path) -> list[dict[str, str]]:
1016
- """Extract Chef search patterns from a single recipe file."""
1215
+ def _extract_search_patterns_from_file(
1216
+ file_path: Path, base_path: Path
1217
+ ) -> list[dict[str, str]]:
1218
+ """
1219
+ Extract Chef search patterns from a single recipe file.
1220
+
1221
+ Args:
1222
+ file_path: Path to the file to parse.
1223
+ base_path: Base directory for path validation.
1224
+
1225
+ Returns:
1226
+ List of search patterns found in the file.
1227
+
1228
+ """
1017
1229
  try:
1018
- content = file_path.read_text()
1230
+ content = safe_read_text(file_path, base_path)
1019
1231
  return _find_search_patterns_in_content(content, str(file_path))
1020
1232
  except Exception:
1021
1233
  return []
1022
1234
 
1023
1235
 
1024
1236
  def _extract_search_patterns_from_cookbook(cookbook_path: Path) -> list[dict[str, str]]:
1025
- """Extract Chef search patterns from all files in a cookbook."""
1237
+ """
1238
+ Extract Chef search patterns from all files in a cookbook.
1239
+
1240
+ Args:
1241
+ cookbook_path: Path to the cookbook directory.
1242
+
1243
+ Returns:
1244
+ List of all search patterns found in the cookbook.
1245
+
1246
+ """
1026
1247
  patterns = []
1027
1248
 
1028
- # Search in recipes directory
1249
+ # Search in recipes directory using safe_glob
1029
1250
  recipes_dir = _safe_join(cookbook_path, "recipes")
1030
- if recipes_dir.exists():
1031
- for recipe_file in recipes_dir.glob("*.rb"):
1032
- file_patterns = _extract_search_patterns_from_file(recipe_file)
1033
- patterns.extend(file_patterns)
1251
+ if safe_exists(recipes_dir, cookbook_path):
1252
+ for recipe_file in safe_glob(recipes_dir, "*.rb", cookbook_path):
1253
+ patterns_found = _extract_search_patterns_from_file(
1254
+ recipe_file, cookbook_path
1255
+ )
1256
+ patterns.extend(patterns_found)
1034
1257
 
1035
- # Search in libraries directory
1258
+ # Search in libraries directory using safe_glob
1036
1259
  libraries_dir = _safe_join(cookbook_path, "libraries")
1037
- if libraries_dir.exists():
1038
- for library_file in libraries_dir.glob("*.rb"):
1039
- file_patterns = _extract_search_patterns_from_file(library_file)
1040
- patterns.extend(file_patterns)
1260
+ if safe_exists(libraries_dir, cookbook_path):
1261
+ for library_file in safe_glob(libraries_dir, "*.rb", cookbook_path):
1262
+ patterns_found = _extract_search_patterns_from_file(
1263
+ library_file, cookbook_path
1264
+ )
1265
+ patterns.extend(patterns_found)
1041
1266
 
1042
- # Search in resources directory
1267
+ # Search in resources directory using safe_glob
1043
1268
  resources_dir = _safe_join(cookbook_path, "resources")
1044
- if resources_dir.exists():
1045
- for resource_file in resources_dir.glob("*.rb"):
1046
- file_patterns = _extract_search_patterns_from_file(resource_file)
1047
- patterns.extend(file_patterns)
1269
+ if safe_exists(resources_dir, cookbook_path):
1270
+ for resource_file in safe_glob(resources_dir, "*.rb", cookbook_path):
1271
+ patterns_found = _extract_search_patterns_from_file(
1272
+ resource_file, cookbook_path
1273
+ )
1274
+ patterns.extend(patterns_found)
1048
1275
 
1049
1276
  return patterns
1050
1277
 
@@ -1261,19 +1488,32 @@ def _build_playbook_header(recipe_name: str) -> list[str]:
1261
1488
  def _add_playbook_variables(
1262
1489
  playbook_lines: list[str], raw_content: str, recipe_file: Path
1263
1490
  ) -> None:
1264
- """Extract and add variables section to playbook."""
1491
+ """
1492
+ Extract and add variables section to playbook.
1493
+
1494
+ Args:
1495
+ playbook_lines: List of playbook lines to add variables to.
1496
+ raw_content: Raw recipe file content.
1497
+ recipe_file: Path to the recipe file, normalized and contained within cookbook.
1498
+
1499
+ """
1265
1500
  variables = _extract_recipe_variables(raw_content)
1266
1501
 
1267
- # Try to parse attributes file
1268
- attributes_path = recipe_file.parent.parent / "attributes" / "default.rb"
1269
- if attributes_path.exists():
1270
- attributes_content = parse_attributes(str(attributes_path))
1271
- if not attributes_content.startswith(
1272
- "Error:"
1273
- ) and not attributes_content.startswith("Warning:"):
1274
- # Parse the resolved attributes
1275
- attr_vars = _extract_attribute_variables(attributes_content)
1276
- variables.update(attr_vars)
1502
+ # Try to parse attributes file - validate it stays within cookbook
1503
+ cookbook_path = recipe_file.parent.parent
1504
+ attributes_path = _safe_join(cookbook_path, "attributes", "default.rb")
1505
+ try:
1506
+ if safe_exists(attributes_path, cookbook_path):
1507
+ attributes_content = parse_attributes(str(attributes_path))
1508
+ if not attributes_content.startswith(
1509
+ "Error:"
1510
+ ) and not attributes_content.startswith("Warning:"):
1511
+ # Parse the resolved attributes
1512
+ attr_vars = _extract_attribute_variables(attributes_content)
1513
+ variables.update(attr_vars)
1514
+ except ValueError:
1515
+ # Path traversal attempt detected - skip safely
1516
+ pass
1277
1517
 
1278
1518
  for var_name, var_value in variables.items():
1279
1519
  playbook_lines.append(f" {var_name}: {var_value}")