Public/New-TripwireDecoy.ps1

<#
.SYNOPSIS
    Plant a decoy identity or application registration, and record it in the inventory.

.DESCRIPTION
    A decoy only works if it is worth finding and impossible to reach by accident. This
    function creates it deliberately inert:

      * The decoy user is created with a random password that is generated, sent to Graph,
        and then discarded. It is never returned, never stored, and nobody -- including the
        operator -- can sign in as it. The bait is the OBJECT, not a usable credential.
      * No group membership, no licence, no directory role, no API permission is granted.
        That is not a promise: Invoke-TripwireGraphWrite has no allowlist entry that could
        grant any of them, and tests/Safety.Tests.ps1 fails the build if one appears.
      * Nothing about the object marks it as a decoy in the directory. The only record is
        the local inventory.

    -WithSecret is the one case where a usable credential is produced: a client secret on
    a decoy application that holds no permissions, returned once so you can seed it where
    an intruder will find it (a fake config file, a stale wiki page, a credential-manager
    entry). Because the application has no permissions, the secret grants nothing -- its
    only function is to be used, and be seen being used.

.PARAMETER Type
    User or Application.

.PARAMETER DisplayName
    Choose something that looks like it belongs. 'svc-backup-archive' gets found;
    'DECOY_DO_NOT_USE' gets skipped. See docs/deception-design.md.

.PARAMETER UserPrincipalName
    Required for -Type User.

.PARAMETER RegisterOnly
    Record an object that already exists as a decoy, without creating anything. No tenant
    write happens at all. Use this when the object was planted by hand or by another team.

.PARAMETER ObjectId
    The directory object id. Required with -RegisterOnly.

.PARAMETER WithSecret
    Application only. Add a client secret and return its value once.

.PARAMETER Note
    Free text recorded with the decoy, e.g. where its credential was seeded.

.EXAMPLE
    New-TripwireDecoy -Type User -DisplayName 'svc-backup-archive' -UserPrincipalName 'svc-backup-archive@contoso.com' -WhatIf

.EXAMPLE
    New-TripwireDecoy -Type Application -DisplayName 'Legacy VPN Connector' -WithSecret
#>

function New-TripwireDecoy {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] [ValidateSet('User', 'Application')] [string] $Type,
        [Parameter(Mandatory)] [string] $DisplayName,
        [string] $UserPrincipalName,
        [switch] $RegisterOnly,
        [string] $ObjectId,
        [switch] $WithSecret,
        [string] $Note = '',
        [string] $Path
    )

    if ($Type -eq 'User' -and -not $RegisterOnly -and [string]::IsNullOrWhiteSpace($UserPrincipalName)) {
        throw 'A decoy user needs -UserPrincipalName.'
    }
    if ($RegisterOnly -and [string]::IsNullOrWhiteSpace($ObjectId)) {
        throw '-RegisterOnly needs the -ObjectId of the object that already exists.'
    }
    if ($WithSecret -and $Type -ne 'Application') {
        throw '-WithSecret applies to -Type Application only.'
    }

    if (-not $Path) { $Path = Get-TripwireStatePath -Name 'decoys.json' }

    $record = [ordered]@{
        id                 = 'decoy-' + [Guid]::NewGuid().ToString('n').Substring(0, 12)
        type               = $Type
        objectId           = $ObjectId
        appId              = $null
        displayName        = $DisplayName
        upn                = $UserPrincipalName
        plantedAt          = [DateTime]::UtcNow.ToString('o')
        plantedBy          = if ($script:TripwireSession -and $script:TripwireSession.Account) { $script:TripwireSession.Account } else { $env:USER }
        legitimateUseCount = 0
        note               = $Note
    }
    $secretValue = $null

    if (-not $RegisterOnly) {
        $target = "$Type '$DisplayName'"
        if ($PSCmdlet.ShouldProcess($target, 'Create a decoy object in the tenant')) {
            if ($Type -eq 'User') {
                # Generated, sent, discarded. Never returned to the caller and never stored.
                $bytes = [byte[]]::new(30)
                [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
                $throwaway = 'Aa1!' + [Convert]::ToBase64String($bytes)

                $created = Invoke-TripwireGraphWrite -Method POST -Path '/v1.0/users' -Body @{
                    accountEnabled    = $true
                    displayName       = $DisplayName
                    mailNickname      = ($UserPrincipalName -split '@')[0]
                    userPrincipalName = $UserPrincipalName
                    passwordProfile   = @{ password = $throwaway; forceChangePasswordNextSignIn = $false }
                }
                $record['objectId'] = [string]$created.id
                Remove-Variable -Name throwaway -ErrorAction SilentlyContinue
            }
            else {
                $created = Invoke-TripwireGraphWrite -Method POST -Path '/v1.0/applications' -Body @{
                    displayName = $DisplayName
                }
                $record['objectId'] = [string]$created.id
                $record['appId'] = [string]$created.appId

                if ($WithSecret) {
                    $cred = Invoke-TripwireGraphWrite -Method POST -Path "/v1.0/applications/$($created.id)/addPassword" -Body @{
                        passwordCredential = @{ displayName = 'tripwire' }
                    }
                    $secretValue = [string]$cred.secretText
                }
            }
        }
        else {
            Write-TripwireLog -Message 'WhatIf: no object was created; the inventory was not written.'
            return [pscustomobject]$record
        }
    }

    $existing = @(Get-TripwireDecoy -Path $Path)
    $all = @($existing) + @([pscustomobject]$record)
    @{ version = 1; decoys = $all } | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding utf8
    Write-TripwireLog -Message "Decoy $($record.id) recorded in $Path"

    $out = [pscustomobject]$record
    if ($secretValue) {
        # Returned once, on the object, never written to the inventory. If it is lost,
        # plant another decoy -- there is nothing of value behind it.
        $out | Add-Member -NotePropertyName 'SecretValueShownOnce' -NotePropertyValue $secretValue
    }
    return $out
}