Відстежуємо видалення файлів на PowerShell

Привіт, Хабр! Тема мого посту вже піднімалася тут, але мені є, що додати.

Коли наше файлове сховище разменяло третій терабайт, все частіше наш відділ став отримувати прохання з'ясувати, хто видалив важливий документ або цілу папку з документами. Нерідко це відбувається за чиїмось злим наміром. Бекапи — це добре, але країна повинна знати своїх героїв. А молоко удвічі смачніше, коли ми можемо написати його на PowerShell.

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

В процесі пошуку рішення задачі прочитав статті за авторством Deks. Вирішив взяти її за основу, але деякі моменти мене не влаштовували.
  • По-перше, час генерації звіту за чотири години на 2-терабайтном сховище, з яким одночасно працює близько 200 осіб, склало близько п'яти хвилин. І це притому, що зайвого у нас в логи не пишеться. Це менше, ніж у Deks, але більше, ніж хотелосю б, тому що...
  • По-друге, все те ж саме потрібно було реалізувати ще на двадцяти серверах, набагато менш продуктивних, ніж основний.
  • -третє, викликав запитання графік запуску генерації звітів.
  • в-четвертих, хотілося виключити себе з процесу доставки зібраної інформації кінцевим споживачам (читай: автоматизувати, щоб мені з цим питанням більше не дзвонили).
Але хід думок Deks мені сподобався...

Короткий дискурс: При включеному аудиті файлової системи в момент видалення файлу в журналі безпеки створюються дві події, з кодами 4663 і, слідом, 4660. Перше записує спробу запиту доступу на видалення, дані про користувача і шляхи до удаляемому файлу, а друге — фіксує факт вилучення. У подій унікальний ідентифікатор EventRecordID, який відрізняється на одиницю у цих двох подій.

Нижче наведено вихідний скрипт, який збирає інформацію про видалених файлів і користувачів, їх видалити.

$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

А ось це вже результат. Прискорення практично в два рази!

Автоматизуємо

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

В планувальнику завдань Windows можна створити тригер події в системному журналі. Ось так:



Відмінно! Скрипт буде запускатися рівно в момент видалення файлу, і наш журнал буде создаватья в реальному часі! Але наша радість буде неповною, якщо ми не зможемо визначити, яка подія нам потрібно записати в момент запуску. Нам потрібна хитрість. Їх є у нас! Недовгий гуглинг показав, що у відповідь на дію «Подія» планувальник може передавати виконуваного файлу інформацію про подію. Але робиться це, м'яко кажучи, неочевидно. Послідовність дій така:

  1. Створити завдання з тригером типу «Event»;
  2. Експортувати завдання у формат XML (через консоль MMC);
  3. Додати в гілку «EventTrigger» нову гілку «ValueQueries» з елементами, що описують змінні:

    <EventTrigger>
    ...
    <ValueQueries>
    <Value name="eventRecordID">Event/System/EventRecordID</Value>
    </ValueQueries>
    </EventTrigger>
    

    де «eventRecordID» — назва змінної, яку можна буде передати скрипту, а «Event/System/EventRecordID» — елемент схеми журналу Windows, з якою можна ознайомитися за посиланням внизу статті. В даному випадку це елемент з унікальним номером події.
  4. Імпортувати завдання назад в планувальник.
Але ми ж не хочемо натыкивать все це мишкою на 20 серверах, вірно? Потрібно автоматизувати. На жаль, PowerShell не всесильний, і команди New-ScheduledTaskTrigger поки що не вміє створювати тригери типу Event. Тому застосуємо чит-код і створимо завдання через COM-об'єкт (поки що досить часто доводиться вдаватися до COM, хоча штатні командлети вміють все більше і більше з кожною новою версією 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»:



$Trigger = $taskDefinition.Triggers.Create(0)
$Trigger.Subscription = '<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[Provider[@Name="Microsoft-Windows-Security-Auditing"] and EventID=4660]]
</Select>
</Query>
</QueryList>'

Головна хитрість: вказуємо змінну, яку потрібно передати скрипту.

$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:\Temp\DeletionLog.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"
)

Далі виникає нюанс: до цього моменту ми отримували події cmdlet Get-WinEvent і фільтрували параметром-FilterHashtable. Він розуміє обмежений набір атрибутів, який не входить EventRecordID. Тому фільтрувати ми будемо через параметр-FilterXml, ми ж тепер і це вміємо!

$XmlQuery="<QueryList>
<Query Id='0' Path='Security'>
<Select Path='Security'>*[System[(EventID=4663) and (EventRecordID=$($RecordID - 1))]]</Select>
</Query>
</QueryList>"
$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\\Підрозділу\\[^\\]*\\)(.*)",'$1\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
}

Підсумковий команди
Ну ось, нарізані шматочки, залишилося зібрати все докупи і ще трохи оптимізувати. Вийде якось так:

[CmdletBinding()]
Param(
[Parameter(Mandatory=$True,Position=1,ParameterSetName='logEvent')][int]$RecordID,
[Parameter(Mandatory=$False,Position=2,ParameterSetName='logEvent')]
[string]$LogPath = "$PSScriptRoot\DeletedFiles.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 = '
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[Provider[@Name="Microsoft-Windows-Security-Auditing"] and EventID=4660]]
</Select>
</Query>
</QueryList>'
$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="<QueryList>
<Query Id='0' Path='Security'>
<Select Path='Security'>*[System[(EventID=4663) and (EventRecordID=$($RecordID - 1))]]</Select>
</Query>
</QueryList>"
$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 планувальника завдань

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.