lista subskrypcji z właścicielami

scenariusz

Enterprise Agreement i dużo subskrypcji. potrzebna lista wszystkich subskrypcji wraz z ich właścicielami. moje konto nie ma uprawnień do wszystkich subskrypcji więc korzystam z przygotowanego AppID dla konta monitorującego. powinno niby być proste…

  • po pierwsze trzeba zdefiniować, co się rozumie przez właściciela. mogą to być osoby, które mają nadane uprawnienie 'owner’ w IAM, ale w przypadq subskrypcji bardziej logicznym wydaje się znalezienie ’Account Administrator’ subskrypcji – czyli konta, które ją zakładało.
  • po drugie, trzeba mieć konto, które będzie miało odpowiednie uprawnienia i mam takie konto – w opisywanym scenariuszu chodzi o tożsamość aplikacyjną… i tu się zaczynają schody – ale to opiszę w części praktycznej.
  • po trzecie – jak mamy już prereq to trzeba wymyślić jak to przygotować

schody

Microsoft przepisuje narzędzia na Microsoft Graph, ze starego GraphAPI. a że wszystko u mnie PowerShellem stoi, ostatnimi czasy obserwuję wiele czerwonych wykwitów, a co gorsza – skrypty zaczęły się wieszać. sqpiając się jednak na zadaniu…

najpierw chciałem wylistować wszystkich, którzy mają uprawnienie 'owner’ w IAM. okazuje się, że był bug w commadlecie get-AzRoleAssignment: jeśli używa się tożsamości Aplikacji, zwracany jest błąd:

Exception of type 'Microsoft.Rest.Azure.CloudException' was thrown

błąd został poprawiony w ostatniej wersji, update commadletów, działa!… ale czy aby na pewno? co prawda błąd już nie wyskakuje i wszystkie subskrypcje się listują… ale nie ma SignInName ani DisplayName!

$s=Get-AzSubscription -SubscriptionName 'w-files'
Get-AzRoleAssignment -Scope "/subscriptions/$($s.id)"|? RoleDefinitionName -eq owner

RoleAssignmentName : e740d3e4-7809-4104-876c-8ce2a27edbf9
RoleAssignmentId : /subscriptions/01234567-3223-3223-2332-012345678901/providers/Microsoft.Authorization/roleAssignme
nts/e740d3e4-7809-4104-876c-8ce2a27edbf9
Scope : /subscriptions/01234567-3223-3223-2332-012345678901
DisplayName :
SignInName :
RoleDefinitionName : Owner
RoleDefinitionId : 8e3af657-a8ff-443c-a75c-2fe8c4bcb635
ObjectId : 76543210-3223-3223-2332-109876543210
ObjectType : Unknown
CanDelegate : False
Description :
ConditionVersion :
Condition :

obejściem problemu może być wykorzystanie az cli… ma pasqdną składnię, ale ma swoje zalety.

az role assignment list --subscription 01234567-3223-3223-2332-012345678901 --role owner

niedługo należy się spodziewać problemów:

The underlying Active Directory Graph API will be replaced by Microsoft Graph API in a future version of Azure CLI. Please carefully review all breaking changes introduced during this migration: https://docs.microsoft.com/cli/azure/microsoft-graph-migration

a teraz 'bardziej prawidłowy’ scenariusz, czyli listing 'account manager’. tu dla odmiany sytuacja jest odwrotna, ponieważ az cli nagle rzyga błędem:

az role assignment list --subscription 01234567-3223-3223-2332-012345678901 --include-classic-administrators

Insufficient privileges to complete the operation.

natomiast z PowerShell hula:

Get-AzRoleAssignment -Scope "/subscriptions/$($s.id)" -IncludeClassicAdministrators|? RoleDefinitionName -match 'AccountAdministrator'


RoleAssignmentName :
RoleAssignmentId :
Scope : /subscriptions/01234567-3223-3223-2332-012345678901
DisplayName : nexor@w-files.pl
SignInName : nexor@w-files.pl
RoleDefinitionName : ServiceAdministrator;AccountAdministrator
RoleDefinitionId :
ObjectId :
ObjectType : Microsoft.Authorization/classicAdministrators
CanDelegate : False
Description :
ConditionVersion :
Condition :

jedyny sensowny komentarz, jaki mi przychodzi do głowy, to starobabcine 'masz ci babo placek’. w każdym razie teraz to już bułka z masłem:

Get-AzSubscription|%{$sub=$_;Get-AzRoleAssignment -Scope "/subscriptions/$($sub.id)" -IncludeClassicAdministrators|? RoleDefinitionName -match 'AccountAdministrator'|select @{L='subscirption name';E={$sub.name}},@{L='subscription ID';E={$sub.id}},@{L='subscirption state';E={$sub.state}},@{L='owner signin name';E={$_.SignInName}},@{L='owner display name';E={$_.DisplayName}} } | export-csv -nti subscriptionList.csv

eN.

 

back to class

długi wstęp o obiektach

kolejna zabawka edukacyjna (po antiidle) tym razem może bardziej praktyczna – wielopoziomowe menu textowe. chociaż ostatnio sporo czasu poświęciłem na pisanie GUI, nadal uważam, że skrypty powinny zostać w linii poleceń.

scenariusz prosty – zrobić coś, co pozwoli uży-adminowi w łatwy sposób wybrać konkretną opcję *z drzewa wyborów*. jednopoziomowych menu można znaleźć od groma, niektóre nawet całkiem sprytne, ale wielopoziomowe musiałem napisać po swojemu. a przy okazji odświeżyłem sobie lekcje ze Ś.P. Janem Bieleckim – ponieważ to na zajęciach z C++ miałem wykłady o klasach.

w początkowych wersjach PowerShell pojęcia klasy nie było – ponieważ PS jest w pełni obiektowy, tworzenie własnych obiektów, rozszerzanie ich i wykorzystanie jest 'natywnie proste’ – czyli jest tak bardzo integralną częścią PS, że używanie obiektów jest hiperproste. ot najprostsze utworzenie obiektu:

C:\ :))o- $person = [pscustomobject]@{gn='John';sn='Smith';age='23'}
C:\ :))o- $person

gn sn age
-- -- ---
John Smith 23

obiekty można oczywiście bez problemu zagnieżdżać. ot mikrospołeczność z dwóch osób:

C:\ :))o- $community = [pscustomobject]@{ citizens=@($person) }
C:\ :))o- $community.citizens += [pscustomobject]@{gn='Joana';sn='DArc';age='19'}
C:\ :))o- $community.citizens

gn sn age
-- -- ---
John Smith 23
Joana DArc 19

C:\ :))o- $community|gm

TypeName: System.Management.Automation.PSCustomObject

Name MemberType Definition
---- ---------- ----------
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
ToString Method string ToString()
citizens NoteProperty Object[] citizens=System.Object[]

być może to z tego powodu klas długo nie było? w dużym uproszczeniu klasa jest definicją własnego obiektu. aż tak nie będę się rozpisywał, bo teorii o klasach jest wszędzie pełno – a przypadq PS polecam MS docs. pojawiły się dopiero w wersji PS 5.o. skoro można tak łatwo operować obiektami, i od zawsze (?) były Enumy, to po co dodatkowo klasy??

lubię o obiektach myśleć jak o bakteriach. mogą być malutkie i statyczne, ale są również całkiem duże i złożone, z dużą ilością różnych funkcji – np. mogą się poruszać, coś przetworzyć… są 'żywymi obiektami’… no dobra… w natywny PSowy sposób również można ożywić obiekt:

C:\ :))o- $person | add-member -MemberType ScriptMethod -Name 'tellYourName' `
-Value { "my name is $($this.gn) $($this.sn) and I'm $($this.age) old" }
C:\ :))o- $person.tellYourName()
my name is John Smith and I'm 23 old

…ale klasy nadal mają pewne przewagi. taka wersja 'pro’ dla obiektów.

  • po pierwsze są i mają nazwę. wszystkie obiekty pscustomobject są… obiektami typu pscustomobject – bez względu na to, co opisują i co robią. to już samo w sobie czasem stanowi ograniczenie. część logiki można oprzeć właśnie na tym, jaki obiekt przetwarzamy.
  • wynikająca z tego faktu powtarzalność – gwarancja, że dane obiekty, mają konkretny typ, gwarantujący te same atrybuty i metody i przewidywalność zachowania. bez klasy – obiekt za każdym razem trzeba tworzyć od nowa.
  • mają konstruktory. konstruktory dodają dodatkowe funkcje życia, już w okresie prenatalnym obiektu (; dodatkowo uzupełniają 'przewidywalność’, potrafiąc w odpowiedni sposób odpowiednia zainicjować zmienne oraz nałożyć na nie restrykcje [np. żeby imię było obligatoryjnie podane i było stringiem, a wiek, żeby nie był ujemny]

back to class

najlepiej pokazać sqteczność klasy na przykładzie. skoro to ma być menu 'z drzewem wyborów’, to trzeba zaimplementować drzewo. drzewo ma korzeń, gałęzie i liście. te są ze sobą powiązane i trzeba móc przemieszczać się z gałęzi na gałąź/liść i z powrotem. czyli trzeba przechowywać wskazanie na poprzedni obiekt i kolejny. przy czym korzeń i liść różnią się od gałęzi tym, że korzeń nie ma poprzedniego [jest pierwszy] a liść kolejnego [terminujący] obiektu.

tutaj 'gałęzią’ jest lista menu danego poziomu typu:

moja lista

wybór 1

wybór 2

dodatkowo widać, że menu powinno mieć również tytuł. ponieważ to jest pojedynczy poziom, brakuje opcji 'wróć’, która również będzie potrzebna do nawigowania po 'ekranach menu’. i tak powstała klasa:

class MenuLevel {
    [string]$menuPrompt
    [string]$name
    [MenuLevel]$previousLevel
    [MenuLevel[]]$nextLevel
    MenuLevel() {
    }
    MenuLevel(
        [string]$title
    ) {
        $this.menuPrompt = $title
        $this.nextLevel += [MenuLevel]@{ name = 'exit' }
    }
    #add additional menu level with subitems
    [MenuLevel[]] addMenuLevel([string]$name,[string]$prompt) {
        $nLevel = [MenuLevel]::new()
        $nLevel.name = $name
        $nLevel.menuPrompt = $prompt
        $nLevel.previousLevel += $this
        $this.nextLevel += $nLevel
        $back = [MenuLevel]::new()
        $back.name = 'back'
        $nLevel.nextLevel += $back
        return $nLevel
    }    
    #add leaf - for actual choice and execution
    [void] addLeafItem([string]$name) {
        $leaf = [MenuLevel]::new()
        $leaf.name = $name
        $leaf.previousLevel += $this
        $this.nextLevel += $leaf
    }
    #print menu items from current level    
    [string[]] getMenuItems(){
        if($this.nextLevel) {
            return $this.nextLevel.name
        } else {
            return $null
        }
    }
}
dzięki temu, że klasa definiuje nowy typ obiektu, nowe menu (korzeń) można zainicjować w prosty sposób:
$mainMenu = [MenuLevel]::new('select option')
obiekt ma dwa konstruktory. jest konstruktor prosty, który po prostu inicjuje obiekt oraz konstruktor przyjmujący parametr – ten wykorzystany powyżej. co się dzieje w takim przypadq? to właśnie utworzenie 'korzenia’:
   MenuLevel(
      [string]$title
  ) {
      $this.menuPrompt = $title
      $this.nextLevel += [MenuLevel]@{ name = 'exit' }
  }
text jest ustawiany jako tytuł menu, i dodawana jest opcja 'exit’. czyli najprostsze menu, tuż po inicjalizacji, składa się z tytułu i opcji 'exit’.
jeśli chcemy dodać gałąź, wykorzystamy metodę 'addMenuLevel’, która tworzy nową 'gałąź’ – de facto nowy obiekt typu 'menuLevel’. dzieją się tu 3 rzeczy:
  • utworzenie nowego obiektu menuLevel – czyli płaskiej listy opcji z tytułem
  • tworzony jest pusty obiekt menuLevel 'back’ – posłuży do zareagowania i wyświetlenia poprzedniej gałęzi
  • atrybut 'previousLevel’ ustawiony jest na obiekt wywołujący tą funkcję (relacja korzeń-gałąź) – będzie wybrany w przypadq opcji 'back’

przykładowe menu:

        $mainMenu = [MenuLevel]::new('select option')
        $mainMenu.addLeafItem('terminating option') #terminating option
        $l2_1 = $mainMenu.addMenuLevel('submenu options 1', 'SUBMENU L2') #adding 2nd level menu
        $l3_1 = $l2_1.addMenuLevel('submenu option 2', 'SUBMENU L3') #adding next submenu - 3rd level
        $l2_1.addLeafItem('terminating option 2_1') #add terminating option to L2 menu
        $l2_1.addLeafItem('terminating option 2_2') #add terminating option to L2 menu
        $l3_1.addLeafItem('terminate op 3_1')
        $l3_1.addLeafItem('terminate op 3_2')
        $choice = Get-MenuSelection $mainMenu
        switch($choice) {
            'terminating option' { write-host "some logic there for option from main menu" }
            'terminating option 2_1' { write-host "some logic there for option 1 from L2 menu" }
            'terminating option 2_2' { write-host "some logic there for option 2 from L2 menu"}
            'terminate op 3_1' { write-host "some logic there for option 1 from L3 menu" }
            'terminate op 3_2' { write-host "some logic there for option 2 from L3 menu" }
            default { write-host -ForegroundColor red "UNKNOWN OPTION" }
        }
dalej nie będę się rozpisywał – pełny kod wraz z 'silnikiem’ wyświetlającym menu, można zassać z GH. i choć dałoby się to napisać bez klasy – tak jest bardziej sexy (=
eN.

anti-idle

o co cho?

pracując na kilq terminalach mam często problem z blokadą ekranu i wymuszeniem hasła – przez agresywne GPO. wpisywanie hasła za każdym razem zabiera czas – zwłaszcza jak tych sesji jest otwartych kilka i trzeba się do nich dostać.

wystarczy ruszyć myszką albo nacisnąć klawisz, żeby idle-timer się wyzerował. oba te zdarzenia można wywołać ze skryptu, a więc – jest rozwiązanie q:

skrypt

gotowy skrypt umieściłem na GH.

jest również wersja skompilowana za pomocą PS2EXE.

eN.

get-member – jak używać?

potęgą PowerShell jest jego wszech-obiektowość. zrozumienie pracy na takich obiektach to połowa sukcesu. dlatego kiedy coś nie działa lub zachowuje się inaczej niż spodziewanie – używa się get-member [lub po prostu gm]. drugim podstawowym mechanizmem jest oczywiście potokowanie – pipelining. i tak najprostszym sposobem sprawdzenia 'co kryje w sobie obiekt’ jest wykonanie

$something|gm

jest jednak pewien … no właśnie – niuans? niby niuans ale de facto istota funkcjonowania mechanizmów PS związany z tym, czym tak na prawdę jest potokowanie. prosty przykład:

$array=@(1,2)
$array|gm

czego intuicyjnie będziesz oczekiwał(a)? czy get-member zwróci informacje o zmiennej typu 'tablica’? oczywiście nie – zwróci opis zmiennych integer:

TypeName: System.Int32

Name MemberType Definition
---- ---------- ----------
CompareTo Method int CompareTo(System.Object value), int CompareTo(int value), int IComparable.CompareTo(System.Object obj), int IComparable[int].Comp...
Equals Method bool Equals(System.Object obj), bool Equals(int obj), bool IEquatable[int].Equals(int other)
GetHashCode Method int GetHashCode()
GetType Method type GetType()
[...]

to zapewne nie jest zaskoczenie, ponieważ każdy zna to z praktyki. jak się nad tym jednak zastanowić, to nie jest oczywiste – czemu w wyniq dostaje się wynik get-member dla elementów tablicy a nie samej zmiennej tablicowej? ponieważ tak właśnie działa potok [pipe]. de facto jest to skrótowa wersja takiegoż zapisu w klasycznych językach:

foreach ($element in $array) {
  get-member -inputObject $element
}

i teraz wszystko jasne! tak bardzo uzależniamy się od automatyki PS i używania potoków, że czasem można zapomnieć, że to nie jest ani jedyny sposób użycia, ani – jak widać na tym przykładzie – niekoniecznie prawidłowy. jeśli więc chcemy zbadać konkretnie obiekt tablicowy, a nie jego elementy, wystarczy zaniechać potokowania:

get-member -inputObject $array

TypeName: System.Object[]

Name MemberType Definition
---- ---------- ----------
Count AliasProperty Count = Length
Add Method int IList.Add(System.Object value)
Address Method System.Object&, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Address(int )
Clear Method void IList.Clear()
Clone Method System.Object Clone(), System.Object ICloneable.Clone()
CompareTo Method int IStructuralComparable.CompareTo(System.Object other, System.Collections.IComparer comparer)
Contains Method bool IList.Contains(System.Object value)
CopyTo Method void CopyTo(array array, int index), void CopyTo(array array, long index), void ICollection.CopyTo(array array, int index)
[...]

eN.

 

PS Borderlands: $null.. czy nie?

$null czy nie $null, o to jest pytanie

jak sprawdzić czy zmienna została prawidłowo zainicjowana czy może jest wyzerowana? sposobów jest oczywiście kilka, zależnie od kontekstu. ale ponieważ trafiłem na brzegową sytuację, kilka ciekawostek…

pierwsze pytanie – co to znaczy 'wyzerowana zmienna’? od razu przychodzi mi do głowy pytanie-killer, dobre na sprawdzenie sprytu początqjącego deva – 'ile wartości można zakodować na jedno-bitowej zmiennej?’. dość oczywistą odpowiedzią jest 'dwie – 0 i 1′, ale prawidłową jest 'trzy – zero, jeden i null’. [oczywiście przykład wyssany z palca i można z tym polemizować, ale chodzi o ideę]. pokazuje to pewien niuans w sprawdzaniu wartości zmiennej, który może nieźle namieszać – zmienna pusta [empty] to nie to samo co zainicjowana zmienna z wartością $null [o ile $null można nazwać wartością, ale nie chodzi o filozofowanie tylko praktyczne konsekwencje].

początkowo zatem używałem prostego porównania:

if($zmienna -eq $null) { }

potem dowiedziałem się, że $null powinien być zawsze po lewej stronie, ponieważ w specyficznych przypadkach zachowa się 'nieoczekiwanie’ – polecam poczytać na docsach. dla tego poprawnym zapisem jest:

if($null -eq $zmienna) {}

przeważnie jednak, ze względu na logikę działania skryptów, chcę sprawdzić czy coś nie jest $null LUB puste. zacząłem więc przez długi czas używać magii PowerShell:

if(-not $zmienna) { }

co w zasadzie powinno uniwersalnie sprawdzić zmienną. tego zapisu nie używam od dłuższego czasu – nie pamiętam jednak dokładnie jakie tutaj występowały aberracje, ale z jakiegoś powodu przesiadłem się na natywną funkcję .NET dla klasy string:

[string]::isNullOrEmpty($zmienna)

i bardzo długo tą funkcję uważałem za uniwersalny tester i myślałem że znalazłem moje panaceum… do czasu aż nie trafiłem na kolejne brzegowe sytuacje, które nieoczekiwanie zwracają głupotę. najpierw przykład oczywisty, tutaj nie ma co się dziwić:

PS> $emptyArray=@()
PS> $emptyArray.count
0
PS> [string]::isNullOrEmpty($emptyArray)
True

kolejny nadal dość oczywisty przykład, choć warto zwrócić uwagę na olbrzymią różnię pomiędzy 'tablicą pustą’ z poprzedniego przykładu, a tablicą zawierającą element $null – zwróć uwagę na ilość elementów w tablicy:

PS> $nullArray=@($null)
PS> $nullArray.count
1
PS> [string]::isNullOrEmpty($nullArray)
True

i tu doszedłem do PS Borderlands – granic swojej wiedzy i możliwości zrozumienia mechanizmów pod spodem. dalszych przykładów nie potrafię wyjaśnić – znam je z obserwacji:

PS> $nullArray=@($null,$null)
PS> $nullArray.count
2
PS> [string]::isNullOrEmpty($nullArray)
False

wychodzi na to że jedno nic to nadal nic, ale dwa nic to już coś. miałem na studiach matematykę nieskończoności, ale nigdy matematyki nicości, najwyraźniej muszę tą dziedzinę lepiej zgłębić (;

a dalej… to już kosmos. przypadek tak zawikłany, że zajęło mi masę czasu póki go wyizolowałem i umiałem pokazać na przykładzie. w przeszłości również trafiałem na podobne zachowanie, więc scenariuszy jest prawdopodobnie więcej, ale dopiero teraz postanowiłem to zrozumieć. opiszę, może znajdzie się harpagan, który to rozwikła…

poza granicami

jeśli funkcja implementuje 'ValueFromRemainingArguments’, typ zwracanego argumentu będzie zależny od tego czy się z tej funkcjonalności skorzysta. tak działa między innymi 'write-host’ – wywołanie polecenia:

PS> write-host tu sobie cos tam napiszemy bez zadnych cudzyslowiow
tu sobie cos tam napiszemy bez zadnych cudzyslowiow

bez cudzysłowiów – czyli de fakto polecenie powinno potraktować każdy wyraz jako oddzielą wartość kolejnego parametru. a jednak zinterpretował to jako pojedynczą wiadomość. to właśnie instrukcja ValueFromRemainingArguments upakowała wszystkie nienazwane wartości pod jedną zmienną. podobny myk stosuję w swoim write-log dzięki czemu mogę uzyskać podobny efekt. na tej też funkcji mogłem zbadać, że przy takim wywołaniu:

PS> write-log -message "to cos"
to cos

zmienna $message będzie typu [string]. ale przy takich wywołaniach:

PS> write-log "to cos"
to cos
PS> write-log to inne cos
to inne cos

zmienna $message jest typu [`list] [nie pytajcie skąd ten backtick w nazwie typu – bardzo dziwaczny to zapis, dodatkowa przyprawa w tym i tak dziwnym przypadq]. to pierwsza ważna różnica, ponieważ problem pojawia się właśnie przy tym typie. a że zazwyczaj ułatwiamy sobie życie i jeśli można ’-message’ nie pisać, to go nie piszemy, scenariusz więc dość codzienny.

