Skip to content
Please update to the latest release 0.77.3 to address Multiple CVEs.
Generic.Applications.PHPInventory

Generic.Applications.PHPInventory

Inventory PHP installations on Windows, Linux and macOS for remediation scoping. Collect package versions, common binary/module paths and running PHP processes. Each row retains its evidence source; an unknown version is not a negative result.

No PHP execution by default. ProbeBinaries runs discovered PHP executables with -n -v (ignore php.ini and print version), under the client account. Enable only for trusted installations: filenames and -n do not make an executable safe. RPM inventory runs the system rpm query command; other package sources are read directly. No downloads, uploads, HTTP probes, configuration changes or restarts.

The 20 October 2026 PHP notice announces two CVSS 9.2 vulnerabilities, with details due 21 October at 14:00 UTC. Affected/fixed versions were not published when this artifact was written. This is inventory, not a vulnerability verdict. Retain vendor package revisions when checking backported fixes.

Default paths are bounded and cannot cover every custom application, container, chroot or embedded runtime. Add custom paths with AdditionalGlobs. Host inventory does not inspect container images/namespaces or prove web exposure. An on-disk version does not establish the version already loaded in a running service.


  name: Generic.Applications.PHPInventory
  author: Matt Green - @mgreen27
  type: CLIENT
  description: |
    Inventory PHP installations on Windows, Linux and macOS for remediation scoping.
    Collect package versions, common binary/module paths and running PHP processes.
    Each row retains its evidence source; an unknown version is not a negative result.

    No PHP execution by default. ProbeBinaries runs discovered PHP executables with
    -n -v (ignore php.ini and print version), under the client account. Enable only
    for trusted installations: filenames and -n do not make an executable safe.
    RPM inventory runs the system rpm query command; other package sources are read
    directly. No downloads, uploads, HTTP probes, configuration changes or restarts.

    The 20 October 2026 PHP notice announces two CVSS 9.2 vulnerabilities, with
    details due 21 October at 14:00 UTC. Affected/fixed versions were not published
    when this artifact was written. This is inventory, not a vulnerability verdict.
    Retain vendor package revisions when checking backported fixes.

    Default paths are bounded and cannot cover every custom application, container,
    chroot or embedded runtime. Add custom paths with AdditionalGlobs. Host inventory
    does not inspect container images/namespaces or prove web exposure. An on-disk
    version does not establish the version already loaded in a running service.
  reference:
    - https://www.php.net/archive/2026.php#2026-10-20-1
    - https://www.php.net/manual/en/features.commandline.options.php
  implied_permissions:
    - EXECVE
    - IMPERSONATION
    - MACHINE_STATE
  resources:
    timeout: 600
  parameters:
    - name: CollectPackages
      type: bool
      default: Y
      description: Read DPKG/APK databases, Homebrew receipts and Windows uninstall entries; query system RPM on Linux.
    - name: CollectProcesses
      type: bool
      default: Y
      description: Report PHP process paths/PIDs/command lines and include their executable paths in discovery.
    - name: CollectLoadedModules
      type: bool
      default: N
      description: Inspect all visible processes for loaded PHP runtime modules on Linux and Windows; emit one row per module with hosting processes and their scan details.
    - name: ResolveAliases
      type: bool
      default: Y
      description: Resolve POSIX executable symlinks with bounded traversal. Windows returns unsupported_platform. Original paths and exact counts are retained.
    - name: DetailedProcesses
      type: bool
      default: N
      description: Emit one process row per PID. Default groups by executable path and retains PID lists and distinct users/command lines.
    - name: ProbeBinaries
      type: bool
      default: N
      description: Execute discovered PHP CLI/CGI/FPM binaries with -n -v. Runs as the client account. DLLs/modules are never executed.
    - name: CommandTimeout
      type: int64
      default: 10
      description: Per-command timeout in seconds, including RPM queries. Values below one use one second.
    - name: MaxCandidates
      type: int64
      default: 500
      description: Maximum distinct binary/module paths to inspect; emit a diagnostic if exceeded. Minimum one.
    - name: AdditionalGlobs
      type: multiline
      default: ''
      description: Additional absolute file globs, one per line. Files must still match PHP binary/module names. Avoid broad filesystem recursion.
    - name: BinaryGlobs
      type: csv
      default: |
        OS,Glob
        linux,"/usr/{bin,sbin}/php*"
        linux,"/usr/local/{bin,sbin}/php*"
        linux,"/usr/{lib,lib64}/apache2/modules/libphp*.so"
        linux,"/usr/{lib,lib64}/httpd/modules/libphp*.so"
        linux,"/opt/remi/php*/root/usr/{bin,sbin}/php*"
        linux,"/opt/plesk/php/*/{bin,sbin}/php*"
        linux,"/opt/cpanel/ea-php*/root/usr/{bin,sbin}/php*"
        linux,"/opt/alt/php*/usr/{bin,sbin}/php*"
        linux,"/usr/local/php*/{bin,sbin}/php*"
        linux,"/opt/lampp/{bin,sbin}/php*"
        linux,"/opt/bitnami/php/{bin,sbin}/php*"
        darwin,"/usr/{bin,sbin}/php*"
        darwin,"/usr/local/{bin,sbin}/php*"
        darwin,"/opt/homebrew/{bin,sbin}/php*"
        darwin,"/opt/homebrew/Cellar/php*/*/{bin,sbin}/php*"
        darwin,"/usr/local/Cellar/php*/*/{bin,sbin}/php*"
        darwin,"/opt/local/{bin,sbin}/php*"
        darwin,"/Applications/MAMP/bin/php/php*/bin/php*"
        darwin,"/Applications/XAMPP/xamppfiles/{bin,sbin}/php*"
        windows,"C:/php*/php*"
        windows,"C:/Program Files/PHP*/**3/php*"
        windows,"C:/Program Files (x86)/PHP*/**3/php*"
        windows,"C:/xampp/php/php*"
        windows,"C:/wamp*/bin/php/php*/php*"
        windows,"C:/laragon/bin/php/php*/php*"
        windows,"C:/Program Files (x86)/Plesk/Additional/PHP*/php*"
    - name: DpkgStatus
      default: /var/lib/dpkg/status
      description: Native DPKG status database. Override for acquired/mounted package metadata.
    - name: ApkInstalled
      default: /lib/apk/db/installed
      description: Native Alpine installed-package database.
    - name: BrewReceipts
      default: /{opt/homebrew,usr/local}/Cellar/php*/*/INSTALL_RECEIPT.json
      description: Homebrew installed PHP receipts. Folder version is retained as package evidence.
  export: |
    LET PHPVersion(Text) = parse_string_with_regex(string=Text,
      regex='''^(?:[0-9]+:)?(?P<Version>[0-9]+\.[0-9]+\.[0-9]+(?:(?:RC|alpha|beta)[0-9]+)?)''').Version
    LET PHPPackage = '''(?i)^(?:(?:ea-|alt-)?php(?:[0-9.]|$|-)|libapache2-mod-php|apache2-mod_php)'''
    LET PHPRuntimePackage = '''(?i)^(?:(?:ea-|alt-)?php[0-9.]*(?:-php)?(?:-(?:cli|fpm|cgi|common|core|embed|apache2))?|libapache2-mod-php[0-9.]*|apache2-mod_php[0-9.]*)$'''
    LET PHPExecutable = '''(?i)^php(?:[0-9]+(?:\.[0-9]+)*)?(?:-(?:cgi|fpm))?(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?$'''
    LET PHPFile = '''(?i)^(?:php(?:[0-9]+(?:\.[0-9]+)*)?(?:-(?:cgi|fpm))?(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?|php[0-9]*(?:ts|apache[0-9_]*|embed)?\.dll|libphp[0-9.]*\.(?:so|dylib))$'''
    LET PHPDpkg(File) = SELECT P.Package AS Name, P.Version AS RawVersion,
        P.Architecture AS Architecture, P.State AS PackageState
      FROM foreach(row={
        SELECT parse_string_with_regex(string=Record, regex=[
          '''(?m)^Package: (?P<Package>[^\r\n]+)''',
          '''(?m)^Version: (?P<Version>[^\r\n]+)''',
          '''(?m)^Architecture: (?P<Architecture>[^\r\n]+)''',
          '''(?m)^Status: \S+ \S+ (?P<State>[^\r\n]+)''']) AS P
        FROM parse_records_with_regex(file=File,
          regex='''(?ms)^(?P<Record>Package:.*?)(?:\r?\n\r?\n|\z)''')
      }) WHERE Name =~ PHPPackage AND NOT PackageState IN ('not-installed', 'config-files')
    LET PHPApk(File) = SELECT P.Package AS Name, P.Version AS RawVersion,
        P.Architecture AS Architecture, 'installed' AS PackageState
      FROM foreach(row={
        SELECT parse_string_with_regex(string=Record, regex=[
          '''(?m)^P:(?P<Package>[^\r\n]+)''',
          '''(?m)^V:(?P<Version>[^\r\n]+)''',
          '''(?m)^A:(?P<Architecture>[^\r\n]+)''']) AS P
        FROM parse_records_with_regex(file=File,
          regex='''(?ms)^(?P<Record>[^\r\n].*?)(?:\r?\n\r?\n|\z)''')
      }) WHERE Name =~ PHPPackage
    LET PHPCommand(Argv) = SELECT * FROM query(
      query={SELECT * FROM execve(argv=CommandArgv, length=65536, sep='\n')},
      env=dict(CommandArgv=Argv),
      timeout=if(condition=CommandTimeout > 0, then=CommandTimeout, else=1))
    LET Host <= SELECT Hostname, OS FROM info()
    LET Platform = Host[0].OS
    LET CandidateLimit = if(condition=MaxCandidates > 0, then=MaxCandidates, else=1)
    // One PHP process snapshot per invocation, shared by serial sources.
    LET ProcessRows <= SELECT * FROM if(condition=CollectProcesses, then={
      SELECT Name, Exe, Pid, Ppid, CommandLine, Username FROM pslist()
      WHERE Name =~ '''(?i)^php(?:[0-9.]|$|-|\.exe)'''
        OR basename(path=Exe) =~ PHPExecutable
    })
    // Walk components in order: resolving only the final link misses /etc/alternatives
    // and parent-directory aliases. No shell commands are used.
    LET ResolveStep(State) = SELECT * FROM query(inherit=TRUE, query='''
          LET Part <= State.Pending[0]
          LET Rest <= slice(list=State.Pending, start=1, end=len(list=State.Pending))
          LET Next <= if(condition=State.Current = '/', then='/' + Part, else=State.Current + '/' + Part)
          LET Key <= Next + '/' + join(array=Rest, sep='/')
          LET Info <= SELECT * FROM if(condition=State.Pending AND NOT Part IN ('', '.', '..'), then={SELECT * FROM stat(filename=Next)})
          SELECT * FROM switch(
            end={SELECT set(item=State, field='Status', value='resolved') FROM scope() WHERE NOT State.Pending},
            skip={SELECT set(item=State, field='Pending', value=Rest) AS B FROM scope() WHERE Part IN ('', '.')},
            parent={SELECT set(item=State, field='Current', value=str(str=dirname(path=State.Current))),
              set(item=State, field='Pending', value=Rest) AS B FROM scope() WHERE Part = '..'},
            absent={SELECT set(item=State, field='Status', value='missing_or_inaccessible') FROM scope() WHERE NOT Info},
            loop={SELECT set(item=State, field='Status', value='symlink_loop') FROM scope()
              WHERE Info[0].IsLink AND get(item=State.Seen, field=Key)},
            bound={SELECT set(item=State, field='Status', value='symlink_limit') FROM scope()
              WHERE Info[0].IsLink AND State.Links >= 32},
            link={SELECT set(item=State.Seen, field=Key, value=TRUE) AS A,
              set(item=State, field='Links', value=State.Links + 1) AS B,
              set(item=State, field='Pending', value=split(string=Info[0].Data.Link, sep='/') + Rest) AS C,
              set(item=State, field='Current', value=if(condition=Info[0].Data.Link =~ '^/', then='/', else=State.Current))
              FROM scope() WHERE Info[0].IsLink AND Info[0].Data.Link},
            unknown={SELECT set(item=State, field='Status', value='link_target_unavailable') FROM scope() WHERE Info[0].IsLink},
            notdir={SELECT set(item=State, field='Status', value='not_directory') FROM scope()
              WHERE Rest AND NOT Info[0].IsDir},
            normal={SELECT set(item=State, field='Current', value=Next),
              set(item=State, field='Pending', value=Rest) AS B FROM scope()})
    ''')
    LET ResolvePath(InputPath) = SELECT * FROM query(inherit=TRUE, query='''
     LET State <= dict(Current='/', Pending=split(string=str(str=InputPath), sep='/'), Seen=dict(), Links=0, Status=if(condition=NOT ResolveAliases, then='disabled', else=if(condition=Platform = 'windows', then='unsupported_platform', else=if(condition=NOT InputPath, then='unknown_path', else=if(condition=str(str=InputPath) =~ '^/', then='resolving', else='not_absolute')))))
     LET Walk <= SELECT * FROM foreach(row={SELECT * FROM range(end=512) WHERE State.Status = 'resolving'}, query={SELECT * FROM ResolveStep(State=State)})
     SELECT if(condition=State.Status = 'resolved', then=State.Current) AS ResolvedPath,
     if(condition=State.Status = 'resolving', then='path_step_limit', else=State.Status) AS ResolutionStatus FROM scope()
    ''')
    LET ModuleHosts = SELECT Pid, Name, Exe FROM pslist()
      GROUP BY Pid
    LET ModuleScan(HostPid, HostName, HostExe) = SELECT * FROM query(inherit=TRUE, query='''
      LET MapsPath <= format(format='/proc/%d/maps', args=HostPid)
      LET MapsData <= read_file(filename=MapsPath, length=2097153)
      // A capped read must not turn its incomplete last line into module evidence.
      LET ParseData <= if(condition=len(list=MapsData) > 2097152,
        then=regex_replace(source=MapsData, re='[^\n]*$', replace=''), else=MapsData)
      // Filter before structured parsing; most mappings are unrelated to PHP.
      LET Parsed = SELECT M.Device AS Device, M.Inode AS Inode, M.ModulePath AS ModulePath FROM foreach(row={
        SELECT parse_string_with_regex(string=Line,
          regex='^([0-9a-f]+)-([0-9a-f]+) +[^ ]+ +[0-9a-f]+ +(?P<Device>[0-9a-f]+:[0-9a-f]+) +(?P<Inode>[0-9]+) +(?P<ModulePath>/.*)$') AS M
        FROM parse_lines(filename=ParseData, accessor='data')
        WHERE Line =~ '/libphp'
      }) WHERE ModulePath =~ '/libphp[^/]*[.]so(?:[.][0-9]+)*(?: [(]deleted[)])?$'
      LET Matches <= SELECT *, serialize(item=dict(Device=Device, Inode=Inode, Path=ModulePath)) AS ModuleKey
        FROM Parsed GROUP BY ModuleKey
      SELECT * FROM chain(
        mappings={SELECT 'loaded_module' AS RecordType, HostPid AS Pid,
          HostName AS HostProcessName, HostExe AS HostExecutable,
          ModulePath, Device, Inode, ModuleKey,
          ModulePath =~ ' [(]deleted[)]$' AS Deleted,
          'mapped_version_unknown' AS Status FROM Matches},
        health={SELECT 'module_scan' AS RecordType, HostPid AS Pid,
          HostName AS HostProcessName, HostExe AS HostExecutable,
          if(condition=len(list=MapsData) > 2097152, then='maps_size_limit_exceeded',
            else='maps_read') AS Status,
          len(list=Matches) AS MatchedModuleCount, MapsPath AS MapsPath
          FROM scope() WHERE Matches})
    ''')
    LET PHPDLL = '(?i)^php[0-9]+(ts|apache[0-9_]+)?[.]dll$'
    LET WindowsPathText(FilePath) = regex_replace(source=str(str=FilePath), re='\\\\', replace='/')
    // modules() exposes rows, not a structured completion result. An empty
    // enumeration is unknown; nonempty results can still be partial.
    LET WindowsModuleScan(HostPid, HostName, HostExe) = SELECT * FROM query(inherit=TRUE, query='''
      LET AllModules <= SELECT ModuleName, ExePath FROM modules(pid=HostPid)
      LET Matches <= SELECT ExePath AS ModulePath,
        lowcase(string=WindowsPathText(FilePath=ExePath)) AS NormalizedModulePath,
        lowcase(string=WindowsPathText(FilePath=ExePath)) AS ModuleKey
        FROM AllModules
        WHERE ExePath AND (ModuleName =~ PHPDLL
          OR basename(path=WindowsPathText(FilePath=ExePath)) =~ PHPDLL)
        GROUP BY ModuleKey
      SELECT * FROM chain(
        mappings={SELECT 'loaded_module' AS RecordType, HostPid AS Pid,
          HostName AS HostProcessName, HostExe AS HostExecutable,
          ModulePath, NormalizedModulePath, ModuleKey,
          NULL AS Device, NULL AS Inode, NULL AS Deleted,
          'mapped_version_unknown' AS Status FROM Matches},
        health={SELECT 'module_scan' AS RecordType, HostPid AS Pid,
          HostName AS HostProcessName, HostExe AS HostExecutable,
          'modules_returned' AS Status,
          len(list=AllModules) AS EnumeratedModuleCount,
          len(list=Matches) AS MatchedModuleCount
          FROM scope() WHERE Matches})
    ''')
    LET ResolveInputs = SELECT str(str=LookupPath) AS LookupPath FROM ResolutionInputs GROUP BY LookupPath
    LET ResolutionRows = SELECT * FROM foreach(row=ResolveInputs, query={
      SELECT LookupPath, * FROM ResolvePath(InputPath=LookupPath)
    })
    LET ResolutionFor(FilePath) = get(item=PathResolutions, field=str(str=FilePath))
    LET Patterns = SELECT Glob FROM BinaryGlobs WHERE OS = Platform
    LET ExtraPatterns = SELECT Line AS Glob FROM parse_lines(
      filename=AdditionalGlobs, accessor='data') WHERE Line
    LET FoundFiles = SELECT OSPath AS Path, Name, Size, Mtime
      FROM glob(globs=Patterns.Glob + ExtraPatterns.Glob)
      WHERE NOT IsDir AND Name =~ PHPFile
    // FPM workers commonly share one executable. Stat each nonempty path once.
    LET ProcessPaths = SELECT Exe FROM ProcessRows WHERE Exe GROUP BY Exe
    LET ProcessFiles = SELECT * FROM foreach(row=ProcessPaths, query={
      SELECT OSPath AS Path, Name, Size, Mtime FROM stat(filename=Exe)
      WHERE Exe AND NOT IsDir AND Name =~ PHPFile
    })
    LET Candidates = SELECT * FROM query(inherit=TRUE, query=format(
      format='SELECT * FROM chain(a=FoundFiles, b=ProcessFiles) GROUP BY Path LIMIT %d',
      args=CandidateLimit + 1))
    LET SelectedFiles = slice(list=Candidates, start=0, end=CandidateLimit)
    LET Files = SELECT * FROM foreach(row={
      SELECT *, if(condition=Name =~ '''(?i)\.(exe|dll)$''', then=parse_pe(file=Path).VersionInformation) AS PE
      FROM SelectedFiles
    }, query={
      SELECT 'binary' AS RecordType, 'file' AS EvidenceSource, Name, Path,
        PHPVersion(Text=PE.ProductVersion || PE.FileVersion) AS Version,
        PE.ProductVersion || PE.FileVersion AS RawVersion,
        if(condition=PE, then='pe_version_resource', else='unknown') AS VersionSource,
        Size, Mtime, PE AS Details
      FROM scope()
    })
    LET Probes = SELECT * FROM if(condition=ProbeBinaries, then={
      SELECT * FROM foreach(row={SELECT * FROM SelectedFiles WHERE Name =~ PHPExecutable}, query={
        SELECT * FROM query(inherit=TRUE, query='''
        LET Output <= SELECT * FROM PHPCommand(Argv=[str(str=Path), '-n', '-v'])
        LET Successful <= SELECT * FROM Output WHERE Complete AND ReturnCode = 0
        LET Banner <= SELECT Stdout, parse_string_with_regex(string=Stdout,
          regex='(?m)^PHP (?P<Version>[0-9]+[.][0-9]+[.][0-9]+[^[:space:]]*)').Version AS ParsedVersion
          FROM Output WHERE Stdout =~ '(?m)^PHP [0-9]+[.][0-9]+[.][0-9]+'
        SELECT 'version_probe' AS RecordType, 'php_-n_-v' AS EvidenceSource,
          Name, Path, Banner[0].ParsedVersion AS Version, Banner[0].Stdout AS RawVersion,
          'binary_version_output' AS VersionSource,
          if(condition=Banner AND Successful, then='version_reported',
            else=if(condition=Banner, then='version_reported_command_incomplete_or_failed',
              else='no_version_returned')) AS Status,
          Output AS Details
        FROM scope()
        ''')
      })
    })
    LET Deb = SELECT * FROM if(condition=CollectPackages AND Platform = 'linux', then={
      SELECT * FROM foreach(row={SELECT OSPath FROM glob(globs=DpkgStatus)}, query={
        SELECT 'package' AS RecordType, 'dpkg' AS EvidenceSource,
          OSPath AS Path, * FROM PHPDpkg(File=OSPath)
      })
    })
    LET Apk = SELECT * FROM if(condition=CollectPackages AND Platform = 'linux', then={
      SELECT * FROM foreach(row={SELECT OSPath FROM glob(globs=ApkInstalled)}, query={
        SELECT 'package' AS RecordType, 'apk' AS EvidenceSource,
          OSPath AS Path, * FROM PHPApk(File=OSPath)
      })
    })
    LET Rpm = SELECT * FROM if(condition=CollectPackages AND Platform = 'linux', then={
      SELECT * FROM foreach(row={SELECT OSPath FROM glob(globs='/usr/bin/rpm')}, query={
        SELECT * FROM query(inherit=TRUE, query='''
        LET Output <= SELECT * FROM PHPCommand(Argv=[str(str=OSPath), '-qa', '--qf',
          '%{NAME}\\t%{EPOCHNUM}:%{VERSION}-%{RELEASE}\\t%{ARCH}\\n'])
        LET Successful <= SELECT * FROM Output WHERE Complete AND ReturnCode = 0
        SELECT * FROM chain(
          packages={SELECT 'package' AS RecordType, 'rpm' AS EvidenceSource, OSPath AS Path,
            R.Name AS Name, R.RawVersion AS RawVersion, R.Architecture AS Architecture,
            'installed' AS PackageState
            FROM foreach(row={SELECT parse_string_with_regex(string=Stdout,
              regex='^(?P<Name>[^\\t]+)\\t(?P<RawVersion>[^\\t]+)\\t(?P<Architecture>[^\\r\\n]+)') AS R
              FROM Output}) WHERE Name =~ PHPPackage},
          diagnostic={SELECT 'diagnostic' AS RecordType, 'rpm' AS EvidenceSource,
            OSPath AS Path, 'rpm_query_incomplete_or_failed' AS Status, Output AS Details FROM scope()
            WHERE NOT Successful})
        ''')
      })
    })
    LET Brew = SELECT * FROM if(condition=CollectPackages AND Platform = 'darwin', then={
      SELECT 'package' AS RecordType, 'homebrew_receipt' AS EvidenceSource,
        basename(path=dirname(path=dirname(path=OSPath))) AS Name,
        basename(path=dirname(path=OSPath)) AS RawVersion, OSPath AS Path,
        'receipt_present' AS PackageState
      FROM glob(globs=BrewReceipts) WHERE NOT IsDir
    })
    LET WinPackages = SELECT * FROM if(condition=CollectPackages AND Platform = 'windows', then={
      SELECT 'package' AS RecordType, 'windows_uninstall' AS EvidenceSource,
        DisplayName AS Name, DisplayVersion AS RawVersion, KeyPath AS Path,
        InstallLocation, Publisher, 'registry_entry' AS PackageState
      FROM Artifact.Windows.Sys.Programs()
      WHERE DisplayName =~ '''(?i)\bPHP\b'''
    })
    LET Packages = SELECT *,
        if(condition=RecordType = 'package' AND (Name =~ PHPRuntimePackage OR EvidenceSource = 'homebrew_receipt'
          OR (EvidenceSource = 'windows_uninstall' AND Name =~ '(?i)^PHP [0-9]')),
          then=PHPVersion(Text=RawVersion)) AS Version,
        if(condition=RecordType = 'package', then='package_metadata') AS VersionSource
      FROM chain(a=Deb, b=Apk, c=Rpm, d=Brew, e=WinPackages)
  sources:
    - name: Packages
      query: |
        LET CollectedAt <= timestamp(epoch=now())
        SELECT Host[0].Hostname AS Hostname, Platform AS OS, CollectedAt,
          RecordType, EvidenceSource, get(field='Name') AS Name,
          get(field='Path') AS Path, Version, get(field='RawVersion') AS RawVersion,
          VersionSource, get(field='Architecture') AS Architecture,
          get(field='PackageState') AS PackageState,
          get(field='InstallLocation') AS InstallLocation, get(field='Publisher') AS Publisher,
          get(field='Status') AS Status, get(field='Details') AS Details
        FROM Packages
    - name: Binaries
      query: |
        // Index the pre-probe snapshot; exact path lookup avoids a scan per row.
        LET ProcessCounts <= memoize(key='Exe', query={
          SELECT Exe, count() AS Total FROM ProcessRows WHERE Exe GROUP BY Exe
        })
        LET Candidates <= SELECT * FROM Candidates
        LET SelectedFiles <= slice(list=Candidates, start=0, end=CandidateLimit)
        LET CollectedAt <= timestamp(epoch=now())
        LET ResolutionInputs = SELECT * FROM chain(
          files={SELECT Path AS LookupPath FROM SelectedFiles},
          processes={SELECT Exe AS LookupPath FROM ProcessRows})
        LET ResolutionSnapshot <= SELECT * FROM ResolutionRows
        LET PathResolutions <= memoize(key='LookupPath', query={SELECT * FROM ResolutionSnapshot})
        LET ResolvedProcessRows <= SELECT Pid, ResolutionFor(FilePath=Exe).ResolvedPath AS ResolvedPath,
          ResolutionFor(FilePath=Exe).ResolutionStatus AS ResolutionStatus FROM ProcessRows GROUP BY Pid
        LET UnresolvedProcesses <= SELECT Pid FROM ResolvedProcessRows WHERE ResolutionStatus != 'resolved'
        LET ResolvedCounts <= memoize(key='ResolvedPath', query={
          SELECT ResolvedPath, count() AS Total FROM ResolvedProcessRows
          WHERE ResolutionStatus = 'resolved' GROUP BY ResolvedPath
        })
        SELECT Host[0].Hostname AS Hostname, Platform AS OS,
          RecordType, EvidenceSource, get(field='Name') AS Name,
          get(field='Path') AS Path, get(field='Version') AS Version,
          get(field='RawVersion') AS RawVersion, get(field='VersionSource') AS VersionSource,
          if(condition=CollectProcesses AND RecordType IN ('binary', 'version_probe')
            AND get(field='Name') =~ PHPExecutable,
            then=get(item=ProcessCounts, field=str(str=Path)).Total || 0) AS ProcessCount,
          if(condition=RecordType IN ('binary', 'version_probe'), then=ResolutionFor(FilePath=Path).ResolvedPath) AS ResolvedPath,
          if(condition=RecordType IN ('binary', 'version_probe'), then=ResolutionFor(FilePath=Path).ResolutionStatus) AS ResolutionStatus,
          if(condition=CollectProcesses AND get(field='Name') =~ PHPExecutable
            AND ResolutionFor(FilePath=Path).ResolutionStatus = 'resolved' AND NOT UnresolvedProcesses,
            then=get(item=ResolvedCounts, field=ResolutionFor(FilePath=Path).ResolvedPath).Total || 0) AS ResolvedProcessCount,
          if(condition=NOT get(field='Name') =~ PHPExecutable, then='not_applicable',
            else=if(condition=NOT CollectProcesses, then='disabled', else=if(condition=ResolutionFor(FilePath=get(field='Path')).ResolutionStatus != 'resolved',
            then='path_resolution_unavailable', else=if(condition=UnresolvedProcesses,
            then='unresolved_process_paths', else='complete_for_collected_processes')))) AS ResolvedCorrelationStatus,
          CollectedAt,
          get(field='Size') AS Size, get(field='Mtime') AS Mtime,
          get(field='Status') AS Status, get(field='Details') AS Details
        FROM chain(files=Files, probes=Probes,
          overflow={SELECT 'diagnostic' AS RecordType, 'discovery' AS EvidenceSource,
            'candidate_limit_exceeded' AS Status, dict(CandidateLimit=CandidateLimit) AS Details
            FROM scope() WHERE len(list=Candidates) > CandidateLimit})
    - name: Processes
      query: |
        LET CollectedAt <= timestamp(epoch=now())
        LET ResolutionInputs = SELECT Exe AS LookupPath FROM ProcessRows
        LET ResolutionSnapshot <= SELECT * FROM ResolutionRows
        LET PathResolutions <= memoize(key='LookupPath', query={SELECT * FROM ResolutionSnapshot})
        LET Grouped = SELECT Exe AS Path, count() AS ProcessCount,
          enumerate(items=Pid) AS Pids, enumerate(items=Ppid) AS ParentPids
          FROM ProcessRows GROUP BY Exe
        LET UsersFor(Executable) = SELECT Username FROM ProcessRows
          WHERE Exe = Executable GROUP BY Username
        LET CommandsFor(Executable) = SELECT CommandLine FROM ProcessRows
          WHERE Exe = Executable GROUP BY CommandLine
        LET Summary = SELECT * FROM foreach(row=Grouped, query={
          SELECT 'process' AS RecordType, 'pslist' AS EvidenceSource,
            Path, basename(path=Path) AS Name, ProcessCount, Pids, ParentPids,
            UsersFor(Executable=Path).Username AS Users,
            CommandsFor(Executable=Path).CommandLine AS CommandLines,
            'unknown_running_version' AS VersionSource
          FROM scope()
        })
        LET Detailed = SELECT 'process' AS RecordType, 'pslist' AS EvidenceSource,
          Name, Exe AS Path, Pid, Ppid, Username, CommandLine,
          1 AS ProcessCount, dict(Items=[Pid]).Items AS Pids, dict(Items=[Ppid]).Items AS ParentPids,
          'unknown_running_version' AS VersionSource FROM ProcessRows
        SELECT Host[0].Hostname AS Hostname, Platform AS OS,
          RecordType, EvidenceSource, Name, Path, VersionSource, ProcessCount, Pids, ParentPids,
          ResolutionFor(FilePath=Path).ResolvedPath AS ResolvedPath,
          ResolutionFor(FilePath=Path).ResolutionStatus AS ResolutionStatus, CollectedAt,
          get(field='Users') AS Users, get(field='CommandLines') AS CommandLines,
          get(field='Pid') AS Pid, get(field='Ppid') AS Ppid,
          get(field='Username') AS Username, get(field='CommandLine') AS CommandLine
        FROM if(condition=DetailedProcesses, then=Detailed, else=Summary)
    - name: LoadedModules
      query: |
        LET CollectedAt <= timestamp(epoch=now())
        LET Hosts <= SELECT * FROM if(
          condition=CollectLoadedModules AND Platform IN ('linux', 'windows'),
          then=ModuleHosts)
        LET Scans <= SELECT * FROM foreach(row=Hosts, query={
          SELECT * FROM if(condition=Platform = 'windows',
            then={SELECT * FROM WindowsModuleScan(HostPid=Pid, HostName=Name, HostExe=Exe)},
            else={SELECT * FROM ModuleScan(HostPid=Pid, HostName=Name, HostExe=Exe)})
        })
        // Scans is the shared snapshot; this single-use projection can stream.
        LET Mappings = SELECT * FROM Scans WHERE RecordType = 'loaded_module'
        LET ScanHealth <= memoize(key='Pid', query={
          SELECT * FROM Scans WHERE RecordType = 'module_scan'
        })
        LET HealthFor(ProcessId) = get(item=ScanHealth, field=str(str=ProcessId))
        LET NamesFor(Processes) = SELECT Name FROM foreach(row=Processes)
          WHERE Name GROUP BY Name ORDER BY Name
        LET ExecutablesFor(Processes) = SELECT Exe FROM foreach(row=Processes)
          WHERE Exe GROUP BY Exe ORDER BY Exe
        LET Groups = SELECT ModuleKey, ModulePath, Device, Inode, Deleted,
          get(field='NormalizedModulePath') AS NormalizedModulePath,
          count() AS LoadedProcessCount, enumerate(items=Pid) AS Pids,
          enumerate(items=dict(Pid=Pid, Name=HostProcessName, Exe=HostExecutable,
            ModulePath=ModulePath, ScanStatus=HealthFor(ProcessId=Pid).Status,
            MapsPath=HealthFor(ProcessId=Pid).MapsPath,
            MatchedModuleCount=HealthFor(ProcessId=Pid).MatchedModuleCount,
            EnumeratedModuleCount=HealthFor(ProcessId=Pid).EnumeratedModuleCount)) AS HostingProcesses
          FROM Mappings GROUP BY ModuleKey
        SELECT Host[0].Hostname AS Hostname, Platform AS OS,
          get(field='ModulePath') AS ModulePath,
          join(array=NamesFor(Processes=get(field='HostingProcesses')).Name, sep=', ') AS ProcessNames,
          get(field='LoadedProcessCount') AS LoadedProcessCount,
          join(array=ExecutablesFor(Processes=get(field='HostingProcesses')).Exe, sep='; ') AS HostExecutables,
          Status, get(field='Deleted') AS Deleted, get(field='Pids') AS Pids,
          get(field='HostingProcesses') AS HostingProcesses,
          get(field='NormalizedModulePath') AS NormalizedModulePath,
          get(field='Device') AS Device, get(field='Inode') AS Inode,
          CollectedAt, RecordType,
          if(condition=Platform = 'windows', then='windows_modules',
            else=if(condition=Platform = 'linux', then='proc_pid_maps', else='module_scope')) AS EvidenceSource
        FROM if(condition=CollectLoadedModules, then={
          SELECT 'loaded_module' AS RecordType, *, 'mapped_version_unknown' AS Status FROM Groups
        })
    - name: Coverage
      query: |
        LET Candidates <= SELECT * FROM Candidates
        SELECT Host[0].Hostname AS Hostname, Platform AS OS,
          'coverage' AS RecordType, 'collection_scope' AS EvidenceSource,
          'inventory_only_not_vulnerability_assessment' AS Status,
          CollectPackages, CollectProcesses, ProbeBinaries, DetailedProcesses, CollectLoadedModules, ResolveAliases,
          timestamp(epoch=now()) AS CollectedAt,
          Patterns.Glob + ExtraPatterns.Glob AS SearchedGlobs,
          len(list=Candidates) AS CandidateCount, CandidateLimit,
          len(list=Candidates) > CandidateLimit AS CandidateLimitExceeded,
          len(list=ProcessRows) AS ProcessCount,
          'Review collection logs for access/parser failures; no matches does not prove PHP absent.' AS Details
        FROM scope()