Inventory Exposed Spring Boot Actuator Endpoints


Inventory of Spring Boot Actuator endpoints that answer 200 anywhere in your estate — flips the entity from 'who scanned us' (infinite) to 'which of my services answer' (finite and fixable). Recovers the real management base path from tr...

KQL Library  /  Posture

 Posture inventory-exposed-spring-boot-actuator-endpoints.kql

Inventory of Spring Boot Actuator endpoints that answer 200 anywhere in your estate — flips the entity from "who scanned us" (infinite) to "which of my services answer" (finite and fixable). Recovers the real management base path from traffic.

Techniques T1595.002 T1190
Platforms Web Multi-cloud
Deep Dive  Deep Dive
// Author: Ian D. Hanley (DevSecOpsDad) | linkedin.com/in/ianhanley | devsecopsdad.com | devsecopsdadattack.com
// Inventory of Spring Boot Actuator endpoints that answer 200 anywhere in your estate — flips the entity
// from "who scanned us" (infinite) to "which of my services answer" (finite and fixable). Recovers the
// real management base path from traffic, classifies P1-P5 by secret-bearing vs state-changing endpoint,
// and requires ONE confirmed 200 in 30 days rather than a request-volume threshold.
// Source: KQL Detection of the Week: A Heap of Trouble (2026-08-03) — https://devsecopsdadattack.com/2026-08-03-KQL-Detection-of-the-Week_-A-Heap-of-Trouble-Detecting-Spring-Boot-Heapdump-Theft-When-the-Exfiltration-Is-a-GET-Request_/
// Tactics: Reconnaissance, Initial Access
// Techniques: T1595.002, T1190
// Platforms: Web, Multi-cloud
// Data: _Im_WebSession, CommonSecurityLog

let lookback = 30d;
let ActuatorEndpoints = dynamic([
    "heapdump","env","configprops","beans","threaddump","mappings",
    "loggers","httpexchanges","httptrace","auditevents","jolokia","dump","trace",
    "health","info","metrics","shutdown","restart","refresh"
]);
let SecretBearing = dynamic(["heapdump","env","configprops","jolokia","dump"]);
let StateChanging = dynamic(["shutdown","restart","refresh","loggers"]);
// DERIVED. This one mattered more than Act I's: `health` and `info` are on the endpoint
// list deliberately, so that base-path recovery works on services where the only thing
// anyone ever requests is the health check -- and my hand-written prefilter omitted
// both, which defeated the entire reason they were there.
let PrefilterTerms = array_concat(ActuatorEndpoints, dynamic(["actuator"]));
_Im_WebSession(starttime=ago(lookback), url_has_any=PrefilterTerms)
| extend UrlLower = tolower(url_decode(tostring(Url)))
| extend PathOnly = trim_start(@"[a-z][a-z0-9+.\-]*://[^/]*", UrlLower)
| extend PathOnly = tostring(split(tostring(split(tostring(split(PathOnly,"?")[0]),"#")[0]),";")[0])
| extend NormPath = trim_end(@"/+", replace_regex(PathOnly, @"/{2,}", "/"))
| extend Segments = split(NormPath, "/")
// Position-independent, same as Act I -- /health/{component}, /metrics/{name} and
// /env/{property} all put the endpoint ID in the middle of the path.
| extend Matched = set_intersect(Segments, ActuatorEndpoints)
| where array_length(Matched) > 0
| extend Endpoint    = tostring(Matched[0])
| extend EndpointIdx = array_index_of(Segments, Endpoint)
// The management BASE PATH, recovered from the data rather than assumed. This is the
// output that goes to the platform team: it tells them where their surface actually is.
| extend BasePath = iff(EndpointIdx <= 0, "",
                        strcat_array(array_slice(Segments, 0, EndpointIdx - 1), "/"))
| extend Selector = strcat_array(array_slice(Segments, EndpointIdx + 1, -1), "/")
// Same three-state result verdict as Act I. EventResult "Success" is ANY status below
// 400, so it cannot stand in for a 200 -- and on this endpoint the sub-400 response
// you'll actually meet is a 302 to a login page, i.e. the healthy case.
| extend StatusRaw  = coalesce(tostring(EventResultDetails),
                               tostring(column_ifexists("EventOriginalResultDetails","")))
| extend StatusCode = toint(extract(@"(\d{3})", 1, StatusRaw))
| extend AnswerVerdict = case(
      StatusCode == 200,                   "Served",
      StatusCode between (300 .. 399),     "Redirected",
      StatusCode in (401, 403),            "AuthWall",
      StatusCode == 404,                   "NotPresent",
      isnotnull(StatusCode),               "OtherStatus",
      tostring(EventResult) =~ "Success",  "MaybeServed",
                                           "NotServed")
| extend RespBodyBytes = column_ifexists("HttpResponseBodyBytes", long(null))
| extend RespDstBytes  = column_ifexists("DstBytes", long(null))
| extend RespBytes = coalesce(iff(RespBodyBytes > 0, RespBodyBytes, long(null)),
                              RespDstBytes)