jeśli jako wiadomość przekaże się obiekt, to zostanie stworzona lista zawierająca obiekty. można to zasymulować tak:

PS> $przekazaneWartosci = new-object System.Collections.Generic.List[system.object]
PS> $mojObiekt = [PSCustomObject] @{page='w-files.pl';proto='https'}
PS> $mojObiekt

page proto
---- -----
w-files.pl https

PS> $przekazaneWartosci.add($mojObiekt)
PS> [string]::isNullOrEmpty($przekazaneWartosci)
True

jeśli obiekt wrzucony do listy jest typu PSCustomObject – nawet jeśli nie jest pusty, funkcja zwraca informację, że lista jest pusta. można by powiedzieć – 'no ale panie, to jest metoda dla stringa, a tutaj masz obiekt, to pewnie dlatego’. tyle, że sprawdzałem na wielu innych typach obiektów – i zachowuje się prawidłowo. póki co ten bug wydaje się działać tylko dla PSCustomObject (-> update dalej). już nawet taka zmiana w deklaracji, z custom na generic:

$myobj = [PSObject] @{page='w-files.pl';proto='https'}

spowoduje, że będzie działać prawidłowo. różnica pomiędzy PSObject a PSCustomObject mogłoby być też fajnym wpisem PS Borderlands i straciłem kiedyś sporo czasu na braq spostrzegawczości, że jest inna deklaracja… ale to nie teraz. dodatkowego smaczq dodaje fakt, że nie do końca wiadomo, kiedy coś zostanie przetworzone na PSCustomObject. ot proszę przykład:

