PHP to Proof / OperatorPRIVATE CONTROL PLANE

PHP2Shell hunting: queries, results and additional value

GhostPaw story / Upstream hunting pack

HISTORICAL REVIEWED CAPTURE / 2026-10-24T18:35:01.215Z to 2026-10-24T18:35:03.883Z. Independent source logs versus actual observations under existing enabled plans; not a Defender-disabled experiment. Read-only, exact lab, retrospective 24-hour queries. No scan, exploit, image execution, DCR/rule deployment, protection change or tenant-wide XDR query. Original operator login/test deadline:2026-10-24T18:45:04.967Z. This public recording remains historical and read-only; no execution window was extended.

Queries below are reviewed templates; actual resource identifiers and raw sensitive fields are not public. Row counts are actual; null means not executed, never zero matches.

Twenty additional-value walkthroughs

row-01 / Defender TVM CVE exposure

Requires: TVM inventory and published CVE detection

tvm-exposure

row-02 / Defender for Cloud VM and running-container posture

Requires: Applicable Defender plan, permissions and actual assessment coverage

php-assessments

row-03 / App Service and Function runtime discovery

Requires: Approved Resource Graph scope; per-app configuration read if fields are missing

appservice-runtimes / appservice-runtime-discovery

row-04 / Registry, running-image and zero-finding SBOM inventory

Requires: Registry assessment coverage and separately authorized image/SBOM access

container-cve-findings / container-php-findings

row-05 / MDE inventory, fixed-version and distinct-software review

Requires: MDE software inventory and supported process telemetry for host discovery

php-inventory / software-discovery

row-06 / Defender EASM external exposure

Requires: Authorized EASM inventory export

Walkthrough only: separate authorization/evidence required; no scanner or mutation is executed.

row-07 / Code search and DevOps ownership mapping

Requires: Explicit repository scope and authorized code-search/DevOps access

Walkthrough only: separate authorization/evidence required; no scanner or mutation is executed.

row-08 / Bundled PHP binaries and reviewed NCSC product leads

Requires: TVM software evidence and reviewed local NCSC/vendor product rows

php-binary-evidence / bundled-products

row-09 / Worker observations, mod_php proxy and unknown launchers

Requires: MDE inventory, process starts and supported inbound network observations

worker-classification / worker-discovery

row-10 / Optional authorized network fingerprint gap review

Requires: Separate written scan scope, approved scanner/network reachability and SOC coordination

Walkthrough only: separate authorization/evidence required; no scanner or mutation is executed.

hunt-A / Prioritize exposed high-risk worker candidates

Requires: Row 09 classification and Row 06 approved/live exposure export

Walkthrough only: separate authorization/evidence required; no scanner or mutation is executed.

hunt-B / Web worker children and legitimate baseline

Requires: Supported MDE process telemetry and reviewed worker-name discovery

worker-child-processes / worker-baseline

hunt-C / Syslog crashes, OOM and worker churn

Requires: Linux AMA, Syslog DCR and actual application error routing

syslog-worker-failures

hunt-D1 / App Service HTTP error, session and cookie leads

Requires: AppServiceHTTPLogs in the intended workspace

appservice-http

hunt-D2 / Application Gateway WAF and backend correlation

Requires: Resource-specific AGWAccessLogs and AGWFirewallLogs, not Front Door logs

appgateway-waf

hunt-E1 / PHP file writes and rapid deletion

Requires: Supported MDE file telemetry

php-file-drops

hunt-E2 / Worker and child-tool public network activity

Requires: Supported MDE network telemetry

worker-network

hunt-F1 / AKS ContainerLogV2 worker errors

Requires: Container Insights V2 and actual PHP/FPM stdout/stderr output

container-worker-errors

hunt-F2 / Custom web error and privacy-preserving access logs

Requires: Custom WebErrorLog_CL/WebAccessLog_CL tables, AMA DCR and the documented access format

web-text-logs

hunt-G / Patch, partial mitigations and closure evidence

Requires: Verified PHP/vendor advisories, authorized change owner and effective web-SAPI configuration

Walkthrough only: separate authorization/evidence required; no scanner or mutation is executed.

lab-waf

lab-workspace / detections/waf-requests.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Real Front Door records after diagnostics ingestion; benign marker only.

Required: At least one declared optional lab source.

Reviewed query template
// Required when available: AzureDiagnostics (FrontDoorWebApplicationFirewallLog).
// The empty source keeps a new workspace queryable when the table is missing.
// Real WAF records of benign input are NOT a CVE detection or proof of compromise.
let RunIdFilter = "";
let Window = 1h;
let Empty = datatable(TimeGenerated:datetime)[];
union isfuzzy=true Empty, AzureDiagnostics
| where TimeGenerated between (ago(Window) .. now())
| where tostring(column_ifexists("Category", "")) == "FrontDoorWebApplicationFirewallLog"
| extend RequestUri = tostring(column_ifexists("requestUri_s", ""))
| extend RunId = extract(@"[?&]runId=([0-9a-f-]{36})", 1, RequestUri)
| where isnotempty(RunId) and (isempty(RunIdFilter) or RunId == RunIdFilter)
| project TimeGenerated, RunId,
    ResourceId = tostring(column_ifexists("_ResourceId", "")),
    ClientIp = tostring(column_ifexists("clientIP_s", "")),
    Hostname = tostring(column_ifexists("host_s", "")),
    Action = tostring(column_ifexists("action_s", "")),
    RuleName = tostring(column_ifexists("ruleName_s", "")),
    TrackingReference = tostring(column_ifexists("trackingReference_s", "")),
    RequestUri, EvidenceKind = "real-waf-log-benign-input"
| order by TimeGenerated asc

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-waf",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "207d566fc3a218dd68db611684c7bc4222fee31886268297ed496a9b84094b36"
}

lab-php-errors

lab-workspace / detections/php-errors.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Synthetic examples are labelled separately from actual PHP log messages.

Required: At least one declared optional lab source.

Reviewed query template
// Required when available: AppServiceConsoleLogs or AppServiceAppLogs.
// Synthetic php-error events are deliberately NOT real PHP exceptions.
let RunIdFilter = "";
let Empty = datatable(TimeGenerated:datetime)[];
union isfuzzy=true Empty, AppServiceConsoleLogs, AppServiceAppLogs
| where TimeGenerated between (ago(1h) .. now())
| extend Message = tostring(column_ifexists("ResultDescription", column_ifexists("Message", "")))
| extend Event = parse_json(extract(@"DOJO_EVENT (\{.*\})", 1, Message))
| where tostring(Event.signal) == "php-error" or Message has_any ("PHP Fatal error", "PHP Warning", "PHP Parse error")
| extend RunId = tostring(Event.runId), Synthetic = tobool(Event.synthetic)
| where isempty(RunIdFilter) or RunId == RunIdFilter
| project TimeGenerated, RunId, Synthetic,
    ResourceId = coalesce(tostring(Event.resourceId), tostring(column_ifexists("_ResourceId", ""))),
    Hostname = tostring(Event.hostname), ClientIp = tostring(Event.clientIp),
    Identity = tostring(Event.identity), Message,
    EvidenceKind = iff(Synthetic == true, "synthetic-php-error", "observed-php-error-review-required")
| order by TimeGenerated asc

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-php-errors",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "72375d860f92ab629466f75ae40ee053d854c2581cd7b8446fd54b3f5e833872"
}

lab-child-process

lab-workspace / detections/unexpected-child-process.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Synthetic app logs only; MDE records require a separately supported workload/ingestion path.

Required: At least one declared optional lab source.

Reviewed query template
// Optional: DeviceProcessEvents, only for a workload with supported MDE telemetry.
// App Service in this scaffold does NOT emit DeviceProcessEvents.
// Required for synthetic examples: AppServiceConsoleLogs.
// Maintenance and legitimate workers can match; an event is not compromise.
let RunIdFilter = "";
let Empty = datatable(TimeGenerated:datetime)[];
union isfuzzy=true withsource=SourceTable Empty, DeviceProcessEvents, AppServiceConsoleLogs
| where TimeGenerated between (ago(1h) .. now())
| extend Event = parse_json(extract(@"DOJO_EVENT (\{.*\})", 1, tostring(column_ifexists("ResultDescription", ""))))
| extend ParentProcess = tostring(column_ifexists("InitiatingProcessFileName", "")),
    ChildProcess = tostring(column_ifexists("FileName", "")), Synthetic = tobool(Event.synthetic)