// A row with no destination identity is NOT dropped. It is bucketed. An unattributable
// 200 on /heapdump is a telemetry finding AND possibly a security one, and `where
// isnotempty(Service)` -- which is what I originally wrote here -- deletes both.
| extend
    Service   = coalesce(tostring(column_ifexists("HttpHost","")),
                         tostring(column_ifexists("DstFQDN","")),
                         tostring(DstIpAddr),
                         "UNATTRIBUTED"),
    UserAgent = tostring(column_ifexists("HttpUserAgent", ""))
// ipv4_is_private() returns null for a v6 literal, and not(null) is null, so a v6
// client would count as neither internal nor external. Default it to external --
// the safer direction for a surface report.
| extend ExternalClient = coalesce(not(ipv4_is_private(tostring(SrcIpAddr))), true)
// Entity = the SERVICE and the ENDPOINT. One row per thing you might have to fix.
| summarize
    Requests        = count(),
    ServedHits      = countif(AnswerVerdict == "Served"),
    MaybeServedHits = countif(AnswerVerdict == "MaybeServed"),
    RedirectHits    = countif(AnswerVerdict == "Redirected"),
    NotFoundHits    = countif(AnswerVerdict == "NotPresent"),
    AuthWallHits    = countif(AnswerVerdict == "AuthWall"),
    // A 500 or a 405 is not "not mounted" -- it is a mounted endpoint failing. Counted,
    // because without this counter those rows fall through the case below into
    // "OK - not mounted" and get filtered out of the report entirely.
    OtherStatusHits = countif(AnswerVerdict == "OtherStatus"),
    DistinctClients = dcount(SrcIpAddr),
    ExternalClients = dcountif(SrcIpAddr, ExternalClient),
    MaxRespBytes    = max(RespBytes),
    BasePaths       = make_set_if(BasePath, isnotempty(BasePath), 5),
    Selectors       = make_set_if(Selector, isnotempty(Selector), 10),
    SampleClients   = make_set(SrcIpAddr, 10),
    Statuses        = make_set(StatusRaw, 10),
    Verdicts        = make_set(AnswerVerdict, 8),
    Agents          = make_set(UserAgent, 5),
    ActiveDays      = dcount(bin(TimeGenerated, 1d)),
    FirstSeen       = min(TimeGenerated),
    LastSeen        = max(TimeGenerated)
    by Service, Endpoint
// Exposed == it returned a CONFIRMED 200 at least once. Not "answered a lot". Once.
// A redirect is not an answer -- it is usually the auth wall doing its job.
| extend
    Exposed         = ServedHits > 0,
    PossiblyExposed = ServedHits == 0 and MaybeServedHits > 0,
    Protected       = ServedHits == 0 and MaybeServedHits == 0
                          and (AuthWallHits > 0 or RedirectHits > 0),
    // Mounted and failing. Evidence of a management surface, just not of a served one.
    MountedErroring = ServedHits == 0 and MaybeServedHits == 0
                          and AuthWallHits == 0 and RedirectHits == 0
                          and OtherStatusHits > 0,
    NotPresent      = ServedHits == 0 and MaybeServedHits == 0
                          and AuthWallHits == 0 and RedirectHits == 0
                          and OtherStatusHits == 0 and NotFoundHits > 0,
    LeaksSecrets    = Endpoint in (SecretBearing),
    ChangesState    = Endpoint in (StateChanging),
    FoundExternally = ExternalClients > 0,
    MaxReadable     = format_bytes(coalesce(MaxRespBytes, long(0)))
// The size field resolves most of the unverified band without a status code: a 302
// carries a few hundred bytes and a heap dump carries hundreds of megabytes. Null
// MaxRespBytes makes this null, the case falls through, and the row stays unverified.
| extend LikelyServed = PossiblyExposed and MaxRespBytes >= 1048576
| extend Confirmed = Exposed or LikelyServed
| extend Priority = case(
      Confirmed and LeaksSecrets and FoundExternally, "P1 - secrets, externally reachable",
      Confirmed and ChangesState and FoundExternally, "P1 - state change, externally reachable",
      Confirmed and LeaksSecrets,                     "P2 - secrets, internal callers only",
      Confirmed and ChangesState,                     "P2 - state change, internal callers only",
      Confirmed,                                      "P3 - management surface exposed",
      PossiblyExposed and LeaksSecrets,               "P4 - unverified, secrets endpoint answered sub-400",
      PossiblyExposed,                                "P4 - unverified, sub-400 with no status code",
      Protected,                                      "P5 - mounted, authenticated or redirected",
      MountedErroring,                                "P5 - mounted, erroring",
                                                      "OK - not mounted")
| extend Unattributed = Service == "UNATTRIBUTED"
| where not(Priority startswith "OK")
| order by Priority asc, MaxRespBytes desc, ExternalClients desc, ServedHits desc