PS> (get-process).gettype()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True True Object[] System.Array

PS> (get-process|select processname).gettype()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True True Object[] System.Array

PS> $ou='OU=borderlands,DC=w-files,DC=pl'
PS> (Get-ADOrganizationalUnit -Identity $ou).gettype()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True False ADOrganizationalUnit Microsoft.ActiveDirectory.Management.ADObject

PS C:\CloudTeam-ScriptLAB> (Get-ADOrganizationalUnit -Identity $ou|select name).gettype()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True False PSCustomObject System.Object

z jakiegoś powodu filtrowanie niektórych typów obiektów zmienia ich typ – kolejna zagadka, już poza granicami zrozumienia, ponieważ to nie jest normalne zachowanie.

update

dopiero co skończyłem ten wpis i zabrałem się za kolejny skrypt. kolejny przykład obiektu, dla którego [string]::isNullOrEmpty zwraca głupotę – i to dużo prostszy. wiedziałem, że trafiałem na więcej takich przypadqw!

PS> $storageAccountList = get-AzStorageAccount -resourceGroupName 'myRG'
PS> $storageAccountList

StorageAccountName ResourceGroupName PrimaryLocation SkuName Kind AccessTier CreationTime Provisioning State
------------------ ----------------- --------------- ------- ---- ---------- ------------ ------------
nexortestsa myRG westeurope Standard_LRS StorageV2 Hot 07.05.2021 10:56:16 Succeeded

PS> $storageAccountList.GetType()

IsPublic IsSerial Name BaseType
-------- -------- ---- --------
True False PSStorageAccount System.Object

PS> [string]::IsNullOrEmpty($storageAccountList)
True

podsumowanie