| where (SourceTable endswith "DeviceProcessEvents"
    and ParentProcess in~ ("php", "php-fpm", "nginx", "httpd", "apache2")
    and ChildProcess !in~ ("php", "php-fpm", "nginx", "httpd", "apache2"))
    or (Synthetic == true and tostring(Event.signal) == "process-synthetic")
| extend RunId = tostring(Event.runId)
| where isempty(RunIdFilter) or RunId == RunIdFilter
| project TimeGenerated, RunId, Synthetic, ParentProcess,
    ChildProcess = iff(Synthetic == true, "(synthetic; no child process launched)", ChildProcess),
    Hostname = coalesce(tostring(Event.hostname), tostring(column_ifexists("DeviceName", ""))),
    DeviceId = tostring(column_ifexists("DeviceId", "")),
    ResourceId = coalesce(tostring(Event.resourceId), tostring(column_ifexists("_ResourceId", ""))),
    ClientIp = tostring(Event.clientIp),
    Identity = coalesce(tostring(Event.identity), tostring(column_ifexists("AccountName", ""))),
    EvidenceKind = iff(Synthetic == true, "synthetic-process-shape", "observed-process-review-required")

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-child-process",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "9969d6f9aba9c93ec0dad63e89f9bfa29eca13cd9abad606cd3a1f99f45d51bf"
}

lab-outbound

lab-workspace / detections/unexpected-outbound.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Synthetic app logs only; no outbound connection is made.

Required: At least one declared optional lab source.

Reviewed query template
// Optional: DeviceNetworkEvents, only when supported workload telemetry exists.
// App Service in this scaffold does NOT emit DeviceNetworkEvents.
// Required for synthetic examples: AppServiceConsoleLogs. No egress is simulated.
let RunIdFilter = "";
let Empty = datatable(TimeGenerated:datetime)[];
union isfuzzy=true withsource=SourceTable Empty, DeviceNetworkEvents, AppServiceConsoleLogs
| where TimeGenerated between (ago(1h) .. now())
| extend Event = parse_json(extract(@"DOJO_EVENT (\{.*\})", 1, tostring(column_ifexists("ResultDescription", ""))))
| extend Process = tostring(column_ifexists("InitiatingProcessFileName", "")), Synthetic = tobool(Event.synthetic)
| where (SourceTable endswith "DeviceNetworkEvents" and Process in~ ("php", "php-fpm", "nginx", "httpd", "apache2"))
    or (Synthetic == true and tostring(Event.signal) == "outbound-synthetic")
| extend RunId = tostring(Event.runId)
| where isempty(RunIdFilter) or RunId == RunIdFilter
| project TimeGenerated, RunId, Synthetic, Process,
    Hostname = coalesce(tostring(Event.hostname), tostring(column_ifexists("DeviceName", ""))),
    DeviceId = tostring(column_ifexists("DeviceId", "")),
    ResourceId = coalesce(tostring(Event.resourceId), tostring(column_ifexists("_ResourceId", ""))),
    Identity = coalesce(tostring(Event.identity), tostring(column_ifexists("InitiatingProcessAccountName", ""))),
    ClientIp = tostring(Event.clientIp), RemoteIp = tostring(column_ifexists("RemoteIP", "")),
    RemotePort = toint(column_ifexists("RemotePort", int(null))),
    EvidenceKind = iff(Synthetic == true, "synthetic-egress-shape-no-connection", "observed-network-review-required")

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-outbound",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "5f03049dfdfb019bffce4af45a4c1182a0533ed0849b08db9c61570d3a1de987"
}

lab-canary

lab-workspace / detections/canary-access.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Fabricated constant fixture, not Azure data-plane access.

Required: At least one declared optional lab source.

Reviewed query template
// Required when available: AppServiceConsoleLogs.
// The canary is an in-code fabricated fixture, NOT a secret or Azure data read.
// A missing RequestAt can be expected when WAF Prevention blocked the marker.
let RunIdFilter = "";
let Empty = datatable(TimeGenerated:datetime)[];
union isfuzzy=true Empty, AppServiceConsoleLogs
| where TimeGenerated between (ago(1h) .. now())
| extend Event = parse_json(extract(@"DOJO_EVENT (\{.*\})", 1, tostring(column_ifexists("ResultDescription", ""))))
| where tobool(Event.synthetic) == true and tostring(Event.signal) in ("request", "canary")
| extend RunId = tostring(Event.runId), Signal = tostring(Event.signal)
| where isnotempty(RunId) and (isempty(RunIdFilter) or RunId == RunIdFilter)
| summarize RequestAt = minif(TimeGenerated, Signal == "request"),
    CanaryAt = minif(TimeGenerated, Signal == "canary"),
    CanaryIds = make_set(tostring(Event.canary.id)), Identities = make_set(tostring(Event.identity)),
    ClientIps = make_set(tostring(Event.clientIp)), Hostnames = make_set(tostring(Event.hostname))
    by RunId, ResourceId = tostring(Event.resourceId)
| where isnotnull(CanaryAt)
| extend TimeGenerated = CanaryAt, RequestBeforeCanary = RequestAt <= CanaryAt,
    EvidenceKind = "synthetic-fixture-access-not-cloud-data-access"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-canary",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "bd434928e3c70f63fa4d6d9c3277d5806367390568a74b2860bf94fb546bf5f2"
}

lab-correlation

lab-workspace / detections/correlation.kql

Without optional protection: Actual WAF/application logs can support manual investigation independently of optional Defender plans.

Additional value: Supported Defender detections and actual posture add investigation context. Synthetic process, egress and ledger fixtures do not demonstrate a workload detection.

Telemetry: Real WAF plus multiple synthetic signals; posture export is not configured.

Required: At least one declared optional lab source.

Reviewed query template
// Available sources: AzureDiagnostics, AppServiceConsoleLogs.
// Optional exported posture source: SecurityRecommendation (not enabled here).
// All tables are optional at query resolution; an empty workspace returns no rows.
// Replace __APP_RESOURCE_ID__ for hunting; Bicep substitutes it for analytics.
// Posture counts are assessment rows to REVIEW, not verified vulnerability/exposure.
// Synthetic process/egress/canary logs are not actual runtime telemetry or compromise.
let AppResourceId = "__APP_RESOURCE_ID__";
let RunIdFilter = "";
let Empty = datatable(TimeGenerated:datetime)[];
let Sources = union isfuzzy=true withsource=SourceTable Empty, AzureDiagnostics, AppServiceConsoleLogs, SecurityRecommendation
    | where TimeGenerated between (ago(1h) .. now());
let AppEvents = Sources
    | where SourceTable endswith "AppServiceConsoleLogs"
    | extend Event = parse_json(extract(@"DOJO_EVENT (\{.*\})", 1, tostring(column_ifexists("ResultDescription", ""))))
    | where tobool(Event.synthetic) == true and tostring(Event.resourceId) =~ AppResourceId
    | extend RunId = tostring(Event.runId)
    | where isnotempty(RunId) and (isempty(RunIdFilter) or RunId == RunIdFilter)
    | summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated),
        LastIngested = max(ingestion_time()), SyntheticSignals = make_set(tostring(Event.signal)),
        Hostnames = make_set(tostring(Event.hostname)), ClientIps = make_set(tostring(Event.clientIp)),
        Identities = make_set(tostring(Event.identity)) by RunId
    | where array_length(SyntheticSignals) >= 2;
let Waf = Sources
    | where tostring(column_ifexists("Category", "")) == "FrontDoorWebApplicationFirewallLog"
    | extend RequestUri = tostring(column_ifexists("requestUri_s", ""))
    | where RequestUri contains "dojoProbe=waf-marker"
    | extend RunId = extract(@"[?&]runId=([0-9a-f-]{36})", 1, RequestUri)
    | where isnotempty(RunId)
    | summarize WafEvents = count(), WafActions = make_set(tostring(column_ifexists("action_s", ""))),
        WafResourceIds = make_set(tostring(column_ifexists("_ResourceId", ""))) by RunId;
