IIS - PHP Usage (Sensor)
This page shows how to create the IIS - PHP Usage sensor in the Tanium Console, and how to use it to find out whether an IIS server is still serving PHP before you remove or upgrade PHP on it.
What it returns: one row per IIS site, with the site state, the PHP folder and version that handle .php pages for that site, how many .php files are in the site folders, how many .php requests the site logged in the last N days, and the most recent one. Machines without IIS return a single Not Applicable row.
Test status
Run through Tanium on Windows lab endpoints with and without IIS, including a server with PHP 7.1 mapped at server level, before and after the mapping was removed. Stopped sites and handlers set in a site web.config have not been tested yet. See Test results.
How the sensor works
For each site in C:\Windows\System32\inetsrv\config\applicationHost.config, the script:
- Reads the site state (
Started,Stopped) fromappcmd list site. - Finds the
*.phphandler mapping that applies to the site, in this order: theweb.configin the site root folder, a<location>section for the site inapplicationHost.config, then the server-level handler. It reports the folder of thephp-cgi.exethat handler points to, and that file's version. - Counts
.phpfiles under every physical folder of the site. Junctions are skipped. - Reads the site's W3C logs (
C:\inetpub\logs\LogFiles\W3SVC<site ID>unless the site logs somewhere else) for the last N days, and counts requests whose URL path ends in.phpor contains.php/.
The script stops itself after 45 seconds. A count that was cut short ends with (partial).
Use cases
Before removing old PHP. A PHP folder that IIS still maps can't be removed safely. The PHP Folder and PHP Version columns show which sites use it, and the request count shows whether anyone is using those sites.
Finding forgotten sites. A site with thousands of .php files, a Started state, and requests every day is live, even if everyone thinks it moved somewhere else.
Fleet view of PHP on IIS. One question across all Windows servers lists every PHP version IIS still serves.
Before you start
- You need a role that can create sensors in the content set you plan to use.
- If a sensor with the same name already exists, edit it instead of creating a second one.
- Copy the script with the copy button on its code block so nothing is changed.
Create the sensor
Step 1: Open a new sensor
- Sign in to the Tanium Console.
- Open the main menu, then go to Administration > Content > Sensors.
- Click Create Sensor.
Step 2: Details section
| Field | Value |
|---|---|
| Name | IIS - PHP Usage |
| Description | Lists each IIS site with its state, the PHP folder and php-cgi.exe version mapped to *.php for that site (site web.config, then applicationHost.config location, then server level), the number of .php files in the site folders, and the number of .php requests in the site W3C logs over the last N days (Days parameter, default 7, 1 to 30) with the most recent one in UTC. Counts cut short by the 45-second limit end with (partial). Returns Not Applicable when IIS is not installed. Read-only. |
| Content Set | Your own custom content set, restricted to your endpoint team |
Step 3: Settings section
| Field | Value |
|---|---|
| Category | Miscellaneous (or any category you use for custom sensors) |
| Result Type | Text |
| Max Sensor Age | 1 and set the unit dropdown to Hours |
| Max String Age | Leave Enable unchecked |
| Max Strings | Leave Enable unchecked |
| Ignore case in result values | Checked |
| Hide this sensor from sensor lists and parse results | Unchecked |
| Split into multiple columns | Checked |
Step 4: Column settings
- Set Delimiter to
|. - Add these 7 columns in this order.
| Index | Column Name | Value Type | Ignore case | Hidden |
|---|---|---|---|---|
| 0 | Site | Text | Checked | Unchecked |
| 1 | State | Text | Checked | Unchecked |
| 2 | PHP Folder | Text | Checked | Unchecked |
| 3 | PHP Version | Text | Checked | Unchecked |
| 4 | PHP Files | Text | Checked | Unchecked |
| 5 | PHP Requests | Text | Checked | Unchecked |
| 6 | Last PHP Request | Text | Checked | Unchecked |
PHP Files and PHP Requests are Text because they can hold (partial) or No logs.
Step 5: Parameters section
The Key must be Days, matching the ||Days|| placeholder in the script.
- In the Parameters section, click Add Parameter, then select Text Input.
- Key: replace
parameter_1withDays. - Label:
Days of IIS logs to scan - Under Options:
- Check Provide Help Text and enter:
1 to 30. Larger values take longer on busy servers. - Check Provide Default Value and enter:
7 - Check Provide Max Chars and enter:
2
- Check Provide Help Text and enter:
- Under Validation Expressions:
- Replace
.*with^([1-9]|[12][0-9]|30)$ - In Enter Help Text, type:
Enter a number from 1 to 30.
- Replace
Step 6: Scripts section
| Platform | Enable | Query Type | Script |
|---|---|---|---|
| Windows | Checked | PowerShell | Windows script below |
| Linux | Checked | UnixShell | Stub below |
| Mac | Checked | UnixShell | Stub below |
| Solaris | Checked | UnixShell | Stub below |
| AIX | Checked | UnixShell | Stub below |
Set Timeout to 60 seconds, above the script's own 45-second limit.
# Tanium sensor: IIS - PHP Usage
# Returns one row per IIS site:
# Site|State|PHP Folder|PHP Version|PHP Files|PHP Requests|Last PHP Request
#
# PHP Folder and PHP Version come from the *.php handler that applies to the site
# (site web.config first, then a <location> section in applicationHost.config, then
# server level) and the file version of the php-cgi.exe it points to.
# PHP Requests counts W3C log lines whose cs-uri-stem is a .php URL in the last
# ||Days|| days (default 7, 1 to 30). Last PHP Request is in UTC, as IIS logs it.
#
# Status rows:
# Not Applicable IIS is not installed
# None IIS is installed but has no sites
# Error: <reason> The IIS configuration could not be read
# Counts that hit the time limit end with " (partial)".
$sw = [Diagnostics.Stopwatch]::StartNew()
$budgetMs = 45000
$colCount = 7
function Out-Status([string]$Status) { $Status + ('|N/A' * ($colCount - 1)) }
function Clean([string]$Value) {
if ([string]::IsNullOrWhiteSpace($Value)) { return 'None' }
return ($Value -replace '[\|\r\n]', '/').Trim()
}
# --- Parameter -----------------------------------------------------------------
$daysRaw = [System.Uri]::UnescapeDataString('||Days||').Trim()
$days = 7
if ($daysRaw -match '^\d+$') { $days = [int]$daysRaw }
if ($days -lt 1) { $days = 1 }
if ($days -gt 30) { $days = 30 }
$sinceUtc = [DateTime]::UtcNow.Date.AddDays(-($days - 1))
$sinceDate = $sinceUtc.ToString('yyyy-MM-dd')
# --- IIS config ------------------------------------------------------------------
# Use Sysnative when running as a 32-bit process so System32 is not redirected to SysWOW64.
$sysDir = if (Test-Path "$env:windir\Sysnative") { "$env:windir\Sysnative" } else { "$env:windir\System32" }
$appHost = Join-Path $sysDir 'inetsrv\config\applicationHost.config'
$appCmd = Join-Path $sysDir 'inetsrv\appcmd.exe'
if (-not (Test-Path -LiteralPath $appHost)) { Out-Status 'Not Applicable'; exit 0 }
try {
[xml]$cfg = Get-Content -LiteralPath $appHost -Raw -ErrorAction Stop
} catch {
Out-Status 'Error: cannot read applicationHost.config'
exit 0
}
$sitesNode = $cfg.configuration.'system.applicationHost'.sites
$siteList = @($sitesNode.site | Where-Object { $_ })
if ($siteList.Count -eq 0) { Out-Status 'None'; exit 0 }
# Site state is not stored in applicationHost.config, so ask appcmd.
$siteState = @{}
try {
[xml]$sx = (& $appCmd list site /xml 2>$null) -join "`n"
foreach ($s in @($sx.appcmd.SITE)) {
if ($s) { $siteState[[string]$s.'SITE.NAME'] = [string]$s.state }
}
} catch { }
$logRoot = [string]$sitesNode.siteDefaults.logFile.directory
if ([string]::IsNullOrWhiteSpace($logRoot)) { $logRoot = '%SystemDrive%\inetpub\logs\LogFiles' }
# --- Helpers -----------------------------------------------------------------------
function Get-PhpProcessor($HandlersNode) {
if (-not $HandlersNode) { return $null }
foreach ($h in @($HandlersNode.add)) {
if ($h -and ([string]$h.path -ieq '*.php') -and $h.scriptProcessor) {
return ([string]$h.scriptProcessor -split '\|')[0].Trim()
}
}
return $null
}
function Get-PhpVersion([string]$Exe) {
$e = [Environment]::ExpandEnvironmentVariables($Exe)
if (-not (Test-Path -LiteralPath $e)) { return 'Missing' }
$v = (Get-Item -LiteralPath $e).VersionInfo.FileVersion
if ([string]::IsNullOrWhiteSpace($v)) { return 'Unknown' }
return $v.Trim()
}
function Get-PhpFileCount([string[]]$Roots) {
$count = 0
$stack = New-Object System.Collections.Generic.Stack[string]
foreach ($r in $Roots) { if (Test-Path -LiteralPath $r) { $stack.Push($r) } }
while ($stack.Count -gt 0) {
if ($sw.ElapsedMilliseconds -gt $budgetMs) { return "$count (partial)" }
$d = $stack.Pop()
try { foreach ($f in [IO.Directory]::EnumerateFiles($d, '*.php')) { $count++ } } catch { }
try {
foreach ($sub in ([IO.DirectoryInfo]$d).EnumerateDirectories()) {
if (-not ($sub.Attributes -band [IO.FileAttributes]::ReparsePoint)) { $stack.Push($sub.FullName) }
}
} catch { }
}
return "$count"
}
function Get-PhpRequests([string]$Dir) {
if (-not (Test-Path -LiteralPath $Dir)) { return @('No logs', 'No logs') }
$files = @(Get-ChildItem -LiteralPath $Dir -Filter '*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTimeUtc -ge $sinceUtc } | Sort-Object Name)
if ($files.Count -eq 0) { return @('0', 'None') }
$phpRx = [regex]'(?i)\.php($|/)'
$hits = 0
$last = 'None'
$partial = $false
$n = 0
foreach ($f in $files) {
$iStem = -1; $iDate = -1; $iTime = -1
$fs = $null; $sr = $null
try {
# IIS keeps today's log open for writing, so open it with shared read/write.
$fs = New-Object IO.FileStream($f.FullName, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)
$sr = New-Object IO.StreamReader($fs)
while ($null -ne ($line = $sr.ReadLine())) {
$n++
if (($n % 5000) -eq 0 -and $sw.ElapsedMilliseconds -gt $budgetMs) { $partial = $true; break }
if ($line.StartsWith('#')) {
if ($line.StartsWith('#Fields:')) {
$fields = $line.Substring(8).Trim() -split ' '
$iStem = [array]::IndexOf($fields, 'cs-uri-stem')
$iDate = [array]::IndexOf($fields, 'date')
$iTime = [array]::IndexOf($fields, 'time')
}
continue
}
if ($iStem -lt 0) { continue }
$p = $line.Split(' ')
if ($p.Count -le $iStem) { continue }
if ($iDate -ge 0 -and $p.Count -gt $iDate -and $p[$iDate] -lt $sinceDate) { continue }
if ($phpRx.IsMatch($p[$iStem])) {
$hits++
$when = if ($iDate -ge 0 -and $iTime -ge 0 -and $p.Count -gt $iTime) { "$($p[$iDate]) $($p[$iTime]) UTC " } else { '' }
$last = $when + $p[$iStem]
}
}
} catch { }
finally {
if ($sr) { $sr.Dispose() } elseif ($fs) { $fs.Dispose() }
}
if ($partial) { break }
}
$count = if ($partial) { "$hits (partial)" } else { "$hits" }
return @($count, $last)
}
# --- Handler mappings ----------------------------------------------------------------
$serverProc = Get-PhpProcessor $cfg.configuration.'system.webServer'.handlers
$locProc = @{}
foreach ($loc in @($cfg.configuration.location)) {
if ($loc -and $loc.'system.webServer') {
$p = Get-PhpProcessor $loc.'system.webServer'.handlers
if ($p) { $locProc[([string]$loc.path).TrimEnd('/')] = $p }
}
}
# --- One row per site ----------------------------------------------------------------
try {
foreach ($site in $siteList) {
$name = [string]$site.name
$state = if ($siteState.ContainsKey($name)) { $siteState[$name] } else { 'Unknown' }
# Physical paths of every virtual directory in the site
$paths = @($site.application | ForEach-Object { $_.virtualDirectory } | Where-Object { $_ } |
ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_.physicalPath) } |
Where-Object { $_ } | Select-Object -Unique)
# Root virtual directory of the root application, for the site web.config
$rootApp = @($site.application | Where-Object { $_ -and [string]$_.path -eq '/' }) | Select-Object -First 1
$rootVdir = if ($rootApp) { @($rootApp.virtualDirectory | Where-Object { $_ -and [string]$_.path -eq '/' }) | Select-Object -First 1 } else { $null }
$rootPath = if ($rootVdir) { [Environment]::ExpandEnvironmentVariables([string]$rootVdir.physicalPath) } else { $null }
# Which php-cgi.exe handles *.php for this site
$proc = $null
if ($rootPath) {
$wc = Join-Path $rootPath 'web.config'
if (Test-Path -LiteralPath $wc) {
try {
[xml]$wx = Get-Content -LiteralPath $wc -Raw -ErrorAction Stop
$proc = Get-PhpProcessor $wx.configuration.'system.webServer'.handlers
} catch { }
}
}
if (-not $proc -and $locProc.ContainsKey($name)) { $proc = $locProc[$name] }
if (-not $proc) { $proc = $serverProc }
if ($proc) {
$phpFolder = Split-Path -Path ([Environment]::ExpandEnvironmentVariables($proc)) -Parent
$phpVersion = Get-PhpVersion $proc
} else {
$phpFolder = 'None'
$phpVersion = 'None'
}
$phpFiles = Get-PhpFileCount $paths
$siteLogRoot = [string]$site.logFile.directory
if ([string]::IsNullOrWhiteSpace($siteLogRoot)) { $siteLogRoot = $logRoot }
$siteLogDir = Join-Path ([Environment]::ExpandEnvironmentVariables($siteLogRoot)) "W3SVC$($site.id)"
$req = Get-PhpRequests $siteLogDir
(@($name, $state, $phpFolder, $phpVersion, $phpFiles, $req[0], $req[1]) | ForEach-Object { Clean "$_" }) -join '|'
}
} catch {
Out-Status ('Error: ' + (Clean $_.Exception.Message))
}
exit 0
Step 7: Save
Scroll to the bottom of the page and click Save.
Step 8: Test
- Go to Interact.
-
Ask:
-
Check that you see 7 columns and one row per IIS site.
Ask the question
All Windows servers:
Only sites mapped to a given PHP branch, for example 7.x:
Get Computer Name and IIS - PHP Usage[7] from all machines with IIS - PHP Usage[7]:PHP Version starts with "7."
Reading the results
| Column | What it tells you |
|---|---|
| Site | IIS site name |
| State | Started, Stopped, or Unknown if appcmd could not be read |
| PHP Folder | Folder of the php-cgi.exe that handles *.php for the site. None means no PHP handler applies |
| PHP Version | File version of that php-cgi.exe. Missing means the handler points to a file that no longer exists |
| PHP Files | Number of .php files in the site folders |
| PHP Requests | .php requests logged in the last N days. No logs means the site has no log folder (logging off, or the site never served a request). The count comes from the logs, so it stays after PHP is removed until those days pass |
| Last PHP Request | Date, time (UTC), and URL path of the most recent .php request |
Example rows (values are illustrative):
www.example.com|Started|C:\Program Files (x86)\PHP\v7.1|7.1.29|3944|412|2026-11-03 14:11:14 UTC /index.php
Default Web Site|Stopped|C:\Program Files (x86)\PHP\v7.1|7.1.29|3944|No logs|No logs
What to do with a row
| What you see | Meaning | Next step |
|---|---|---|
PHP Folder is None |
IIS does not run PHP for this site | PHP on this server is not used by IIS. Go to Remove PHP Install |
Requests are 0 or No logs, and State is Stopped |
The site is not in use | Confirm with the site owner, then follow PHP on IIS |
Requests above 0 |
The site is serving PHP | Do not remove PHP. The site owner moves it to a supported PHP version or retires it |
Requests can come from internet scanners probing for common files. Look at Last PHP Request: a scanner usually asks for paths such as /wp-login.php or config files and gets errors. A real site gets requests for its own pages. Even scanner traffic means the site is reachable.
Troubleshooting
| Result | Meaning | What to do |
|---|---|---|
Error: cannot read applicationHost.config |
The IIS config file is locked or damaged | Check IIS on that server |
(partial) in a count |
The 45-second limit was reached | Ask again with a smaller Days value |
TSE-Error: Sensor evaluation timed out |
The sensor Timeout is below 60 seconds | Edit the sensor and recheck Step 6 |
TSE-Error: No Sensor Definition for this Platform |
The Linux, Mac, Solaris, or AIX script tab was left empty | Edit the sensor and add the stub script to those tabs (Step 6) |
| All values in one column | The column split was not saved | Edit the sensor and recheck Step 4 |
Limits
- Only IIS. PHP run by Apache, a scheduled task, or a service is not reported. Use Remove PHP Install, which refuses to delete a folder with running processes.
- Only W3C logs that include the
cs-uri-stemfield are counted. That field is on by default. - Handlers set in a
web.configbelow the site root, or in a nested application, are not read.
Test results
| Case | Expected | Result |
|---|---|---|
| Windows PowerShell 5.1 parser | No errors | Pass |
| Machine without IIS, run locally | Not Applicable row |
Pass |
| Through Tanium, Windows machines without IIS | Not Applicable row |
Pass |
| Through Tanium, IIS with two sites and no PHP | One row per site, Started, PHP Folder None, counts 0 |
Pass |
| Through Tanium, site that has never logged | No logs |
Pass |
| Through Tanium, Linux endpoints with no stub script | TSE-Error: No Sensor Definition for this Platform |
Expected. Fixed by adding the stub |
| Through Tanium, server-level PHP 7.1 handler, two sites | Both sites show C:\Program Files (x86)\PHP\v7.1 and 7.1.33 |
Pass |
| Site with requests in the logs | Count above 0, last request shown | Pass. 4, 2026-11-05 14:48:34 UTC /index.php |
| Site that was never browsed | No logs |
Pass |
| After the handler was removed | PHP Folder and PHP Version None, request history still shown |
Pass |
| Stopped site | Stopped |
Not tested yet |
Handler set in the site web.config |
Site-level folder reported | Not tested yet |