WebsiteFailedLogins.psm1
# initialize global variable for ini config $Global:Ini = @{} # initialize global variable for README [string]$Global:WFLReadme = "" # initialize global variable for DefaultConfig INI [string]$Global:WFLDefaultConfig = "" # initialize global variable for Standard Out Results [string[]]$Global:WFLResults = @() # require TLS1.2 for all communications [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Function Get-LogparserQuery { <# .SYNOPSIS Returns a Log parser query based on values from the configuration file. .OUTPUTS System.String #> [CmdletBinding()] [OutputType('System.String')] param( [switch] # By default, returns query for Client IP (c-ip) failed logins. This switch is used to get the total failed login count query. $TotalFailedLogins, [switch] # Includes cs-uri-stem=UrlPath and cs-method=POST $FormsAuth ) # Begin query build [string]$ReturnQuery = '"SELECT ' if($TotalFailedLogins){ $ReturnQuery += 'COUNT(*) AS Hits ' } else { $ReturnQuery += 'c-ip AS ClientIP, COUNT(*) AS Hits ' } $ReturnQuery += "FROM `'{0}`' " -f $Global:Ini.Website.LogPath $ReturnQuery += "WHERE s-sitename LIKE `'{0}`' " -f $Global:Ini.Website.Sitename $ReturnQuery += "AND TO_LOCALTIME(TO_TIMESTAMP(date,time)) >= TO_TIMESTAMP(`'{0}`',`'yyyy-MM-dd HH:mm:ss`') " -f $Global:Ini.Website.StartTimeTS $ReturnQuery += 'AND sc-status = {0} ' -f $Global:Ini.Website.HttpResponse if($FormsAuth){ $ReturnQuery += "AND cs-uri-stem LIKE `'{0}`' AND cs-Method LIKE `'POST`' " -f $Global:Ini.Website.UrlPath } if($TotalFailedLogins -eq $false){ $ReturnQuery += 'GROUP BY ClientIP HAVING Hits >= {0} ORDER BY Hits DESC"' -f $Global:Ini.Website.FailedLoginsPerIP } if($ReturnQuery.TrimEnd().EndsWith('"') -eq $false){ $ReturnQuery = $ReturnQuery.TrimEnd() + '"' } return $ReturnQuery } # End Function Get-LogparserQuery Function Confirm-IniConfig { <# .SYNOPSIS Validates settings in the configuration file. .OUTPUTS System.Boolean #> [CmdletBinding()] [OutputType('System.Boolean')] param( [parameter(Mandatory=$false)] [ValidateScript({Test-Path -LiteralPath $_})] [string] # Path to configuration file. $Path , [parameter(Mandatory=$false)] [switch] # Perform basic checks. $Brief ) $i = 0 [string[]]$ErrorMsg = @() [int[]]$MinimumChecks = 1,6,7,8,9,10,11,15,16,21,22,23,25 do{ $i++ if($Brief){ if($MinimumChecks.Contains($i) -eq $false){ do { $i++ } until($MinimumChecks.Contains($i) -eq $true -or $i -gt $MinimumChecks[-1]) } } if($ErrorMsg.Length -gt 0){ $i = 1000 } switch($i) { 1 { # BEGIN validate [INI] if([System.String]::IsNullOrEmpty($Path) -eq $false){ $Global:Ini = Get-IniConfig -FilePath $Path } if($Global:Ini.Count -eq 0){ $ErrorMsg += '[Error] No configuration file.' $i = 1000 } } # END validate [INI] 2 { # BEGIN validate [Website] Sitename if([System.String]::IsNullOrEmpty($Global:Ini.Website.Sitename) -eq $false){ if($Global:Ini.Website.Sitename -notmatch "^(?i)(w3svc)[0-9]{1,6}$"){ $ErrorMsg += '[Error][Website] Sitename not valid.' } } else { $ErrorMsg += '[Error][Website] Sitename not specified.' } } # END validate [Website] Sitename 3 { # BEGIN validate [Website] Authentication if([System.String]::IsNullOrEmpty($Global:Ini.Website.Authentication) -eq $false){ if($Global:Ini.Website.Authentication -notmatch "^(?i)(Forms|Basic|Windows)$"){ $ErrorMsg += '[Error][Website] Authentication not valid.' } } else { $ErrorMsg += '[Error][Website] Authentication not specified.' } } # END validate [Website] Authentication 4 { # BEGIN validate [Website] HttpResponse if([System.String]::IsNullOrEmpty($Global:Ini.Website.HttpResponse) -eq $false){ if($Global:Ini.Website.HttpResponse -notmatch "^[0-9]{3}$"){ $ErrorMsg += '[Error][Website] HttpResponse not valid.' } } else { $ErrorMsg += '[Error][Website] HttpResponse not specified.' } } # END validate [Website] HttpResponse 5 { # BEGIN validate [Website] UrlPath if($Global:Ini.Website.Authentication -match "^(?i)(Forms)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Website.UrlPath) -eq $false){ try{ $URI = [System.Uri]$('https://www.domain.com{0}' -f $Global:Ini.Website.UrlPath) } catch { $ErrorMsg += '[Error][Website] UrlPath not valid.' } } else { $ErrorMsg += '[Error][Website] UrlPath not specified.' } } } # END validate [Website] UrlPath 6 { # BEGIN validate [Website] LogPath if([System.String]::IsNullOrEmpty($Global:Ini.Website.LogPath) -eq $false){ try { $LogPathRef = Get-Item -LiteralPath $Global:Ini.Website.LogPath -ErrorAction Stop if($LogPathRef.PSIsContainer){ if($LogPathRef.FullName.EndsWith('\')){ $Global:Ini.Website.LogPath = '{0}*' -f $LogPathRef.FullName } else { $Global:Ini.Website.LogPath = '{0}\*' -f $LogPathRef.FullName } } else { $Global:Ini.Website.LogPath = $LogPathRef.FullName } } catch { $ErrorMsg += '[Error][Website] LogPath not valid.' } } else { $ErrorMsg += '[Error][Website] LogPath not specified.' } } # END validate [Website] LogPath 7 { # BEGIN validate [Website] FailedLoginsPerIP if([System.String]::IsNullOrEmpty($Global:Ini.Website.FailedLoginsPerIP) -eq $false){ [int]$intPerIP = 0 if([System.Int32]::TryParse($Global:Ini.Website.FailedLoginsPerIP, [ref]$intPerIP)){ if($intPerIP -gt 0){ [int]$Global:Ini.Website.FailedLoginsPerIP = $intPerIP } else { $ErrorMsg += '[Error][Website] FailedLoginsPerIP must be a positive number.' } } else { $ErrorMsg += '[Error][Website] FailedLoginsPerIP must be a positive number.' } } else { $ErrorMsg += '[Error][Website] FailedLoginsPerIP not specified.' } } # END validate [Website] FailedLoginsPerIP 8 { # BEGIN validate [Website] TotalFailedLogins if([System.String]::IsNullOrEmpty($Global:Ini.Website.TotalFailedLogins) -eq $false){ [int]$intTotal = 0 if([System.Int32]::TryParse($Global:Ini.Website.TotalFailedLogins, [ref]$intTotal)){ if($intTotal -gt 0){ [int]$Global:Ini.Website.TotalFailedLogins = $intTotal } else { $ErrorMsg += '[Error][Website] TotalFailedLogins must be a positive number.' } } else { $ErrorMsg += '[Error][Website] TotalFailedLogins must be a positive number.' } } else { $ErrorMsg += '[Error][Website] TotalFailedLogins not specified.' } } # END validate [Website] TotalFailedLogins 9 { # BEGIN validate [Website] StartTime if([System.String]::IsNullOrEmpty($Global:Ini.Website.StartTime) -eq $false){ [int]$intSeconds = 0 if([System.Int32]::TryParse($Global:Ini.Website.StartTime, [ref]$intSeconds)){ if($intSeconds -gt 0){ [int]$Global:Ini.Website.StartTime = $intSeconds $Global:Ini.Website.StartTimeTS = (Get-Date).AddSeconds($intSeconds * -1).ToString('yyyy-MM-dd HH:mm:ss') } else { $ErrorMsg += '[Error][Website] StartTime must be a positive number.' } } else { $ErrorMsg += '[Error][Website] StartTime must be a positive number.' } } else { $ErrorMsg += '[Error][Website] StartTime not specified.' } } # END validate [Website] StartTime 10 { # BEGIN validate [Logparser] Path if([System.String]::IsNullOrEmpty($Global:Ini.Logparser.Path) -eq $false){ if(Test-Path -LiteralPath $Global:Ini.Logparser.Path){ $lp = Get-Item -LiteralPath $Global:Ini.Logparser.Path if($lp.PSIsContainer){ $Global:Ini.Logparser.Path = Join-Path -Path $Global:Ini.Logparser.Path -ChildPath 'LogParser.exe' } try{ $lpExe = Get-Item -LiteralPath $Global:Ini.Logparser.Path -ErrorAction Stop if($lpExe.VersionInfo.FileVersion -ne '2.2.10.0'){ $ErrorMsg += $('[Error][Logparser] Current Microsoft (R) Log Parser Version {0}' -f $lpExe.VersionInfo.FileVersion) $ErrorMsg += '[Error][Logparser] Must be Microsoft (R) Log Parser Version 2.2.10' } } catch { $e = $_ $ErrorMsg += $('[Error][Logparser] {0}' -f $e.Exception.Message) } # test launch of logparser try{ $lpQuery = "`"SELECT FileVersion FROM `'$($Global:Ini.Logparser.Path)`'`"" [string]$lpFileVersion = & $Global:Ini.Logparser.Path -e:-1 -iw:ON -headers:OFF -q:ON -i:FS -o:CSV $lpQuery if($null -ne $lpFileVersion){ if([System.String]::IsNullOrEmpty($lpFileVersion) -eq $false){ if($lpFileVersion.Trim() -eq 'Task aborted.'){ $ErrorMsg += '[Error][Logparser] Error testing launch of Logparser.exe' } elseif($lpFileVersion.Trim().StartsWith('2.2.10') -eq $false){ $ErrorMsg += $('[Error][Logparser] Current Microsoft (R) Log Parser Version {0}' -f $lpFileVersion) $ErrorMsg += '[Error][Logparser] Must be Microsoft (R) Log Parser Version 2.2.10' } } else { $ErrorMsg += '[Error][Logparser] Error testing launch of Logparser.exe' } } else { $ErrorMsg += '[Error][Logparser] Error testing launch of Logparser.exe' } } catch { $e = $_ $ErrorMsg += '[Error][Logparser] Error testing launch of Logparser.exe' $ErrorMsg += $('[Error][Logparser] {0}' -f $e.Exception.Message) } } else { $ErrorMsg += '[Error][Logparser] Path not valid.' } } else { $ErrorMsg += '[Error][Logparser] Path not specified.' } } # END validate [Logparser] Path 11 { # BEGIN validate [Alert] Method if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -notmatch "^(?i)Smtp|WinEvent|stdout$"){ $ErrorMsg += '[Error][Alert] Method not valid.' } } else { $Global:Ini.Alert.Method = 'stdout' } } # END validate [Alert] Method 12 { # BEGIN validate [SMTP] To if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.To) -eq $false){ try { $smtpTo = [System.Net.Mail.MailAddress]::New($Global:Ini.Smtp.To) } catch { $ErrorMsg += '[Error][SMTP] TO not valid.' } } else { $ErrorMsg += '[Error][SMTP] TO not specified.' } } } } # END validate [SMTP] To 13 { # BEGIN validate [SMTP] From if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.From) -eq $false){ try { $smtpFrom = [System.Net.Mail.MailAddress]::new($Global:Ini.Smtp.From) } catch { $ErrorMsg += '[Error][SMTP] FROM not valid.' } } else { $ErrorMsg += '[Error][SMTP] FROM not specified.' } } } } # END validate [SMTP] From 14 { # BEGIN validate [SMTP] Subject if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.Subject)){ $ErrorMsg += '[Error][SMTP] SUBJECT not specified.' } else { try { $msg = [System.Net.Mail.MailMessage]::new() $msg.Subject = $Global:Ini.Smtp.Subject $msg.Dispose() Remove-Variable -Name msg } catch { $ErrorMsg += '[Error][SMTP] SUBJECT not valid.' } } } } } # END validate [SMTP] Subject 15 { # BEGIN validate [SMTP] Port if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.Port) -eq $false){ [int]$intPort = 0 if([System.Int32]::TryParse($Global:Ini.Smtp.Port, [ref]$intPort)){ if($intPort -gt 0){ [int]$Global:Ini.Smtp.Port = $intPort } else { $ErrorMsg += '[Error][SMTP] PORT must be a positive number.' } } else { $ErrorMsg += '[Error][SMTP] PORT must be a positive number.' } } else { $ErrorMsg += '[Error][SMTP] PORT not specified.' } } } } # END validate [SMTP] Port 16 { # BEGIN validate [SMTP] Server if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.Server) -eq $false){ try { $smtpServer = New-Object System.Net.Sockets.TcpClient($Global:Ini.Smtp.Server, $Global:Ini.Smtp.Port) if($smtpServer.Connected -eq $false){ $ErrorMsg += '[Error][SMTP] TCP connection failed to {0}:{1}' -f $Global:Ini.Smtp.Server,$Global:Ini.Smtp.Port } $smtpServer.Dispose() Remove-Variable -Name smtpServer } catch { $ErrorMsg += '[Error][SMTP] TCP connection failed to {0}:{1}' -f $Global:Ini.Smtp.Server,$Global:Ini.Smtp.Port } } else { $ErrorMsg += '[Error][SMTP] SERVER not specified.' } } } } # END validate [SMTP] Server 17 { # BEGIN validate [SMTP] CredentialXml if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.CredentialXml) -eq $false){ try { $credFile = Get-Item -LiteralPath $Global:Ini.Smtp.CredentialXml -ErrorAction Stop $credCheck = Import-Clixml -LiteralPath $credFile.FullName if($credCheck.GetType().Name -ne 'PSCredential'){ $ErrorMsg += '[Error][SMTP] CredentialXml import failed.' } Remove-Variable -Name credCheck,credFile } catch { $ErrorMsg += '[Error][SMTP] CredentialXml import failed.' } } } } } # END validate [SMTP] CredentialXml 18 { # BEGIN validate [WinEvent] Logname if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.WinEvent.Logname) -eq $false){ try { Get-WinEvent -LogName $Global:Ini.WinEvent.Logname -MaxEvents 1 -ErrorAction Stop | Out-Null } catch { $ErrorMsg += '[Error][WinEvent] no Logname {0}' -f $Global:Ini.WinEvent.Logname } } else { $ErrorMsg += '[Error][WinEvent] Logname not specified.' } } } } # END validate [WinEvent] Logname 19 { # BEGIN validate [WinEvent] Source if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.WinEvent.Source) -eq $false){ try{ $result = [System.Diagnostics.EventLog]::SourceExists($Global:Ini.WinEvent.Source) if ($result -eq $false){ $ErrorMsg += '[Error][WinEvent] Source does not exist.' } } catch { $e = $_ $ErrorMsg += '[Error][WinEvent] Source does not exist.' $ErrorMsg += '[Error][WinEvent] Source check exception: {0}' -f $e.Exception.Message } } else { $ErrorMsg += '[Error][WinEvent] Source not specified.' } } } } # END validate [WinEvent] Source 20 { # BEGIN validate [WinEvent] EntryType if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.WinEvent.EntryType) -eq $false){ if($Global:Ini.WinEvent.EntryType -notmatch "^(?i)(Error|FailureAudit|Information|SuccessAudit|Warning)$"){ $ErrorMsg += '[Error][WinEvent] EntryType not valid.' } } else { $ErrorMsg += '[Error][WinEvent] EntryType not specified.' } } } } # END validate [WinEvent] EntryType 21 { # BEGIN validate [WinEvent] FailedLoginsPerIPEventId if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.WinEvent.FailedLoginsPerIPEventId) -eq $false){ [int]$intFailedLoginsPerIPEventId = 0 if([System.Int32]::TryParse($Global:Ini.WinEvent.FailedLoginsPerIPEventId, [ref]$intFailedLoginsPerIPEventId)){ if($intFailedLoginsPerIPEventId -gt 0){ if($intFailedLoginsPerIPEventId -eq 100 -or $intFailedLoginsPerIPEventId -eq 200){ $ErrorMsg += $('[Error][WinEvent] FailedLoginsPerIPEventId can not be {0}.' -f $intFailedLoginsPerIPEventId) } else { [int]$Global:Ini.WinEvent.FailedLoginsPerIPEventId = $intFailedLoginsPerIPEventId } } else { $ErrorMsg += '[Error][WinEvent] FailedLoginsPerIPEventId must be a positive number.' } } else { $ErrorMsg += '[Error][WinEvent] FailedLoginsPerIPEventId must be a positive number.' } } else { $ErrorMsg += '[Error][WinEvent] FailedLoginsPerIPEventId not specified.' } } } } # END validate [WinEvent] FailedLoginsPerIPEventId 22 { # BEGIN validate [WinEvent] TotalFailedLoginsEventId if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if([System.String]::IsNullOrEmpty($Global:Ini.WinEvent.TotalFailedLoginsEventId) -eq $false){ [int]$intTotalFailedLoginsEventId = 0 if([System.Int32]::TryParse($Global:Ini.WinEvent.TotalFailedLoginsEventId, [ref]$intTotalFailedLoginsEventId)){ if($intTotalFailedLoginsEventId -gt 0){ if($intTotalFailedLoginsEventId -eq 100 -or $intTotalFailedLoginsEventId -eq 200){ $ErrorMsg += $('[Error][WinEvent] TotalFailedLoginsEventId can not be {0}.' -f $intTotalFailedLoginsEventId) } else { [int]$Global:Ini.WinEvent.TotalFailedLoginsEventId = $intTotalFailedLoginsEventId } } else { $ErrorMsg += '[Error][WinEvent] TotalFailedLoginsEventId must be a positive number.' } } else { $ErrorMsg += '[Error][WinEvent] TotalFailedLoginsEventId must be a positive number.' } } else { $ErrorMsg += '[Error][WinEvent] TotalFailedLoginsEventId not specified.' } } } } # END validate [WinEvent] TotalFailedLoginsEventId 23 { # BEGIN validate [WinEvent] Unique TotalFailedLoginsEventId & FailedLoginsPerIPEventId if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if($Global:Ini.WinEvent.TotalFailedLoginsEventId -eq $Global:Ini.WinEvent.FailedLoginsPerIPEventId){ $ErrorMsg += '[Error][WinEvent] TotalFailedLoginsEventId and FailedLoginsPerIPEventId must be different.' } } } } # END validate [WinEvent] Unique TotalFailedLoginsEventId & FailedLoginsPerIPEventId 24 { # BEGIN validate IIS Log Access $lpQuery = "SELECT TOP 1 * FROM `'$($Global:Ini.Website.LogPath)`' WHERE s-sitename LIKE `'$($Global:Ini.Website.Sitename)`'" try{ $lpOutput = & $Global:Ini.Logparser.Path -iw:ON -q:ON -i:IISW3C -o:CSV $lpQuery | ConvertFrom-Csv if($null -ne $lpOutput){ if([System.String]::IsNullOrEmpty($lpOutput.'s-sitename') -eq $false){` if($lpOutput.'s-sitename' -ne $Global:Ini.Website.Sitename){ $ErrorMsg += $('[Error][Script] Failed to return log entry for sitename {0} using Logparser.exe and path {1}' -f $Global:Ini.Website.Sitename.ToUpper(),$Global:Ini.Website.LogPath) } } else { $ErrorMsg += $('[Error][Script] Failed to return log entry for sitename {0} using Logparser.exe and path {1}' -f $Global:Ini.Website.Sitename.ToUpper(),$Global:Ini.Website.LogPath) } } else { $ErrorMsg += $('[Error][Script] Failed to return log entry for sitename {0} using Logparser.exe and path {1}' -f $Global:Ini.Website.Sitename.ToUpper(),$Global:Ini.Website.LogPath) } } catch { $e = $_ $ErrorMsg += $('[Error][Script] Failed to return log entry for sitename {0} using Logparser.exe and path {1}' -f $Global:Ini.Website.Sitename.ToUpper(),$Global:Ini.Website.LogPath) $ErrorMsg += $('[Error][Script] {0}' -f $e.Exception.Message) } } # END validate IIS Log Access 25 { # BEGIN validate [WinEvent] Write Start if([System.String]::IsNullOrEmpty($Global:Ini.Alert.Method) -eq $false){ if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ [string[]]$EventMessage = 'Status = Started' $EventMessage += Get-LoadedConfig try { Write-EventLog -LogName $Global:Ini.WinEvent.Logname ` -Source $Global:Ini.WinEvent.Source ` -EntryType Information ` -EventId 100 ` -ErrorAction Stop ` -Message $($EventMessage -Join [System.Environment]::NewLine) } catch { $ErrorMsg += '[Error][Script] Event log write failed.' } } } } # END validate [WinEvent] Write Start default { if($ErrorMsg.Length -gt 0){ $Global:Ini['Script'].Add('HasError',$true) $ErrorMsg = @('[Error][Script] Terminating error.') + $ErrorMsg $Global:Ini['Script'].Add('ErrorMsg',$ErrorMsg) } else { $Global:Ini['Script'].Add('HasError',$false) } $i = 0 } } } while($i -gt 0) return $Global:Ini.Script.HasError } # End Function Confirm-IniConfig Function Get-IniConfig { <# .SYNOPSIS Parses the configuration file into a hashtable. .INPUTS System.String .OUTPUTS System.Collections.Hashtable .LINK https://blogs.technet.microsoft.com/heyscriptingguy/2011/08/20/use-powershell-to-work-with-any-ini-file/ #> [CmdletBinding()] [OutputType('System.Collections.Hashtable')] param( [parameter(Mandatory=$true)] [ValidateScript({Test-Path $_})] [string] # Path to configuration file. $Path ) $config = @{} switch -regex -file $Path { "^.*\[(.+)\].*$" # Section { $section = $matches[1] if($section.ToString().Trim().StartsWith('#') -eq $false){ $config.Add($section.Trim(),@{}) } } "(.+?)\s*=(.*)" # Key { $name,$value = $matches[1..2] if($name.ToString().Trim().StartsWith('#') -eq $false){ $config[$section].Add($name.Trim(), $value.Trim()) } } } if([System.String]::IsNullOrEmpty($config['Script'])){ $config.Add('Script',@{}) } $config['Script'].Add('ConfigPath',(Get-Item $Path).FullName) $config['Script'].Add('StartTS', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) return $config } # End Function Get-IniConfig Function Get-LoadedConfig { <# .SYNOPSIS Gets the currently loaded configuration. .OUTPUTS System.String[] #> [CmdletBinding()] [OutputType('System.String[]')] param( ) [string[]]$returnConfig = @() if($Global:Ini.Count -le 1){ $returnConfig += '[Error] No configuration loaded.' return $returnConfig } else { $Sections = $Global:Ini.Keys | Sort-Object foreach($Section in $Sections){ $returnConfig += '[{0}]' -f $Section.ToUpper() $Settings = $Global:Ini[$Section].Keys | Sort-Object foreach($Setting in $Settings){ if($Setting -eq 'ErrorMsg'){ [string[]]$errorList = $Global:Ini[$Section][$Setting] for($i=0; $i -lt $errorList.Length; $i++){ $returnConfig += ' ErrorMsg{0} = {1}' -f $($i + 1), $errorList[$i] } } else { $returnConfig += ' {0} = {1}' -f $Setting, $Global:Ini[$Section][$Setting] } } } } return $returnConfig } # End Get-LoadedConfig Function Invoke-WebsiteFailedLogins { <# .SYNOPSIS Launches WebsiteFailedLogins. .DESCRIPTION Generates an alert for: - Each IP address meeting or exceeding the threshold FailedLoginsPerIP - When the total failed logins threshold (TotalFailedLogins) is met or exceeded Automate this by running it as a scheduled task. See README.md for details or run the following command: Get-WebsiteFailedLoginsREADME -SectionKeyword Scheduling .INPUTS System.String .OUTPUTS System.String[] #> [CmdletBinding()] [OutputType('System.String[]')] param( [parameter(Mandatory=$true)] [ValidateScript({Test-Path -LiteralPath $_})] [string] # Path to configuration file. $Configuration , [parameter(Mandatory=$false)] [switch] # Performs the minimum number of validation checks against the configuration file. Use this switch after all configuration errors have been resolved. $MinimumValidation ) [bool]$HasError = $false [string[]]$Global:WFLResults = @() $Global:Ini = Get-IniConfig -Path $Configuration if($MinimumValidation){ $HasError = Confirm-IniConfig -Brief } else { $HasError = Confirm-IniConfig } if($HasError -eq $false){ $Global:WFLResults += 'Status = Started' # Per IP Failed Logins $hashMsg = Get-FailedLoginsPerIP if($hashMsg.Count -gt 0){ [string[]]$keys = $hashMsg.Keys | Sort-Object for($i=0; $i -lt $keys.Length; $i++){ $key = $keys[$i] $Global:WFLResults += "# IP Entry $($i + 1) #" $hashMsg[$key] | foreach-Object{ $Global:WFLResults += ' {0}' -f $_ } Submit-Alert -Message $($hashMsg[$key]) -SubjectAppend $key } } # Total Failed Logins $arrMsg = Get-TotalFailedLogins if($arrMsg.Length -gt 0){ $Global:WFLResults += '# Total Failed Logins #' $arrMsg | ForEach-Object{ $Global:WFLResults += ' {0}' -f $_ } Submit-Alert -Message $arrMsg -SubjectAppend 'TotalFailedLogins' -TotalFailedLogins } Write-Output $Global:WFLResults } else { [string[]]$Message = $Global:Ini.Script.ErrorMsg $Message += '# Loaded Configuration #' $Message += $(Get-LoadedConfig) Write-Output $Message Submit-Alert -Message $Message -TerminatingError } Write-Output 'Status = Finished' } # End Function Invoke-WebsiteFailedLogins Function Get-FailedLoginsPerIP { <# .SYNOPSIS Gets each Client IP (c-ip) having generated failed logins >= FailedLoginsPerIP since StartTime. .INPUTS None .OUTPUTS System.Collections.Hashtable #> [CmdletBinding()] [OutputType('System.Collections.Hashtable')] param( ) $ReturnHash = @{} $LogparserQuery = Get-LogparserQuery if($Global:Ini.Website.Authentication -match "^(?i)Forms$"){ $LogparserQuery = Get-LogparserQuery -FormsAuth } $LogparserResults = & $Global:Ini.Logparser.Path -i:IISW3C -o:CSV -q:ON -stats:OFF $LogparserQuery $queryTimestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') if($null -ne $LogparserResults){ $ResultsObj = $LogparserResults | ConvertFrom-Csv foreach($item in $ResultsObj){ [string[]]$EventMessage = @() $EventMessage += 'ClientIP = {0}' -f $item.ClientIP $EventMessage += 'FailedLogins = {0}' -f $item.Hits $EventMessage += 'Sitename = {0}' -f $Global:Ini.Website.Sitename $EventMessage += 'IISLogPath = {0}' -f $Global:Ini.Website.LogPath $EventMessage += 'Authentication = {0}' -f $Global:Ini.Website.Authentication $EventMessage += 'HttpResponse = {0}' -f $Global:Ini.Website.HttpResponse $EventMessage += 'UrlPath = {0}' -f $Global:Ini.Website.UrlPath $EventMessage += 'Start = {0}' -f $Global:Ini.Website.StartTimeTS $EventMessage += 'End ~ {0}' -f $queryTimestamp $ReturnHash.Add($item.ClientIP, $EventMessage) } } return $ReturnHash } # End Function Get-FailedLoginsPerIP Function Submit-Alert { <# .SYNOPSIS Submits alert to Event Log or via SMTP. .OUTPUTS None #> [CmdletBinding()] param( [parameter(Mandatory=$true)] [System.String[]] # Message body. $Message , [parameter(Mandatory=$false)] [System.String] # Message appended to subject. $SubjectAppend , [parameter(Mandatory=$false)] [switch] # Defaults to FailedLoginsPerIP. This switch enables TotalFailedLogins. $TotalFailedLogins , [parameter(Mandatory=$false)] [switch] # Signifies terminating error. $TerminatingError ) if($Global:Ini.Alert.Method -match "^(?i)(.*)WinEvent(.*)$"){ if($TerminatingError){ Write-EventAlert -Message $Message -TerminatingError } elseif($TotalFailedLogins){ Write-EventAlert -Message $Message -TotalFailedLogins } else { Write-EventAlert -Message $Message } } if($Global:Ini.Alert.Method -match "^(?i)(.*)Smtp(.*)$"){ if($TerminatingError){ Send-SmtpAlert -Message $Message -TerminatingError } else { $subject = $Global:Ini.Smtp.Subject if([System.String]::IsNullOrEmpty($SubjectAppend) -eq $false){ $subject = '{0} {1}' -f $Global:Ini.Smtp.Subject,$SubjectAppend } Send-SmtpAlert -Message $Message -MessageSubject $subject } } } # End Function Submit-Alert Function Get-TotalFailedLogins { <# .SYNOPSIS Gets the total failed login count if it meets or exceeds TotalFailedLogins threshold during the specified window. .INPUTS None .OUTPUTS System.String[] #> [CmdletBinding()] [OutputType('System.String[]')] param( ) [string[]]$EventMessage = @() [int]$TotalHits = 0 $LogparserQuery = Get-LogparserQuery -TotalFailedLogins if($Global:Ini.Website.Authentication -match "^(?i)Forms$"){ $LogparserQuery = Get-LogparserQuery -TotalFailedLogins -FormsAuth } [string]$LogparserResult = & $Global:Ini.Logparser.Path -headers:OFF -i:IISW3C -o:CSV -q:ON -stats:OFF $LogparserQuery [string]$queryTimestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') if([System.String]::IsNullOrEmpty($LogparserResult) -eq $false){ if([System.Int32]::TryParse($LogparserResult, [ref]$TotalHits)){ if($TotalHits -ge $Global:Ini.Website.TotalFailedLogins){ $EventMessage += 'TotalFailedLogins = {0}' -f $TotalHits $EventMessage += 'Sitename = {0}' -f $Global:Ini.Website.Sitename $EventMessage += 'IISLogPath = {0}' -f $Global:Ini.Website.LogPath $EventMessage += 'Authentication = {0}' -f $Global:Ini.Website.Authentication $EventMessage += 'HttpResponse = {0}' -f $Global:Ini.Website.HttpResponse $EventMessage += 'UrlPath = {0}' -f $Global:Ini.Website.UrlPath $EventMessage += 'Start = {0}' -f $Global:Ini.Website.StartTimeTS $EventMessage += 'End ~ {0}' -f $queryTimestamp } } } return $EventMessage } # End Function Get-TotalFailedLogins Function Write-EventAlert { <# .SYNOPSIS Writes alert to windows event log. .OUTPUTS None #> [CmdletBinding()] param( [parameter(Mandatory=$true)] [System.String[]] # Message for alert. $Message , [parameter(Mandatory=$false)] [switch] # Defaults to FailedLoginsPerIP. This switch enables TotalFailedLogins. $TotalFailedLogins , [parameter(Mandatory=$false)] [switch] # Signifies terminating error. $TerminatingError ) $EventEntryType = $Global:Ini.WinEvent.EntryType $EventId = $Global:Ini.WinEvent.FailedLoginsPerIPEventId if($TotalFailedLogins){ $EventId = $Global:Ini.WinEvent.TotalFailedLoginsEventId } if($TerminatingError){ $EventEntryType = 'Error' $EventId = 200 } try { Write-EventLog -LogName $Global:Ini.WinEvent.Logname ` -Source $Global:Ini.WinEvent.Source ` -EntryType $EventEntryType ` -EventId $EventId ` -Message $($Message -Join [System.Environment]::NewLine) ` -ErrorAction Stop } catch { $e = $_ Write-Output $('[Error][Script] Alert Event log write failed. {0}' -f $e.Exception.Message) } } # End Function Write-EventAlert Function Send-SmtpAlert { <# .SYNOPSIS Sends alert via SMTP. .OUTPUTS None #> [CmdletBinding()] param( [parameter(Mandatory=$true)] [System.String[]] # Message body. $Message , [parameter(Mandatory=$false)] [System.String] # Subject for email. $MessageSubject , [parameter(Mandatory=$false)] [switch] # Signifies terminating error $TerminatingError ) $EmailSubject = $Global:Ini.Smtp.Subject if([System.String]::IsNullOrEmpty($EmailSubject) -eq $false){ $EmailSubject = $MessageSubject } if($TerminatingError){ $EmailSubject = '[TerminatingError] {0}' -f $EmailSubject } try { if([System.String]::IsNullOrEmpty($Global:Ini.Smtp.CredentialXml)){ Send-MailMessage -To $Global:Ini.Smtp.To ` -From $Global:Ini.Smtp.From ` -Subject $EmailSubject ` -SmtpServer $Global:Ini.Smtp.Server ` -Port $Global:Ini.Smtp.Port ` -Body $($Message -Join [System.Environment]::NewLine) ` -UseSsl ` -ErrorAction Stop } else { $creds = Import-Clixml -LiteralPath $Global:Ini.Smtp.CredentialXml Send-MailMessage -To $Global:Ini.Smtp.To ` -From $Global:Ini.Smtp.From ` -Subject $EmailSubject ` -SmtpServer $Global:Ini.Smtp.Server ` -Port $Global:Ini.Smtp.Port ` -Body $($Message -Join [System.Environment]::NewLine) ` -Credential $creds ` -UseSsl ` -ErrorAction Stop Remove-Variable -Name creds } } catch { $e = $_ Write-Output $('[Error][Script] Alert smtp send failed. {0}' -f $e.Exception.Message) } } # End Function Send-SmtpAlert Function Get-WebsiteFailedLoginsREADME { <# .SYNOPSIS Gets the WebsiteFailedLogins README file. #> [CmdletBinding()] param( [parameter(Mandatory=$false)] [System.String] # Section to return. $SectionKeyword ) try { $ReadMeFile = Get-Item -LiteralPath $Global:WFLReadme -ErrorAction Stop $ReadMeContent = Get-Content -LiteralPath $ReadMeFile.FullName if([System.String]::IsNullOrEmpty($SectionKeyword)){ $ReadMeContent | foreach-Object{ Write-Output $_ } } else { $PrintLine = $false $SectionKeywordLower = $SectionKeyword.ToLower().Trim() for($i=0; $i -lt $ReadMeContent.Length; $i++){ $line = $ReadMeContent[$i] if($line.Trim().StartsWith('#')){ $PrintLine = $false } if([System.String]::IsNullOrEmpty($line) -eq $false){ if($line.ToLower().Contains($SectionKeywordLower)){ if($PrintLine -eq $false){ Write-Output $ReadMeContent[$i - 1] } $PrintLine = $true } } if($PrintLine){ Write-Output $line } } } } catch { $e = $_ Write-Output $('[ERROR][Script] {0}' -f $e.Exception.Message) } } # End Function Get-WebsiteFailedLoginsReadme Function Copy-WebsiteFailedLoginsREADME { <# .SYNOPSIS Copy the WebsiteFailedLogins README file (README.md) to the destination folder. #> [CmdletBinding()] param( [parameter(Mandatory=$true)] [ValidateScript({Test-Path -LiteralPath $_ -PathType Container})] [string] # Destination folder to copy README.md $DestinationFolder ) try { $ReadMeFile = Get-Item -LiteralPath $Global:WFLReadme -ErrorAction Stop Copy-Item -Path $ReadMeFile.FullName -Destination $DestinationFolder } catch { $e = $_ Write-Output $('[ERROR][Script] {0}' -f $e.Exception.Message) } } # End Function Copy-WebsiteFailedLoginsReadme Function Get-WebsiteFailedLoginsDefaultConfiguration { <# .SYNOPSIS Gets the WebsiteFailedLogins default configuration file. #> [CmdletBinding()] param( ) try { $ConfigFile = Get-Item -LiteralPath $Global:WFLDefaultConfig -ErrorAction Stop Get-Content -LiteralPath $ConfigFile.FullName | foreach-Object{ Write-Output $_ } } catch { $e = $_ Write-Output $('[ERROR][Script] {0}' -f $e.Exception.Message) } } # End Function Get-WebsiteFailedLoginsDefaultConfiguration Function Copy-WebsiteFailedLoginsDefaultConfiguration { <# .SYNOPSIS Copy the WebsiteFailedLogins default configuration file to the destination folder. #> [CmdletBinding()] param( [parameter(Mandatory=$true)] [ValidateScript({Test-Path -LiteralPath $_ -PathType Container})] [string] # Destination folder to copy WebsiteFailedLogins.ini $DestinationFolder ) try { $ConfigFile = Get-Item -LiteralPath $Global:WFLDefaultConfig -ErrorAction Stop Copy-Item -Path $ConfigFile.FullName -Destination $DestinationFolder } catch { $e = $_ Write-Output $('[ERROR][Script] {0}' -f $e.Exception.Message) } } # End Function Copy-WebsiteFailedLoginsDefaultConfiguration Function Set-ReadMeAndIniPath { <# .SYNOPSIS Set path for README and default configuration file in global variables. #> [CmdletBinding()] param( ) if([System.String]::IsNullOrEmpty($Global:Ini['Script'])){ $Global:Ini.Add('Script',@{}) } $ReadMePath = Join-Path -Path $PSScriptRoot -ChildPath 'README.md' $Global:WFLReadme = $ReadMePath $DefaultConfigPath = Join-Path -Path $PSScriptRoot -ChildPath 'WebsiteFailedLogins.ini' $Global:WFLDefaultConfig = $DefaultConfigPath } Set-ReadMeAndIniPath |