let Posture = Sources
    | where SourceTable endswith "SecurityRecommendation"
    | extend AssessmentResource = tostring(column_ifexists("AssessedResourceId", "")),
        AssessmentText = tostring(column_ifexists("RecommendationDisplayName", ""))
    | where AssessmentResource =~ AppResourceId
    | summarize PhpAssessmentRows = countif(AssessmentText contains "PHP"
            or AssessmentText contains "CVE-2026-109618" or AssessmentText contains "CVE-2026-109619"),
        ExposureAssessmentRows = countif(AssessmentText has_any ("public", "internet")),
        AssessmentNamesToReview = make_set(AssessmentText)
    | extend JoinKey = 1;
AppEvents
| join kind=inner Waf on RunId
| extend JoinKey = 1
| join kind=leftouter Posture on JoinKey
| where LastIngested > ago(15m)
| project TimeGenerated = LastSeen, RunId, AppResourceId,
    FirstSeen, LastSeen, SyntheticSignals, WafEvents, WafActions, WafResourceIds,
    Hostnames, ClientIps, Identities, PhpAssessmentRows, ExposureAssessmentRows,
    AssessmentNamesToReview, EvidenceKind = "synthetic-training-correlation-not-compromise"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "lab-correlation",
  "platform": "lab-workspace",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [],
  "rows": null,
  "queryTemplateSha256": "a003b61b37262b40c6d9eb142e35ea7600a3b7a83528f310f7510eaa58f737bb"
}

tvm-exposure

defender-xdr / detections/defender-xdr/tvm-exposure.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: Defender XDR TVM onboarding and published CVE detection required.

Required: DeviceTvmSoftwareVulnerabilities.

Reviewed query template
// Defender XDR: requires TVM coverage and published detection for these CVEs.
// Inventory snapshots have no event Timestamp. Empty results are not clearance.
DeviceTvmSoftwareVulnerabilities
| where CveId in~ ("CVE-2026-109618", "CVE-2026-109619")
| project DeviceId, DeviceName, OSPlatform, SoftwareName, SoftwareVersion,
    CveId, RecommendedSecurityUpdate,
    EvidenceKind = "reported-vulnerability-review-required"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "tvm-exposure",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareVulnerabilities"
  ],
  "rows": null,
  "queryTemplateSha256": "484987d63987667ce886c9da609944211f0dc917a832c852068ccb60117f2409"
}

php-inventory

defender-xdr / detections/defender-xdr/php-inventory.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: MDE software inventory required.

Required: DeviceTvmSoftwareInventory.

Reviewed query template
// Defender XDR: PHP-named inventory is a lead, not proof of a vulnerable runtime.
// Fixed versions: PHP advisories GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp.
// Package revisions, epochs and prereleases require vendor/backport review.
let FixedBranches = datatable(Branch:string, FixedVersion:string)
[
    "8.2", "8.2.35",
    "8.3", "8.3.36",
    "8.4", "8.4.27",
    "8.5", "8.5.12"
];
DeviceTvmSoftwareInventory
| where SoftwareName contains "php"
| extend RuntimeRole = case(
    SoftwareName matches regex @"(?i)(phpmyadmin|phpbb|phpunit|phpseclib|phpldapadmin)", "application-version-not-runtime",
    SoftwareName matches regex @"(?i)(mod[-_]?php|libapache2-mod-php|libphp)", "apache-module-package",
    SoftwareName contains "fpm", "fpm",
    SoftwareName contains "cgi", "cgi",
    SoftwareName matches regex @"(?i)(^|[-_])cli($|[-_])", "cli-not-web-facing-by-itself",
    "unclassified-php-name")
| extend RuntimeVersion = extract(@"^(?:[0-9]+:)?([0-9]+\.[0-9]+\.[0-9]+)(?:$|[-+~.])", 1, SoftwareVersion),
    Branch = extract(@"^(?:[0-9]+:)?([0-9]+\.[0-9]+)\.", 1, SoftwareVersion)
| extend VendorPackaged = isnotempty(RuntimeVersion) and SoftwareVersion != RuntimeVersion
| join kind=leftouter FixedBranches on Branch
| extend ReviewStatus = case(
    RuntimeRole == "application-version-not-runtime", "check-serving-runtime-not-application-version",
    isempty(RuntimeVersion) or isnull(parse_version(RuntimeVersion)), "unparsed-version-review-required",
    VendorPackaged, "vendor-backport-or-prerelease-review-required",
    parse_version(RuntimeVersion) < parse_version("8.2.0"), "unsupported-upstream-branch-check-vendor",
    isempty(FixedVersion), "branch-outside-reference-review-required",
    parse_version(RuntimeVersion) < parse_version(FixedVersion), "below-upstream-fixed-version",
    "upstream-threshold-met-verify-deployment")
| project DeviceId, DeviceName, OSPlatform, SoftwareVendor, SoftwareName,
    SoftwareVersion, RuntimeRole, RuntimeVersion, FixedVersion, VendorPackaged,
    ReviewStatus, EvidenceKind = "inventory-version-lead-not-finding"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "php-inventory",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareInventory"
  ],
  "rows": null,
  "queryTemplateSha256": "15216f6dc1258c6f6a8fa2eb3c98da322ce8d037a40fbfa0d4dcf9a22c3ca2e0"
}

software-discovery

defender-xdr / detections/defender-xdr/software-discovery.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: inventory plus supported MDE process observations required.

Required: DeviceTvmSoftwareInventory, DeviceProcessEvents.

Reviewed query template
// Defender XDR: review distinct PHP names AND all software on observed web hosts.
// This discovers candidates outside a fixed product list; it does not prove PHP.
let WebHosts = DeviceProcessEvents
    | where Timestamp between (ago(30d) .. now())
    | where FileName matches regex @"(?i)^(php.*|lsphp.*|apache.*|httpd.*|nginx.*|w3wp\.exe|litespeed|lshttpd|frankenphp)$"
    | distinct DeviceId;
DeviceTvmSoftwareInventory
| where SoftwareName contains "php" or DeviceId in (WebHosts)
| extend DiscoveryReason = iff(SoftwareName contains "php", "php-name", "software-on-observed-web-host")
| summarize Devices = dcount(DeviceId), Versions = make_set(SoftwareVersion, 20),
    SampleDevices = make_set(DeviceName, 10) by SoftwareVendor, SoftwareName, DiscoveryReason
| project SoftwareVendor, SoftwareName, DiscoveryReason, Devices, Versions,
    SampleDevices, EvidenceKind = "software-discovery-lead-review-required"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "software-discovery",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareInventory",
    "DeviceProcessEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "ac1c9fe1fe793638f98004b919e2b86cc51672f717683be224fc1951aa287807"
}

php-binary-evidence

defender-xdr / detections/defender-xdr/php-binary-evidence.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: recognized TVM software evidence; beta schema needs live verification.

Required: DeviceTvmSoftwareEvidenceBeta, DeviceInfo.

Reviewed query template
// Defender XDR: beta TVM evidence schema may change; only recognized software appears.
// Disk evidence is not an exhaustive filesystem scan or proof a module is loaded.
DeviceTvmSoftwareEvidenceBeta
| mv-expand DiskPath = DiskPaths to typeof(string)
| where DiskPath matches regex @"(?i)([/\\]php([0-9.]*|[0-9.]*-?fpm|[0-9.]*-cgi)(\.exe)?$|libphp[0-9.]*\.so|php[0-9]*(ts|apache2[_0-9]*)?\.dll)"
| join kind=leftouter (
    DeviceInfo
    | where Timestamp between (ago(30d) .. now())
    | summarize arg_max(Timestamp, DeviceName, OSPlatform) by DeviceId
) on DeviceId
| project DeviceId, DeviceName, OSPlatform, SoftwareVendor, SoftwareName,
    SoftwareVersion, DiskPath, LastSeenTime,
    EvidenceKind = "recognized-software-disk-evidence-not-loaded-proof"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "php-binary-evidence",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareEvidenceBeta",
    "DeviceInfo"
  ],
  "rows": null,
  "queryTemplateSha256": "4d75f53d3b01953276ee6b4b81a59641f4c5075bed342a5d5ca0556d6d51af40"
}

bundled-products

defender-xdr / detections/defender-xdr/bundled-products.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: TVM inventory and explicitly reviewed product input required.

Required: DeviceTvmSoftwareInventory.

