Skip to content

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:

  1. Reads the site state (Started, Stopped) from appcmd list site.
  2. Finds the *.php handler mapping that applies to the site, in this order: the web.config in the site root folder, a <location> section for the site in applicationHost.config, then the server-level handler. It reports the folder of the php-cgi.exe that handler points to, and that file's version.
  3. Counts .php files under every physical folder of the site. Junctions are skipped.
  4. 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 .php or 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

  1. Sign in to the Tanium Console.
  2. Open the main menu, then go to Administration > Content > Sensors.
  3. 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

  1. Set Delimiter to |.
  2. 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.

  1. In the Parameters section, click Add Parameter, then select Text Input.
  2. Key: replace parameter_1 with Days.
  3. Label: Days of IIS logs to scan
  4. 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
  5. Under Validation Expressions:
    • Replace .* with ^([1-9]|[12][0-9]|30)$
    • In Enter Help Text, type: Enter a number from 1 to 30.

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
#!/bin/sh
echo "Not Applicable|N/A|N/A|N/A|N/A|N/A|N/A"

Step 7: Save

Scroll to the bottom of the page and click Save.

Step 8: Test

  1. Go to Interact.
  2. Ask:

    Get Computer Name and IIS - PHP Usage[7] from all machines with Computer Name contains "<test server>"
    
  3. Check that you see 7 columns and one row per IIS site.


Ask the question

All Windows servers:

Get Computer Name and IIS - PHP Usage[7] from all machines with Windows OS Type contains "Server"

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-stem field are counted. That field is on by default.
  • Handlers set in a web.config below 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