My why did we pick a month with 31 days co-conspirator for the Month of PowerShell Mick Douglas offered up a quick Bash function a while back called keeper():
keeper() { fc -ln | tail -n 1 >> ~/.keeper.txt }
I added it to my .bash_profile script, and whenever I'm particularly pleased with a Bash command that I want to save for future reference, I run keeper:
PrintExport-5x7 $ ls Crop-5x7-DSC_4745.jpg Crop-5x7-DSC_4947.jpg Crop-5x7-DSC_5043.jpg Crop-5x7-DSC_5296.jpg Crop-5x7-DSC_4791.jpg Crop-5x7-DSC_4973.jpg Crop-5x7-DSC_5187.jpg Crop-5x7-DSC_5312.jpg Crop-5x7-DSC_4830.jpg Crop-5x7-DSC_5035.jpg Crop-5x7-DSC_5217.jpg PrintExport-5x7 $ for image in *.jpg; do newimagename=${image#Crop-5x7-}; echo "Renaming $image to mv $newimagename" ; mv $image $newimagename; done Renaming Crop-5x7-DSC_4745.jpg to mv DSC_4745.jpg Renaming Crop-5x7-DSC_4791.jpg to mv DSC_4791.jpg Renaming Crop-5x7-DSC_4830.jpg to mv DSC_4830.jpg Renaming Crop-5x7-DSC_4947.jpg to mv DSC_4947.jpg Renaming Crop-5x7-DSC_4973.jpg to mv DSC_4973.jpg Renaming Crop-5x7-DSC_5035.jpg to mv DSC_5035.jpg Renaming Crop-5x7-DSC_5043.jpg to mv DSC_5043.jpg Renaming Crop-5x7-DSC_5187.jpg to mv DSC_5187.jpg Renaming Crop-5x7-DSC_5217.jpg to mv DSC_5217.jpg Renaming Crop-5x7-DSC_5296.jpg to mv DSC_5296.jpg Renaming Crop-5x7-DSC_5312.jpg to mv DSC_5312.jpg PrintExport-5x7 $ keeper PrintExport-5x7 $ tail -1 ~/.keeper.txt for image in *.jpg; do newimagename=${image#Crop-5x7-}; echo "Renaming $image to mv $newimagename" ; mv $image $newimagename; done
Forgive me for my use of Bash here during the Month of PowerShell; it is only for illustrative purposes. 🥸
In this Bash example, I used a for loop to rename a bunch of files, removing the Crop-5x7- beginning of the file name using the Bash shell parameter expansion feature {image#Crop-5x7-}. Then, because I was pretty pleased with myself, I ran keeper to save that command to ~/.keeper.txt so I could reference and reuse it later.
Let's create this for PowerShell!
We can use Get-History to see a list of commands that we ran in the current session:
PS C:\Users\Sec504> Get-History Id CommandLine -- ----------- 1 Get-WmiObject -Class Win32_Product 2 $InstalledSoftware = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion... 3 foreach($obj in $InstalledSoftware){write-host $obj.GetValue('DisplayName') -NoNewl... 4 $InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersio... 5 $InstalledSoftware = (Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersio... 6 $InstalledSoftware = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion... 7 ForEach-Object ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName'... 8 foreach ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName') -NoNe...
The parameter CommandLine is what we want, and can use Select-Object -Last 1 to retrieve the last command:
PS C:\Users\Sec504> Get-History | Select-Object -Last 1 -Property CommandLine CommandLine ----------- foreach ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName') -NoNewline...
Perfect! Now, just add the Out-File to save to ~/keeper.txt (I'm recalling the previous foreach command before running Get-History for consistency in examples):
PS C:\Users\Sec504> Get-History | Select-Object -Last 1 -ExpandProperty CommandLine | Out-File -Append ~/keeper.txt PS C:\Users\Sec504> Get-Content C:\Users\Sec504\keeper.txt foreach ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName') -NoNewline; Write-Host " - " -NoNewline; Write-Host $obj.GetValue('DisplayVersion') } PS C:\Users\Sec504>
A couple of things to point out here:
- I used ExpandProperty instead of just Property with Select-Object; this allows us to get the CommandLine by itself without the accompanying header line
- It's necessary to add -Append to the Out-File command to keep adding to the file; by default, Out-File will overwrite the specified file
Next, all that is needed is to add this as a function to the PowerShell profile:
PS C:\Users\Sec504> notepad $profile PS C:\Users\Sec504>
To keep with the Verb-Noun convention, I called this Save-Keeper:
Here is the function to copy-paste into your PowerShell profile.
Function Save-Keeper() { Get-History | Select-Object -Last 1 -ExpandProperty CommandLine | Out-File -Append ~/keeper.txt }
Then you can close and open a new PowerShell session, or reload your PowerShell profile:
PS C:\Users\Sec504> . $profile
Let's put this new function to use. I want to build a list of installed software on Windows by enumerating the software uninstall keys for HKEY_CURRENT_USER and HKEY_LOCAL_MACHINE. First, identify the keys using Get-ChildItem in the variable $InstalledSoftware:
PS C:\Users\Sec504> $InstalledSoftware = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall" PS C:\Users\Sec504> Save-Keeper PS C:\Users\Sec504>
Next, get the DisplayName and DisplayVersion elements for each key:
PS C:\Users\Sec504> foreach ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName') -NoNewline; Write-Host " - " -NoNewline; Write-Host $obj.GetValue('DisplayVersion') } 7-Zip 19.00 (x64) - 19.00 - - - - Git version 2.21.0 - 2.21.0 - - - - - Process Hacker 2.39 (r124) - 2.39.0.124 Rekall v1.6.0 Gotthard - - USBPcap 1.1.0.0-g794bf26-5 - 1.1.0.0-g794bf26-5 - Update for Windows 10 for x64-based Systems (KB4480730) - 2.55.0.0 Update for Windows 10 for x64-based Systems (KB4023057) - 2.67.0.0 OpenCL™ runtime for Intel® Core™ and Xeon® Processors - 6.4.0.25 Microsoft Visual C++ 2013 x64 Additional Runtime - 12.0.40649 - 12.0.40649 Java 8 Update 111 (64-bit) - 8.0.1110.14 PowerShell 7-x64 - 7.2.3.0 Microsoft Visual C++ 2008 Redistributable - x64 9.0.30729.6161 - 9.0.30729.6161 Java SE Development Kit 8 Update 111 (64-bit) - 8.0.1110.14 Microsoft Update Health Tools - 3.67.0.0 Microsoft Visual C++ 2019 X64 Minimum Runtime - 14.24.28127 - 14.24.28127 Microsoft Visual C++ 2019 X64 Additional Runtime - 14.24.28127 - 14.24.28127 Microsoft Visual C++ 2013 x64 Minimum Runtime - 12.0.40649 - 12.0.40649 VMware Tools - 11.0.6.15940789 PS C:\Users\Sec504> Save-Keeper
By running Save-Keeper after the commands, I can stash those commands in my keeper.txt file:
PS C:\Users\Sec504> Get-Content C:\Users\Sec504\keeper.txt ... $InstalledSoftware = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall" foreach ($obj in $InstalledSoftware){ Write-Host $obj.GetValue('DisplayName') -NoNewline; Write-Host " - " -NoNewline; Write-Host $obj.GetValue('DisplayVersion') }
This works great, and I'll be sure to use it immediately after interesting commands to populate the keeper.txt file with interesting tidbits.
Consider extending the Save-Keeper function on your own to add some more features:
- Record the date and time in the keeper.txt file
- Get a few additional lines of context before the keeper using Select-Object -Last 3
- Save the user name, computer name, and current directory for each saved command
- Accept an optional argument to Save-Keeper for a description of the command, or maybe a URL to remember where the inspiration for a command came from
If you add these (or more) features to Save-Keeper, let me know! Tag me @joswr1ght or #MonthOfPowerShell in your tweet, DM me, or email me! Thanks!
-Joshua Wright
Return to Getting Started With PowerShell
Joshua Wright is the author of SANS SEC504: Hacker Tools, Techniques, and Incident Handling, a faculty fellow for the SANS Institute, and a senior technical director at Counter Hack.