Reviewed query template
// Defender XDR: populate reviewed NCSC/vendor product rows with prepare-products.mjs.
// Empty input returns no leads, NOT a completed product review. No remote feed runs.
// Scope inventory to approved devices before a large keyword join; names are leads.
let ReviewedProducts = datatable(Keyword:string, Product:string, Status109618:string, Status109619:string, Source:string)
[
    // REVIEWED_PRODUCT_ROWS
];
ReviewedProducts
| where isnotempty(Keyword)
| extend MatchKey = 1
| join kind=inner (DeviceTvmSoftwareInventory | extend MatchKey = 1) on MatchKey
| where tolower(SoftwareName) contains tolower(Keyword)
| project DeviceId, DeviceName, SoftwareVendor, SoftwareName, SoftwareVersion,
    Keyword, Product, Status109618, Status109619, Source,
    EvidenceKind = "product-name-lead-check-serving-runtime"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "bundled-products",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareInventory"
  ],
  "rows": null,
  "queryTemplateSha256": "9539de8c8008491acd119ef3ebb95af642efb4610c769abbba4c571e0133bca2"
}

worker-classification

defender-xdr / detections/defender-xdr/worker-classification.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: supported MDE telemetry and inventory required.

Required: DeviceTvmSoftwareInventory, DeviceProcessEvents.

Reviewed query template
// Defender XDR: process START observations do not establish current running state.
// Apache plus a mod_php package is a prioritization proxy, not loaded-module proof.
// Include installed-but-quiet devices, even when no process started in the window.
let ModulePackages = DeviceTvmSoftwareInventory
    | where SoftwareName matches regex @"(?i)(mod[-_]?php|libapache2-mod-php|libphp)"
    | summarize ModPhpPackages = make_set(strcat(SoftwareName, " ", SoftwareVersion), 30) by DeviceId, DeviceName;
let Starts = DeviceProcessEvents
    | where Timestamp between (ago(30d) .. now())
    | where FileName matches regex @"(?i)^(apache2?(\.exe)?|httpd(\.exe)?|php[-0-9.]*fpm[0-9.]*|php-cgi(\.exe)?|lsphp[0-9.]*|frankenphp)$"
    | summarize ApacheStarts = countif(FileName matches regex @"(?i)^(apache2?(\.exe)?|httpd(\.exe)?)$"),
        WorkerStarts = countif(FileName matches regex @"(?i)^(php[-0-9.]*fpm[0-9.]*|php-cgi(\.exe)?|lsphp[0-9.]*|frankenphp)$"),
        WorkerBinaries = make_set(FileName, 30), LastProcessStart = max(Timestamp) by DeviceId, DeviceName;
union (ModulePackages | project DeviceId, DeviceName), (Starts | project DeviceId, DeviceName)
| summarize DeviceName = take_any(DeviceName) by DeviceId
| join kind=leftouter ModulePackages on DeviceId
| join kind=leftouter Starts on DeviceId
| extend Priority = case(
    array_length(ModPhpPackages) > 0 and ApacheStarts > 0, 1,
    WorkerStarts > 0, 2,
    array_length(ModPhpPackages) > 0, 3,
    4)
| extend Classification = case(
    Priority == 1, "apache-plus-module-package-proxy",
    Priority == 2, "php-worker-start-observed",
    Priority == 3, "module-package-installed-no-recent-start",
    "apache-start-no-module-package-evidence")
| project DeviceId, DeviceName, Priority, Classification, ModPhpPackages,
    WorkerBinaries, LastProcessStart,
    EvidenceKind = "runtime-start-observation-not-live-state"
| order by Priority asc, DeviceName asc

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "worker-classification",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceTvmSoftwareInventory",
    "DeviceProcessEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "4b681cd56419ea9c114262b6b0a76295c32a827800360473f9cce2b6f617382b"
}

worker-discovery

defender-xdr / detections/defender-xdr/worker-discovery.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: process and OS-dependent inbound-network telemetry required.

Required: DeviceProcessEvents, DeviceNetworkEvents.

Reviewed query template
// Defender XDR: discover unfamiliar launchers and inbound web listeners.
// Inbound telemetry varies by OS. Neither branch is an exhaustive worker list.
let Starts = DeviceProcessEvents
    | where Timestamp between (ago(30d) .. now())
    | where FileName contains "php" or ProcessCommandLine contains "php"
    | summarize Events = count(), Devices = dcount(DeviceId), LastSeen = max(Timestamp),
        SampleDevices = make_set(DeviceName, 10) by Process = FileName
    | extend DiscoveryKind = "php-name-or-command";
let Listeners = DeviceNetworkEvents
    | where Timestamp between (ago(30d) .. now())
    | where ActionType == "InboundConnectionAccepted" and LocalPort in (80, 443, 8000, 8080, 8443, 8888)
    | summarize Events = count(), Devices = dcount(DeviceId), LastSeen = max(Timestamp),
        SampleDevices = make_set(DeviceName, 10) by Process = InitiatingProcessFileName
    | extend DiscoveryKind = "inbound-web-port";
union Starts, Listeners
| project DiscoveryKind, Process, Events, Devices, LastSeen, SampleDevices,
    EvidenceKind = "worker-discovery-lead-review-required"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "worker-discovery",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceProcessEvents",
    "DeviceNetworkEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "e72d9e2262c0fa0937f16cf125b905fa8226b93363f8b6b60e2f96f19f1b41ea"
}

worker-baseline

defender-xdr / detections/defender-xdr/worker-baseline.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: supported MDE process telemetry required.

Required: DeviceProcessEvents.

Reviewed query template
// Defender XDR: baseline legitimate children before tuning the worker hunts.
// Repeated patterns are not automatically benign. Review app owners and changes.
let WorkerPattern = @"(?i)^(php(?:[0-9.]*-?fpm[0-9.]*|-cgi|[0-9.]*)?(?:\.exe)?|lsphp[0-9.]*|apache2?(?:\.exe)?|httpd(?:\.exe)?|w3wp\.exe|nginx|litespeed|lshttpd|frankenphp)$";
DeviceProcessEvents
| where Timestamp between (ago(30d) .. now())
| where InitiatingProcessFileName matches regex WorkerPattern
| summarize Executions = count(), Devices = dcount(DeviceId), LastSeen = max(Timestamp)
    by ParentWorker = InitiatingProcessFileName, ChildProcess = FileName
| project ParentWorker, ChildProcess, Executions, Devices, LastSeen,
    EvidenceKind = "observed-worker-child-baseline"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "worker-baseline",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceProcessEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "c48f71d4a0e9184cec0999120a51fee74e32bec728609e8f2604cc70f1527ea0"
}

worker-child-processes

defender-xdr / detections/defender-xdr/worker-child-processes.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: real MDE child-process events required; never generated by the simulator.

Required: DeviceProcessEvents.

Reviewed query template
// Defender XDR: requires supported MDE process telemetry; not emitted by this lab.
// Legitimate composer, image, mail and deployment activity needs contextual review.
// In-worker execution has no child process; no match is not proof of safety.
let WorkerPattern = @"(?i)^(php(?:[0-9.]*-?fpm[0-9.]*|-cgi|[0-9.]*)?(?:\.exe)?|lsphp[0-9.]*|apache2?(?:\.exe)?|httpd(?:\.exe)?|w3wp\.exe|nginx|litespeed|lshttpd|frankenphp)$";
DeviceProcessEvents
| where Timestamp between (ago(30d) .. now())
| where InitiatingProcessFileName matches regex WorkerPattern
| extend Child = tolower(FileName)
| extend Category = case(
    Child in ("sh", "bash", "dash", "ash", "zsh", "ksh", "csh", "cmd.exe", "powershell.exe", "pwsh", "pwsh.exe"), "shell",
    Child matches regex @"^(python[0-9.]*|perl|ruby|node|lua)(\.exe)?$", "interpreter",
    Child in ("curl", "wget", "nc", "ncat", "netcat", "socat", "busybox", "tftp", "certutil.exe", "bitsadmin.exe", "mshta.exe", "rundll32.exe", "regsvr32.exe"), "network-or-system-tool",
    Child in ("whoami", "id", "uname", "hostname", "ifconfig", "ipconfig", "ip", "netstat", "ss", "ps", "env", "cat", "ls", "find", "net.exe", "net1.exe", "systeminfo", "tasklist", "nltest.exe"), "discovery-tool",
    "")
| where isnotempty(Category)
| extend SuspiciousCommandShape = ProcessCommandLine matches regex @"(?i)(/dev/(tcp|udp)/|mkfifo|encodedcommand|downloadstring|fsockopen)"
| extend Priority = iff(SuspiciousCommandShape, 1, 2)
| project Timestamp, DeviceId, DeviceName, ReportId, Priority, Category,
    ParentWorker = InitiatingProcessFileName, ChildProcess = FileName,
    ProcessCommandLine, AccountName, SuspiciousCommandShape,
    EvidenceKind = "observed-worker-child-review-required"
| order by Priority asc, Timestamp desc

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "worker-child-processes",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceProcessEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "daa822601cf17c62127d6e0d0bff6c78f458884f8afba479615bbe5bdade2890"
}

php-file-drops

defender-xdr / detections/defender-xdr/php-file-drops.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: real MDE file events required; no files are dropped by the simulator.

Required: DeviceFileEvents.

Reviewed query template
// Defender XDR: requires MDE file telemetry; CMS/plugin/cache writes cause noise.
// A path-based create/delete association is a lead, not proof of file execution.
let WorkerPattern = @"(?i)^(php(?:[0-9.]*-?fpm[0-9.]*|-cgi|[0-9.]*)?(?:\.exe)?|lsphp[0-9.]*|apache2?(?:\.exe)?|httpd(?:\.exe)?|w3wp\.exe|nginx|litespeed|lshttpd|frankenphp)$";
let Writes = DeviceFileEvents
    | where Timestamp between (ago(30d) .. now())
    | where ActionType in ("FileCreated", "FileModified", "FileRenamed")
    | where FileName matches regex @"(?i)\.(php[3-8]?|phtml|pht|phar|inc)$"
    | where InitiatingProcessFileName matches regex WorkerPattern
        or FolderPath matches regex @"(?i)(^/(tmp|var/tmp|dev/shm)/|\\temp\\)"
    | project CreatedAt = Timestamp, DeviceId, DeviceName, ReportId, FolderPath,
        FileName, Writer = InitiatingProcessFileName, SHA1;
let Deletes = DeviceFileEvents
    | where Timestamp between (ago(30d) .. now()) and ActionType == "FileDeleted"
    | project DeletedAt = Timestamp, DeviceId, FolderPath, FileName;
Writes
| join kind=leftouter Deletes on DeviceId, FolderPath, FileName
| summarize RapidDelete = max(toint(isnotnull(DeletedAt) and DeletedAt between (CreatedAt .. (CreatedAt + 10m))))
    by Timestamp = CreatedAt, DeviceId, DeviceName, ReportId, FolderPath, FileName, Writer, SHA1
| project Timestamp, DeviceId, DeviceName, ReportId, FolderPath, FileName, Writer,
    SHA1, DeletedWithin10Min = RapidDelete == 1,
    EvidenceKind = "observed-php-file-lifecycle-review-required"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "php-file-drops",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceFileEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "62ca0964c79b5d521b49809dba70252254844f171efd28f6591bdf0492f3b876"
}

worker-network

defender-xdr / detections/defender-xdr/worker-network.kql

Without optional protection: Web/WAF logs do not reconstruct device software, process ancestry, disk writes or endpoint network activity.

Additional value: Onboarded, supported MDE/TVM sources add those device-level observations and published CVE findings. App Service is not an MDE device; this console has no tenant-wide XDR hunting consent.

Telemetry: Not provided: real MDE network events required; no egress is simulated.

Required: DeviceNetworkEvents.

Reviewed query template
// Defender XDR: requires MDE network telemetry; no connections are simulated here.
// A worker's child tool is retained even on 80/443. Direct worker API/mail traffic
// on common ports is excluded; review that blind spot and private destinations too.
let WorkerPattern = @"(?i)^(php(?:[0-9.]*-?fpm[0-9.]*|-cgi|[0-9.]*)?(?:\.exe)?|lsphp[0-9.]*|apache2?(?:\.exe)?|httpd(?:\.exe)?|w3wp\.exe|nginx|litespeed|lshttpd|frankenphp)$";
DeviceNetworkEvents
| where Timestamp between (ago(30d) .. now())
| where ActionType in ("ConnectionSuccess", "ConnectionAttempt", "ConnectionFailed")
| where RemoteIPType =~ "Public"
| extend WorkerDirect = InitiatingProcessFileName matches regex WorkerPattern and RemotePort !in (80, 443, 53, 25, 465, 587),
    WorkerChild = InitiatingProcessParentFileName matches regex WorkerPattern
        and InitiatingProcessFileName matches regex @"(?i)^(sh|bash|dash|ash|zsh|ksh|python[0-9.]*|perl|ruby|node|curl|wget|nc|ncat|netcat|socat|cmd|powershell|pwsh|certutil|bitsadmin)(\.exe)?$"
| where WorkerDirect or WorkerChild
| project Timestamp, DeviceId, DeviceName, ReportId,
    Signal = iff(WorkerChild, "worker-child-tool-public-connection", "worker-unusual-public-port"),
    Process = InitiatingProcessFileName, ParentWorker = InitiatingProcessParentFileName,
    RemoteIP, RemotePort, RemoteUrl, ActionType,
    EvidenceKind = "observed-network-lead-review-required"

Actual result: unavailable / 2026-10-24T18:35:02.072Z; not executed.

No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.

Reviewed result and public fields
{
  "id": "worker-network",
  "platform": "defender-xdr",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": null,
  "reason": "No XDR hunting connector/tenant-wide consent is configured. App Service does not supply MDE device telemetry.",
  "missingTables": [
    "DeviceNetworkEvents"
  ],
  "rows": null,
  "queryTemplateSha256": "32a08d4944c560f039aed488c3ed1698cf519a9ae8b1c6f936eb89a3a6adaa92"
}

php-assessments

resource-graph / detections/resource-graph/php-assessments.kql

Without optional protection: ARM runtime configuration and foundational recommendations identify leads; major.minor stack strings are not patch evidence.

Additional value: Applicable Defender assessments and paid CSPM graph/attack-path context can prioritize actual affected assets. Empty findings and text matches never prove coverage or safety.

Telemetry: Optional actual posture assessments; discovery and coverage are not guaranteed.

Required: securityresources.

Reviewed query template
// Resource Graph: set an explicitly authorized subscription/resource-group scope.
// Requires actual Defender assessments; text matches are not confirmed findings.
securityresources
| where type in~ ("microsoft.security/assessments", "microsoft.security/assessments/subassessments")
| extend Details = tostring(properties)
| where Details contains "PHP" or Details contains "CVE-2026-109618" or Details contains "CVE-2026-109619"
| project AssessmentId = id, subscriptionId, resourceGroup,
    ResourceId = coalesce(tostring(properties.resourceDetails.id), tostring(properties.resourceDetails.Id)),
    DisplayName = tostring(properties.displayName), Status = tostring(properties.status.code),
    AssessmentTime = todatetime(properties.timeGenerated),
    EvidenceKind = "assessment-text-match-review-required"

Actual result: queried / 2026-10-24T18:35:02.072Z; 4 rows.

Reviewed result and public fields
{
  "id": "php-assessments",
  "platform": "resource-graph",
  "status": "queried",
  "collectedAt": "2026-10-24T18:35:02.072Z",
  "rowCount": 4,
  "rows": [
    {
      "Status": "Healthy",
      "EvidenceKind": "assessment-text-match-review-required"
    },
    {
      "Status": "Healthy",
      "EvidenceKind": "assessment-text-match-review-required"
    },
    {
      "Status": "Healthy",
      "EvidenceKind": "assessment-text-match-review-required"
    },
    {
      "Status": "Healthy",
      "EvidenceKind": "assessment-text-match-review-required"
    }
  ],
  "queryTemplateSha256": "f88a8d5b52084c94ebc858e396d24f27ab7e74a3dcaea7d3b0a2cf9232de9fb9"
}

appservice-runtimes

resource-graph / detections/resource-graph/appservice-runtimes.kql

Without optional protection: ARM runtime configuration and foundational recommendations identify leads; major.minor stack strings are not patch evidence.

Additional value: Applicable Defender assessments and paid CSPM graph/attack-path context can prioritize actual affected assets. Empty findings and text matches never prove coverage or safety.

