Skip to content

Instantly share code, notes, and snippets.

What would you like to do?
Audit changed and deleted files on Server 2008 R2, 2012, and 2012 R2
# Version 1.2 :: This script reads offline event logs, oldest to newest, bottom to top.
# See for instructions.
$LogPath = "C:\Event_Logs\"
$ReportPath = "C:\Audit\File-Audit-Reports\"
$Formatted_Date = (Get-Date -UFormat %A-%B-%d-at-%I-%M-%S%p)
$ZipName = "Security-Events-for-" + (Get-Date -UFormat %A-%B-%d) + ".zip"
$Report_in_CSV = $ReportPath + "Audit of changed files on " + $Formatted_Date + ".csv"
$Truncated_Log_Path = $LogPath + "Archive-Security_on_" + $Formatted_Date + ".evtx"
$Today_Midnight = (Get-Date -Hour 0 -Minute 0 -Second 0)
$Total_Events = 0
$Start_Time = Get-Date
$Exclude_Windows_Directory = $TRUE
# Hashtable
# Dynamically expanding array
# Files to purge from the Pending_Delete hashtable
# This allows the Try...Catch error handling to work.
$ErrorActionPreference = Stop
# This function decides if the object is a file or a folder
Function Is_a_File
# If the path still exists, we can know for certain if it's a file or folder
Try {If ((Get-Item $Event.ObjectName) -is [System.IO.FileInfo]) {Return $True}}
# If the path is gone, we'll assume that it's a file if it contains a period.
Catch {If ($Event.ObjectName -like "*.*") {Return $True}}
# This function returns just the filename from a full path.
Function Get-FileName ($fullpath)
{If ($fullpath -ne $null) {Return $fullpath.Split('\')[-1]}}
# This function returns just the foldername from a full path.
Function Get-FolderName ($fullpath)
{If ($fullpath -ne $null) {Return $fullpath.Substring(0,$fullpath.LastIndexOf("\"))}}
# This function checks to see if the file should be ignored.
Function Is_Temporary
$fullpath = $Event.ObjectName
# A list of file extensions to ignore
$Temp_Files = "tmp","rgt","mta","tlg",".nd",".ps","log",":Zone.Identifier","crdownload",".DS_Store",":AFP_AfpInfo",":AFP_Resource"
# Check that the full path is long enough to be applicable, then ask if the file's last few characters match a temporary extension.
$Temp_Files | ForEach {If ($fullpath.length -ge $_.length -AND $fullpath.substring($fullpath.length - $_.length, $_.length) -eq $_) {Return $True}}
If ((Get-FileName $fullpath).substring(0,1) -eq "~") {Return $True}
If ((Get-FileName $fullpath) -eq "thumbs.db") {Return $True}
Return $False
# This function empties out the Pending_Delete array, deciding whether the file was created/modified or deleted.
Function CleanUp ($Seconds2Wait)
ForEach ($Key in $Pending_Delete.Keys)
# Calculate the number of seconds
$TimeSpan = -((New-TimeSpan -Start $Event.TimeCreated -End $Pending_Delete[$Key].TimeCreated).TotalSeconds)
If ($Pending_Delete[$Key].Alive)
Audit_Report_Add $Pending_Delete[$Key].User "created/modified" $Key "" $Pending_Delete[$Key].TimeCreated "Single 4663 event"
# Mark the item for removal from the list of deleted items
# Conclude that the object was deleted if it hasn't been referenced in the past $Seconds2Wait
# I was using the .Confirmed tag for this, but PDF printers were triggering false positives.
ElseIf ($TimeSpan -ge $Seconds2Wait)
Audit_Report_Add $Pending_Delete[$Key].User "deleted" $Key "" $Pending_Delete[$Key].TimeCreated "Aged out"
# Mark the item for removal from the list of deleted items
# You can't remove items from a hashtable and then continue enumerating it, so this is a work-around.
ForEach ($Item in $MyGarbage) {$Pending_Delete.Remove($Item)}
# Clear this temporary array now that I'm done with it.
# This is not expected to happen, but if it does - I'll know about it!
If ($Seconds2Wait -eq 0 -AND $Pending_Delete.Count -ge 1)
{$Audit_Report.Add("Orphaned objects")
$Pending_Delete.Keys | ForEach-Object {$Audit_Report.Add($_)}}
# This function checks for duplicate lines when writing audit lines to the report.
# A default value of " " is assigned to each parameter in case no value was specified when the function was called.
Function Audit_Report_Add ($User1 = " ",$Action1 = " ",$Source1 = " ",$Destination1 = " ",$Time1 = " ",$DebugNotes1 = " ")
# Attempt to show the person's full name instead of just their username
$User1 = Try{[string](Get-ADUser $User1).Name} Catch {$User1}
# The current line that we'd like to write into the report.
$NewLine = $User1 + "," + $Action1 + ",`"" + $Source1 + "`""
# The last line that was written into the report.
# On Server 2008 R2 w/ .NET 3.5, I wasn't able to reference the last item in a System.Collections.ArrayList via [-1], so used this way instead.
$LastLine = Try{$Audit_Report[$Audit_Report.Count-1].Substring(0,$NewLine.Length)} Catch {" "}
# If the new line isn't a duplicate, then put it in the report.
If ($LastLine -ne $NewLine) {$Audit_Report.Add($User1 + "," + $Action1 + ",`"" + $Source1 + "`",`"" + $Destination1 + "`"," + $Time1 + "," + $DebugNotes1)}
# This function increases the number of logs you can save by compressing them to save disk space
Function Compress_Logs
# You'll need the command-line version of 7-zip ( in the $LogPath directory
Set-Location $LogPath
Start-Process -FilePath "7za.exe" -ArgumentList "a $ZipName Archive-Security*" -Wait
# Manually trigger .NET garbage collection to close open file handles so security logs can be deleted after compression.
Sleep 60
# Delete the originals now that they're zipped.
Get-ChildItem ($LogPath + "Archive-Security*") | Remove-Item
"$Start_Time Unable to zip/delete the logs. Maybe you need to put `"7za.exe`" in $LogPath" | Out-File ($LogPath + "A WARNING.txt") -Append
# This optional function deletes old logs so they don't accumulate forever
Function Prune_Logs ($Days2Retain)
# Gather all the zip files older than the specified number of days and delete them.
Get-ChildItem *.zip | ? {$_.LastWriteTime -lt (Get-Date).AddDays(-$Days2Retain)} | Remove-Item
# Backup and clear the event log
$Security_log = get-wmiobject win32_nteventlogfile -filter "logfilename = 'Security'"
Clear-Eventlog "Security"
# Collect all the event logs from today and order them oldest to newest.
$Event_Logs = Get-ChildItem ($LogPath + "*.evtx") | ?{$_.LastWriteTime -ge $Today_Midnight} | Sort LastWriteTime
ForEach ($Log in $Event_Logs)
# Define what we want to pull out of the event log
$MyFilter = @{Path=($Log).FullName;ID=4656,4659,4660,4663}
# Retrieve events; if none are found, immediately go to the next log file.
Try {$Events = Get-WinEvent -FilterHashTable $MyFilter -Oldest} Catch {"No events were found in $Log"; Continue}
ForEach ($Raw_Event in $Events)
# Count how many events are processed and display it in the HTML report
# Convert the event into an XML object
# Thanks to
Try{$EventXML = [xml]$Raw_Event.ToXML()} Catch {Audit_Report_Add "Unable to convert an event to XML"}
# Loop through the XML values and turn them into a hashtable for easy access.
$Event = @{}
ForEach ($object in $EventXML.Event.EventData.Data) {$Event.Add($,$object.'#text')}
# Check if we're supposed to ignore changes in the C:\Windows directory (e.g. Windows Updates).
If ($Exclude_Windows_Directory)
{If ($Event.ObjectName.Length -ge 10 -and $Event.ObjectName.Substring(0,10) -eq "C:\Windows") {Continue}}
If ($Event.ID -eq "4656" -AND # Event 4656 = a handle was requested.
-NOT (Is_Temporary) -AND # Exclude temporary files.
$Pending_Delete.ContainsKey($Event.ObjectName)) # It's common for a "Delete" event to be logged right before a file is created/saved.
# The file was not deleted, so mark it as created/modified.
{If ($Pending_Delete.ContainsKey($Event.ObjectName)) {$Pending_Delete[$Event.ObjectName].Alive = $TRUE}}
ElseIf ($Event.ID -eq "4663" -AND # Event 4663 = object access.
$Event.AccessMask -eq "0x10000" -AND # 0x10000 = Delete, but this can mean different things - delete, overwrite, rename, move.
-NOT (Is_Temporary)) # Exclude temporary files
# If I've already logged a "Delete" for this file...and am seeing it again...
# ...the file wasn't actually deleted, so mark it as created/modified.
If ($Pending_Delete.ContainsKey($Event.ObjectName))
# Exclude folders
If (Is_a_File) {Audit_Report_Add $Event.SubjectUsername "created/modified" $Event.ObjectName "" $Event.TimeCreated "Twin 4663 events"}
# The file/folder was not actually deleted, so remove it from this array.
# Now record the filename, username, handle ID, and time.
$Pending_Delete.Add($Event.ObjectName,@{User = $Event.SubjectUsername; HandleID = $Event.HandleId; TimeCreated = $Event.TimeCreated; Alive = $FALSE; Confirmed = $FALSE})
ElseIf ($Event.ID -eq "4663" -AND # Event 4663 = object access.
$Event.AccessMask -eq "0x2" -AND # 0x2 = is a classic "object was modified" signal.
-NOT (Is_Temporary) -AND # Exclude temporary files.
(Is_a_File)) # Exclude folders
Audit_Report_Add $Event.SubjectUsername "created/modified" $Event.ObjectName "" $Event.TimeCreated "0x2 AccessMask"
# The file was not actually deleted, so remove it from this array.
ElseIf ($Event.ID -eq "4663" -AND
$Event.AccessMask -eq "0x80") # A 4663 event with 0x80 (Read Attributes) is logged
{ # with the same handle ID when files/folders are moved or renamed.
ForEach ($Key in $Pending_Delete.Keys)
# If the Handle & User match...and the object wasn't deleted...figure out whether it was moved or renamed.
If ($Pending_Delete[$Key].HandleID -eq $Event.HandleID -AND
$Pending_Delete[$Key].User -eq $Event.SubjectUsername -AND
-NOT $Pending_Delete[$Key].Confirmed -AND
-NOT (Is_Temporary))
If ((Get-FileName $Event.ObjectName) -ceq (Get-FileName $Key) -AND -NOT $Pending_Delete[$Key].InvalidHandleID)
{Audit_Report_Add $Event.SubjectUsername "moved" $Key $Event.ObjectName $Event.TimeCreated
ElseIf ((Get-FolderName $Event.ObjectName) -ceq (Get-FolderName $Key))
If ((Get-Filename $Key) -eq "New Folder")
{Audit_Report_Add $Event.SubjectUsername "created" $Event.ObjectName "" $Event.TimeCreated}
{Audit_Report_Add $Event.SubjectUsername "renamed" $Key $Event.ObjectName $Event.TimeCreated}
# If none of those conditions match, at least note that the file still exists (if applicable).
If ($Pending_Delete.ContainsKey($Event.ObjectName)) {$Pending_Delete[$Event.ObjectName].Alive = $TRUE}
ElseIf ($Event.ID -eq "4659" -AND # Event 4659 = a handle was requested with intent to delete
-NOT (Is_Temporary)) # Exclude temporary files
# If you use Windows File Explorer on Server 2012 R2 to delete a file: event 4659 is logged on the destination file server.
# If you use a command prompt on Server 2012 R2 to delete a file: event 4663 is logged on the destination file server.
Audit_Report_Add $Event.SubjectUsername "deleted" $Event.ObjectName "" $Event.TimeCreated "Event 4659"
# This delete confirmation doesn't happen when objects are moved/renamed; it does when files are created/deleted.
ElseIf ($Event.ID -eq "4660")
ForEach ($Key in $Pending_Delete.Keys)
If ($Event.HandleID -eq $Pending_Delete[$Key].HandleID -AND
$Event.SubjectUserName -eq $Pending_Delete[$Key].User)
{$Pending_Delete[$Key].Confirmed = $TRUE}
CleanUp 120
# Flush out the list of deleted items (improbable in production at 11:45pm, but was an issue when testing the script).
CleanUp 0
# Output the audit results to a CSV file for manipulation in Excel
$Audit_Report | Out-File $Report_in_CSV
# Zip the event logs to reduce file size
# Delete logs older than 90 days
Prune_Logs 90
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment