Отслеживаем удаление файлов на PowerShell

Когда наше файловое хранилище разменяло 3-ий терабайт, все почаще наш отдел стал получать просьбы узнать, кто удалил принципиальный документ либо целую папку с документами. Часто это происходит по чьему-то злому умыслу. Бэкапы — это отлично, но страна должна знать собственных героев. А молоко вдвойне вкусней, когда мы можем написать его на PowerShell.

Пока разбирался, решил записать для коллег по цеху, а позже пошевелил мозгами, что может понадобиться кому-то еще. Материал вышел смешанный. Кто-то отыщет себе готовое решение, кому-то понадобятся несколько неочевидные способы работы с PowerShell либо планировщиком задач, а кто-то проверит на быстродействие свои скрипты.

В процессе поиска решения задачки прочел статью за авторством Deks. Решил взять ее за базу, но некие моменты меня не устраивали.
Во-1-х, время генерации отчета за четыре часа на 2-терабайтном хранилище, с которым сразу работает около 200 человек, составило около 5 минут. И это притом, что излишнего у нас в логи не пишется. Это меньше, чем у Deks, но больше, чем хотелосю бы, так как…
Во-2-х, все то же самое необходимо было воплотить еще на 20 серверах, еще наименее производительных, чем основной.
В-3-х, вызывал вопросы график пуска генерации отчетов.
И в-4-х, хотелось исключить себя из процесса доставки собранной инфы конечным потребителям (читай: заавтоматизировать, чтоб мне с этим вопросом больше не звонили).

Но ход мыслей Deks мне приглянулся…

Лаконичный дискурс: При включенном аудите файловой системы в момент удаления файла в журнальчике безопасности создаются два действия, с кодами 4663 и, следом, 4660. 1-ое записывает попытку запроса доступа на удаление, данные о юзере и пути к удаляемому файлу, а 2-ое — фиксирует свершившийся факт удаления. У событий есть уникальный идентификатор EventRecordID, который отличается на единицу у этих 2-ух событий.

Ниже приведен начальный скрипт, собирающий информацию об удаленных файлах и юзерах, их удаливших.

