mirror of
https://github.com/peass-ng/PEASS-ng.git
synced 2026-03-12 21:23:13 -07:00
* feat: MITRE ATT&CK integration for LinPEAS and WinPEAS - Add -T T1234,T5678 flag to LinPEAS to filter checks by technique - Add mitre=T1234,T5678 argument to WinPEAS for technique-based filtering - Annotate every check title with MITRE technique ID(s) displayed in grey - Add $_mitre_tag to Generated Global Variables in 0_variables_base.sh - Add check_mitre_filter() shell function with prefix-match support - Add MitreAttackIds property to ISystemCheck interface (C#) - Update MainPrint/GreatPrint in Beaprint.cs to accept optional mitreIds - Tag all 158 LinPEAS check modules with # Mitre: metadata - Tag all 16 WinPEAS check classes with MitreAttackIds property - Update linpeasModule.py to parse # Mitre: metadata field - Update linpeasBaseBuilder.py to emit check_mitre_filter wrappers - Add 3 MITRE argument parsing tests to ArgumentParsingTests.cs * test: add MITRE filter coverage for LinPEAS builder and WinPEAS LinPEAS (test_builder.py): - test_mitre_flag_present_in_getopts: -T: must appear in getopts string - test_mitre_flag_present_in_help_text: -T must appear in built help text - test_mitre_filter_function_present: check_mitre_filter() must be in built script WinPEAS (ArgumentParsingTests.cs): - PassesMitreFilter_EmptyFilter_AllChecksPass: no filter -> all checks run - PassesMitreFilter_ExactMatch_Passes: T1082 filter matches T1082 check - PassesMitreFilter_NoMatch_Fails: T1082 filter rejects T1057 check - PassesMitreFilter_PrefixMatch_Passes: T1552 filter matches T1552.001/T1552.005 - PassesMitreFilter_SubtechniqueDoesNotMatchDifferentBase_Fails: T1548 != T1552.001 * chore: ignore .github/instructions/ and untrack todos.instructions.md * fix: complete and accurate MITRE ATT&CK mappings for LinPEAS and WinPEAS gitignore: - Add .github/instructions/ to .gitignore and untrack todos.instructions.md LinPEAS — corrected mappings: - 29_Interesting_environment_variables.sh: add missing T1552.007,T1082 - 3_USBCreator.sh: T1548 → T1548.003,T1068 (polkit bypass + CVE-class exploit) - 9_Doas.sh: T1548 → T1548.003 (doas is a sudo/sudo-caching equivalent) - 10_Pkexec.sh: T1548 → T1548.003,T1548.004,T1068 per-section specificity - 2_Process_cred_in_memory.sh: T1003,T1055 → T1003.007 (Proc Filesystem, drop wrong T1055) - 11_Superusers.sh: T1087.001,T1548 → T1087.001 (discovery only, no elevation abuse) - 14/15/16 writable files: T1574 → T1574.009,T1574.010 (specific sub-techniques) WinPEAS — corrected mappings: - SystemInfo: class expanded to full technique union; WSUS T1195→T1072,T1068; KrbRelayUp T1558→T1187,T1558; Object Manager T1548→T1068; Named Pipes T1559.001→T1559; Low-priv pipes T1559.001→T1134.001,T1559 - EventsInfo: class expanded with T1078.003,T1552.001,T1059.001,T1082 - UserInfo: class expanded; Token privileges T1134→T1134.001 - ProcessInfo: Leaked Handlers T1134.003→T1134.001 (token impersonation, not make-token) - ServicesInfo: class adds T1574.011,T1068 - ApplicationsInfo: class adds T1010,T1014 - NetworkInfo: class adds T1018,T1090 - ActiveDirectoryInfo: T1484→T1484.001; class adds T1003 - WindowsCreds: class sub-techniques T1552→T1552.001,T1552.002, T1555→T1555.003,T1555.004; SSClient T1059→T1552.001 (wrong technique entirely) - FilesInfo: class expanded with T1552.002,T1552.004,T1552.006,T1564.001,T1574.001, T1059.004,T1114.001,T1218,T1649; Cloud Credentials T1552.005→T1552.001 - SoapClientInfo: T1059,T1071→T1559,T1071.001 (IPC/Web protocol, not scripting) * fix: add missing T1613 and T1562.001 to SystemInfo class-level MitreAttackIds; label AD object enumeration with T1087.002 and T1018 * fix: correct linpeas mitre filter matching logic * fix: MITRE code bugs — pass-through for untagged checks, remove dead OR in section gate - PassesMitreFilter (Checks.cs): when MitreAttackIds is null or empty and a filter is active, return true (pass-through) instead of false. Previously any future ISystemCheck added without MITRE IDs would be silently excluded by an active filter. - linpeasBaseBuilder.py: remove redundant '|| [ -z "$MITRE_FILTER" ]' from the generated section-level gate. check_mitre_filter already returns 0 immediately when MITRE_FILTER is empty, so the OR branch was unreachable and inconsistent with the check-level gate which uses the same function without the extra guard. - ArgumentParsingTests.cs: add PassesMitreFilter_NullMitreAttackIds_PassesThrough and PassesMitreFilter_EmptyMitreAttackIds_PassesThrough regression tests. * fix(mitre): 4 bugs — dead arg parser, wait logic, subprocess forks, cleanup race Checks.cs: max-regex-file-size used string.Equals which requires exact match, so 'max-regex-file-size=500000' could never match and MaxRegexFileSize was stuck at 1000000 forever. Fixed to arg.StartsWith. Checks.cs RunChecks: wait compared loop index i against _systemCheckSelectedKeysHashSet.Count, which is 0 when all checks run (so i < -1 is always false) and semantically wrong when a key subset is selected. Replaced with a pre-count of checks that pass both filters and a running counter. 0_variables_base.sh check_mitre_filter: replaced two $(echo ... | tr ...) subprocess forks per call with pure parameter-expansion while-loops. Zero process forks, POSIX-compliant, ~632 fork()s saved per full filtered run. Declares _mitre_tags_left and _mitre_filters_left in Generated Global Variables. linpeas_builder.py: os.remove of the shared temp file raised FileNotFoundError when multiple sequential builder invocations ran (the second saw the file already deleted by the first). Wrapped in try/except FileNotFoundError. Tests: Added PassesMitreFilter_SubtechniqueFilter_DoesNotMatchParentOnlyTag and MaxRegexFileSize_ArgParsed_Correctly regression tests (16 total). * ci: add manual build-artifacts workflow (winPEAS.exe + linpeas.sh) * fix(linpeas): getopts silent mode — clear error when -T given without argument Switch getopts to silent mode (leading ':') so the shell does not emit its own terse 'No arg for -T option' message. Add explicit :) case that prints ERROR: -T requires an argument (e.g. -T T1082,T1552) and then dumps the help text before exiting 1. Add *) case for unrecognised flags with the same pattern. Behaviour for all valid flags is unchanged. * chore: untrack build-artifacts workflow, add to .gitignore
136 lines
6.4 KiB
Python
136 lines
6.4 KiB
Python
import os
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
class LinpeasBuilderTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.repo_root = Path(__file__).resolve().parents[2]
|
|
self.linpeas_dir = self.repo_root / "linPEAS"
|
|
|
|
def _run_builder(self, args, output_path):
|
|
cmd = ["python3", "-m", "builder.linpeas_builder"] + args + ["--output", str(output_path)]
|
|
result = subprocess.run(cmd, cwd=str(self.linpeas_dir), capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise AssertionError(
|
|
f"linpeas_builder failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
)
|
|
|
|
def test_small_build_creates_executable(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas_small.sh"
|
|
self._run_builder(["--small"], output_path)
|
|
self.assertTrue(output_path.exists(), "linpeas_small.sh was not created.")
|
|
mode = output_path.stat().st_mode
|
|
self.assertTrue(mode & stat.S_IXUSR, "linpeas_small.sh is not executable.")
|
|
|
|
def test_include_exclude_modules(self):
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas_include.sh"
|
|
self._run_builder(["--include", "system_information,container", "--exclude", "container"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
self.assertIn("Operative system", content)
|
|
self.assertNotIn("Am I Containered?", content)
|
|
|
|
def test_threads_flag_present_in_getopts(self):
|
|
"""Regression: -z must appear in the getopts string so it is actually parsed."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas.sh"
|
|
self._run_builder(["--all-no-fat"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
# Match the actual option-parsing line: 'while getopts' followed by
|
|
# either a single or double quoted option string, to avoid matching
|
|
# comments or help text that happen to contain 'getopts'.
|
|
getopts_line = next(
|
|
(l for l in content.splitlines()
|
|
if re.match(r'\s*while\s+getopts\s+[\'"]', l)),
|
|
None
|
|
)
|
|
self.assertIsNotNone(getopts_line,
|
|
"'while getopts' line not found in built script.")
|
|
self.assertIn("z:", getopts_line,
|
|
"-z: option is missing from the getopts string in the built script.")
|
|
|
|
def test_threads_flag_present_in_help_text(self):
|
|
"""Regression: -z must be documented in the help text of the built script."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas.sh"
|
|
self._run_builder(["--all-no-fat"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
self.assertIn("-z <N>", content,
|
|
"-z <N> help entry is missing from the built script.")
|
|
|
|
def test_mitre_flag_present_in_getopts(self):
|
|
"""The -T flag must appear in the getopts string so it is actually parsed."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas.sh"
|
|
self._run_builder(["--all-no-fat"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
getopts_line = next(
|
|
(l for l in content.splitlines()
|
|
if re.match(r'\s*while\s+getopts\s+[\'"]', l)),
|
|
None
|
|
)
|
|
self.assertIsNotNone(getopts_line,
|
|
"'while getopts' line not found in built script.")
|
|
self.assertIn("T:", getopts_line,
|
|
"-T: option is missing from the getopts string in the built script.")
|
|
|
|
def test_mitre_flag_present_in_help_text(self):
|
|
"""The -T flag must be documented in the help text of the built script."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas.sh"
|
|
self._run_builder(["--all-no-fat"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
self.assertIn("-T", content,
|
|
"-T help entry is missing from the built script.")
|
|
|
|
def test_mitre_filter_function_present(self):
|
|
"""check_mitre_filter() must be emitted into the built script."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_path = Path(tmpdir) / "linpeas.sh"
|
|
self._run_builder(["--all-no-fat"], output_path)
|
|
content = output_path.read_text(encoding="utf-8", errors="ignore")
|
|
self.assertIn("check_mitre_filter", content,
|
|
"check_mitre_filter function is missing from the built script.")
|
|
|
|
def _run_base_mitre_filter(self, mitre_filter, check_ids):
|
|
base_file = self.linpeas_dir / "builder" / "linpeas_parts" / "linpeas_base" / "0_variables_base.sh"
|
|
result = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-lc",
|
|
(
|
|
f'source "{base_file}" >/dev/null 2>&1 || true; '
|
|
f'MITRE_FILTER="{mitre_filter}"; '
|
|
f'check_mitre_filter "{check_ids}"; '
|
|
'echo $?'
|
|
),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(self.repo_root),
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
return result.stdout.strip().splitlines()[-1]
|
|
|
|
def test_mitre_parent_filter_matches_subtechnique(self):
|
|
"""Regression: filtering by a base technique must include child sub-techniques."""
|
|
exit_code = self._run_base_mitre_filter("T1552", "T1552.001")
|
|
self.assertEqual("0", exit_code,
|
|
"Parent MITRE filter T1552 should match sub-technique T1552.001.")
|
|
|
|
def test_mitre_subtechnique_filter_does_not_match_parent(self):
|
|
"""Regression: filtering by a sub-technique must not include a parent-only tag."""
|
|
exit_code = self._run_base_mitre_filter("T1552.001", "T1552")
|
|
self.assertEqual("1", exit_code,
|
|
"Sub-technique MITRE filter T1552.001 should not match parent tag T1552.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|