mcp-souschef 2.1.2__py3-none-any.whl → 2.5.3__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.
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/METADATA +200 -19
- mcp_souschef-2.5.3.dist-info/RECORD +38 -0
- mcp_souschef-2.5.3.dist-info/entry_points.txt +4 -0
- souschef/assessment.py +531 -180
- souschef/ci/__init__.py +11 -0
- souschef/ci/github_actions.py +379 -0
- souschef/ci/gitlab_ci.py +299 -0
- souschef/ci/jenkins_pipeline.py +343 -0
- souschef/cli.py +691 -1
- souschef/converters/playbook.py +43 -5
- souschef/converters/resource.py +146 -49
- souschef/core/__init__.py +22 -0
- souschef/core/errors.py +275 -0
- souschef/core/validation.py +35 -2
- souschef/deployment.py +414 -100
- souschef/filesystem/operations.py +0 -7
- souschef/parsers/__init__.py +6 -1
- souschef/parsers/habitat.py +35 -6
- souschef/parsers/inspec.py +415 -52
- souschef/parsers/metadata.py +89 -23
- souschef/profiling.py +568 -0
- souschef/server.py +948 -255
- souschef/ui/__init__.py +8 -0
- souschef/ui/app.py +1837 -0
- souschef/ui/pages/cookbook_analysis.py +425 -0
- mcp_souschef-2.1.2.dist-info/RECORD +0 -29
- mcp_souschef-2.1.2.dist-info/entry_points.txt +0 -4
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/WHEEL +0 -0
- {mcp_souschef-2.1.2.dist-info → mcp_souschef-2.5.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""Filesystem operations for Chef cookbook exploration."""
|
|
2
2
|
|
|
3
|
-
from mcp.server.fastmcp import FastMCP
|
|
4
|
-
|
|
5
3
|
from souschef.core.constants import (
|
|
6
4
|
ERROR_FILE_NOT_FOUND,
|
|
7
5
|
ERROR_IS_DIRECTORY,
|
|
@@ -9,11 +7,7 @@ from souschef.core.constants import (
|
|
|
9
7
|
)
|
|
10
8
|
from souschef.core.path_utils import _normalize_path
|
|
11
9
|
|
|
12
|
-
# Get MCP instance to register tools
|
|
13
|
-
mcp = FastMCP("souschef")
|
|
14
|
-
|
|
15
10
|
|
|
16
|
-
@mcp.tool()
|
|
17
11
|
def list_directory(path: str) -> list[str] | str:
|
|
18
12
|
"""
|
|
19
13
|
List the contents of a directory.
|
|
@@ -40,7 +34,6 @@ def list_directory(path: str) -> list[str] | str:
|
|
|
40
34
|
return f"An error occurred: {e}"
|
|
41
35
|
|
|
42
36
|
|
|
43
|
-
@mcp.tool()
|
|
44
37
|
def read_file(path: str) -> str:
|
|
45
38
|
"""
|
|
46
39
|
Read the contents of a file.
|
souschef/parsers/__init__.py
CHANGED
|
@@ -13,7 +13,11 @@ from souschef.parsers.inspec import (
|
|
|
13
13
|
generate_inspec_from_chef,
|
|
14
14
|
parse_inspec_profile,
|
|
15
15
|
)
|
|
16
|
-
from souschef.parsers.metadata import
|
|
16
|
+
from souschef.parsers.metadata import (
|
|
17
|
+
list_cookbook_structure,
|
|
18
|
+
parse_cookbook_metadata,
|
|
19
|
+
read_cookbook_metadata,
|
|
20
|
+
)
|
|
17
21
|
from souschef.parsers.recipe import parse_recipe
|
|
18
22
|
from souschef.parsers.resource import parse_custom_resource
|
|
19
23
|
from souschef.parsers.template import parse_template
|
|
@@ -24,6 +28,7 @@ __all__ = [
|
|
|
24
28
|
"parse_attributes",
|
|
25
29
|
"parse_custom_resource",
|
|
26
30
|
"read_cookbook_metadata",
|
|
31
|
+
"parse_cookbook_metadata",
|
|
27
32
|
"list_cookbook_structure",
|
|
28
33
|
"parse_inspec_profile",
|
|
29
34
|
"convert_inspec_to_test",
|
souschef/parsers/habitat.py
CHANGED
|
@@ -205,6 +205,32 @@ def _extract_plan_exports(content: str, var_name: str) -> list[dict[str, str]]:
|
|
|
205
205
|
return exports
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def _is_quote_blocked(
|
|
209
|
+
ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""Check if a quote character is blocked by other active quotes."""
|
|
212
|
+
if ch == "'":
|
|
213
|
+
return in_double_quote or in_backtick
|
|
214
|
+
if ch == '"':
|
|
215
|
+
return in_single_quote or in_backtick
|
|
216
|
+
if ch == "`":
|
|
217
|
+
return in_single_quote or in_double_quote
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _toggle_quote(
|
|
222
|
+
ch: str, in_single_quote: bool, in_double_quote: bool, in_backtick: bool
|
|
223
|
+
) -> tuple[bool, bool, bool]:
|
|
224
|
+
"""Toggle the appropriate quote state based on character."""
|
|
225
|
+
if ch == "'":
|
|
226
|
+
return not in_single_quote, in_double_quote, in_backtick
|
|
227
|
+
if ch == '"':
|
|
228
|
+
return in_single_quote, not in_double_quote, in_backtick
|
|
229
|
+
if ch == "`":
|
|
230
|
+
return in_single_quote, in_double_quote, not in_backtick
|
|
231
|
+
return in_single_quote, in_double_quote, in_backtick
|
|
232
|
+
|
|
233
|
+
|
|
208
234
|
def _update_quote_state(
|
|
209
235
|
ch: str,
|
|
210
236
|
in_single_quote: bool,
|
|
@@ -213,18 +239,21 @@ def _update_quote_state(
|
|
|
213
239
|
escape_next: bool,
|
|
214
240
|
) -> tuple[bool, bool, bool, bool]:
|
|
215
241
|
"""Update quote tracking state for shell script parsing."""
|
|
242
|
+
# Handle escape sequences
|
|
216
243
|
if escape_next:
|
|
217
244
|
return in_single_quote, in_double_quote, in_backtick, False
|
|
218
245
|
|
|
219
246
|
if ch == "\\":
|
|
220
247
|
return in_single_quote, in_double_quote, in_backtick, True
|
|
221
248
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
# Handle quote characters
|
|
250
|
+
if ch in ("'", '"', "`") and not _is_quote_blocked(
|
|
251
|
+
ch, in_single_quote, in_double_quote, in_backtick
|
|
252
|
+
):
|
|
253
|
+
single, double, backtick = _toggle_quote(
|
|
254
|
+
ch, in_single_quote, in_double_quote, in_backtick
|
|
255
|
+
)
|
|
256
|
+
return single, double, backtick, False
|
|
228
257
|
|
|
229
258
|
return in_single_quote, in_double_quote, in_backtick, False
|
|
230
259
|
|
souschef/parsers/inspec.py
CHANGED
|
@@ -8,6 +8,14 @@ from typing import Any
|
|
|
8
8
|
from souschef.core.constants import ERROR_PREFIX, INSPEC_END_INDENT, INSPEC_SHOULD_EXIST
|
|
9
9
|
from souschef.core.path_utils import _normalize_path, _safe_join
|
|
10
10
|
|
|
11
|
+
# Regex patterns used across converters
|
|
12
|
+
_VERSION_PATTERN = r"match\s+/([^/]+)/"
|
|
13
|
+
_MODE_PATTERN = r"cmp\s+'([^']+)'"
|
|
14
|
+
_OWNER_PATTERN = r"eq\s+['\"]([^'\"]+)['\"]"
|
|
15
|
+
|
|
16
|
+
# ServerSpec formatting constants
|
|
17
|
+
_SERVERSPEC_END = " end"
|
|
18
|
+
|
|
11
19
|
|
|
12
20
|
def parse_inspec_profile(path: str) -> str:
|
|
13
21
|
"""
|
|
@@ -21,17 +29,32 @@ def parse_inspec_profile(path: str) -> str:
|
|
|
21
29
|
|
|
22
30
|
"""
|
|
23
31
|
try:
|
|
32
|
+
# Validate input
|
|
33
|
+
if not path or not path.strip():
|
|
34
|
+
return (
|
|
35
|
+
"Error: Path cannot be empty\n\n"
|
|
36
|
+
"Suggestion: Provide a path to an InSpec profile directory "
|
|
37
|
+
"or control file"
|
|
38
|
+
)
|
|
39
|
+
|
|
24
40
|
profile_path = _normalize_path(path)
|
|
25
41
|
|
|
26
42
|
if not profile_path.exists():
|
|
27
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
f"Error: Path does not exist: {path}\n\n"
|
|
45
|
+
"Suggestion: Check that the path is correct and the InSpec "
|
|
46
|
+
"profile exists"
|
|
47
|
+
)
|
|
28
48
|
|
|
29
49
|
if profile_path.is_dir():
|
|
30
50
|
controls = _parse_controls_from_directory(profile_path)
|
|
31
51
|
elif profile_path.is_file():
|
|
32
52
|
controls = _parse_controls_from_file(profile_path)
|
|
33
53
|
else:
|
|
34
|
-
return
|
|
54
|
+
return (
|
|
55
|
+
f"Error: Invalid path type: {path}\n\n"
|
|
56
|
+
"Suggestion: Provide a directory or file path, not a special file type"
|
|
57
|
+
)
|
|
35
58
|
|
|
36
59
|
return json.dumps(
|
|
37
60
|
{
|
|
@@ -43,18 +66,19 @@ def parse_inspec_profile(path: str) -> str:
|
|
|
43
66
|
)
|
|
44
67
|
|
|
45
68
|
except (FileNotFoundError, RuntimeError) as e:
|
|
46
|
-
return f"Error: {e}"
|
|
69
|
+
return f"Error: {e}\n\nSuggestion: Verify the path exists and is accessible"
|
|
47
70
|
except Exception as e:
|
|
48
71
|
return f"An error occurred while parsing InSpec profile: {e}"
|
|
49
72
|
|
|
50
73
|
|
|
51
|
-
def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -> str:
|
|
74
|
+
def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -> str: # noqa: C901
|
|
52
75
|
"""
|
|
53
|
-
Convert InSpec controls to
|
|
76
|
+
Convert InSpec controls to test framework format.
|
|
54
77
|
|
|
55
78
|
Args:
|
|
56
79
|
inspec_path: Path to InSpec profile or control file.
|
|
57
|
-
output_format: Output format ('testinfra'
|
|
80
|
+
output_format: Output format ('testinfra', 'ansible_assert',
|
|
81
|
+
'serverspec', or 'goss').
|
|
58
82
|
|
|
59
83
|
Returns:
|
|
60
84
|
Converted test code or error message.
|
|
@@ -73,19 +97,51 @@ def convert_inspec_to_test(inspec_path: str, output_format: str = "testinfra") -
|
|
|
73
97
|
controls = profile_data["controls"]
|
|
74
98
|
|
|
75
99
|
if not controls:
|
|
76
|
-
return "
|
|
100
|
+
return "Error: No controls found in InSpec profile"
|
|
77
101
|
|
|
78
102
|
# Convert each control
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
converted_tests = []
|
|
104
|
+
|
|
105
|
+
if output_format == "testinfra":
|
|
106
|
+
converted_tests.append("import pytest")
|
|
107
|
+
converted_tests.append("")
|
|
108
|
+
converted_tests.append("")
|
|
109
|
+
for control in controls:
|
|
110
|
+
test_code = _convert_inspec_to_testinfra(control)
|
|
111
|
+
converted_tests.append(test_code)
|
|
112
|
+
|
|
113
|
+
elif output_format == "ansible_assert":
|
|
114
|
+
converted_tests.append("---")
|
|
115
|
+
converted_tests.append("# Validation tasks converted from InSpec")
|
|
116
|
+
converted_tests.append("")
|
|
117
|
+
for control in controls:
|
|
118
|
+
assert_code = _convert_inspec_to_ansible_assert(control)
|
|
119
|
+
converted_tests.append(assert_code)
|
|
120
|
+
converted_tests.append("")
|
|
121
|
+
|
|
122
|
+
elif output_format == "serverspec":
|
|
123
|
+
converted_tests.append("# frozen_string_literal: true")
|
|
124
|
+
converted_tests.append("")
|
|
125
|
+
converted_tests.append("require 'serverspec'")
|
|
126
|
+
converted_tests.append("")
|
|
127
|
+
converted_tests.append("set :backend, :exec")
|
|
128
|
+
converted_tests.append("")
|
|
129
|
+
for control in controls:
|
|
130
|
+
serverspec_code = _convert_inspec_to_serverspec(control)
|
|
131
|
+
converted_tests.append(serverspec_code)
|
|
132
|
+
|
|
133
|
+
elif output_format == "goss":
|
|
134
|
+
# Goss uses a single YAML spec for all tests
|
|
135
|
+
return _convert_inspec_to_goss(controls)
|
|
87
136
|
|
|
88
|
-
|
|
137
|
+
else:
|
|
138
|
+
error_msg = (
|
|
139
|
+
f"Error: Unsupported format '{output_format}'. "
|
|
140
|
+
"Use 'testinfra', 'ansible_assert', 'serverspec', or 'goss'"
|
|
141
|
+
)
|
|
142
|
+
return error_msg
|
|
143
|
+
|
|
144
|
+
return "\n".join(converted_tests)
|
|
89
145
|
|
|
90
146
|
except json.JSONDecodeError as e:
|
|
91
147
|
return f"Error parsing InSpec result: {e}"
|
|
@@ -388,7 +444,7 @@ def _convert_package_to_testinfra(
|
|
|
388
444
|
if "be_installed" in exp["matcher"]:
|
|
389
445
|
lines.append(" assert pkg.is_installed")
|
|
390
446
|
elif exp["type"] == "its" and exp["property"] == "version":
|
|
391
|
-
version_match = re.search(
|
|
447
|
+
version_match = re.search(_VERSION_PATTERN, exp["matcher"])
|
|
392
448
|
if version_match:
|
|
393
449
|
version = version_match.group(1)
|
|
394
450
|
lines.append(f' assert pkg.version.startswith("{version}")')
|
|
@@ -500,6 +556,275 @@ def _convert_inspec_to_testinfra(control: dict[str, Any]) -> str:
|
|
|
500
556
|
return "\n".join(lines)
|
|
501
557
|
|
|
502
558
|
|
|
559
|
+
def _convert_package_to_serverspec(
|
|
560
|
+
lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
|
|
561
|
+
) -> None:
|
|
562
|
+
"""
|
|
563
|
+
Convert package resource to ServerSpec expectations.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
lines: List to append test lines to.
|
|
567
|
+
resource_name: Name of the package.
|
|
568
|
+
expectations: List of InSpec expectations.
|
|
569
|
+
|
|
570
|
+
"""
|
|
571
|
+
lines.append(f" describe package('{resource_name}') do")
|
|
572
|
+
for exp in expectations:
|
|
573
|
+
if "be_installed" in exp["matcher"]:
|
|
574
|
+
lines.append(" it { should be_installed }")
|
|
575
|
+
elif exp["type"] == "its" and exp["property"] == "version":
|
|
576
|
+
version_match = re.search(_VERSION_PATTERN, exp["matcher"])
|
|
577
|
+
if version_match:
|
|
578
|
+
version = version_match.group(1)
|
|
579
|
+
lines.append(f" its('version') {{ should match /{version}/ }}")
|
|
580
|
+
lines.append(_SERVERSPEC_END)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _convert_service_to_serverspec(
|
|
584
|
+
lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
|
|
585
|
+
) -> None:
|
|
586
|
+
"""
|
|
587
|
+
Convert service resource to ServerSpec expectations.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
lines: List to append test lines to.
|
|
591
|
+
resource_name: Name of the service.
|
|
592
|
+
expectations: List of InSpec expectations.
|
|
593
|
+
|
|
594
|
+
"""
|
|
595
|
+
lines.append(f" describe service('{resource_name}') do")
|
|
596
|
+
for exp in expectations:
|
|
597
|
+
if "be_running" in exp["matcher"]:
|
|
598
|
+
lines.append(" it { should be_running }")
|
|
599
|
+
elif "be_enabled" in exp["matcher"]:
|
|
600
|
+
lines.append(" it { should be_enabled }")
|
|
601
|
+
lines.append(_SERVERSPEC_END)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _convert_file_to_serverspec(
|
|
605
|
+
lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
|
|
606
|
+
) -> None:
|
|
607
|
+
"""
|
|
608
|
+
Convert file resource to ServerSpec expectations.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
lines: List to append test lines to.
|
|
612
|
+
resource_name: Path to the file.
|
|
613
|
+
expectations: List of InSpec expectations.
|
|
614
|
+
|
|
615
|
+
"""
|
|
616
|
+
lines.append(f" describe file('{resource_name}') do")
|
|
617
|
+
for exp in expectations:
|
|
618
|
+
if "exist" in exp["matcher"]:
|
|
619
|
+
lines.append(" it { should exist }")
|
|
620
|
+
elif exp["type"] == "its" and exp["property"] == "mode":
|
|
621
|
+
mode_match = re.search(_MODE_PATTERN, exp["matcher"])
|
|
622
|
+
if mode_match:
|
|
623
|
+
mode = mode_match.group(1)
|
|
624
|
+
lines.append(f" its('mode') {{ should cmp '{mode}' }}")
|
|
625
|
+
elif exp["type"] == "its" and exp["property"] == "owner":
|
|
626
|
+
owner_match = re.search(_OWNER_PATTERN, exp["matcher"])
|
|
627
|
+
if owner_match:
|
|
628
|
+
owner = owner_match.group(1)
|
|
629
|
+
lines.append(f" its('owner') {{ should eq '{owner}' }}")
|
|
630
|
+
lines.append(_SERVERSPEC_END)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _convert_port_to_serverspec(
|
|
634
|
+
lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
|
|
635
|
+
) -> None:
|
|
636
|
+
"""
|
|
637
|
+
Convert port resource to ServerSpec expectations.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
lines: List to append test lines to.
|
|
641
|
+
resource_name: Port number or address.
|
|
642
|
+
expectations: List of InSpec expectations.
|
|
643
|
+
|
|
644
|
+
"""
|
|
645
|
+
lines.append(f" describe port({resource_name}) do")
|
|
646
|
+
for exp in expectations:
|
|
647
|
+
if "be_listening" in exp["matcher"]:
|
|
648
|
+
lines.append(" it { should be_listening }")
|
|
649
|
+
lines.append(_SERVERSPEC_END)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _convert_inspec_to_serverspec(control: dict[str, Any]) -> str:
|
|
653
|
+
"""
|
|
654
|
+
Convert InSpec control to ServerSpec test.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
control: Parsed InSpec control dictionary.
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
ServerSpec test code as string.
|
|
661
|
+
|
|
662
|
+
"""
|
|
663
|
+
lines = []
|
|
664
|
+
|
|
665
|
+
# Add describe block for the control
|
|
666
|
+
lines.append(f"describe '{control.get('title') or control['id']}' do")
|
|
667
|
+
|
|
668
|
+
# Convert each test within the control
|
|
669
|
+
for test in control["tests"]:
|
|
670
|
+
resource_type = test["resource_type"]
|
|
671
|
+
resource_name = test["resource_name"]
|
|
672
|
+
expectations = test["expectations"]
|
|
673
|
+
|
|
674
|
+
# Map InSpec resources to ServerSpec using dedicated converters
|
|
675
|
+
if resource_type == "package":
|
|
676
|
+
_convert_package_to_serverspec(lines, resource_name, expectations)
|
|
677
|
+
elif resource_type == "service":
|
|
678
|
+
_convert_service_to_serverspec(lines, resource_name, expectations)
|
|
679
|
+
elif resource_type == "file":
|
|
680
|
+
_convert_file_to_serverspec(lines, resource_name, expectations)
|
|
681
|
+
elif resource_type == "port":
|
|
682
|
+
_convert_port_to_serverspec(lines, resource_name, expectations)
|
|
683
|
+
|
|
684
|
+
lines.append("end")
|
|
685
|
+
lines.append("")
|
|
686
|
+
return "\n".join(lines)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _convert_package_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
|
|
690
|
+
"""
|
|
691
|
+
Convert package resource to Goss specification.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
expectations: List of InSpec expectations.
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
Goss package specification dictionary.
|
|
698
|
+
|
|
699
|
+
"""
|
|
700
|
+
spec: dict[str, Any] = {}
|
|
701
|
+
for exp in expectations:
|
|
702
|
+
if "be_installed" in exp["matcher"]:
|
|
703
|
+
spec["installed"] = True
|
|
704
|
+
elif exp["type"] == "its" and exp["property"] == "version":
|
|
705
|
+
version_match = re.search(_VERSION_PATTERN, exp["matcher"])
|
|
706
|
+
if version_match:
|
|
707
|
+
spec["versions"] = [version_match.group(1)]
|
|
708
|
+
return spec
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _convert_service_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
|
|
712
|
+
"""
|
|
713
|
+
Convert service resource to Goss specification.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
expectations: List of InSpec expectations.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
Goss service specification dictionary.
|
|
720
|
+
|
|
721
|
+
"""
|
|
722
|
+
spec: dict[str, Any] = {}
|
|
723
|
+
for exp in expectations:
|
|
724
|
+
if "be_running" in exp["matcher"]:
|
|
725
|
+
spec["running"] = True
|
|
726
|
+
elif "be_enabled" in exp["matcher"]:
|
|
727
|
+
spec["enabled"] = True
|
|
728
|
+
return spec
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _convert_file_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
|
|
732
|
+
"""
|
|
733
|
+
Convert file resource to Goss specification.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
expectations: List of InSpec expectations.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
Goss file specification dictionary.
|
|
740
|
+
|
|
741
|
+
"""
|
|
742
|
+
spec: dict[str, Any] = {}
|
|
743
|
+
for exp in expectations:
|
|
744
|
+
if "exist" in exp["matcher"]:
|
|
745
|
+
spec["exists"] = True
|
|
746
|
+
elif exp["type"] == "its" and exp["property"] == "mode":
|
|
747
|
+
mode_match = re.search(_MODE_PATTERN, exp["matcher"])
|
|
748
|
+
if mode_match:
|
|
749
|
+
spec["mode"] = mode_match.group(1)
|
|
750
|
+
elif exp["type"] == "its" and exp["property"] == "owner":
|
|
751
|
+
owner_match = re.search(_OWNER_PATTERN, exp["matcher"])
|
|
752
|
+
if owner_match:
|
|
753
|
+
spec["owner"] = owner_match.group(1)
|
|
754
|
+
return spec
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _convert_port_to_goss(expectations: list[dict[str, Any]]) -> dict[str, Any]:
|
|
758
|
+
"""
|
|
759
|
+
Convert port resource to Goss specification.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
expectations: List of InSpec expectations.
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
Goss port specification dictionary.
|
|
766
|
+
|
|
767
|
+
"""
|
|
768
|
+
spec: dict[str, Any] = {}
|
|
769
|
+
for exp in expectations:
|
|
770
|
+
if "be_listening" in exp["matcher"]:
|
|
771
|
+
spec["listening"] = True
|
|
772
|
+
return spec
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _convert_inspec_to_goss(controls: list[dict[str, Any]]) -> str:
|
|
776
|
+
"""
|
|
777
|
+
Convert InSpec controls to Goss YAML specification.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
controls: List of parsed InSpec control dictionaries.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Goss specification as YAML string.
|
|
784
|
+
|
|
785
|
+
"""
|
|
786
|
+
goss_spec: dict[str, dict[str, Any]] = {
|
|
787
|
+
"package": {},
|
|
788
|
+
"service": {},
|
|
789
|
+
"file": {},
|
|
790
|
+
"port": {},
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
# Process all controls and group by resource type
|
|
794
|
+
for control in controls:
|
|
795
|
+
for test in control["tests"]:
|
|
796
|
+
resource_type = test["resource_type"]
|
|
797
|
+
resource_name = test["resource_name"]
|
|
798
|
+
expectations = test["expectations"]
|
|
799
|
+
|
|
800
|
+
if resource_type == "package":
|
|
801
|
+
spec = _convert_package_to_goss(expectations)
|
|
802
|
+
goss_spec["package"][resource_name] = spec
|
|
803
|
+
elif resource_type == "service":
|
|
804
|
+
spec = _convert_service_to_goss(expectations)
|
|
805
|
+
goss_spec["service"][resource_name] = spec
|
|
806
|
+
elif resource_type == "file":
|
|
807
|
+
spec = _convert_file_to_goss(expectations)
|
|
808
|
+
goss_spec["file"][resource_name] = spec
|
|
809
|
+
elif resource_type == "port":
|
|
810
|
+
# Goss uses string format for ports
|
|
811
|
+
port_key = f"tcp://{resource_name}"
|
|
812
|
+
spec = _convert_port_to_goss(expectations)
|
|
813
|
+
goss_spec["port"][port_key] = spec
|
|
814
|
+
|
|
815
|
+
# Remove empty sections
|
|
816
|
+
goss_spec = {k: v for k, v in goss_spec.items() if v}
|
|
817
|
+
|
|
818
|
+
# Convert to YAML (using JSON for now, will need PyYAML for proper YAML)
|
|
819
|
+
try:
|
|
820
|
+
import yaml
|
|
821
|
+
|
|
822
|
+
return yaml.dump(goss_spec, default_flow_style=False, sort_keys=False)
|
|
823
|
+
except ImportError:
|
|
824
|
+
# Fallback to JSON if PyYAML not available
|
|
825
|
+
return json.dumps(goss_spec, indent=2)
|
|
826
|
+
|
|
827
|
+
|
|
503
828
|
def _convert_package_to_ansible_assert(
|
|
504
829
|
lines: list[str], resource_name: str, expectations: list[dict[str, Any]]
|
|
505
830
|
) -> None:
|
|
@@ -566,7 +891,7 @@ def _convert_inspec_to_ansible_assert(control: dict[str, Any]) -> str:
|
|
|
566
891
|
|
|
567
892
|
"""
|
|
568
893
|
lines = [
|
|
569
|
-
f"- name: Verify {control
|
|
894
|
+
f"- name: Verify {control.get('title') or control['id']}",
|
|
570
895
|
" ansible.builtin.assert:",
|
|
571
896
|
" that:",
|
|
572
897
|
]
|
|
@@ -616,6 +941,50 @@ def _generate_inspec_package_checks(
|
|
|
616
941
|
return lines
|
|
617
942
|
|
|
618
943
|
|
|
944
|
+
def _generate_inspec_resource_checks(
|
|
945
|
+
resource_type: str,
|
|
946
|
+
resource_name: str,
|
|
947
|
+
properties: dict[str, Any] | None = None,
|
|
948
|
+
custom_checks: list[str] | None = None,
|
|
949
|
+
) -> list[str]:
|
|
950
|
+
"""
|
|
951
|
+
Generate InSpec checks for a resource using a generic pattern.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
resource_type: InSpec resource type (e.g., 'file', 'user', 'service').
|
|
955
|
+
resource_name: Name/path of the resource.
|
|
956
|
+
properties: Optional resource properties to check.
|
|
957
|
+
custom_checks: Optional list of custom check lines.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
List of InSpec check lines.
|
|
961
|
+
|
|
962
|
+
"""
|
|
963
|
+
lines = [f" describe {resource_type}('{resource_name}') do"]
|
|
964
|
+
|
|
965
|
+
# Add custom checks if provided
|
|
966
|
+
if custom_checks:
|
|
967
|
+
lines.extend(custom_checks)
|
|
968
|
+
else:
|
|
969
|
+
# Default: should exist
|
|
970
|
+
lines.append(INSPEC_SHOULD_EXIST)
|
|
971
|
+
|
|
972
|
+
# Add property checks
|
|
973
|
+
if properties:
|
|
974
|
+
property_map = {
|
|
975
|
+
"mode": lambda v: f" its('mode') {{ should cmp '{v}' }}",
|
|
976
|
+
"owner": lambda v: f" its('owner') {{ should eq '{v}' }}",
|
|
977
|
+
"group": lambda v: f" its('group') {{ should eq '{v}' }}",
|
|
978
|
+
"shell": lambda v: f" its('shell') {{ should eq '{v}' }}",
|
|
979
|
+
}
|
|
980
|
+
for prop, value in properties.items():
|
|
981
|
+
if prop in property_map:
|
|
982
|
+
lines.append(property_map[prop](value))
|
|
983
|
+
|
|
984
|
+
lines.append(INSPEC_END_INDENT)
|
|
985
|
+
return lines
|
|
986
|
+
|
|
987
|
+
|
|
619
988
|
def _generate_inspec_service_checks(resource_name: str) -> list[str]:
|
|
620
989
|
"""
|
|
621
990
|
Generate InSpec checks for service resource.
|
|
@@ -627,12 +996,14 @@ def _generate_inspec_service_checks(resource_name: str) -> list[str]:
|
|
|
627
996
|
List of InSpec check lines.
|
|
628
997
|
|
|
629
998
|
"""
|
|
630
|
-
return
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
999
|
+
return _generate_inspec_resource_checks(
|
|
1000
|
+
"service",
|
|
1001
|
+
resource_name,
|
|
1002
|
+
custom_checks=[
|
|
1003
|
+
" it { should be_running }",
|
|
1004
|
+
" it { should be_enabled }",
|
|
1005
|
+
],
|
|
1006
|
+
)
|
|
636
1007
|
|
|
637
1008
|
|
|
638
1009
|
def _generate_inspec_file_checks(
|
|
@@ -649,15 +1020,11 @@ def _generate_inspec_file_checks(
|
|
|
649
1020
|
List of InSpec check lines.
|
|
650
1021
|
|
|
651
1022
|
"""
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if "group" in properties:
|
|
658
|
-
lines.append(f" its('group') {{ should eq '{properties['group']}' }}")
|
|
659
|
-
lines.append(INSPEC_END_INDENT)
|
|
660
|
-
return lines
|
|
1023
|
+
return _generate_inspec_resource_checks(
|
|
1024
|
+
"file",
|
|
1025
|
+
resource_name,
|
|
1026
|
+
properties=properties,
|
|
1027
|
+
)
|
|
661
1028
|
|
|
662
1029
|
|
|
663
1030
|
def _generate_inspec_directory_checks(
|
|
@@ -674,15 +1041,15 @@ def _generate_inspec_directory_checks(
|
|
|
674
1041
|
List of InSpec check lines.
|
|
675
1042
|
|
|
676
1043
|
"""
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1044
|
+
return _generate_inspec_resource_checks(
|
|
1045
|
+
"file",
|
|
1046
|
+
resource_name,
|
|
1047
|
+
properties=properties,
|
|
1048
|
+
custom_checks=[
|
|
1049
|
+
INSPEC_SHOULD_EXIST,
|
|
1050
|
+
" it { should be_directory }",
|
|
1051
|
+
],
|
|
1052
|
+
)
|
|
686
1053
|
|
|
687
1054
|
|
|
688
1055
|
def _generate_inspec_user_checks(
|
|
@@ -699,11 +1066,11 @@ def _generate_inspec_user_checks(
|
|
|
699
1066
|
List of InSpec check lines.
|
|
700
1067
|
|
|
701
1068
|
"""
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1069
|
+
return _generate_inspec_resource_checks(
|
|
1070
|
+
"user",
|
|
1071
|
+
resource_name,
|
|
1072
|
+
properties=properties,
|
|
1073
|
+
)
|
|
707
1074
|
|
|
708
1075
|
|
|
709
1076
|
def _generate_inspec_group_checks(resource_name: str) -> list[str]:
|
|
@@ -717,11 +1084,7 @@ def _generate_inspec_group_checks(resource_name: str) -> list[str]:
|
|
|
717
1084
|
List of InSpec check lines.
|
|
718
1085
|
|
|
719
1086
|
"""
|
|
720
|
-
return
|
|
721
|
-
f" describe group('{resource_name}') do",
|
|
722
|
-
INSPEC_SHOULD_EXIST,
|
|
723
|
-
INSPEC_END_INDENT,
|
|
724
|
-
]
|
|
1087
|
+
return _generate_inspec_resource_checks("group", resource_name)
|
|
725
1088
|
|
|
726
1089
|
|
|
727
1090
|
def _generate_inspec_from_resource(
|