cała informatyka opiera się na determiniźmie [no w każdym razie do czasu pojawienia się AI/ML, które jest w IT odpowiednikiem fizyki kwantowej w fizyce (; ]. brak determinizmu powoduje, że trzeba się nieźle napocić żeby wyłapać błędy i prawidłowo je obsłużyć.

strasznie mnie drażni, kiedy nie potrafię czegoś zrozumieć. takie 'niuanse’ to jakieś górne wartości zasady pareto – ten promil promila całego systemu, na który trzeba poświęcić mnóstwo, mnóstwo czasu, żeby w ogóle go uchwycić. nie wspominając o zrozumieniu. w tym przypadq – to jest kilka brzegowych sytuacji, które nakładają się na siebie. istna qmulacja. IMHO to nie jest coś 'do zrozumienia’ a bardziej błędy w implementacji, ale póki co – wpada do teczki w-Files.

eN.

 

jak pobrać wartość zmiennej z wyższego zakresu

praktyka

zmienne mają swoje zakresy (scopes). zakresy są niby bardzo proste… ale czy na pewno? to jak pobrać zmienną z zakresu nadrzędnego (parent scope)?

bardzo proste:

get-variable -name <varName> -scope 1

teoria

ale, że '1′? zawsze i niezmiennie '1′? jak to tak? a jak pobrać wartość zakresu w którym obecnie się znajduję?


zmienne układane są na stosie, zwanym czasem kolejką LIFO (Last-In, First-Out). [najstarsza zarejestrowana implementacja LIFO to ’pierwsi będą ostatnimi, a ostatni pierwszymi’ więc łatwo zapamiętać (; ]. element na szczycie stosu, to element 'obecny’, i zawsze ma wartość '0′. proste, ale konsekwencje są nieoczywiste. do tego dochodzi specyfika samego PS i tego, jaki próbuje być 'przyjazny’, co dodatkowo może mieć niespodziewane konsekwencje. oprócz zakresów 'numerowanych’ są zakresy 'nazwane’ i to one powodują sporo zamieszania. nie będę kopiował definicji, które można znaleźć łatwo i szybko, ale z nazwy, podstawowe zakresy to 'Global’ i 'Script’. tak więc uruchamiając dowolny skrypt, zakresy wyglądają tak:

prosty kod testowy:

function level1 {
    $x = 1
    $x
}
$x = 0
level1
$x

wyświetli oczywiście '1′ a następnie '0′ – ponieważ przy każdym wyświetleniu $x wskazuje na zmienną z obecnego zakresu. obecnego, czyli '0′, co obrazuje:

mamy więc odpowiedź 'jak pobrać obecny zakres’ – jest to po prostu zakres zero. skompliqjmy ten skrypt tak, aby przetestować czy faktycznie 'parent scope’ to '1′:

function level1 {
    $x = 1
    "here: $x"
    "parent from l1: {0}" -f (Get-Variable -Name x -Scope 1).Value
    level2
}

function level2 {
    $x = 2
    "here: $x"
    "parent to l2: {0}" -f (get-variable -name x -Scope 1).value
    "script from l2: {0}" -f ($script:x)
}

$x=0
Write-Host -ForegroundColor Magenta "level1:"
level1

Write-Host -ForegroundColor Red "level2:"
level2

write-host -ForegroundColor Green "script:"
$script:x

write-host -ForegroundColor Yellow 'global:'
$Global:x

stos zmiennych dla tego wywołania ma teraz taką strukturę:

oczywiste? ruchome wartości dla zakresów nazwanych mogą sprawić problem i wydawać się nieintuicyjne. ale to nie koniec pułapek. ostatnia wartość [$global:x] może przyjąć albo $null albo… zero. zależnie od środowiska uruchomieniowego [możesz odpalić kod w VSC]. dodatkowo problemy może sprawić zmiana wartości zmiennej nadrzędnej… o ile odczytana zostanie zmienna z dowolnego, pierwszego zakresu, w którym istnieje, o tyle zmiana wartości będzie wykonana w tym samym zakresie. żeby to zobrazować, zmodyfiqjmy odrobinę pierwszy kod:

function level1 {
    $x
    $x = 1
}

$x = 0
level1
$x

tym razem na ekranie pojawią się dwa zera. w funkcji 'level1′ żądamy wyświetlenia $x, które w tym zakresie jeszcze nie istnienie. w związq z tym wyświetlony został $x z zakresu wyżej. ale w momencie wykonania „$x = 1”, zgodnie z definicją, iż przypisanie następuje zawsze w tym samym zakresie, zmienna została zainicjowana lokalnie – a nie nadpisana pierwsza istniejąca – i to jej przypisana została wartość '1′. a więc $x wyświtlony na końcu nie uległ zmianie i wyświelone zostało '0 0′

jeśli chcemy zmienić wartość zmiennej w zakresie nadrzędnym (parent scope) kod wygląda tak:

function level1 {
    $x
    set-variable -name x -scope 1 -Value 1
}

$x = 0
level1
$x

…no i warto zwrócić uwagę, że podając nazwy zmiennych dla commandletów *-variable nie dodaje się '$’.

…i może jeszcze to, że nie ma standardowo metody aby odpytać się o to, jak głęboki jest obecnie stos. jeśli masz taki scenariusz, to przyda ci się jeden z poprzednich wpisów. no dobra, podpowiem… to będzie „((get-PSCallStack).count-1)”.

eN.

AzPseudoGUI – łatwiejsza praca z commandletami Az

trochę GUI dla commandline’a

pracując z linii poleceń braqje możliwości szybkiej weryfikacji nazw i list. ponieważ nie ma GUI, co chwile trzeba listować obiekty, wybierać odpowiednie właściwości (properites) i dopiero w ten sposób wykorzystać nazwę dalej. jest to denerwujące zwłaszcza w połowie pisania jakiegoś długiego oneliner’a i nagle 'o rety, ale jak była nazwa tej resorsgrupy?’ … i ctrl+c, listing wszystkich Resource Group i dopiero od nowa pisanie komendy…

żeby sobie ułatwić życie, oraz pisanie prostych skryptów, przydatny jest moduł AzPseugdoGUI, który obudowuje popularne funkcje Az i dodaje do niech out-gridView. w ten sposób, bez konieczności pamiętania nazw, często dość długich i pokrętnych można np.:

$ctx=select-subscription

co pozwoli wybrać konkretną subskrypcję i zwróci obecny kontext pracy, który można potem wykorzystać.

jak szybko wybrać maszynę wirtualną? po prostu

$vm=select-VM

za dużo maszyn w subskrypcji ale pamiętasz o którą resource group chodzi?

$vm=select-RG -isCritical | select-VM

ułatwia życie i nie trzeba ciągle sięgać do Azure Portal czy listować w kółko… moduł można łatwo zainstalować

install-module AzPseudoGUI

nowy destroy-AzureVM

wykorzystując ten moduł, poprawiłem destroy-AzureVM – który teraz również wykrywa backup maszyny i pozwala wybrać które zasoby mają zostać usunięte a które nie.

przy okazji ciekawostka:

  • W Azure (niemal) wszystko jest zasobem (resource) a dowolny zasób można usunąć uniwersalną funkcją 'remove-AzResource’.

to bardzo wygodnie i nawet złożone konstrukty takie, jak VM można potencjalnie usunąć jedną komendą… niestety zasobem (resource) nie jest kontener Storage Account. ale i tak można zoptymalizować skrypt, tworząc tabelę URI zasobów i wrzucając je do niszczarki:

foreach($resource in $resourcesForDeletion) {
    write-log "removing $resource" -type info
    $resourceSplited=$resource.split('/')
    try{
        remove-AzResource -ResourceID $resource -force
        write-log "$($resourceSplited[-2]): $($resourceSplited[-1]) removed." -type ok
    } catch { 
        write-log "error removing $($resourceSplited[-2]): $($resourceSplited[-1])." -type error
        write-log $_.exception -type error
    }
}
wygodne.
eN.

nazwa skryptu, moduły i stos wywołania

Zakres

robiąc poprzedni wpis stwierdziłem, że największym problemem było pobranie nazwy skryptu uruchamiającego i spodziewałem się, że już jest to ogarnięte. chwilę potem znalazłem dodatkowe scenariusze udowadniające, że sprawa jest jeszcze trudniejsza i bardziej pogmatwana.

temat wydaje się prosty: uruchamiamy skrypt 'skrypt.ps1′ i chcemy założyć plik logu '_skrypt.log’. przy normalnym wywołaniu nie ma najmniejszego problemu. do wyboru mamy kilka zmiennych automatycznych. główną taką zmienną jest $PSScript oraz linqjące do poszczególnych gałęzi skróty:

 

  • $PSCmdlet
  • $MyInvocation ($PSCmdlet.MyInvocation)
  • $PSScriptRoot
  • $PSCommandPath ($MyInvocation.MyCommand.Path)

hint: aby $PSCmdlet zainicjowało się prawidłowo, należy włączyć advanced functions np. przy pomocy cmdletbinding().

w czym zatem problem? otóż $PScmdlet jest inicjowany w momencie ładowania skryptu, i jest zmienną lokalną. mamy więc wiele scenariuszy, w których zamienna przyjmie różne wartości:

  • uruchomione bezpośrednio z linii poleceń (nie inicjuje zmiennych automatycznych, ale to co uruchamiamy zwróci wartość)
  • uruchomione z funkcji, zdefiniowanej w ramach tego samego pliq
  • uruchomione z funkcji, która ładuję bibliotekę
  • uruchomione z funkcji która uruchamia funkcję z innej biblioteki

aby to unaocznić przygotowałem scenariusz testowy – dostępny na GH, jeśli ktoś chciałby się pobawić samemu. jest tu dużo ciekawych niuansów i zabawy (np. niedostępność wartości $MyInvocation podczas debugowania).

hint: jeśli odpalisz plik test-context.ps1 z tego samego katalogu gdzie są moduły testowe, nie musisz ich instalować.

czy zatem nie da się wyciągnąć informacji o faktycznym źródle wywołania?

get-PSCallStack

da się – przy pomocy funkcji get-PSCallStack. Funkcja ta zwraca … właśnie 'call stack’ czyli stos wywołania. przypatrzmy się, co konkretnie zwraca funkcja, zależnie od kontextu uruchomienia

  • bezpośrednio z linii poleceń:
C:\_ScriptZ :))o- Get-PSCallStack

Command Arguments Location
------- --------- --------
<ScriptBlock> {} <No file>
  • z linii poleceń, wywołanie funkcji z modułu
C:\_ScriptZ :))o- level1-callstack

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
<ScriptBlock> {} <No file>
  • ze skryptu:
C:\...PSCmdletTest :))o- .\test-contexts.ps1
WARNING: The names of some imported commands from the module 'level1module' include unapproved verbs that might make them less discoverable. To find the
commands with unapproved verbs, run the Import-Module command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.
PSCallStack in different contexts

directly from here

Command Arguments Location
------- --------- --------
test-contexts.ps1 {} test-contexts.ps1: line 8
<ScriptBlock> {} <No file>


from the function loaded by module

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
test-contexts.ps1 {} test-contexts.ps1: line 11
<ScriptBlock> {} <No file>


from the function in module L2 calling function in module L1

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
level2-callstack {} level2module.psm1: line 11
test-contexts.ps1 {} test-contexts.ps1: line 13
<ScriptBlock> {} <No file>


MtInvocation test from different contexts

first in-script PScommandPath (from myinvocation)
local myinvocation -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\test-contexts.ps1
now PScommandpath but run from the function, loaded in a module L1 ($myinvocation.command.path is empty for functions)
f1 PScommandPath -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1
similar, but function inside module L1 calls function in the same module
f1 via f2 PScommandPath -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1
more complex - call function in module L2, which is calling function in module L1
f1 via diferent module -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1

to co się jako pierwsze rzuca w oczy, równocześnie spójne z definicją 'stosu wykonania’, to że wielkość stosu jest równa zagnieżdżeniu wywołania. z linii poleceń – jest tylko jeden element, bezpośrednio ze skryptu: 2, z funkcji w skrypcie: 3, a z funkcji wołającej funkcję: 4.