Telemetry: Lab resource configuration is available; effective patch version is not established by Resource Graph.

Required: resources.

Reviewed query template
// Resource Graph: configuration reports a stack, NOT its effective patch level.
// Unknown config and custom containers remain review candidates, including slots.
// For empty config use the authorized app's read-only webapp config show fallback.
resources
| where type in~ ("microsoft.web/sites", "microsoft.web/sites/slots")
| extend LinuxStack = tostring(properties.siteConfig.linuxFxVersion),
    WindowsPhp = tostring(properties.siteConfig.phpVersion)
| extend CandidateKind = case(
    LinuxStack startswith "PHP|", "php-platform-stack",
    LinuxStack startswith "WORDPRESS|", "wordpress-platform-stack",
    LinuxStack startswith "DOCKER|" or LinuxStack startswith "COMPOSE|" or LinuxStack startswith "KUBE|", "custom-container-inspect-image",
    WindowsPhp !in~ ("", "off", "0"), "windows-php-config",
    isempty(LinuxStack), "runtime-not-reported-review-config",
    "other-reported-runtime")
| where CandidateKind != "other-reported-runtime"
| project ResourceId = id, AppName = name, subscriptionId, resourceGroup, location,
    Runtime = coalesce(LinuxStack, WindowsPhp), CandidateKind,
    DefaultHostName = tostring(properties.defaultHostName),
    ReviewStatus = "verify-effective-runtime-and-patch-level",
    EvidenceKind = "runtime-configuration-lead-not-patch-proof"

Actual result: queried / 2026-10-24T18:35:02.475Z; 1 rows.

Reviewed result and public fields
{
  "id": "appservice-runtimes",
  "platform": "resource-graph",
  "status": "queried",
  "collectedAt": "2026-10-24T18:35:02.475Z",
  "rowCount": 1,
  "rows": [
    {
      "Runtime": "PHP|8.4",
      "CandidateKind": "php-platform-stack",
      "ReviewStatus": "verify-effective-runtime-and-patch-level",
      "EvidenceKind": "runtime-configuration-lead-not-patch-proof"
    }
  ],
  "queryTemplateSha256": "e4b193eb85a7b811cd00b0bccc1c265cc582968a48e95ad3b83de875ac12feb5"
}

appservice-runtime-discovery

resource-graph / detections/resource-graph/appservice-runtime-discovery.kql

Without optional protection: ARM runtime configuration and foundational recommendations identify leads; major.minor stack strings are not patch evidence.

Additional value: Applicable Defender assessments and paid CSPM graph/attack-path context can prioritize actual affected assets. Empty findings and text matches never prove coverage or safety.

Telemetry: Read-only inventory within the operator's approved Resource Graph scope.

Required: resources.

Reviewed query template
// Resource Graph: review ALL runtime strings, not only a fixed PHP stack list.
// An unreported runtime is a collection/configuration gap, not proof of no PHP.
resources
| where type in~ ("microsoft.web/sites", "microsoft.web/sites/slots")
| extend LinuxStack = tostring(properties.siteConfig.linuxFxVersion),
    WindowsPhp = tostring(properties.siteConfig.phpVersion)
| extend Runtime = case(
    isnotempty(LinuxStack), LinuxStack,
    WindowsPhp !in~ ("", "off", "0"), strcat("Windows PHP ", WindowsPhp),
    "(runtime not reported)")
| summarize Apps = count(), SampleResourceIds = make_set(id, 10) by Runtime
| project Runtime, Apps, SampleResourceIds,
    EvidenceKind = "runtime-discovery-review-required"

Actual result: queried / 2026-10-24T18:35:02.944Z; 1 rows.

Reviewed result and public fields
{
  "id": "appservice-runtime-discovery",
  "platform": "resource-graph",
  "status": "queried",
  "collectedAt": "2026-10-24T18:35:02.944Z",
  "rowCount": 1,
  "rows": [
    {
      "Runtime": "PHP|8.4",
      "Apps": 1,
      "EvidenceKind": "runtime-discovery-review-required"
    }
  ],
  "queryTemplateSha256": "1bd74a23eec6081e2a88617e9ac303a7051e9cb3447ab28821296350fca5aa49"
}

container-cve-findings

resource-graph / detections/resource-graph/container-cve-findings.kql

Without optional protection: ARM runtime configuration and foundational recommendations identify leads; major.minor stack strings are not patch evidence.

Additional value: Applicable Defender assessments and paid CSPM graph/attack-path context can prioritize actual affected assets. Empty findings and text matches never prove coverage or safety.

Telemetry: Not provided: registry/container assessment coverage and CVE logic required.

Required: securityresources.

Reviewed query template
// Resource Graph: only ACR-scoped image assessments, not arbitrary VM findings.
// Requires assessment coverage and published CVE logic; zero rows is not clearance.
// Supports legacy subassessments and newer securityentitydata/CvesDetails rows.
securityresources
| where type in~ ("microsoft.security/assessments", "microsoft.security/assessments/subassessments")
| extend Details = tostring(properties), Additional = properties.additionalData
| where Details contains "CVE-2026-109618" or Details contains "CVE-2026-109619"
| extend RegistryId = coalesce(
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, id),
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, tostring(properties.resourceDetails.id)),
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, tostring(properties.resourceDetails.Id)))
| where isnotempty(RegistryId)
| extend CveRows = iff(type =~ "microsoft.security/assessments/subassessments",
    dynamic([{}]), parse_json(tostring(Additional.CvesDetails)))
| mv-expand Cve = CveRows
| where type =~ "microsoft.security/assessments/subassessments"
    or tostring(Cve.CveId) in~ ("CVE-2026-109618", "CVE-2026-109619")
| project AssessmentId = id, RegistryId, subscriptionId, resourceGroup,
    Repository = coalesce(tostring(Additional.artifactDetails.repositoryName), tostring(Additional.repositoryName)),
    ImageDigest = coalesce(tostring(Additional.artifactDetails.digest), tostring(Additional.imageDigest),
        extract(@"(?i)/securityentitydata/[^/]+-images-(sha256:[a-f0-9]{64})(?:/|$)", 1, id)),
    VulnerabilityId = coalesce(tostring(Cve.CveId), tostring(properties.id)), DisplayName = tostring(properties.displayName),
    Status = tostring(properties.status.code),
    Severity = coalesce(tostring(Cve.Severity), tostring(properties.status.severity), tostring(properties.metadata.severity)),
    EvidenceKind = "reported-container-cve-review-required"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "container-cve-findings",
  "platform": "resource-graph",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "securityresources"
  ],
  "rows": null,
  "queryTemplateSha256": "01b852531f4792fd91da39f01d22cc3606bbea194b00ea57afe9e97fa675673a"
}

container-php-findings

resource-graph / detections/resource-graph/container-php-findings.kql

Without optional protection: ARM runtime configuration and foundational recommendations identify leads; major.minor stack strings are not patch evidence.

Additional value: Applicable Defender assessments and paid CSPM graph/attack-path context can prioritize actual affected assets. Empty findings and text matches never prove coverage or safety.

Telemetry: Not provided: registry findings required; zero-finding images need independent SBOM inventory.

Required: securityresources.

Reviewed query template
// Resource Graph: PHP-related finding text is a lead, not a full image inventory.
// Images with zero findings require an authorized digest-based SBOM review.
// Newer image findings can be primary assessments, not just subassessments.
securityresources
| where type in~ ("microsoft.security/assessments", "microsoft.security/assessments/subassessments")
| extend Additional = properties.additionalData, Details = tostring(properties)
| where Details matches regex @"(?i)(\bphp[0-9.]*\b|libphp|mod_php|libapache2-mod-php)"
| extend RegistryId = coalesce(
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, id),
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, tostring(properties.resourceDetails.id)),
    extract(@"(?i)^(.*/providers/Microsoft\.ContainerRegistry/registries/[^/]+)", 1, tostring(properties.resourceDetails.Id)))
| where isnotempty(RegistryId)
| extend Repository = coalesce(tostring(Additional.artifactDetails.repositoryName), tostring(Additional.repositoryName)),
    ImageDigest = coalesce(tostring(Additional.artifactDetails.digest), tostring(Additional.imageDigest),
        extract(@"(?i)/securityentitydata/[^/]+-images-(sha256:[a-f0-9]{64})(?:/|$)", 1, id))
