Public/Get-AtomicTechnique.ps1

filter Get-AtomicTechnique {
    <#
    .SYNOPSIS
    
    Retrieve and validate an atomic technique.
    
    .DESCRIPTION
    
    Get-AtomicTechnique retrieves and validates one or more atomic techniques. Get-AtomicTechnique supports retrieval from YAML files or from a raw YAML string.
    
    This function facilitates the following use cases:
    
    1) Validation prior to execution of atomic tests.
    2) Writing code to reason over one or more atomic techniques/tests.
    3) Representing atomic techniques/tests in a format that is more conducive to PowerShell. ConvertFrom-Yaml returns a large, complicated hashtable that is difficult to work with and reason over. Get-AtomicTechnique helps abstract those challenges away.
    4) Representing atomic techniques/tests in a format that can be piped directly to ConvertTo-Yaml.
    
    .PARAMETER Path
    
    Specifies the path to an atomic technique YAML file. Get-AtomicTechnique expects that the file extension be .yaml or .yml and that it is well-formed YAML content.
    
    .PARAMETER Yaml
    
    Specifies a single string consisting of raw atomic technique YAML.
    
    .EXAMPLE
    
    Get-ChildItem -Path C:\atomic-red-team\atomics\* -Recurse -Include 'T*.yaml' | Get-AtomicTechnique
    
    .EXAMPLE
    
    Get-Item C:\atomic-red-team\atomics\T1117\T1117.yaml | Get-AtomicTechnique
    
    .EXAMPLE
    
    Get-AtomicTechnique -Path C:\atomic-red-team\atomics\T1117\T1117.yaml
    
    .EXAMPLE
    
    $Yaml = @'
    ---
    attack_technique: T1152
    display_name: Launchctl
    
    atomic_tests:
    - name: Launchctl
      description: |
        Utilize launchctl
    
      supported_platforms:
        - macos
    
      executor:
        name: sh
        command: |
          launchctl submit -l evil -- /Applications/Calculator.app/Contents/MacOS/Calculator
    '@
    
    Get-AtomicTechnique -Yaml $Yaml
    
    .INPUTS
    
    System.IO.FileInfo
    
    The output of Get-Item and Get-ChildItem can be piped directly into Get-AtomicTechnique.
    
    .OUTPUTS
    
    AtomicTechnique
    
    Outputs an object representing a parsed and validated atomic technique.
    #>

    
    [CmdletBinding(DefaultParameterSetName = 'FilePath')]
    [OutputType([AtomicTechnique])]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FilePath')]
        [String]
        [Alias('FullName')]
        [ValidateScript({ Test-Path -Path $_ -Include '*.yaml', '*.yml' })]
        $Path,

        [Parameter(Mandatory, ParameterSetName = 'Yaml')]
        [String]
        [ValidateNotNullOrEmpty()]
        $Yaml
    )


    switch ($PSCmdlet.ParameterSetName) {
        'FilePath' {
            $ResolvedPath = Resolve-Path -Path $Path

            $YamlContent = Get-Content -Path $ResolvedPath -Raw
            $ErrorStringPrefix = "[$($ResolvedPath)]"
        }

        'Yaml' {
            $YamlContent = $Yaml
            $ErrorStringPrefix = ''
        }
    }

    $ParsedYaml = $null

    $ValidSupportedPlatforms = @('windows', 'macos', 'linux', 'office-365', 'azure-ad', 'google-workspace', 'saas', 'iaas', 'containers', 'iaas:aws', 'iaas:azure', 'iaas:gcp')
    $ValidInputArgTypes = @('Path', 'Url', 'String', 'Integer', 'Float')
    $ValidExecutorTypes = @('command_prompt', 'sh', 'bash', 'powershell', 'manual')

    # ConvertFrom-Yaml will throw a .NET exception rather than a PowerShell error.
    # Capture the exception and convert to PowerShell error so that the user can decide
    # how to handle the error.
    try {
        [Hashtable] $ParsedYaml = ConvertFrom-Yaml -Yaml $YamlContent
    } catch {
        Write-Error $_
    }

    if ($ParsedYaml) {
        # The document was well-formed YAML. Now, validate against the atomic red schema

        $AtomicInstance = [AtomicTechnique]::new()

        if (-not $ParsedYaml.Count) {
            Write-Error "$ErrorStringPrefix YAML file has no elements."
            return
        }

        if (-not $ParsedYaml.ContainsKey('attack_technique')) {
            Write-Error "$ErrorStringPrefix 'attack_technique' element is required."
            return
        }

        $AttackTechnique = $null

        if ($ParsedYaml['attack_technique'].Count -gt 1) {
            # An array of attack techniques are supported.
            foreach ($Technique in $ParsedYaml['attack_technique']) {
                if ("$Technique" -notmatch '^(?-i:T\d{4}(\.\d{3}){0,1})$') {
                    Write-Warning "$ErrorStringPrefix Attack technique: $Technique. Each attack technique should start with the letter 'T' followed by a four digit number."
                }

                [String[]] $AttackTechnique = $ParsedYaml['attack_technique']
            }
        } else {
            if ((-not "$($ParsedYaml['attack_technique'])".StartsWith('T'))) {
                # If the attack technique is a single entry, validate that it starts with the letter T.
                Write-Warning "$ErrorStringPrefix Attack technique: $($ParsedYaml['attack_technique']). Attack techniques should start with the letter T."
            }

            [String] $AttackTechnique = $ParsedYaml['attack_technique']
        }

        $AtomicInstance.attack_technique = $AttackTechnique

        if (-not $ParsedYaml.ContainsKey('display_name')) {
            Write-Error "$ErrorStringPrefix 'display_name' element is required."
            return
        }

        if (-not ($ParsedYaml['display_name'] -is [String])) {
            Write-Error "$ErrorStringPrefix 'display_name' must be a string."
            return
        }

        $AtomicInstance.display_name = $ParsedYaml['display_name']

        if (-not $ParsedYaml.ContainsKey('atomic_tests')) {
            Write-Error "$ErrorStringPrefix 'atomic_tests' element is required."
            return
        }

        if (-not ($ParsedYaml['atomic_tests'] -is [System.Collections.Generic.List`1[Object]])) {
            Write-Error "$ErrorStringPrefix 'atomic_tests' element must be an array."
            return
        }

        $AtomicTests = [AtomicTest[]]::new($ParsedYaml['atomic_tests'].Count)

        if (-not $ParsedYaml['atomic_tests'].Count) {
            Write-Error "$ErrorStringPrefix 'atomic_tests' element is empty - you have no tests."
            return
        }

        for ($i = 0; $i -lt $ParsedYaml['atomic_tests'].Count; $i++) {
            $AtomicTest = $ParsedYaml['atomic_tests'][$i]

            $AtomicTestInstance = [AtomicTest]::new()

            $StringsWithPotentialInputArgs = New-Object -TypeName 'System.Collections.Generic.List`1[String]'

            if (-not $AtomicTest.ContainsKey('name')) {
                Write-Error "$ErrorStringPrefix 'atomic_tests[$i].name' element is required."
                return
            }

            if (-not ($AtomicTest['name'] -is [String])) {
                Write-Error "$ErrorStringPrefix 'atomic_tests[$i].name' element must be a string."
                return
            }

            $AtomicTestInstance.name = $AtomicTest['name']
            $AtomicTestInstance.auto_generated_guid = $AtomicTest['auto_generated_guid']

            if (-not $AtomicTest.ContainsKey('description')) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].description' element is required."
                return
            }

            if (-not ($AtomicTest['description'] -is [String])) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].description' element must be a string."
                return
            }

            $AtomicTestInstance.description = $AtomicTest['description']

            if (-not $AtomicTest.ContainsKey('supported_platforms')) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].supported_platforms' element is required."
                return
            }

            if (-not ($AtomicTest['supported_platforms'] -is [System.Collections.Generic.List`1[Object]])) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].supported_platforms' element must be an array."
                return
            }

            foreach ($SupportedPlatform in $AtomicTest['supported_platforms']) {
                if ($ValidSupportedPlatforms -cnotcontains $SupportedPlatform) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].supported_platforms': '$SupportedPlatform' must be one of the following: $($ValidSupportedPlatforms -join ', ')."
                    return
                }
            }

            $AtomicTestInstance.supported_platforms = $AtomicTest['supported_platforms']

            $Dependencies = $null

            if ($AtomicTest['dependencies'].Count) {
                $Dependencies = [AtomicDependency[]]::new($AtomicTest['dependencies'].Count)
                $j = 0

                # dependencies are optional and there can be multiple
                foreach ($Dependency in $AtomicTest['dependencies']) {
                    $DependencyInstance = [AtomicDependency]::new()

                    if (-not $Dependency.ContainsKey('description')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].description' element is required."
                        return
                    }

                    if (-not ($Dependency['description'] -is [String])) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].description' element must be a string."
                        return
                    }

                    $DependencyInstance.description = $Dependency['description']
                    $StringsWithPotentialInputArgs.Add($Dependency['description'])

                    if (-not $Dependency.ContainsKey('prereq_command')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].prereq_command' element is required."
                        return
                    }

                    if (-not ($Dependency['prereq_command'] -is [String])) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].prereq_command' element must be a string."
                        return
                    }

                    $DependencyInstance.prereq_command = $Dependency['prereq_command']
                    $StringsWithPotentialInputArgs.Add($Dependency['prereq_command'])

                    if (-not $Dependency.ContainsKey('get_prereq_command')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].get_prereq_command' element is required."
                        return
                    }

                    if (-not ($Dependency['get_prereq_command'] -is [String])) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependencies[$j].get_prereq_command' element must be a string."
                        return
                    }

                    $DependencyInstance.get_prereq_command = $Dependency['get_prereq_command']
                    $StringsWithPotentialInputArgs.Add($Dependency['get_prereq_command'])

                    $Dependencies[$j] = $DependencyInstance

                    $j++
                }

                $AtomicTestInstance.dependencies = $Dependencies
            }

            if ($AtomicTest.ContainsKey('dependency_executor_name')) {
                if ($ValidExecutorTypes -notcontains $AtomicTest['dependency_executor_name']) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].dependency_executor_name': '$($AtomicTest['dependency_executor_name'])' must be one of the following: $($ValidExecutorTypes -join ', ')."
                    return
                }

                if ($null -eq $AtomicTestInstance.Dependencies) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] If 'atomic_tests[$i].dependency_executor_name' is defined, there must be at least one dependency defined."
                }

                $AtomicTestInstance.dependency_executor_name = $AtomicTest['dependency_executor_name']
            }

            $InputArguments = $null

            # input_arguments is optional
            if ($AtomicTest.ContainsKey('input_arguments')) {
                if (-not ($AtomicTest['input_arguments'] -is [Hashtable])) {
                    $AtomicTest['input_arguments'].GetType().FullName
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments' must be a hashtable."
                    return
                }

                if (-not ($AtomicTest['input_arguments'].Count)) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments' must have at least one entry."
                    return
                }

                $InputArguments = @{}

                $j = 0

                foreach ($InputArgName in $AtomicTest['input_arguments'].Keys) {
                    $InputArgument = [AtomicInputArgument]::new()

                    if (-not $AtomicTest['input_arguments'][$InputArgName].ContainsKey('description')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments['$InputArgName'].description' element is required."
                        return
                    }

                    if (-not ($AtomicTest['input_arguments'][$InputArgName]['description'] -is [String])) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments['$InputArgName'].description' element must be a string."
                        return
                    }

                    $InputArgument.description = $AtomicTest['input_arguments'][$InputArgName]['description']

                    if (-not $AtomicTest['input_arguments'][$InputArgName].ContainsKey('type')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments['$InputArgName'].type' element is required."
                        return
                    }

                    if ($ValidInputArgTypes -notcontains $AtomicTest['input_arguments'][$InputArgName]['type']) {
                        Write-Warning "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments['$InputArgName'].type': '$($AtomicTest['input_arguments'][$InputArgName]['type'])' should be one of the following: $($ValidInputArgTypes -join ', ')"
                    }

                    $InputArgument.type = $AtomicTest['input_arguments'][$InputArgName]['type']

                    if (-not $AtomicTest['input_arguments'][$InputArgName].ContainsKey('default')) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].input_arguments['$InputArgName'].default' element is required."
                        return
                    }

                    $InputArgument.default = $AtomicTest['input_arguments'][$InputArgName]['default']

                    $InputArguments[$InputArgName] = $InputArgument

                    $j++
                }
            }

            $AtomicTestInstance.input_arguments = $InputArguments

            if (-not $AtomicTest.ContainsKey('executor')) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor' element is required."
                return
            }

            if (-not ($AtomicTest['executor'] -is [Hashtable])) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor' element must be a hashtable."
                return
            }

            if (-not $AtomicTest['executor'].ContainsKey('name')) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.name' element is required."
                return
            }

            if (-not ($AtomicTest['executor']['name'] -is [String])) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].description.name' element must be a string."
                return
            }

            if ($AtomicTest['executor']['name'] -notmatch '^(?-i:[a-z_]+)$') {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].description.name' element must be lowercased and underscored."
                return
            }

            if ($ValidExecutorTypes -notcontains $AtomicTest['executor']['name']) {
                Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].description.name': '$($AtomicTest['executor']['name'])' must be one of the following: $($ValidExecutorTypes -join ', ')"
                return
            }

            if ($AtomicTest['executor']['name'] -eq 'manual') {
                if (-not $AtomicTest['executor'].ContainsKey('steps')) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.steps' element is required when the 'manual' executor is used."
                    return
                }

                if (-not ($AtomicTest['executor']['steps'] -is [String])) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.steps' element must be a string."
                    return
                }

                $ExecutorInstance = [AtomicExecutorManual]::new()
                $ExecutorInstance.steps = $AtomicTest['executor']['steps']
                $StringsWithPotentialInputArgs.Add($AtomicTest['executor']['steps'])
            } else {
                if (-not $AtomicTest['executor'].ContainsKey('command')) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.command' element is required when the '$($ValidExecutorTypes -join ', ')' executors are used."
                    return
                }

                if (-not ($AtomicTest['executor']['command'] -is [String])) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.command' element must be a string."
                    return
                }

                $ExecutorInstance = [AtomicExecutorDefault]::new()
                $ExecutorInstance.command = $AtomicTest['executor']['command']
                $StringsWithPotentialInputArgs.Add($AtomicTest['executor']['command'])
            }

            # cleanup_command element is optional
            if ($AtomicTest['executor'].ContainsKey('cleanup_command')) {
                $ExecutorInstance.cleanup_command = $AtomicTest['executor']['cleanup_command']
                $StringsWithPotentialInputArgs.Add($AtomicTest['executor']['cleanup_command'])
            }

            # elevation_required element is optional
            if ($AtomicTest['executor'].ContainsKey('elevation_required')) {
                if (-not ($AtomicTest['executor']['elevation_required'] -is [Bool])) {
                    Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] 'atomic_tests[$i].executor.elevation_required' element must be a boolean."
                    return
                }

                $ExecutorInstance.elevation_required = $AtomicTest['executor']['elevation_required']
            } else {
                # if elevation_required is not present, default to false
                $ExecutorInstance.elevation_required = $False
            }

            $InputArgumentNames = $null

            # Get all input argument names
            $InputArgumentNames = $InputArguments.Keys

            # Extract all input arguments names from the executor
            # Potential places where input arguments can be populated:
            # - Dependency description
            # - Dependency prereq_command
            # - Dependency get_prereq_command
            # - Executor steps
            # - Executor command
            # - Executor cleanup_command

            $Regex = [Regex] '#\{(?<ArgName>[^}]+)\}'
            [String[]] $InputArgumentNamesFromExecutor = $StringsWithPotentialInputArgs |
                ForEach-Object { $Regex.Matches($_) } |
                Select-Object -ExpandProperty Groups |
                Where-Object { $_.Name -eq 'ArgName' } |
                Select-Object -ExpandProperty Value |
                Sort-Object -Unique


            # Validate that all executor input arg names are defined input arg names.
            if ($InputArgumentNamesFromExecutor.Count) {
                $InputArgumentNamesFromExecutor | ForEach-Object {
                    if ($InputArgumentNames -notcontains $_) {
                        Write-Error "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] The following input argument was specified but is not defined: '$_'"
                        return
                    }
                }
            }

            # Validate that all defined input args are utilized at least once in the executor.
            if ($InputArgumentNames.Count) {
                $InputArgumentNames | ForEach-Object {
                    if ($InputArgumentNamesFromExecutor -notcontains $_) {
                        # Write a warning since this scenario is not considered a breaking change
                        Write-Warning "$ErrorStringPrefix[Atomic test name: $($AtomicTestInstance.name)] The following input argument is defined but not utilized: '$_'."
                    }
                }
            }

            $ExecutorInstance.name = $AtomicTest['executor']['name']

            $AtomicTestInstance.executor = $ExecutorInstance

            $AtomicTests[$i] = $AtomicTestInstance
        }

        $AtomicInstance.atomic_tests = $AtomicTests

        $AtomicInstance
    }
}