drugim elementem rzucającym się w oczy jest kolumna 'location’ która pokazuje ścieżkę do pliq w którym nastąpiło wywołanie, wraz z linią. jest to stos, ale żeby nie było za łatwo – odwrócony. kolejność elementów jest zawsze taka sama ale przez odwrócenie, trzeba zrobić trochę matematyki:

  • zero (na dnie): ostatnie wywołanie
  • ostatnie-1 : właściwy skrypt wywołujący
  • ostatnie: wywołanie bezpośrednie, linia hosta

i te dwie informacje dają nam już możliwość weryfikacji jak dana funkcja jest wywoływana (bezpośrednio z linii poleceń czy gdzieś ze skryptu) oraz dokładne sprawdzenie czy był to moduł, który, a może po prostu jakaś funkcja.

podsumowanie

funkcja get-pscallstack nie jest specjalnie popularna więc nie łatwo było na nią trafić. jednak przy pisaniu modułów lub jakiejś logiki wymagającej wiedzy o środowisq uruchomienia – okazuje się być dużo bardziej przydatna, niż niekontrolowalne $myInvocation, które zmieniają kontext i wartości.

eN.

install-module eNLib

install-module eNLib

nie tylko udało mi się skończyć* moduł eNLib, ale również opublikować w PowerShell Gallery. przy okazji tego drobnego sukcesu chciałem podzielić się kilkoma doświadczeniami z pisania modułów. wszędobylska zasada Pareto (lub dla bardziej dociekliwych – prawo Zipfa) okazała się sporym wyzwaniem – im bliżej końca, tym trudniej. dla tego słowo opisu samej biblioteki, oraz z czym były największe problemy przy jego wykończeniu.

tak, że teraz instalacja biblioteki sprowadza się do trywialnego 'install-module eNLib’ a źródła można również pobrać z GitHub.

