diff --git a/.github/workflows/CI-master_tests.yml b/.github/workflows/CI-master_tests.yml index 3eed69f..6072597 100644 --- a/.github/workflows/CI-master_tests.yml +++ b/.github/workflows/CI-master_tests.yml @@ -232,6 +232,9 @@ jobs: python3 -m builder.linpeas_builder --all-no-fat --output linpeas.sh python3 -m builder.linpeas_builder --small --output linpeas_small.sh + - name: Run linPEAS module metadata tests + run: python3 -m unittest linPEAS.tests.test_modules_metadata + - name: Run linPEAS builder tests run: python3 -m unittest discover -s linPEAS/tests -p "test_*.py" diff --git a/.github/workflows/PR-tests.yml b/.github/workflows/PR-tests.yml index 851e4b4..39f5d72 100644 --- a/.github/workflows/PR-tests.yml +++ b/.github/workflows/PR-tests.yml @@ -131,6 +131,9 @@ jobs: python3 -m builder.linpeas_builder --all-no-fat --output linpeas.sh python3 -m builder.linpeas_builder --small --output linpeas_small.sh + - name: Run linPEAS module metadata tests + run: python3 -m unittest linPEAS.tests.test_modules_metadata + - name: Run linPEAS builder tests run: python3 -m unittest discover -s linPEAS/tests -p "test_*.py" diff --git a/linPEAS/tests/test_modules_metadata.py b/linPEAS/tests/test_modules_metadata.py index d2ec199..95e6c91 100644 --- a/linPEAS/tests/test_modules_metadata.py +++ b/linPEAS/tests/test_modules_metadata.py @@ -1,4 +1,6 @@ import re +import shutil +import subprocess import sys import unittest from pathlib import Path @@ -17,26 +19,23 @@ class LinpeasModulesMetadataTests(unittest.TestCase): from builder.src.linpeasModule import LinpeasModule # pylint: disable=import-error cls.LinpeasModule = LinpeasModule + cls.module_files = sorted(cls.parts_dir.rglob("*.sh")) + cls.modules = [cls.LinpeasModule(str(path)) for path in cls.module_files] def _iter_module_files(self): - return sorted(self.parts_dir.rglob("*.sh")) + return self.module_files def test_all_modules_parse(self): - module_files = self._iter_module_files() - self.assertGreater(len(module_files), 0, "No linPEAS module files were found.") - - # Parsing a module validates its metadata and dependencies. - for path in module_files: - _ = self.LinpeasModule(str(path)) + self.assertGreater(len(self.modules), 0, "No linPEAS module files were found.") def test_check_module_id_matches_filename(self): - for path in self._iter_module_files(): - module = self.LinpeasModule(str(path)) + for module in self.modules: if not getattr(module, "is_check", False): continue # For checks, the filename (without numeric prefix) must match the module ID # (either full ID or stripping section prefix like `SI_`). + path = Path(module.path) file_base = re.sub(r"^[0-9]+_", "", path.stem) module_id = getattr(module, "id", "") module_id_tail = module_id[3:] if len(module_id) >= 3 else "" @@ -48,13 +47,60 @@ class LinpeasModulesMetadataTests(unittest.TestCase): def test_module_ids_are_unique(self): ids = [] - for path in self._iter_module_files(): - module = self.LinpeasModule(str(path)) + for module in self.modules: ids.append(getattr(module, "id", "")) duplicates = {x for x in ids if x and ids.count(x) > 1} self.assertEqual(set(), duplicates, f"Duplicate module IDs found: {sorted(duplicates)}") + def test_module_shell_snippets_are_syntactically_valid(self): + shell = shutil.which("sh") + self.assertIsNotNone(shell, "sh is required to syntax-check linPEAS snippets.") + + failures = [] + for path in self._iter_module_files(): + result = subprocess.run([shell, "-n", str(path)], capture_output=True, text=True) + if result.returncode != 0: + failures.append(f"{path}: {result.stderr.strip()}") + + self.assertEqual([], failures) + + def test_declared_function_dependencies_exist(self): + defined_functions = { + function_name + for module in self.modules + for function_name in getattr(module, "defined_funcs", []) + } + + missing = [] + for module in self.modules: + for function_name in sorted(set(module.functions_used + module.initial_functions)): + if function_name not in defined_functions: + missing.append(f"{module.path}: {function_name}") + + self.assertEqual([], missing) + + def test_declared_global_variable_dependencies_exist(self): + # These are shell/runtime values intentionally supplied by the environment + # or CLI parser instead of a linPEAS variable module. + runtime_globals = { + "IP", + "RANDOM", + } + generated_globals = { + variable_name + for module in self.modules + for variable_name in getattr(module, "generated_global_variables", []) + } + + missing = [] + for module in self.modules: + for variable_name in sorted(set(module.global_variables)): + if variable_name not in generated_globals and variable_name not in runtime_globals: + missing.append(f"{module.path}: ${variable_name}") + + self.assertEqual([], missing) + def test_sudo_l_check_is_bounded_for_non_interactive_runs(self): sudo_l_module = self.parts_dir / "6_users_information" / "7_Sudo_l.sh" content = sudo_l_module.read_text(encoding="utf-8")