| summarize FindingRows = count(), SampleAssessmentIds = make_set(id, 10)
    by RegistryId, Repository, ImageDigest, subscriptionId, resourceGroup
| project RegistryId, Repository, ImageDigest, subscriptionId, resourceGroup,
    FindingRows, SampleAssessmentIds,
    EvidenceKind = "php-finding-text-lead-not-image-inventory"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "container-php-findings",
  "platform": "resource-graph",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "securityresources"
  ],
  "rows": null,
  "queryTemplateSha256": "9997615f28960f8486f41489eff42428a20891c09b61a6f1a7fa4eaa083204f3"
}

syslog-worker-failures

sentinel-log-analytics / detections/sentinel-log-analytics/syslog-worker-failures.kql

Without optional protection: Collected source logs can be queried manually without enabling Microsoft Sentinel.

Additional value: Explicitly configured Sentinel analytics can correlate ingested sources. Missing DCRs/tables, disabled rules and late ingestion remain gaps; a query result is not an alert or incident.

Telemetry: Not provided: supported Linux host, AMA and a Syslog DCR required.

Required: Syslog.

Reviewed query template
// Log Analytics: requires AMA + Syslog DCR on supported Linux web hosts.
// App error files only reach Syslog if explicitly routed there. Fixed 10m bins
// can split bursts. Crashes, OOM and churn are leads, not exploitation proof.
Syslog
| where TimeGenerated between (ago(14d) .. now())
| where SyslogMessage matches regex @"(?i)(php|libphp|apache|httpd|lsphp)"
    or ProcessName matches regex @"(?i)(php|apache|httpd|lsphp)"
| extend Signal = case(
    SyslogMessage contains "segfault", "worker-segfault",
    SyslogMessage contains "out of memory" or SyslogMessage contains "oom-kill", "worker-oom",
    SyslogMessage contains "Allowed memory size", "php-memory-limit",
    SyslogMessage contains "Maximum execution time", "php-execution-time",
    SyslogMessage matches regex @"(?i)child( pid)? [0-9]+ exit(ed)? (on |with |)signal", "worker-signal-exit",
    SyslogMessage matches regex @"(?i)child [0-9]+ exited with code [0-9]+ after", "worker-churn",
    "")
| where isnotempty(Signal)
| summarize Events = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated)
    by TimeGenerated = bin(TimeGenerated, 10m), ResourceId = _ResourceId, Computer, Signal
| project TimeGenerated, ResourceId, Computer, Signal, Events, FirstSeen, LastSeen,
    IsBurst = Events >= 5, EvidenceKind = "observed-worker-failure-review-required"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "syslog-worker-failures",
  "platform": "sentinel-log-analytics",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "Syslog"
  ],
  "rows": null,
  "queryTemplateSha256": "661f8bd52f6e03741e8ef625d75b51b543b36855a08a9a1ee976e8af6aebe5de"
}

appservice-http

sentinel-log-analytics / detections/sentinel-log-analytics/appservice-http.kql

Without optional protection: Collected source logs can be queried manually without enabling Microsoft Sentinel.

Additional value: Explicitly configured Sentinel analytics can correlate ingested sources. Missing DCRs/tables, disabled rules and late ingestion remain gaps; a query result is not an alert or incident.

Telemetry: Real HTTP diagnostics after ingestion; harmless GET simulation is not an expected hunt hit.

Required: AppServiceHTTPLogs.

Reviewed query template
// Log Analytics: requires AppServiceHTTPLogs diagnostics, enabled by this lab.
// No Content-Type, request body or response cookie is available in this table.
// Request cookies are measured, never projected. Short bins are clusters, not
// proof of an ordered exploit sequence; custom session names need separate review.
let Requests = AppServiceHTTPLogs
    | where TimeGenerated between (ago(14d) .. now())
    | project TimeGenerated, ResourceId = tolower(_ResourceId), Hostname = tolower(CsHost),
        ClientIp = CIp, Method = CsMethod, Status = ScStatus, RequestBytes = CsBytes,
        RequestCookieLength = strlen(Cookie),
        HasSession = Cookie matches regex @"(?i)(^|;\s*)PHPSESSID=", Uri = CsUriStem;
let Bursts = Requests
    | where Method == "POST"
    | summarize Events = count(), Errors = countif(Status between (500 .. 599)),
        MaxRequestBytes = max(RequestBytes), MaxRequestCookieLength = max(RequestCookieLength),
        SessionPosts = countif(HasSession), Uris = make_set(Uri, 5)
        by TimeGenerated = bin(TimeGenerated, 5m), ResourceId, Hostname, ClientIp
    | where Errors >= 5 and MaxRequestBytes > 100000
    | extend Signal = "large-post-server-error-burst";
let SessionClusters = Requests
    | where Method == "POST"
    | summarize Events = count(), Errors = countif(Status between (500 .. 599)),
        MaxRequestBytes = max(RequestBytes), MaxRequestCookieLength = max(RequestCookieLength),
        SessionPosts = countif(HasSession), Uris = make_set(Uri, 5)
        by TimeGenerated = bin(TimeGenerated, 10s), ResourceId, Hostname, ClientIp
    | where Events >= 2 and SessionPosts >= 1 and Errors >= 1
    | extend Signal = "short-post-session-error-cluster";
let LargeCookies = Requests
    | where RequestCookieLength > 1000
    | summarize Events = count(), Errors = countif(Status between (500 .. 599)),
        MaxRequestBytes = max(RequestBytes), MaxRequestCookieLength = max(RequestCookieLength),
        SessionPosts = countif(Method == "POST" and HasSession), Uris = make_set(Uri, 5)
        by TimeGenerated = bin(TimeGenerated, 5m), ResourceId, Hostname, ClientIp
    | extend Signal = "large-request-cookie";
union Bursts, SessionClusters, LargeCookies
| project TimeGenerated, ResourceId, Hostname, ClientIp, Signal, Events, Errors,
    MaxRequestBytes, MaxRequestCookieLength, SessionPosts, Uris,
    EvidenceKind = "observed-http-heuristic-not-exploit-proof"

Actual result: unavailable / 2026-10-24T18:35:03.365Z; not executed.

Azure evidence exceeds the 1 MiB response bound.

Reviewed result and public fields
{
  "id": "appservice-http",
  "platform": "sentinel-log-analytics",
  "status": "unavailable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "Azure evidence exceeds the 1 MiB response bound.",
  "missingTables": [
    "AppServiceHTTPLogs"
  ],
  "rows": null,
  "queryTemplateSha256": "6a79eabab5747bcce49875d1f86e42e3d9276b5e026a9fece5bb8f29a737b326"
}

appgateway-waf

sentinel-log-analytics / detections/sentinel-log-analytics/appgateway-waf.kql

Without optional protection: Collected source logs can be queried manually without enabling Microsoft Sentinel.

Additional value: Explicitly configured Sentinel analytics can correlate ingested sources. Missing DCRs/tables, disabled rules and late ingestion remain gaps; a query result is not an alert or incident.

Telemetry: Not provided: this lab uses Front Door, not Application Gateway.

Required: AGWAccessLogs, AGWFirewallLogs.

Reviewed query template
// Log Analytics: requires resource-specific AGWAccessLogs + AGWFirewallLogs.
// This lab deploys Front Door, NOT Application Gateway. These are optional hunts.
// Rules 200002/200003 are generic parsing leads; verify the active ruleset.
// Correlate nonempty transaction IDs within the SAME gateway to avoid false joins.
let Access = AGWAccessLogs
    | where TimeGenerated between (ago(14d) .. now())
    | project TimeGenerated, ResourceId = tolower(_ResourceId), TransactionId,
        AccessHost = Host, ClientIp, HttpMethod, ServerStatus, ReceivedBytes;
let Bursts = Access
    | where HttpMethod == "POST"
    | summarize Events = count(), BackendErrors = countif(ServerStatus between (500 .. 599)),
        MaxRequestBytes = max(ReceivedBytes)
        by TimeGenerated = bin(TimeGenerated, 5m), ResourceId, Hostname = AccessHost, ClientIp
    | where BackendErrors >= 5 and MaxRequestBytes > 100000
    | extend Signal = "gateway-large-post-backend-error-burst",
        RuleId = "", WafAction = "", TransactionId = "";