[*skończyć w metodyce CI/CD, czyli najważniejsze gotowe, a z resztą jakoś to będzie (; ]

eNLib

typowa biblioteka użytkowa – kiedy skryptów pisze się dużo, bardzo szybko okazuje się, że pewne funkcje ułatwiające życie, wykorzystuje się w niemal każdym skrypcie. dla mnie taką funkcją jest write-log – funkcja, która forkuje wszelkie informacje zarówno na ekran jak do pliq. dzięki temu nie muszę zastanawiać się nad 'verbose’ [który ma swoje wady] i mam gwarancję, że wszystko co widzę, będzie również w logu. koniec zastanawiania się, że jakiś błąd śmignął na ekranie i nie wiadomo co to. łatwiej też troubleshootować – 'prześlij mi log z przebiegu’ i wszystko będzie jasne.

żeby logować do pliq, należy go zainicjalizować. i z tym było najwięcej zabawy, o czym za chwilę. drugą (czy też raczej zerową) funkcją, jest zatem start-logging, inicjalizująca zmienną $logFile i sam plik logu. jeśli wywoła się write-log bez uprzedniej inicjalizacji – zostanie automatycznie wykonana. wywołanie jawne pozwala na użycie dodatkowych atrybutów – założenie logu o zadanej nazwie, w zadanym folderze lub w katalogu 'documents’ użytkownika.

każdy plik logu zawiera podstawowe informacje dot. środowiska uruchomieniowego, każdy wpis ma timestamp a dodatkowo można tagować linijki błędów/ostrzeżeń/ok przy pomocy parametru 'type’ – nie tylko pokazuje w innym kolorze, co pomaga podczas pracy bieżącej, ale dodaje tak 'ERROR:’ czy 'INFO:’ w logu, pomagając przeszukiwać go później:

*logging initiated 02/06/2021 09:30:41 in C:\Users\nexor\OneDrive\Documents\Logs\_console-2102060930.log
*script parameters:
<none>
***************************************************
09:30:41> test
09:30:42> ERROR: test error

jasna sprawa, jako wiadomość można podać dowolny obiekt, który zostanie skonwertowany do tekstu. szczegóły oraz przykłady użycia – 'man write-log -examples’, 'man start-logging -full’ – więc nie rozpisuję się.

ponadto moduł zawiera funkcje

  • new-randomPassword – generuje ciąg znaków o zadanej złożoności
  • get-AnswerBox – Formsowe okienko OK/Cancel
  • get-valueFromInputBox – Formsowe okienko pozwalające na wpisanie promptu użytkownika
  • get-ExchangeConnectionStatus – weryfikacja połączenia z Exchange [Ex/EXO]
  • connect-Azure – weryfikacja połączenia z Azure

te funkcje są wersjach słabo przetestowanych i na pewno nie są dopieszczone tak jak write-log, którego po prostu używam nagminnie [już nawet odruchowo wpisuje wrtite-log zamiast write-host (; ]. na pewno powoli będę nad nimi pracował – skoro są opublikowane, to będą potrzebowały trochę więcej atencji.

Z czym było najwięcej zabawy?

z uniwersalnym inicjowaniem pliq logu i tzw. ’scopes’ dla zmiennych. idea jest trywialna, ale w praktyce okazało się to zabójcze. np:
  • jeśli funkcja odpalana jest bezpośrednio z konsoli, to $MyInvocation ma inną wartość niż…
  • …kiedy odpali się ze skryptu. ale co jeśli…
  • …funkcja uruchamiana jest przez inną funkcję, która wywołana jest ze skryptu?

przyjąłem default, że plik logu będzie miał nazwę '_<nazwa-skryptu>-<datestamp>.log’ . i żeby ową nazwę skryptu pobrać, trzeba zastosować inną metodę, zależnie od kontekstu – opisanego wyżej. 3ci opisany przypdek, choć wydaje się być najbardziej złożony, jest równocześnie domyślnym:

  • nie chcemy pamiętać i inicjalizować logu, więc po prostu w skrypcie zamieszczamy 'write-log „coś tam”’
  • funkcja write-log wykrywa, że to pierwsze użycie, więc woła start-logging.
  • dodatkowo obie funkcje są w module, a więc w zewnętrznym skrypcie

zmienne typu $MyInvocation to zmienne automatyczne, tworzone w trakcie egzekucji skryptu. maja więc określony kontekst, czy też zakres (scope) i w każdym będą miały inną, lokalną wartość. do tego nie można założyć konkretnego scenariusza – muszą być obsłużone wszystkie. z tego powodu kawałek inicjalizacji musiał zostać przeniesiony do samego write-log, choć łamie to logikę [powinno być wszystko w start-logging]:

    #if function is called without pre-initialize with start-logging, run it to initialize log.
    if( [string]::isNullOrEmpty($script:logFile) ){
        #these need to be calculated here, as $myinvocation context changes giving library name instead of script
        if( [string]::isNullOrEmpty($MyInvocation.PSCommandPath) ) { #it's run directly from console.
            $scriptBaseName = 'console'
        } else {
            $scriptBaseName = ([System.IO.FileInfo]$($MyInvocation.PSCommandPath)).basename 
        }
        if([string]::isNullOrEmpty($MyInvocation.PSScriptRoot) ) { #it's run directly from console
            $logFolder = [Environment]::GetFolderPath("MyDocuments") + '\Logs'
        } else {
            $logFolder = "$($MyInvocation.PSScriptRoot)\Logs"
        }
        $logFile = "{0}\_{1}-{2}.log" -f $logFolder,$scriptBaseName,$(Get-Date -Format yyMMddHHmm)
        start-Logging -logFileName $logFile
    }
zmienna $logFile jest wykorzystana jako flaga – jeśli nie jest zainicjowana, odpalana jest 'start-logging’. zmienna jest eksportowana, więc można jej potem używać wewnątrz własnych funkcji np. żeby poinformować, 'zapis przebiegu jest zapisany w $logFile’.
drugim problemem/ciekawostką, nad którą chwilę musiałem się pogłowić, było umożliwienie funkcji konsumpcji dowolnej ilości parametrów. znów teoretycznie – sprawa prosta. wystarczy dodać 'ValueFromRemainingArguments=$true’ i pozamiatane…. tak przynajmiej wynikało z licznych przykładów dostępnych w necie. tyle, że te przykłady zawsze pokazywały sytuację, gdzie był tylko jeden parametr – a tu, funkcja ma ich kilka. i za każdym razem wywalało błąd, próbując podstawić pod nie wartość, która miała iść jako 'message’. podobną funkcjonalość ma write-host. wszystkie przykłady zadziałają:
write-log "jakis text"
write-log jakis text
write-log jakis -type ok text
z tą różnicą, że write-host zapisze wszystkie parametry w jednej linii, write-log – w oddzielnych liniach. zgubna okazała się … poprawność zapisu parametrów. kiedy deklaracja nagłówka była 'pełna’, przekazywanie nie działało prawidłowo:
    param(
        #message to display - can be an object
        [parameter(ValueFromRemainingArguments=$true,mandatory=$false,position=0)]
            $message,
        #adds description and colour dependently on message type
        [parameter(mandatory=$false,position=1)]
            [string][validateSet('error','info','warning','ok')]$type,
        #do not output to a screen - logfile only
        [parameter(mandatory=$false,position=2)]
            [switch]$silent,
        # do not show timestamp with the message
        [Parameter(mandatory=$false,position=3)]
            [switch]$skipTimestamp
    )
natomiast po usunięciu deklaracji prametrów udało się uzyskać porządany efekt:
    param(
        #message to display - can be an object
        [parameter(ValueFromRemainingArguments=$true,mandatory=$false,position=0)]
            $message,
        #adds description and colour dependently on message type
            [string][validateSet('error','info','warning','ok')]$type,
        #do not output to a screen - logfile only
            [switch]$silent,
        # do not show timestamp with the message
            [switch]$skipTimestamp
    )
nie wiem czy 'pedanteryjna’ wersja deklaracji pozwalająca na pożądany efekt, a póki co nie udało mi się znaleźć sposobu na zdebugowanie write-host. ale pewnie kiedyś poświęcę czas, żeby go podejrzeć i przeanalizować.

2o/8o

choć write-log używam od dawien-dawna, czyli większość pracy niby była zrobiona, okazało się, że to jest to 2o% pracy, robiące 8o% wyniq. kiedy zacząłem robić z tego moduł, z myślą o publikacji, pozostałe 8o% pracy, stanowiące 2o% tego, co widać to:
  • testy, testy i jeszcze raz testy – w różnych konfiguracjach. i tak nie zrobiłem wszystkich – choćby z wersji core.
  • opisanie funkcji wraz z przykładami.
  • zapanowanie nad inicjalizacją logu bez względu na sposób uruchomienia
  • prawidłowe przygotowanie do publikacji jako moduł
mam nadzieję, że komuś ułatwi pracę (:
eN.

bezpieczne hardcodowanie hasła w skrypcie

bezpieczne hardocodowanie hasła?

uruchamiając skrypt, najbezpieczniej jest NIE hardcodować hasła. jeśli coś jest w skrypcie, to znaczy, że może być wykorzystane w nieautoryzowany sposób. jednak przykłady z życia pokazują, że czasem taka jest konieczność .. czy po prostu zlecenie (; przykład – obsługa wsparcia, która ma korzystać ze skryptu, który gdzieś tam sobie sięga. to 'gdzieś tam’ to środowisko, w którym nawet owa obsługa nie ma konta.

podstawowe zasady bezpieczeństwa to 3xA (AAA) – Authentication, Authorization & Accounting. jeśli utworzymy konto serwisowe, które ma coś wykonywać w imieniu uruchamiającego, to aby nie złamać zasady AAA i być w stanie stwierdzić kto wykonał daną akcję, trzeba to przesunąć do miejsca, gdzie uruchamia się skrypt/aplikację i umieć to powiązać… dalej mogłoby być o SIEM i tak dalej… ale nie o tym tutaj. ogólnie najważniejsze – wykorzystanie konta serwisowego (w środowisq legacy-domain), czy aplikacji proxy (tak jak działa to w Azure) nie musi koniecznie być złem, ale pozostaje pytanie… *jak zamieścić hasło skrypcie tak, aby nie wyciekło wraz ze skryptem?*

najbezpieczniej byłoby wykorzystać certyfikat… tak to można zrobić np. dla aplikacji Azure. niestety w środowisq klasycznym, gdzie mamy np. wykonać akcję w domenie, nie jest to łatwe. najbezpieczniej byłoby umieszczenie serwisu aka proxy, który działa sobie na serwerze w owej domenie i do tegoż serwisu uwierzytelniać się certyfikatem. spoko ale to developerka i do 'małych projekcików’ czy 'drobnych narzędzi’ – po prostu jest za duża czasochłonność (czyt. koszt), nie wspominając o tym że napisanie własnego serwisu (np. wystawiającego REST API) to nie jest już skryptowanie. to developerka.

no więc zróbmy to, co da się zrobić tak, aby było szybko i *możliwie* bezpiecznie.

…na ile się da

skoro nie jesteśmy w stanie uwierzytelnić się certyfikatem, to przynajmniej zaszyfrujmy hasło certyfikatem. co nam to da:

  • dany skrypt będzie działał wyłącznie na tej maszynie gdzie jest certyfikat i dla tych osób, którym na to pozwolimy.

żeby była jasność – jeśli ktoś, kto ma złe intencje będzie tego skryptu używał, to sobie hasło odkoduje. ale po pierwsze – 'pierwszy lepszy’ tego nie zrobi, po drugie – jeśli założylibyśmy takiej osobie konto, to i tak wykorzysta je do niecnych celów. trzeba wiedzieć że to może być rozwiązanie *good enough* i tylko w tym kontekście można nazwać je 'bezpiecznym’. spory disclaimer ale nie chcę przypadkowych czytelników wprowadzić w błąd.

jak już wyjaśniłem co rozumiem przez 'bezpieczne’, recepta:

  1. załóż konto w domenie, bez żadnych dodatkowych uprawnień
  2. dobrze byłoby poustawiać 'deny’ dla takiego konta, przykrajając odpowiednio uprawnienia – wedle potrzeby, uznania i paranoia-level
  3. nadać uprawnienia do tego, do czego będzie wykorzystywane
  4. wygenerować odpowiedni certyfikat, zainstalować go i odpowiednio nadać uprawnienia
  5. zaszyfrować hasło certyfikatem
  6. i voila.. można go wykorzystać w skrypcie

punkty 1-3 pominę, natomiast 4-6 zaraz opiszę.

dzięki takiej konfiguracji – skrypt, nawet skopiowany czy przypadkowo udostępniony, na niewiele się zda. aby wydobyć hasło – trzeba mieć dostęp do klucza prywatnego certyfikatu.

generowanie certyfikatu

super proste, przy czym do tego celu cert musi spełniać odpowiednie funkcje, czyli (nomen-omen) kluczem jest dobry key-usage i document type:

$cert=New-SelfSignedCertificate -DnsName "script automation" -CertStoreLocation "Cert:\CurrentUser\My" -KeyUsage KeyEncipherment,DataEncipherment,KeyAgreement -Type DocumentEncryptionCert

$cert|Export-PfxCertificate -FilePath C:\temp\scriptautomation.pfx -Password (ConvertTo-SecureString -String "secret-string" -Force -AsPlainText)

dodatkowo można certyfikat usunąć z własnego komputera tak, aby jedna kopia była w pliq:

$cert|remove-Item

…albo zachować go jako backup a usunąć potem plik. up2u.

instalacja w środowisq docelowym

najłatwiej oczywiście kliknąć sobie na pliq pfx i wybrać opcję 'local machine’. jeśli umieścimy w 'current user’ to będzie dostępny tylko dla nas.

kolejną hiper-istotną kwestią jest *NIE włączanie* możliwości exportu klucza.

jeśli ktoś woli z PowerShell – należy użyć ’import-pfxCertificate*bez* parametru -exportable.

Import-PfxCertificate -FilePath C:\temp\scriptautomation.pfx -Password (ConvertTo-SecureString -string "secret-string" -force -AsPlainText) -CertStoreLocation Cert:\LocalMachine\My\

ciekawostka: w PS 5.1 ten commandlet ma dodatkowy parametr, nieopisany w doc, protectPrivateKey, który pozwala wykorzystać Virtual Secure Mode.

to połowa sukcesu – bo do tak zaimportowanego certyfikatu, nikt bez uprawnień administratora nie będzie mógł skorzystać. należy nadać uprawnienia do klucza prywatnego:

  1. odpal 'certlm.msc’ (zarządzanie certyfikatami komputera)
  2. wybierz certyfikat i kliknij PGM
  3. jest tam opcja 'manage private keys…’

4. nadaj uprawnienie READ (nie FC!) dla wybranych osób

kodowanie i odkodowanie hasła

teraz już z górki. przygotowanie środowiska to bardziej wymagający element, ponieważ hasło można zaszyfrować za pomocą polecenia ’protect-cmsMessage’:

C:\_scriptz :))o- Protect-CmsMessage -To 'cn=script automation' -Content 'moje tajne hasło'
-----BEGIN CMS-----
MIIBtAYJKoZIhvcNAQcDoIIBpTCCAaECAQAxggFMMIIBSAIBADAwMBwxGjAYBgNVBAMMEXNjcmlw
dCBhdXRvbWF0aW9uAhB2BdZafxn2nkcWjQNvBi+pMA0GCSqGSIb3DQEBBzAABIIBAFY0TU8LAPl4
4IAWp9tGDOZK/bAEwQpZPix5dz8AoOpaDckpZ8dqatggEKq7vo4VX6wL4sBkImQoZrCZjkT7oCSi
N1YFDOIghjD7e2mIvEl/Gq2bReUbsFQ0iw6Wg1K8zVLBzDQAIUHgJvaV2Ssf4nCJ8WfW5d9UzmBB
JpHAmy7cdofboQt6hRz0wafGNivD47z2xNSSVyhqHVUzBt6NWmAYouFMGYGsBZBuOHXnQJzh+saG
Q5VpWxZGRHzM3igl8IgYDF75R+b/FSfciPu5Se2eeD6xxHrPj9rfLm37KVIab/4+QA/lRFVt4cwA
ztjhh8puyPqYgKCE7t0Tp5VNQv4wTAYJKoZIhvcNAQcBMB0GCWCGSAFlAwQBKgQQjmYztOm64boC
6XqnH/9kMYAgYTQlOXeGex7qMH5S5gVRKy/JOhfIdqlabP9e/N/6iRk=
-----END CMS-----

ciekawostka: przy tym mechaniźmie szyfrowania (Cryptographic Message Syntax), uruchomienie tego polecenia, da za każdym razem inny wynik

jeśli ktoś nie pamięta nazwy certu, to można je szybko wylistować:

C:\_scriptz :))o- ls Cert:\LocalMachine\My\