$time = (get-date) – (new-timespan -min 240)
$Events = Get-WinEvent -FilterHashtable @{LogName=»Security»;ID=4660;StartTime=$time} | Select TimeCreated,@{n=»Запись»;e={([xml]$_.ToXml()).Event.System.EventRecordID}} |sort Запись
$BodyL = «»
$TimeSpan = new-TimeSpan -sec 1
foreach($event in $events){
$PrevEvent = $Event.Запись
$PrevEvent = $PrevEvent – 1
$TimeEvent = $Event.TimeCreated
$TimeEventEnd = $TimeEvent+$TimeSpan
$TimeEventStart = $TimeEvent- (new-timespan -sec 1)
$Body = Get-WinEvent -FilterHashtable @{LogName=»Security»;ID=4663;StartTime=$TimeEventStart;EndTime=$TimeEventEnd} |where {([xml]$_.ToXml()).Event.System.EventRecordID -match «$PrevEvent»}|where{ ([xml]$_.ToXml()).Event.EventData.Data |where {$_.name -eq «ObjectName»}|where {($_.’#text’) -notmatch «.*tmp»} |where {($_.’#text’) -notmatch «.*~lock*»}|where {($_.’#text’) -notmatch «.*~$*»}} |select TimeCreated, @{n=»Файл_»;e={([xml]$_.ToXml()).Event.EventData.Data | ? {$_.Name -eq «ObjectName»} | %{$_.’#text’}}},@{n=»Пользователь_»;e={([xml]$_.ToXml()).Event.EventData.Data | ? {$_.Name -eq «SubjectUserName»} | %{$_.’#text’}}}
if ($Body -match «.*Secret*»){
$BodyL=$BodyL+$Body.TimeCreated+»`t»+$Body.Файл_+»`t»+$Body.Пользователь_+»`n»
}
}
$Month = $Time.Month
$Year = $Time.Year
$name = «DeletedFiles-»+$Month+»-»+$Year+».txt»
$Outfile = «serverServerLogFilesDeletedFilesLog»+$name
$BodyL | out-file $Outfile -append
При помощи команды Measure-Command получили последующее:

Measure-Command {

} | Select-Object TotalSeconds | Format-List

TotalSeconds : 313,6251476
Много, на вторичных ФС будет подольше. Слету очень не приглянулся десятиэтажный пайп, потому для начала я его структурировал:

Get-WinEvent -FilterHashtable @{
LogName=»Security»;ID=4663;StartTime=$TimeEventStart;EndTime=$TimeEventEnd
} `
| Where-Object {([xml]$_.ToXml()).Event.System.EventRecordID -match «$PrevEvent»} `
| Where-Object {([xml]$_.ToXml()).Event.EventData.Data `
| Where-Object {$_.name -eq «ObjectName»} `
| Where-Object {($_.’#text’) -notmatch «.*tmp»} `
| Where-Object {($_.’#text’) -notmatch «.*~lock*»} `
| Where-Object {($_.’#text’) -notmatch «.*~$*»}
}
| Select-Object TimeCreated,
@{
n=»Файл_»;
e={([xml]$_.ToXml()).Event.EventData.Data `
| Where-Object {$_.Name -eq «ObjectName»} `
| ForEach-Object {$_.’#text’}
}
},
@{
n=»Пользователь_»;
e={([xml]$_.ToXml()).Event.EventData.Data `
| Where-Object {$_.Name -eq «SubjectUserName»} `
| ForEach-Object {$_.’#text’}
}
}
Вышло уменьшить этажность пайпа и убрать перечисления Foreach, а заодно сделать код более читаемым, но огромного эффекта это не отдало, разница в границах погрешности:

Measure-Command {
$time = (Get-Date) – (New-TimeSpan -min 240)
$Events = Get-WinEvent -FilterHashtable @{LogName=»Security»;ID=4660;StartTime=$time}`
| Select TimeCreated,@{n=»EventID»;e={([xml]$_.ToXml()).Event.System.EventRecordID}}`
| Sort-Object EventID
$DeletedFiles = @()
$TimeSpan = new-TimeSpan -sec 1
foreach($Event in $Events){
$PrevEvent = $Event.EventID
$PrevEvent = $PrevEvent – 1
$TimeEvent = $Event.TimeCreated
$TimeEventEnd = $TimeEvent+$TimeSpan
$TimeEventStart = $TimeEvent- (New-TimeSpan -sec 1)
$DeletedFiles += Get-WinEvent -FilterHashtable @{LogName=»Security»;ID=4663;StartTime=$TimeEventStart;EndTime=$TimeEventEnd} `
| Where-Object {`
([xml]$_.ToXml()).Event.System.EventRecordID -match «$PrevEvent» `
-and (([xml]$_.ToXml()).Event.EventData.Data `
| where {$_.name -eq «ObjectName»}).’#text’ `
-notmatch «.*tmp$|.*~lock$|.*~$*»
} `
| Select-Object TimeCreated,
@{n=»FilePath»;e={
(([xml]$_.ToXml()).Event.EventData.Data `
| Where-Object {$_.Name -eq «ObjectName»}).’#text’
}
},
@{n=»UserName»;e={
(([xml]$_.ToXml()).Event.EventData.Data `
| Where-Object {$_.Name -eq «SubjectUserName»}).’#text’
}
} `
}
} | Select-Object TotalSeconds | Format-List
$DeletedFiles | Format-Table UserName,FilePath -AutoSize

TotalSeconds : 302,6915627
Пришлось незначительно помыслить головой. Какие операции занимают больше всего времени? Можно было бы натыкать еще десяток Measure-Command, но в общем-то в этом случае и так разумеется, что больше всего времени тратится на запросы в журнальчик (это не самая стремительная процедура даже в MMC) и на повторяющиеся конвертации в XML (к тому же, в случае с EventRecordID это и совсем необязательно). Попробуем сделать и то и это по одному разу, а заодно исключить промежные переменные:

Measure-Command {
$time = (Get-Date) – (New-TimeSpan -min 240)
$Events = Get-WinEvent -FilterHashtable @{LogName=»Security»;ID=4660,4663;StartTime=$time}`
| Select TimeCreated,ID,RecordID,@{n=»EventXML»;e={([xml]$_.ToXml()).Event.EventData.Data}}`
| Sort-Object RecordID
$DeletedFiles = @()
foreach($Event in ($Events | Where-Object {$_.Id -EQ 4660})){
$DeletedFiles += $Events `
| Where-Object {`
$_.Id -eq 4663 `
-and $_.RecordID -eq ($Event.RecordID – 1) `
-and ($_.EventXML | where Name -eq «ObjectName»).’#text’`
-notmatch «.*tmp$|.*~lock$|.*~$»
} `
| Select-Object `
@{n=»RecordID»;e={$Event.RecordID}}, TimeCreated,
@{n=»ObjectName»;e={($_.EventXML | where Name -eq «ObjectName»).’#text’}},
@{n=»UserName»;e={($_.EventXML | where Name -eq «SubjectUserName»).’#text’}}
}
} | Select-Object TotalSeconds | Format-List
$DeletedFiles | Sort-Object UserName,TimeDeleted | Format-Table -AutoSize -HideTableHeaders

TotalSeconds : 167,7099384
А вот это уже итог. Ускорение фактически вдвое!

Автоматизируем

Порадовались, и хватит. Три минутки — это лучше, чем 5, но как идеальнее всего запускать скрипт? Раз в час? Так могут улизнуть записи, которые возникают сразу с пуском скрипта. Делать запрос не за час, а за 65 минут? Тогда записи могут повторяться. Ну и находить позже запись о подходящем файле посреди тыщи логов — мутор. Писать раз в день? Ротация логов забудет половину. Необходимо что-то более надежное. В комментах к статье Deks кто-то гласил о приложении на дотнете, работающем в режиме службы, но это, понимаете, из разряда «There are 14 competing standards»…

В планировщике заданий Windows можно сделать триггер на событие в системном журнальчике. Вот так:

Отслеживаем удаление файлов на PowerShell
Прирастить

Отлично! Скрипт будет запускаться ровно в момент удаления файла, и наш журнальчик будет создаватья в реальном времени! Но наша удовлетворенность будет неполной, если мы не сможем найти, какое событие нам необходимо записать в момент пуска. Нам нужна хитрость. Их есть у нас! Недолгий гуглинг показал, что по триггеру «Событие» планировщик может передавать исполняемому файлу информацию о событии. Но делается это, мягко говоря, неочевидно. Последовательность действий такая:

Сделать задачку с триггером типа «Event»;
Экспортировать задачку в формат XML (через консоль MMC);
Добавить в ветку «EventTrigger» новейшую ветвь «ValueQueries» с элементами, описывающими переменные:


Event/System/EventRecordID

где «eventRecordID» — заглавие переменной, которую можно будет передать скрипту, а «Event/System/EventRecordID» — элемент схемы журнальчика Windows, с которой можно ознакомиться по ссылке понизу статьи. В этом случае это элемент с уникальным номером действия.
Импортировать задание назад в планировщик.

Но мы же не желаем натыкивать все это мышкой на 20 серверах, правильно? Необходимо заавтоматизировать. К огорчению, PowerShell не всесилен, и командлет New-ScheduledTaskTrigger пока не умеет создавать триггеры типа Event. Потому применим чит-код и сделаем задачку через COM-объект (пока довольно нередко приходится прибегать к COM, хотя штатные командлеты могут все в большей и большей степени c каждой новейшей версией PS):

$scheduler = New-Object -ComObject «Schedule.Service»
$scheduler.Connect(«localhost»)
$rootFolder = $scheduler.GetFolder(«»)
$taskDefinition = $scheduler.NewTask(0)
Необходимо непременно разрешить одновременный пуск нескольких экземпляров, также, как мне кажется, стоит запретить ручной пуск и задать предел времени выполнения:

$taskDefinition.Settings.Enabled = $True
$taskDefinition.Settings.Hidden = $False
$taskDefinition.Principal.RunLevel = 0 # 0 – обыденные привилегии, 1 – завышенные привилегии
$taskDefinition.Settings.MultipleInstances = $True
$taskDefinition.Settings.AllowDemandStart = $False
$taskDefinition.Settings.ExecutionTimeLimit = «PT5M»
Сделаем триггер типа 0 (Event). Дальше задаем XML-запрос для получения подходящих нам событий. Код XML-запроса можно получить в консоли MMC «Журнал событий», выбрав нужные характеристики и переключившись на вкладку «XML»:

Отслеживаем удаление файлов на PowerShell
Прирастить

$Trigger = $taskDefinition.Triggers.Create(0)
$Trigger.Subscription = ‘
*[System[Provider[@Name="Microsoft-Windows-Security-Auditing"] and EventID=4660]]

Основная хитрость: указываем переменную, которую необходимо передать скрипту.

$Trigger.ValueQueries.Create(«eventRecordID», «Event/System/EventRecordID»)
Фактически, описание выполняемой команды:

$Action = $taskDefinition.Actions.Create(0)
$Action.Path = ‘PowerShell.exe’
$Action.WorkingDirectory = ‘C:Temp’
$Action.Arguments = ‘.ParseDeleted.ps1 $(eventRecordID) C:TempDeletionLog.log’
И — взлетаем!

$rootFolder.RegisterTaskDefinition(«Log Deleted Files», $taskDefinition, 6, ‘SYSTEM’, $null, 5)

«Концепция поменялась»

Вернемся к скрипту для записи логов. Сейчас нам не нужно получать все действия, а необходимо доставать одно-единственное, да еще переданное в качестве аргумента. Для этого мы допишем заглавия, превращающие скрипт в командлет с параметрами. До кучи — создадим вероятным изменять путь к логу «на лету», авось, понадобится:

[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,Position=1)]$RecordID,
[Parameter(Mandatory=$False,Position=2)]$LogPath = «C:DeletedFiles.log»
)
Далее появляется аспект: до этого момента мы получали действия командлетом Get-WinEvent и фильтровали параметром -FilterHashtable. Он осознает ограниченный набор атрибутов, в который не заходит EventRecordID. Потому фильтровать мы будем через параметр -FilterXml, мы же сейчас и это умеем!

$XmlQuery=»
*[System[(EventID=4663) and (EventRecordID=$($RecordID - 1))]]
»
$Event = Get-WinEvent -FilterXml $XmlQuery `
| Select TimeCreated,ID,RecordID,@{n=»EventXML»;e={([xml]$_.ToXml()).Event.EventData.Data}}`
Сейчас нам больше не надо перечисление Foreach-Object, так как обрабатывается всего одно событие. Не два, так как событие с кодом 4660 употребляется только для того, чтоб инициировать скрипт, полезной инфы оно внутри себя не несет.
Помните, сначала я желал, чтоб юзеры могли без моего роли узнатьзлодея? Итак вот, в случае, если файл удален в папке документов какого-нибудь отдела — пишем лог также в корень папки отдела.

$EventLine = «»
if (($Event.EventXML | where Name -eq «ObjectName»).’#text’ -notmatch «.*tmp$|.*~lock$|.*~$»){
$EventLine += «$($Event.TimeCreated)`t»
$EventLine += «$($Event.RecordID)`t»
$EventLine += ($Event.EventXML | where Name -eq «SubjectUserName»).’#text’ + «`t»
$EventLine += ($ObjectName = ($Event.EventXML | where Name -eq «ObjectName»).’#text’)
if ($ObjectName -match «DocumentsПодразделения»){
$OULogPath = $ObjectName `
-replace «(.*Documents\Подразделения\[^\]*\)(.*)»,’$1DeletedFiles.log’
if (!(Test-Path $OULogPath)){
«DeletionDate`tEventID`tUserName`tObjectPath»| Out-File -FilePath $OULogPath
}
$EventLine | Out-File -FilePath $OULogPath -Append
}
if (!(Test-Path $LogPath)){
«DeletionDate`tEventID`tUserName`tObjectPath» | Out-File -FilePath $LogPath }
$EventLine | Out-File -FilePath $LogPath -Append
}

Итоговый командлет

Ну вот, куски нарезаны, осталось собрать все воедино и еще немножко улучшить. Получится как-то так:

[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,Position=1,ParameterSetName='logEvent')][int]$RecordID,
[Parameter(Mandatory=$False,Position=2,ParameterSetName='logEvent')]
[string]$LogPath = «$PSScriptRootDeletedFiles.log»,
[Parameter(ParameterSetName='install')][switch]$Install
)
if ($Install) {
$service = New-Object -ComObject «Schedule.Service»
$service.Connect(«localhost»)
$rootFolder = $service.GetFolder(«»)
$taskDefinition = $service.NewTask(0)
$taskDefinition.Settings.Enabled = $True
$taskDefinition.Settings.Hidden = $False
$taskDefinition.Settings.MultipleInstances = $True
$taskDefinition.Settings.AllowDemandStart = $False
$taskDefinition.Settings.ExecutionTimeLimit = «PT5M»
$taskDefinition.Principal.RunLevel = 0
$trigger = $taskDefinition.Triggers.Create(0)
$trigger.Subscription = ‘
*[System[Provider[@Name="Microsoft-Windows-Security-Auditing"] and EventID=4660]]

$trigger.ValueQueries.Create(«eventRecordID», «Event/System/EventRecordID»)
$Action = $taskDefinition.Actions.Create(0)
$Action.Path = ‘PowerShell.exe’
$Action.WorkingDirectory = $PSScriptRoot
$Action.Arguments = ‘.’ + $MyInvocation.MyCommand.Name + ‘ $(eventRecordID) ‘ + $LogPath
$rootFolder.RegisterTaskDefinition(«Log Deleted Files», $taskDefinition, 6, ‘SYSTEM’, $null, 5)
} else {
$XmlQuery=»
*[System[(EventID=4663) and (EventRecordID=$($RecordID - 1))]]
»
$Event = Get-WinEvent -FilterXml $XmlQuery `
| Select TimeCreated,ID,RecordID,@{n=»EventXML»;e={([xml]$_.ToXml()).Event.EventData.Data}}`
if (($ObjectName = ($Event.EventXML | where Name -eq «ObjectName»).’#text’) `
-notmatch «.*tmp$|.*~lock$|.*~$»){
$EventLine = «$($Event.TimeCreated)`t» + «$($Event.RecordID)`t» `
+ ($Event.EventXML | where Name -eq «SubjectUserName»).’#text’ + «`t» `
+ $ObjectName
if ($ObjectName -match «.*Documents\Подразделения\[^\]*\»){
$OULogPath = $Matches[0] + ‘DeletedFiles.log’
if (!(Test-Path $OULogPath)){
«DeletionDate`tEventID`tUserName`tObjectPath»| Out-File -FilePath $OULogPath
}
$EventLine | Out-File -FilePath $OULogPath -Append
}
if (!(Test-Path $LogPath)){
«DeletionDate`tEventID`tUserName`tObjectPath» | Out-File -FilePath $LogPath }
$EventLine | Out-File -FilePath $LogPath -Append
}
}
Осталось поместить скрипт в комфортное вам место и запустить с ключом -Install.

Сейчас сотрудники хоть какого отдела могут в реальном времени созидать, кто, что и когда удалил из их каталогов. Отмечу, что я не стал рассматривать тут права доступа к файлам логов (чтоб злодей не мог их удалить) и ротацию. Структура и права доступа к каталогам на нашем файлере тянут на отдельную статью, а ротация в некий степени усложнит поиск подходящей строчки.

Использованные материалы:

— Прекраснейший справочник по постоянным выражениям
— Туториал по созданию задачки, привязанной к событию
— Описание скриптового API планировщика заданий

Аналогичный товар: Комментирование на данный момент запрещено, но Вы можете оставить ссылку на Ваш сайт.

Комментарии закрыты.