let BodySignals = AGWFirewallLogs
    | where TimeGenerated between (ago(14d) .. now())
    | where RuleId in ("200002", "200003")
        or Message contains "multipart" or DetailedMessage contains "multipart"
        or Message contains "request body" or DetailedMessage contains "request body"
    | project TimeGenerated, ResourceId = tolower(_ResourceId), TransactionId,
        Hostname, ClientIp, RuleId, WafAction = Action
    | join kind=leftouter (
        Access
        | where isnotempty(TransactionId)
        | summarize arg_max(TimeGenerated, AccessHost, ServerStatus, ReceivedBytes) by ResourceId, TransactionId
    ) on ResourceId, TransactionId
    | extend Hostname = coalesce(AccessHost, Hostname), Signal = "gateway-body-parsing-rule-lead",
        Events = long(1), BackendErrors = iff(isnull(ServerStatus), long(null), tolong(iff(ServerStatus between (500 .. 599), 1, 0))),
        MaxRequestBytes = ReceivedBytes;
union Bursts, BodySignals
| project TimeGenerated, ResourceId, Hostname, ClientIp, Signal, Events,
    BackendErrors, MaxRequestBytes, RuleId, WafAction, TransactionId,
    EvidenceKind = "observed-generic-waf-http-lead"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "appgateway-waf",
  "platform": "sentinel-log-analytics",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "AGWAccessLogs",
    "AGWFirewallLogs"
  ],
  "rows": null,
  "queryTemplateSha256": "c65602666540bb47bfae878d53ff08df9af6d52c5c8572fea7f0b87f0ef5d057"
}

container-worker-errors

sentinel-log-analytics / detections/sentinel-log-analytics/container-worker-errors.kql

Without optional protection: Collected source logs can be queried manually without enabling Microsoft Sentinel.

Additional value: Explicitly configured Sentinel analytics can correlate ingested sources. Missing DCRs/tables, disabled rules and late ingestion remain gaps; a query result is not an alert or incident.

Telemetry: Not provided: AKS Container Insights and actual stderr/stdout logging required.

Required: ContainerLogV2.

Reviewed query template
// Log Analytics: requires Container Insights ContainerLogV2, not deployed here.
// PHP/FPM must actually send errors/worker output to stdout/stderr for collection.
// LogMessage can be dynamic. Workload failures do not establish exploitation.
ContainerLogV2
| where TimeGenerated between (ago(14d) .. now())
| extend Message = tostring(LogMessage)
| extend Signal = case(
    Message contains "Allowed memory size" or Message contains "Out of memory", "php-memory-error",
    Message contains "Maximum execution time", "php-execution-time",
    Message matches regex @"(?i)child( pid)? [0-9]+ exit(ed)? (on |with |)signal", "worker-signal-exit",
    Message matches regex @"(?i)child [0-9]+ exited with code [0-9]+ after", "worker-churn",
    Message matches regex @"(?i)PHP (Fatal error|Warning|Deprecated).*(session|upload_progress)", "php-session-diagnostic",
    "")
| where isnotempty(Signal)
| summarize Events = count(), Containers = make_set(ContainerName, 10),
    FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated)
    by TimeGenerated = bin(TimeGenerated, 10m), ResourceId = _ResourceId, PodNamespace, PodName, Signal
| project TimeGenerated, ResourceId, PodNamespace, PodName, Signal, Events,
    Containers, FirstSeen, LastSeen,
    EvidenceKind = "observed-container-worker-error-review-required"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "container-worker-errors",
  "platform": "sentinel-log-analytics",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "ContainerLogV2"
  ],
  "rows": null,
  "queryTemplateSha256": "76f5d8b8c9317185b755edaf9c1707dfa71812de6e494042b84e572773d8d224"
}

web-text-logs

sentinel-log-analytics / detections/sentinel-log-analytics/web-text-logs.kql

Without optional protection: Collected source logs can be queried manually without enabling Microsoft Sentinel.

Additional value: Explicitly configured Sentinel analytics can correlate ingested sources. Missing DCRs/tables, disabled rules and late ingestion remain gaps; a query result is not an alert or incident.

Telemetry: Not provided: custom tables, AMA DCR and privacy-preserving access-log format required.

Required: WebErrorLog_CL, WebAccessLog_CL.

Reviewed query template
// Log Analytics: requires custom DCR tables with TimeGenerated, RawData, Computer.
// Access format: timestamp|ip|method|uri|status|bytes|content-type|transfer-encoding|cookie-flag
// Only a 0/1/- response-cookie SIZE FLAG is collected, never cookie/session values.
// "-" means unobserved, not small. Malformed records remain visible as parse errors.
let Errors = WebErrorLog_CL
    | where TimeGenerated between (ago(14d) .. now())
    | where RawData matches regex @"(?i)(Allowed memory size|Out of memory|Maximum execution time|child( pid)? [0-9]+ exit(ed)? (on |with |)signal|child [0-9]+ exited with code [0-9]+ after)"
    | summarize Events = count()
        by TimeGenerated = bin(TimeGenerated, 10m), ResourceId = _ResourceId, Computer
    | extend ClientIp = "", Signal = "web-error-memory-or-worker-failure",
        MultipartErrors = long(0), ChunkedMultipart = long(0), LargeResponseCookieFlags = long(0),
        UnknownResponseCookie = long(0), MaxRequestBytes = long(0), ParseErrors = long(0);
let Access = WebAccessLog_CL
    | where TimeGenerated between (ago(14d) .. now())
    | parse RawData with LoggedAt "|" ClientIp "|" Method "|" Uri "|" Status:int "|" RequestBytes:long "|" ContentType "|" TransferEncoding "|" CookieFlag
    | extend ParseOk = isnotnull(Status) and Status between (100 .. 599)
        and isnotnull(RequestBytes) and RequestBytes >= 0 and CookieFlag in ("0", "1", "-");
let AccessSignals = Access
    | where ParseOk
    | summarize Events = count(),
        MultipartErrors = countif(Method == "POST" and ContentType contains "multipart/form-data" and Status between (500 .. 599)),
        ChunkedMultipart = countif(Method == "POST" and ContentType contains "multipart/form-data" and TransferEncoding has "chunked"),
        LargeResponseCookieFlags = countif(CookieFlag == "1"), UnknownResponseCookie = countif(CookieFlag == "-"),
        MaxRequestBytes = max(RequestBytes)
        by TimeGenerated = bin(TimeGenerated, 10m), ResourceId = _ResourceId, Computer, ClientIp
    | where MultipartErrors >= 3 or ChunkedMultipart >= 1 or LargeResponseCookieFlags >= 1
    | extend Signal = "web-access-multipart-or-response-cookie-flag", ParseErrors = long(0);
let ParseFailures = Access
    | where not(ParseOk)
    | summarize Events = count()
        by TimeGenerated = bin(TimeGenerated, 10m), ResourceId = _ResourceId, Computer
    | extend ClientIp = "", Signal = "collection-parse-error-review-required",
        MultipartErrors = long(0), ChunkedMultipart = long(0), LargeResponseCookieFlags = long(0),
        UnknownResponseCookie = long(0), MaxRequestBytes = long(0), ParseErrors = Events;
union Errors, AccessSignals, ParseFailures
| project TimeGenerated, ResourceId, Computer, ClientIp, Signal, Events,
    MultipartErrors, ChunkedMultipart, LargeResponseCookieFlags, UnknownResponseCookie,
    MaxRequestBytes, ParseErrors, EvidenceKind = "observed-custom-web-log-review-required"

Actual result: not-applicable / 2026-10-24T18:35:03.365Z; not executed.

This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.

Reviewed result and public fields
{
  "id": "web-text-logs",
  "platform": "sentinel-log-analytics",
  "status": "not-applicable",
  "collectedAt": "2026-10-24T18:35:03.365Z",
  "rowCount": null,
  "reason": "This hunt requires a separately authorized VM, registry/container, Application Gateway or custom-log scope; none is selected by this App Service console.",
  "missingTables": [
    "WebErrorLog_CL",
    "WebAccessLog_CL"
  ],
  "rows": null,
  "queryTemplateSha256": "f83b5fe602995145ee5579a900182e312e656dc404fe02d74cdf7aaa9356f37d"
}