PSParentPath: Microsoft.PowerShell.Security\Certificate::LocalMachine\My

Thumbprint Subject
---------- -------
B643393E34BDC10BE2266F307F63A712578416F1 CN=script automation

wkleić sobie ten ciąg do skryptu i kiedy potrzebne jest hasło, użyć unprotect-CMSMessage… np. tak:

[string]$encPass="-----BEGIN CMS----- 
MIIBtAYJKoZIhvcNAQcDoIIBpTCCAaECAQAxggFMMIIBSAIBADAwMBwxGjAYBgNVBAMMEXNjcmlw
dCBhdXRvbWF0aW9uAhB2BdZafxn2nkcWjQNvBi+pMA0GCSqGSIb3DQEBBzAABIIBAFY0TU8LAPl4
4IAWp9tGDOZK/bAEwQpZPix5dz8AoOpaDckpZ8dqatggEKq7vo4VX6wL4sBkImQoZrCZjkT7oCSi
N1YFDOIghjD7e2mIvEl/Gq2bReUbsFQ0iw6Wg1K8zVLBzDQAIUHgJvaV2Ssf4nCJ8WfW5d9UzmBB
JpHAmy7cdofboQt6hRz0wafGNivD47z2xNSSVyhqHVUzBt6NWmAYouFMGYGsBZBuOHXnQJzh+saG
Q5VpWxZGRHzM3igl8IgYDF75R+b/FSfciPu5Se2eeD6xxHrPj9rfLm37KVIab/4+QA/lRFVt4cwA
ztjhh8puyPqYgKCE7t0Tp5VNQv4wTAYJKoZIhvcNAQcBMB0GCWCGSAFlAwQBKgQQjmYztOm64boC
6XqnH/9kMYAgYTQlOXeGex7qMH5S5gVRKy/JOhfIdqlabP9e/N/6iRk=
-----END CMS-----"

try {
  $cert = Get-ChildItem Cert:\LocalMachine\My\B643393E34BDC10BE2266F307F63A712578416F1 -ErrorAction stop
} catch {
  write-log "missing authentication certificate. quitting" -type error
  retrun $null
}
  if(-not $cert.HasPrivateKey) {
  write-log "missing decription factor. quitting." -type error
  return $null
}
  $certCN = $cert.Subject
try {
  [securestring]$secStringPassword = ConvertTo-SecureString (Unprotect-CmsMessage -Content $encPass -To $certCN -ErrorAction stop) -AsPlainText -Force
} catch {
  write-log "error decrypting auth password $($_.exception)" -type error
  return $null
}
[pscredential]$creds = New-Object System.Management.Automation.PSCredential ('CORP\SVC-contactCreator', $secStringPassword)

inne scenariusze i metody

jakiś czas temu linkowałem dostęp do EXO certyfikatem. wygodne.

a podczas uruchamiania własnych skryptów, i tam gdzie wystarczą zwykłe credsy, nie chce mi się wpisywać hasła. zapisuję więc je sobie do pliq:

if(test-path $credsFile) {
    $myCreds = Import-CliXml -Path $credsFile
    write-log "used saved credentials in $credsFile" -type info
} else {
    $myCreds=Get-Credential
    if($NULL -eq $myCreds) {
        write-log 'Cancelled.' -type error
        exit -3
    }
    if($saveCredentials) {
        $myCreds | Export-Clixml -Path $credsFile
        write-log "credentials saved as $credsFile" -type info
    }
}

get-Credential używa DPAPI i w 'zamkniętym’ środowisq (np. na własnym lapq) w zupełności wystarcza. z tego co kojarzę szyfrowane jest kluczem użytkownika i komputera więc taki plik, aby był odczytany, musi być w tym samym środowisq. mimo wszystko w 'otwartym’ środowisq raczej nie należy stosować takich metod.

eN.