references/all-security-events.md
# All Security Events (Cross-Provider Summary)
Unified view across Dynatrace-native **and** external security findings —
vulnerabilities, detections, compliance findings, and scan events from
Dynatrace RVA / SPM / RAP and any external security tool ingested via OpenPipeline.
> **Use this when the question spans providers.** For Dynatrace-native only, or advanced use cases per type,
> use [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md), [compliance.md](compliance.md),
> or [detections.md](detections.md).
## Contents
- [When to Use](#when-to-use)
- [Providers & Products](#providers--products)
- [Semantic-Dictionary Fields](#semantic-dictionary-fields)
- [Canonical Cross-Provider Query](#canonical-cross-provider-query)
- [Double-Counting Guard](#double-counting-guard)
- [Broad-Question Query Decomposition](#broad-question-query-decomposition)
- [Time Window Guidance](#time-window-guidance-for-cross-provider-queries)
- [Security Posture Overview](#security-posture-overview--products-and-volumes-uc-g8) (UC-G8)
- [Recent Findings Stream](#recent-cross-provider-findings-stream-uc-g2) (UC-G2)
- [Findings for a Specific Entity](#security-findings-for-a-specific-entity-uc-g3--uc-g4) (UC-G3/G4)
- [Common Workflows](#common-workflows)
- [Event Type Reference](#event-type-reference)
- [Best Practices](#best-practices)
---
## When to Use
Use this pattern when a user asks:
- "What security findings do we have across all tools?"
- "Which providers are reporting detections this week?"
- "How many critical findings across Dynatrace and our external security tools?"
- "What integrations are sending us security data?"
For single-provider / single-event-type questions, use the more specific
references — they give tighter queries and richer fields.
---
## Providers & Products
Dynatrace-native sources use these provenance fields:
| `event.provider` | `product.name` | Notes |
|---|---|---|
| `Dynatrace` | (varies) | Dynatrace-native (RVA, SPM, internal). Filter native vulnerability/compliance via `product.vendor == "Dynatrace"`. |
| `OneAgent` | `Runtime Application Protection` | Dynatrace RAP — runtime attack detections. Both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated; either filter works (skill prefers the latter as canonical). Passes the cross-provider double-counting guard (see Canonical Cross-Provider Query below). |
| `Dynatrace Automated Detections` | `Automated Detections` | Dynatrace built-in / custom detection rules — emits both `DETECTION_FINDING` and `DETECTION_EXECUTION_SUMMARY` |
External findings arrive from any ingested security tool (cloud-security posture/threat
services, SAST/SCA scanners, SIEM/SOAR, WAF/edge, etc.). They are intentionally **not**
enumerated here — the skill stays provider-neutral. Always discover what's actually active
in the tenant rather than assuming a provider name. This discovery enumerates **external +
DT-detection** providers; the double-counting guard keeps it off the high-cardinality DT
RVA/KSPM snapshot streams (whose presence is confirmed separately via the constrained-window
queries — see [Broad-Question Query Decomposition](#broad-question-query-decomposition)):
```dql
fetch security.events, from: -24h
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| summarize count = count(), by: {event.provider, product.name, product.vendor, event.type}
| sort count desc
```
> **Window cost note:** even with the double-counting guard, the external
> `VULNERABILITY_FINDING` streams are high-volume — a `-7d` discovery scan can approach or
> exceed the 500 GB scan limit (≈9.5 GB at `-24h` vs. 500 GB+ at `-7d` on a busy tenant).
> `-24h` is the safe **default** when no window is specified; active integrations emit well
> within a day. **Honor an explicitly requested window** (e.g. "in the last 7 days" → `-7d`) —
> just expect the larger scan and add `scanLimitGBytes` if it trips the limit.
### Scoping to a Specific Provider (any finding type)
This pattern is shared by detections, external vulnerabilities, and external compliance —
the per-domain references link here rather than restating it.
**Step 1 — discover the exact provider strings.** Run the discovery query above first. Provider identity may live in `event.provider`, `product.vendor`, or both — and a single integration can appear under two paths (e.g. direct ingest **and** via AWS Security Hub).
**Step 2 — scope with exact match once strings are confirmed.** Prefer exact equality over fuzzy `contains` — it avoids false positives from providers whose names share a substring with the target:
```dql-snippet
// Exact match (preferred for known provider — substitute strings from discovery)
| filter event.provider == "<ExactProviderString>"
OR product.name == "<ExactProductString>"
```
If the same integration appears via multiple paths (e.g. `event.provider == "Amazon GuardDuty"` for the direct integration and `event.provider == "AWS Security Hub" AND product.name == "GuardDuty"` for the Security Hub relay), combine both exact conditions with `OR`.
**During discovery only — fuzzy match.** Use `contains(lower(...))` only when the exact string is not yet known:
```dql-snippet
| filter contains(lower(event.provider), "<provider>")
OR contains(lower(product.vendor), "<provider>")
```
- **All external** (exclude Dynatrace-native): `filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"`.
- For **hyperscaler-specific** provider field handling (cloud resource IDs, ARNs, account /
subscription / project scoping), use the dedicated AWS / Azure / GCP skills — this skill
keys off the generic `object.*` / `dt.security.*` namespaces.
---
## Semantic-Dictionary Fields
Cross-provider queries rely on the normalized semantic-dictionary fields that every
provider populates. These are the fields to filter, summarize, and project on
when mixing providers:
| Field | Description |
|---|---|
| `event.id` | Unique event identifier |
| `event.provider` | Source integration |
| `event.type` | Finding / scan event type |
| `finding.id` | Unique finding identifier |
| `finding.title` | Human-readable finding title |
| `finding.type` | Finding type |
| `finding.time.created` | When the finding was created by the source |
| `dt.security.risk.level` | Normalized risk: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `NONE`, `NOT_AVAILABLE` |
| `object.id` | Affected object ID |
| `object.name` | Affected object display name |
| `object.type` | Affected object type |
| `product.name`, `product.vendor` | Generating product/vendor |
**Mandatory for all cross-provider counts and summaries.** These guards filter out non-conformant rows that the Threats & Exploits app UI silently ignores. Without them, DQL counts include malformed provider/product/entity rows, making DQL totals exceed app totals. Never drop these guards on cross-provider count, percentage, or summary queries:
```dql-snippet
| filter isNotNull(event.id)
AND isNotNull(event.provider)
AND isNotNull(finding.type)
AND isNotNull(finding.id)
AND isNotNull(finding.time.created)
AND isNotNull(finding.title)
AND isNotNull(dt.security.risk.level)
AND isNotNull(object.id)
AND isNotNull(object.type)
```
---
## Canonical Cross-Provider Query
The canonical cross-provider summary template — combines external findings and Dynatrace `DETECTION_FINDING` rows under the SD contract:
```dql
fetch security.events, from: -24h
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| filter in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})
// All three finding types are required — dropping any one omits an entire category:
// VULNERABILITY_FINDING covers external scanners; DETECTION_FINDING covers RAP + external;
// COMPLIANCE_FINDING covers KSPM + external posture tools.
// Require semantic-dictionary-compliant rows
| filter isNotNull(event.id)
AND isNotNull(event.provider)
AND isNotNull(finding.type)
AND isNotNull(finding.id)
AND isNotNull(finding.time.created)
AND isNotNull(finding.title)
AND isNotNull(dt.security.risk.level)
AND isNotNull(object.id)
AND isNotNull(object.type)
// Compliance: only failed (skip PASSED, MANUAL, NOT_RELEVANT)
| filter (not exists(compliance.result.status.level)
OR compliance.result.status.level == "FAILED"
OR compliance.status == "FAILED")
| summarize {
finding.count = count(),
affected_object.types = arrayRemoveNulls(collectDistinct(object.type)),
affected_smartscape.node.ids = arrayRemoveNulls(collectDistinct(dt.smartscape_source.id)),
finding.ids = arrayRemoveNulls(collectDistinct(finding.id)),
finding.titles = arrayRemoveNulls(collectDistinct(finding.title)),
finding.times = collectDistinct(finding.time.created),
affected_object.ids = arrayRemoveNulls(collectDistinct(object.id)),
affected_object.names = arrayRemoveNulls(collectDistinct(object.name)),
vulnerable_components = arrayConcat(
arrayRemoveNulls(collectDistinct(software_component.name)),
arrayRemoveNulls(collectDistinct(component.name))
)
}, by: {event.provider, product.name, event.type, dt.security.risk.level}
| sort finding.count desc
| limit 100
```
### Mandatory guards for any cross-provider count or summary
The canonical query above includes four structural guards. **All four must be applied together** for cross-provider count/percentage/summary questions — dropping any one silently corrupts the result. Treat them as mandatory boilerplate, not optional optimization:
1. **SD-isNotNull guard** — `isNotNull(event.id) AND isNotNull(event.provider) AND isNotNull(finding.type) AND ...` drops rows that don't satisfy the Semantic Dictionary contract. Without it, the Threats & Exploits app filters them out but DQL counts them, so DQL totals exceed app totals. The short form `filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)` catches most malformed rows; the full 9-field guard in the canonical query above is authoritative.
2. **Cross-finding-type union** — `filter in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})` restricts to the three normalized finding streams. All three are required — dropping any one omits an entire category. Do not narrow to a single event type for a cross-provider summary.
3. **Compliance-FAILED guard** — `filter (not exists(compliance.result.status.level) or compliance.result.status.level == "FAILED" or compliance.status == "FAILED")` skips PASSED / MANUAL / NOT_RELEVANT compliance rows.
4. **Double-counting guard** — `filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"` (see § below).
**Use `summarize` — never raw row projection past `limit 50`.** The cross-provider stream is high-volume; raw `| fields ... | sort timestamp desc | limit 50` will pick rows from whichever provider happens to emit first and silently truncate the breadth the question asked for. The canonical pattern is `fetch → filter → summarize → sort → limit`.
If any guard is intentionally dropped (e.g. the UC-G2 raw stream below), state the reason and the consequence in your final answer.
---
## Double-Counting Guard
When mixing providers, Dynatrace-native **vulnerability** and **compliance**
findings should be read via the dedicated tools (see
[vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md), [compliance.md](compliance.md)) which
correctly dedup state reports and scans. In a cross-provider summary, **exclude
Dynatrace-generated vulnerabilities and compliance findings** to avoid counting
each state-report row and every scan attempt:
```dql-snippet
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
```
This allows:
- **Dynatrace detections** (one-shot `DETECTION_FINDING` — safe to include)
- **All external findings** (not from Dynatrace at all)
and excludes:
- Dynatrace vulnerability state reports (inflate counts massively)
- Dynatrace compliance findings (per-(rule, object, scan) rows)
To see Dynatrace-native vulnerability / compliance counts alongside external
findings, compute them separately via [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md)
and [compliance.md](compliance.md) and merge the results at the presentation
layer. For broad/overview questions, the
[Broad-Question Query Decomposition](#broad-question-query-decomposition) below
operationalizes this into a concrete 3-query plan.
---
## Broad-Question Query Decomposition
A **broad question** spans all finding categories **including Dynatrace-native** rather
than one stream — e.g. "which security products are integrated?", "give me a security
posture overview", "what security data do we have across everything?", or any cross-category
count. **Default these to DT-inclusive** — they *must* query DT vulnerabilities (RVA) and
compliance (KSPM), not just external findings. Do **not** answer them with a single wide
`fetch security.events` over every event type: that scans the high-cardinality DT RVA
state-report stream (emitted every ~15 min per `(vulnerability, entity)` pair) and the DT
KSPM compliance stream, which is expensive even at `24h` **and** double-counts snapshot rows.
> **Not this:** the single external-only
> [Which external integrations are active](#which-external-integrations-are-active-and-volume)
> query applies **only** when the user explicitly scopes to external / third-party tools — e.g.
> "which **external** integrations / tools are sending us data?". A bare "which security products
> are integrated?" is **DT-inclusive by default** → use the decomposition (which queries RVA and
> KSPM). When in doubt, decompose.
Instead, **decompose into three independent queries, each on its own window, and
merge at the presentation layer:**
| Stream | Covers | Query | Window |
|---|---|---|---|
| **A — External + DT detections** | all external providers (vulnerability / detection / compliance findings) + DT RAP & Automated Detections | [Canonical Cross-Provider Query](#canonical-cross-provider-query) (double-counting + SD guards) | `24h` (widen as the question needs) |
| **B — DT vulnerabilities (RVA)** | Dynatrace-native CVEs / runtime vulnerabilities | canonical RVA pipeline → [vulnerabilities-dynatrace.md § DT RVA: Full Snapshot Queries](vulnerabilities-dynatrace.md) | `30m` fixed |
| **C — DT compliance (KSPM)** | Dynatrace-native CIS / DORA / NIST / STIG | canonical SPM pipeline → [compliance.md § DT SPM: Base Pattern](compliance.md) | `1h` fixed |
Stream A's double-counting guard
(`product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"`) deliberately
excludes DT RVA and DT KSPM — they are **absent from Stream A by design** and must
be picked up by Streams B and C. A `30m` RVA snapshot and a `1h` KSPM snapshot report
each product's **current finding count** without scanning history.
> **Empty snapshot ≠ not integrated.** A `0` result from a 30m/1h snapshot means *no
> recent findings* — not that the product is absent. Before reporting RVA or KSPM as
> "not integrated", confirm presence with a wider low-cardinality probe over `24h` — RVA
> `VULNERABILITY_STATE_REPORT_EVENT` filtered to `event.provider=="Dynatrace"`, KSPM
> `COMPLIANCE_SCAN_COMPLETED` filtered to `product.vendor=="Dynatrace"` (RVA keys off
> `event.provider`, SPM off `product.vendor` — see [data-model.md § Provider Taxonomy](data-model.md)).
> These marker streams are far cheaper than the full finding streams, so a `24h` presence check
> stays inexpensive.
**Prohibitions:**
- Never answer a broad question with a single unguarded `fetch security.events`
over all event types, or a `from: -7d` full-table scan to "list every provider".
- Never widen Streams B/C to `24h` to fold them into Stream A — that reintroduces
the cost blowup and double-counting the guard exists to prevent.
To present the merged result, label each stream's rows by source (external provider
name / `Dynatrace RVA` / `Dynatrace KSPM`) and concatenate — the three streams are
disjoint by construction.
> **Presentation order — lead with compliance (CIS).** When merging the streams for a
> broad question, present **Stream C's KSPM compliance failed-rule count as the headline
> summary**, with **CIS first** (CIS is the mandatory K8s baseline; sort it to the top via
> [compliance.md § CIS-Primary Standard Summary](compliance.md)). List the other KSPM
> standards (DORA / NIST / DISA STIG) immediately after as *additional* failed rules that
> may overlap with CIS — never sum failed counts across standards. Then show Stream B (RVA
> vulnerabilities) and Stream A (external + DT detections) beneath. This is a **presentation**
> choice only — the streams stay disjoint by construction (a correctness property); leading
> with compliance does not merge or re-weight the underlying counts.
**Entity-scoped broad questions follow the same decomposition.** If the user asks
for "security findings" on a specific entity (host, K8s node, workload, cluster,
service), do not query only RVA/SPM. Run Stream A scoped to the entity with the
wide entity OR-chain (`dt.smartscape_source.id`, `dt.entity.*`, `object.*`,
relevant `k8s.*`) and merge it with
Stream B (RVA `affected_entity.*` / `related_entities.*`) and Stream C (SPM object
scope). A 0-row Stream A result means **no external findings matched that entity**
for the stated window — it is still part of the answer. Apply the same CIS-led
presentation order above.
When the user explicitly asks about *compliance / misconfigurations* on the entity (rather
than all findings), render Stream C as the **Entity Security-Tab View** — three tables
mirroring the entity Security tab (CIS default, failed-only):
**Table 1** Dynatrace CIS failed rules → **Table 2** other DT standards (DORA/NIST/STIG,
overlap caveat) → **Table 3** the external `COMPLIANCE_FINDING` subset of Stream A (externally
ingested misconfigurations) scoped to the entity. See
[compliance.md § Entity Security-Tab View](compliance.md) (broad posture/count questions
instead use § CIS-Primary Standard Summary).
---
## Time Window Guidance for Cross-Provider Queries
The canonical cross-provider pattern in this file **always** includes the double-counting guard:
```dql-snippet
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
```
This guard excludes DT RVA vulnerability state reports and DT KSPM compliance findings — the high-cardinality snapshot streams that carry strict 30m / 1h window requirements. Because those streams are already filtered out, **cross-provider queries are not snapshot-constrained**.
Default windows by query class:
| Query class | Default window | Notes |
|---|---|---|
| Cross-provider summary (count/breakdown/aggregation) | **`24h`** | Summaries aggregate over time; start broad to avoid undercounting |
| Cross-provider retrieval (raw finding listing) | `2h` first attempt | Widen to `24h` only if zero rows returned |
`from:now()-24h` is the reliable default for summary questions. Starting at `2h` for retrieval queries limits data volume on first attempt; widening is safe since the double-counting guard is already in place.
For the domain-specific snapshot constraints (RVA 30m fixed window, SPM 1h scan-completion join, detection stream guidance), see the specialist references:
- [vulnerabilities-dynatrace.md § Snapshot vs. History](vulnerabilities-dynatrace.md) — RVA 30m fixed window
- [compliance.md § Snapshot vs. History](compliance.md) — SPM 1h fixed window
- [detections.md](detections.md) — detection streams have no snapshot constraint; use the window that covers the attack history the question requires
**Matching a UI app's view.** If the user asks "what does the Threats & Exploits / SPM / Vulnerabilities app show right now?", apply the app's default time-picker value. App defaults are defined in [SKILL.md § Default Time Ranges](../SKILL.md#default-time-ranges-in-the-dynatrace-apps).
---
## Security Posture Overview — Products and Volumes (UC-G8)
A posture overview ("which products are reporting findings?", "which products cover entity X?")
is a **broad question** — resolve it with the
[Broad-Question Query Decomposition](#broad-question-query-decomposition): run the three streams
separately and merge. Do **not** run a single `fetch security.events` over all event types —
that double-counts the DT RVA/KSPM snapshot streams and scans them needlessly.
**Stream A — external + DT-detection products and volumes** (guarded, `24h`):
```dql
fetch security.events, from:now()-24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| summarize {
Findings=countIf(in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})),
Scans=countIf(in(event.type,{"VULNERABILITY_SCAN","COMPLIANCE_SCAN"}))
}, by:{Product=product.name, Provider=event.provider}
| sort Findings desc
```
**Stream B — DT RVA vulnerability count** (`30m` fixed): run the canonical RVA pipeline
([vulnerabilities-dynatrace.md § DT RVA: Full Snapshot Queries](vulnerabilities-dynatrace.md)) and take its
vulnerability count.
**Stream C — DT KSPM compliance count** (`1h` fixed): run the canonical SPM pipeline
([compliance.md § DT SPM: Base Pattern](compliance.md)) and take its failed-control count.
Merge: present Stream A's per-product rows, then append `Dynatrace RVA` (Stream B count) and
`Dynatrace KSPM` (Stream C count) as their own product rows. The double-counting guard keeps the
three streams disjoint, so the merged list has exactly one row per integrated product.
## Recent Cross-Provider Findings Stream (UC-G2)
Latest 100 ingested findings across all providers. For genuinely "new" findings use
`toTimestamp(finding.time.created) > now() - 24h`. This approach works for all
`*_FINDING` event types.
> **Always include the double-counting guard, even on this raw stream.** Earlier
> versions of this section dropped the `product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"` guard with the rationale "we already filter to `*_FINDING`
> event types, so state-report duplication isn't possible." That's true for the
> three event types listed, but the moment a downstream question becomes "give me
> a summary of what arrived" / "by provider" / "by risk level", the model adds a
> `summarize count()` to this stream and Dynatrace-native vulnerability and
> compliance findings *do* get double-counted (per-(rule, object, scan) rows on
> SPM, per-(vuln, entity) on RVA `VULNERABILITY_FINDING`). Keep the guard in by
> default — it's a one-line filter on an already-filtered stream, and it makes
> the snippet copy-paste-safe for the common hybrid case where a user asks for a
> "recent" feed but expects a digested summary.
```dql
fetch security.events, from:now()-24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| sort timestamp desc
| limit 100
```
To keep results lean while preserving entity context, add a `fieldsKeep` projection (see [common-patterns.md § 17](common-patterns.md)):
```dql
fetch security.events, from:now()-24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| sort timestamp desc
| limit 100
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
event.type, event.provider, product.name,
finding.id, finding.title, dt.security.risk.level,
object.id, object.name, object.type
```
For new findings only in a specific window:
```dql
fetch security.events, from:now()-24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter toTimestamp(finding.time.created) > now() - 24h
| summarize findings=count(), by:{event.type, event.provider, dt.security.risk.level}
| sort findings desc
```
> **DT RVA exception:** for newly-OPEN Dynatrace vulnerabilities use
> `toTimestamp(vulnerability.resolution.change_date) > now() - 24h` after the
> canonical RVA pipeline (see [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md)).
---
## Security Findings for a Specific Entity (UC-G3 / UC-G4)
For a named entity, find all security findings across types. Use the entity OR-chain
from [common-patterns.md § 5](common-patterns.md#5-wide-entity-scoping-or-chain)
to match the entity across the rich scoping-field set.
**Direct findings** (entity is the `object.id`/`object.name`):
```dql
fetch security.events, from:now()-24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| filter object.name == "my-service" OR object.id == "PROCESS_GROUP-1234567890ABCDEF"
| summarize {
findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH")
}, by:{event.type, event.provider, product.name}
| sort Critical desc
```
**Including indirect/related entities (UC-G4):** for DT RVA vulnerabilities,
run the canonical pipeline and then filter on `related_entities.names` or
`related_entities.ids` after Step 3 to capture findings where the entity is a
related (not directly affected) entity:
```dql-snippet
// After Steps 1–3 of the RVA pipeline:
| filter in("my-service", related_entities.names)
OR in("PROCESS_GROUP-1234...", related_entities.ids)
```
**Note:** UC-G5 (problem-scoped security) first requires extracting affected entity
IDs from the problem via a separate skill (`dt-obs-problems`), then passing those
IDs into the entity OR-chain above. This is a multi-skill workflow — the security
query itself is identical to UC-G4.
---
## Common Workflows
### What's hitting us, by provider and risk
```dql
fetch security.events, from: -24h
| filterOut isNull(event.type) or isNull(object.id) or isNull(finding.id)
| filter in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
| filter (not exists(compliance.result.status.level)
OR compliance.result.status.level == "FAILED"
OR compliance.status == "FAILED")
| summarize findings = count(),
by: {event.provider, event.type, dt.security.risk.level}
| sort findings desc
```
### Cross-provider critical findings summary (canonical recipe)
All four mandatory guards applied; all three finding types included; all four `by:` keys preserved. Use this as the reference template for any "critical findings across providers" question:
```dql
fetch security.events, from: -24h
// Double-counting guard: exclude DT vulnerability/compliance state reports; keep DT detections
| filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"
// All three finding types required — do not drop any
| filter in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})
// SD-isNotNull guard: mandatory — prevents malformed rows from inflating counts
| filter isNotNull(event.id) AND isNotNull(event.provider) AND isNotNull(finding.type)
AND isNotNull(finding.id) AND isNotNull(finding.time.created) AND isNotNull(finding.title)
AND isNotNull(dt.security.risk.level) AND isNotNull(object.id) AND isNotNull(object.type)
// Compliance guard: count only failed controls
| filter (not exists(compliance.result.status.level)
OR compliance.result.status.level == "FAILED"
OR compliance.status == "FAILED")
| filter dt.security.risk.level == "CRITICAL"
// Preserve all four by: keys — do not drop any
| summarize {
findings.count = count(),
finding.titles = arraySlice(collectDistinct(finding.title), from: 0, to: 5),
affected_object.ids = arrayRemoveNulls(collectDistinct(object.id)),
affected_object.names = arraySlice(collectDistinct(object.name), from: 0, to: 10)
}, by: {event.provider, product.name, event.type, dt.security.risk.level}
| sort findings.count desc
| limit 50
```
### Which external integrations are active (and volume)
Enumerate the **external** security tools sending data, with event volume and first/last-seen.
This is the integration-health question — distinct from a DT-inclusive posture overview (for
that, use the [Broad-Question Query Decomposition](#broad-question-query-decomposition)). Exclude
Dynatrace-native sources; the `FirstSeen`/`LastSeen` grain makes a multi-day window meaningful,
so `-7d` is the natural default here (honor an explicitly requested window):
```dql
fetch security.events, from:now()-7d
| filter isNotNull(event.provider) and event.provider != "Dynatrace"
| summarize {
Events = count(),
EventTypes = collectDistinct(event.type),
Findings = countIf(in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})),
Scans = countIf(in(event.type, {"VULNERABILITY_SCAN", "COMPLIANCE_SCAN"})),
FirstSeen = takeMin(timestamp),
LastSeen = takeMax(timestamp)
}, by: {event.provider, product.vendor}
| sort Events desc
```
> **Scan-cost caveat:** external `VULNERABILITY_FINDING` volume is high (millions of rows on a
> busy tenant) — a `-7d` scan here can approach the 500 GB limit. If you only need *current*
> activity rather than 7-day first/last-seen, narrow to `-24h`; otherwise add `scanLimitGBytes`
> to the `fetch`.
### GuardDuty risk and resource summary (named-provider exact-match example)
GuardDuty data may arrive via two paths in the same tenant — match both. The exact provider strings below were verified via the discovery query (`event.provider == "Amazon GuardDuty"` for the direct integration, `event.provider == "AWS Security Hub" AND product.name == "GuardDuty"` for the Security Hub relay). **Run the discovery query on your tenant first to confirm which paths are active.**
```dql
fetch security.events, from: -24h
// Exact-match both GuardDuty ingestion paths
| filter (event.provider == "Amazon GuardDuty")
OR (event.provider == "AWS Security Hub" AND product.name == "GuardDuty")
| filter event.type == "DETECTION_FINDING"
// SD-isNotNull guard — mandatory
| filter isNotNull(event.id) AND isNotNull(event.provider) AND isNotNull(finding.type)
AND isNotNull(finding.id) AND isNotNull(finding.time.created) AND isNotNull(finding.title)
AND isNotNull(dt.security.risk.level) AND isNotNull(object.id) AND isNotNull(object.type)
// Preserve identity: object.type, object.id, object.name, and event.provider distinguish resources
| summarize {
findings.count = count(),
finding.titles = collectDistinct(finding.title),
affected_object.ids = collectDistinct(object.id),
affected_object.names = collectDistinct(object.name)
}, by: {event.provider, product.name, dt.security.risk.level, object.type}
| sort findings.count desc
```
### Findings on a specific cloud resource
Use the entity OR-chain from
[common-patterns.md § 5](common-patterns.md).
### Look up a finding by ID or title across providers
```dql-template
fetch security.events, from: -24h
| filter in(event.type, {"VULNERABILITY_FINDING", "DETECTION_FINDING", "COMPLIANCE_FINDING"})
| filter finding.id == "<FINDING_ID>"
or scan.id == "<SCAN_ID>"
or contains(finding.title, "<TITLE_SUBSTRING>")
or contains(event.description, "<DESCRIPTION_SUBSTRING>")
| sort timestamp desc
| limit 5
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
event.type, event.provider, finding.id, finding.title,
dt.security.risk.level, object.id, object.name, object.type,
event.description
```
---
## Event Type Reference
| `event.type` | Families that emit it |
|---|---|
| `VULNERABILITY_STATE_REPORT_EVENT` | Dynatrace RVA |
| `VULNERABILITY_STATUS_CHANGE_EVENT` | Dynatrace RVA |
| `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` | Dynatrace RVA |
| `VULNERABILITY_FINDING` | External SCA / SAST / image scanners (ingested) |
| `VULNERABILITY_SCAN` | Dynatrace RVA + external scan coverage events |
| `DETECTION_FINDING` | Dynatrace RAP + Automated Detections + external detection sources (ingested) |
| `COMPLIANCE_FINDING` | Dynatrace SPM + external compliance / posture tools (ingested) |
| `COMPLIANCE_SCAN` | External compliance scan coverage events |
| `COMPLIANCE_SCAN_COMPLETED` | Dynatrace SPM scan completion markers |
| `VULNERABILITY_COVERAGE_REPORT_EVENT` | **Deprecated** — use `VULNERABILITY_SCAN` |
| `THREAT_REPORT` | External threat-intelligence platforms (AlienVault OTX, CrowdStrike Falcon Intelligence). **Threat intelligence, not a finding** — **excluded from every cross-provider query on this page** (no `finding.*` / `object.*` / `dt.security.risk.level`, not part of the double-counting guard or the 3-stream decomposition). Query separately via [threat-intelligence.md](threat-intelligence.md). |
---
## Best Practices
1. **Lead with the semantic-dictionary filter** — cross-provider queries should
only consider rows that populate the normalized fields.
2. **Always apply the double-counting guard** —
`product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"`. This is
the canonical filter for cross-provider summaries — drops Dynatrace-native vulnerabilities and compliance (covered by the RVA and SPM snapshot patterns) while keeping Dynatrace detections.
3. **Use the correct default window for the query class.** For cross-provider
summary/aggregation queries, start at `24h` (summaries aggregate over time; a
narrow window silently undercounts). For retrieval queries (listing raw findings,
"show latest detections"), start at `2h` and widen to `24h` only if zero rows
are returned. See [common-patterns.md § 7](common-patterns.md) for the full table.
4. **Decompose broad questions; compute DT-native vulnerability/compliance separately** —
for posture overviews, "which products are integrated", and other cross-category questions,
follow the [Broad-Question Query Decomposition](#broad-question-query-decomposition): Stream A
(external + DT detections, `24h`, guarded), Stream B (DT RVA, `30m`), Stream C (DT KSPM, `1h`),
merged at reporting. Never a single wide `fetch security.events` over all event types.
5. **Use `dt.security.risk.level` for cross-provider risk** — it's the normalized
field. Provider-specific severity fields (`vulnerability.risk.level`,
`compliance.rule.severity.level`) exist, but only within their own event-type
family.
references/common-patterns.md
# Common Query Building Blocks
Cross-cutting patterns reused across vulnerability, compliance, detection, coverage,
and entity-enrichment queries. Each block is named so per-domain references can
point here without repeating code.
> **No `dt.system.bucket` filter unless users ask for it explicitly.** Security event data may live in any bucket;
> bucket scoping is unnecessary and can hide data.
---
## Contents
- [Common Query Building Blocks](#common-query-building-blocks)
- [Contents](#contents)
- [1. Risk Score → Risk Level Mapping](#1-risk-score--risk-level-mapping)
- [5. Wide Entity Scoping OR Chain](#5-wide-entity-scoping-or-chain)
- [6. Provider / Product Filter (case-insensitive)](#6-provider--product-filter-case-insensitive)
- [7. Time Window Conventions](#7-time-window-conventions)
- [8. Dynatrace-vs-External Routing Logic](#8-dynatrace-vs-external-routing-logic)
- [12. Repository / Artifact Coalescing](#12-repository--artifact-coalescing)
- [13. K8s Workload Resolution from CONTAINER smartscapeNode](#13-k8s-workload-resolution-from-container-smartscapenode)
- [15. Default Summarization Recipe (cross-provider summary)](#15-default-summarization-recipe-cross-provider-summary)
- [16. Result Limits for Top-N and Raw Listings](#16-result-limits-for-top-n-and-raw-listings)
- [17. Entity-Identifier Preservation on Raw Listings](#17-entity-identifier-preservation-on-raw-listings)
- [Cross-provider `*_FINDING` and scan-coverage events](#cross-provider-_finding-and-scan-coverage-events)
- [RVA state/change events](#rva-statechange-events)
- [When NOT to apply this](#when-not-to-apply-this)
- [Don't double-list on RVA Stage 3 output](#dont-double-list-on-rva-stage-3-output)
- [18. Lifecycle — what counts as "new" / "resolved" (per event family)](#18-lifecycle--what-counts-as-new--resolved-per-event-family)
- [Common Mistakes & Troubleshooting](#common-mistakes--troubleshooting)
---
## 1. Risk Score → Risk Level Mapping
The same thresholds apply to both score fields:
| Score field | Level field | Used in |
|---|---|---|
| `vulnerability.risk.score` | `vulnerability.risk.level` | DT RVA per-entity raw events and Stage-3 derivation |
| `dt.security.risk.score` | `dt.security.risk.level` | Cross-provider `*_FINDING` events (normalized score set at ingest) |
**Threshold table (identical for both):**
| Score | Level |
|---|---|
| ≥ 9.0 | CRITICAL |
| ≥ 7.0 | HIGH |
| ≥ 4.0 | MEDIUM |
| ≥ 0.1 | LOW |
| else | NONE |
**DT RVA (derive `vulnerability.risk.level` from score):**
```dql-snippet
| fieldsAdd vulnerability.risk.level=if(vulnerability.risk.score>=9,"CRITICAL",
else:if(vulnerability.risk.score>=7,"HIGH",
else:if(vulnerability.risk.score>=4,"MEDIUM",
else:if(vulnerability.risk.score>=0.1,"LOW",
else:"NONE"))))
```
**Cross-provider (derive `dt.security.risk.level` from score, when not already set):**
```dql-snippet
| fieldsAdd dt.security.risk.level=if(dt.security.risk.score>=9,"CRITICAL",
else:if(dt.security.risk.score>=7,"HIGH",
else:if(dt.security.risk.score>=4,"MEDIUM",
else:if(dt.security.risk.score>=0.1,"LOW",
else:"NONE"))))
```
`vulnerability.risk.score` is Dynatrace's contextual DSS (factors in exposure,
exploit availability, function usage) — never exceeds CVSS base. Prefer `risk.score`
for prioritization; report `vulnerability.cvss.base_score` only when the user asks
about CVSS. `dt.security.risk.score` is the normalized cross-provider score set at
ingest (external severity strings map to fixed values: Critical → 10.0, High → 8.9,
Medium → 6.9, Low → 3.9).
---
## 5. Wide Entity Scoping OR Chain
Match a user-supplied entity ID or name against every supported scoping field by building
a per-row array of field values and checking membership with `in()`.
**Single value (most common — inline the known ID/name):**
```dql-snippet
| filter in("<entity_id_or_name>", arrayRemoveNulls(array(
toString(dt.smartscape_source.id),
toString(dt.smartscape.process),
toString(dt.smartscape.host),
toString(dt.smartscape.k8s_cluster),
toString(dt.smartscape.k8s_node),
toString(dt.smartscape.k8s_pod),
toString(dt.entity.host),
toString(dt.entity.process_group),
toString(dt.entity.process_group_instance),
toString(dt.entity.kubernetes_cluster),
toString(dt.entity.kubernetes_node),
toString(dt.entity.cloud_application_namespace),
toString(k8s.cluster.uid), toString(k8s.pod.uid),
toString(aws.resource.id), toString(azure.resource.id), toString(gcp.resource.id),
object.id, object.name, host.name,
k8s.cluster.name, k8s.namespace.name, k8s.node.name,
aws.resource.name, azure.resource.name, gcp.resource.name
)))
```
**Multiple values (spread the user-supplied list):**
```dql-snippet
| filter in(array("<id1>","<id2>"), arrayRemoveNulls(array(
toString(dt.smartscape_source.id),
toString(dt.smartscape.process),
toString(dt.smartscape.host),
toString(dt.smartscape.k8s_cluster),
toString(dt.smartscape.k8s_node),
toString(dt.smartscape.k8s_pod),
toString(dt.entity.host),
toString(dt.entity.process_group),
toString(dt.entity.process_group_instance),
toString(dt.entity.kubernetes_cluster),
toString(dt.entity.kubernetes_node),
toString(dt.entity.cloud_application_namespace),
toString(k8s.cluster.uid), toString(k8s.pod.uid),
toString(aws.resource.id), toString(azure.resource.id), toString(gcp.resource.id),
object.id, object.name, host.name,
k8s.cluster.name, k8s.namespace.name, k8s.node.name,
aws.resource.name, azure.resource.name, gcp.resource.name
)))
```
Smartscape ID fields (`dt.smartscape.*`, `dt.smartscape_source.id`) are typed as
`SmartscapeId`; `toString()` converts them to string for uniform comparison with
user-supplied strings. `arrayRemoveNulls()` drops fields that are null on a given event
row — most events populate only a subset of these fields.
> **This OR-chain does NOT apply to RVA state/change events** (`VULNERABILITY_STATE_REPORT_EVENT`,
> `VULNERABILITY_STATUS_CHANGE_EVENT`). Those events embed entity refs in `affected_entity.*` and
> `related_entities.<group>.{ids,names}` — all §5 fields (`dt.smartscape*`, `dt.entity*`, etc.) are
> **null** on them. For entity scoping on RVA events see
> [vulnerabilities-dynatrace.md § Vulnerabilities on a specific entity](vulnerabilities-dynatrace.md#vulnerabilities-on-a-specific-entity-by-name-or-id)
> and [§17 RVA state/change events below](#rva-statechange-events).
> **For entity-scoping on non-`security.events` row sets** (IoC matches, `smartscapeNodes` rows,
> enrichment output): use **dt-sec-contextualization** →
> `dt-sec-contextualization/references/entity-enrichment.md` (§ Entity-Scoping OR-Chain).
**Trim to the relevant fields.** Omit namespaces that can't match the entity type in
question (e.g. drop `k8s.*` / cloud resource fields when searching for a host or process):
```dql-snippet
// Host/process scope only
| filter in("<entity_id_or_name>", arrayRemoveNulls(array(
toString(dt.smartscape_source.id),
toString(dt.smartscape.process),
toString(dt.smartscape.host),
toString(dt.entity.host),
toString(dt.entity.process_group),
toString(dt.entity.process_group_instance),
object.id, object.name, host.name
)))
```
---
## 6. Provider / Product Filter (case-insensitive)
Check whether the user-supplied value matches either `event.provider` (vendor name, e.g. `"crowdstrike"`), `product.vendor` or `product.name` (specific product, e.g. `"falcon"`). Lowercase both sides; the skill inlines already-lowercased string literals.
**Single value (discovery / unknown string):**
```dql-snippet
| filter in("<provider_or_product>", array(lower(event.provider), lower(product.vendor), lower(product.name)))
```
**Multiple values (discovery / unknown strings):**
```dql-snippet
| filter in(array("<p1>", "<p2>"), array(lower(event.provider), lower(product.vendor), lower(product.name)))
```
**Known provider — exact match preferred.** Once the provider string is confirmed via the discovery query (see [all-security-events.md § Providers & Products](all-security-events.md)), use exact equality to avoid false positives from providers whose names share a substring:
```dql-snippet
| filter event.provider == "<ExactProviderString>"
OR product.name == "<ExactProductString>"
```
A provider may appear via two ingestion paths (e.g. direct integration and via AWS Security Hub). Combine both exact values with `OR` when both paths are active in the tenant.
Reserve `contains(lower(...))` for initial discovery or when the exact string is not yet known.
---
## 7. Time Window Conventions
| Pattern | Window | Why |
|---|---|---|
| DT RVA snapshots | **30m fixed** | Snapshot — captures latest 15-min state-report cycle; do not widen |
| DT KSPM snapshots | **1h fixed** | Aligned with scan-completion cycle inner-join; do not widen |
| RAP / external detection retrieval or current summary | `2h` first attempt | Event stream; matches Threats & Exploits app default — widen to `24h` only if zero rows returned |
| Cross-provider summary (aggregated count/breakdown) | **24h** | Summaries aggregate over time; broader window gives representative coverage |
| Single-finding drill-down (by id/title) | `24h` default | Point lookup |
| Coverage analysis | `7d` for discovery; `2h–24h` for recent scans | `VULNERABILITY_SCAN` events are sparse |
**Critical:** RVA and SPM are *snapshot* tools, not history. The 30m / 1h windows
are operational — they only ensure the latest state report or completed scan is
captured. They do NOT look back further. Don't widen them.
**Retrieval vs. summary distinction:** for detection queries, both retrieval and unqualified current summaries ("how many detections do I have?", "detections by severity", "show me latest detections") start at `2h` to match the Threats & Exploits app default, then widen to `24h` only when zero rows are returned. For non-detection cross-provider summaries (counts, breakdowns, "how many critical findings across providers") start at `24h` — these aggregate over time and a narrow window silently undercounts.
**DT-inclusive broad / posture-overview questions** ("which security products are integrated incl. Dynatrace-native?", posture overview, cross-category counts that include DT vulnerabilities/compliance) are **not** a single window — decompose into three streams, each on its own window (external + DT detections `24h`; DT RVA `30m`; DT KSPM `1h`), and merge. (A narrower "which external integrations are sending data?" stays a single external-only query.) See [all-security-events.md § Broad-Question Query Decomposition](all-security-events.md#broad-question-query-decomposition).
> **Detection-retrieval widen-on-empty rule:** start at `2h`; widen only when zero rows
> are returned. The authoritative fallback query and the list of intentionally-wider
> history/analytics exceptions live in
> [detections.md § Widen-on-empty fallback](detections.md).
> **Entity coverage empty-result rule:** when validating whether a **specific
> entity** is covered by a Dynatrace security capability, 0 relevant findings plus
> 0 relevant scan/scan-completed/coverage events means the entity is **not
> covered** by that capability. Explain that the capability is likely not enabled,
> not deployed, or not configured for that entity; do not present this as merely
> "no findings". See [coverage-and-dashboards.md](coverage-and-dashboards.md).
---
## 8. Dynatrace-vs-External Routing Logic
The cross-provider summary pattern excludes Dynatrace-native VULNERABILITY/COMPLIANCE findings (they
belong to the RVA and SPM snapshot patterns), but keeps Dynatrace-native DETECTION findings:
```dql-snippet
| filter (product.vendor != "Dynatrace" or event.type=="DETECTION_FINDING")
```
**Implication:** for full coverage of vulnerabilities or compliance, **pair**
the Dynatrace RVA snapshot pattern (vulnerabilities) or the Dynatrace SPM snapshot
pattern (compliance) with the cross-provider summary. Only `DETECTION_FINDING` is
fully covered by the cross-provider summary alone.
---
## 12. Repository / Artifact Coalescing
External container scanners use one of two repository fields. Coalesce so a single
column works for both:
```dql-snippet
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
```
---
## 13. K8s Workload Resolution from CONTAINER smartscapeNode
A CONTAINER node is part of exactly one workload — fan out the workload reference
via nested `coalesce`:
```dql-snippet
| expand dt.k8s.workload.id=coalesce(references[is_part_of.k8s_deployment],
coalesce(references[is_part_of.k8s_daemonset],
coalesce(references[is_part_of.k8s_cronjob],
coalesce(references[is_part_of.k8s_statefulset],
coalesce(references[is_part_of.k8s_job],
references[is_part_of.k8s_replicaset])))))
```
For HOST resolution (CONTAINER → host) use `references[runs_on.host]` instead.
---
## 15. Default Summarization Recipe (cross-provider summary)
Without a user-specified summarization, the cross-provider summary pattern collapses
results by provider × product × event type × risk level. This is the safe default
for any time window:
> **Do not drop `by:` keys without an explicit user request.** The four keys —
> `{event.provider, product.name, event.type, dt.security.risk.level}` — are mandatory for any
> cross-provider count or summary. Dropping any one silently merges rows from different providers,
> products, or finding types into a single count, corrupting the result.
>
> For container images and external findings, also preserve `object.name` (user-friendly display
> name for the scanned artifact), `object.id`, and the repository/digest identity fields
> (`container_image.digest`, `coalesce(artifact.repository, container_image.repository)`) in the
> `summarize` or projection **before ranking**. Pair `object.name` with `digest` or `repository` —
> names alone are not unique across registries or providers.
```dql-snippet
| summarize {
findings.count = count(),
finding.ids = collectDistinct(finding.id),
finding.titles = collectDistinct(finding.title),
affected_object.types = collectDistinct(object.type),
affected_object.ids = collectDistinct(object.id),
affected_object.names = collectDistinct(object.name),
affected_smartscape.node.ids = arrayRemoveNulls(collectArray(coalesce(dt.smartscape_source.id,
dt.smartscape.process,
dt.smartscape.host,
dt.smartscape.k8s_cluster,
dt.smartscape.k8s_node,
dt.smartscape.k8s_pod))),
related_entities.ids = arrayRemoveNulls(collectArray(coalesce(dt.entity.host,
dt.entity.process_group,
dt.entity.process_group_instance,
dt.entity.kubernetes_cluster,
dt.entity.kubernetes_node,
dt.entity.cloud_application_namespace))),
vulnerable_components = arrayRemoveNulls(collectDistinct(coalesce(software_component.name, component.name)))
}, by: {event.provider, product.name, event.type, dt.security.risk.level}
```
For longer time ranges (>24h), **always** apply a summarization — raw field
selection past 24h hits performance limits.
---
## 16. Result Limits for Top-N and Raw Listings
Security findings can be high-volume. Any query that returns raw rows (no
`summarize` / `makeTimeseries`) must be bounded unless the user explicitly asks
for all rows or an export-style result.
**Required limit rule:**
| User intent | Required query shape |
|---|---|
| "top X" / "last X" / "first X" | `| sort <ranking fields> desc` then `| limit X` |
| "top findings" with no number | Treat as top 50: `| sort <ranking fields> desc` then `| limit 50` |
| "show/list/latest findings" with no explicit "all" | Add `| limit 50` after the final `sort` |
| Pure summary (`summarize`, `makeTimeseries`, pass-rate, counts) | No default raw-row limit required; optionally limit high-cardinality grouped rankings |
| Explicit "all" / export request | Do not silently truncate; warn about volume and prefer a summary or scoped filters |
**Placement:** apply `limit` after deduplication, enrichment, final projection, and the
final user-relevant `sort`. A limit before `summarize`, `join`, or entity enrichment
can bias counts or drop matching entities.
```dql-snippet
| fields timestamp, finding.id, finding.title, dt.security.risk.level,
object.name, object.type, "dt.smartscape*"
| sort dt.security.risk.score desc, timestamp desc
| limit 50
```
For top-N summary tables, keep the aggregation grain first, then sort and limit:
```dql-snippet
| summarize Findings=countDistinctExact(finding.id), by:{event.provider, product.name, object.type}
| sort Findings desc
| limit 50
```
---
## 17. Entity-Identifier Preservation on Raw Listings
When a query projects raw rows for a listing (top / latest / list / drill-down — no `summarize`), keep the entity-identifier namespaces in the projection. Users asking for findings almost always want to know which entity each finding is on.
**The namespaces split by event family — they are NOT interchangeable:**
### Cross-provider `*_FINDING` and scan-coverage events
Applies to `DETECTION_FINDING`, `COMPLIANCE_FINDING`, external `VULNERABILITY_FINDING`, `VULNERABILITY_SCAN`, `COMPLIANCE_SCAN`. Post-ingest enrichment populates the generic Smartscape/entity namespaces on these events.
```dql-snippet
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
<finding-specific fields…>
```
What the wildcards match:
| Wildcard | Generation | Covers |
|---|---|---|
| `dt.smartscape*` | 3rd-gen (Smartscape) | `dt.smartscape_source.id` (+ `.type`/`.name` if present) and `dt.smartscape.process` / `.host` / `.k8s_cluster` / `.k8s_node` / `.k8s_pod` |
| `dt.entity*` | 2nd-gen (classic) | `dt.entity.host`, `dt.entity.process_group`, `dt.entity.process_group_instance`, `dt.entity.kubernetes_cluster`, `dt.entity.kubernetes_node`, `dt.entity.cloud_application_namespace` — deprecated for Smartscape navigation, still valid as identifiers |
| `dt.source*` | Legacy / scan fallback | `dt.source_entity` — use only when the event family documents it explicitly (for example `VULNERABILITY_SCAN` coverage); do not make it the primary cross-provider finding correlation key |
### RVA state/change events
Applies to `VULNERABILITY_STATE_REPORT_EVENT`, `VULNERABILITY_STATUS_CHANGE_EVENT`, `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT`. These events embed resolved entity refs directly in the event payload; the generic `dt.smartscape*` / `dt.entity*` / `dt.source*` namespaces are **null** on RVA events — including them would produce empty columns.
```dql-snippet
| fieldsKeep timestamp, "affected_entity*", "related_entities*",
vulnerability.display_id, vulnerability.title, vulnerability.risk.score, …
```
`affected_entity.*` carries the directly affected entity (2nd-gen ID + name + type + vulnerable-component info, resolved in-event). `related_entities.{kubernetes_workloads,kubernetes_clusters,applications,services,hosts,databases}.{ids,names}` carries the indirect blast-radius entities (classic IDs + names) as arrays. Note: `.ids` carry classic entity IDs whose type prefix may differ from the group name — see [vulnerabilities-dynatrace.md § Classic ID prefix gotcha](vulnerabilities-dynatrace.md#classic-id-prefix-gotcha).
### When NOT to apply this
Pure summary queries — counts, pass rates, breakdown-by-risk-level, "how many" questions — should not project entity fields; aggregate them into the `summarize` block instead (see §15 cross-provider summary recipe and §16 RVA Stage 2 related-entity aggregation).
### Don't double-list on RVA Stage 3 output
If the query starts from the canonical RVA Stage 3 pipeline ([vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md)), Stage 3 already `collectDistinct`'s `affected_entity.ids/names` and `arrayConcat`'s `related_entities.*` into per-vulnerability arrays. Do not re-add `fieldsKeep "affected_entity*"` on top — those columns are already present as scalars in the post-summarize result.
---
## 18. Lifecycle — what counts as "new" / "resolved" (per event family)
"New" and "resolved" are detected differently per family — use the right signal:
| Family | "New" signal | "Resolved" signal |
|---|---|---|
| External findings (one-shot `*_FINDING`) | `toTimestamp(finding.time.created) > now()-Nd` | not modeled — compare presence across periods (anti-join; see the "new-not-in-prior-period" patterns in [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md) / [compliance.md](compliance.md)) |
| DT-generated vulnerability findings (`VULNERABILITY_FINDING`) | finding rows re-emit every scan run (~15 min); use current-vs-prior **anti-join** on the scoped identity, such as `{genai_service.id, vulnerability.id}` for AI workloads | not modeled on the finding stream — compare presence across periods or use RVA lifecycle only when explicitly routed to state reports |
| DT RVA vulnerabilities (snapshot) | newly **OPEN**: `toTimestamp(vulnerability.resolution.change_date) > now()-Nd` (no "first-ever-seen" variant — `vulnerability.first_seen` is null on this pipeline) | `vulnerability.resolution.status == "RESOLVED"` (use `resolution.change_date` for when) |
| DT SPM compliance (per-rule/object snapshot) | rule-object pair absent in the prior scan period — period-over-period anti-join (see [compliance.md](compliance.md) § Week-over-Week Config Drift) | pair present in the prior period, absent now |
Keep the RVA/SPM snapshot fetch window fixed (30m / 1h) — apply the "new" horizon as a
**post-derive filter**, never by widening the fetch (see § 7).
### Two distinct "new" intents — do not conflate them
1. **"New(ly created) in the last N days"** — a property of each finding in
isolation. The created-time filter from the table above is the correct and
complete answer.
2. **"Reported in this period AND NOT in the previous period"** (also: "newly
failing", "drift vs last week", "what appeared this week that wasn't there
before") — a **set comparison between two periods**. This REQUIRES the
prior-period anti-join (outer join + `isNull(right.…)`); a
`finding.time.created` filter is **not equivalent**: external providers
re-report long-known findings on every scan, `finding.time.created` is
unreliable or vendor-relative for many providers, and the created-time
shortcut silently misses findings that existed before but were first
*reported to Dynatrace* this period. Canonical anti-join templates:
- external vulnerabilities → [vulnerabilities-external.md § Critical external vulnerabilities newly reported in the last 7d](vulnerabilities-external.md#critical-external-vulnerabilities-newly-reported-in-the-last-7d-not-in-the-prior-7d)
- external compliance → [compliance.md § External Compliance](compliance.md)
- DT KSPM drift → [compliance.md § Week-over-Week Config Drift](compliance.md#dt-spm-week-over-week-config-drift-newly-failing-rules)
---
## Common Mistakes & Troubleshooting
Detailed companion to the **Common Mistakes** and **Best Practices** sections in
[SKILL.md](../SKILL.md). Surface this reference when a query is producing
unexpected results or when the user reports counts that disagree with the
Vulnerabilities / Threats & Exploits / SPM apps.
---
## Mistakes to Avoid
1. **Querying `VULNERABILITY_STATE_REPORT_EVENT` alone** → use the three-event-type union (`STATE_REPORT`, `STATUS_CHANGE`, `TRACKING_LINK_CHANGE`).
2. **Deduping on `vulnerability.display_id` alone** → use the composite key `{vulnerability.display_id, affected_entity.id}`, else per-entity context collapses.
3. **Skipping `event.level == "ENTITY"`** on RVA queries → non-entity rows skew aggregations.
4. **`dt.system.bucket` filters** → never filter by bucket; security events may live in any bucket.
5. **`vulnerability.parent.*` (deprecated)** → derive vuln-level values from per-entity arrays: verdicts via `collectDistinct()` + `in()`, scalars via `takeMax/takeFirst`.
6. **Wrong risk field / raw CVSS for triage** → `dt.security.risk.*` on `*_FINDING` events; `vulnerability.risk.*` on RVA state-reports (which lack `dt.security.risk.*`). Both beat `vulnerability.cvss.base_score`.
7. **Counting `NOT_RELEVANT` compliance** → exclude it from pass-rate denominators.
8. **Widening the snapshot fetch window** → `from:now()-30m` (RVA) and `from:now()-1h` (SPM) are snapshot windows, not history — widening them returns ~50× more duplicate state rows, not older or newer state. The only valid reason to widen beyond 30m on RVA events is a **pure change-event query** (`VULNERABILITY_STATUS_CHANGE_EVENT` / `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` only, no `STATE_REPORT`) where the user asks what changed over a period; in that case, match the window to the user's time horizon and omit the snapshot dedup. For lifecycle metrics (new in 24h, resolved in 7d), keep 30m and apply a **post-derive filter** on `resolution.change_date` after Stage 3. For trends, use `makeTimeseries`.
9. **`bin()` for trend/chart questions** → use `makeTimeseries interval:<N>` (charts need a `timeseries`-typed column); `bin()` is only for tabular bucketed counts. [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md#dt-rva-time-series-trends-7-days-3h-buckets)
10. **Filtering `vulnerability.resolution.status == "OPEN"` pre-Stage-3** → the raw field is per-entity; filter only after the Stage-3 `fieldsAdd` derives the vuln-level verdict.
11. **`vulnerability.first_seen` (null on RVA pipeline — do not use)** → for "newly OPEN" use `toTimestamp(vulnerability.resolution.change_date) > now()-<window>`; for "how long open" aggregate `open_since=toTimestamp(takeMin(if(vulnerability.resolution.status=="OPEN", vulnerability.resolution.change_date, else: null)))` in Step 3, then `fieldsAdd open_duration = now() - open_since` (cast once in the summarize; `change_date` is an epoch-nanoseconds value). `first_seen` is commented out of `entity.state` (0/2124 populated). A resolution-time proxy (MTTR) **is** computable from `resolution.change_date` without `first_seen` — it equals true detection-to-resolution only for vulns that never reopened, counts auto-resolutions, and is bounded by the change-event fetch window; treat it as time-to-resolution-by-any-cause, not patch velocity. See [vulnerabilities-dynatrace.md § Resolution time (MTTR proxy)](vulnerabilities-dynatrace.md#resolution-time-mttr-proxy--openresolved-per-affected-object).
12. **Reading external compliance via `compliance.rule.*`** (null for external) → use `compliance.standards` (expand) / `compliance.policy` / `compliance.control`, plus `finding.title`/`finding.type`.
13. **`event.provider == "Dynatrace"` for compliance** → SPM uses `product.vendor == "Dynatrace"`; RVA uses `event.provider`. Don't mix.
14. **Inventing fields** → inspect a sample row, or [data-model.md](data-model.md), first.
15. **Wrong `vulnerability.stack` values** → enum is `CODE / CODE_LIBRARY / SOFTWARE / CONTAINER_ORCHESTRATION` (not `THIRD_PARTY/FIRST_PARTY/CODE_LEVEL`). CLV = `CODE`; "third-party" = `in(stack, array("CODE_LIBRARY","SOFTWARE"))`.
16. **Filtering CLV by runtime assessment** → CLV always scores 10.0 and skips assessment modifiers; scope with `vulnerability.stack == "CODE"`, drill via `vulnerability.code_location.name`.
17. **Treating `ADJACENT_NETWORK` as public exposure** → the Stage-3 derivation intentionally doesn't promote it; for adjacent-network questions filter the raw `vulnerability.davis_assessment.exposure_status`.
18. **Treating `NOT_AVAILABLE` as harmless** → it means "couldn't tell" (ranked above `NOT_DETECTED`/`NOT_IN_USE`); surface it. `assessment_mode` (`FULL`/`REDUCED`/`NOT_AVAILABLE`) explains partial coverage.
19. **Collapsing mute metadata to vuln level** → `mute.{reason,user,comment,change_date}` are per-entity; keep the per-entity row for the mute audit.
20. **Assuming auto-resolution takes days** → third-party resolves after the component is absent >2h; CLV resolves after a process restart + clean re-analysis.
21. **Querying KSPM for AWS/Azure/GCP** → KSPM is K8s-only; route cloud/host compliance to CSPM/VSPM or external ([all-security-events.md](all-security-events.md)).
22. **Asking KSPM for PCI/ISO/HIPAA/GDPR** → KSPM emits only `CIS`/`DORA`/`NIST`/`DISA STIG`; others arrive via external. STIG's `short_name` is the full `"DISA STIG"` — use `contains(lower(...),"stig")`, not `== "STIG"`.
23. **`compliance.rule.severity.level == "NONE"/"NOT_AVAILABLE"`** → KSPM severity is exactly `CRITICAL/HIGH/MEDIUM/LOW`.
24. **Counting MANUAL as PASSED (or ignoring it)** → MANUAL is in the denominator only, never the numerator; surface as a separate triage queue.
25. **Confusing the two object-type fields (KSPM)** → `object.type` = uppercase DT entity type; `compliance.result.object.type` = analyzer lowercase code (`k8scluster`, …). On external rows `object.type` is the vendor value as-is (e.g. `AwsEc2Instance`) — match it directly.
26. **Inventing `compliance.mute.*` / `compliance.tracking_link.*`** → neither exists; compliance has no mute/waiver/tracking namespace. Explain the limitation rather than guessing fields.
27. **`product.vendor == "Dynatrace"` is shared (RVA/RAP/SPM)** → pin `product.name == "Security Posture Management"` for KSPM-only scoping.
28. **Confusing the two RAP filter axes** → both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated; use the latter (canonical), don't OR/AND them.
29. **`attack.type` / `attack.vector` (not in SD)** → use `finding.type` (vendor-original free-form string, not a normalized enum). Filter with a substring match: `contains(lower(finding.type), "sql")`. Values are display strings like `SQL injection`, `CMD injection` — not underscore enums like `SQL_INJECTION`.
30. **Auto-scoping cross-provider questions to RAP** → attacker-IP/campaign/attack-type analytics are SD-canonical across providers; default to `event.type == "DETECTION_FINDING"` and group by `object.id` (not `dt.entity.process_group`, null for external). Add a provider/RAP filter only when asked.
31. **Expecting MITRE tags on RAP** → only Automated Detections populate `threat.attack.*`; for RAP, map `finding.type` → technique manually.
32. **Inventing `detection.mute.*` / `detection.dismiss.*`** → detections aren't lifecycle-tracked in events; suppression is UI/ingest-side.
33. **Confusing `DETECTION_FINDING` with `DETECTION_EXECUTION_SUMMARY`** → the summary is the per-rule-run audit; don't include it in finding counts.
34. **`event.outcome` as the RAP block signal** → use `finding.action` (`Blocked`/`Audited`/`Allowlisted`).
35. **Using `actor.ips` as-is** → it's `ipAddress[]`; `expand actor.ips` then `fieldsAdd ip = ip(actor.ips)`. `actor.ip`/`actor.location` don't exist; geo is `actor.geo.{country,city,continent}.name` (Experimental); reputation is app-side.
36. **`detection.mitre_ids` (not in SD)** → MITRE is `threat.attack.technique.ids` / a separate `threat.attack.subtechnique.ids` (dotted) / `threat.attack.tactic.ids`. Use `in("T1078", …)`; "parent + subs" = `in("T1110", technique.ids) OR iAny(startsWith(subtechnique.ids[], "T1110."))`.
37. **Filtering CVE arrays as scalars** → `vulnerability.references.cve` is an array; use `in("CVE-…", vulnerability.references.cve)` (or `expand`), not `==`.
38. **Inventing `vulnerability.cve.id` / `vulnerability.cve.ids`** → CVEs live in `vulnerability.references.cve`; use that field for RVA and external `VULNERABILITY_FINDING` correlation.
39. **Ranking hosts by `affected_entity.type == "HOST"`** → RVA attaches to process groups; host context is in `related_entities.hosts.{ids,names}` — expand those.
40. **Assuming one exact `event.provider` per provider** → the name may be in `event.provider` or `product.vendor`; match both with `contains(lower(...))` and discover first. [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type)
41. **Treating RAP as only `DETECTION_FINDING`** → some tenants expose RAP under `SECURITY_EVENT`; use `in(event.type, {"DETECTION_FINDING","SECURITY_EVENT"})` with `product.name == "Runtime Application Protection"`.
42. **KSPM windows/tools for external compliance** → external is `COMPLIANCE_FINDING` over `24h+` with the external taxonomy; the 1h scan-join is KSPM-only.
43. **Passing two args to `countIf`** → one boolean only: `countIf(vulnerability.risk.level == "CRITICAL")`.
44. **Confusing `vulnerability.external_url` with `vulnerability.tracking_link.url`** → `external_url` is the provider reference (NVD/advisory), populated almost always; the user-attached remediation link is `tracking_link.url`. Same for `external_id` (provider id) vs `tracking_link.text`.
45. **Mixing record-level conditionals with aggregations in one `summarize`** → fails with `INVALID_MIX_OF_AGGREGATIONS_AND_OTHER_EXPRESSIONS`. Derive the conditional in a prior `fieldsAdd` (scalar), or use `takeAny()`. For host ranking, merge `affected_entity.id` into `related_entities.hosts.ids` then resolve via Smartscape. [vulnerabilities-entities.md § Most vulnerable hosts](vulnerabilities-entities.md#most-vulnerable-hosts--dt-rva)
46. **`event.status` for compliance status** → `event.status` is a generic event-lifecycle field (`Active`/`Closed`); it is null or wrong on `COMPLIANCE_FINDING` rows. Use `compliance.result.status.level` instead. See [compliance.md](compliance.md).
47. **`"PASS"` / `"FAIL"` enum values, or `!= "PASSED"` negation for failed count** → the canonical enum is `PASSED`, `FAILED`, `MANUAL`, `NOT_RELEVANT`. Count failures with an explicit `countIf(compliance.result.status.level == "FAILED")`; a negation (`!= "PASSED"`) wrongly folds `MANUAL` and `NOT_RELEVANT` into the failed count.
48. **`on: {left.scan.id == right.scan.id}` join syntax** → `left.`/`right.` prefixes are valid inside the join *body* (e.g. `isNull(right.object.id)`) but not inside the `on:` clause for same-named fields. Use the shorthand `on: {scan.id}` when the field name is identical on both sides.
49. **`compliance.rule.standard` (does not exist) / bare `compliance.rule.severity`** → `compliance.rule.standard` has no entry in the Semantic Dictionary — use `compliance.standard.short_name` (or `.name`) for the standard label. Bare `compliance.rule.severity` resolves to nothing; use `compliance.rule.severity.level` (values `CRITICAL` / `HIGH` / `MEDIUM` / `LOW`).
50. **Computing pass rate directly on raw per-`(rule, object)` rows** → raw rows mix multiple objects per rule; pass rate computed at this level over-counts or under-counts. Run the Step 2 per-rule status rollup first (`summarize … by: {compliance.rule.id}`), then derive `passRate` from the per-rule verdict counts. See [compliance.md § Step 2](compliance.md).
51. **Filtering by a vulnerability ID on only one field when the format is unknown** → `vulnerability.display_id` holds `S-XXXX`, `vulnerability.id` holds the internal numeric string (e.g. `7712027161588397174`), and `vulnerability.external_id` holds provider advisory IDs (e.g. `DTV-2026-GO-0001133`, NVD references). Searching only `display_id` silently returns zero rows for DTV/NVD advisories. Use the multi-field OR filter from [vulnerabilities-dynatrace.md § Step 2](vulnerabilities-dynatrace.md#step-2--optional-pre-aggregation-filter-insert-after-step-1-before-step-3).
52. **Using array indexing (`related_entities.hosts.names[0]` / `.ids[0]`) to extract entity names from RVA events** → array indexing grabs only the first element. For a simple list, project the whole array (`related_entities.hosts.names`) directly. For one-row-per-host fanout, use the named-alias expand form `expand related_host.id = related_entities.hosts.ids` + Smartscape lookup. Also: when `affected_entity.type == "HOST"` or `"KUBERNETES_NODE"`, the directly-affected entity is itself a host and may not appear in `related_entities.hosts.*` — always include `affected_entity.*` in the projection. See [vulnerabilities-dynatrace.md § Named entity list for a specific vulnerability](vulnerabilities-dynatrace.md#named-entity-list-for-a-specific-vulnerability).
53. **`iAny(related_entities.hosts.ids[] == "HOST-...")` — wrong DQL for array membership** → `iAny()` with array indexing is not the correct DQL membership operator. Use `in("HOST-...", related_entities.hosts.ids)` for a single value, or `in({"HOST-A","HOST-B"}, related_entities.hosts.ids)` for a set. Always pair with `OR affected_entity.id == "HOST-..."` (or `OR in(affected_entity.id, {...})`): HOST and KUBERNETES_NODE entities can be the directly-affected entity and will not appear in `related_entities.hosts.*` in that case.
54. **Answering *runtime-entity* coverage with a scan-event summary** → for hosts / processes / workloads / cloud resources, counting `VULNERABILITY_SCAN` events shows only the covered set — there is no denominator, so it cannot give a coverage percentage or reveal uncovered entities. Start from `smartscapeNodes` and `lookup` scan events **and** findings. [coverage-and-dashboards.md § DT Runtime Coverage Analysis](coverage-and-dashboards.md#dt-runtime-coverage-analysis-smartscapenodes). **Non-runtime** entities (container images, code artifacts) have no Smartscape population, so a distinct-object count from scan/finding events *is* the correct answer — there is no percentage. [coverage-and-dashboards.md § Non-Runtime Entity Coverage](coverage-and-dashboards.md#non-runtime-entity-coverage-images--artifacts)
55. **`finding.time.created` filter for "new this period and not in the prior period"** → that wording is a set comparison between two periods and requires the prior-period **anti-join** (outer join + `isNull(right.…)`). Providers re-report old findings and created timestamps are vendor-relative, so the created-time shortcut answers a different question. [common-patterns.md § 18](common-patterns.md#18-lifecycle--what-counts-as-new--resolved-per-event-family)
56. **`count()` after `expand` / `join` / `lookup` when the grain is not already one-per-identity** → if component or related-entity arrays are collected then expanded *without* a preceding one-row-per-vulnerability grain, post-`expand` rows duplicate each `(vulnerability, group-key)` pair and `count()` inflates rankings 3–50×. Use `countDistinctExact(vulnerability.display_id)` / `countDistinctExact(finding.id)`, or `dedup` on identity + group key before the `summarize`. (`count()` *is* correct when the pipeline already deduped to one row per vulnerability before the `expand` — e.g. the host/workload rankings in [vulnerabilities-entities.md § Resolving RVA entity names via Smartscape](vulnerabilities-entities.md#resolving-rva-entity-names-via-smartscape).)
57. **Grouping external findings by raw `k8s.namespace.name` / `host.name` / `object.name` / cloud resource IDs as "entity mapping"** → names are not unique and skip topology reconciliation. Use the Smartscape join recipes: 3-way match (K8s workloads), host-by-IP, direct `dt.smartscape_source.id` (cloud). `dt-sec-contextualization/references/entity-enrichment.md`
58. **Parsing or querying `compliance.rule.metadata_json`** → do not use this field; it is forbidden in this skill. Use `compliance.rule.id` (e.g. `CIS-2762`, `STIG-82824`, `DORA-67952`, `NIST-82827`) and `compliance.rule.title` for rule identity instead. The field exists in the data as a standard-specific JSON blob but must never be accessed.
---
## Best Practices
1. **Start with the canonical window** — RVA `from:now()-30m`, SPM
`from:now()-1h`, detections / cross-provider `from:now()-2h` (widen only
when the 2h detection query returns zero rows — see
[detections.md § Widen-on-empty fallback](detections.md#widen-on-empty-fallback-retrieval-queries)).
Widen for other event types only when the question explicitly demands history.
2. **Use shortened runtime-assessment status names in output** —
`vulnerability.exposure.status`, `vulnerability.exploit.status`,
`vulnerability.vulnerable_function.status`, `vulnerability.data_assets.status`
— derived in Stage 3 from the raw `vulnerability.davis_assessment.*_status`
fields.
3. **Use `dt.smartscape.*` for new Smartscape lookups** — `dt.entity.*` is
deprecated for Smartscape navigation (classic entity IDs like `dt.entity.host`
remain valid as identifiers).
4. **Coalesce repository fields** —
`coalesce(artifact.repository, container_image.repository)` for external
container scanners. See
[common-patterns.md § 12](common-patterns.md#12-repository--artifact-coalescing).
5. **Use `arraySize()` not `size()`; `lower()` not `toLowercase()`** — DQL
constraints, see `dt-dql-essentials`.
6. **`arraySlice` requires named parameters** — `arraySlice(arr, from: 0, to: N)` is
correct; positional form `arraySlice(arr, 0, N)` fails with
`TOO_MANY_POSITIONAL_PARAMETERS_WITH_OPTIONS`.
7. **`collectDistinct` has no `limit:` parameter** — wrap the call:
`arraySlice(collectDistinct(field), from: 0, to: N)`.
8. **Python-style slice `arr[0:N]` is rejected inside `summarize`** — use
`arraySlice(...)` in the summarize expression or in a follow-up `fieldsAdd`.
9. **`count()` must be aliased to be referenced downstream** —
`summarize total = count() | sort total desc` works;
`summarize count() | sort count() desc` fails.
10. **Always split mute status when reporting open vulnerabilities** —
`Open NOT_MUTED`, `Open MUTED`, `Resolved`. Total counts alone are
misleading.
---
## Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Open-vulnerability count is unexpectedly low | Muted vulnerabilities are filtered out by default in some workflows; the canonical pattern keeps them but separates them in reporting | Split by mute status; report MUTED separately. See [common-patterns.md § 3](common-patterns.md#3-mute-status-separated-count-canonical-reporting) |
| Compliance pass rate is 0% | The query filtered to FAILED rows only, so PASSED isn't in the denominator | Include all statuses (`PASSED`, `FAILED`, `MANUAL`) and exclude `NOT_RELEVANT`. See [compliance.md](compliance.md) |
| External finding details missing the affected entity | External findings reach Smartscape via 3-way match — only one path may have populated for the row | Use the 3-way enrichment query from `dt-sec-contextualization/references/entity-enrichment.md` |
| Query times out on long time ranges | Raw field selection over a large window — no summarization to bound output size | Add a `summarize` block, shorten the time range, or apply pre-aggregation filters earlier |
| Drill-down by `finding.id` returns nothing | Default time range may be too narrow, or the ID format is wrong for that provider | Widen the time range; verify the exact ID format (UUID for Dynatrace, ARN for AWS, hex hash for AutomationEngine) |
| External compliance group-by `compliance.rule.id` returns null | External compliance findings don't populate `compliance.rule.*` | Group by `compliance.standards` (expand) / `compliance.policy` / `compliance.control`, with `finding.title` / `finding.type` for display |
| RVA snapshot missing a recent vulnerability state change | RVA cycle is ~15m; widening past 30m doesn't help | Wait for the next cycle; or query `VULNERABILITY_STATUS_CHANGE_EVENT` history outside the 30m window |
| Object's compliance findings are missing from results | No `COMPLIANCE_SCAN_COMPLETED` for that object within the 1h window — the inner join drops it | Object wasn't scanned in the last cycle. By design, not a bug. Don't widen beyond 1h to work around this. |
| Coverage query uses `VULNERABILITY_COVERAGE_REPORT_EVENT` | Deprecated event type | Use `VULNERABILITY_SCAN` instead — see [coverage-and-dashboards.md](coverage-and-dashboards.md) |
| Some expected findings are missing from the T&E or Vulnerabilities apps | The apps require all SD-required fields (`event.id`, `event.provider`, `finding.type`, `finding.id`, `finding.time.created`, `finding.title`, `dt.security.risk.level`, `object.id`, `object.type`). Findings missing any are filtered out. | Run the same query *without* the SD-compliance filter to see which fields are missing. See [detections.md § Threats & Exploits (T&E) App Compatibility](detections.md#threats--exploits-te-app-compatibility) |
| Cross-provider count includes Dynatrace state-report rows multiple times | Missing the double-counting guard | Add `filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"` |
| `vulnerability.parent.*` filter behaves unexpectedly | The entire `vulnerability.parent.*` namespace is deprecated | Derive every vulnerability-level value from per-entity fields/arrays in Stage 3 — verdicts via `collectDistinct(...)` + `in(...)`, scalars via `takeMin/takeMax/takeFirst` |
| `filter vulnerability.stack == "THIRD_PARTY"` matches nothing | Wrong enum value | Use `in(vulnerability.stack, array("CODE_LIBRARY","SOFTWARE"))` for third-party; `=="CODE"` for CLV |
| `affected_entity.vulnerable_functions` is empty for IN_USE rows | Per-language vulnerable function reporting feature is disabled on the OneAgent | Enable the feature in OneAgent settings; until then trust the status flag but don't expect FQCN detail |
| `affected_entity.affected_processes.count` is 0 on a HOST/KUBERNETES_NODE row | These fields are populated only when `affected_entity.type == "PROCESS_GROUP"` | Filter to PG entities for process-level rollups; for host-level use `affected_entity.id` directly |
| DSS (`vulnerability.risk.score`) seems to exceed CVSS base score | Reading the wrong field, or comparing per-entity vs. vulnerability-level | DSS modifiers can only reduce CVSS; if values diverge, you're likely projecting raw `vulnerability.cvss.base_score` against post-aggregation `vulnerability.risk.score` |
| All CLV findings show score 10.0 | This is correct — CLV always scores Critical | Don't filter CLV by runtime-assessment modifiers; use `vulnerability.code_location.name` and `affected_entity.id` to drill |
| `filter compliance.standard.short_name == "PCI"` returns nothing on DT-native data | KSPM only emits `CIS` / `DORA` / `NIST` / `DISA STIG` | PCI/ISO/HIPAA/GDPR arrive via CSPM/VSPM or external; remove the `product.vendor == "Dynatrace"` filter and use cross-provider routing |
| `filter compliance.standard.short_name == "STIG"` returns nothing | The KSPM short_name is the full `"DISA STIG"` — bare `"STIG"` doesn't match anything | Use `contains(lower(compliance.standard.short_name), "stig")` (or exact `== "DISA STIG"`). Same caveat applies to other multi-word labels |
| `compliance.rule.id` / `compliance.rule.title` are null for some compliance rows | Those rows are external (CSPM/VSPM or other posture tools) | KSPM patterns (Steps 1+2) don't apply; use the external taxonomy — group by `compliance.standards` (expand) / `compliance.policy` / `compliance.control` and `event.provider` |
| Pass rate seems too high — MANUAL counted as pass | MANUAL must be in the denominator only, not numerator | Rebuild as `Passed * 100 / (Passed + Failed + Manual)`; never `Passed * 100 / (Passed + Failed)` |
| Compliance pass rate seems too low — NOT_RELEVANT included | NOT_RELEVANT must be excluded *before* aggregation | Add `filter compliance.result.status.level != "NOT_RELEVANT"` in Step 1 |
| `COMPLIANCE_SCAN_COMPLETED` event missing for some objects | Scan didn't complete in the 1h window for that cluster | Wait for next ActiveGate dataset push (typically hourly), or use a longer window for history (deliberately bypassing the snapshot pattern) |
| `scan.result.summary_json` used for compliance posture | Bypasses the per-rule pipeline; pre-aggregated blob cannot be filtered or broken down by rule/severity; causes a redundant second query | **Do not use `scan.result.summary_json` for posture questions.** Always route through the `COMPLIANCE_FINDING` canonical pipeline (Steps 1+2 in [compliance.md](compliance.md)). |
| Asked "show muted compliance findings" returns confusing results | Compliance has no mute fields | Explain that mute / waiver isn't modeled in `security.events` for compliance — only vulnerabilities have `mute.*` |
| `object.type` filter doesn't match expected K8s objects | Wrong field — `object.type` is uppercase entity type | Use `compliance.result.object.type` for analyzer codes (`k8scluster`, `k8spod`, …) or `object.type` for entity types (`KUBERNETES_CLUSTER`, …) |
| RAP query with `event.provider == "OneAgent"` returns nothing | Likely a non-RAP filtering issue (window too narrow, wrong event.type, etc.) — both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated on current RAP rows. | Switch to the canonical `product.name == "Runtime Application Protection"`; widen the window; verify `event.type == "DETECTION_FINDING"`. |
| `threat.attack.technique.ids == "T1078"` matches no rows | Field is an array | Use `in("T1078", threat.attack.technique.ids)` or `expand technique = threat.attack.technique.ids` |
| Sub-technique IDs like `T1059.003` don't match `threat.attack.technique.ids` | Sub-techniques live in a separate `threat.attack.subtechnique.ids` array | Query the sub-technique array directly, or OR across both arrays for "parent + sub" coverage |
| MITRE techniques missing on RAP / external detections | Only Automated Detections populates `threat.attack.*` | For RAP, map `finding.type` → MITRE manually; for external, parse `dt.raw_data` if the provider includes MITRE in its raw payload |
| Asked "show muted detections" returns confusing results | Detections have no mute namespace | Explain: detections aren't lifecycle-tracked in `security.events`; suppression is UI-side or ingest-side |
| Rule-execution count includes both findings and summary rows | Mixed event types | Filter `event.type == "DETECTION_EXECUTION_SUMMARY"` only; findings counts go through `DETECTION_FINDING` |
| Block-vs-monitor breakdown uses `event.outcome` and is mostly null | Wrong field | Use `finding.action` (`Blocked` / `Audited` / `Allowlisted`) for RAP |
| Top-attacker query returns null/empty for IP, or mismatched comparisons against other IP fields | Wrong field name, or array not cast to `ip()` | Use `actor.ips` (plural, `ipAddress[]`) — `actor.ip`/`actor.location` don't exist in the SD. `expand actor.ips` then `fieldsAdd ip = ip(actor.ips)`; project `actor.geo.country.name` for geo. Reputation enrichment (AbuseIPDB / VirusTotal) is client-side in the Threats & Exploits app, not in DQL rows |
| `event.status == "PASS"` (or `"FAIL"`) matches nothing on compliance rows | `event.status` is a generic lifecycle field; wrong field for compliance verdicts | Replace with `compliance.result.status.level == "PASSED"` (or `"FAILED"`, `"MANUAL"`, `"NOT_RELEVANT"`). See [compliance.md](compliance.md) |
| Compliance `countIf(... != "PASSED")` over-counts failed rules | Negation includes `MANUAL` and `NOT_RELEVANT` in the failed count | Use an explicit `countIf(compliance.result.status.level == "FAILED")` |
| KSPM join with `on: {left.scan.id == right.scan.id}` fails or returns unexpected columns | `left.`/`right.` prefixes are not valid in `on:` | Use the shorthand `on: {scan.id}` (DQL join shorthand when the field name matches on both sides) |
| Pass rate from KSPM query is wrong (each object inflates the rule count) | Pass rate computed on raw per-`(rule, object)` rows before Step 2 rollup | Apply the Step 2 per-rule summarize first; compute `passRate` from the per-rule verdict counts. See [compliance.md § Step 2](compliance.md) |
references/compliance.md
# Compliance Queries — `security.events`
Dynatrace Security Posture Management (SPM / XSPM) compliance findings and
external provider compliance findings.
> **Cross-references:** field reference → [data-model.md § Compliance Fields](data-model.md#compliance-fields-spm) ·
> time-window rules → [common-patterns.md](common-patterns.md).
> **SPM has three flavors:** KSPM (Kubernetes — DT-native, the
> security-analyzer-service), CSPM (cloud posture) and VSPM (VMware posture),
> the latter two delivered via an external/partner integration. The DQL patterns
> in this file target **KSPM**.
> CSPM/VSPM ride on the same `security.events` table but use the cross-provider
> `finding.*` namespace, not `compliance.rule.*` — see
> [all-security-events.md](all-security-events.md) and § External Compliance
> below for those.
> **KSPM scope is Kubernetes-only.** The DT-native SPM analyzer assesses K8s
> clusters, nodes, pods, deployments, statefulsets, daemonsets, jobs, cronjobs,
> replicasets, replication controllers. There are no DT-native compliance
> findings against AWS / Azure / GCP / host / process entities. If the user
> asks "what's our AWS compliance posture?" they need CSPM or another external
> integration.
> **KSPM standards — `compliance.standard.short_name` values: `CIS`, `DORA`,
> `NIST`, `DISA STIG`.** Note STIG specifically: the short_name is the full
> `"DISA STIG"` label, not bare `"STIG"`. A filter like
> `compliance.standard.short_name == "STIG"` returns nothing. For all standard
> scoping, prefer **`contains(lower(compliance.standard.short_name), "<keyword>")`**
> over exact equality — it tolerates the DISA prefix, version suffixes, and
> case mismatches uniformly across all four standards. PCI DSS, ISO 27001,
> HIPAA, GDPR, BSI C5, TISAX, Cyber Essentials, Essential Eight, etc. arrive
> only via CSPM/VSPM or other external integrations — they don't
> populate the KSPM rule namespace. CIS is mandatory for K8s; DORA / NIST /
> DISA STIG are opt-in (configurable in Settings → Application Security →
> Security Posture Management).
> **Snapshot vs. history.** DT SPM queries use a **1-hour fixed window**
> (`from:now()-1h`). This is aligned with the scan-completion cycle — it ensures
> the latest `COMPLIANCE_SCAN_COMPLETED` event is captured per object so the
> inner join succeeds. Widening doesn't extend history; if no scan completed in
> the last 1h for an object, that object's findings will NOT appear (inner-join
> behavior — by design). Scans are triggered when an ActiveGate ships a fresh
> configuration dataset (typically hourly per K8s cluster).
> **NOT_RELEVANT.** Always excluded by the base filter. These are rules that
> don't apply to the assessed object (e.g. AWS rule on a GCP resource, or a
> rule that requires a K8s version mismatch). Never count them in pass/fail
> totals. The SPM app's "Recommended" view excludes them by default; "Complete"
> view includes them.
> **MANUAL is currently non-actionable.** A `MANUAL` rule is one the analyzer
> can't auto-evaluate (e.g. it depends on physical-security checks, or
> external-control configuration the analyzer can't see). The Dynatrace docs
> note "Manual results aren't currently actionable" — there's no remediation
> workflow. Treat MANUAL as a triage hint, not a remediable defect, and don't
> include it in pass-rate numerators.
> **No mute / exemption / waiver mechanism.** Unlike vulnerabilities, compliance
> findings have no `mute.*` namespace in `security.events`. "Accepted risk" is
> not modeled in DQL. If asked "show me accepted compliance findings," explain
> the field doesn't exist (the SPM app may surface acceptance UI-side, but it's
> not exposed to queries).
> **Default tool filter is FAILED-only.** To compute pass rate ("how compliant
> are we?"), set `resultStatuses=ALL` so PASSED is in the denominator.
> **Compliance status field and values — use `compliance.result.status.level`, never `event.status`.**
> The canonical enum is `PASSED`, `FAILED`, `MANUAL`, `NOT_RELEVANT` — not `PASS`/`FAIL`. Always count
> failures with an explicit equality check: `countIf(compliance.result.status.level == "FAILED")`.
> Using a negation (`!= "PASSED"`) wrongly folds `MANUAL` and `NOT_RELEVANT` into the failed count.
> `event.status` is a generic event-lifecycle field (`Active`/`Closed`) unrelated to compliance verdicts;
> it is null or wrong on `COMPLIANCE_FINDING` rows. Additional field rules:
> — Standard field: `compliance.standard.short_name` (not `compliance.rule.standard`, which does not exist in the Semantic Dictionary).
> — Severity field: `compliance.rule.severity.level` (not bare `compliance.rule.severity`, which resolves to nothing).
> — Latest-scan join uses `on: {scan.id}` shorthand — not `on: {left.scan.id == right.scan.id}` (`left.`/`right.` prefixes are body syntax only, not valid inside `on:`).
> — Pass rate must be computed on **per-rule** verdicts (after Step 2 rollup), not directly on raw per-`(rule, object)` rows.
---
## Routing: DT KSPM vs External (CSPM / VSPM / external posture tools)
| Source | `event.type` | Provider filter |
|---|---|---|
| Dynatrace KSPM | `COMPLIANCE_FINDING` | `product.vendor=="Dynatrace"` AND `product.name=="Security Posture Management"` AND `compliance.result.status.level != "NOT_RELEVANT"` |
| External (CSPM/VSPM + other posture tools) | `COMPLIANCE_FINDING` | `filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"` |
The `product.name == "Security Posture Management"` filter is the most precise
KSPM scope — `product.vendor == "Dynatrace"` alone matches RVA, RAP, and
Automated Detections too on certain event types.
Use the KSPM 1h latest-scan pipeline only for Dynatrace SPM/Kubernetes
questions. For AWS / Azure / GCP / PCI / ISO / HIPAA / GDPR and other non-KSPM
compliance questions, route to raw external `COMPLIANCE_FINDING` over `24h+`,
using the external taxonomy (`compliance.standards` / `compliance.policy` /
`compliance.control`), not the KSPM `compliance.rule.*` namespace.
**Section routing for non-obvious intents — jump directly, do not improvise:**
| Intent | Section |
|---|---|
| "configuration drift" / "newly failing rules" / "new violations vs last week" (DT KSPM) | [§ Week-over-Week Config Drift](#dt-spm-week-over-week-config-drift-newly-failing-rules) — prior-period **anti-join**; a `7d` fetch window or a created-time filter is NOT a substitute |
| External violations "by standard" / "by framework" | [§ External Compliance](#external-compliance-queries-cspm--vspm--external-posture-tools) — `compliance.standards` is an **array**: always `expand compliance.standard = compliance.standards` before `summarize`, otherwise the per-standard breakdown collapses |
| External findings "not present in the previous period" | [§ External Compliance](#external-compliance-queries-cspm--vspm--external-posture-tools) newness anti-join — same anti-join rule as drift |
| Top failing external rules | [§ External Compliance](#external-compliance-queries-cspm--vspm--external-posture-tools) — group by `compliance.control` / `compliance.policy` (+ expanded standard), not by `finding.title` alone |
---
## DT SPM: Base Pattern
> **Answer posture questions overall first; per-cluster only on request.** For
> "what is my compliance posture?" or "what is my DORA/CIS/NIST/STIG posture?" —
> run Steps 1+2 + "Grouped by Standard with Pass Rate" as the primary answer (one
> query). Do **not** generate a per-cluster breakdown unless the user explicitly
> asks ("by cluster", "per system", "which clusters fail"). If asked for both in
> one prompt, run two queries: standard summary first, then the per-cluster 3-step
> variant.
All Dynatrace compliance queries share two building blocks. **Build any query by
combining Step 1 + Step 2 + a variant extension.**
### Step 1 — Base Filter + Latest Scan Join (always identical)
The inner join enriches each finding with the latest scan's `scan.id`, `timestamp`,
`object.name`, and `object.type` per scanned entity (`object.id`).
```dql
fetch security.events, from:now()-1h
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor=="Dynatrace"
AND compliance.result.status.level != "NOT_RELEVANT"
| join [
fetch security.events, from:now()-1h
| filter event.type == "COMPLIANCE_SCAN_COMPLETED"
AND product.vendor == "Dynatrace"
| sort timestamp asc
| summarize {
scan.id = takeLast(scan.id),
timestamp = takeLast(timestamp),
object.name = takeLast(object.name),
object.type = takeLast(object.type)
}, by: {object.id}
], on: {scan.id}
```
### Step 2 — Per-Rule Summarize + Derived Status (shared block)
Groups findings by `compliance.rule.id`. Collects pass/fail/manual counts and all
related entity identifiers.
```dql-snippet
| summarize {
compliance.rule.severity.level = takeFirst(compliance.rule.severity.level),
compliance.standard.short_name = takeFirst(compliance.standard.short_name),
compliance.standard.name = takeFirst(compliance.standard.name),
compliance.result.count.passed = countIf(compliance.result.status.level == "PASSED"),
compliance.result.count.failed = countIf(compliance.result.status.level == "FAILED"),
compliance.result.count.manual = countIf(compliance.result.status.level == "MANUAL"),
compliance.rule.title = takeFirst(compliance.rule.title),
affected_entity.types = collectDistinct(object.type),
affected_entity.ids = collectDistinct(object.id),
affected_entity.names = collectDistinct(array(object.name, compliance.result.object.name)),
related_entities.names = arrayConcat(
arrayRemoveNulls(collectDistinct(compliance.result.object.name)),
arrayRemoveNulls(collectDistinct(k8s.pod.name)),
arrayRemoveNulls(collectDistinct(k8s.workload.name)),
arrayRemoveNulls(collectDistinct(k8s.node.name)),
arrayRemoveNulls(collectDistinct(k8s.namespace.name)),
arrayRemoveNulls(collectDistinct(k8s.cluster.name)),
arrayRemoveNulls(collectDistinct(host.name)),
arrayRemoveNulls(collectDistinct(azure.resource.name)),
arrayRemoveNulls(collectDistinct(aws.resource.name)),
arrayRemoveNulls(collectDistinct(gcp.resource.name))),
related_entities.ids = arrayConcat(
arrayRemoveNulls(collectDistinct(dt.smartscape_source.id)),
arrayRemoveNulls(collectDistinct(dt.smartscape.process)),
arrayRemoveNulls(collectDistinct(dt.smartscape.host)),
arrayRemoveNulls(collectDistinct(dt.smartscape.k8s_cluster)),
arrayRemoveNulls(collectDistinct(dt.smartscape.k8s_node)),
arrayRemoveNulls(collectDistinct(dt.smartscape.k8s_pod)),
arrayRemoveNulls(collectDistinct(dt.entity.host)),
arrayRemoveNulls(collectDistinct(dt.entity.process_group)),
arrayRemoveNulls(collectDistinct(dt.entity.process_group_instance)),
arrayRemoveNulls(collectDistinct(dt.entity.kubernetes_cluster)),
arrayRemoveNulls(collectDistinct(k8s.pod.uid)),
arrayRemoveNulls(collectDistinct(k8s.cluster.uid)),
arrayRemoveNulls(collectDistinct(dt.entity.cloud_application_namespace)),
arrayRemoveNulls(collectDistinct(dt.entity.kubernetes_node)),
arrayRemoveNulls(collectDistinct(azure.resource.id)),
arrayRemoveNulls(collectDistinct(aws.resource.id)),
arrayRemoveNulls(collectDistinct(gcp.resource.id)))
}, by: {compliance.rule.id}
| fieldsAdd compliance.result.status.level =
if(compliance.result.count.failed > 0, "FAILED",
else: if(compliance.result.count.manual > 0, "MANUAL",
else: if(compliance.result.count.passed > 0, "PASSED", else: "NOT_RELEVANT")))
```
**Why each stage exists:**
| Stage | Purpose |
|---|---|
| Top-level filters | Scope to Dynatrace SPM-generated `COMPLIANCE_FINDING` rows; drop `NOT_RELEVANT`. |
| `join` with `COMPLIANCE_SCAN_COMPLETED` | Dedupe to the **latest completed scan** per object. Without the join, repeated scan attempts double-count findings. |
| `summarize … by: {compliance.rule.id}` | Rolls per-(rule, object) rows into per-rule rows with pass/fail/manual counters. |
| `fieldsAdd compliance.result.status.level` | Derives a rule-level verdict: any failure → `FAILED`; otherwise any manual → `MANUAL`; else `PASSED`. |
---
## DT SPM: Variant Extensions
Apply Step 1 + Step 2, then append:
| Use case | Append after Step 2 |
|---|---|
| Latest execution — flat list per rule | _(no addition)_ |
| Failed rules only | `\| filter compliance.result.status.level=="FAILED"` |
| Grouped by standard with pass rate | See below |
| Grouped by affected systems | See below (requires Step 2 modification) |
| Rules failing on the most objects | See below |
### Pre-Aggregation Filters (insert between Step 1 and Step 2)
These reduce data volume before the per-rule aggregation. All accept `"ALL"` as a
no-op:
```dql-snippet
// Scope to a specific cluster / system (KSPM + external CSPM/cloud)
| filter ("${systems}" == "ALL" or in("${systems}", arrayRemoveNulls(array(
k8s.cluster.name,
aws.account.id,
aws.account.name,
azure.subscription.name,
gcp.project.id
))))
// Scope to a compliance standard (short_name OR full name, e.g. "CIS", "DORA", "NIST", "STIG")
| filter ("${standards}" == "ALL" or in("${standards}", arrayRemoveNulls(array(
compliance.standard.short_name,
compliance.standard.name
))))
// Scope to specific rule titles or rule IDs (exact match)
| filter ("${ruleTitles}" == "ALL" or compliance.rule.title == "${ruleTitles}")
| filter ("${ruleIds}" == "ALL" or compliance.rule.id == "${ruleIds}")
```
> **Standard short-name values for KSPM are limited to `CIS`, `DORA`, `NIST`,
> `STIG`.** PCI / ISO / HIPAA / GDPR / BSI / TISAX / Cyber Essentials / Essential
> Eight come only via CSPM/VSPM or other external integrations and
> may or may not populate `compliance.standard.short_name` consistently.
### Post-Aggregation Filters (insert after Step 2)
```dql-snippet
// Filter by rule severity (CRITICAL / HIGH / MEDIUM / LOW)
| filter ("${riskLevels}"=="ALL" or in(compliance.rule.severity.level, splitString("${riskLevels}",",")))
// Filter by aggregated rule status — default is FAILED (skip PASSED / MANUAL / NOT_RELEVANT)
| filter ("${resultStatuses}" == "ALL" or compliance.result.status.level == "${resultStatuses}")
// Filter by entity (post-aggregation arrays — see common-patterns.md § 5)
| filter ("${entityIdsOrNames}"=="ALL"
OR in(affected_entity.ids, splitString("${entityIdsOrNames}",","))
OR in(affected_entity.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.ids, splitString("${entityIdsOrNames}",",")))
```
### Grouped by Standard with Pass Rate
```dql-snippet
// Step 1 + Step 2 (as above), then:
| summarize {
Rules=count(),
Passed=countIf(compliance.result.status.level=="PASSED"),
Manual=countIf(compliance.result.status.level=="MANUAL"),
Failed=countIf(compliance.result.status.level=="FAILED")
}, by: {compliance.standard.short_name}
| fieldsAdd passRate=round(Passed*100.0/Rules, decimals:0)
| sort passRate asc
```
### CIS-Primary Standard Summary
This is the **scorecard for broad posture / "main summary for counts" questions**
("compliance posture overview", "what's our pass rate"). For compliance/misconfiguration
questions scoped to a **specific entity**, use the [Entity Security-Tab View](#entity-security-tab-view-cis-led-failed-rules)
below instead (failed-rules lists, not a scorecard).
CIS is the **mandatory K8s baseline** (always enabled), so it leads the count summary. Reuse
the per-standard rollup above and force CIS to the top with the numeric-priority sort idiom —
a boolean `sort … == "CIS" desc` is invalid DQL:
```dql-snippet
// Step 1 + Step 2 (as above), then:
| summarize {
Rules=count(),
Passed=countIf(compliance.result.status.level=="PASSED"),
Manual=countIf(compliance.result.status.level=="MANUAL"),
Failed=countIf(compliance.result.status.level=="FAILED")
}, by: {compliance.standard.short_name}
| fieldsAdd passRate=round(Passed*100.0/Rules, decimals:0)
| fieldsAdd standardPriority=if(compliance.standard.short_name=="CIS", 0, else: 1)
| sort standardPriority asc, Failed desc
```
**Present the CIS row as the headline failed-rule count.** List DORA / NIST / DISA STIG rows
immediately after as *additional* failed rules — phrase them as "additional failed rules from
`<standard>`, which may overlap with CIS". **Do not sum failed counts across standards:** the
standards overlap, so the same misconfiguration on the same object can fail a CIS rule *and* a
DORA/NIST/STIG rule, and a cross-standard total double-counts the same underlying issues.
### Entity Security-Tab View (CIS-led failed rules)
Use this when the user asks about **compliance / misconfigurations on a specific entity**
(host, K8s node, workload, cluster, …). It mirrors the **Security tab shown on an individual
entity** in the Dynatrace UI: it **defaults to CIS** and **lists failed rules only**. Render
**three tables in order** — it is the entity-scoped counterpart to the broad
[CIS-Primary Standard Summary](#cis-primary-standard-summary) scorecard.
Replace `${entityIdsOrNames}` with the target entity's id(s) / name(s). `NOT_RELEVANT` is
already dropped by the Step-1 base filter, consistent with the tab's failed-only view.
> **Kubernetes scoping note (SPM/KSPM).** For `KUBERNETES_NODE` / `K8S_POD` questions, Dynatrace SPM `COMPLIANCE_FINDING` events are typically emitted per individual K8s resource (e.g. `k8sclusterrole`, `KUBERNETES_NODE`, `CLOUD_APPLICATION_INSTANCE`) and are reliably scoped via `k8s.cluster.name`.
> Resolve the cluster name if needed and filter on `k8s.cluster.name` (and any available `k8s.*` fields); do **not** assume `object.type == KUBERNETES_CLUSTER` or rely on `related_entities.*` for scoping.
> **Window per source.** Tables 1 and 2 (Dynatrace SPM/KSPM) always use `from:now()-1h`.
> Table 3 (external CSPM/VSPM tools) uses `from:now()-24h` — external findings arrive on
> a slower polling cycle and are not inner-joined to a scan event.
**Table 1 — Dynatrace CIS failed rules** (the headline; mirrors the Security tab):
```dql-snippet
// Step 1 + Step 2 (as above), then:
| filter compliance.standard.short_name == "CIS"
AND compliance.result.status.level == "FAILED"
// Scope to the entity (post-aggregation arrays — see common-patterns.md § 5)
| filter ("${entityIdsOrNames}"=="ALL"
OR in(affected_entity.ids, splitString("${entityIdsOrNames}",","))
OR in(affected_entity.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.ids, splitString("${entityIdsOrNames}",",")))
| fieldsAdd sevPriority = if(compliance.rule.severity.level=="CRITICAL", 0,
else: if(compliance.rule.severity.level=="HIGH", 1,
else: if(compliance.rule.severity.level=="MEDIUM", 2,
else: 3)))
| sort sevPriority asc
| fields Severity = compliance.rule.severity.level,
Rule = compliance.rule.title,
`Rule ID` = compliance.rule.id,
Status = compliance.result.status.level
```
> The view is already scoped to one entity, so omit a per-rule affected-resource column —
> it would carry the rule's full cluster-wide object list (huge and off-topic). The CIS
> **recommendation section is already embedded in `compliance.rule.title`** (e.g.
> "4.5.1 Prefer using secrets as files…"), so no `metadata_json` parse is needed for it.
**Table 2 — additional failed rules from other Dynatrace standards** (DORA / NIST / DISA STIG).
Same pipeline, `compliance.standard.short_name != "CIS"`, with a **Standard** column:
```dql-snippet
// Step 1 + Step 2 (as above), then:
| filter compliance.standard.short_name != "CIS"
AND compliance.result.status.level == "FAILED"
| filter ("${entityIdsOrNames}"=="ALL"
OR in(affected_entity.ids, splitString("${entityIdsOrNames}",","))
OR in(affected_entity.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.names, splitString("${entityIdsOrNames}",","))
OR in(related_entities.ids, splitString("${entityIdsOrNames}",",")))
| fieldsAdd sevPriority = if(compliance.rule.severity.level=="CRITICAL", 0,
else: if(compliance.rule.severity.level=="HIGH", 1,
else: if(compliance.rule.severity.level=="MEDIUM", 2,
else: 3)))
| sort sevPriority asc
| fields Standard = compliance.standard.short_name,
Severity = compliance.rule.severity.level,
Rule = compliance.rule.title,
`Rule ID` = compliance.rule.id,
Status = compliance.result.status.level
```
> Label Table 2 as *additional* failed rules **that may overlap with CIS** — the standards
> overlap, so the same misconfiguration on the entity can fail a CIS rule *and* a
> DORA/NIST/STIG rule. **Never sum failed counts across Tables 1 and 2.**
**Table 3 — externally ingested misconfigurations / compliance findings** (CSPM/VSPM and other
external providers on the entity). Do **not** rebuild this — use the
[External Compliance Queries](#external-compliance-queries-cspm--vspm--external-posture-tools)
patterns below, scoping the
**pre-aggregation** filter to the entity via `object.id` / `dt.smartscape_source.id` /
`k8s.*` (external rows are raw per-finding; the KSPM `affected_entity.*` arrays do not apply).
Use `from:now()-24h` for the external query — external findings arrive on a slower polling
cycle and are not inner-joined to a scan event (unlike DT SPM which requires 1h).
A 0-row result means no external posture tool reported on this entity — still state that
explicitly.
### Grouped by Affected Systems with Pass Rate
In Step 2, replace `affected_entity.ids` and `affected_entity.names` with a single
record field:
```dql-snippet
// In the Step 2 summarize block, replace:
// affected_entity.ids = collectDistinct(object.id),
// affected_entity.names = collectDistinct(array(object.name, compliance.result.object.name)),
// With:
// affected_entities = collectDistinct(record(object.id, compliance.result.object.name,
// dt.smartscape_source.id)),
// Then after Step 2:
| expand affected_entities
| summarize {
Rules=count(),
Passed=countIf(compliance.result.status.level=="PASSED"),
Manual=countIf(compliance.result.status.level=="MANUAL"),
Failed=countIf(compliance.result.status.level=="FAILED")
}, by: {affected_entities}
| fieldsAdd passRate=round(Passed*100.0/Rules, decimals:0)
```
### Rules Failing on the Most Objects
```dql-snippet
// Append after Step 2:
| filter compliance.result.status.level == "FAILED"
| fields compliance.rule.id, compliance.rule.title, compliance.standard.short_name,
compliance.rule.severity.level, compliance.result.count.failed, affected_entity.names
| sort compliance.result.count.failed desc
| limit 20
```
### Overall Pass Rate
```dql-snippet
// Append after Step 2:
| summarize {
Rules = count(),
Passed = countIf(compliance.result.status.level == "PASSED"),
Manual = countIf(compliance.result.status.level == "MANUAL"),
Failed = countIf(compliance.result.status.level == "FAILED")
}
| fieldsAdd overallPassRate = round(Passed * 100.0 / Rules, decimals: 0)
```
---
> **Do not use `scan.result.summary_json`.** Although `COMPLIANCE_SCAN_COMPLETED`
> events carry a pre-computed `scan.result.summary_json` blob, agents **must not**
> parse it for compliance posture answers. It bypasses the per-rule pipeline,
> causes a second parallel query, and yields pre-aggregated data that cannot be
> filtered, extended, or broken down by rule or severity. Always use the
> `COMPLIANCE_FINDING` canonical pipeline (Steps 1 + 2) instead.
---
## DT SPM: New/Changed Compliance Findings (UC-G2 for compliance)
To find genuinely new compliance violations (not already known from a prior scan
cycle), filter on `finding.time.created` — a string timestamp set when the finding
was first ingested. Works for both DT SPM and external compliance findings:
```dql-snippet
// After Step 1 + Step 2, append:
| filter toTimestamp(finding.time.created) > now() - 24h
| sort finding.time.created desc
```
For SPM the inner-join to `COMPLIANCE_SCAN_COMPLETED` already scopes to the latest
scan; combining with `finding.time.created` narrows further to findings that first
appeared in the current scan window.
---
## DT SPM: Week-over-Week Config Drift (newly failing rules)
"What compliance rules are failing **now** that weren't failing a week ago?" Take this hour's
latest-scan **FAILED** findings and anti-join (outer join + `isNull(right…)`) against the
**FAILED** findings from ~7 days ago. The inner Step-1 scan-join is applied to **both** periods
so each is deduped to its latest scan. Group on `{object.id, compliance.rule.id}` for the diff.
> **Both periods must filter to `compliance.result.status.level == "FAILED"` BEFORE the
> anti-join — not `!= "NOT_RELEVANT"`.** The diff key is `{object.id, compliance.rule.id}`, so if
> the prior period carries PASSED rows, a rule that was **passing** a week ago and is **failing
> now** produces a matching `(object, rule)` pair on both sides and gets wrongly excluded —
> dropping exactly the pass→fail drift the question asks for. Comparing FAILED-vs-FAILED surfaces
> both brand-new `(object, rule)` pairs and genuine pass→fail transitions.
```dql
// This hour's latest scan (Step 1 with a prefix so its fields don't collide)
fetch security.events, from:now()-1h
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor == "Dynatrace"
AND product.name == "Security Posture Management"
AND compliance.result.status.level == "FAILED"
| join [
fetch security.events, from:now()-1h
| filter event.type == "COMPLIANCE_SCAN_COMPLETED" AND product.vendor == "Dynatrace"
| sort timestamp asc
| summarize { scan.id = takeLast(scan.id), timestamp = takeLast(timestamp) }, by: {object.id}
], on: {scan.id}, prefix:"last_scan."
// Anti-join the scan from ~7 days ago (same Step-1 latest-scan logic, 30m window)
| join kind:outer, on:{object.id, compliance.rule.id}, [
fetch security.events, from:-7d, to:-7d+30m
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor == "Dynatrace"
AND product.name == "Security Posture Management"
AND compliance.result.status.level == "FAILED"
| join [
fetch security.events, from:-7d, to:-7d+30m
| filter event.type == "COMPLIANCE_SCAN_COMPLETED" AND product.vendor == "Dynatrace"
| sort timestamp asc
| summarize { scan.id = takeLast(scan.id) }, by: {object.id}
], on: {scan.id}
| dedup {compliance.rule.id, object.id}
| fields compliance.rule.id, object.id
]
| filter isNull(right.object.id) // present now, absent a week ago
| summarize {
compliance.rule.severity.level = takeFirst(compliance.rule.severity.level),
compliance.standard.short_name = takeFirst(compliance.standard.short_name),
compliance.rule.title = takeFirst(compliance.rule.title),
compliance.result.count.failed = countIf(compliance.result.status.level == "FAILED"),
k8s.cluster.names = collectDistinct(k8s.cluster.name),
affected_entity.names = collectDistinct(object.name)
}, by: {compliance.rule.id, last_scan.timestamp}
| filter compliance.result.count.failed > 0
AND in(compliance.rule.severity.level, array("CRITICAL","HIGH"))
| sort compliance.result.count.failed desc
| limit 50
```
> The previous-period sub-window uses `to:-7d+30m` (a 30-min slice a week back) so it captures one
> completed scan without pulling 7 days of rows. Adjust the offset for month-over-month, etc. If a
> 30-min slice catches no completed scan, widen it (e.g. `to:-7d+2h`) so the prior-period baseline
> isn't artificially empty — an empty baseline makes every current failure look "newly failing".
>
> The trailing `in(compliance.rule.severity.level, {"CRITICAL","HIGH"})` is an optional prioritizer
> and may legitimately return zero rows when the period's newly-failing rules are all lower
> severity — drop or widen it to see all newly-failing rules.
---
## DT SPM: Rule Drilldown with Evidence Parsing
For a specific rule, parses JSON evidence attached to each finding and expands
individual findings. **Does not** use the per-rule summarize block (Step 2) —
applies directly after Step 1.
```dql-snippet
// Step 1 (as above), then:
| filter compliance.rule.id == "CIS-75904"
| parse compliance.result.object.evidence_json, "JSON_ARRAY:findings"
| fieldsAdd allFindings = if(iAny(not isNull(findings[])), findings, else: array(record(type = "", description = "", value = "")))
| expand allFindings
| fieldsAdd Result = compliance.result.status.level,
`Resource name` = object.name,
Type = object.type,
`Resource type` = compliance.result.object.type,
`Related configuration properties` = allFindings,
System = k8s.cluster.name,
`Analyzed at` = timestamp
| sort System == "unguard-dev" desc,
Result == "FAILED" desc, Result == "MANUAL" desc, Result == "PASSED" desc
| fieldsKeep `Analyzed at`, System, "dt.smartscape*", "dt.entity*", "dt.source*",
Result, `Resource name`, Type, `Resource type`,
`Related configuration properties`
```
The evidence array carries `{type, description, value}` records where `type` is
`AUTOMATIC` (analyzer evaluated the property) or `MANUAL` (requires human
input — `value` is typically `"Unknown"`). Filter on `allFindings.type == "MANUAL"`
to surface checks waiting on operator input.
---
## DT SPM: Per-Cluster + Per-Namespace Breakdown
To count **rules** (not object checks) per cluster, first roll up to the per-rule-per-cluster
level (Step 2 variant keyed by `{compliance.rule.id, k8s.cluster.name}`), then count rule
verdicts per cluster (Step 3). Grouping directly by cluster after Step 1 without this
intermediate step counts *object-check results*, not rules — the pass rate will be
inflated and inconsistent with the per-standard summary.
```dql-snippet
// Step 1 (as above), then:
// Step 2 variant — per-rule-per-cluster verdict
| summarize {
compliance.rule.severity.level = takeFirst(compliance.rule.severity.level),
passed = countIf(compliance.result.status.level == "PASSED"),
failed = countIf(compliance.result.status.level == "FAILED"),
manual = countIf(compliance.result.status.level == "MANUAL"),
SmartscapeCluster = takeFirst(dt.smartscape.k8s_cluster)
}, by: {compliance.rule.id, Cluster = k8s.cluster.name}
| fieldsAdd ruleStatus =
if(failed > 0, "FAILED",
else: if(manual > 0, "MANUAL",
else: if(passed > 0, "PASSED", else: "NOT_RELEVANT")))
// Step 3 — per-cluster rule counts
| summarize {
Rules = count(),
Passed = countIf(ruleStatus == "PASSED"),
Manual = countIf(ruleStatus == "MANUAL"),
Failed = countIf(ruleStatus == "FAILED"),
CriticalFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "CRITICAL"),
HighFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "HIGH")
}, by: {Cluster, SmartscapeCluster}
| fieldsAdd passRate = round(Passed * 100.0 / Rules, decimals: 0)
| sort passRate asc
```
For per-namespace, extend the Step 2 `by` key to
`{compliance.rule.id, Cluster = k8s.cluster.name, Namespace = k8s.namespace.name}`
and the Step 3 `by` key to `{Cluster, Namespace}`.
### Single Cluster / System Summary
When the user asks about a *specific* named cluster, scope with a pre-aggregation filter and
collapse all per-rule rows into a **single summary row**. This avoids a `by: {Cluster}` grouping
and lets the final `summarize` include per-severity failure counts in the same query — one query,
one result row:
```dql-snippet
// Step 1 (as above), then:
// Pre-aggregation filter — scope to the named cluster
| filter k8s.cluster.name == "<cluster-name>"
// Step 2 — per-rule verdict (no cluster key needed; already filtered)
| summarize {
compliance.rule.severity.level = takeFirst(compliance.rule.severity.level),
passed = countIf(compliance.result.status.level == "PASSED"),
failed = countIf(compliance.result.status.level == "FAILED"),
manual = countIf(compliance.result.status.level == "MANUAL")
}, by: {compliance.rule.id}
| fieldsAdd ruleStatus =
if(failed > 0, "FAILED",
else: if(manual > 0, "MANUAL",
else: if(passed > 0, "PASSED", else: "NOT_RELEVANT")))
// Single summarize — rule counts + per-severity failure breakdown in one row (no by: clause)
| summarize {
Rules = count(),
Passed = countIf(ruleStatus == "PASSED"),
Manual = countIf(ruleStatus == "MANUAL"),
Failed = countIf(ruleStatus == "FAILED"),
CriticalFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "CRITICAL"),
HighFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "HIGH"),
MediumFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "MEDIUM"),
LowFailed = countIf(ruleStatus == "FAILED" AND compliance.rule.severity.level == "LOW")
}
| fieldsAdd passRate = round(Passed * 100.0 / Rules, decimals: 0)
```
If a standard filter is also needed (e.g. "DORA posture on eks-live"), add it as a second
pre-aggregation filter right after the cluster filter:
`| filter contains(lower(compliance.standard.short_name), "dora")`
---
## DT SPM: Severity-Weighted Risk Score
A simple "compliance risk" rollup that weights failures by severity. Use for
prioritization tiles ("which clusters carry the most compliance risk?"):
```dql-snippet
// Step 1 + Step 2 with a `compliance.standard.short_name`-aware grouping if needed, then:
| filter compliance.result.status.level == "FAILED"
| summarize {
riskScore = sum(if(compliance.rule.severity.level == "CRITICAL", 10,
else: if(compliance.rule.severity.level == "HIGH", 7,
else: if(compliance.rule.severity.level == "MEDIUM", 4,
else: 1)))
* compliance.result.count.failed),
failedRules = count(),
affectedObjects = sum(compliance.result.count.failed)
}, by: {compliance.standard.short_name}
| sort riskScore desc
```
The severity scores `CRITICAL=10 / HIGH=7 / MEDIUM=4 / LOW=1` match
`compliance.rule.severity.score` (CCSS-derived). Substitute the field if
present on raw events.
---
## DT SPM: MANUAL Deep Dive
`MANUAL` rules require human input — surface them as a separate triage list,
not as failures. Surface the question (description) and the `Unknown` value:
```dql-snippet
// Step 1 (as above), then:
| filter compliance.result.status.level == "MANUAL"
| parse compliance.result.object.evidence_json, "JSON_ARRAY:findings"
| expand finding = findings
| filter finding.type == "MANUAL"
| fieldsAdd System = k8s.cluster.name,
Resource = object.name,
ResourceType = compliance.result.object.type,
Rule = compliance.rule.title,
RuleID = compliance.rule.id,
Severity = compliance.rule.severity.level,
Question = finding.description,
CurrentValue = finding.value
| sort Severity == "CRITICAL" desc, Severity == "HIGH" desc
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
System, Resource, ResourceType, Rule, RuleID, Severity, Question, CurrentValue
```
Group by `compliance.rule.id` and `Question` to count how many objects share
the same unanswered question — useful for batch resolution via configuration
(e.g. enabling Node Configuration Collector resolves a class of MANUAL checks
at once).
---
## DT SPM: Compliance-Finding Age
Compliance findings don't have a per-finding "first seen" timestamp baked in,
but `finding.time.created` is set when the finding was ingested in the current
scan cycle. To approximate "how long has this rule been failing on this
object", look at the **earliest** `finding.time.created` for the
`(rule, object)` pair across the historical event stream — widening the window
deliberately past 1h:
```dql
fetch security.events, from:now()-30d
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor == "Dynatrace"
AND product.name == "Security Posture Management"
AND compliance.result.status.level == "FAILED"
| summarize firstFailedAt = takeMin(toTimestamp(finding.time.created)),
lastSeen = takeMax(toTimestamp(finding.time.created)),
scans = countDistinctExact(scan.id),
by: {compliance.rule.id, compliance.rule.title,
object.id, object.name, k8s.cluster.name}
| fieldsAdd ageDays = round((toLong(now()) - toLong(firstFailedAt)) / 1000000000.0 / 86400, decimals: 1)
| sort ageDays desc
| limit 50
```
**Worked single-rule/object form:** substitute the rule and object when the user
asks "this rule on this object." `finding.time.created` is a string timestamp, so
wrap it with `toTimestamp(...)` before `takeMin`, `takeMax`, comparisons, or age
math.
```dql-template
fetch security.events, from:now()-30d
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor == "Dynatrace"
AND product.name == "Security Posture Management"
AND compliance.result.status.level == "FAILED"
| filter compliance.rule.id == "<RULE_ID>" AND object.id == "<OBJECT_ID>"
| summarize firstFailedAt = takeMin(toTimestamp(finding.time.created)),
lastSeen = takeMax(toTimestamp(finding.time.created)),
scans = countDistinctExact(scan.id),
by: {compliance.rule.id, compliance.rule.title, object.id, object.name}
| fieldsAdd ageDays = round((toLong(now()) - toLong(firstFailedAt)) / 1000000000.0 / 86400, decimals: 1)
```
> **Bypassing the 1h window is intentional here.** This question is genuinely
> about history, not snapshot — we want every scan that ever reported FAILED
> for the pair. The inner-join-to-latest-scan trick is dropped, so each scan
> contributes a row, and the dedup is per `(rule, object)`.
---
## External Compliance Queries (CSPM / VSPM / external posture tools)
```dql
fetch security.events, from:now()-24h
| filter event.type == "COMPLIANCE_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| fieldsAdd statusNormalized = coalesce(compliance.result.status.level, compliance.status)
| filterOut statusNormalized == "NOT_RELEVANT"
| summarize findings=count(), by:{event.provider, product.name, dt.security.risk.level, statusNormalized}
| sort findings desc
```
When users ask for "violations", "failed controls", or "misconfigurations",
filter to `statusNormalized == "FAILED"` only if that field is present. Some
external providers send findings without a pass/fail status; for those, treat
each row as a provider-reported finding and group by `finding.title` /
`finding.type`.
### External compliance taxonomy fields
External `COMPLIANCE_FINDING` rows carry their own taxonomy namespace (distinct from KSPM's
`compliance.rule.*` / `compliance.standard.*`):
| Field | Shape | Example | Notes |
|---|---|---|---|
| `compliance.standards` | **array of string** | `["standards/cis-aws-foundations-benchmark/v/5.0.0"]`, `["Azure CSPM"]` | The standards/benchmarks a finding maps to. `expand` it for per-standard rollups. |
| `compliance.control` | string | `"S3.8"` | Provider control identifier. |
| `compliance.policy` | string | _(provider policy name)_ | Present on some providers, absent on others. |
| `compliance.status` | string | `"PASSED"` / `"FAILED"` | Top-level status; external rows usually leave `compliance.result.status.level` null. |
| `compliance.requirements` | array **or** `""` | `["CIS AWS Foundations Benchmark v5.0.0/2.1.4.2"]` | Type-inconsistent across providers — surface only, don't build filters/grouping on it. |
**External compliance by standard/framework** (expand the `compliance.standards` array):
```dql
fetch security.events, from:now()-24h
| filter event.type == "COMPLIANCE_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| fieldsAdd statusNormalized = coalesce(compliance.result.status.level, compliance.status)
| filterOut statusNormalized == "NOT_RELEVANT"
| filter isNotNull(compliance.standards)
| expand compliance.standard = compliance.standards
| summarize {
Findings=count(),
Failed=countIf(statusNormalized == "FAILED"),
Critical=countIf(dt.security.risk.level == "CRITICAL" AND statusNormalized == "FAILED"),
High=countIf(dt.security.risk.level == "HIGH" AND statusNormalized == "FAILED"),
SampleTitles=collectDistinct(finding.title, maxLength: 10),
AffectedObjects=countDistinctExact(object.id)
}, by:{event.provider, product.name, compliance.standard}
| sort Critical desc, High desc, Failed desc, Findings desc
```
> Providers that don't populate `compliance.standards` won't appear after `filter isNotNull(...)`.
> For those, fall back to grouping by `finding.title` / `finding.type` (see Field caveat below).
**Top failing controls by policy/control** — variant: swap the `by:` clause after `expand`:
```dql-snippet
// …after `expand compliance.standard = compliance.standards`, replace the summarize `by:` with:
}, by:{event.provider, product.name, compliance.standard, dt.security.risk.level,
compliance.policy, compliance.control}
| sort Critical desc, High desc, Findings desc
| limit 10
```
**Critical/high external compliance findings newly reported in the last 7d (not in the prior 7d):**
```dql
// This-period findings
fetch security.events, from:-7d, to:now()
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor != "Dynatrace" AND event.provider != "Dynatrace"
| filter finding.time.created > now()-7d
| filter isNotNull(compliance.standards)
| expand compliance.standard = compliance.standards
| dedup {object.id, finding.id}
// Anti-join the prior period: keep only findings absent a week ago
| join kind:outer, on:{object.id, finding.id}, [
fetch security.events, from:-14d, to:-7d
| filter event.type == "COMPLIANCE_FINDING"
AND product.vendor != "Dynatrace" AND event.provider != "Dynatrace"
| filter finding.time.created > now()-14d and finding.time.created < now()-7d
| filter isNotNull(compliance.standards)
| expand compliance.standard = compliance.standards
| dedup {object.id, finding.id}
| fields object.id, finding.id
]
| filter isNull(right.finding.id)
| summarize {
Findings=count(),
SampleTitles=collectDistinct(finding.title, maxLength: 5),
maxScore=takeMax(dt.security.risk.score),
AffectedObjects=countDistinctExact(object.id)
}, by:{event.provider, product.name, compliance.standard, dt.security.risk.level,
compliance.policy, compliance.control}
| sort maxScore desc, Findings desc
```
### Scoping external compliance to a provider
External compliance findings come from CSPM/VSPM and other ingested posture tools, each with
its own provider string and standard coverage. To scope to one, use the shared provider pattern
in [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type);
discover the active providers first (`summarize by {event.provider, product.vendor, product.name}`).
External standard names populate `compliance.standards` (not `compliance.standard.short_name`).
### Field caveat — KSPM `compliance.rule.*` vs. external taxonomy
External compliance findings use a **different** compliance namespace than KSPM. They do **not**
populate the KSPM rule/standard fields (`compliance.rule.id`, `compliance.rule.title`,
`compliance.rule.severity.level`, `compliance.standard.short_name` / `.name`) — those will
be null. Instead, external rows carry the
**external taxonomy** documented above: `compliance.standards` (array), `compliance.control`,
`compliance.policy`, `compliance.status`, `compliance.requirements`.
- For external findings, group/filter/sort by `compliance.standards` (expand the array),
`compliance.policy`, `compliance.control` — and `finding.title` / `finding.type` for
display. Do **not** use `compliance.rule.*` or `compliance.standard.short_name` on them.
- The DT KSPM query patterns (Steps 1 + 2 above) are **incompatible** with external
compliance findings — they require the KSPM `compliance.rule.*` namespace and the
inner-join to `COMPLIANCE_SCAN_COMPLETED` (which only DT KSPM emits).
- Status: external rows usually populate the top-level `compliance.status`
(`FAILED` / `PASSED`) rather than `compliance.result.status.level` — coalesce both
(`coalesce(compliance.result.status.level, compliance.status)`) when filtering.
- `compliance.requirements` is type-inconsistent (array on some providers, `""` on others) —
surface it if asked, but don't build filters or grouping on it.
For a cross-provider compliance view (DT KSPM + external aggregated by
`dt.security.risk.level`), see [all-security-events.md](all-security-events.md).
---
## Best Practices
1. **KSPM is K8s-only.** If the user's question implies AWS/Azure/GCP/host
compliance, route them to CSPM/external — DT-native KSPM has no rules for
those scopes.
2. **Pin `product.name == "Security Posture Management"`** for KSPM scoping
when precision matters; `product.vendor == "Dynatrace"` alone is shared
with RVA and other DT products.
3. **Always exclude `NOT_RELEVANT`** in the base filter and in pass-rate math.
It's the docs' "Recommended" view default for a reason.
4. **MANUAL is not a defect** — surface it separately, never count it as
PASSED, never count it in numerator of "pass rate." It's an open question.
5. **Per-rule status precedence is `FAILED > MANUAL > PASSED`** — Step 2's
`fieldsAdd` derives this. If you re-derive it elsewhere, follow the same
precedence (or the per-rule verdict will silently disagree with the SPM app).
6. **Severity is `CRITICAL / HIGH / MEDIUM / LOW`** — exactly four. KSPM does
not emit `NONE` or `NOT_AVAILABLE`. CCSS-derived since 2026-03-10.
7. **Never parse `compliance.rule.metadata_json`** — it is forbidden in this skill.
Use `compliance.rule.id` (already standard-prefixed and human-recognizable, e.g.
`CIS-2762`, `STIG-82824`, `DORA-67952`, `NIST-82827`) and `compliance.rule.title` as the
rule identifiers. The finer benchmark-section citation (CIS `1.2.3`) is intentionally
not surfaced.
8. **Object identity has two fields on KSPM rows** — `object.type` (normalized
Dynatrace entity type, uppercase: `KUBERNETES_CLUSTER`, `KUBERNETES_NODE`,
…) and `compliance.result.object.type` (analyzer's lowercase code:
`k8scluster`, `k8snode`, …). They're different fields, not synonyms. **On
external compliance findings**, `object.type` instead carries the
vendor-reported value as-is (e.g. `AwsEc2Instance`); don't normalize.
9. **Don't expect mute / waiver fields.** Compliance findings have no
`mute.*` namespace — that's vulnerability-only. If a user asks "show me
accepted-risk findings," explain the data isn't there.
10. **`COMPLIANCE_SCAN_COMPLETED` is per-cluster, per-scan-run** — not per-object.
The inner-join on `scan.id` deduplicates findings to the latest scan; the
join target itself doesn't carry per-rule info. Per-rule rollups go through
`COMPLIANCE_FINDING` + Step 2.
11. **No SPM scan-completed events for a specific cluster means no SPM coverage.**
If a named cluster (for example, `gke-live`) has no `COMPLIANCE_SCAN_COMPLETED`
events and no matching `COMPLIANCE_FINDING` rows for Security Posture
Management in the operational window, answer that SPM/KSPM is not covering
that cluster. The likely cause is that SPM is not enabled, deployed, or
configured for the cluster — not simply that the cluster has no violations.
12. **Widen past 1h only when answering history questions** (e.g. compliance-
finding age). The 1h default is a snapshot window — widening it in the
snapshot pattern won't add data, only cost.
13. **One query for compliance posture.** For "what is my compliance posture?"
run a single query: Steps 1 + 2 + "Grouped by Standard with Pass Rate"
extension. Do not add a supplementary `scan.result.summary_json` query for
per-cluster scores. If the user explicitly asks for a per-cluster breakdown
in the same prompt, run the 3-step per-cluster variant from
§ "Per-Cluster + Per-Namespace Breakdown" as a second query — not a third.
14. **Per-cluster pass rate requires a per-rule-per-cluster Step 2.** Grouping
directly by `k8s.cluster.name` after Step 1 counts object-check results, not
rules — the pass rate will be inflated and inconsistent with the per-standard
summary. Always run the 3-step pipeline: Step 1 → per-rule-per-cluster
summarize → per-cluster rule counts. See § "Per-Cluster + Per-Namespace
Breakdown".
14. **Answer posture questions overall first; per-cluster only on request.**
For "what is my DORA/CIS/NIST posture?" — the primary answer is one query:
Steps 1+2 + "Grouped by Standard with Pass Rate" (filtered to the named
standard). Do **not** jump to a per-cluster breakdown unless the user
explicitly asks for it ("by cluster", "per system", "which clusters fail").
If asked for both in one prompt, run two queries: standard summary first,
then the per-cluster 3-step variant.
references/coverage-and-dashboards.md
# Coverage & Dashboard Patterns
How to measure which processes, hosts, K8s workloads, and cloud entities are
covered by Dynatrace vulnerability scanning or by external security products.
> **Match recipes are in `dt-sec-contextualization`.** The 2-way container→workload
> match recipe (K8s workload coverage), cloud entity match (Path 1), and host
> coverage by IP match live in
> `dt-sec-contextualization/references/correlation-and-coverage.md`. This file owns the **counting
> logic** — the `smartscapeNodes` denominator queries, the DT-native scan-event
> lookups, and the covered/not-covered classification. Load
> `dt-sec-contextualization` alongside this file for any external-product coverage
> question that requires the container→workload or cloud match recipe.
> ⚠️ **Coverage means different things for runtime vs. non-runtime entities —
> pick the right shape first.**
>
> **Runtime entities** (hosts, processes, K8s workloads, cloud resources tracked
> in Smartscape) have a known total population, so coverage is a *percentage*
> (covered vs. not covered). These questions **REQUIRE a topology denominator —
> start from `smartscapeNodes`, never from `security.events` alone.** Compare the
> full entity population against scan/finding events via `lookup`. Scan events
> only exist for entities that *were* scanned, so summarizing them counts the
> covered set but can never reveal the uncovered set or a percentage.
>
> **Anti-pattern for runtime-entity coverage (wrong — no denominator):**
```dql
fetch security.events, from:now()-1h
| filter event.type == "VULNERABILITY_SCAN"
| summarize scans = count(), entities = countDistinctExact(object.id),
by: {event.provider, product.name}
```
> For a *runtime* entity this answers "how many entities were scanned" — NOT
> "what is my coverage". Use [§ DT Runtime Coverage Analysis](#dt-runtime-coverage-analysis-smartscapenodes)
> instead; the scan-event-only queries in the first section are building blocks
> for the `lookup` subquery, not standalone runtime-coverage answers.
>
> **Non-runtime entities** (container images, code artifacts, repositories) have
> **no Smartscape population to divide by**, so there is no percentage. Coverage
> here is simply a *count of distinct scanned objects* — and the scan-event
> summary above is the **correct** answer for this class (see [§ Non-Runtime
> Entity Coverage](#non-runtime-entity-coverage-images--artifacts)).
>
> **Build the "covered" set from scan events AND findings.** Scan events
> (`VULNERABILITY_SCAN` / `COMPLIANCE_SCAN`) are the preferred coverage signal,
> but some providers/features emit no scan event — in that case a *finding* on an
> entity also proves it was covered. Product coverage dashboards union the two.
> When scan events may be missing, `lookup`/`join` both scan events and findings
> and treat an entity as covered if it appears in either (the container-image
> recipe below already does this).
> **Specific-entity coverage interpretation:** when the user asks whether a
> named entity is covered by a Dynatrace security capability (for example, RVA on
> a host/process/workload, SPM/KSPM on a K8s cluster, RAP on a service/process, or
> another DT-native capability), query the capability-specific coverage signals
> and findings in the correct operational window. If **no relevant scan,
> scan-completed, coverage, or finding events** exist for that entity, answer that
> the entity is **not covered** by that capability. The likely reason is that the
> capability is not enabled, not deployed, or not configured to monitor that
> entity. Do not answer only "no findings" when the user asked about coverage.
> **`VULNERABILITY_SCAN` is the current event type for scan coverage.**
> `VULNERABILITY_COVERAGE_REPORT_EVENT` is **deprecated** — do not use in new
> queries.
> **`product.feature` distinguishes RVA modes** — `Code-level Vulnerability
> Analytics` vs. third-party VA. Filter or filterOut on this to scope.
---
## Contents
- [DT Vulnerability Scan Events](#dt-vulnerability-scan-events)
- [DT Runtime Coverage Analysis (smartscapeNodes)](#dt-runtime-coverage-analysis-smartscapenodes)
- [Non-Runtime Entity Coverage (Images & Artifacts)](#non-runtime-entity-coverage-images--artifacts)
- [External Product Coverage Analysis](#external-product-coverage-analysis)
- [3-Way Match Strategy for Container-Based Entities](#3-way-match-strategy-for-container-based-entities)
- [K8s Workload Coverage (count by provider/product)](#k8s-workload-coverage-count-by-providerproduct)
- [Cloud Entity Coverage (count by provider/product)](#cloud-entity-coverage-count-by-providerproduct)
- [Host Coverage by IP Match (count by provider/product)](#host-coverage-by-ip-match-count-by-providerproduct)
- [Best Practices](#best-practices)
---
## DT Vulnerability Scan Events
Scan events (`event.type == "VULNERABILITY_SCAN"`) mark which processes were
analyzed.
| Feature | `product.feature` filter |
|---|---|
| Third-party Vulnerability Analytics | `filterOut product.feature == "Code-level Vulnerability Analytics"` |
| Code-level Vulnerability Analytics | `filter product.feature == "Code-level Vulnerability Analytics"` |
**All Dynatrace scan coverage events for processes:**
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
```
**Covered processes — Third-party Vulnerability Analytics** (deduplicated per
host+process):
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
```
**Covered processes — Code-level Vulnerability Analytics only:**
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
| filter product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
```
---
## DT Runtime Coverage Analysis (smartscapeNodes)
> **Topology-start vs. events-start.** For "what's NOT covered" questions
> (entities present in topology but findings missing), **start from
> `smartscapeNodes`** and `lookup` the scan events. For "covered with what?"
> questions (findings present and you want to know which entities they map to),
> start from `security.events` and join back to topology. The wrong start
> direction produces structurally-correct queries that under- or over-count.
Start from Smartscape topology, then `lookup` scan events to classify entities as
covered vs. not covered.
> **Scan-only is sufficient for DT process/host coverage.** Dynatrace RVA emits a
> `VULNERABILITY_SCAN` event for every analyzed process, so the scan-event lookup
> below is a reliable covered-set on its own. The scan-events-**and**-findings
> union (top of file) matters for **external products** and any feature that may
> not emit a scan event — there a finding on the entity is the only proof it was
> covered (see the container-image recipe, which `lookup`s findings).
**Process coverage — Third-party Vulnerability Analytics:**
```dql
smartscapeNodes PROCESS
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
| fields dt.entity.process_group_instance
],
sourceField: id_classic, lookupField: dt.entity.process_group_instance
| fieldsAdd coverageStatus = if(isNull(lookup.dt.entity.process_group_instance), "not covered", else: "covered")
| summarize count(), by: { coverageStatus }
```
**Host coverage — Third-party Vulnerability Analytics:**
```dql
smartscapeNodes HOST
| dedup id_classic
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| fieldsKeep dt.entity.host
| dedup dt.entity.host
],
sourceField: id_classic, lookupField: dt.entity.host, prefix: "scan.events."
| fields id_classic,
coverage = if(isNotNull(scan.events.dt.entity.host), "covered", else: "not covered")
| summarize hosts = count(), by: { coverage }
| fieldsKeep hosts, coverage
```
**Uncovered hosts — list form:**
```dql
smartscapeNodes HOST
| dedup id_classic
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| fieldsKeep dt.entity.host
| dedup dt.entity.host
],
sourceField: id_classic, lookupField: dt.entity.host, prefix: "scan.events."
| fields id_classic, name,
coverage = if(isNotNull(scan.events.dt.entity.host), "covered", else: "not covered")
| filter coverage == "not covered"
| sort name asc
```
### Interpreting `0 not covered` results
**When `not covered = 0` is returned, that is a complete answer** — every host
in the topology has a matching `VULNERABILITY_SCAN` event. Do not re-query to
sanity-check. Instead, surface the total host count from the same query
(`summarize hosts = count(), by: { coverage }`) so the answer reads
`0 of N hosts uncovered` — that's the actionable form. To confirm scan
freshness, surface scan-event timestamps separately rather than re-running the
join.
---
## Non-Runtime Entity Coverage (Images & Artifacts)
Container images, code artifacts, and repositories are **not runtime entities**
and have no Smartscape population to divide by — so there is **no coverage
percentage**. Coverage for this class is a *count of distinct scanned objects*,
optionally broken down by provider/product. Here the scan-event summary (the
"anti-pattern" for runtime coverage) is the **correct** shape, because there is
no denominator to reconcile against.
Count the covered set from scan events **and** findings (a finding on an image
also proves it was covered, and some providers emit no scan event):
```dql
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_SCAN", "VULNERABILITY_FINDING"})
| filter isNotNull(container_image.digest) or isNotNull(container_image.id)
| summarize {
images = countDistinctExact(coalesce(container_image.digest, container_image.id))
}, by: {event.provider, product.name}
| sort images desc
```
There is no "not covered" row here: without an authoritative image/artifact
inventory, the uncovered set is unknown. Report this as an absolute count, not a
ratio. (To answer "which *running workloads* have images with no findings" — a
runtime question with a denominator — use [§ K8s workloads with container images
that have no security findings](#k8s-workloads-with-container-images-that-have-no-security-findings)
instead.)
---
## External Product Coverage Analysis
Count entities covered (or not) by any external security product. These queries
start from Smartscape topology (not `security.events`) and join external findings.
### 2-Way Match Strategy for Container-Based Entities
External findings link to Dynatrace entities via two independent paths — both
combined with `append`:
| Path | Match key | Source node |
|---|---|---|
| 1 | `dt.smartscape_source.id` (direct entity ID) | finding → workload via Smartscape ID |
| 2 | `container_image.digest` | finding → CONTAINER smartscapeNode → parent workload |
> **Why only 2 paths?** A third path matching on `container_image.id`
> (OCI image ID) previously used `dt.entity.container_group_instance`, which
> carries `containerImageId`. That field does not exist on `smartscapeNodes
> CONTAINER`, so the path has no pure-Smartscape equivalent and is omitted.
### K8s Workload Coverage (count by provider/product)
> **Do not rename `id` before the join.** The `id` field of `smartscapeNodes`
> must be used as-is in join conditions (`left[id]`). Renaming it to an alias
> before the join (e.g. `| fields workload.id=id`) causes the DQL engine to
> return 0 rows on the Smartscape-ID comparison. Always add the alias after
> all joins via `fieldsAdd`.
```dql
smartscapeNodes {K8S_DEPLOYMENT, K8S_CRONJOB, K8S_DAEMONSET, K8S_JOB, K8S_STATEFULSET, K8S_REPLICASET}
| fields id, containerNames=name
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter exists(dt.smartscape_source.id)
| filterOut isNull(event.type) or isNull(object.id)
| dedup dt.smartscape_source.id, event.provider, product.name
| fields dt.smartscape_source.id, event.provider, product.name
], kind:leftouter, on:{left[id]==right[dt.smartscape_source.id]},
fields:{event.provider, product.name, dt.smartscape_source.id}
| append [
smartscapeNodes CONTAINER
| expand dt.k8s.workload.id=coalesce(references[is_part_of.k8s_deployment],
coalesce(references[is_part_of.k8s_daemonset],
coalesce(references[is_part_of.k8s_cronjob],
coalesce(references[is_part_of.k8s_statefulset],
coalesce(references[is_part_of.k8s_job],
references[is_part_of.k8s_replicaset])))))
| filter isNotNull(dt.k8s.workload.id)
| fields dt.k8s.workload.id, container.image.digest
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter isNotNull(container_image.digest)
| filterOut isNull(event.type) or isNull(object.id)
| dedup event.provider, product.name, container_image.digest
| fields event.provider, product.name, container_image.digest
], kind:leftOuter, on:{left[container.image.digest]==right[container_image.digest]},
fields:{event.provider, product.name, container_image.digest}
]
| fieldsAdd Product=if(isNotNull(container_image.digest) or isNotNull(dt.smartscape_source.id), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(container_image.digest) or isNotNull(dt.smartscape_source.id), event.provider, else:"Not covered")
| fieldsAdd dt.k8s.workload.id=coalesce(dt.smartscape_source.id, dt.k8s.workload.id, id)
| summarize {Entities=countDistinctExact(dt.k8s.workload.id)}, by:{Provider, Product}
| sort Entities desc
```
### K8s workloads with container images that have no security findings
Start from workload topology, expand container image identifiers, then anti-join
security findings. If the pre-flight in
`dt-sec-contextualization/references/entity-enrichment.md` (§ K8s Workload Enrichment)
shows no container-image identifiers in external findings, report that the
tenant cannot answer this as a coverage gap rather than treating zero findings
as proof of safety.
```dql
smartscapeNodes {K8S_DEPLOYMENT, K8S_CRONJOB, K8S_DAEMONSET, K8S_JOB, K8S_STATEFULSET, K8S_REPLICASET}
| fields workload.id = id,
workload.name = name,
replicaCount = coalesce(k8s.deployment.replicas.desired,
coalesce(k8s.statefulset.replicas.desired,
coalesce(k8s.daemonset.desired_scheduled_nodes, 0))),
references
| join [
smartscapeNodes CONTAINER
| fields container.image.digest, container.image.id, references
| fieldsAdd workload.id = coalesce(references[is_part_of.k8s_deployment],
coalesce(references[is_part_of.k8s_daemonset],
coalesce(references[is_part_of.k8s_cronjob],
coalesce(references[is_part_of.k8s_statefulset],
coalesce(references[is_part_of.k8s_job],
references[is_part_of.k8s_replicaset])))))
| filter isNotNull(workload.id)
], kind:leftOuter, on:{workload.id}, fields:{container.image.digest, container.image.id}
| lookup [
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter isNotNull(container_image.digest) or isNotNull(container_image.id)
| dedup container_image.digest, container_image.id, event.provider, product.name
| fields container_image.digest, container_image.id, event.provider, product.name
], sourceField:container.image.digest, lookupField:container_image.digest, prefix:"finding.digest."
| lookup [
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter isNotNull(container_image.id)
| dedup container_image.id, event.provider, product.name
| fields container_image.id, event.provider, product.name
], sourceField:container.image.id, lookupField:container_image.id, prefix:"finding.id."
| fieldsAdd hasFinding = isNotNull(finding.digest.event.provider) or isNotNull(finding.id.event.provider)
| summarize {
containerImages = countDistinctExact(coalesce(container.image.digest, container.image.id)),
matchedImages = countDistinctExact(if(hasFinding, coalesce(container.image.digest, container.image.id))),
replicaCount = takeMax(replicaCount)
}, by:{workload.id, workload.name}
| filter matchedImages == 0 and containerImages > 0
| sort replicaCount desc, containerImages desc
```
### Cloud Entity Coverage (count by provider/product)
Only uses `dt.smartscape_source.id` — direct match is sufficient for cloud entities.
```dql
smartscapeNodes "*"
| filter exists(cloud.provider)
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filterOut isNull(event.type) or isNull(object.id)
| filter exists(dt.smartscape_source.id)
| dedup dt.smartscape_source.id, event.provider, product.name
| fields dt.smartscape_source.id, event.provider, product.name
], kind:leftOuter,
on:{right[dt.smartscape_source.id]==left[id]},
fields:{dt.smartscape_source.id, event.provider, product.name}
| fieldsAdd Product=if(isNotNull(dt.smartscape_source.id), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(dt.smartscape_source.id), event.provider, else:"Not covered")
| summarize {Entities=countDistinctExact(id)}, by:{Provider, Product}
| sort Entities desc
```
### Host Coverage by IP Match (count by provider/product)
Matches via IP address. The inner join also counts findings/scans per IP to
enable filtering if needed.
```dql
smartscapeNodes HOST
| fields id, name, ip
| expand host.ip=ip
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filterOut isNull(event.type) or isNull(object.id)
| filterOut isNull(host.ip)
| expand host.ip
| fieldsAdd host.ip=ip(host.ip)
| summarize {
Findings=countIf(in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})),
Scans=countIf(in(event.type,{"VULNERABILITY_SCAN","COMPLIANCE_SCAN"}))
}, by:{host.ip, event.provider, product.name}
], kind:leftOuter, on:{host.ip}, fields:{event.provider, product.name}
| fieldsAdd Product=if(isNotNull(event.provider) or isNotNull(product.name), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(event.provider) or isNotNull(product.name), event.provider, else:"Not covered")
| summarize {Entities=countDistinctExact(id)}, by:{Provider, Product}
| sort Entities desc
```
---
## Best Practices
1. **Use `VULNERABILITY_SCAN` not `VULNERABILITY_COVERAGE_REPORT_EVENT`** — the
latter is deprecated.
2. **Distinguish Code-level vs Third-party VA via `product.feature`** — these
are separate scanning modes within RVA; coverage means different things.
3. **For runtime coverage of external products**, start from `smartscapeNodes`
not `security.events` — the question is "which entities exist," and the answer
joins back to findings.
4. **Use the 3-way match for container-based entities** — direct Smartscape ID,
container image digest, and container image ID. Different external scanners
populate different paths.
5. **For cloud entities**, direct `dt.smartscape_source.id` match is sufficient
— they don't go through the container abstraction.
6. **For hosts**, prefer IP-based matching when the external scanner doesn't
carry Dynatrace entity IDs (most don't).
7. **No `dt.system.bucket` filter** — security event data may live in any bucket;
bucket scoping risks hiding data.
---
## Dashboard Query Patterns
KPI tiles, top-N tables, trend charts, and container/registry rollups. Each
pattern complements the base queries in [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md),
[compliance.md](compliance.md), and [detections.md](detections.md).
For shared building blocks (risk-level mapping, status aggregation, sem-dict
filter, time-window rules), see [common-patterns.md](common-patterns.md). For
field reference, see [data-model.md](data-model.md).
> **No `dt.system.bucket` filter.** Security event data may live in any bucket;
> bucket scoping risks hiding tile data.
> **Load this reference only for dashboard / chart / KPI requests.** For plain
> investigation, triage, or drill-down, the per-domain references are sufficient.
---
## Contents
- [KPI Tiles (single-value)](#kpi-tiles-single-value)
- [Open Non-Muted Vulnerability Count (DT RVA)](#open-non-muted-vulnerability-count-dt-rva)
- [Vulnerabilities with Public Exploit Available](#vulnerabilities-with-public-exploit-available)
- [Open vs. Resolved Status Counter](#open-vs-resolved-status-counter)
- [Critical External Findings by Registry](#critical-external-findings-by-registry)
- [Top-N Tables](#top-n-tables)
- [Top 10 External Vulnerabilities by Affected Object Count](#top-10-external-vulnerabilities-by-affected-object-count)
- [Top 10 Container-Image Vulnerabilities by Image Count](#top-10-container-image-vulnerabilities-by-image-count)
- [Top 10 Repositories by Critical+High Findings](#top-10-repositories-by-criticalhigh-findings)
- [Top 10 Vulnerable Components](#top-10-vulnerable-components)
- [Findings Distribution by Object Type (HIGH+CRITICAL only)](#findings-distribution-by-object-type-highcritical-only)
- [Top 10 Affected Hosts (entity-enriched)](#top-10-affected-hosts-entity-enriched)
- [Trend Charts (timeseries)](#trend-charts-timeseries)
- [Vulnerability Counts Over Time, Stacked by Risk Level](#vulnerability-counts-over-time-stacked-by-risk-level)
- [Provider / Product Coverage Summary](#provider--product-coverage-summary)
- [Findings vs. Scans Split per Product](#findings-vs-scans-split-per-product)
- [Coverage Donut Variants (smartscapeNodes-driven)](#coverage-donut-variants-smartscapenodes-driven)
- [Host Coverage by Any External Product (donut)](#host-coverage-by-any-external-product-donut)
- [Entity-Centric Coverage via `fetch dt.entity.host`](#entity-centric-coverage-via-fetch-dtentityhost)
- [Multi-Type Combined Views](#multi-type-combined-views)
- [One-Row-Per-Entity Risk Summary (DT RVA + external)](#one-row-per-entity-risk-summary-dt-rva--external)
---
## KPI Tiles (single-value)
### Open Non-Muted Vulnerability Count (DT RVA)
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| filter vulnerability.mute.status != "MUTED"
| summarize count()
```
### Vulnerabilities with Public Exploit Available
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| filter vulnerability.davis_assessment.exploit_status=="AVAILABLE"
| summarize {Vulnerabilities = countDistinctExact(vulnerability.display_id)}
```
### Open vs. Resolved Status Counter
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Open=countIf(vulnerability.resolution.status=="OPEN"),
Resolved=countIf(vulnerability.resolution.status=="RESOLVED")
}
```
### Critical External Findings by Registry
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING" AND dt.security.risk.level=="CRITICAL"
| filter isNotNull(container_image.registry)
| dedup {object.id, vulnerability.id}
| summarize {Findings=count()}, by:{Registry=container_image.registry}
```
---
## Top-N Tables
### Top 10 External Vulnerabilities by Affected Object Count
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
| filterOut isNull(finding.id) OR isNull(object.id) OR isNull(vulnerability.id)
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| dedup {object.id, vulnerability.id, component_name, component_version}, sort: {timestamp desc}
| summarize {
`Risk score`=toDouble(takeMax(dt.security.risk.score)),
`Affected objects`=countDistinctExact(object.id),
`Vulnerable components`=countDistinctExact(component_name)
}, by:{Vulnerability=vulnerability.title, `Risk level`=dt.security.risk.level}
| sort {`Risk score`, direction:"descending"}
| fields `Risk level`, Vulnerability, `Affected objects`, `Vulnerable components`
| limit 10
```
### Top 10 Container-Image Vulnerabilities by Image Count
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
AND object.type == "CONTAINER_IMAGE"
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| filter isNotNull(component_name)
| dedup {object.id, vulnerability.id, component_name, component_version,
container_image.registry, container_image.repository}, sort: {timestamp desc}
| summarize {
`Risk score`=toDouble(takeMax(dt.security.risk.score)),
`Container images`=countDistinctExact(container_image.digest)
}, by:{Vulnerability=vulnerability.id, `Risk level`=dt.security.risk.level}
| sort {`Risk score`, direction:"descending"}, {`Container images`, direction:"descending"}
| fields `Risk level`, Vulnerability, `Container images`
| limit 10
```
### Top 10 Repositories by Critical+High Findings
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
| filter isNotNull(repository)
| dedup {repository, vulnerability.id, object.id}
| summarize {
Findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH")
}, by:{Repository=repository}
| sort Critical desc, High desc
| limit 10
```
### Top 10 Vulnerable Components
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| filter isNotNull(component_name)
| dedup {component_name, component_version, vulnerability.id}
| summarize {
Findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH"),
`Affected images`=countDistinctExact(container_image.digest)
}, by:{Component=component_name, Version=component_version}
| sort Critical desc
| limit 10
```
### Findings Distribution by Object Type (HIGH+CRITICAL only)
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING" AND in(dt.security.risk.level,{"HIGH","CRITICAL"})
| dedup {object.id, vulnerability.id}, sort: {timestamp desc}
| summarize {Findings=count()}, by:{`Object type`=object.type}
| sort Findings desc
```
### Top 10 Affected Hosts (entity-enriched)
Joins findings to HOST smartscapeNodes for runtime context. For full 3-way
enrichment (container image digest / id paths), see
`dt-sec-contextualization/references/entity-enrichment.md`.
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Vulnerabilities=count(),
Critical=countIf(vulnerability.risk.score >= 9)
}, by:{affected_entity.id, affected_entity.name}
| join [smartscapeNodes HOST], on:{right[id]==left[affected_entity.id]}
| sort Vulnerabilities desc
| limit 10
```
---
## Trend Charts (timeseries)
### Vulnerability Counts Over Time, Stacked by Risk Level
Uses a computed sort key (`riskLevelSorting`) so the visualization stacks levels
in CRITICAL → LOW order:
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| fieldsAdd riskLevelSorting = coalesce(
if(dt.security.risk.level=="CRITICAL", 1),
if(dt.security.risk.level=="HIGH", 2),
if(dt.security.risk.level=="MEDIUM", 3),
if(dt.security.risk.level=="LOW", 4),
5)
| makeTimeseries countDistinct(vulnerability.id),
by:{riskLevelSorting, dt.security.risk.level},
bins: 24
```
For DT RVA equivalents (open vulnerability counts over 7d in 3h buckets), see
[vulnerabilities-dynatrace.md § Time-Series Trends](vulnerabilities-dynatrace.md#dt-rva-time-series-trends-7-days-3h-buckets).
---
## Provider / Product Coverage Summary
### Findings vs. Scans Split per Product
```dql
fetch security.events
| summarize {
Findings=countIf(in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})),
Scans=countIf(in(event.type,{"VULNERABILITY_SCAN","COMPLIANCE_SCAN"}))
}, by:{Provider=event.provider, Product=product.name}
```
This is the canonical "which integrations are active" query — counts both findings
and scan-coverage events per source.
---
## Coverage Donut Variants (smartscapeNodes-driven)
### Host Coverage by Any External Product (donut)
```dql
smartscapeNodes HOST
| dedup id
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter exists(host.ip)
], on:{host.ip}, kind:leftOuter
| summarize count(), by:{covered=if(isNotNull(event.provider), "Covered", else:"Not covered")}
```
### Entity-Centric Coverage via `fetch dt.entity.host`
Alternative to `smartscapeNodes`-based coverage; uses the entity stream directly:
```dql
fetch dt.entity.host
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| dedup dt.entity.host
], sourceField:id, lookupField:dt.entity.host
| summarize count(), by:{coverage=if(isNotNull(lookup.dt.entity.host), "covered", else: "not covered")}
```
For full coverage analysis broken down by provider/product (and the 3-way
K8s/host match), see [§ External Product Coverage Analysis](#external-product-coverage-analysis).
---
## Multi-Type Combined Views
### One-Row-Per-Entity Risk Summary (DT RVA + external)
To produce a single row per entity with risk counts across vulnerability types,
combine the canonical RVA pattern (from [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md))
with external findings via `union`/`append`, then summarize. Keep this as a
reporting-layer merge — don't try to unify the two query shapes upstream.
```dql
// Branch A: DT RVA per entity
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Critical = countIf(vulnerability.risk.score >= 9),
High = countIf(vulnerability.risk.score >= 7 and vulnerability.risk.score < 9),
Medium = countIf(vulnerability.risk.score >= 4 and vulnerability.risk.score < 7),
Low = countIf(vulnerability.risk.score >= 0.1 and vulnerability.risk.score < 4)
}, by:{Entity=affected_entity.name}
| append [
// Branch B: external per object (sample)
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| dedup {object.id, vulnerability.id}
| summarize {
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High =countIf(dt.security.risk.level=="HIGH"),
Medium =countIf(dt.security.risk.level=="MEDIUM"),
Low =countIf(dt.security.risk.level=="LOW")
}, by:{Entity=object.name}
]
| summarize {
Critical=sum(Critical), High=sum(High), Medium=sum(Medium), Low=sum(Low)
}, by:{Entity}
| sort Critical desc, High desc
| limit 25
```
---
## Threat Intelligence Dashboards
Threat-intelligence (`THREAT_REPORT`) tiles use the canonical base (SD guard +
`dedup {threat.report.id}`) — see [threat-intelligence.md](threat-intelligence.md). Two ready-made
sample dashboards live in `evals/skills/dt-sec-insights/sources/sample-dashboards/`:
- **`emerging-threat-intelligence-reports.json`** — overview: unique-reports-over-time by provider
(`makeTimeseries count(), by:{event.provider}`), total-reports single value, last-10-reports table,
and top-N IOC/actor/industry/country/tag/technique tiles (`expand` observable →
`countDistinctExact(threat.report.id)`). Visualizations: **choropleth** for target countries
(`threat.target.countries.iso_codes`), **categorical bar** (log scale) for actors / industries /
tags, **bar chart** for the provider trend.
- **`threat-exposure-analysis.json`** — per-report drilldown: select a report, extract its IOCs, then
correlate against the environment (RVA + external vulnerabilities by CVE, detections by actor IP /
MITRE technique, logs by `contains(content, …)`, traces by IP/domain/URL). Uses dashboard
**variables** to carry the IOC lists and `in(field, $Var)` filters (the self-contained-query
equivalent uses `join` — see [threat-intelligence.md § Threat-Exposure Correlation](threat-intelligence.md#threat-exposure-correlation-ioc--environment)).
> **Tile field nuances:** use `threat.target.industries` (plural) and
> `threat.target.countries.iso_codes` for the map; `coalesce(toTimestamp(threat.report.time.created), timestamp)`
> for report time. These are threat-intel tiles — do **not** add `dt.security.risk.level` coloring
> (the field is null on `THREAT_REPORT`).
references/coverage.md
# Coverage Queries — Scan Events & Runtime Coverage
How to measure which processes, hosts, K8s workloads, and cloud entities are
covered by Dynatrace vulnerability scanning or by external security products.
> **Match recipes are in `dt-sec-contextualization`.** The 2-way container→workload
> match recipe (K8s workload coverage), cloud entity match (Path 1), and host
> coverage by IP match live in
> `dt-sec-contextualization/references/correlation-and-coverage.md`. This file owns the **counting
> logic** — the `smartscapeNodes` denominator queries, the DT-native scan-event
> lookups, and the covered/not-covered classification. Load
> `dt-sec-contextualization` alongside this file for any external-product coverage
> question that requires the container→workload or cloud match recipe.
> ⚠️ **Coverage means different things for runtime vs. non-runtime entities —
> pick the right shape first.**
>
> **Runtime entities** (hosts, processes, K8s workloads, cloud resources tracked
> in Smartscape) have a known total population, so coverage is a *percentage*
> (covered vs. not covered). These questions **REQUIRE a topology denominator —
> start from `smartscapeNodes`, never from `security.events` alone.** Compare the
> full entity population against scan/finding events via `lookup`. Scan events
> only exist for entities that *were* scanned, so summarizing them counts the
> covered set but can never reveal the uncovered set or a percentage.
>
> > **Anti-pattern for runtime-entity coverage (wrong — no denominator):**
> >
> > ```dql
> > fetch security.events, from:now()-1h
> > | filter event.type == "VULNERABILITY_SCAN"
> > | summarize scans = count(), entities = countDistinctExact(object.id),
> > by: {event.provider, product.name}
> > ```
> >
> > For a *runtime* entity this answers "how many entities were scanned" — NOT
> > "what is my coverage". Use [§ DT Runtime Coverage Analysis](#dt-runtime-coverage-analysis-smartscapenodes)
> > instead; the scan-event-only queries in the first section are building blocks
> > for the `lookup` subquery, not standalone runtime-coverage answers.
>
> **Non-runtime entities** (container images, code artifacts, repositories) have
> **no Smartscape population to divide by**, so there is no percentage. Coverage
> here is simply a *count of distinct scanned objects* — and the scan-event
> summary above is the **correct** answer for this class (see [§ Non-Runtime
> Entity Coverage](#non-runtime-entity-coverage-images--artifacts)).
>
> **Build the "covered" set from scan events AND findings.** Scan events
> (`VULNERABILITY_SCAN` / `COMPLIANCE_SCAN`) are the preferred coverage signal,
> but some providers/features emit no scan event — in that case a *finding* on an
> entity also proves it was covered. Product coverage dashboards union the two.
> When scan events may be missing, `lookup`/`join` both scan events and findings
> and treat an entity as covered if it appears in either (the container-image
> recipe below already does this).
> **Specific-entity coverage interpretation:** when the user asks whether a
> named entity is covered by a Dynatrace security capability (for example, RVA on
> a host/process/workload, SPM/KSPM on a K8s cluster, RAP on a service/process, or
> another DT-native capability), query the capability-specific coverage signals
> and findings in the correct operational window. If **no relevant scan,
> scan-completed, coverage, or finding events** exist for that entity, answer that
> the entity is **not covered** by that capability. The likely reason is that the
> capability is not enabled, not deployed, or not configured to monitor that
> entity. Do not answer only "no findings" when the user asked about coverage.
> **`VULNERABILITY_SCAN` is the current event type for scan coverage.**
> `VULNERABILITY_COVERAGE_REPORT_EVENT` is **deprecated** — do not use in new
> queries.
> **`product.feature` distinguishes RVA modes** — `Code-level Vulnerability
> Analytics` vs. third-party VA. Filter or filterOut on this to scope.
---
## Contents
- [DT Vulnerability Scan Events](#dt-vulnerability-scan-events)
- [DT Runtime Coverage Analysis (smartscapeNodes)](#dt-runtime-coverage-analysis-smartscapenodes)
- [Non-Runtime Entity Coverage (Images & Artifacts)](#non-runtime-entity-coverage-images--artifacts)
- [External Product Coverage Analysis](#external-product-coverage-analysis)
- [3-Way Match Strategy for Container-Based Entities](#3-way-match-strategy-for-container-based-entities)
- [K8s Workload Coverage (count by provider/product)](#k8s-workload-coverage-count-by-providerproduct)
- [Cloud Entity Coverage (count by provider/product)](#cloud-entity-coverage-count-by-providerproduct)
- [Host Coverage by IP Match (count by provider/product)](#host-coverage-by-ip-match-count-by-providerproduct)
- [Best Practices](#best-practices)
---
## DT Vulnerability Scan Events
Scan events (`event.type == "VULNERABILITY_SCAN"`) mark which processes were
analyzed.
| Feature | `product.feature` filter |
|---|---|
| Third-party Vulnerability Analytics | `filterOut product.feature == "Code-level Vulnerability Analytics"` |
| Code-level Vulnerability Analytics | `filter product.feature == "Code-level Vulnerability Analytics"` |
**All Dynatrace scan coverage events for processes:**
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
```
**Covered processes — Third-party Vulnerability Analytics** (deduplicated per
host+process):
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
```
**Covered processes — Code-level Vulnerability Analytics only:**
```dql
fetch security.events
| filter event.type == "VULNERABILITY_SCAN" AND product.vendor=="Dynatrace"
| filter dt.source_entity.type == "process_group_instance"
| filter product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
```
---
## DT Runtime Coverage Analysis (smartscapeNodes)
> **Topology-start vs. events-start.** For "what's NOT covered" questions
> (entities present in topology but findings missing), **start from
> `smartscapeNodes`** and `lookup` the scan events. For "covered with what?"
> questions (findings present and you want to know which entities they map to),
> start from `security.events` and join back to topology. The wrong start
> direction produces structurally-correct queries that under- or over-count.
Start from Smartscape topology, then `lookup` scan events to classify entities as
covered vs. not covered.
> **Scan-only is sufficient for DT process/host coverage.** Dynatrace RVA emits a
> `VULNERABILITY_SCAN` event for every analyzed process, so the scan-event lookup
> below is a reliable covered-set on its own. The scan-events-**and**-findings
> union (top of file) matters for **external products** and any feature that may
> not emit a scan event — there a finding on the entity is the only proof it was
> covered (see the container-image recipe, which `lookup`s findings).
**Process coverage — Third-party Vulnerability Analytics:**
```dql
smartscapeNodes PROCESS
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| dedup dt.entity.host, dt.entity.process_group_instance
| fields dt.entity.process_group_instance
],
sourceField: id_classic, lookupField: dt.entity.process_group_instance
| fieldsAdd coverageStatus = if(isNull(lookup.dt.entity.process_group_instance), "not covered", else: "covered")
| summarize count(), by: { coverageStatus }
```
**Host coverage — Third-party Vulnerability Analytics:**
```dql
smartscapeNodes HOST
| dedup id_classic
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| fieldsKeep dt.entity.host
| dedup dt.entity.host
],
sourceField: id_classic, lookupField: dt.entity.host, prefix: "scan.events."
| fields id_classic,
coverage = if(isNotNull(scan.events.dt.entity.host), "covered", else: "not covered")
| summarize hosts = count(), by: { coverage }
| fieldsKeep hosts, coverage
```
**Uncovered hosts — list form:**
```dql
smartscapeNodes HOST
| dedup id_classic
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| filter dt.source_entity.type == "process_group_instance"
| filterOut product.feature == "Code-level Vulnerability Analytics"
| fieldsKeep dt.entity.host
| dedup dt.entity.host
],
sourceField: id_classic, lookupField: dt.entity.host, prefix: "scan.events."
| fields id_classic, name,
coverage = if(isNotNull(scan.events.dt.entity.host), "covered", else: "not covered")
| filter coverage == "not covered"
| sort name asc
```
### Interpreting `0 not covered` results
**When `not covered = 0` is returned, that is a complete answer** — every host
in the topology has a matching `VULNERABILITY_SCAN` event. Do not re-query to
sanity-check. Instead, surface the total host count from the same query
(`summarize hosts = count(), by: { coverage }`) so the answer reads
`0 of N hosts uncovered` — that's the actionable form. To confirm scan
freshness, surface scan-event timestamps separately rather than re-running the
join.
---
## Non-Runtime Entity Coverage (Images & Artifacts)
Container images, code artifacts, and repositories are **not runtime entities**
and have no Smartscape population to divide by — so there is **no coverage
percentage**. Coverage for this class is a *count of distinct scanned objects*,
optionally broken down by provider/product. Here the scan-event summary (the
"anti-pattern" for runtime coverage) is the **correct** shape, because there is
no denominator to reconcile against.
Count the covered set from scan events **and** findings (a finding on an image
also proves it was covered, and some providers emit no scan event):
```dql
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_SCAN", "VULNERABILITY_FINDING"})
| filter isNotNull(container_image.digest) or isNotNull(container_image.id)
| summarize {
images = countDistinctExact(coalesce(container_image.digest, container_image.id))
}, by: {event.provider, product.name}
| sort images desc
```
There is no "not covered" row here: without an authoritative image/artifact
inventory, the uncovered set is unknown. Report this as an absolute count, not a
ratio. (To answer "which *running workloads* have images with no findings" — a
runtime question with a denominator — use [§ K8s workloads with container images
that have no security findings](#k8s-workloads-with-container-images-that-have-no-security-findings)
instead.)
---
## External Product Coverage Analysis
Count entities covered (or not) by any external security product. These queries
start from Smartscape topology (not `security.events`) and join external findings.
### 2-Way Match Strategy for Container-Based Entities
External findings link to Dynatrace entities via two independent paths — both
combined with `append`:
| Path | Match key | Source node |
|---|---|---|
| 1 | `dt.smartscape_source.id` (direct entity ID) | finding → workload via Smartscape ID |
| 2 | `container_image.digest` | finding → CONTAINER smartscapeNode → parent workload |
> **Why only 2 paths?** A third path matching on `container_image.id`
> (OCI image ID) previously used `dt.entity.container_group_instance`, which
> carries `containerImageId`. That field does not exist on `smartscapeNodes
> CONTAINER`, so the path has no pure-Smartscape equivalent and is omitted.
### K8s Workload Coverage (count by provider/product)
> **Do not rename `id` before the join.** The `id` field of `smartscapeNodes`
> must be used as-is in join conditions (`left[id]`). Renaming it to an alias
> before the join (e.g. `| fields workload.id=id`) causes the DQL engine to
> return 0 rows on the Smartscape-ID comparison. Always add the alias after
> all joins via `fieldsAdd`.
```dql
smartscapeNodes {K8S_DEPLOYMENT, K8S_CRONJOB, K8S_DAEMONSET, K8S_JOB, K8S_STATEFULSET, K8S_REPLICASET}
| fields id, containerNames=name
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter exists(dt.smartscape_source.id)
| filterOut isNull(event.type) or isNull(object.id)
| dedup dt.smartscape_source.id, event.provider, product.name
| fields dt.smartscape_source.id, event.provider, product.name
], kind:leftouter, on:{left[id]==right[dt.smartscape_source.id]},
fields:{event.provider, product.name, dt.smartscape_source.id}
| append [
smartscapeNodes CONTAINER
| expand dt.k8s.workload.id=coalesce(references[is_part_of.k8s_deployment],
coalesce(references[is_part_of.k8s_daemonset],
coalesce(references[is_part_of.k8s_cronjob],
coalesce(references[is_part_of.k8s_statefulset],
coalesce(references[is_part_of.k8s_job],
references[is_part_of.k8s_replicaset])))))
| filter isNotNull(dt.k8s.workload.id)
| fields dt.k8s.workload.id, container.image.digest
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter isNotNull(container_image.digest)
| filterOut isNull(event.type) or isNull(object.id)
| dedup event.provider, product.name, container_image.digest
| fields event.provider, product.name, container_image.digest
], kind:leftOuter, on:{left[container.image.digest]==right[container_image.digest]},
fields:{event.provider, product.name, container_image.digest}
]
| fieldsAdd Product=if(isNotNull(container_image.digest) or isNotNull(dt.smartscape_source.id), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(container_image.digest) or isNotNull(dt.smartscape_source.id), event.provider, else:"Not covered")
| fieldsAdd dt.k8s.workload.id=coalesce(dt.smartscape_source.id, dt.k8s.workload.id, id)
| summarize {Entities=countDistinctExact(dt.k8s.workload.id)}, by:{Provider, Product}
| sort Entities desc
```
### K8s workloads with container images that have no security findings
Start from workload topology, expand container image identifiers, then anti-join
security findings. If the pre-flight in
[dt-sec-contextualization/references/entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md) (§ K8s Workload Enrichment)
shows no container-image identifiers in external findings, report that the
tenant cannot answer this as a coverage gap rather than treating zero findings
as proof of safety.
```dql
smartscapeNodes {K8S_DEPLOYMENT, K8S_CRONJOB, K8S_DAEMONSET, K8S_JOB, K8S_STATEFULSET, K8S_REPLICASET}
| fields workload.id = id,
workload.name = name,
replicaCount = coalesce(k8s.deployment.replicas.desired,
coalesce(k8s.statefulset.replicas.desired,
coalesce(k8s.daemonset.desired_scheduled_nodes, 0))),
references
| join [
smartscapeNodes CONTAINER
| fields container.image.digest, container.image.id, references
| fieldsAdd workload.id = coalesce(references[is_part_of.k8s_deployment],
coalesce(references[is_part_of.k8s_daemonset],
coalesce(references[is_part_of.k8s_cronjob],
coalesce(references[is_part_of.k8s_statefulset],
coalesce(references[is_part_of.k8s_job],
references[is_part_of.k8s_replicaset])))))
| filter isNotNull(workload.id)
], kind:leftOuter, on:{workload.id}, fields:{container.image.digest, container.image.id}
| lookup [
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter isNotNull(container_image.digest) or isNotNull(container_image.id)
| dedup container_image.digest, container_image.id, event.provider, product.name
| fields container_image.digest, container_image.id, event.provider, product.name
], sourceField:container.image.digest, lookupField:container_image.digest, prefix:"finding.digest."
| lookup [
fetch security.events, from:now()-24h
| filter in(event.type, {"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})
| filter isNotNull(container_image.id)
| dedup container_image.id, event.provider, product.name
| fields container_image.id, event.provider, product.name
], sourceField:container.image.id, lookupField:container_image.id, prefix:"finding.id."
| fieldsAdd hasFinding = isNotNull(finding.digest.event.provider) or isNotNull(finding.id.event.provider)
| summarize {
containerImages = countDistinctExact(coalesce(container.image.digest, container.image.id)),
matchedImages = countDistinctExact(if(hasFinding, coalesce(container.image.digest, container.image.id))),
replicaCount = takeMax(replicaCount)
}, by:{workload.id, workload.name}
| filter matchedImages == 0 and containerImages > 0
| sort replicaCount desc, containerImages desc
```
### Cloud Entity Coverage (count by provider/product)
Only uses `dt.smartscape_source.id` — direct match is sufficient for cloud entities.
```dql
smartscapeNodes "*"
| filter exists(cloud.provider)
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filterOut isNull(event.type) or isNull(object.id)
| filter exists(dt.smartscape_source.id)
| dedup dt.smartscape_source.id, event.provider, product.name
| fields dt.smartscape_source.id, event.provider, product.name
], kind:leftOuter,
on:{right[dt.smartscape_source.id]==left[id]},
fields:{dt.smartscape_source.id, event.provider, product.name}
| fieldsAdd Product=if(isNotNull(dt.smartscape_source.id), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(dt.smartscape_source.id), event.provider, else:"Not covered")
| summarize {Entities=countDistinctExact(id)}, by:{Provider, Product}
| sort Entities desc
```
### Host Coverage by IP Match (count by provider/product)
Matches via IP address. The inner join also counts findings/scans per IP to
enable filtering if needed.
```dql
smartscapeNodes HOST
| fields id, name, ip
| expand host.ip=ip
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filterOut isNull(event.type) or isNull(object.id)
| filterOut isNull(host.ip)
| expand host.ip
| fieldsAdd host.ip=ip(host.ip)
| summarize {
Findings=countIf(in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})),
Scans=countIf(in(event.type,{"VULNERABILITY_SCAN","COMPLIANCE_SCAN"}))
}, by:{host.ip, event.provider, product.name}
], kind:leftOuter, on:{host.ip}, fields:{event.provider, product.name}
| fieldsAdd Product=if(isNotNull(event.provider) or isNotNull(product.name), product.name, else:"Not covered")
| fieldsAdd Provider=if(isNotNull(event.provider) or isNotNull(product.name), event.provider, else:"Not covered")
| summarize {Entities=countDistinctExact(id)}, by:{Provider, Product}
| sort Entities desc
```
---
## Best Practices
1. **Use `VULNERABILITY_SCAN` not `VULNERABILITY_COVERAGE_REPORT_EVENT`** — the
latter is deprecated.
2. **Distinguish Code-level vs Third-party VA via `product.feature`** — these
are separate scanning modes within RVA; coverage means different things.
3. **For runtime coverage of external products**, start from `smartscapeNodes`
not `security.events` — the question is "which entities exist," and the answer
joins back to findings.
4. **Use the 3-way match for container-based entities** — direct Smartscape ID,
container image digest, and container image ID. Different external scanners
populate different paths.
5. **For cloud entities**, direct `dt.smartscape_source.id` match is sufficient
— they don't go through the container abstraction.
6. **For hosts**, prefer IP-based matching when the external scanner doesn't
carry Dynatrace entity IDs (most don't).
7. **No `dt.system.bucket` filter** — security event data may live in any bucket;
bucket scoping risks hiding data.
references/dashboard-patterns.md
# Dashboard Query Patterns
KPI tiles, top-N tables, trend charts, and container/registry rollups. Each
pattern complements the base queries in [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md),
[compliance.md](compliance.md), and [detections.md](detections.md).
For shared building blocks (risk-level mapping, status aggregation, sem-dict
filter, time-window rules), see [common-patterns.md](common-patterns.md). For
field reference, see [data-model.md](data-model.md).
> **No `dt.system.bucket` filter.** Security event data may live in any bucket;
> bucket scoping risks hiding tile data.
> **Load this reference only for dashboard / chart / KPI requests.** For plain
> investigation, triage, or drill-down, the per-domain references are sufficient.
---
## Contents
- [KPI Tiles (single-value)](#kpi-tiles-single-value)
- [Open Non-Muted Vulnerability Count (DT RVA)](#open-non-muted-vulnerability-count-dt-rva)
- [Vulnerabilities with Public Exploit Available](#vulnerabilities-with-public-exploit-available)
- [Open vs. Resolved Status Counter](#open-vs-resolved-status-counter)
- [Critical External Findings by Registry](#critical-external-findings-by-registry)
- [Top-N Tables](#top-n-tables)
- [Top 10 External Vulnerabilities by Affected Object Count](#top-10-external-vulnerabilities-by-affected-object-count)
- [Top 10 Container-Image Vulnerabilities by Image Count](#top-10-container-image-vulnerabilities-by-image-count)
- [Top 10 Repositories by Critical+High Findings](#top-10-repositories-by-criticalhigh-findings)
- [Top 10 Vulnerable Components](#top-10-vulnerable-components)
- [Findings Distribution by Object Type (HIGH+CRITICAL only)](#findings-distribution-by-object-type-highcritical-only)
- [Top 10 Affected Hosts (entity-enriched)](#top-10-affected-hosts-entity-enriched)
- [Trend Charts (timeseries)](#trend-charts-timeseries)
- [Vulnerability Counts Over Time, Stacked by Risk Level](#vulnerability-counts-over-time-stacked-by-risk-level)
- [Provider / Product Coverage Summary](#provider--product-coverage-summary)
- [Findings vs. Scans Split per Product](#findings-vs-scans-split-per-product)
- [Coverage Donut Variants (smartscapeNodes-driven)](#coverage-donut-variants-smartscapenodes-driven)
- [Host Coverage by Any External Product (donut)](#host-coverage-by-any-external-product-donut)
- [Entity-Centric Coverage via `fetch dt.entity.host`](#entity-centric-coverage-via-fetch-dtentityhost)
- [Multi-Type Combined Views](#multi-type-combined-views)
- [One-Row-Per-Entity Risk Summary (DT RVA + external)](#one-row-per-entity-risk-summary-dt-rva--external)
---
## KPI Tiles (single-value)
### Open Non-Muted Vulnerability Count (DT RVA)
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| filter vulnerability.mute.status != "MUTED"
| summarize count()
```
### Vulnerabilities with Public Exploit Available
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| filter vulnerability.davis_assessment.exploit_status=="AVAILABLE"
| summarize {Vulnerabilities = countDistinctExact(vulnerability.display_id)}
```
### Open vs. Resolved Status Counter
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Open=countIf(vulnerability.resolution.status=="OPEN"),
Resolved=countIf(vulnerability.resolution.status=="RESOLVED")
}
```
### Critical External Findings by Registry
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING" AND dt.security.risk.level=="CRITICAL"
| filter isNotNull(container_image.registry)
| dedup {object.id, vulnerability.id}
| summarize {Findings=count()}, by:{Registry=container_image.registry}
```
---
## Top-N Tables
### Top 10 External Vulnerabilities by Affected Object Count
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
| filterOut isNull(finding.id) OR isNull(object.id) OR isNull(vulnerability.id)
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| dedup {object.id, vulnerability.id, component_name, component_version}, sort: {timestamp desc}
| summarize {
`Risk score`=toDouble(takeMax(dt.security.risk.score)),
`Affected objects`=countDistinctExact(object.id),
`Vulnerable components`=countDistinctExact(component_name)
}, by:{Vulnerability=vulnerability.title, `Risk level`=dt.security.risk.level}
| sort {`Risk score`, direction:"descending"}
| fields `Risk level`, Vulnerability, `Affected objects`, `Vulnerable components`
| limit 10
```
### Top 10 Container-Image Vulnerabilities by Image Count
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
AND object.type == "CONTAINER_IMAGE"
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| filter isNotNull(component_name)
| dedup {object.id, vulnerability.id, component_name, component_version,
container_image.registry, container_image.repository}, sort: {timestamp desc}
| summarize {
`Risk score`=toDouble(takeMax(dt.security.risk.score)),
`Container images`=countDistinctExact(container_image.digest)
}, by:{Vulnerability=vulnerability.id, `Risk level`=dt.security.risk.level}
| sort {`Risk score`, direction:"descending"}, {`Container images`, direction:"descending"}
| fields `Risk level`, Vulnerability, `Container images`
| limit 10
```
### Top 10 Repositories by Critical+High Findings
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
| filter isNotNull(repository)
| dedup {repository, vulnerability.id, object.id}
| summarize {
Findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH")
}, by:{Repository=repository}
| sort Critical desc, High desc
| limit 10
```
### Top 10 Vulnerable Components
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| fieldsAdd component_name=coalesce(software_component.name, component.name),
component_version=component.version
| filter isNotNull(component_name)
| dedup {component_name, component_version, vulnerability.id}
| summarize {
Findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH"),
`Affected images`=countDistinctExact(container_image.digest)
}, by:{Component=component_name, Version=component_version}
| sort Critical desc
| limit 10
```
### Findings Distribution by Object Type (HIGH+CRITICAL only)
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING" AND in(dt.security.risk.level,{"HIGH","CRITICAL"})
| dedup {object.id, vulnerability.id}, sort: {timestamp desc}
| summarize {Findings=count()}, by:{`Object type`=object.type}
| sort Findings desc
```
### Top 10 Affected Hosts (entity-enriched)
Joins findings to HOST smartscapeNodes for runtime context. For full 3-way
enrichment (container image digest / id paths), see
[dt-sec-contextualization/references/entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md).
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Vulnerabilities=count(),
Critical=countIf(vulnerability.risk.score >= 9)
}, by:{affected_entity.id, affected_entity.name}
| join [smartscapeNodes HOST], on:{right[id]==left[affected_entity.id]}
| sort Vulnerabilities desc
| limit 10
```
---
## Trend Charts (timeseries)
### Vulnerability Counts Over Time, Stacked by Risk Level
Uses a computed sort key (`riskLevelSorting`) so the visualization stacks levels
in CRITICAL → LOW order:
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| fieldsAdd riskLevelSorting = coalesce(
if(dt.security.risk.level=="CRITICAL", 1),
if(dt.security.risk.level=="HIGH", 2),
if(dt.security.risk.level=="MEDIUM", 3),
if(dt.security.risk.level=="LOW", 4),
5)
| makeTimeseries countDistinct(vulnerability.id),
by:{riskLevelSorting, dt.security.risk.level},
bins: 24
```
For DT RVA equivalents (open vulnerability counts over 7d in 3h buckets), see
[vulnerabilities-dynatrace.md § Time-Series Trends](vulnerabilities-dynatrace.md#dt-rva-time-series-trends-7-days-3h-buckets).
---
## Provider / Product Coverage Summary
### Findings vs. Scans Split per Product
```dql
fetch security.events
| summarize {
Findings=countIf(in(event.type,{"VULNERABILITY_FINDING","DETECTION_FINDING","COMPLIANCE_FINDING"})),
Scans=countIf(in(event.type,{"VULNERABILITY_SCAN","COMPLIANCE_SCAN"}))
}, by:{Provider=event.provider, Product=product.name}
```
This is the canonical "which integrations are active" query — counts both findings
and scan-coverage events per source.
---
## Coverage Donut Variants (smartscapeNodes-driven)
### Host Coverage by Any External Product (donut)
```dql
smartscapeNodes HOST
| dedup id
| join [
fetch security.events
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| filter exists(host.ip)
], on:{host.ip}, kind:leftOuter
| summarize count(), by:{covered=if(isNotNull(event.provider), "Covered", else:"Not covered")}
```
### Entity-Centric Coverage via `fetch dt.entity.host`
Alternative to `smartscapeNodes`-based coverage; uses the entity stream directly:
```dql
fetch dt.entity.host
| lookup [
fetch security.events
| filter event.type == "VULNERABILITY_SCAN"
| dedup dt.entity.host
], sourceField:id, lookupField:dt.entity.host
| summarize count(), by:{coverage=if(isNotNull(lookup.dt.entity.host), "covered", else: "not covered")}
```
For full coverage analysis broken down by provider/product (and the 3-way
K8s/host match), see [coverage.md](coverage.md).
---
## Multi-Type Combined Views
### One-Row-Per-Entity Risk Summary (DT RVA + external)
To produce a single row per entity with risk counts across vulnerability types,
combine the canonical RVA pattern (from [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md))
with external findings via `union`/`append`, then summarize. Keep this as a
reporting-layer merge — don't try to unify the two query shapes upstream.
```dql
// Branch A: DT RVA per entity
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
Critical = countIf(vulnerability.risk.score >= 9),
High = countIf(vulnerability.risk.score >= 7 and vulnerability.risk.score < 9),
Medium = countIf(vulnerability.risk.score >= 4 and vulnerability.risk.score < 7),
Low = countIf(vulnerability.risk.score >= 0.1 and vulnerability.risk.score < 4)
}, by:{Entity=affected_entity.name}
| append [
// Branch B: external per object (sample)
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| dedup {object.id, vulnerability.id}
| summarize {
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High =countIf(dt.security.risk.level=="HIGH"),
Medium =countIf(dt.security.risk.level=="MEDIUM"),
Low =countIf(dt.security.risk.level=="LOW")
}, by:{Entity=object.name}
]
| summarize {
Critical=sum(Critical), High=sum(High), Medium=sum(Medium), Low=sum(Low)
}, by:{Entity}
| sort Critical desc, High desc
| limit 25
```
---
## Threat Intelligence Dashboards
Threat-intelligence (`THREAT_REPORT`) tiles use the canonical base (SD guard +
`dedup {threat.report.id}`) — see [threat-intelligence.md](threat-intelligence.md). Two ready-made
sample dashboards live in `evals/skills/dt-sec-insights/sources/sample-dashboards/`:
- **`emerging-threat-intelligence-reports.json`** — overview: unique-reports-over-time by provider
(`makeTimeseries count(), by:{event.provider}`), total-reports single value, last-10-reports table,
and top-N IOC/actor/industry/country/tag/technique tiles (`expand` observable →
`countDistinctExact(threat.report.id)`). Visualizations: **choropleth** for target countries
(`threat.target.countries.iso_codes`), **categorical bar** (log scale) for actors / industries /
tags, **bar chart** for the provider trend.
- **`threat-exposure-analysis.json`** — per-report drilldown: select a report, extract its IOCs, then
correlate against the environment (RVA + external vulnerabilities by CVE, detections by actor IP /
MITRE technique, logs by `contains(content, …)`, traces by IP/domain/URL). Uses dashboard
**variables** to carry the IOC lists and `in(field, $Var)` filters (the self-contained-query
equivalent uses `join` — see [threat-intelligence.md § Threat-Exposure Correlation](threat-intelligence.md#threat-exposure-correlation-ioc--environment)).
> **Tile field nuances:** use `threat.target.industries` (plural) and
> `threat.target.countries.iso_codes` for the map; `coalesce(toTimestamp(threat.report.time.created), timestamp)`
> for report time. These are threat-intel tiles — do **not** add `dt.security.risk.level` coloring
> (the field is null on `THREAT_REPORT`).
references/data-model.md
# Security Events Data Model
Canonical reference for `fetch security.events` — event types, providers, fields,
entity scoping. Use this as the field dictionary when building any DQL query against
`security.events`.
> **No bucket filter.** Security event data may live in any bucket; do **not**
> apply `dt.system.bucket == "..."` filters in queries.
---
## Contents
- [Event Types (`event.type`)](#event-types-eventtype)
- [RVA cadence](#rva-cadence)
- [Provider Taxonomy](#provider-taxonomy)
- [Common Fields (semantic dictionary required)](#common-fields-semantic-dictionary-required)
- [Entity Scoping Fields](#entity-scoping-fields)
- [Vulnerability Fields (RVA — post-aggregation)](#vulnerability-fields-rva--post-aggregation)
- [Vulnerability Fields (external — raw stream)](#vulnerability-fields-external--raw-stream)
- [Compliance Fields (SPM — post-aggregation)](#compliance-fields-spm--post-aggregation)
- [Coverage Fields (`VULNERABILITY_SCAN`)](#coverage-fields-vulnerability_scan)
- [Threat Intelligence Fields (`THREAT_REPORT`)](#threat-intelligence-fields-threat_report)
- [Finding ID Format Cheatsheet](#finding-id-format-cheatsheet)
---
## Event Types (`event.type`)
| Value | Description | Where it comes from |
|---|---|---|
| `DETECTION_FINDING` | Behavioral detection / threat alert | DT RAP (OneAgent — `product.name == "Runtime Application Protection"`), DT Automated Detections (`event.provider == "Dynatrace Automated Detections"`), AutomationEngine, and external detection sources (cloud-security / SIEM / WAF, ingested) |
| `DETECTION_EXECUTION_SUMMARY` | Audit row emitted **per Automated Detections rule run** — was the rule triggered, how many records scanned, did it succeed or warn | DT Automated Detections only. Both `event.provider == "Dynatrace Automated Detections"` and `product.name == "Automated Detections"` are populated; either filter works equivalently. This skill prefers `event.provider == "Dynatrace Automated Detections"` to keep the filter symmetric with `DETECTION_FINDING` queries. One row per rule execution; carries `execution.id`, scan stats (`eventsWritten`, `scannedRecords`, `scannedBytes`), `analysisTimeframeStart` / `End`, status (`SUCCESS` / `SUCCESS_W_WARNINGS` / `FAILURE`). Not a finding; query separately when investigating "did my rule fire?" |
| `THREAT_REPORT` | Threat intelligence report (external campaign / adversary report / IOC feed) — **NOT a finding** | External TI platforms — AlienVault OTX (`event.provider == "AlienVault OTX"`), CrowdStrike Falcon Intelligence (`event.provider == "CrowdStrike"`). Describes threats **in the wild**, not on your entities: no `finding.*` / `object.*` / `dt.security.risk.level`, no affected entity, no scan cycle. Dedup by `threat.report.id`. Query via [threat-intelligence.md](threat-intelligence.md) — **excluded from cross-provider finding summaries and the double-counting guard.** |
| `VULNERABILITY_FINDING` | Software / component vulnerability | **External SCA / SAST / image scanners** (ingested). **Also emitted by Dynatrace's vulnerability-scan-service** as the raw per-scan finding feed — but use the RVA state-report types below for queries about "current Dynatrace vulnerabilities." |
| `COMPLIANCE_FINDING` | Misconfiguration / policy violation | DT SPM (`product.vendor == "Dynatrace"`) and external compliance / posture tools (ingested) |
| `VULNERABILITY_SCAN` | Coverage event — a vulnerability scan ran | DT RVA (`product.vendor == "Dynatrace"`) and external. Lowercase `dt.source_entity.type` (`process_group_instance` / `host`). |
| `COMPLIANCE_SCAN` | Coverage event — a compliance scan ran | DT and external |
| `VULNERABILITY_STATE_REPORT_EVENT` | DT RVA per-entity vulnerability snapshot | Dynatrace RVA only (`event.provider == "Dynatrace"`, `event.level == "ENTITY"`) |
| `VULNERABILITY_STATUS_CHANGE_EVENT` | DT RVA state transition (open→resolved, mute, etc.) | Dynatrace RVA only — emitted **on change**, immediate |
| `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` | DT RVA tracking-link / ticket update (Jira, wiki, …) | Dynatrace RVA only — emitted **on change**, immediate |
| `COMPLIANCE_SCAN_COMPLETED` | DT SPM scan-completion marker (used as inner-join target) | Dynatrace SPM only |
| `VULNERABILITY_COVERAGE_REPORT_EVENT` | **Deprecated** — use `VULNERABILITY_SCAN` instead | Dynatrace RVA (legacy) |
| `VULNERABILITY_ASSESSMENT_CHANGE_EVENT` | **Legacy** — assessment-change deltas; not used by current RVA aggregation pipeline. | Dynatrace RVA (legacy) |
**Critical routing rules:**
- DT RVA uses the three RVA-internal types — **not** `VULNERABILITY_FINDING`. The
three must be queried together as a union via `in(event.type, {…})`.
- DT SPM uses `COMPLIANCE_FINDING` joined with `COMPLIANCE_SCAN_COMPLETED` on
`scan.id`.
- External vulnerability / compliance findings always use `VULNERABILITY_FINDING`
/ `COMPLIANCE_FINDING`.
- **`THREAT_REPORT` is threat intelligence, not a finding** — never mix it into the
cross-provider `*_FINDING` summary or the DT-inclusive posture overview. It has its own
query patterns in [threat-intelligence.md](threat-intelligence.md).
### RVA cadence
- `VULNERABILITY_STATE_REPORT_EVENT` is emitted every ~15 minutes per
`(vulnerability, affected_entity)` pair.
- `VULNERABILITY_STATUS_CHANGE_EVENT` is emitted on transition (immediate).
- `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` is emitted on tracking-link update
(immediate).
- The 30-minute RVA snapshot window guarantees at least one state-report cycle
is captured.
---
## Provider Taxonomy
| `event.provider` | `product.name` | `product.vendor` | Notes |
|---|---|---|---|
| `Dynatrace` | (varies) | `Dynatrace` | DT RVA / DT KSPM. Also the provider on RAP detection findings (see next row). Excluded from cross-provider summaries unless `event.type == "DETECTION_FINDING"` (double-counting guard). |
| `OneAgent` | `Runtime Application Protection` | `Dynatrace` | DT RAP — runtime attack detections via OneAgent. Both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated on every RAP row; either filter works equivalently. This skill prefers `product.name == "Runtime Application Protection"` as the canonical form (matches the official Dynatrace docs naming). |
| `Dynatrace Automated Detections` | `Automated Detections` | `Dynatrace` | DT detection rules engine (threat-detection-service) — both custom and built-in rules. Hardcoded provider; emits both `DETECTION_FINDING` and `DETECTION_EXECUTION_SUMMARY`. |
| `Dynatrace` | `Security Posture Management` | `Dynatrace` | DT KSPM compliance findings + scan-completed events. |
| `AutomationEngine` | `AutomationEngine` | `Dynatrace` | Custom workflow detections from the AutomationEngine product. |
| _external cloud-security / SIEM / SOAR_ | (varies) | (non-`Dynatrace`) | Cloud posture/threat services, identity/sign-in, WAF/edge, SIEM detections — ingested via OpenPipeline |
| _external SCA / SAST / image scanners_ | (varies) | (non-`Dynatrace`) | Software-composition, code, and container-image scanners — may carry provider-specific namespaces (e.g. `<vendor>.*`) |
| _custom / OCSF ingest_ | (varies) | (varies) | Anything conforming to the SD `*_FINDING` schema via custom HTTP / OpenPipeline |
| `AlienVault OTX` | `AlienVault OTX` | `LevelBlue` | **Threat intelligence** (`event.type == "THREAT_REPORT"`) — OTX pulses + IOCs. Not a finding; see [threat-intelligence.md](threat-intelligence.md). |
| `CrowdStrike` | `Falcon Intelligence` | `CrowdStrike` | **Threat intelligence** (`event.type == "THREAT_REPORT"`) — Falcon Intelligence reports + IOCs. Not a finding; see [threat-intelligence.md](threat-intelligence.md). |
External rows are deliberately not enumerated — discover the active providers and scope to one
via [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type).
**Routing form for "all Dynatrace-generated detections":**
```dql
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter product.vendor == "Dynatrace"
```
The `event.provider` distinction (`OneAgent` vs `Dynatrace Automated Detections`)
is informational metadata — only split when the user specifically asks for one.
---
## Common Fields (semantic dictionary required)
All cross-provider queries rely on these normalized fields:
| Field | Type | Notes |
|---|---|---|
| `event.id` | string | Unique event ID — required |
| `event.type` | string | See table above — required |
| `event.provider` | string | Integration name — required |
| `finding.id` | string | Provider-specific (UUID, ARN, hash) — required |
| `finding.type` | string | Sub-classification — required |
| `finding.title` | string | Human-readable description — required |
| `finding.time.created` | string timestamp | When the finding was created — required (use `toTimestamp()` for comparison) |
| `dt.security.risk.level` | string | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `NONE`, `NOT_AVAILABLE` — required |
| `dt.security.risk.score` | double | Numeric 0–10; present on `*_FINDING` events — **not** present on DT RVA state-report events (use `vulnerability.risk.score` there) |
| `object.id` | string | ID of affected object — required (or `object.name`) |
| `object.name` | string | Name of affected object — required (or `object.id`) |
| `object.type` | string | **For Dynatrace-emitted events** (RVA, RAP, KSPM, Automated Detections), this is always one of the supported Smartscape entity types: `PROCESS_GROUP`, `CONTAINER`, `CONTAINER_IMAGE`, `K8S_POD`, `KUBERNETES_CLUSTER`, `KUBERNETES_NODE`, `HOST`, etc. (uppercase, snake-style). **For external / third-party events**, `object.type` carries the vendor-reported value as-is — e.g. `AwsEc2Instance`, `AwsEksCluster`, `AWS::EC2::Instance`. Don't try to normalize external object types to Smartscape types — accept them as the vendor reports. |
| `product.name` | string | Product within the provider — e.g. `OneAgent` (DT) |
| `product.vendor` | string | `Dynatrace` or external vendor |
**Object types observed (Dynatrace-emitted, normalized):** `PROCESS_GROUP`,
`CONTAINER`, `CONTAINER_IMAGE`, `K8S_POD`, `KUBERNETES_CLUSTER`,
`KUBERNETES_NODE`, `HOST`. **External-emitted (vendor-reported, as-is):**
`AwsEc2Instance`, `AwsEksCluster`, `AWS::EC2::Instance`, etc. Match on the
exact vendor string when filtering external; on the normalized Smartscape
type when filtering Dynatrace-emitted.
---
## Entity Scoping Fields
When filtering findings by entity ID/name, use one (or all in an OR chain — see
[common-patterns.md § 5](common-patterns.md#5-wide-entity-scoping-or-chain)).
> **Entity-identifier namespaces split by event family.** Which fields carry entity refs depends on the event type — they are NOT interchangeable:
>
> | Event family | Entity namespaces present | Entity namespaces absent |
> |---|---|---|
> | `DETECTION_FINDING`, `COMPLIANCE_FINDING`, external `VULNERABILITY_FINDING`, `VULNERABILITY_SCAN`, `COMPLIANCE_SCAN` | `dt.smartscape*` (3rd-gen), `dt.entity*` (2nd-gen); `dt.source_entity` only as a legacy / event-family-specific fallback | `affected_entity.*`, `related_entities.*` (RVA-specific, null here) |
> | `VULNERABILITY_STATE_REPORT_EVENT`, `VULNERABILITY_STATUS_CHANGE_EVENT`, `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` (RVA) | `affected_entity.*` (classic ID + name + type, resolved in-event), `related_entities.{kubernetes_workloads,kubernetes_clusters,applications,services,hosts,databases}.{ids,names}` (blast-radius classic IDs + names; `.ids` type prefix ≠ group name — see [vulnerabilities-dynatrace.md § Classic ID prefix gotcha](vulnerabilities-dynatrace.md#classic-id-prefix-gotcha)) | `dt.smartscape*`, `dt.entity*`, `dt.source_entity` (null on RVA events) |
>
> For raw-listing projection guidance per event family see [common-patterns.md § 17](common-patterns.md#17-entity-identifier-preservation-on-raw-listings).
### Smartscape (preferred for new queries)
`dt.entity.*` is deprecated for Smartscape navigation; classic entity IDs like
`dt.entity.host` remain valid as identifiers.
| Field | Example |
|---|---|
| `dt.smartscape_source.id` | Smartscape node ID for the source entity |
| `dt.smartscape.process` / `.host` / `.k8s_cluster` / `.k8s_node` / `.k8s_pod` | Per-type Smartscape node IDs |
| `dt.source_entity` | Legacy / event-family-specific classic entity ID; prefer `dt.smartscape_source.id`, `dt.smartscape.*`, `dt.entity.*`, `object.*`, image digest, or host IP paths for new correlation queries |
### Classic entity IDs
| Field | Example |
|---|---|
| `dt.entity.host` | `HOST-XXXXXXXXXXXXXXXX` |
| `dt.entity.process_group` / `process_group_instance` | `PROCESS_GROUP[-INSTANCE]-XXXXXXXXXXXXXXXX` |
| `dt.entity.kubernetes_cluster` / `kubernetes_node` | `KUBERNETES_*-XXXXXXXXXXXXXXXX` |
| `dt.entity.cloud_application_namespace` | `CLOUD_APPLICATION_NAMESPACE-XXXXXXXXXXXXXXXX` |
### Kubernetes
| Field | Example |
|---|---|
| `k8s.pod.uid` / `k8s.cluster.uid` | UUIDs |
| `k8s.cluster.name` / `k8s.namespace.name` / `k8s.node.name` / `k8s.pod.name` / `k8s.workload.name` | Names |
### Cloud
| Field | Example |
|---|---|
| `aws.resource.id` / `aws.resource.name` | AWS ARN / friendly name |
| `azure.resource.id` / `azure.resource.name` | Azure |
| `gcp.resource.id` / `gcp.resource.name` | GCP |
### Object-level (raw stream)
| Field | Notes |
|---|---|
| `object.id`, `object.name` | Direct match on the finding's object |
| `host.name` | Host name (string) |
| `host.ip` | IP address — array; use `expand` + `ip()` to normalize |
---
## Detection Fields (`DETECTION_FINDING`)
Detection findings are **one-shot events** — there's no per-(rule, entity) aggregation
pipeline like RVA's state-report stream or KSPM's scan-completed join. Each row is a
discrete detection. Different sources populate different sub-namespaces.
### Cross-provider core (all detections)
| Field | Notes |
|---|---|
| `finding.id`, `finding.title`, `finding.description`, `finding.type` | Cross-provider normalized |
| `finding.severity` | Vendor-supplied severity string (provider-native scale) |
| `finding.score` | Vendor-supplied score |
| `finding.time.created` (raw) / `finding.created_time` | When the detection was generated. String timestamp; use `toTimestamp()` for comparison. |
| `finding.remediation` | Free-text remediation guidance (DT Automated Detections populates this from rule template; some external providers also populate it) |
| `dt.security.risk.level` | Cross-provider normalized: `CRITICAL` / `HIGH` / `MEDIUM` / `LOW` / `NONE` / `NOT_AVAILABLE`. Always prefer this over `finding.severity` for cross-provider comparisons. |
| `dt.security.risk.score` | Normalized 0–10 score. External severities map: `Critical → 10.0`, `High → 8.9`, `Medium → 6.9`, `Low → 3.9`, other → `0.0` |
| `event.description` | Provider-supplied detection description; useful for search by attack pattern keyword |
| `event.outcome` | When populated, indicates whether the detection led to a real action (e.g. `success` / `failure`). Sparse — many providers don't emit it. **Prefer `finding.action` for RAP** (see below). |
| `dt.raw_data` | Original ingested JSON for external findings — useful for fields not normalized to the SD. Parse with `parse … "JSON:raw"`. |
### DT Automated Detections (`event.provider == "Dynatrace Automated Detections"`)
Emitted by threat-detection-service when a user-defined or built-in rule fires.
| Field | Notes |
|---|---|
| `detection.id` | Rule UUID — stable across executions of the same rule |
| `detection.title` | Rule title (configurable) |
| `detection.description` | Rule description |
| `detection.owner_id` | User ID of the rule owner |
| `threat.attack.technique.ids` | **Array** of MITRE ATT&CK technique IDs (T-prefixed, e.g. `["T1059", "T1078"]`). Primary pivot for heat maps. Empty / null if the rule isn't tagged. |
| `threat.attack.subtechnique.ids` | **Array** of sub-technique IDs (dotted, e.g. `["T1059.003", "T1078.004"]`). **Independent** from `technique.ids` — no positional alignment; parent technique is encoded in the ID itself (`T1059.003` → `T1059`). |
| `threat.attack.tactic.ids` | **Array** of MITRE tactic IDs (TA-prefixed, e.g. `["TA0002"]`). |
| `threat.attack.technique.names` / `subtechnique.names` / `tactic.names` | Optional companion `.names` arrays — positional with `.ids` when populated. |
| `threat.attack.version` | ATT&CK framework version, e.g. `"15.1"`. Useful for audit / reproducibility across renumbering. |
| `execution.id` | Execution UUID — joins this finding to its `DETECTION_EXECUTION_SUMMARY` row |
| `execution.actor_id` | Actor that triggered the execution (scheduler / on-demand user) |
| `finding.event_properties` | User-defined custom properties from the rule template (free-form key/value map) |
> **`finding.severity` enum for Automated Detections** is `CRITICAL`, `HIGH`,
> `MEDIUM`, `LOW`, `NONE` — **five values, including `NONE`** (used for
> informational-only rules). Cross-provider `dt.security.risk.level` may also
> add `NOT_AVAILABLE`.
> **No mute / dismiss / suppress fields.** Detections have no equivalent of
> `vulnerability.mute.*`. Suppression is handled UI-side (Threats & Exploits
> app filters) or via Application Protection rules (at OneAgent ingest time
> for RAP) — neither writes to `security.events`.
### Runtime Application Protection (`product.name == "Runtime Application Protection"`)
Emitted by OneAgent when an attack pattern is detected (SQL injection, command
injection, JNDI injection, SSRF, path traversal). Java 8+, .NET, and Go (Go
limited to SQL/command injection; JNDI/SSRF are Java-only).
| Field | Notes |
|---|---|
| `finding.type` | Original vendor-reported attack type — **free-form `string`, not a normalized enum** (SD `finding.yaml`: "Original type of the finding reported by the vendor"). Values vary by vendor and version; observed RAP examples: `SQL injection`, `CMD injection`, `JNDI injection`, `SSRF`. Filter with `contains(lower(finding.type), …)` rather than exact match; discover live values with the summarize-by-`finding.type` query in `detections.md`. The legacy `attack.type` / `attack.vector` names from older RAP-namespace docs are not in the Semantic Dictionary. |
| `finding.action` | What OneAgent did about the attack — `Blocked`, `Audited` (monitor mode — detected only), `Allowlisted` (allowed by an explicit allowlist rule). The "blocked vs monitored" breakdown query keys off this field. |
| `actor.ips` | Array of attacker source IPs (`ipAddress[]`, **Stable**). One row may carry multiple IPs (IPv4 + IPv6, proxy chains). For top-IP queries, `expand actor.ips` then cast with `ip(actor.ips)` so downstream IP comparisons / CIDR checks type-check correctly. Enrichable via the Security Enrichment app (AbuseIPDB / VirusTotal / custom). |
| `actor.geo.country.name` | Country name (Experimental); also `actor.geo.city.name`, `actor.geo.continent.name`, `actor.geo.location.lat`/`lon`. May be null when enrichment isn't configured. |
| `dt.security.rap.target.{id,type,name}` | What was attacked — for SQL injection this is the database entity (`HOST-…`), for service-targeted attacks it's the service. `object.*` carries the entity where the exploit happened; `dt.security.rap.target.*` is the underlying target. |
| `url.path` | HTTP path targeted (when applicable) |
| `dt.smartscape.process` / `dt.smartscape.host` | Smartscape entity IDs of the attacked process / host |
> RAP-specific drilldown into entry-point payloads (`entry_point.url.path`,
> `entry_point.payload`, `entry_point.function.name`,
> `entry_point.user_controlled_inputs`), sink-code (`sink.code.function`,
> `sink.code.namespace`), and code-location (`code.function`, `code.namespace`,
> `code.filepath`, `code.line.number`) lives in product-emitted fields outside
> the SD stable namespace. The Threats & Exploits app is the supported
> surface for full attack reconstruction.
> **RAP control mode is configured at OneAgent**, not in queries. Modes are
> `Off` (not detected — no event), `Monitor` (detected only — `finding.action == "Audited"`),
> `Block` (detected and stopped — `finding.action == "Blocked"`). Per-PG / per-vulnerability-type
> custom rules can override the global mode.
### Detection finding by event-storage bucket (informational only)
| `dt.system.bucket` | Source | Retention |
|---|---|---|
| `default_securityevents_builtin` | DT-native (RVA / RAP / KSPM / Automated Detections) | 3 years |
| `default_securityevents` | External / OpenPipeline ingest | 1 year |
> **Do NOT add `dt.system.bucket` filters to queries.** Security event data
> may live in any bucket (custom routing rules, retention overrides). Bucket
> filters can hide data. The bucket field is informational metadata for
> understanding *why* retention differs between sources, not a filter axis.
---
## Vulnerability Fields (RVA — raw + post-aggregation)
The raw `VULNERABILITY_STATE_REPORT_EVENT` carries one row per
`(vulnerability, affected_entity)` pair. The canonical RVA Stage-3 `fieldsAdd`
(see [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md)) collapses these per-entity rows
into vulnerability-level verdicts. Some fields below exist only in the raw
stream, some only post-aggregation, some in both.
### Identity / metadata
| Field | Values / notes |
|---|---|
| `vulnerability.display_id` | Human-readable, e.g. `S-12345` |
| `vulnerability.id` | Internal UUID |
| `vulnerability.external_id` | **Provider-emitted reference identifier** (e.g. NVD CVE ID, MITRE ID). Not a user-attached tracking ticket — for that, see `vulnerability.tracking_link.*` further down. |
| `vulnerability.external_url` | **Provider-emitted reference URL** (NVD page, vendor advisory, etc.). **NOT the user-attached remediation/Jira link** — that field is `vulnerability.tracking_link.url`. Filtering `vulnerability.external_url != ""` to find "vulnerabilities with a tracking link" is a common mistake; it returns vulnerabilities that have any vendor reference URL, which is almost all of them. |
| `vulnerability.title` | Short description |
| `vulnerability.description` | Long description |
| `vulnerability.type` | Classification (e.g. CWE family) |
| `vulnerability.technology` | Target tech (e.g. `Java`, `.NET`, `Go`, `Node.js`) |
| `vulnerability.stack` | `CODE` (CLV — OneAgent IAST/Attack), `CODE_LIBRARY` (matched on software components), `SOFTWARE` (matched on runtime / OS packages), `CONTAINER_ORCHESTRATION` (Kubernetes-level). See `TechnologyStack` enum. |
| `vulnerability.code_location.name` | CLV only — source file + line of vulnerable code |
| `vulnerability.is_fix_available` | Boolean — `true` if a fix exists upstream |
| `vulnerability.remediation.description` | Free-text guidance |
| `vulnerability.references.cve` | CVE ID (string or array) |
| `vulnerability.references.cwe` | CWE ID(s) |
| `vulnerability.references.owasp` | OWASP category reference(s) |
### Scoring
| Field | Values / notes |
|---|---|
| `vulnerability.cvss.base_score` | CVSS base score (0–10), `vulnerability.cvss.version` and `vulnerability.cvss.vector` accompany |
| `vulnerability.cvss.version` | `2.0`, `3.0`, `3.1`, `4.0` |
| `vulnerability.cvss.vector` | Static CVSS vector string |
| `vulnerability.risk.score` | Dynatrace Security Score (DSS) — context-aware, **never exceeds CVSS base**. Per-entity in raw events; collapsed to vulnerability-level via `takeMax` (muted entities contribute 0). |
| `vulnerability.risk.level` | Derived in Stage 3: ≥9 CRITICAL / ≥7 HIGH / ≥4 MEDIUM / ≥0.1 LOW / else NONE |
| `vulnerability.davis_assessment.score` | Per-entity DSS (raw stream only) |
| `vulnerability.davis_assessment.vector` | Modified CVSS vector if DSS adjusted CVSS |
> **DSS for CLV is always 10.0 (Critical).** Code-level vulnerabilities skip the
> DSS modifiers — entry points and data-flow proof of exploitability are
> sufficient to score Critical.
### Dynatrace runtime assessment statuses (raw stream)
Per-entity statuses; collapsed to vulnerability-level by Stage-3 `fieldsAdd`
into the shortened names below. **Precedence** (most-severe first) drives the
collapse — if any entity has the most-severe status, the vulnerability inherits
it.
| Raw field (per-entity) | Stage-3 short name | Values (precedence top → bottom) |
|---|---|---|
| `vulnerability.davis_assessment.exposure_status` | `vulnerability.exposure.status` | `PUBLIC_NETWORK` > `NOT_AVAILABLE` > `NOT_DETECTED`. Raw values include `ADJACENT_NETWORK`, but it's intentionally **not** treated as public exposure — falls through to `NOT_DETECTED` in the derived field. Query the raw field directly for adjacent-network analysis. |
| `vulnerability.davis_assessment.exploit_status` | `vulnerability.exploit.status` | `AVAILABLE` > `NOT_AVAILABLE` |
| `vulnerability.davis_assessment.vulnerable_function_status` | `vulnerability.vulnerable_function.status` | `IN_USE` > `NOT_AVAILABLE` > `NOT_IN_USE` |
| `vulnerability.davis_assessment.data_assets_status` | `vulnerability.data_assets.status` | `REACHABLE` > `NOT_AVAILABLE` > `NOT_DETECTED` |
| `vulnerability.davis_assessment.assessment_mode` | (kept long-form in projections) | `FULL` (all entities full-stack) > `REDUCED` (some entity in Foundation/Infra-Only) > `NOT_AVAILABLE` |
| `vulnerability.davis_assessment.assessment_mode_reasons` | — | array; values: `LIMITED_BY_CONFIGURATION`, `LIMITED_AGENT_SUPPORT` (note: underscore, not dot) |
> **Why `NOT_AVAILABLE` outranks `NOT_DETECTED` / `NOT_IN_USE`.** Missing
> telemetry is treated as "could be exploitable" — surfaces gaps for
> investigation rather than hiding them under a clean-looking `NOT_DETECTED`.
> **CLV scope.** `vulnerable_function.status` and `exposure.status` are
> populated only for `CODE_LIBRARY` / `SOFTWARE` (third-party). For CLV
> (`CODE`), the entry-point and data-flow proof carries the assessment.
### Lifecycle / workflow (per-entity raw, vulnerability-level after Stage 3)
| Raw field (per-entity) | Stage-3 derivation | Notes |
|---|---|---|
| `vulnerability.resolution.status` | derived: `if(in("OPEN", resolutionStatuses), "OPEN", else: "RESOLVED")` | Auto-resolved when no PG reports the vulnerable library for >2 h (third-party) or process restarts and OneAgent finds no exploitable data flow (CLV) |
| `vulnerability.resolution.change_date` | `takeMax` | Last status transition timestamp (nanoseconds). For OPEN vulnerabilities indicates for how long they are in that status. |
| `vulnerability.mute.status` | derived: `if(in("NOT_MUTED", muteStatuses), "NOT_MUTED", else: "MUTED")` | Per-entity mute; vulnerability is fully muted only if every entity is muted |
| `vulnerability.mute.reason` | (raw, per-entity) | `FALSE_POSITIVE`, `IGNORE`, `AFFECTED` (=> NOT_MUTED), `CONFIGURATION_NOT_AFFECTED`, `OTHER` |
| `vulnerability.mute.user` | (raw, per-entity) | User who set the mute |
| `vulnerability.mute.comment` | (raw, per-entity) | Free-text reason |
| `vulnerability.mute.change_date` | (raw, per-entity) | Mute timestamp |
| `vulnerability.tracking_link.url` | (raw, per-entity; emitted by `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT`) | Jira / wiki / ticket URL |
| `vulnerability.tracking_link.text` | (raw, per-entity) | Display text for the link |
> **Mute states observed in the UI** include `Muted (Open)` (vulnerability
> remains open but every entity is silenced) and `Muted (Resolved)` (a muted
> vulnerability auto-resolved). These compose from the OPEN/RESOLVED + MUTED/NOT_MUTED
> per-entity arrays — they're **not separate enum values** in `security.events`.
### Affected / related entity fields
| Field | Values / notes |
|---|---|
| `affected_entity.id` | Smartscape / classic entity ID |
| `affected_entity.name` | Display name |
| `affected_entity.type` | `PROCESS_GROUP`, `HOST`, `KUBERNETES_NODE`, `PROCESS_GROUP_INSTANCE` (rare). See `MonitoredEntityType`. |
| `affected_entity.affected_processes.count` | Process instances inside the PG that carry the vulnerability |
| `affected_entity.affected_processes.ids` | Array of `PROCESS_GROUP_INSTANCE-...` IDs |
| `affected_entity.vulnerable_component.id` | Internal component ID |
| `affected_entity.vulnerable_component.name` | Library/component name (e.g. `log4j-core 2.14.1`) |
| `affected_entity.vulnerable_component.short_name` | Short label |
| `affected_entity.vulnerable_component.package_name` | Package name (e.g. `org.apache.logging.log4j:log4j-core`) |
| `affected_entity.vulnerable_functions` | Array of FQCN method names actually executed (e.g. `org.apache.http.client.utils.URIUtils#decode`). **Requires** the OneAgent `Java vulnerable function reporting` (or equivalent) feature. |
| `affected_entity.vulnerable_functions_not_in_use` | FQCN methods present but not executed (note: underscore, not dot — internal/experimental in SD) |
| `affected_entity.vulnerable_functions_not_available` | FQCN methods that could not be evaluated (note: underscore, not dot — internal/experimental in SD) |
| `affected_entity.reachable_data_assets.ids` | Database IDs reachable from this entity (the Davis "reachable data assets" dimension — distinct from the blast-radius `related_entities.databases.*` group) |
| `related_entities.{kubernetes_workloads,kubernetes_clusters,applications,services,hosts,databases}.{ids,names}` | Indirect blast-radius entities (arrays of classic IDs + display names). `.ids` carry classic entity IDs; the type prefix may differ from the group name (e.g. `kubernetes_workloads.ids` → `CLOUD_APPLICATION-…`; `databases.ids` → `SERVICE-…`). `.names` are positionally paired with `.ids`. |
> **PG-only collateral.** `affected_processes.*` fields are non-empty **only**
> when `affected_entity.type == "PROCESS_GROUP"`. HOST and KUBERNETES_NODE
> entities don't carry them.
> **Runtime-assessment status field naming.** Raw events carry the long names
> `vulnerability.davis_assessment.vulnerable_function_status`, etc. Stage-3
> `fieldsAdd` collapses them to the shortened forms — those are the names
> used in filters, projections, and downstream queries.
---
## Vulnerability Fields (external + DT-emitted `VULNERABILITY_FINDING`)
`VULNERABILITY_FINDING` events come from two sources:
- **External SCA / SAST / image scanners** ingested via OpenPipeline.
- **vulnerability-scan-service (Dynatrace)** — emits one `VULNERABILITY_FINDING`
per matched vulnerability inside a scan, **plus** one `VULNERABILITY_SCAN`
per scan request. These are the "raw" findings that feed RVA's state-report
aggregation; normally you query the state-report stream instead. To exclude
Dynatrace-emitted findings: `filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"`.
External `VULNERABILITY_FINDING` events do NOT carry the RVA-derived fields.
They use the cross-provider normalized fields documented in [§ Common Fields](#common-fields-semantic-dictionary-required).
| Field | Notes |
|---|---|
| `vulnerability.references.cve` | CVE ID (also on RVA) |
| `vulnerability.id` | Provider's vulnerability identifier |
| `software_component.name` | Vulnerable library |
| `software_component.purl` | Package URL (PURL) — canonical identifier for SOFTWARE_COMPONENT scans |
| `software_component.type` | E.g. `MAVEN`, `NPM`, `PYPI`, `GO`, `RPM`, `DEB` |
| `component.name` / `component.version` | Alternate component fields (used by some external scanners) |
| `dt.entity.software_component` | DT-emitted PURL-based component entity ID (SOFTWARE_COMPONENT scans only) |
| `container_image.digest` / `.id` / `.registry` / `.repository` | Container image dimensions |
| `artifact.repository` | Artifact repository (alternative to `container_image.repository`; coalesce them — see [common-patterns.md § 12](common-patterns.md#12-repository--artifact-coalescing)) |
> **Container-image dedup precedence.** For "distinct images" counts, key dedup on the most
> specific identifier present: `container_image.digest` (immutable) > `container_image.id` >
> `container_image.registry` + `container_image.repository`. Scanners vary in which they populate.
> **Severity normalization.** When external findings are ingested, severities
> are mapped to the normalized risk score: `Critical → 10.0`, `High → 8.9`,
> `Medium → 6.9`, `Low → 3.9`, `Other / unknown → 0.0`. Use
> `dt.security.risk.level` rather than provider-specific severity fields for
> cross-provider comparisons.
> **`finding.id` composition.** `finding.id` is a deterministic hash of
> `object.id` + `vulnerability.id` + `component.name` + `component.version` +
> `product.name` + `product.vendor`. `dedup finding.id` is the canonical grain for
> Dynatrace-generated findings — it collapses the same finding re-emitted each scan
> cycle (~15 min) to a single latest row.
> **DT-generated `VULNERABILITY_FINDING` entity/scope fields.** DT-generated findings
> carry `dt.security.risk.level` / `dt.security.risk.score`, `software_component.purl`,
> `finding.time.created`, and `dt.smartscape.process` with `dt.smartscape_source.type`
> — scope by entity via Smartscape. They do **not** carry the embedded
> `affected_entity.*` / `related_entities.*` arrays that RVA state reports do.
---
## Compliance Fields (SPM)
Dynatrace Security Posture Management (SPM / XSPM) has three flavors:
| Flavor | What | Source | `product.name` | `event.provider` |
|---|---|---|---|---|
| **KSPM** | Kubernetes — CIS, DORA, NIST, DISA STIG | DT-native (security-analyzer-service) | `Security Posture Management` | `Dynatrace` |
| **CSPM** | Cloud posture — AWS / Azure / GCP foundations, plus broad standards (PCI DSS, ISO 27001, HIPAA, GDPR, …) | external/partner integration | (varies) | (varies) |
| **VSPM** | VMware posture — DISA STIG, NIST | external/partner integration | (varies) | (varies) |
**KSPM is the only DT-native flavor** — its events fit the `event.provider == "Dynatrace"` AND `product.vendor == "Dynatrace"` filter. CSPM/VSPM and other external compliance providers populate the cross-provider `finding.*` namespace but **do not** populate `compliance.rule.*` consistently — see § Compliance Fields (external).
### KSPM event types
| Event type | Granularity | Use |
|---|---|---|
| `COMPLIANCE_FINDING` | One row per `(rule, object)` pair | Per-rule findings, evidence drill, status, severity. NOT_RELEVANT rows are emitted; filter them out. |
| `COMPLIANCE_SCAN_COMPLETED` | One row per scan run (per cluster) | Scan-level summary; carries pre-computed pass-rate JSON via `scan.result.summary_json`. Used as inner-join target on `scan.id` to dedup findings to the latest scan. |
> **No `COMPLIANCE_SCAN_STARTED`.** The lifecycle is implicit — a scan begins
> when a configuration dataset arrives, finishes when `COMPLIANCE_SCAN_COMPLETED`
> is emitted. There is no per-scan progress event.
### KSPM rule + standard fields
| Field | Values / notes |
|---|---|
| `compliance.rule.id` | `<STANDARD>-<NUMBER>` — e.g. `CIS-2762`, `STIG-V-242400`, `DORA-9`, `NIST-AU-2` |
| `compliance.rule.title` | Human-readable rule name |
| `compliance.rule.severity.level` | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW` — **exactly four values**. KSPM does not emit `NONE` / `NOT_AVAILABLE`. |
| `compliance.rule.severity.score` | Numeric severity (0–10): `CRITICAL=10`, `HIGH=7`, `MEDIUM=4`, `LOW=1` (CCSS-based since 2026-03-10) |
| `compliance.rule.metadata_json` | ❌ **Do not use.** Field exists in the data (standard-specific JSON blob) but the skill must **never** query or parse it. Use `compliance.rule.id` / `compliance.rule.title` for rule identity instead. |
| `compliance.standard.short_name` | KSPM-native: `CIS`, `DORA`, `NIST`, `DISA STIG`. **Note the full `"DISA STIG"` label — `short_name == "STIG"` matches nothing.** Prefer `contains(lower(compliance.standard.short_name), "stig")` for filtering — it tolerates version suffixes and the DISA prefix. PCI / ISO / HIPAA / GDPR appear only via CSPM/VSPM or external integrations. |
| `compliance.standard.name` | Versioned full name — e.g. `"CIS Kubernetes 1.6.0"`, `"NIST SP 800-53 Rev. 5.2.0"`, `"DISA STIG Kubernetes V2R5"` |
| `compliance.standard.url` | Reference URL for the standard |
### KSPM result + evidence fields
| Field | Values / notes |
|---|---|
| `compliance.result.status.level` | `FAILED`, `MANUAL`, `PASSED`, `NOT_RELEVANT` — **exactly four**. No ERROR/UNKNOWN. |
| `compliance.result.status.score` | Numeric: `FAILED=10.0`, `MANUAL=7.0`, `PASSED=4.0`, `NOT_RELEVANT=1.0` |
| `compliance.result.description` | Optional status-detail text (nullable) |
| `compliance.result.count.passed` / `.failed` / `.manual` | **Post-aggregation only** — counters derived in the per-rule summarize (Step 2). Not on raw events. |
| `compliance.result.object.type` | Lowercase analysis-object code: `k8scluster`, `k8snode`, `k8spod`, `k8sdeployment`, `k8sstatefulset`, `k8sreplicaset`, `k8sdaemonset`, `k8sjob`, `k8scronjob`, `k8sreplicationcontroller`. **Different field from the cross-provider `object.type`** which carries the Dynatrace entity type (`KUBERNETES_CLUSTER`, etc.). |
| `compliance.result.object.name` | Resource name (pod / deployment / cluster name) |
| `compliance.result.object.evidence_json` | JSON array of discovered configuration values — see schema below |
### `compliance.result.object.evidence_json` schema
```json
[
{"type": "AUTOMATIC", "description": "Property '--enable-admission-plugins' value", "value": "restricted"},
{"type": "MANUAL", "description": "Question about external control", "value": "Unknown"}
]
```
- `type`: `AUTOMATIC` (analyzer evaluated the property) or `MANUAL` (requires
human input — value is typically `"Unknown"` when MANUAL is emitted).
- Long values (>3000 chars) are truncated by the analyzer to fit Grail record limits.
- Parse via `parse compliance.result.object.evidence_json, "JSON_ARRAY:findings"` then expand for per-property drilldown.
### KSPM `COMPLIANCE_SCAN_COMPLETED` fields
| Field | Notes |
|---|---|
| `scan.id` | UUID — joins the scan-completed row to all `COMPLIANCE_FINDING` rows it produced |
| `scan.time_completed` | Nanosecond timestamp of scan completion |
| `scan.result.summary_json` | JSON with pre-computed pass percentages — schema below |
| `object.id` / `object.name` / `object.type` | Cluster identifier (`KUBERNETES_CLUSTER`) |
| `dt.entity.kubernetes_cluster`, `k8s.cluster.name`, `k8s.cluster.uid` | Cluster identifiers |
### `scan.result.summary_json` schema
```json
{
"compliancePercentage": 85,
"standardResultSummaries": [
{"standardCode": "CIS", "compliancePercentage": 85},
{"standardCode": "DORA", "compliancePercentage": 92}
]
}
```
`compliancePercentage` is the overall pass rate across all enabled standards;
`standardResultSummaries` breaks it down. Use this for fast UC-C0 dashboards
without a full `COMPLIANCE_FINDING` join.
### KSPM-assessed object types (Kubernetes only)
KSPM is **K8s-only today** — no AWS/Azure/GCP/host-level rules in the
DT-native pipeline. Cloud and VMware coverage flows through CSPM/VSPM.
The `compliance.result.object.type` codes are: `k8scluster`, `k8snode`,
`k8spod`, `k8sdeployment`, `k8sstatefulset`, `k8sreplicaset`, `k8sdaemonset`,
`k8sjob`, `k8scronjob`, `k8sreplicationcontroller`.
### Severity precedence + per-rule status derivation
Per-(rule, object) rows are aggregated to per-rule rows by Step 2 (see
[compliance.md](compliance.md)). Status precedence:
```
any FAILED → rule = FAILED
else any MANUAL → rule = MANUAL
else any PASSED → rule = PASSED
else → rule = NOT_RELEVANT
```
> **MANUAL is currently non-actionable in the SPM app** — there's no
> remediation workflow for it. It surfaces "we couldn't auto-determine; check
> manually." Treat MANUAL as a triage hint, not a remediable defect.
> **No mute / exemption mechanism** in the KSPM event stream. Findings have
> no equivalent of `vulnerability.mute.{status,reason,user,comment}`. "Accepted
> risk" is not modeled in `security.events` — it's a downstream UI/policy
> concept that doesn't surface in DQL. If users ask "show me accepted-risk
> compliance findings," explain that the field doesn't exist.
## Compliance Fields (external — CSPM / VSPM / external posture tools)
External `COMPLIANCE_FINDING` events use the cross-provider normalized
`finding.*` namespace and **do not** populate `compliance.rule.*` consistently.
| Field | Notes |
|---|---|
| `finding.id`, `finding.title`, `finding.type`, `finding.time.created` | Use these for filtering, grouping, and drilldown — `compliance.rule.*` will be null on most external rows |
| `dt.security.risk.level` | Cross-provider severity (`CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, `NONE`, `NOT_AVAILABLE`) |
| `compliance.status` | Some providers populate this as a top-level string (`FAILED` / `PASSED`) instead of `compliance.result.status.level`; check both when filtering external |
| `compliance.standards` (array), `compliance.policy`, `compliance.control` | External compliance taxonomy — group/filter by these (`expand` the `compliance.standards` array). See [compliance.md § External compliance taxonomy fields](compliance.md#external-compliance-taxonomy-fields). |
| `event.provider` / `product.vendor` / `product.name` | Provider identification — scope via [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type) |
| `object.*` | Affected entity (cloud resource identifier, etc.) |
> **DT SPM query patterns (Steps 1 + 2) don't work for external compliance** —
> the inner join to `COMPLIANCE_SCAN_COMPLETED` is KSPM-only, and `compliance.rule.*`
> columns are null on external rows. Use the cross-provider patterns in
> [all-security-events.md](all-security-events.md) for unified queries.
---
## Coverage Fields (`VULNERABILITY_SCAN`)
`VULNERABILITY_SCAN` events are emitted by vulnerability-scan-service once per
SBOM scan request. They mark "this entity was assessed at this time."
| Field | Notes |
|---|---|
| `dt.source_entity` | Entity ID that was scanned (PROCESS_GROUP_INSTANCE-… or HOST-…) |
| `dt.source_entity.type` | **Lowercase** — `process_group_instance` or `host`. Scan service supports only these two types. |
| `product.name` | `Runtime Vulnerability Analytics` |
| `product.vendor` | `Dynatrace` |
| `product.feature` | RVA scan mode — see table below. Populated on CLV scan events (`Code-level Vulnerability Analytics`); **null on third-party / OS scan events** — `filterOut product.feature == "Code-level Vulnerability Analytics"` keeps both null and explicitly-non-CLV rows, which is what coverage queries want. Always populated on the paired `VULNERABILITY_FINDING` events. |
| `scan.id` | Scan request UUID — links a `VULNERABILITY_SCAN` to its `VULNERABILITY_FINDING`s |
| `scan.time.started` / `scan.time.completed` | Server-side scan timestamps (nanoseconds) |
| `dt.entity.host`, `dt.entity.process_group_instance` | Identifiers for dedup and lookup-against-smartscapeNodes |
### `product.feature` values for RVA
| Value | Stack | Where populated |
|---|---|---|
| `Library Vulnerability Analytics` | `CODE_LIBRARY` | `VULNERABILITY_FINDING` only (third-party / OSS library scanning, SOFTWARE_COMPONENT SBOM). On `VULNERABILITY_SCAN` events for the same scope, `product.feature` is null. |
| `Operating System Vulnerability Analytics` | `SOFTWARE` | `VULNERABILITY_FINDING` only (OS package scanning, RPM/DEB). On `VULNERABILITY_SCAN` events for the same scope, `product.feature` is null. |
| `Code-level Vulnerability Analytics` | `CODE` | Both `VULNERABILITY_SCAN` and `VULNERABILITY_FINDING` (OneAgent IAST/Attack-detected CLV; Java 8+, .NET, Go only). |
For coverage queries against `VULNERABILITY_SCAN`:
`filter product.feature == "Code-level Vulnerability Analytics"` scopes to CLV scans;
`filterOut product.feature == "Code-level Vulnerability Analytics"` scopes to third-party / OS scans (which have null `product.feature`). See [coverage-and-dashboards.md](coverage-and-dashboards.md) for canonical patterns.
> **Scanning is event-driven, not periodic.** Scans run when an agent submits
> an SBOM. There is no fixed cadence; in practice every monitored process
> generates frequent SBOMs. A scan event missing for an entity in the last
> 24h–7d is a meaningful signal that scanning didn't happen, not just a quiet
> period. Coverage analysis windows: `7d` for "is this entity ever scanned",
> `2h–24h` for "recently scanned".
---
## Threat Intelligence Fields (`THREAT_REPORT`)
`THREAT_REPORT` events are **threat intelligence reports** (external campaigns / IOC feeds), **not
findings**. They carry **none** of the finding/entity/risk fields above — no `finding.*`, `object.*`,
`dt.security.risk.level`/`score`, `dt.smartscape*`/`dt.entity*`, and no scan events. One event per
published report; **dedup by `threat.report.id`** (reports re-ingest on update). Full query patterns
and the threat-exposure correlation workflow → [threat-intelligence.md](threat-intelligence.md).
### Report identity & provenance (required)
| Field | Notes |
|---|---|
| `threat.report.id` | Vendor-assigned report ID — **dedup key** (stable across revisions) |
| `threat.report.name` | Report title (e.g. `CSA-260753 …`, `IMMEDIATE THREAT: …`) |
| `threat.report.description` | Summary / abstract (≤2 KB) |
| `threat.report.time.created` / `.updated` | `timestamp` type (nullable) — use `coalesce(toTimestamp(…), timestamp)` |
| `threat.report.author` | Publishing author / team (optional) |
| `threat.report.references.urls` / `.tags` | Report URLs / free-form tags (optional arrays) |
### Adversary context (recommended)
| Field | Notes |
|---|---|
| `threat.actor.names` | Attributed threat-actor names/aliases (array) |
| `threat.target.countries.names` / `.iso_codes` | Targeted countries (full names / ISO 3166-1 alpha-2 — use `.iso_codes` for maps) |
| `threat.target.industries` | Targeted industry verticals (**plural** — `threat.target.industry` singular does not exist) |
| `threat.malware.families` | Malware family names (array) |
| `threat.attack.{tactic,technique,subtechnique}.{ids,names}`, `threat.attack.version` | MITRE ATT&CK of the **reported campaign** (separate arrays). Membership via `in(value, array)`. This is intel about threats in the wild — **not** ATT&CK observed on your entities (that's `detections.md`). |
### Observables / IOCs (optional)
| Field | Notes |
|---|---|
| `threat.observables.ips` | IOC IPv4/IPv6 (`ipAddress[]`) |
| `threat.observables.domains` / `.urls` / `.emails` | Domain / URL / email IOCs |
| `threat.observables.cves` | Referenced CVE IDs |
| `threat.observables.hashes.md5` / `.sha1` / `.sha256` | File-hash IOCs |
### Vendor extensions (additive — no SD counterpart)
| Field | Notes |
|---|---|
| `alienvault.pulse.public` / `alienvault.pulse.tlp` | AlienVault OTX: public flag / TLP (`WHITE` \| `GREEN`) |
| `crowdstrike.report.slug` / `.type` / `.type.id` / `.type.name` | CrowdStrike: report slug + type (`Notice` \| `Tipper` \| `Periodic Report` \| `Intelligence Report` \| `Recon+`) |
---
## Finding ID Format Cheatsheet
`finding.id` is provider-specific — match exactly when drilling down:
| Provider | Format example |
|---|---|
| Dynatrace | UUID — `a9bc7599-2b1b-45b7-8f6c-6ab57ad4c343` |
| AutomationEngine | 64-char hex hash |
| External providers | Provider-specific format — e.g. a cloud resource ARN or a provider-native finding ID; consult the sample data / discover per provider |references/detections.md
# Detection Queries — `security.events`
Runtime Application Protection (RAP) detections, Automated Detection rules, and
external provider detections.
> **Cross-references:** field reference → [data-model.md § Detection Fields](data-model.md#detection-fields-detection_finding) · entity
> scoping OR chain, sem-dict filter, default summarization →
> [common-patterns.md](common-patterns.md).
> **`DETECTION_FINDING` is the only event type fully covered by the
> cross-provider summary pattern for both Dynatrace and external sources.** The
> canonical vendor filter (`product.vendor != "Dynatrace" or event.type=="DETECTION_FINDING"`)
> lets DT RAP / Automated Detections through. For vulnerabilities and compliance,
> pair the cross-provider summary with the Dynatrace RVA snapshot pattern or the
> Dynatrace SPM snapshot pattern.
> **No state-report pattern.** Detection findings are **one-shot events**. Each
> detection arrives as a single `DETECTION_FINDING` row — no dedup or
> vulnerability-level summarize required.
> **RAP event-type caveat.** Most RAP examples use `DETECTION_FINDING`, but some
> tenants expose RAP rows as `SECURITY_EVENT`. For broad RAP/critical-detection
> summaries, include both event types and scope by
> `product.name == "Runtime Application Protection"`:
> `in(event.type, {"DETECTION_FINDING","SECURITY_EVENT"})`.
> **No mute / dismiss / suppress in `security.events`.** Detections have no
> equivalent of `vulnerability.mute.*` — there's no per-finding lifecycle
> tracked in DQL. Suppression happens UI-side (Threats & Exploits app
> filters) or at OneAgent ingest time (Application Protection allowlist
> rules — see § RAP Action / Block-vs-Monitor); neither writes to
> `security.events`. If asked "show me dismissed detections," explain the
> data isn't there.
> **Two DT-native event types from Automated Detections.** Beside
> `DETECTION_FINDING`, threat-detection-service also emits
> `DETECTION_EXECUTION_SUMMARY` — one audit row per rule run with scan stats
> and status. Use it to answer "did my rule fire? how often? did it succeed?"
> See § Automated Detections — Execution Summary below.
---
## Contents
- [Provider Routing](#provider-routing)
- [All Dynatrace-Generated Detections (canonical)](#all-dynatrace-generated-detections-canonical)
- [Widen-on-empty fallback (retrieval queries)](#widen-on-empty-fallback-retrieval-queries)
- [Latest RAP detections — current window (both event types)](#latest-rap-detections--current-window-both-event-types)
- [Automated Detections (custom / built-in rules)](#automated-detections-custom--built-in-rules)
- [Critical Dynatrace detections grouped by affected host (24h)](#critical-dynatrace-detections-grouped-by-affected-host-24h)
- [RAP: Attack-Type & Action Workflows](#rap-attack-type--action-workflows)
- [Attack-type breakdown](#attack-type-breakdown)
- [Blocked vs. monitored breakdown](#blocked-vs-monitored-breakdown)
- [Top attacker IPs — cross-provider](#top-attacker-ips-last-24h-cross-provider)
- [Filter detections by an IoC IP list](#filter-detections-by-an-ioc-ip-list-cross-provider)
- [Filter detections by domain/URL/URI IoC list](#filter-detections-by-domainurluri-ioc-list)
- [Same source IP attacking multiple targets — cross-provider](#same-source-ip-attacking-multiple-targets-cross-provider)
- [Scope variants (RAP-only / external-only / single provider)](#scope-variants-narrow-only-when-the-user-explicitly-asks)
- [`actor.ips` coverage check](#actorips-coverage-check-when-results-look-sparse)
- [RAP attack drilldown (specific attack on a process)](#rap-attack-drilldown-specific-attack-on-a-process)
- [Automated Detections — MITRE ATT&CK Workflows](#automated-detections--mitre-attck-workflows)
- [Coverage breakdown by MITRE technique](#coverage-breakdown-by-mitre-technique)
- [Filter detections by technique (single, or parent + sub)](#filter-detections-by-technique-single-technique-or-parent--sub-techniques)
- [Untagged Automated Detections (rules missing MITRE)](#untagged-automated-detections-rules-missing-mitre)
- [Automated Detections — Execution Summary](#automated-detections--execution-summary)
- [External Detection Queries](#external-detection-queries)
- [Threats & Exploits (T&E) App Compatibility](#threats--exploits-te-app-compatibility)
- [Detections from a specific external provider](#detections-from-a-specific-external-provider)
- [Custom / non-normalized ingest](#custom--non-normalized-ingest)
- [Repeated Detections — Frequency-of-Occurrence](#repeated-detections--frequency-of-occurrence)
- [Cross-Provider Summary Pattern (all finding types)](#cross-provider-summary-pattern-all-finding-types)
- [Single-Finding Drill-Down Pattern](#single-finding-drill-down-pattern)
- [Detection Workflows](#detection-workflows)
- [New findings within a time window (UC-G2)](#new-findings-within-a-time-window-uc-g2-for-detections)
- [Detections by finding type (UC-D3)](#detections-by-finding-type-uc-d3)
- [Detections over time (grouped by day or hour)](#detections-over-time-grouped-by-day-or-hour)
- [Critical detections on a specific entity](#critical-detections-on-a-specific-entity)
- [Detection lookup by ID or title](#detection-lookup-by-id-or-title)
- [Best Practices](#best-practices)
---
## Provider Routing
| Source | Filter |
|---|---|
| All Dynatrace-generated detections (RAP + Automated Detections) | `event.type == "DETECTION_FINDING" AND product.vendor == "Dynatrace"` |
| **RAP only** (Runtime Application Protection via OneAgent) | `in(event.type, {"DETECTION_FINDING","SECURITY_EVENT"}) AND product.name == "Runtime Application Protection"` |
| Automated Detections only (custom / built-in rules) | `event.type == "DETECTION_FINDING" AND event.provider == "Dynatrace Automated Detections"` |
| AutomationEngine (workflow detections) | `event.type == "DETECTION_FINDING" AND event.provider == "AutomationEngine"` |
| All external providers | `event.type == "DETECTION_FINDING"` + `filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"` |
External detections all share the `DETECTION_FINDING` event type — query them together with
the catch-all above. To narrow to one external provider, use the shared provider-scoping
pattern in
[all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type).
Default to the unified Dynatrace form (`product.vendor == "Dynatrace"`) — only split when the
user specifically asks for RAP-only, Automated-Detections-only, or a specific provider.
> **RAP filter — both forms are current and equivalent.** On every RAP
> detection, `event.provider == "OneAgent"` AND `product.name == "Runtime
> Application Protection"` are both populated (`product.vendor == "Dynatrace"`
> as well). The two are independent SD fields — neither subsumes the other.
> Either filter works on its own; this skill prefers
> `product.name == "Runtime Application Protection"` as the canonical form
> because it matches the official Dynatrace docs naming. Don't OR them or AND
> them — that's redundant.
---
## All Dynatrace-Generated Detections (canonical)
```dql
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter product.vendor == "Dynatrace"
```
### Widen-on-empty fallback (detection queries)
**Default to `from:now()-2h` for all unqualified detection queries** — "how many detections do I have?",
"detections by severity", "show me detections", "list recent attacks", "by attack type / entity / provider".
This matches the Threats & Exploits app default. Run the fallback **only when the 2h query returns
zero rows**; do not pre-emptively widen. If the user explicitly asks for a time window (for example
"last 24 hours" or "last 7 days"), honor that explicit window.
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| filter product.vendor == "Dynatrace"
```
If 24h is still empty (sparse tenant, new environment), widen once more to `from:now()-7d` before
concluding "no detections." **Report the wider window used** when you fall back, e.g. "no
detections in the last 2h; widening to 24h returned X results."
> **Never run the 2h and 24h queries in parallel.** The 24h window is a *sequential* fallback —
> trigger it only when the 2h query returns zero rows. Running both upfront wastes ~10× the
> data budget and misleads the answer (two windows → two result sets that must be reconciled).
> **History and analytics queries use wider windows only when the user asks for that history** —
> top attacker IPs over 24h, MITRE breakdowns over 24h or 7d, detections-grouped-by-day over 7d,
> and critical-grouped-by-host over 24h are valid when the prompt includes that timeframe. Without
> an explicit timeframe, start at `2h` and widen only on empty.
### Latest RAP detections — current window (both event types)
RAP detects code-level attacks via OneAgent (`finding.type` = attack class,
`finding.action` = response). RAP emits `DETECTION_FINDING` on most tenants but
`SECURITY_EVENT` on some — **include both**; never narrow to `DETECTION_FINDING` alone
for RAP. Canonical "show me current RAP attacks" recipe:
```dql
fetch security.events, from:now()-2h
// Include both RAP event types — never narrow to DETECTION_FINDING alone for RAP
| filter in(event.type, {"DETECTION_FINDING", "SECURITY_EVENT"})
| filter product.name == "Runtime Application Protection"
| fields timestamp, finding.id, finding.title, dt.security.risk.level,
finding.type, finding.action, actor.ips, actor.geo.country.name,
object.id, object.name, object.type,
"dt.smartscape*", "dt.entity*"
| sort timestamp desc
| limit 100
```
If zero rows are returned, widen to `from:now()-24h` (widen-on-empty rule — see § Widen-on-empty fallback).
### Automated Detections (custom / built-in rules)
```dql
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter event.provider == "Dynatrace Automated Detections"
| fields timestamp, "dt.smartscape*", "dt.entity*",
detection.id, detection.title, finding.title,
dt.security.risk.level, threat.attack.technique.ids, threat.attack.subtechnique.ids,
object.name, object.type, execution.id
| sort timestamp desc
```
### Critical Dynatrace detections grouped by affected host (24h)
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
AND product.vendor == "Dynatrace"
AND dt.security.risk.level == "CRITICAL"
| summarize {
detections = count(),
titles = collectDistinct(finding.title),
smartscape_node.ids = collectDistinct(dt.smartscape_source.id)
}, by: {host.name, dt.smartscape.host, dt.entity.host}
| sort detections desc
| limit 25
```
---
## RAP: Attack-Type & Action Workflows
RAP detects code-level attacks via OneAgent. `finding.type` is the canonical
attack class; `finding.action` carries OneAgent's response.
### Attack-type breakdown
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
AND product.name == "Runtime Application Protection"
| summarize {
Detections = count(),
Critical = countIf(dt.security.risk.level == "CRITICAL"),
Blocked = countIf(finding.action == "Blocked"),
Audited = countIf(finding.action == "Audited"),
Allowlisted = countIf(finding.action == "Allowlisted"),
AffectedProcesses = countDistinctExact(dt.entity.process_group),
smartscape_node.ids = collectDistinct(dt.smartscape_source.id)
}, by: {AttackType = finding.type}
| sort Detections desc
```
### Blocked vs. monitored breakdown
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
AND product.name == "Runtime Application Protection"
| summarize Detections = count(),
by: {Action = finding.action,
AttackType = finding.type,
Severity = dt.security.risk.level}
| sort Detections desc
```
> **`finding.action` enum**: `Blocked` (OneAgent stopped the request),
> `Audited` (Monitor mode — detected but not stopped), `Allowlisted` (an
> Application Protection rule explicitly allowed it). If `finding.action` is
> null on a row, the OneAgent version may not yet emit it — fall back to
> `event.outcome` if present, or treat as `Audited` for triage purposes.
### Top attacker IPs (last 24h, cross-provider)
> **Default to cross-provider scope for attacker analytics.** `actor.ips` is
> populated across RAP and external detection sources. Don't narrow to
> `product.name == "Runtime Application Protection"` unless the user explicitly
> asks (see § Scope variants below).
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| filter isNotNull(actor.ips)
| expand actor.ips
| fieldsAdd ip = ip(actor.ips)
| summarize {
Detections = count(),
DistinctTargets = countDistinctExact(object.id),
AttackTypes = collectDistinct(finding.type),
Providers = collectDistinct(event.provider),
Products = collectDistinct(product.name),
Countries = collectDistinct(actor.geo.country.name),
FirstSeen = takeMin(timestamp),
LastSeen = takeMax(timestamp)
}, by: {SourceIP = ip}
| sort Detections desc
| limit 25
```
> **Why `expand` + `ip()` cast.** `actor.ips` is `ipAddress[]` — one row may
> carry IPv4 + IPv6 or a proxy chain. `expand` splits multi-IP rows into one
> row per IP so each ranks independently. The `ip(actor.ips)` cast types the
> value as a proper IP address, so downstream `==`, range, and CIDR
> comparisons type-check correctly.
> **Mixed provenance.** The same IP appearing under multiple `Providers`
> (e.g. RAP + an external detection source) is signal — perimeter and in-process detection
> agreeing on an attacker. Same IP in only one provider is normal — different
> providers see different traffic.
### Filter detections by an IoC IP list (cross-provider)
Use this when you have a **known list of attacker IPs** (from a threat report, STIX
bundle, or advisory) and want to check whether any of them appeared as attackers in
your environment. Distinct from the `join`-based correlation in
`threat-intelligence.md` (which correlates against IPs already ingested as
`THREAT_REPORT` events) — this query works directly against a pasted/extracted IP list.
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| filter isNotNull(actor.ips)
| expand actor.ips
| filter in(ip(actor.ips), array(toIp("<ip1>"), toIp("<ip2>")))
| fields timestamp, finding.id, finding.title, dt.security.risk.level,
finding.type, finding.action, actor.ips, actor.geo.country.name,
object.id, object.name, object.type, event.provider, product.name
| sort timestamp desc
| limit 100
```
> **Why `ip()`/`toIp()`, not `toString()`.** Typed IP comparison canonicalizes
> IPv6 representations and supports CIDR/range predicates. String equality silently
> misses the same IPv6 address in a different notation. Always use the typed form
> in skill templates; `toString()` string comparison is only appropriate when you
> know the IoC list is IPv4-only.
Zero matches is a valid, meaningful answer ("none of the IoC IPs attacked us in
this window") — report it truthfully with the window used.
### Filter detections by domain/URL/URI IoC list
Use when you have a list of domain, URL, or path indicators and want to check whether
any detection recorded activity involving those values. Not all providers or event types
populate every URL field — the filter is a broad OR across all fields that may carry
domain/URL evidence. Zero matches is a valid result; report the window used.
Fields searched (populated varies by provider and attack type):
| Field | Notes |
|---|---|
| `url.full` | Full URL; populated by some providers and RAP SSRF/injection events |
| `url.path` | HTTP path targeted; populated by RAP (`url.path`) and `entry_point.url.path` |
| `url.domain` | Domain component of the URL; provider-dependent |
| `server.address` | Target server domain/hostname; provider-dependent |
| `host.fqdn` | Array; fully qualified domain names of the affected host |
For external providers, `http.request.header.Host` and similar raw request headers may
appear only in `dt.raw_data` — parse with `parse dt.raw_data, "JSON:raw"` if the above
fields are null and the provider is known to carry the header in raw payload.
**Safety:** DQL-escape all IoC values before inserting into string literals (replace `\` with `\\` and `"` with `\"`). See `dt-sec-ioc-hunting/references/ioc-intake.md` § URL Cleaning step 7.
**Domain casing:** Domains and hostnames are case-insensitive — provide them in lowercase and the template normalizes field values with `lower()` before comparison. URL/path IoCs are matched as-is (URL paths can be case-sensitive on the target server).
```dql-template
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| fieldsAdd Domains = array("<domain1_lowercase>", "<domain2_lowercase>"),
URLs = array("<url1>")
| filter iAny(contains(lower(url.full), Domains[]))
OR in(lower(url.domain), Domains)
OR in(lower(server.address), Domains)
OR iAny(in(lower(host.fqdn[]), Domains))
OR iAny(contains(url.full, URLs[]))
OR iAny(contains(url.path, URLs[]))
| fields timestamp, finding.id, finding.title, dt.security.risk.level,
finding.type, finding.action, event.provider, product.name,
url.full, url.path, url.domain, server.address, host.fqdn,
object.id, object.name, object.type,
actor.ips, "dt.smartscape*", "dt.entity*"
| sort timestamp desc
| limit 100
```
Apply the widen-on-empty fallback: if `from:now()-2h` returns zero rows, re-run with
`from:now()-24h` before concluding no match. Report the window used.
Omit `URLs` lines entirely when no URL IoCs are present; omit the `Domains` lines when
no domain IoCs are present.
---
### Same source IP attacking multiple targets (cross-provider)
Fan-out pattern: a single IP hitting many distinct targets, not the same target
many times. Strong signal for reconnaissance / wide-scan campaigns. Take the **Top
attacker IPs** query above, add `SampleTargets = arraySlice(collectDistinct(object.name), from: 0, to: 10)`
to the summarize, then append:
```dql-snippet
| filter DistinctTargets >= 2
| fieldsAdd DurationMin = round((toLong(LastSeen) - toLong(FirstSeen)) / 1000000000.0 / 60, decimals: 1)
| sort DistinctTargets desc, Detections desc
| limit 25
```
`DistinctTargets >= 2` is the threshold — raise to `>= 5` for wide-scan recon. Long
`DurationMin` + high `Detections` = slow-burn; short = automated burst. Groups by
`object.id` (cross-provider target id), not `dt.entity.process_group` (null for external).
### Scope variants (narrow only when the user explicitly asks)
Apply on top of either query above; never apply by default.
| User intent | Add this filter |
|---|---|
| "In-application attacks" / "RAP" / "OneAgent attacks" | `AND product.name == "Runtime Application Protection"` |
| "External tools" / "perimeter" / "cloud security findings" | `AND product.vendor != "Dynatrace"` |
| A specific external provider | See [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type) |
### `actor.ips` coverage check (when results look sparse)
Not every provider populates `actor.ips` — some carry the IP only in
`dt.raw_data`. Run this first if the cross-provider query looks empty:
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| summarize {
Total = count(),
HasIPs = countIf(isNotNull(actor.ips)),
CoveragePct = round(countIf(isNotNull(actor.ips)) * 100.0 / count(), decimals: 1)
}, by: {event.provider, product.name}
| sort Total desc
```
For providers with `HasIPs == 0`, the attacker IP (if any) is in
`dt.raw_data` — parse with `parse dt.raw_data, "JSON:raw"` and project the
relevant key.
> **Enrichment context.** `actor.ips` is enrichable via the **Security
> Enrichment** app (AbuseIPDB / VirusTotal / custom threat-intel APIs) —
> enrichment happens client-side in the Threats & Exploits app, not in
> `security.events`. For queries, group by the cast IP and resolve reputation
> outside the DQL pipeline. Geo fields (`actor.geo.country.name`,
> `actor.geo.city.name`, `actor.geo.continent.name`) are marked Experimental
> and may be null when enrichment isn't configured.
### RAP attack drilldown (specific attack on a process)
```dql-template
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
AND product.name == "Runtime Application Protection"
AND contains(lower(finding.type), "sql")
| filter dt.entity.process_group == "PROCESS_GROUP-XXXXXXXXXXXXXXXX"
| sort timestamp desc
| limit 50
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
finding.id, finding.type, finding.action, finding.title,
actor.ips, actor.geo.country.name,
dt.security.rap.target.id, dt.security.rap.target.type, dt.security.rap.target.name,
object.id, object.name, object.type, url.path
```
> RAP-specific drilldown into entry-point payloads, user-controlled inputs,
> and sink-code details lives in product-emitted fields outside the SD
> stable namespace. Use the Threats & Exploits app for the full attack
> reconstruction; SD-stable fields above are sufficient for "which attack,
> from whom, hitting which target" queries.
`finding.type` is a **vendor-original free-form string** — not a normalized enum. RAP emits
display strings whose exact form can differ across OneAgent versions; observed values:
`SQL injection`, `CMD injection`, `JNDI injection`, `SSRF`. Use `contains(lower(finding.type), …)`
rather than exact-match literals. JNDI/SSRF are Java-only; SQL/command/path injection cover
Java + .NET + Go. Discover active values on any tenant with the summarize query in UC-D3 below.
---
## Automated Detections — MITRE ATT&CK Workflows
Automated Detections rules can be tagged with MITRE ATT&CK classifications
via the canonical SD namespace `threat.attack.*` (technique, sub-technique,
tactic — separate arrays). This is the only DT-native source that emits
MITRE in `security.events` — RAP findings don't carry MITRE tags directly.
Full field reference (types, examples, companion `.names` / `.version`) →
[data-model.md § DT Automated Detections](data-model.md#dt-automated-detections-eventprovider--dynatrace-automated-detections).
> **Query rules that affect DQL:** use `in(value, array)` for exact-ID membership
> (fallback: `expand technique = threat.attack.technique.ids | filter technique == "T1078"`).
> Sub-technique IDs (`T1059.003`) live in their **own** array — querying
> `threat.attack.technique.ids` for them won't match. "Parent + all sub-techniques"
> requires an OR across both arrays (see the parent+sub query below).
### Coverage breakdown by MITRE technique
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
AND event.provider == "Dynatrace Automated Detections"
| filter isNotNull(threat.attack.technique.ids) AND arraySize(threat.attack.technique.ids) > 0
| expand technique = threat.attack.technique.ids
| summarize {
Detections = count(),
Rules = countDistinctExact(detection.id),
AffectedObjects = countDistinctExact(object.id),
Severities = collectDistinct(dt.security.risk.level)
}, by: {Technique = technique}
| sort Detections desc
```
### Filter detections by technique (single technique, or parent + sub-techniques)
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
AND event.provider == "Dynatrace Automated Detections"
| filter in("T1078", threat.attack.technique.ids)
| fields timestamp, "dt.smartscape*", "dt.entity*",
detection.title, finding.title, dt.security.risk.level,
threat.attack.technique.ids, threat.attack.subtechnique.ids, object.name
| sort timestamp desc
```
For a **parent technique + all its sub-techniques** (e.g. T1110 Brute Force +
T1110.001/002/…), OR across both arrays — swap the single-technique filter for:
```dql-snippet
| filter in("T1110", threat.attack.technique.ids)
OR iAny(startsWith(threat.attack.subtechnique.ids[], "T1110."))
```
### Untagged Automated Detections (rules missing MITRE)
Coverage gap audit — which rules fired but lack MITRE tagging?
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
AND event.provider == "Dynatrace Automated Detections"
| filter (isNull(threat.attack.technique.ids) OR arraySize(threat.attack.technique.ids) == 0)
AND (isNull(threat.attack.subtechnique.ids) OR arraySize(threat.attack.subtechnique.ids) == 0)
| summarize Detections = count(),
Sample = takeFirst(finding.title),
by: {RuleID = detection.id, Rule = detection.title}
| sort Detections desc
```
---
## Automated Detections — Execution Summary
`DETECTION_EXECUTION_SUMMARY` is a separate event type emitted **per rule
run**. Use it to answer "did my rule fire? did it succeed? how much data did
it scan?" — without scanning the findings stream itself.
> **Filter canonically by `event.provider == "Dynatrace Automated Detections"`**
> for consistency with the findings filter — both `event.provider` and
> `product.name == "Automated Detections"` are populated on every execution
> summary row, so either works on its own. (`product.name` and `event.provider`
> are independent SD fields and don't have to share a value; for Automated
> Detections they happen to convey the same source.)
### Execution status breakdown (last 24h)
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_EXECUTION_SUMMARY"
AND event.provider == "Dynatrace Automated Detections"
| summarize {
Runs = count(),
Success = countIf(execution.status == "SUCCESS"),
Warnings = countIf(execution.status == "SUCCESS_W_WARNINGS"),
Failures = countIf(execution.status == "FAILURE"),
TotalEventsWritten = sum(execution.events_written),
TotalRecordsScanned = sum(execution.scanned_records)
}, by: {RuleID = detection.id, Rule = detection.title}
| sort Failures desc, Warnings desc, Runs desc
```
> **Field names for execution stats** (`execution.status`,
> `execution.events_written`, `execution.scanned_records`,
> `execution.scanned_bytes`, `execution.analysis_timeframe_start` /
> `_end`) are the SD-normalized forms; raw events may carry the
> Java-camelCase names (`eventsWritten`, `scannedRecords`, etc.). If a query
> returns nulls, inspect a single row first to confirm casing.
### Rules that have not fired in the last 24h
Take the execution-status query above, reduce its summarize to
`{ Runs = count(), Findings = sum(execution.events_written) }` keyed by
`{detection.id, detection.title}`, then append `| filter Findings == 0 | sort Runs desc`.
This answers "is my detection rule actually catching anything?" — `Findings == 0` over a
representative window means either the threat hasn't occurred or the rule is mis-tuned.
---
## External Detection Queries
Ingested from external cloud-security and SIEM/SOAR tools via OpenPipeline — same
`DETECTION_FINDING` event type as Dynatrace-native detections, different
`event.provider` / `product.vendor`.
**All external detections (not Dynatrace-generated):**
```dql
fetch security.events
| filter event.type == "DETECTION_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
```
### Threats & Exploits (T&E) App Compatibility
To return findings the way the **Threats & Exploits app** displays them, apply the
9-field SD-compliance filter (`event.id`, `event.provider`, `finding.type`,
`finding.id`, `finding.time.created`, `finding.title`, `dt.security.risk.level`,
`object.id`, `object.type` all `isNotNull`). Findings missing any are hidden or
rendered incomplete. The canonical filter (applicable to any `*_FINDING` type) lives
in [all-security-events.md § Semantic-Dictionary Fields](all-security-events.md#semantic-dictionary-fields)
— scope it to detections with `filter event.type == "DETECTION_FINDING"`. **Omit it**
when investigating *why* findings are missing from the app (query unfiltered to see
the non-compliant rows).
**Time-bounded detections** — add the SD-compatibility filter plus a
`finding.time.created` bound (it's a string — wrap with `toTimestamp()`):
```dql-snippet
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter <SD-compatibility filter — see all-security-events.md § Semantic-Dictionary Fields>
| filter toTimestamp(finding.time.created) > now()-2h
```
### Detections from a specific external provider
**Step 1 — discover the active provider strings** (if not already known):
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
| summarize detections=count(), by:{event.provider, product.vendor, product.name}
| sort detections desc
```
**Step 2 — scope with exact match** once the provider strings are confirmed (prefer
exact equality over fuzzy `contains`). The general scoping pattern lives in
[all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type);
the detection-specific point is that **one provider may arrive via two ingestion paths**
(e.g. direct and via AWS Security Hub) — combine both with `OR`.
**Named example — Amazon GuardDuty** (two ingestion paths in the same tenant; provider
strings verified via the discovery query above):
```dql
fetch security.events, from:now()-24h
// Exact match both GuardDuty ingestion paths
| filter (event.provider == "Amazon GuardDuty")
OR (event.provider == "AWS Security Hub" AND product.name == "GuardDuty")
| filter event.type == "DETECTION_FINDING"
| filter isNotNull(finding.id) AND isNotNull(object.id) AND isNotNull(dt.security.risk.level)
| summarize {
Detections = count(),
AffectedObjects = countDistinctExact(object.id),
SampleTitles = collectDistinct(finding.title, maxLength: 5),
MaxScore = takeMax(dt.security.risk.score),
AffectedResourceTypes = collectDistinct(object.type)
}, by: {event.provider, dt.security.risk.level, finding.type}
| sort MaxScore desc, Detections desc
```
The query keys off the generic `object.*` namespace so it works for any provider. For
hyperscaler-specific resource identifiers (cloud resource IDs, ARNs, account /
subscription / project scoping), use the dedicated AWS / Azure / GCP skills — this
skill stays provider-neutral. For attacker-IP / sign-in / WAF detections, the
cross-provider `actor.ips` pattern (§ Top attacker IPs) already covers external
sources — don't narrow to one provider unless the user asks.
### Custom / non-normalized ingest
Custom ingest sources arrive as `DETECTION_FINDING` with their own field values.
Discover the populated providers/products first, then parse any non-normalized
fields from `dt.raw_data` (the original ingested JSON payload):
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| summarize Count = count(), by: {event.provider, product.vendor, product.name, finding.type}
| sort Count desc
```
```dql-snippet
// project non-normalized fields from the raw payload (key names vary by source schema)
| parse dt.raw_data, "JSON:raw"
| fields timestamp, raw[`<field-a>`], raw[`<field-b>`], raw[`<message>`]
```
---
## Repeated Detections — Frequency-of-Occurrence
Detections are one-shot, but the **same kind of detection** can repeat —
either because the same `finding.id` re-arrives (rare; some external
providers re-ingest unchanged findings), or more typically because the same
attack pattern (`finding.type` / `detection.id` for
Automated Detections) hits the same target repeatedly.
### Repeated attack patterns by source IP + attack type (cross-provider)
Distinct from "Same IP attacking multiple targets" — group by **both** IP and attack
type to surface the same kind of attack hitting infrastructure repeatedly (an IP probing
for SQL injection across the fleet, or repeatedly triggering one external rule). Take the
**Top attacker IPs** query, change its `by:` to `{SourceIP = ip, AttackType = finding.type}`,
then append:
```dql-snippet
| filter Detections >= 5
| fieldsAdd DurationMin = round((toLong(LastSeen) - toLong(FirstSeen)) / 1000000000.0 / 60, decimals: 1)
| sort Detections desc
```
`Detections >= 5` is a noise-floor heuristic — adjust to taste. Long duration + high count =
slow-burn campaign; short = automated bursts. To narrow to one source, apply a scope variant
from § Top attacker IPs.
### Same rule / attack firing on the same object repeatedly (cross-provider)
Groups by `finding.type` (cross-provider attack/rule classification) and
`object.id` (cross-provider target) to surface "this kind of detection
keeps hitting the same target." Works for RAP attacks repeating against a
process group, Automated Detections rules re-firing on a K8s pod, and
external findings re-arriving against the same
cloud resource.
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
| filter isNotNull(finding.type) AND isNotNull(object.id)
| summarize {
Fires = count(),
Providers = collectDistinct(event.provider),
Products = collectDistinct(product.name),
SampleTitle = takeFirst(finding.title),
FirstSeen = takeMin(timestamp),
LastSeen = takeMax(timestamp)
}, by: {
Kind = finding.type,
ObjectID = object.id,
ObjectName = object.name,
ObjectType = object.type
}
| filter Fires >= 3
| fieldsAdd DurationHours = round((toLong(LastSeen) - toLong(FirstSeen)) / 1000000000.0 / 3600, decimals: 1)
| sort Fires desc
| limit 50
```
`Fires >= 3` is a noise-floor — adjust to taste. **Long duration + high
fires** suggests an unaddressed persistent issue (root cause not
remediated). **Short duration + high fires** suggests a tuning issue (noisy
rule or attack pattern).
To narrow to a specific target type (e.g. "only pods"):
```dql-snippet
| filter object.type == "KUBERNETES_POD" // SD-normalized form
OR object.type == "k8spod" // KSPM analyzer code (lowercase)
```
To narrow to a specific source, apply a scope variant from § Top attacker
IPs (RAP-only / external-only / single-provider).
---
## Cross-Provider Summary Pattern (all finding types)
For a unified summary across detections + other finding types and providers, use the canonical
cross-provider query in
[all-security-events.md § Canonical Cross-Provider Query](all-security-events.md#canonical-cross-provider-query)
— it applies the SD, compliance-FAILED, and double-counting guards. Scope it to detections by
adding `filter event.type == "DETECTION_FINDING"`, and narrow to a provider via
[§ Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type).
---
## Single-Finding Drill-Down Pattern
For a specific `finding.id`, `scan.id`, or title substring. Uses `append` to
combine a windowed external query with a fixed-1h Dynatrace query, then sorts
latest first:
```dql-template
fetch security.events, from:now()-${timeRange}
| filter (product.vendor != "Dynatrace" or event.type=="DETECTION_FINDING")
| filter ("${findingID}"=="ALL" or finding.id == "${findingID}")
| filter ("${scanID}"=="ALL" or scan.id == "${scanID}")
| filter ("${title}"=="ALL" or contains(finding.title, "${title}")
or contains(scan.title, "${title}")
or contains(event.description, "${title}"))
| append [
fetch security.events, from:now()-1h
| filter (product.vendor == "Dynatrace" and not event.type=="DETECTION_FINDING")
| filter ("${findingID}"=="ALL" or finding.id == "${findingID}")
| filter ("${scanID}"=="ALL" or scan.id == "${scanID}")
| filter ("${title}"=="ALL" or contains(finding.title, "${title}")
or contains(scan.title, "${title}")
or contains(event.description, "${title}"))
]
| sort timestamp desc
| limit ${maxResults}
```
`finding.id` format is provider-specific — match exactly. See
[data-model.md § Finding ID Format Cheatsheet](data-model.md#finding-id-format-cheatsheet).
---
## Detection Workflows
### Simple detection count ("how many?")
For questions like "How many security detections do I have?" or "How many CRITICAL detections?",
deliver total + per-risk-level breakdown in **one query** using `countIf`. Do **not** group by
`event.provider` or `product.name` — those are detail fields for breakdown and list queries;
for a simple count they obscure the primary number and inflate the result set.
```dql-snippet
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| summarize {
Total = count(),
Critical = countIf(dt.security.risk.level == "CRITICAL"),
High = countIf(dt.security.risk.level == "HIGH"),
Medium = countIf(dt.security.risk.level == "MEDIUM"),
Low = countIf(dt.security.risk.level == "LOW"),
AffectedObjects = countDistinctExact(object.id)
}
// If Total == 0, re-run sequentially with from:now()-24h (widen-on-empty fallback).
// Report the wider window: "no detections in the last 2h; 24h returned X."
```
If the user also asks "by provider" or "by product" in the same prompt, run a **second** query
(grouped `by: {event.provider, dt.security.risk.level}`) — do not merge it into the count query
with a combined `by:` clause.
### New findings within a time window (UC-G2 for detections)
Detections are one-shot events — filter `finding.time.created` to scope to
genuinely new arrivals. `finding.time.created` is a string timestamp; use
`toTimestamp()` for comparison:
```dql
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| filter toTimestamp(finding.time.created) > now() - 24h
| summarize findings=count(), by:{event.provider, dt.security.risk.level, finding.type}
| sort findings desc
```
This approach (filter on `finding.time.created`) works for **all** `*_FINDING`
event types — `VULNERABILITY_FINDING`, `DETECTION_FINDING`, `COMPLIANCE_FINDING`.
For DT RVA, prefer filtering on `vulnerability.resolution.change_date` instead
(see [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md#new-vulnerabilities-in-the-last-24h--7-days-uc-v3)).
### Detections by finding type (UC-D3)
Filter by `finding.type` to scope to a specific attack category.
`finding.type` is a **vendor-original free-form string** (not a normalized enum) —
always discover the live values first, then use a case-insensitive substring match:
```dql
// Discover active finding types:
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
| summarize Count=count(), by:{finding.type, event.provider}
| sort Count desc
```
```dql
// Filter for a specific type — match both finding.type and finding.title (vendor-original):
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter contains(lower(finding.type), "sql") OR contains(lower(finding.title), "sql")
| fields timestamp, "dt.smartscape*", "dt.entity*",
event.provider, finding.title, dt.security.risk.level,
object.name, object.type, event.description
| sort timestamp desc
```
### Detections over time (grouped by day or hour)
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
| summarize detections = count(),
by: {day = bin(timestamp, 1d), event.provider, dt.security.risk.level}
| sort day asc, detections desc
```
For an hourly trend by provider, change `bin(timestamp, 1d)` → `bin(timestamp, 1h)`
(drop `dt.security.risk.level` from `by:` if you only want per-provider counts).
### Critical detections on a specific entity
Use the entity OR-chain from
[common-patterns.md § 5](common-patterns.md#5-wide-entity-scoping-or-chain). Example
inline with a Smartscape host ID:
```dql-template
fetch security.events, from:now()-2h
| filter event.type == "DETECTION_FINDING"
| filter dt.security.risk.level == "CRITICAL"
| filter dt.smartscape.host == "<SMARTSCAPE_HOST_ID>"
or dt.entity.host == "<HOST_ENTITY_ID>"
or host.name == "<HOST_NAME>"
| sort timestamp desc
| fieldsKeep timestamp, "dt.smartscape*", "dt.entity*", "dt.source*",
event.provider, finding.title, dt.security.risk.level,
object.id, object.name, object.type, event.description
```
### Detection lookup by ID or title
```dql-template
fetch security.events, from:now()-24h
| filter event.type == "DETECTION_FINDING"
| filter finding.id == "<FINDING_ID>"
or contains(finding.title, "<TITLE_SUBSTRING>")
or contains(event.description, "<DESCRIPTION_SUBSTRING>")
| sort timestamp desc
| limit 5
```
---
## Best Practices
1. **No dedup or summarize is required** — detection findings are one-shot events;
each row is a discrete detection.
2. **Start with a 2h window; widen only on empty results, sequentially** — see
[§ Widen-on-empty fallback](#widen-on-empty-fallback-retrieval-queries) for the
fallback query and the intentionally-wider history/analytics exceptions.
3. **Project normalized fields first** — `dt.security.risk.level`, `finding.title`,
`object.name`, `event.provider`, `product.name` (exist across all providers);
project provider-specific payloads (`actor.ips`, `threat.attack.*`) explicitly when
needed. For list/detail queries use the wildcard block `"dt.smartscape*",
"dt.entity*", "dt.source*"`; for entity-grouped summaries add
`smartscape_node.ids = collectDistinct(dt.smartscape_source.id)`, and where the node
type is known include the domain field in `by:` (e.g. `dt.smartscape.host` alongside
`dt.entity.host`).
4. **Correlate with traces and audit events** — for RAP detections on a process, load
`dt-obs-tracing` for representative traces; for settings / access-control changes
around a detection, use the audit trail.
5. **Use `product.vendor == "Dynatrace"` for the all-Dynatrace form**; split by
`event.provider` / `product.name` only when the user asks for one source.
6. **Default to cross-provider scope for attacker / target analytics** — `actor.ips`,
`finding.type`, `dt.security.risk.level`, `object.id` are SD-canonical across RAP,
Automated Detections, and external providers. Narrow to a source only on an explicit
ask; if results look sparse, run the `actor.ips` coverage check first
([§ `actor.ips` coverage check](#actorips-coverage-check-when-results-look-sparse)).
7. **RAP filter is `product.name == "Runtime Application Protection"`** (canonical per
docs); `event.provider == "OneAgent"` is equivalent — pick one, don't OR/AND them.
8. **MITRE lives in `threat.attack.*` arrays** (Automated Detections only) — use
`in(value, array)` membership; parent + all sub-techniques needs an OR across
`threat.attack.technique.ids` and `threat.attack.subtechnique.ids`. Field reference →
[data-model.md § DT Automated Detections](data-model.md#dt-automated-detections-eventprovider--dynatrace-automated-detections).
9. **`finding.action` is the RAP block-vs-monitor signal** — `Blocked` / `Audited` /
`Allowlisted`; don't conflate with `event.outcome`.
10. **`actor.ips` is `ipAddress[]` — `expand` then `ip()` cast** before IP comparisons;
see [§ Top attacker IPs](#top-attacker-ips-last-24h-cross-provider).
11. **`DETECTION_EXECUTION_SUMMARY` ≠ `DETECTION_FINDING`** — per-rule-run audit vs.
per-detection; don't mix them in one count query.
12. **Detections have no mute / suppression in events** — explain the limitation rather
than inventing fields (suppression is UI-side or RAP-ingest-side).
13. **Simple count questions use `countIf` — one row, one query** ([§ Simple detection
count](#simple-detection-count-how-many)); don't group by provider/product unless
asked.
references/entity-enrichment.md
# Entity Enrichment — Moved to dt-sec-contextualization
> **This capability has moved to `dt-sec-contextualization`.**
>
> Load **dt-sec-contextualization** and read
> `references/entity-enrichment.md` for all entity-mapping recipes:
>
> - 3-way match for K8s workloads (Paths 1 / 2 / 3)
> - Host enrichment by IP and by entity
> - Cloud entity enrichment (Path 1)
> - Natural-language entity name fallback
> - Problem → affected entities → findings (two-query chain)
> - Entity-scoping OR-chain
> - Pre-flight check for identifier availability
>
> The layering contract: dt-sec-contextualization is a lower layer that
> owns the Smartscape mapping primitive and is free of finding-schema
> semantics. dt-sec-insights is a consumer that calls into it for any
> question that needs Smartscape entity resolution.
---
For backwards-compatible cross-links from within dt-sec-insights references,
the key sections are now at:
| Old section | New location |
|---|---|
| § 3-Way Match Strategy | `dt-sec-contextualization/references/identity-mapping.md` (Mapping Primitive section) |
| § Pre-flight check | `dt-sec-contextualization/references/identity-mapping.md` § Mapping Primitive (Pre-flight Check) |
| § Cloud Entity Enrichment | `dt-sec-contextualization/references/entity-enrichment.md` § Cloud Entity Enrichment |
| § K8s Workload Enrichment | `dt-sec-contextualization/references/entity-enrichment.md` § K8s Workload Enrichment |
| § Host Enrichment by IP | `dt-sec-contextualization/references/entity-enrichment.md` § Host Enrichment by IP |
| § Natural-language fallback | `dt-sec-contextualization/references/entity-enrichment.md` § Natural-language Entity Name Fallback |
| § Problem → entities → findings | `dt-sec-contextualization/references/entity-enrichment.md` § Problem → Affected Entities → Findings |
references/mistakes-and-troubleshooting.md
# Common Mistakes & Troubleshooting
Detailed companion to the **Common Mistakes** and **Best Practices** sections in
[SKILL.md](../SKILL.md). Surface this reference when a query is producing
unexpected results or when the user reports counts that disagree with the
Vulnerabilities / Threats & Exploits / SPM apps.
---
## Mistakes to Avoid
1. **Querying `VULNERABILITY_STATE_REPORT_EVENT` alone** → use the three-event-type union (`STATE_REPORT`, `STATUS_CHANGE`, `TRACKING_LINK_CHANGE`).
2. **Deduping on `vulnerability.display_id` alone** → use the composite key `{vulnerability.display_id, affected_entity.id}`, else per-entity context collapses.
3. **Skipping `event.level == "ENTITY"`** on RVA queries → non-entity rows skew aggregations.
4. **`dt.system.bucket` filters** → never filter by bucket; security events may live in any bucket.
5. **`vulnerability.parent.*` (deprecated)** → derive vuln-level values from per-entity arrays: verdicts via `collectDistinct()` + `in()`, scalars via `takeMax/takeFirst`.
6. **Wrong risk field / raw CVSS for triage** → `dt.security.risk.*` on `*_FINDING` events; `vulnerability.risk.*` on RVA state-reports (which lack `dt.security.risk.*`). Both beat `vulnerability.cvss.base_score`.
7. **Counting `NOT_RELEVANT` compliance** → exclude it from pass-rate denominators.
8. **Widening the snapshot fetch window** → `from:now()-30m` (RVA) and `from:now()-1h` (SPM) are snapshot windows, not history — widening them returns ~50× more duplicate state rows, not older or newer state. The only valid reason to widen beyond 30m on RVA events is a **pure change-event query** (`VULNERABILITY_STATUS_CHANGE_EVENT` / `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT` only, no `STATE_REPORT`) where the user asks what changed over a period; in that case, match the window to the user's time horizon and omit the snapshot dedup. For lifecycle metrics (new in 24h, resolved in 7d), keep 30m and apply a **post-derive filter** on `resolution.change_date` after Stage 3. For trends, use `makeTimeseries`.
9. **`bin()` for trend/chart questions** → use `makeTimeseries interval:<N>` (charts need a `timeseries`-typed column); `bin()` is only for tabular bucketed counts. [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md#dt-rva-time-series-trends-7-days-3h-buckets)
10. **Filtering `vulnerability.resolution.status == "OPEN"` pre-Stage-3** → the raw field is per-entity; filter only after the Stage-3 `fieldsAdd` derives the vuln-level verdict.
11. **`vulnerability.first_seen` (null on RVA pipeline — do not use)** → for "newly OPEN" use `toTimestamp(vulnerability.resolution.change_date) > now()-<window>`; for "how long open" aggregate `open_since=toTimestamp(takeMin(if(vulnerability.resolution.status=="OPEN", vulnerability.resolution.change_date, else: null)))` in Step 3, then `fieldsAdd open_duration = now() - open_since` (cast once in the summarize; `change_date` is an epoch-nanoseconds value). `first_seen` is commented out of `entity.state` (0/2124 populated). A resolution-time proxy (MTTR) **is** computable from `resolution.change_date` without `first_seen` — it equals true detection-to-resolution only for vulns that never reopened, counts auto-resolutions, and is bounded by the change-event fetch window; treat it as time-to-resolution-by-any-cause, not patch velocity. See [vulnerabilities-dynatrace.md § Resolution time (MTTR proxy)](vulnerabilities-dynatrace.md#resolution-time-mttr-proxy--openresolved-per-affected-object).
12. **Reading external compliance via `compliance.rule.*`** (null for external) → use `compliance.standards` (expand) / `compliance.policy` / `compliance.control`, plus `finding.title`/`finding.type`.
13. **`event.provider == "Dynatrace"` for compliance** → SPM uses `product.vendor == "Dynatrace"`; RVA uses `event.provider`. Don't mix.
14. **Inventing fields** → inspect a sample row, or [data-model.md](data-model.md), first.
15. **Wrong `vulnerability.stack` values** → enum is `CODE / CODE_LIBRARY / SOFTWARE / CONTAINER_ORCHESTRATION` (not `THIRD_PARTY/FIRST_PARTY/CODE_LEVEL`). CLV = `CODE`; "third-party" = `in(stack, array("CODE_LIBRARY","SOFTWARE"))`.
16. **Filtering CLV by runtime assessment** → CLV always scores 10.0 and skips assessment modifiers; scope with `vulnerability.stack == "CODE"`, drill via `vulnerability.code_location.name`.
17. **Treating `ADJACENT_NETWORK` as public exposure** → the Stage-3 derivation intentionally doesn't promote it; for adjacent-network questions filter the raw `vulnerability.davis_assessment.exposure_status`.
18. **Treating `NOT_AVAILABLE` as harmless** → it means "couldn't tell" (ranked above `NOT_DETECTED`/`NOT_IN_USE`); surface it. `assessment_mode` (`FULL`/`REDUCED`/`NOT_AVAILABLE`) explains partial coverage.
19. **Collapsing mute metadata to vuln level** → `mute.{reason,user,comment,change_date}` are per-entity; keep the per-entity row for the mute audit.
20. **Assuming auto-resolution takes days** → third-party resolves after the component is absent >2h; CLV resolves after a process restart + clean re-analysis.
21. **Querying KSPM for AWS/Azure/GCP** → KSPM is K8s-only; route cloud/host compliance to CSPM/VSPM or external ([all-security-events.md](all-security-events.md)).
22. **Asking KSPM for PCI/ISO/HIPAA/GDPR** → KSPM emits only `CIS`/`DORA`/`NIST`/`DISA STIG`; others arrive via external. STIG's `short_name` is the full `"DISA STIG"` — use `contains(lower(...),"stig")`, not `== "STIG"`.
23. **`compliance.rule.severity.level == "NONE"/"NOT_AVAILABLE"`** → KSPM severity is exactly `CRITICAL/HIGH/MEDIUM/LOW`.
24. **Counting MANUAL as PASSED (or ignoring it)** → MANUAL is in the denominator only, never the numerator; surface as a separate triage queue.
25. **Confusing the two object-type fields (KSPM)** → `object.type` = uppercase DT entity type; `compliance.result.object.type` = analyzer lowercase code (`k8scluster`, …). On external rows `object.type` is the vendor value as-is (e.g. `AwsEc2Instance`) — match it directly.
26. **Inventing `compliance.mute.*` / `compliance.tracking_link.*`** → neither exists; compliance has no mute/waiver/tracking namespace. Explain the limitation rather than guessing fields.
27. **`product.vendor == "Dynatrace"` is shared (RVA/RAP/SPM)** → pin `product.name == "Security Posture Management"` for KSPM-only scoping.
28. **Confusing the two RAP filter axes** → both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated; use the latter (canonical), don't OR/AND them.
29. **`attack.type` / `attack.vector` (not in SD)** → use `finding.type` (vendor-original free-form string, not a normalized enum). Filter with a substring match: `contains(lower(finding.type), "sql")`. Values are display strings like `SQL injection`, `CMD injection` — not underscore enums like `SQL_INJECTION`.
30. **Auto-scoping cross-provider questions to RAP** → attacker-IP/campaign/attack-type analytics are SD-canonical across providers; default to `event.type == "DETECTION_FINDING"` and group by `object.id` (not `dt.entity.process_group`, null for external). Add a provider/RAP filter only when asked.
31. **Expecting MITRE tags on RAP** → only Automated Detections populate `threat.attack.*`; for RAP, map `finding.type` → technique manually.
32. **Inventing `detection.mute.*` / `detection.dismiss.*`** → detections aren't lifecycle-tracked in events; suppression is UI/ingest-side.
33. **Confusing `DETECTION_FINDING` with `DETECTION_EXECUTION_SUMMARY`** → the summary is the per-rule-run audit; don't include it in finding counts.
34. **`event.outcome` as the RAP block signal** → use `finding.action` (`Blocked`/`Audited`/`Allowlisted`).
35. **Using `actor.ips` as-is** → it's `ipAddress[]`; `expand actor.ips` then `fieldsAdd ip = ip(actor.ips)`. `actor.ip`/`actor.location` don't exist; geo is `actor.geo.{country,city,continent}.name` (Experimental); reputation is app-side.
36. **`detection.mitre_ids` (not in SD)** → MITRE is `threat.attack.technique.ids` / a separate `threat.attack.subtechnique.ids` (dotted) / `threat.attack.tactic.ids`. Use `in("T1078", …)`; "parent + subs" = `in("T1110", technique.ids) OR iAny(startsWith(subtechnique.ids[], "T1110."))`.
37. **Filtering CVE arrays as scalars** → `vulnerability.references.cve` is an array; use `in("CVE-…", vulnerability.references.cve)` (or `expand`), not `==`.
38. **Inventing `vulnerability.cve.id` / `vulnerability.cve.ids`** → CVEs live in `vulnerability.references.cve`; use that field for RVA and external `VULNERABILITY_FINDING` correlation.
39. **Ranking hosts by `affected_entity.type == "HOST"`** → RVA attaches to process groups; host context is in `related_entities.hosts.{ids,names}` — expand those.
40. **Assuming one exact `event.provider` per provider** → the name may be in `event.provider` or `product.vendor`; match both with `contains(lower(...))` and discover first. [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type)
41. **Treating RAP as only `DETECTION_FINDING`** → some tenants expose RAP under `SECURITY_EVENT`; use `in(event.type, {"DETECTION_FINDING","SECURITY_EVENT"})` with `product.name == "Runtime Application Protection"`.
42. **KSPM windows/tools for external compliance** → external is `COMPLIANCE_FINDING` over `24h+` with the external taxonomy; the 1h scan-join is KSPM-only.
43. **Passing two args to `countIf`** → one boolean only: `countIf(vulnerability.risk.level == "CRITICAL")`.
44. **Confusing `vulnerability.external_url` with `vulnerability.tracking_link.url`** → `external_url` is the provider reference (NVD/advisory), populated almost always; the user-attached remediation link is `tracking_link.url`. Same for `external_id` (provider id) vs `tracking_link.text`.
45. **Mixing record-level conditionals with aggregations in one `summarize`** → fails with `INVALID_MIX_OF_AGGREGATIONS_AND_OTHER_EXPRESSIONS`. Derive the conditional in a prior `fieldsAdd` (scalar), or use `takeAny()`. For host ranking, merge `affected_entity.id` into `related_entities.hosts.ids` then resolve via Smartscape. [vulnerabilities-entities.md § Most vulnerable hosts](vulnerabilities-entities.md#most-vulnerable-hosts--dt-rva)
46. **`event.status` for compliance status** → `event.status` is a generic event-lifecycle field (`Active`/`Closed`); it is null or wrong on `COMPLIANCE_FINDING` rows. Use `compliance.result.status.level` instead. See [compliance.md](compliance.md).
47. **`"PASS"` / `"FAIL"` enum values, or `!= "PASSED"` negation for failed count** → the canonical enum is `PASSED`, `FAILED`, `MANUAL`, `NOT_RELEVANT`. Count failures with an explicit `countIf(compliance.result.status.level == "FAILED")`; a negation (`!= "PASSED"`) wrongly folds `MANUAL` and `NOT_RELEVANT` into the failed count.
48. **`on: {left.scan.id == right.scan.id}` join syntax** → `left.`/`right.` prefixes are valid inside the join *body* (e.g. `isNull(right.object.id)`) but not inside the `on:` clause for same-named fields. Use the shorthand `on: {scan.id}` when the field name is identical on both sides.
49. **`compliance.rule.standard` (does not exist) / bare `compliance.rule.severity`** → `compliance.rule.standard` has no entry in the Semantic Dictionary — use `compliance.standard.short_name` (or `.name`) for the standard label. Bare `compliance.rule.severity` resolves to nothing; use `compliance.rule.severity.level` (values `CRITICAL` / `HIGH` / `MEDIUM` / `LOW`).
50. **Computing pass rate directly on raw per-`(rule, object)` rows** → raw rows mix multiple objects per rule; pass rate computed at this level over-counts or under-counts. Run the Step 2 per-rule status rollup first (`summarize … by: {compliance.rule.id}`), then derive `passRate` from the per-rule verdict counts. See [compliance.md § Step 2](compliance.md).
51. **Filtering by a vulnerability ID on only one field when the format is unknown** → `vulnerability.display_id` holds `S-XXXX`, `vulnerability.id` holds the internal numeric string (e.g. `7712027161588397174`), and `vulnerability.external_id` holds provider advisory IDs (e.g. `DTV-2026-GO-0001133`, NVD references). Searching only `display_id` silently returns zero rows for DTV/NVD advisories. Use the multi-field OR filter from [vulnerabilities-dynatrace.md § Step 2](vulnerabilities-dynatrace.md#step-2--optional-pre-aggregation-filter-insert-after-step-1-before-step-3).
52. **Using array indexing (`related_entities.hosts.names[0]` / `.ids[0]`) to extract entity names from RVA events** → array indexing grabs only the first element. For a simple list, project the whole array (`related_entities.hosts.names`) directly. For one-row-per-host fanout, use the named-alias expand form `expand related_host.id = related_entities.hosts.ids` + Smartscape lookup. Also: when `affected_entity.type == "HOST"` or `"KUBERNETES_NODE"`, the directly-affected entity is itself a host and may not appear in `related_entities.hosts.*` — always include `affected_entity.*` in the projection. See [vulnerabilities-dynatrace.md § Named entity list for a specific vulnerability](vulnerabilities-dynatrace.md#named-entity-list-for-a-specific-vulnerability).
53. **`iAny(related_entities.hosts.ids[] == "HOST-...")` — wrong DQL for array membership** → `iAny()` with array indexing is not the correct DQL membership operator. Use `in("HOST-...", related_entities.hosts.ids)` for a single value, or `in({"HOST-A","HOST-B"}, related_entities.hosts.ids)` for a set. Always pair with `OR affected_entity.id == "HOST-..."` (or `OR in(affected_entity.id, {...})`): HOST and KUBERNETES_NODE entities can be the directly-affected entity and will not appear in `related_entities.hosts.*` in that case.
54. **Answering *runtime-entity* coverage with a scan-event summary** → for hosts / processes / workloads / cloud resources, counting `VULNERABILITY_SCAN` events shows only the covered set — there is no denominator, so it cannot give a coverage percentage or reveal uncovered entities. Start from `smartscapeNodes` and `lookup` scan events **and** findings. [coverage.md § DT Runtime Coverage Analysis](coverage.md#dt-runtime-coverage-analysis-smartscapenodes). **Non-runtime** entities (container images, code artifacts) have no Smartscape population, so a distinct-object count from scan/finding events *is* the correct answer — there is no percentage. [coverage.md § Non-Runtime Entity Coverage](coverage.md#non-runtime-entity-coverage-images--artifacts)
55. **`finding.time.created` filter for "new this period and not in the prior period"** → that wording is a set comparison between two periods and requires the prior-period **anti-join** (outer join + `isNull(right.…)`). Providers re-report old findings and created timestamps are vendor-relative, so the created-time shortcut answers a different question. [common-patterns.md § 18](common-patterns.md#18-lifecycle--what-counts-as-new--resolved-per-event-family)
56. **`count()` after `expand` / `join` / `lookup` when the grain is not already one-per-identity** → if component or related-entity arrays are collected then expanded *without* a preceding one-row-per-vulnerability grain, post-`expand` rows duplicate each `(vulnerability, group-key)` pair and `count()` inflates rankings 3–50×. Use `countDistinctExact(vulnerability.display_id)` / `countDistinctExact(finding.id)`, or `dedup` on identity + group key before the `summarize`. (`count()` *is* correct when the pipeline already deduped to one row per vulnerability before the `expand` — e.g. the host/workload rankings in [vulnerabilities-entities.md § Resolving RVA entity names via Smartscape](vulnerabilities-entities.md#resolving-rva-entity-names-via-smartscape).)
57. **Grouping external findings by raw `k8s.namespace.name` / `host.name` / `object.name` / cloud resource IDs as "entity mapping"** → names are not unique and skip topology reconciliation. Use the Smartscape join recipes: 3-way match (K8s workloads), host-by-IP, direct `dt.smartscape_source.id` (cloud). `dt-sec-contextualization/references/entity-enrichment.md`
58. **Parsing or querying `compliance.rule.metadata_json`** → do not use this field; it is forbidden in this skill. Use `compliance.rule.id` (e.g. `CIS-2762`, `STIG-82824`, `DORA-67952`, `NIST-82827`) and `compliance.rule.title` for rule identity instead. The field exists in the data as a standard-specific JSON blob but must never be accessed.
59. **Using any keyword/name/library heuristic as an AI-workload proxy when no GENAI_SERVICE entities are found** → when `smartscapeNodes "GENAI_SERVICE"` returns zero rows, do not run any further queries. No substitute is valid: not entity-name substring matching (`"ai"`, `"llm"`, `"ml"`, `"genai"`, `"model"`, `"inference"`, `"copilot"`, etc.), not component-library matching (torch, tensorflow, langchain, openai, etc.), not K8s namespace/label patterns, not process-name filters. All of these produce false positives and false negatives. Zero GENAI_SERVICE rows means no monitored AI workloads — state that and stop. See [vulnerabilities-dynatrace-advanced.md § Prerequisite: confirm GenAI entities exist](vulnerabilities-dynatrace-advanced.md#prerequisite-confirm-genai-entities-exist).
---
## Best Practices
1. **Start with the canonical window** — RVA `from:now()-30m`, SPM
`from:now()-1h`, detections / cross-provider `from:now()-2h` (widen only
when the 2h detection query returns zero rows — see
[detections.md § Widen-on-empty fallback](detections.md#widen-on-empty-fallback-retrieval-queries)).
Widen for other event types only when the question explicitly demands history.
2. **Use shortened runtime-assessment status names in output** —
`vulnerability.exposure.status`, `vulnerability.exploit.status`,
`vulnerability.vulnerable_function.status`, `vulnerability.data_assets.status`
— derived in Stage 3 from the raw `vulnerability.davis_assessment.*_status`
fields.
3. **Use `dt.smartscape.*` for new Smartscape lookups** — `dt.entity.*` is
deprecated for Smartscape navigation (classic entity IDs like `dt.entity.host`
remain valid as identifiers).
4. **Coalesce repository fields** —
`coalesce(artifact.repository, container_image.repository)` for external
container scanners. See
[common-patterns.md § 12](common-patterns.md#12-repository--artifact-coalescing).
5. **Use `arraySize()` not `size()`; `lower()` not `toLowercase()`** — DQL
constraints, see `dt-dql-essentials`.
6. **`arraySlice` requires named parameters** — `arraySlice(arr, from: 0, to: N)` is
correct; positional form `arraySlice(arr, 0, N)` fails with
`TOO_MANY_POSITIONAL_PARAMETERS_WITH_OPTIONS`.
7. **`collectDistinct` has no `limit:` parameter** — wrap the call:
`arraySlice(collectDistinct(field), from: 0, to: N)`.
8. **Python-style slice `arr[0:N]` is rejected inside `summarize`** — use
`arraySlice(...)` in the summarize expression or in a follow-up `fieldsAdd`.
9. **`count()` must be aliased to be referenced downstream** —
`summarize total = count() | sort total desc` works;
`summarize count() | sort count() desc` fails.
10. **Always split mute status when reporting open vulnerabilities** —
`Open NOT_MUTED`, `Open MUTED`, `Resolved`. Total counts alone are
misleading.
---
## Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Open-vulnerability count is unexpectedly low | Muted vulnerabilities are filtered out by default in some workflows; the canonical pattern keeps them but separates them in reporting | Split by mute status; report MUTED separately. See [common-patterns.md § 3](common-patterns.md#3-mute-status-separated-count-canonical-reporting) |
| Compliance pass rate is 0% | The query filtered to FAILED rows only, so PASSED isn't in the denominator | Include all statuses (`PASSED`, `FAILED`, `MANUAL`) and exclude `NOT_RELEVANT`. See [compliance.md](compliance.md) |
| External finding details missing the affected entity | External findings reach Smartscape via 3-way match — only one path may have populated for the row | Use the 3-way enrichment query from `dt-sec-contextualization/references/entity-enrichment.md` |
| Query times out on long time ranges | Raw field selection over a large window — no summarization to bound output size | Add a `summarize` block, shorten the time range, or apply pre-aggregation filters earlier |
| Drill-down by `finding.id` returns nothing | Default time range may be too narrow, or the ID format is wrong for that provider | Widen the time range; verify the exact ID format (UUID for Dynatrace, ARN for AWS, hex hash for AutomationEngine) |
| External compliance group-by `compliance.rule.id` returns null | External compliance findings don't populate `compliance.rule.*` | Group by `compliance.standards` (expand) / `compliance.policy` / `compliance.control`, with `finding.title` / `finding.type` for display |
| RVA snapshot missing a recent vulnerability state change | RVA cycle is ~15m; widening past 30m doesn't help | Wait for the next cycle; or query `VULNERABILITY_STATUS_CHANGE_EVENT` history outside the 30m window |
| Object's compliance findings are missing from results | No `COMPLIANCE_SCAN_COMPLETED` for that object within the 1h window — the inner join drops it | Object wasn't scanned in the last cycle. By design, not a bug. Don't widen beyond 1h to work around this. |
| Coverage query uses `VULNERABILITY_COVERAGE_REPORT_EVENT` | Deprecated event type | Use `VULNERABILITY_SCAN` instead — see [coverage.md](coverage.md) |
| Some expected findings are missing from the T&E or Vulnerabilities apps | The apps require all SD-required fields (`event.id`, `event.provider`, `finding.type`, `finding.id`, `finding.time.created`, `finding.title`, `dt.security.risk.level`, `object.id`, `object.type`). Findings missing any are filtered out. | Run the same query *without* the SD-compliance filter to see which fields are missing. See [detections.md § Threats & Exploits (T&E) App Compatibility](detections.md#threats--exploits-te-app-compatibility) |
| Cross-provider count includes Dynatrace state-report rows multiple times | Missing the double-counting guard | Add `filter product.vendor != "Dynatrace" OR event.type == "DETECTION_FINDING"` |
| `vulnerability.parent.*` filter behaves unexpectedly | The entire `vulnerability.parent.*` namespace is deprecated | Derive every vulnerability-level value from per-entity fields/arrays in Stage 3 — verdicts via `collectDistinct(...)` + `in(...)`, scalars via `takeMin/takeMax/takeFirst` |
| `filter vulnerability.stack == "THIRD_PARTY"` matches nothing | Wrong enum value | Use `in(vulnerability.stack, array("CODE_LIBRARY","SOFTWARE"))` for third-party; `=="CODE"` for CLV |
| `affected_entity.vulnerable_functions` is empty for IN_USE rows | Per-language vulnerable function reporting feature is disabled on the OneAgent | Enable the feature in OneAgent settings; until then trust the status flag but don't expect FQCN detail |
| `affected_entity.affected_processes.count` is 0 on a HOST/KUBERNETES_NODE row | These fields are populated only when `affected_entity.type == "PROCESS_GROUP"` | Filter to PG entities for process-level rollups; for host-level use `affected_entity.id` directly |
| DSS (`vulnerability.risk.score`) seems to exceed CVSS base score | Reading the wrong field, or comparing per-entity vs. vulnerability-level | DSS modifiers can only reduce CVSS; if values diverge, you're likely projecting raw `vulnerability.cvss.base_score` against post-aggregation `vulnerability.risk.score` |
| All CLV findings show score 10.0 | This is correct — CLV always scores Critical | Don't filter CLV by runtime-assessment modifiers; use `vulnerability.code_location.name` and `affected_entity.id` to drill |
| `filter compliance.standard.short_name == "PCI"` returns nothing on DT-native data | KSPM only emits `CIS` / `DORA` / `NIST` / `DISA STIG` | PCI/ISO/HIPAA/GDPR arrive via CSPM/VSPM or external; remove the `product.vendor == "Dynatrace"` filter and use cross-provider routing |
| `filter compliance.standard.short_name == "STIG"` returns nothing | The KSPM short_name is the full `"DISA STIG"` — bare `"STIG"` doesn't match anything | Use `contains(lower(compliance.standard.short_name), "stig")` (or exact `== "DISA STIG"`). Same caveat applies to other multi-word labels |
| `compliance.rule.id` / `compliance.rule.title` are null for some compliance rows | Those rows are external (CSPM/VSPM or other posture tools) | KSPM patterns (Steps 1+2) don't apply; use the external taxonomy — group by `compliance.standards` (expand) / `compliance.policy` / `compliance.control` and `event.provider` |
| Pass rate seems too high — MANUAL counted as pass | MANUAL must be in the denominator only, not numerator | Rebuild as `Passed * 100 / (Passed + Failed + Manual)`; never `Passed * 100 / (Passed + Failed)` |
| Compliance pass rate seems too low — NOT_RELEVANT included | NOT_RELEVANT must be excluded *before* aggregation | Add `filter compliance.result.status.level != "NOT_RELEVANT"` in Step 1 |
| `COMPLIANCE_SCAN_COMPLETED` event missing for some objects | Scan didn't complete in the 1h window for that cluster | Wait for next ActiveGate dataset push (typically hourly), or use a longer window for history (deliberately bypassing the snapshot pattern) |
| `scan.result.summary_json` used for compliance posture | Bypasses the per-rule pipeline; pre-aggregated blob cannot be filtered or broken down by rule/severity; causes a redundant second query | **Do not use `scan.result.summary_json` for posture questions.** Always route through the `COMPLIANCE_FINDING` canonical pipeline (Steps 1+2 in [compliance.md](compliance.md)). |
| Asked "show muted compliance findings" returns confusing results | Compliance has no mute fields | Explain that mute / waiver isn't modeled in `security.events` for compliance — only vulnerabilities have `mute.*` |
| `object.type` filter doesn't match expected K8s objects | Wrong field — `object.type` is uppercase entity type | Use `compliance.result.object.type` for analyzer codes (`k8scluster`, `k8spod`, …) or `object.type` for entity types (`KUBERNETES_CLUSTER`, …) |
| RAP query with `event.provider == "OneAgent"` returns nothing | Likely a non-RAP filtering issue (window too narrow, wrong event.type, etc.) — both `event.provider == "OneAgent"` and `product.name == "Runtime Application Protection"` are populated on current RAP rows. | Switch to the canonical `product.name == "Runtime Application Protection"`; widen the window; verify `event.type == "DETECTION_FINDING"`. |
| `threat.attack.technique.ids == "T1078"` matches no rows | Field is an array | Use `in("T1078", threat.attack.technique.ids)` or `expand technique = threat.attack.technique.ids` |
| Sub-technique IDs like `T1059.003` don't match `threat.attack.technique.ids` | Sub-techniques live in a separate `threat.attack.subtechnique.ids` array | Query the sub-technique array directly, or OR across both arrays for "parent + sub" coverage |
| MITRE techniques missing on RAP / external detections | Only Automated Detections populates `threat.attack.*` | For RAP, map `finding.type` → MITRE manually; for external, parse `dt.raw_data` if the provider includes MITRE in its raw payload |
| Asked "show muted detections" returns confusing results | Detections have no mute namespace | Explain: detections aren't lifecycle-tracked in `security.events`; suppression is UI-side or ingest-side |
| Rule-execution count includes both findings and summary rows | Mixed event types | Filter `event.type == "DETECTION_EXECUTION_SUMMARY"` only; findings counts go through `DETECTION_FINDING` |
| Block-vs-monitor breakdown uses `event.outcome` and is mostly null | Wrong field | Use `finding.action` (`Blocked` / `Audited` / `Allowlisted`) for RAP |
| Top-attacker query returns null/empty for IP, or mismatched comparisons against other IP fields | Wrong field name, or array not cast to `ip()` | Use `actor.ips` (plural, `ipAddress[]`) — `actor.ip`/`actor.location` don't exist in the SD. `expand actor.ips` then `fieldsAdd ip = ip(actor.ips)`; project `actor.geo.country.name` for geo. Reputation enrichment (AbuseIPDB / VirusTotal) is client-side in the Threats & Exploits app, not in DQL rows |
| `event.status == "PASS"` (or `"FAIL"`) matches nothing on compliance rows | `event.status` is a generic lifecycle field; wrong field for compliance verdicts | Replace with `compliance.result.status.level == "PASSED"` (or `"FAILED"`, `"MANUAL"`, `"NOT_RELEVANT"`). See [compliance.md](compliance.md) |
| Compliance `countIf(... != "PASSED")` over-counts failed rules | Negation includes `MANUAL` and `NOT_RELEVANT` in the failed count | Use an explicit `countIf(compliance.result.status.level == "FAILED")` |
| KSPM join with `on: {left.scan.id == right.scan.id}` fails or returns unexpected columns | `left.`/`right.` prefixes are not valid in `on:` | Use the shorthand `on: {scan.id}` (DQL join shorthand when the field name matches on both sides) |
| Pass rate from KSPM query is wrong (each object inflates the rule count) | Pass rate computed on raw per-`(rule, object)` rows before Step 2 rollup | Apply the Step 2 per-rule summarize first; compute `passRate` from the per-rule verdict counts. See [compliance.md § Step 2](compliance.md) |
references/threat-intelligence.md
# Threat Intelligence Queries — `security.events`
Threat intelligence report events (`event.type == "THREAT_REPORT"`) ingested from external
threat-intelligence platforms — **AlienVault OTX** (LevelBlue) pulses and **CrowdStrike Falcon
Intelligence** reports today, extensible to STIX/TAXII feeds. One event per published report:
report identity + provenance (`threat.report.*`), adversary context (`threat.actor.*`,
`threat.target.*`, `threat.malware.*`, `threat.attack.*`), and extracted indicators of compromise
(`threat.observables.*`).
> **Cross-references:** field reference → [data-model.md § Threat Intelligence Fields](data-model.md#threat-intelligence-fields-threat_report) · provider-scoping idiom → [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type) · dashboard/tile recipes → [coverage-and-dashboards.md § Dashboard Query Patterns](coverage-and-dashboards.md#dashboard-query-patterns).
> **THREAT_REPORT is NOT a finding.** These events describe threats **in the wild** — external
> campaigns, adversary reports, IOC feeds — **not** findings on your monitored entities. They carry
> **no `finding.*`, no `object.*`, no `dt.security.risk.level`/`score`, no affected entity, and no
> scan cycle**. Do **not**:
> - put THREAT_REPORT in the cross-provider four-key `summarize` (`{event.provider, product.name, event.type, dt.security.risk.level}`) — the risk field is always null here;
> - include it in the DT-inclusive posture/overview 3-stream decomposition or the double-counting guard;
> - scope it by entity (`dt.smartscape*` / `dt.entity*` / `object.*` are all absent).
>
> Threat intelligence answers a **separate class of question** ("what threats/IOCs/campaigns are
> being reported?", "am I exposed to the IOCs in report X?") — route here only for those intents.
> **MITRE here vs. in detections.** `threat.attack.*` on a THREAT_REPORT describes the ATT&CK
> techniques of an **external campaign reported by a TI vendor** — it is NOT evidence the technique
> was observed in your environment. For ATT&CK observed on your own entities, use
> [detections.md § Automated Detections — MITRE ATT&CK Workflows](detections.md#automated-detections--mitre-attck-workflows).
> **Reports are re-ingested on update — always dedup by `threat.report.id`.** A report gets a fresh
> event each time the vendor revises it. Every query below starts from the canonical base
> (SD-compliance guard + `dedup {threat.report.id}, sort:{timestamp desc}`) so counts reflect one
> row per report (latest version), not one row per ingestion.
> **No snapshot window.** Unlike RVA (30m) / KSPM (1h), threat intel has no snapshot semantics —
> events accumulate. Use `from:now()-24h` for "recent reports", `from:now()-7d` (or wider) for
> overviews and IOC rollups. Honor an explicit user window.
---
## Contents
- [Provider Routing](#provider-routing)
- [Canonical Base (SD guard + dedup)](#canonical-base-sd-guard--dedup)
- [Overview & Summary](#overview--summary)
- [Latest threat intelligence reports (list)](#latest-threat-intelligence-reports-list)
- [Unique reports by provider / product](#unique-reports-by-provider--product)
- [Reports over time (trend)](#reports-over-time-trend)
- [Indicators of Compromise (IOC extraction)](#indicators-of-compromise-ioc-extraction)
- [Filtering Reports](#filtering-reports)
- [By MITRE technique](#by-mitre-technique)
- [By actor / malware family](#by-actor--malware-family)
- [By targeted country / industry](#by-targeted-country--industry)
- [By TLP (AlienVault) / report type (CrowdStrike)](#by-tlp-alienvault--report-type-crowdstrike)
- [Free-text search (title / tags)](#free-text-search-title--tags)
- [Threat-Exposure Correlation (IOC → environment)](#threat-exposure-correlation-ioc--environment)
- [CVEs → vulnerabilities](#cves--vulnerabilities)
- [Attacker IPs → detections](#attacker-ips--detections)
- [MITRE techniques → detections](#mitre-techniques--detections)
- [IOCs → logs and spans](#iocs--logs-and-spans)
- [Field Nuances & Vendor Extensions](#field-nuances--vendor-extensions)
- [Best Practices](#best-practices)
---
## Provider Routing
| Source | Filter |
|---|---|
| All threat intelligence reports | `event.type == "THREAT_REPORT"` |
| AlienVault OTX (LevelBlue) | `event.type == "THREAT_REPORT" AND event.provider == "AlienVault OTX"` |
| CrowdStrike Falcon Intelligence | `event.type == "THREAT_REPORT" AND event.provider == "CrowdStrike"` (`product.name == "Falcon Intelligence"`) |
| A specific provider (generic) | discover first, then exact-match — see [all-security-events.md § Scoping to a Specific Provider](all-security-events.md#scoping-to-a-specific-provider-any-finding-type) |
Threat-intel providers are **not** enumerated beyond the two above — discover active providers with
the [Unique reports by provider / product](#unique-reports-by-provider--product) query and scope by
exact `event.provider` match.
---
## Canonical Base (SD guard + dedup)
Every query starts from this base. The `filterOut` guard keeps only events that satisfy the SD
required set for THREAT_REPORT (`event.provider`, `product.name`, `threat.report.id`,
`threat.report.name`); the `dedup` collapses re-ingested revisions to the latest per report.
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
// keep only SD-compliant reports
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
// latest version of each report (reports re-ingest on update)
| dedup {threat.report.id}, sort:{timestamp desc}
```
Subsequent snippets shown as `| …` are appended to this base.
---
## Overview & Summary
### Latest threat intelligence reports (list)
```dql
fetch security.events, from:now()-24h
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| fields Created = coalesce(toTimestamp(threat.report.time.created), timestamp),
Updated = coalesce(toTimestamp(threat.report.time.updated), timestamp),
event.provider, product.name, threat.report.name,
threat.actor.names, threat.report.author
| sort Created desc
| limit 10
```
`threat.report.time.created` / `.updated` are `timestamp`-typed but may be null on some rows —
`coalesce(toTimestamp(...), timestamp)` falls back to the ingestion timestamp.
### Unique reports by provider / product
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| summarize reports = count(), by: {event.provider, product.name}
| sort reports desc
```
### Reports over time (trend)
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| makeTimeseries count(), by: {event.provider}
```
---
## Indicators of Compromise (IOC extraction)
Observables are typed arrays (`threat.observables.{ips,domains,urls,emails,cves,hashes.md5,hashes.sha1,hashes.sha256}`).
The idiom is: dedup reports → `expand` the observable → count **distinct reports** per value (never a
raw `count()` — one report contributes many observables, and `expand` fans out rows).
**Top CVEs referenced across reports:**
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| filter arraySize(threat.observables.cves) > 0
| expand CVE = threat.observables.cves
| summarize Reports = countDistinctExact(threat.report.id), by: {CVE}
| sort Reports desc
| limit 10
```
**Top attacker IPs** — swap `CVE = threat.observables.cves` for `IP = threat.observables.ips`
(and the `arraySize` guard field accordingly). The same shape covers **domains**
(`threat.observables.domains`), **URLs** (`threat.observables.urls`), **emails**
(`threat.observables.emails`), and **file hashes** (`threat.observables.hashes.sha256` /
`.md5` / `.sha1`):
```dql-snippet
| expand IP = threat.observables.ips
| filterOut isNull(IP)
| summarize Reports = countDistinctExact(threat.report.id), by: {IP}
| sort Reports desc
| limit 10
```
**Observable count by type** (single-row coverage summary):
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| summarize {
CVEs = sum(coalesce(arraySize(threat.observables.cves), 0)),
IPs = sum(coalesce(arraySize(threat.observables.ips), 0)),
Domains = sum(coalesce(arraySize(threat.observables.domains), 0)),
URLs = sum(coalesce(arraySize(threat.observables.urls), 0)),
Emails = sum(coalesce(arraySize(threat.observables.emails), 0)),
Hashes = sum(coalesce(arraySize(threat.observables.hashes.sha256), 0)),
Techniques = sum(coalesce(arraySize(threat.attack.technique.ids), 0))
}
```
---
## Filtering Reports
### By MITRE technique
`threat.attack.technique.ids` / `.subtechnique.ids` / `.tactic.ids` are **separate arrays** (a
sub-technique ID like `T1110.001` lives only in `subtechnique.ids`).
```dql
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(event.provider) OR isNull(product.name) OR isNull(threat.report.id) OR isNull(threat.report.name)
| dedup {threat.report.id}, sort:{timestamp desc}
| filter in("T1110", threat.attack.technique.ids)
| fields threat.report.name, threat.actor.names, event.provider, threat.attack.technique.ids
| sort threat.report.name asc
| limit 50
```
For a **parent technique + all its sub-techniques**, OR across both arrays:
```dql-snippet
| filter in("T1110", threat.attack.technique.ids)
OR iAny(startsWith(threat.attack.subtechnique.ids[], "T1110."))
```
**Coverage breakdown by technique** — `expand Technique = threat.attack.technique.ids` then
`summarize Reports = countDistinctExact(threat.report.id), by:{Technique} | sort Reports desc`.
### By actor / malware family
```dql-snippet
| filter in("CURLY SPIDER", threat.actor.names)
```
Or rank actors/families by report count — `expand Actor = threat.actor.names` (or
`Family = threat.malware.families`), then `summarize Reports = countDistinctExact(threat.report.id), by:{Actor}`.
### By targeted country / industry
Targeting uses `threat.target.countries.names` (full names), `threat.target.countries.iso_codes`
(ISO 3166-1 alpha-2 — use for choropleth maps), and `threat.target.industries` (**plural**).
```dql-snippet
| expand ISO = threat.target.countries.iso_codes
| filterOut isNull(ISO)
| summarize Reports = countDistinctExact(threat.report.id), by: {ISO}
| sort Reports desc
```
> **Field-name gotchas:** the SD-canonical fields are `threat.target.countries.names` /
> `.iso_codes` and `threat.target.industries` (plural). A bare `threat.target.countries` is **not**
> an SD field, and `threat.target.industry` (singular) does not exist — filtering on either matches
> nothing. Some producers omit `.names`; `coalesce(threat.target.countries.names, threat.target.countries)`
> is a defensive fallback only if you know a producer populated the non-standard bare field.
### By TLP (AlienVault) / report type (CrowdStrike)
Vendor-extension fields (no SD counterpart — see [§ Field Nuances](#field-nuances--vendor-extensions)):
```dql-snippet
| filter alienvault.pulse.tlp == "WHITE" // AlienVault OTX: WHITE | GREEN
```
```dql-snippet
| filter crowdstrike.report.type.name == "Notice" // CrowdStrike: Notice | Tipper | Periodic Report | Intelligence Report | Recon+
```
### Free-text search (title / tags)
```dql-snippet
| filter matchesValue(threat.report.tags, "*ransomware*") OR contains(threat.report.name, "ransomware", caseSensitive:false)
```
`threat.report.tags` is an array — use `matchesValue()` for it; `threat.report.name` is a string —
use `contains(field, "…", caseSensitive:false)` for case-insensitive substring match. **The
`caseSensitive` argument must be named** (`caseSensitive:false`) — a bare positional third argument
(`contains(field, "…", false)`) is rejected as `TOO_MANY_POSITIONAL_PARAMETERS`.
---
## Threat-Exposure Correlation (IOC → environment)
The high-value use case: take the IOCs/CVEs/techniques from threat reports and check whether they
appear in **your** monitored environment. Because the two sides live in different event families (or
different datasets), correlate with **`join` on a shared key**, not `in(x, [subquery])` (an
execution block is not a valid `in()` argument). Dashboards pre-compute the IOC list into a variable
and use `in(field, $Var)`; a self-contained query uses `join`.
> **Reverse direction (IoC → report attribution) lives elsewhere.** The queries here go report →
> environment. For the inverse — you already matched an IoC and want *"which report(s) mention it,
> attributed to which actor/malware/campaign?"* — use
> `dt-sec-contextualization/references/ioc-enrichment.md`.
### CVEs → vulnerabilities
"Am I running anything with a CVE named in recent threat intel?" — join report CVEs to
`VULNERABILITY_FINDING` (external scanners) on the CVE:
```dql
fetch security.events, from:now()-7d
| filter event.type == "VULNERABILITY_FINDING"
| filter isNotNull(vulnerability.references.cve)
| expand cve = vulnerability.references.cve
| join [
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| filterOut isNull(threat.report.id)
| expand cve = threat.observables.cves
| filterOut isNull(cve)
| summarize reports = countDistinctExact(threat.report.id), reportNames = collectDistinct(threat.report.name), by: {cve}
], on: {cve}, kind: inner, prefix: "tr."
| summarize {
findings = count(),
affectedObjects = countDistinctExact(object.id)
}, by: {cve, event.provider}
| sort findings desc
```
For **Dynatrace RVA** matches, run the canonical RVA 30m snapshot pipeline (see
[vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md)) and `filter in(vulnerability.references.cve, <cveList>)`,
or join the RVA stream on `vulnerability.references.cve` the same way.
### Attacker IPs → detections
"Have any IPs flagged in threat intel shown up as attackers?" — join report IPs to
`DETECTION_FINDING` `actor.ips` on a shared string key:
```dql
fetch security.events, from:now()-7d
| filter event.type == "DETECTION_FINDING"
| filter isNotNull(actor.ips)
| expand ioc = actor.ips
| fieldsAdd ioc = toString(ioc)
| join [
fetch security.events, from:now()-7d
| filter event.type == "THREAT_REPORT"
| expand ioc = threat.observables.ips
| filterOut isNull(ioc)
| fieldsAdd ioc = toString(ioc)
| summarize reportNames = collectDistinct(threat.report.name), by: {ioc}
], on: {ioc}, kind: inner, prefix: "tr."
| summarize matchedDetections = count(), matchedIPs = countDistinctExact(ioc),
reports = collectDistinct(tr.reportNames)
| fieldsRemove reports // drop if you want the report names projected
```
Zero matches is a valid, meaningful answer ("none of the reported IPs have attacked us in this
window") — report it truthfully with the window used.
### MITRE techniques → detections
Same join shape, keyed on the technique ID: expand `threat.attack.technique.ids` on the report side
and `threat.attack.technique.ids` on the Automated-Detections side (`event.provider == "Dynatrace
Automated Detections"`), join `on:{technique}`.
### IOCs → logs and spans
Report IOCs can also be searched in **logs** and **spans** — these are different datasets
with their own validated hunt templates. Route to **`dt-sec-ioc-hunting`** for IoC-specific
hunting across both sources.
- **Logs** — literal `matchesPhrase(content, "<ioc>")` OR-chain prefilter (one clause per IoC), then per-class matched-observable derivation using `contains` after that prefilter. Supports IPs, Domains, URLs, Emails, and Hashes.
See `dt-sec-ioc-hunting/references/hunt-logs.md`.
- **Spans (inbound + outbound)** — single combined pass over `span.kind in {client,server}`;
matches IPs on `client.ip`/`server.resolved_ips`/`request_attribute.SourceIP` and
Domains/URLs on `http.host`/`url.full`. See `dt-sec-ioc-hunting/references/hunt-spans.md`.
Keep the log/span window tight (default `from:now()-15m`, unless the user explicitly asks for a wider window) and pre-filter the IOC list. The
`dt-sec-ioc-hunting` skill owns the expand-on-approval protocol for widening.
For **generic** log or trace queries not tied to an IoC hunt, route to `dt-obs-logs` /
`dt-obs-tracing` instead.
---
## Field Nuances & Vendor Extensions
- **Timestamps:** `threat.report.time.created` / `.updated` are `timestamp`-typed; use
`coalesce(toTimestamp(...), timestamp)` to fall back to ingestion time when null.
- **Targeting:** `threat.target.countries.names` / `.iso_codes` / `threat.target.industries`
(plural). Avoid bare `threat.target.countries` and singular `threat.target.industry` — not SD fields.
- **Observable counts after `expand`:** always `countDistinctExact(threat.report.id)`, never `count()`.
- **Vendor extensions (additive — no SD counterpart, keep as-is):**
- AlienVault OTX: `alienvault.pulse.public` (boolean), `alienvault.pulse.tlp` (`WHITE` | `GREEN`).
- CrowdStrike: `crowdstrike.report.slug`, `crowdstrike.report.type`, `crowdstrike.report.type.id`,
`crowdstrike.report.type.name` (`Notice` | `Tipper` | `Periodic Report` | `Intelligence Report` | `Recon+`).
- **Raw payload:** `event.original_content` carries the vendor's original report JSON (extension/pull
integrations); parse with `parse event.original_content, "JSON:raw"` for fields not normalized to SD.
---
## Best Practices
1. **Always dedup by `threat.report.id`** (`sort:{timestamp desc}`) — reports re-ingest on update;
without dedup, counts and IOC rollups double-count revisions.
2. **Apply the SD-compliance guard** (`filterOut isNull(event.provider) OR isNull(product.name) OR
isNull(threat.report.id) OR isNull(threat.report.name)`) so partial/non-conformant rows don't skew results.
3. **Never use finding/entity/risk fields** — `finding.*`, `object.*`, `dt.security.risk.level`,
`dt.smartscape*`/`dt.entity*` are all null on THREAT_REPORT. Do not add them to filters, `by:`, or projections.
4. **Keep threat intel out of the posture/overview streams** — it is a separate intent; a broad
"what security data do we have?" must not fold in THREAT_REPORT (see the top-of-file callout).
5. **Parent + sub-techniques require an OR** across `threat.attack.technique.ids` and `threat.attack.subtechnique.ids` — they are separate arrays.
6. **Count distinct reports after `expand`** — `countDistinctExact(threat.report.id)`, not `count()`.
7. **No snapshot window** — use `24h` for recent, `7d`+ for overviews; honor explicit windows. Threat intel accumulates; widening looks back further (unlike RVA/KSPM).
8. **Correlate with `join` on a shared IOC/CVE/technique key** — not `in(x, [subquery])`. Cross-dataset IoC hunts in logs/spans route to `dt-sec-ioc-hunting`; generic log/trace exploration routes to `dt-obs-logs` / `dt-obs-tracing`.
9. **Vendor extensions (`alienvault.pulse.*`, `crowdstrike.report.*`) are valid additive context** — use them for TLP / report-type filters; they have no SD counterpart.
10. **Report empty correlation results truthfully** — "none of the reported IOCs appear in your environment in the last X" is a real, useful answer; never fabricate matches.
references/vulnerabilities-dynatrace-advanced.md
# Dynatrace Vulnerabilities — Advanced Workflows
Additional guidance for Dynatrace-native vulnerabilities:
- extended best practices
- AI-workload findings (`VULNERABILITY_FINDING`) scoped to GenAI services
## Contents
- [Best Practices](#best-practices)
- [AI-workload vulnerabilities (Dynatrace findings)](#ai-workload-vulnerabilities-dynatrace-findings)
- [Prerequisite: confirm GenAI entities exist](#prerequisite-confirm-genai-entities-exist)
- [DT findings base](#dt-findings-base)
- [UC-AI1 - Which AI services/processes have vulnerabilities](#uc-ai1---which-ai-servicesprocesses-have-vulnerabilities)
- [UC-AI2 - New AI-workload vulnerabilities this window](#uc-ai2---new-ai-workload-vulnerabilities-this-window)
- [Coverage](#coverage)
---
## Best Practices
1. **Always use the three-event-type union for RVA snapshot queries** (`vulnerabilities-dynatrace.md`) — state reports alone miss transitions. AI-workload queries (UC-AI1 / UC-AI2) use `VULNERABILITY_FINDING` — the three-event union does **not** apply to them.
2. **Dedup on the composite key** `{vulnerability.display_id, affected_entity.id}` - deduping on either field alone corrupts aggregates.
3. **Derive vulnerability-level status in Step 3** - never filter `vulnerability.resolution.status == "OPEN"` before the `fieldsAdd` when answering a vulnerability-level question; the pre-derived field is per-entity. Filtering pre-Stage-3 is fine when the question is genuinely per-entity.
4. **Use shortened runtime-assessment names in Step 3+** - `vulnerability.exposure.status`, `vulnerability.exploit.status`, `vulnerability.vulnerable_function.status`, `vulnerability.data_assets.status`.
5. **Prefer Dynatrace runtime assessments for triage** - `vulnerability.risk.level` is contextual and `vulnerability.risk.score` never exceeds `vulnerability.cvss.base_score`.
6. **`vulnerability.stack` is `CODE` / `CODE_LIBRARY` / `SOFTWARE` / `CONTAINER_ORCHESTRATION`** - not `THIRD_PARTY` / `FIRST_PARTY` / `CODE_LEVEL`.
7. **CLV (`stack=="CODE"`) always scores 10.0** - use `vulnerability.code_location.name` to drill to source.
8. **Exposure precedence is `PUBLIC_NETWORK > NOT_AVAILABLE > NOT_DETECTED`**. Query raw `vulnerability.davis_assessment.exposure_status` for adjacent-network analysis.
9. **`NOT_AVAILABLE` outranks `NOT_DETECTED` / `NOT_IN_USE`** in runtime-assessment precedence.
10. **Keep the summarize block lean** - include only fields required by the user question.
11. **`affected_entity.vulnerable_component.name` is singular per entity**, but collected into plural arrays at vulnerability level.
12. **`vulnerability.parent.*` is deprecated** - do not use it. Use non-deprecated fields and derivations from Step 3.
13. **Mute metadata is per-entity** - do not collapse `mute.reason`, `mute.user`, `mute.comment`, `mute.change_date` to vulnerability-level.
14. **Simple count questions use one summarize with `countIf`** - avoid adding unrequested grouping dimensions.
---
## AI-workload vulnerabilities (Dynatrace findings)
The base RVA pipeline in `vulnerabilities-dynatrace.md` covers state reports.
These workflows use Dynatrace-generated `VULNERABILITY_FINDING` rows scoped to
AI/GenAI workloads.
**Query conventions:**
- **Window `from:now()-30m`** to capture the latest scan cycle.
- **DT-generated findings re-emit every scan run (~15 min)** for all scanned
processes, hosts, and K8s nodes. Treat the 30m window as the current-cycle
snapshot and **dedup on `finding.id`**.
- **DT provenance**: `event.provider == "Dynatrace" OR product.vendor == "Dynatrace"`.
- **AI scoping path**: `GENAI_SERVICE -> SERVICE -> PROCESS` from
`dt-sec-contextualization/references/identity-mapping.md` (Path 4 in Mapping Primitive section).
- **New AI-workload vulnerabilities require a prior-window anti-join** on the
scoped identity (for example `{genai_service.id, vulnerability.id}`); do not
use `finding.time.created` alone, because every scan cycle re-reports the
current finding set.
- **Library-vulnerability scope**: UC-AI1 and UC-AI2 are scoped to
`product.feature == "Library Vulnerability Analytics"` findings. Code-level
vulnerabilities on AI workloads are a separate use case, not yet templated here.
## Prerequisite: confirm GenAI entities exist
Before running any AI-workload vulnerability query, probe for `GENAI_SERVICE` entities
in Smartscape. The UC-AI1 and UC-AI2 templates use an **inner join** on those entities,
so they return zero rows whenever the topology is absent — which is indistinguishable
from "no vulnerabilities" without this check.
```dql
smartscapeNodes "GENAI_SERVICE", from:now()-2h
| fields id, name
| limit 10
```
**Empty-state branches — never skip this check:**
| Result | Meaning | Required response |
|---|---|---|
| Zero rows | No GenAI services are registered in Smartscape — Dynatrace is not monitoring any AI/GenAI workload in this environment | State: "No GenAI-monitored services are registered in Smartscape, so AI-workload vulnerabilities cannot be determined. Enable Dynatrace AI Observability to start monitoring GenAI workloads." **Stop here. Do not run any further queries or alternative approaches.** |
| One or more rows | AI workloads are monitored | Run UC-AI1/UC-AI2 normally. If the findings join then returns zero, report: "GenAI services are registered in Smartscape, but no vulnerabilities were detected on them in the current window." |
**Prohibited fallbacks — do not substitute any keyword-based or heuristic approach.** When
the `GENAI_SERVICE` probe returns zero rows, the answer is "cannot be determined" — full stop.
None of the following are valid substitutes and all must be rejected outright:
- Filtering `affected_entity.name`, `object.name`, `k8s.workload.name`, `k8s.namespace.name`,
or any name field for AI-related substrings (`"ai"`, `"llm"`, `"genai"`, `"model"`,
`"inference"`, `"copilot"`, `"ml"`, etc.)
- Matching vulnerable component names against known ML/AI library lists (`torch`,
`tensorflow`, `keras`, `transformers`, `langchain`, `openai`, `anthropic`, etc.)
- Filtering process names, image names, or labels for AI-related patterns
- Any other name-based, tag-based, or keyword-based proxy for AI-workload scoping
These approaches produce false positives (e.g. `rsva` contains `"ai"`; any service
named `"email"` contains `"ml"`) and false negatives (AI workloads with neutral names).
AI-workload scope **must** flow exclusively through the `GENAI_SERVICE` Smartscape
topology. If that topology is empty, there are no monitored AI workloads — say so and stop.
---
## DT findings base
```dql
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_FINDING"
AND (event.provider == "Dynatrace" OR product.vendor == "Dynatrace")
AND dt.smartscape_source.type == "PROCESS"
| dedup {finding.id}, sort:{timestamp desc}
```
## UC-AI1 - Which AI services/processes have vulnerabilities
Use a **lazy two-step pattern** to minimize DQL executions: always run Step 1; run Step 2 only when the user explicitly asks for a per-service breakdown.
### Step 1 — Always run: overall totals + service list (one query)
Resolve names before the `summarize` so both the severity totals and the list of affected service names are returned in a single row.
```dql
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_FINDING"
AND (event.provider == "Dynatrace" OR product.vendor == "Dynatrace")
AND product.feature == "Library Vulnerability Analytics"
AND isNotNull(finding.id) AND isNotNull(vulnerability.id)
AND isNotNull(dt.smartscape.process)
| dedup {finding.id}, sort:{timestamp desc}
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="GENAI_SERVICE"
| fields genai_service.id=source_id, service.id=target_id
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="SERVICE"
| filter target_type=="PROCESS"
| fields process.id=target_id, service.id=source_id
], on:{service.id}, fields:{process.id}
], on:{left[dt.smartscape.process]==right[process.id]}, kind:inner, fields:{genai_service.id}
| lookup [ smartscapeNodes "GENAI_SERVICE", from:-2h ],
sourceField:genai_service.id, lookupField:id, fields:{genai_service.name=name}
| summarize {
total_vulnerabilities = countDistinctExact(vulnerability.id),
critical = countDistinctExact(if(dt.security.risk.level == "CRITICAL", vulnerability.id, else: null)),
high = countDistinctExact(if(dt.security.risk.level == "HIGH", vulnerability.id, else: null)),
medium = countDistinctExact(if(dt.security.risk.level == "MEDIUM", vulnerability.id, else: null)),
low = countDistinctExact(if(dt.security.risk.level == "LOW", vulnerability.id, else: null)),
affected_genai_services = countDistinctExact(genai_service.id),
affected_processes = countDistinctExact(dt.smartscape.process),
service_names = collectDistinct(genai_service.name)
}
| fieldsAdd service_names = arraySort(service_names)
```
**Answer format (Step 1):**
1. Summary sentence: "X unique vulnerabilities across N GenAI services (C critical / H high / M medium / L low), spanning P processes."
2. Service list: "Affected services: [values from `service_names`]."
### Step 2 — On demand only: top 10 services with per-severity breakdown
Run this query only when the user explicitly asks for a per-service breakdown or ranking (e.g. "which services are most affected?", "show me the top services", "break it down by service").
```dql
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_FINDING"
AND (event.provider == "Dynatrace" OR product.vendor == "Dynatrace")
AND product.feature == "Library Vulnerability Analytics"
AND isNotNull(finding.id) AND isNotNull(vulnerability.id)
AND isNotNull(dt.smartscape.process)
| dedup {finding.id}, sort:{timestamp desc}
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="GENAI_SERVICE"
| fields genai_service.id=source_id, service.id=target_id
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="SERVICE"
| filter target_type=="PROCESS"
| fields process.id=target_id, service.id=source_id
], on:{service.id}, fields:{process.id}
], on:{left[dt.smartscape.process]==right[process.id]}, kind:inner, fields:{genai_service.id}
| summarize {
total_vulnerabilities = countDistinctExact(vulnerability.id),
critical = countDistinctExact(if(dt.security.risk.level == "CRITICAL", vulnerability.id, else: null)),
high = countDistinctExact(if(dt.security.risk.level == "HIGH", vulnerability.id, else: null)),
medium = countDistinctExact(if(dt.security.risk.level == "MEDIUM", vulnerability.id, else: null)),
low = countDistinctExact(if(dt.security.risk.level == "LOW", vulnerability.id, else: null)),
affected_processes = countDistinctExact(dt.smartscape.process)
}, by:{genai_service.id}
| lookup [ smartscapeNodes "GENAI_SERVICE", from:-2h ],
sourceField:genai_service.id, lookupField:id, fields:{genai_service.name=name}
| sort critical desc, high desc, medium desc, low desc
| limit 10
```
**Answer format (Step 2):** table ranked highest-critical-first: `genai_service.name | critical | high | medium | low | total_vulnerabilities | affected_processes`.
Use `from:-2h` on `smartscapeEdges` so topology edges are available for the 30m finding window. Severity counts are **distinct CVEs** (`vulnerability.id`) per risk level — consistent with `total_vulnerabilities`. Use CVSS bands only when explicitly asked.
## UC-AI2 - New AI-workload vulnerabilities this window
"New" must be modeled as a prior-window anti-join, not only a `finding.time.created` filter.
```dql-template
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_FINDING"
| filter product.feature == "Library Vulnerability Analytics"
| filter in(dt.security.risk.level, {"CRITICAL","HIGH"})
| filter event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
| filter isNotNull(finding.id) AND isNotNull(object.id) AND isNotNull(vulnerability.id) AND isNotNull(dt.smartscape.process)
| dedup {finding.id}, sort:{timestamp desc}
// Resolve genai_service per process BEFORE the anti-join
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="GENAI_SERVICE"
| fields genai_service.id=source_id, service.id=target_id
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="SERVICE"
| filter target_type=="PROCESS"
| fields process.id=target_id, service.id=source_id
], on:{service.id}, fields:{process.id}
], on:{left[dt.smartscape.process]==right[process.id]}, kind:inner, fields:{genai_service.id}
// Anti-join at {genai_service.id, vulnerability.id} — suppresses any CVE already known for this service
| join kind:outer, on:{genai_service.id, vulnerability.id}, [
fetch security.events, from:-90m, to:-60m
| filter event.type == "VULNERABILITY_FINDING"
| filter product.feature == "Library Vulnerability Analytics"
| filter in(dt.security.risk.level, {"CRITICAL","HIGH"})
| filter event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
| filter isNotNull(finding.id) AND isNotNull(object.id) AND isNotNull(vulnerability.id) AND isNotNull(dt.smartscape.process)
| dedup {finding.id}, sort:{timestamp desc}
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="GENAI_SERVICE"
| fields genai_service.id=source_id, service.id=target_id
| join [
smartscapeEdges "*", from:-2h
| filter source_type=="SERVICE"
| filter target_type=="PROCESS"
| fields process.id=target_id, service.id=source_id
], on:{service.id}, fields:{process.id}
], on:{left[dt.smartscape.process]==right[process.id]}, kind:inner, fields:{genai_service.id}
| dedup {genai_service.id, vulnerability.id}
| fields genai_service.id, vulnerability.id
]
| filter isNull(right.genai_service.id)
// Summarize across all processes per genai_service
| summarize {
finding.ids = collectDistinct(finding.id),
object.ids = arrayRemoveNulls(collectDistinct(dt.smartscape_source.id)),
vulnerability.ids = collectDistinct(vulnerability.id),
dt.security.risk.score = toDouble(takeMax(dt.security.risk.score)),
vulnerable_components = arrayRemoveNulls(collectDistinct(software_component.purl)),
first_seen = takeMin(toTimestamp(finding.time.created))
}, by:{genai_service.id}
| fieldsAdd dt.security.risk.level = if(dt.security.risk.score >= 9, "CRITICAL",
else:if(dt.security.risk.score >= 7, "HIGH",
else:if(dt.security.risk.score >= 4, "MEDIUM",
else:if(dt.security.risk.score >= 0.1, "LOW", else:"NONE"))))
| lookup [
smartscapeNodes "GENAI_SERVICE", from:-2h
], sourceField:genai_service.id, lookupField:id, fields:{genai_service.name=name}
| fieldsAdd finding.ids=arraySort(finding.ids), vulnerability.ids=arraySort(vulnerability.ids), object.ids=arraySort(object.ids)
| sort genai_service.name asc
```
## Coverage
For AI-process coverage, use `GENAI_SERVICE -> PROCESS` as denominator and left-lookup
`VULNERABILITY_SCAN`, then report covered vs uncovered entities.
Cross-reference: `dt-sec-contextualization/references/identity-mapping.md` (Path 4 in Mapping Primitive section).
references/vulnerabilities-dynatrace.md
# Dynatrace Vulnerabilities — `security.events`
Dynatrace-generated vulnerability data: **Runtime Vulnerability Analytics (RVA) state
reports** (the canonical snapshot pipeline below) and **Dynatrace-generated
`VULNERABILITY_FINDING` events** (§ AI-workload vulnerabilities — findings scoped to AI
workloads). For **external** SCA / SAST / image-scanner findings see
[vulnerabilities-external.md](vulnerabilities-external.md).
> **Cross-references:** field reference → [data-model.md](data-model.md) ·
> risk-level mapping, status aggregation precedence, mute-status reporting rule,
> time-window rules → [common-patterns.md](common-patterns.md) · KPIs, top-N
> tables, trend charts → [coverage-and-dashboards.md § Dashboard Query Patterns](coverage-and-dashboards.md#dashboard-query-patterns).
> **Snapshot vs. history.** DT RVA queries start with a **30-minute fixed window**
> (`from:now()-30m`). State reports usually emit every ~15 min; the 30m window
> captures the latest cycle when ingestion is healthy. This is a snapshot window,
> not history. If the 30m snapshot is empty or clearly stale, use the controlled
> 24h latest-known-state fallback below — do not simply widen every RVA query by
> default. For historical trend analysis use `makeTimeseries` over a longer
> window (see § Time-Series Trends).
>
> **Change-event-only queries.** The 30m window rule applies whenever
> `VULNERABILITY_STATE_REPORT_EVENT` is in the event-type filter — that event
> type is what imposes snapshot semantics. If the user asks "what status changes
> happened in the last 7 days?", use a **pure change-event query**: filter only
> `VULNERABILITY_STATUS_CHANGE_EVENT` (and/or `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT`),
> set the fetch window to match the user's time horizon, and omit the snapshot
> dedup. Do **not** widen a snapshot query (one that includes `STATE_REPORT`)
> to gather history — it returns ~50× more rows without adding newer data.
> **The entire `vulnerability.parent.*` namespace is deprecated** — derive every
> vulnerability-level value from per-entity fields and per-entity status arrays in
> Step 3 (`takeMax`/`collectDistinct`; see Best Practices #12).
> **`vulnerability.first_seen` is null on the RVA pipeline — do not use it.** The field is
> commented out of the `entity.state` SD model, so it is null on every
> `VULNERABILITY_STATE_REPORT_EVENT` / `VULNERABILITY_STATUS_CHANGE_EVENT`. To express
> **how long a vulnerability has been open**, use `vulnerability.resolution.change_date`
> (populated on every row). A resolution-time proxy (MTTR) **is** computable from
> `resolution.change_date` without `first_seen` — see
> [§ Resolution time (MTTR proxy)](#resolution-time-mttr-proxy--openresolved-per-affected-object).
> **Vulnerability lifecycle (auto-resolution).** RVA does not require user action
> to resolve a vulnerability — it auto-resolves when the underlying signal
> disappears. **Third-party (`CODE_LIBRARY` / `SOFTWARE`)**: resolved when no
> process group reports the vulnerable component for >2 hours (library upgraded,
> component unused, no traffic post-restart, process stopped). **Code-level
> (`CODE`, CLV)**: resolved when a process restart followed by OneAgent
> re-analysis finds no exploitable data flow. On a re-open (RESOLVED → OPEN), only
> `vulnerability.resolution.change_date` updates (to the re-open transition).
## Contents
- [Routing: DT RVA vs External](#routing-dt-rva-vs-external)
- [Full Snapshot Queries — Steps 1–4 Canonical Pipeline](#dt-rva-full-snapshot-queries)
- [Count by Risk Level](#dt-rva-count-by-risk-level-simplified)
- [Runtime Assessment Workflows](#dt-rva-runtime-assessment-workflows)
- [Lifecycle Workflows](#dt-rva-lifecycle-workflows)
- [Time-Series Trends](#dt-rva-time-series-trends-7-days-3h-buckets)
- [Entity Scoping Workflows](#dt-rva-entity-scoping-workflows) · rankings → [vulnerabilities-entities.md](vulnerabilities-entities.md)
- [Code-Level Vulnerability (CLV) Workflows](#dt-rva-code-level-vulnerability-clv-workflows)
- [Tracking Links & Remediation](#dt-rva-tracking-links--remediation)
- [Mute Audit](#dt-rva-mute-audit-who-muted-what-why-when)
- [Fix-Available Filter](#dt-rva-fix-available-filter)
- [Vulnerable Functions Detail](#dt-rva-vulnerable-functions-detail)
- [Per-Affected-Entity-Type Breakdown](#dt-rva-per-affected-entity-type-breakdown)
- [External Vulnerability Findings](#external-vulnerability-findings)
- [Advanced Workflows And Best Practices](vulnerabilities-dynatrace-advanced.md)
---
## Routing: DT RVA vs External
| Source | `event.type` filter | Provider filter |
|---|---|---|
| Dynatrace RVA | `in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT","VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})` | `event.provider=="Dynatrace"` AND `event.level=="ENTITY"` |
| External providers | `event.type == "VULNERABILITY_FINDING"` | `filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"` |
### Stack-aware routing inside Dynatrace RVA
`vulnerability.stack` distinguishes the four kinds of vulnerabilities Dynatrace
reports. Each carries different fields and is detected by a different scanner.
| Stack | Source | Notes |
|---|---|---|
| `CODE_LIBRARY` | OSS / Maven / NPM / PyPI / Go modules etc. | Most common third-party CVE class. Carries `affected_entity.vulnerable_component.*`. Dynatrace runtime assessments fully populated. |
| `SOFTWARE` | Runtime / OS packages (RPM, DEB, runtime binaries) | Component matched on host packages or runtime. Dynatrace runtime assessments populated. |
| `CODE` | OneAgent IAST / Attack | **Code-level vulnerability (CLV)**. DSS always **10.0**. Carries `vulnerability.code_location.name`. `vulnerable_function.status` and `exposure.status` are not the relevant signals — entry-point and data-flow proof carry the assessment. Java 8+, .NET, Go only. |
| `CONTAINER_ORCHESTRATION` | Kubernetes / container info | Image- or orchestration-level findings. |
**Filter examples:**
```dql-snippet
| filter vulnerability.stack == "CODE" // CLV only
| filter in(vulnerability.stack, array("CODE_LIBRARY","SOFTWARE")) // third-party only
| filterOut vulnerability.stack == "CODE" // exclude CLV
```
---
## DT RVA: Full Snapshot Queries
RVA stores one state event per `(vulnerability, affected_entity)` pair. All full
snapshot queries share a 4-step pipeline. **Build any snapshot query by combining
Steps 1 + 2 (optional) + 3 + 4.**
### Step 1 — Base Filter + Dedup (always identical)
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT",
"VULNERABILITY_STATUS_CHANGE_EVENT",
"VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
```
### Latest-known-state fallback when 30m is empty or stale
Use this fallback only after the canonical 30m snapshot returns zero rows or
obviously misses known RVA data. **Take Step 1 and change two things:** set
`from:now()-24h`, and add `| sort timestamp desc` immediately before the `dedup`.
The `dedup {vulnerability.display_id, affected_entity.id}` still collapses to one
latest row per `(vulnerability, affected entity)` pair, preserving snapshot semantics
while tolerating scan-cycle or ingest drift.
Report that the result is the "latest known state observed in the last 24h" when
using this fallback. For lifecycle questions ("new in 7d", "resolved in 24h"),
keep the snapshot/fallback fetch separate from the lifecycle predicate and apply
the user's horizon to `vulnerability.resolution.change_date` after the
per-vulnerability summarize.
> **Raw-row listings of state reports.** If you stop after Step 1 (one row per `(vulnerability, entity)` without summarizing), use the RVA entity namespaces in the projection — `dt.smartscape*` / `dt.entity*` / `dt.source*` are **null** on these events:
>
> ```dql
> | fieldsKeep timestamp, "affected_entity*", "related_entities*",
> vulnerability.display_id, vulnerability.title,
> vulnerability.risk.score, vulnerability.references.cve,
> vulnerability.resolution.status, vulnerability.mute.status
> ```
>
> See [common-patterns.md § 17](common-patterns.md#17-entity-identifier-preservation-on-raw-listings).
### Step 2 — Optional Pre-Aggregation Filter (insert after Step 1, before Step 3)
| Scope | Filter to insert |
|---|---|
| Vulnerable component name (e.g. "log4j") | `\| filter contains(affected_entity.vulnerable_component.name,"log4j",caseSensitive:false)` |
| Specific CVE | `\| filter in("CVE-2021-44228", vulnerability.references.cve)` |
| CVE list (IoC / threat-report hunts) | `\| filter in(vulnerability.references.cve, array("CVE-A","CVE-B","CVE-C"))` — array-to-array intersection: matches if **any** of the finding's CVEs is in the target list. Validated form; see `threat-intelligence.md` § CVEs → vulnerabilities for the join-based variant. |
| Specific vulnerability by any ID or title keyword | `\| filter vulnerability.display_id == "<id>" OR vulnerability.id == "<id>" OR vulnerability.external_id == "<id>" OR contains(vulnerability.title,"<id>",caseSensitive:false)` |
| Host name (e.g. "easytravel-demo2") | `\| filter in("easytravel-demo2",related_entities.hosts.names) OR affected_entity.name=="easytravel-demo2"` |
| Specific K8s workload / host / service by name or ID | See [Vulnerabilities on a specific entity](#vulnerabilities-on-a-specific-entity-by-name-or-id) — use `related_entities.<group>.{names,ids}` and `affected_entity.*`; **not §5** (§5 fields are null on RVA events) |
> **Vulnerability ID formats:** `display_id` (`S-8517`), `id` (internal numeric string),
> `external_id` (advisory, e.g. `DTV-2026-GO-0001133` / NVD) — full cheatsheet in
> [data-model.md § Finding ID Format Cheatsheet](data-model.md#finding-id-format-cheatsheet).
> When the format is unknown use the multi-field OR above (the `contains(title,…)` arm is a
> keyword fallback for text, not ID matching). For CVEs use the dedicated
> `in("CVE-…", vulnerability.references.cve)` row (it's an array).
### Step 3 — Summarize to Vulnerability Level + Derived Status (shared block)
> **Use `vulnerability.risk.score` for filtering and ranking — not `cvss.base_score`.**
> `vulnerability.risk.score` is the Dynatrace Security Score (DSS): it is mute-aware (the
> `takeMax(if(mute.status!="MUTED", risk.score, else:0))` expression below excludes muted
> findings from the score), and it incorporates exposure, exploit availability, and
> function-in-use context that raw CVSS lacks. Only use `cvss.base_score` when the user
> explicitly asks for CVSS. The Step 3 derivation uses `vulnerability.risk.score`
> exclusively — do not substitute `cvss.base_score` into the threshold comparisons.
```dql-snippet
| sort {timestamp, direction:"descending"}
| summarize
{
vulnerability.stack=takeAny(vulnerability.stack),
vulnerability.type=takeAny(vulnerability.type),
vulnerability.cvss.base_score=takeFirst(vulnerability.cvss.base_score),
vulnerability.title=takeFirst(vulnerability.title),
vulnerability.resolution.change_date=takeMax(vulnerability.resolution.change_date),
vulnerability.references.cve=takeFirst(vulnerability.references.cve),
vulnerability.risk.score=round(takeMax(if(vulnerability.mute.status!="MUTED",vulnerability.risk.score,else:0)),decimals:1),
muteStatuses=collectDistinct(vulnerability.mute.status),
resolutionStatuses=collectDistinct(vulnerability.resolution.status),
functionStatuses=collectDistinct(vulnerability.davis_assessment.vulnerable_function_status),
exposureStatuses=collectDistinct(vulnerability.davis_assessment.exposure_status),
exploitStatuses=collectDistinct(vulnerability.davis_assessment.exploit_status),
dataAssetStatuses=collectDistinct(vulnerability.davis_assessment.data_assets_status),
affected_entity.ids=collectDistinct(affected_entity.id),
affected_entity.names=collectDistinct(affected_entity.name),
affected_entity.vulnerable_component.names=arrayRemoveNulls(collectArray(affected_entity.vulnerable_component.name)),
related_entities.names=arrayConcat(arrayRemoveNulls(collectArray(related_entities.kubernetes_workloads.names, expand:true)),
arrayRemoveNulls(collectArray(related_entities.kubernetes_clusters.names, expand:true)),
arrayRemoveNulls(collectArray(related_entities.applications.names, expand:true)),
arrayRemoveNulls(collectArray(related_entities.services.names, expand:true)),
arrayRemoveNulls(collectArray(related_entities.hosts.names, expand:true)),
arrayRemoveNulls(collectArray(related_entities.databases.names, expand:true))),
related_entities.ids=arrayConcat(arrayRemoveNulls(collectArray(related_entities.kubernetes_workloads.ids, expand:true)),
arrayRemoveNulls(collectArray(related_entities.kubernetes_clusters.ids, expand:true)),
arrayRemoveNulls(collectArray(related_entities.applications.ids, expand:true)),
arrayRemoveNulls(collectArray(related_entities.services.ids, expand:true)),
arrayRemoveNulls(collectArray(related_entities.hosts.ids, expand:true)),
arrayRemoveNulls(collectArray(related_entities.databases.ids, expand:true)))
},by: {vulnerability.display_id, vulnerability.id}
| fieldsAdd vulnerability.resolution.status=if(in("OPEN",resolutionStatuses), "OPEN", else: "RESOLVED"),
vulnerability.mute.status=if(in("NOT_MUTED",muteStatuses), "NOT_MUTED", else: "MUTED"),
vulnerability.vulnerable_function.status=if(in("IN_USE",functionStatuses), "IN_USE",
else:if(in("NOT_AVAILABLE",functionStatuses), "NOT_AVAILABLE", else:"NOT_IN_USE")),
vulnerability.exposure.status=if(in("PUBLIC_NETWORK",exposureStatuses), "PUBLIC_NETWORK",
else:if(in("NOT_AVAILABLE",exposureStatuses), "NOT_AVAILABLE", else:"NOT_DETECTED")),
vulnerability.exploit.status=if(in("AVAILABLE",exploitStatuses), "AVAILABLE", else:"NOT_AVAILABLE"),
vulnerability.data_assets.status=if(in("REACHABLE",dataAssetStatuses), "REACHABLE",
else:if(in("NOT_AVAILABLE",dataAssetStatuses), "NOT_AVAILABLE", else:"NOT_DETECTED")),
vulnerability.risk.level=if(vulnerability.risk.score>=9,"CRITICAL",
else:if(vulnerability.risk.score>=7,"HIGH",
else:if(vulnerability.risk.score>=4,"MEDIUM",
else:if(vulnerability.risk.score>=0.1,"LOW",
else:"NONE"))))
| fieldsRemove muteStatuses, resolutionStatuses, functionStatuses, exposureStatuses, exploitStatuses, dataAssetStatuses
```
**Why each stage exists:** Step 1 dedup keeps one latest row per
`(vulnerability, entity)` pair so per-entity aggregates aren't inflated; Step 3
summarize rolls per-entity rows into one row per vulnerability (so you can filter/sort
by `risk.score`, `references.cve`, `title`); Step 3 `fieldsAdd` derives
**vulnerability-level verdicts** from the per-entity status arrays using the precedence
below (a vulnerability is `OPEN` if any entity is `OPEN`, risk score is the max across
non-muted entities, etc.).
**Precedence rules for `fieldsAdd` derivations** (most-severe first):
| Field | Priority 1 | Priority 2 | Priority 3 | Default |
|---|---|---|---|---|
| `vulnerability.mute.status` | `NOT_MUTED` | — | — | `MUTED` |
| `vulnerability.resolution.status` | `OPEN` | — | — | `RESOLVED` |
| `vulnerability.vulnerable_function.status` | `IN_USE` | `NOT_AVAILABLE` | — | `NOT_IN_USE` |
| `vulnerability.exposure.status` | `PUBLIC_NETWORK` | `NOT_AVAILABLE` | `NOT_DETECTED` | `NOT_DETECTED` |
| `vulnerability.exploit.status` | `AVAILABLE` | — | — | `NOT_AVAILABLE` |
| `vulnerability.data_assets.status` | `REACHABLE` | `NOT_AVAILABLE` | — | `NOT_DETECTED` |
| `vulnerability.davis_assessment.assessment_mode` | `REDUCED` | `NOT_AVAILABLE` | — | `FULL` |
> **`ADJACENT_NETWORK`** intentionally falls through to `NOT_DETECTED` in the derived
> `vulnerability.exposure.status` (query raw `vulnerability.davis_assessment.exposure_status`
> for adjacent-network analysis — see Best Practices). **`assessment_mode` precedence is
> inverted** — `REDUCED` wins (degraded telemetry = more conservative outcome); `FULL` wins
> only when every entity was fully assessed.
**Naming note:** raw events carry full namespace
(`vulnerability.davis_assessment.exposure_status`, etc.); the Stage-3 `fieldsAdd`
collapses them to shortened forms (`vulnerability.exposure.status`, etc.). Downstream
filters and projections use the shortened forms.
> **Scope the summarize.** The summarize block above is the **full** aggregation
> used by the canonical RVA snapshot pattern. Trim to the fields you actually need for each
> specific query to keep results small and token-efficient.
### Step 4 — Final Filter Variants (append after Step 3)
| Use case | Append |
|---|---|
| Open, non-muted (baseline) | `\| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED"` |
| Open + internet-exposed | `\| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED" AND vulnerability.exposure.status=="PUBLIC_NETWORK"` |
| Open + critical with Dynatrace confirming | `\| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED" AND vulnerability.risk.level=="CRITICAL" AND vulnerability.vulnerable_function.status=="IN_USE" AND vulnerability.exposure.status=="PUBLIC_NETWORK" AND vulnerability.exploit.status=="AVAILABLE"` |
| All (no filter) | _(omit)_ |
| Scope to a specific entity | See entity filter note below |
**Entity scoping after Step 3** — Step 3's `summarize` collects the per-entity-row scalars `affected_entity.id` and `affected_entity.name` into the arrays `affected_entity.ids` and `affected_entity.names` (via `collectDistinct`), and `arrayConcat`s the per-category `related_entities.*.ids` / `related_entities.*.names` sub-fields into the flat arrays `related_entities.ids` / `related_entities.names`. Once those arrays exist, entity scoping becomes a single `in()` check:
```dql-snippet
| filter in("<entity_id_or_name>", affected_entity.ids)
or in("<entity_id_or_name>", affected_entity.names)
or in("<entity_id_or_name>", related_entities.ids)
or in("<entity_id_or_name>", related_entities.names)
```
Prefer entity scoping at Step 2 (pre-summarize) when you know the entity up front — it reduces the rows the summarize processes. Use the Step 4 form when the entity constraint is combined with post-summarize fields (`risk.level`, `resolution.status`, etc.) that don't exist before the summarize.
---
## Mute-Status-Separated Count (canonical reporting)
> **For "how many?" questions, use the simple `countIf` form below (one query, one row).**
> The risk-level–grouped form is for detailed breakdown analysis. Do **not** add
> `vulnerability.stack` grouping unless the user explicitly asks about stack type.
### Simple count ("how many?")
Apply Steps 1–3 (full pipeline to vulnerability level), then:
```dql-snippet
| summarize {
Total = count(),
OpenNotMuted = countIf(vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED"),
OpenMuted = countIf(vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status == "MUTED"),
Resolved = countIf(vulnerability.resolution.status == "RESOLVED"),
Critical = countIf(vulnerability.risk.level == "CRITICAL"
AND vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED"),
High = countIf(vulnerability.risk.level == "HIGH"
AND vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED"),
Medium = countIf(vulnerability.risk.level == "MEDIUM"
AND vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED"),
Low = countIf(vulnerability.risk.level == "LOW"
AND vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED")
}
// One row. OpenNotMuted is the primary answer; per-risk and muted/resolved are supplementary.
```
**Detailed breakdown by risk level** — always split by mute status (total counts alone
mislead, since muted vulnerabilities are suppressed but still open). Apply Steps 1–3, then
summarize per risk level:
```dql-snippet
| summarize {
`Total vulnerabilities`=count(),
`Open not muted`=countIf(vulnerability.mute.status=="NOT_MUTED" AND vulnerability.resolution.status=="OPEN"),
`Open muted`=countIf(vulnerability.mute.status=="MUTED" AND vulnerability.resolution.status=="OPEN"),
Resolved=countIf(vulnerability.resolution.status=="RESOLVED")
}, by:{vulnerability.risk.level}
```
---
## DT RVA: Count by Risk Level (simplified)
A lighter variant — dedup per entity, then reduce to vulnerability level (no full
Step-3 summarize). Apply **Step 1** (§ DT RVA: Full Snapshot Queries), then:
```dql-snippet
| summarize {vulnerability.risk.score=takeMax(vulnerability.risk.score)}, by: {vulnerability.display_id}
| fieldsAdd vulnerability.risk.level = <derive from risk.score — see common-patterns.md § 1>
| summarize { Vulnerabilities=count(), maxScore=takeMax(vulnerability.risk.score) }, by:{vulnerability.risk.level}
| sort maxScore desc
```
---
## DT RVA: Runtime Assessment Workflows
### Critical that Dynatrace runtime assessment agrees is critical
Strongest signal for remediation priority — append after Step 3:
```dql-snippet
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
AND vulnerability.risk.level=="CRITICAL"
AND vulnerability.vulnerable_function.status=="IN_USE"
AND vulnerability.exposure.status=="PUBLIC_NETWORK"
AND vulnerability.exploit.status=="AVAILABLE"
```
### Runtime-assessment-based risk fanout
```dql-snippet
// After Step 3:
| summarize vulnerabilities=count(),
by: {
vulnerability.exploit.status,
vulnerability.vulnerable_function.status,
vulnerability.data_assets.status,
vulnerability.exposure.status
}
| sort vulnerabilities desc
```
---
## DT RVA: Lifecycle Workflows
### New vulnerabilities in the last 24h / 7 days (UC-V3)
For DT RVA, "new" means the vulnerability first became `OPEN` within the window.
Filter on `vulnerability.resolution.change_date` — the timestamp of the last
status transition. **Keep the 30m snapshot window** — the 24h/7d scope is a
**post-derive filter** on the collapsed rows, not a wider fetch (see the
snapshot-vs-history rule at the top of this file). Apply **Steps 1 + 3**, then:
```dql-snippet
| filter vulnerability.resolution.status=="OPEN"
AND toTimestamp(vulnerability.resolution.change_date) > now() - 24h
| sort vulnerability.resolution.change_date desc
```
### How long have open vulnerabilities been open
`vulnerability.resolution.change_date` (epoch **nanoseconds**) marks the transition into the
current state; for an OPEN vulnerability it is when it became open. Collect the **earliest**
OPEN transition across the per-entity rows as "open since" via a **non-dotted alias** by casting
in the Step-3 `summarize` (`open_since=toTimestamp(takeMin(if(vulnerability.resolution.status=="OPEN", vulnerability.resolution.change_date, else: null)))`),
then compute the open duration **immediately after the `summarize`**, before the status-deriving `fieldsAdd`. Apply Steps 1 + 3
(this snippet shows the full tail; `open_since` is added to the Step 3 `summarize`):
```dql
fetch security.events, from:now()-30m
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT","VULNERABILITY_STATUS_CHANGE_EVENT",
"VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"}) AND event.level=="ENTITY"
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| summarize {
vulnerability.title=takeFirst(vulnerability.title),
open_since=toTimestamp(takeMin(if(vulnerability.resolution.status=="OPEN", vulnerability.resolution.change_date, else: null))),
resolutionStatuses=collectDistinct(vulnerability.resolution.status),
muteStatuses=collectDistinct(vulnerability.mute.status),
vulnerability.risk.score=round(takeMax(if(vulnerability.mute.status!="MUTED",vulnerability.risk.score,else:0)),decimals:1)
}, by:{vulnerability.display_id, vulnerability.id}
| fieldsAdd open_duration = now() - open_since // compute right after summarize
| fieldsAdd vulnerability.resolution.status=if(in("OPEN",resolutionStatuses),"OPEN",else:"RESOLVED"),
vulnerability.mute.status=if(in("NOT_MUTED",muteStatuses),"NOT_MUTED",else:"MUTED"),
vulnerability.risk.level=if(vulnerability.risk.score>=9,"CRITICAL",else:if(vulnerability.risk.score>=7,"HIGH",else:if(vulnerability.risk.score>=4,"MEDIUM",else:if(vulnerability.risk.score>=0.1,"LOW",else:"NONE"))))
| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED"
| sort open_duration desc // longest-open first
| fields open_since, open_duration, vulnerability.display_id, vulnerability.title, vulnerability.risk.level
| limit 50
```
### Recently resolved vulnerabilities
```dql-snippet
| filter vulnerability.resolution.status=="RESOLVED"
| filter toTimestamp(vulnerability.resolution.change_date) >= now() - 7d
| sort vulnerability.resolution.change_date desc
```
### Resolution time (MTTR proxy) — open→resolved per affected object
A resolution-time proxy **is** computable from `resolution.change_date` (OPEN→RESOLVED
transition, both carried on `VULNERABILITY_STATUS_CHANGE_EVENT`), without `first_seen`. It equals
true detection-to-resolution **only for vulnerabilities that never reopened**, counts
**auto-resolutions** as resolutions, and is bounded by the change-event fetch window — so treat
it as time-to-resolution-by-any-cause, not patch velocity.
**Method:** per `(vulnerability.id, affected_entity.id)`, diff the OPEN-transition `change_date`
and the RESOLVED-transition `change_date` from `VULNERABILITY_STATUS_CHANGE_EVENT`; average the
diffs = MTTR. Both transitions are emitted as status-change events at transition time — no state
reports needed.
```dql
// CHANGE-EVENT-ONLY — no state reports. Fetch window = OPEN-transition lookback:
// widen it past your oldest open to reduce censoring (it caps measurable TTR).
fetch security.events, from:now()-30d
| filter event.provider=="Dynatrace"
| filter event.type=="VULNERABILITY_STATUS_CHANGE_EVENT" AND event.level=="ENTITY"
| fieldsAdd open_date = if(vulnerability.resolution.status=="OPEN", toTimestamp(vulnerability.resolution.change_date)),
resolved_date = if(vulnerability.resolution.status=="RESOLVED", toTimestamp(vulnerability.resolution.change_date))
| summarize {
open_date = takeMax(open_date), // latest OPEN transition (most recent spell if reopened)
resolved_date = takeMax(resolved_date) // latest RESOLVED transition
}, by: {vulnerability.id, affected_entity.id}
| filter isNotNull(resolved_date) // resolved pairs only
// optional: report only resolutions within a shorter period, independent of the open lookback
// | filter resolved_date > now()-7d
| fieldsAdd ttr = resolved_date - open_date // duration; open_date/resolved_date are timestamps
| summarize {
resolved_pairs = count(),
censored = countIf(isNull(open_date)), // opened before the fetch window → MTTR is a LOWER BOUND
mttr = avg(if(ttr>0s, ttr)),
median_hours = median(if(ttr>0s, (resolved_date-open_date)/1h)),
p90_hours = percentile(if(ttr>0s, (resolved_date-open_date)/1h), 90)
}
// If censored > 0, widen the fetch window; if it then returns an incomplete-result
// warning, you've hit the read-limit ceiling — report MTTR as a lower bound.
```
**Caveats:**
- **The fetch window is the OPEN lookback, not a snapshot window.** The 30m state-report rule
does not apply here — this query uses only change events. Widen it to capture long-lived opens
(open transitions are status-change events, so a wider window reaches them directly — no state
reports needed).
- **Right-censored at the window** — if `censored > 0`, opens older than the fetch window were
missed; MTTR is a lower bound. Widen the fetch to reduce it. Verified on demo.live: 121/6,985
resolved pairs dropped at 30d (max open age 322 d); widening to 90d recovered 47 more but hit
the 10 s read limit (incomplete result).
- **Performance ceiling** — on high-volume tenants a wide change-event window can still hit the
10 s read limit. Do not use `samplingRatio` — RESOLVED rows are one-per-pair and would be
dropped, undercounting. When the incomplete-result warning fires, treat MTTR as a lower bound.
- **Auto-resolution counts** — RVA auto-resolves when the component is absent >2 h; those pairs
are indistinguishable from patched remediations. On auto-resolution-dominated tenants the
median will be near 2 h. Always show median + p90 next to the mean so the skew is visible.
- **Entity-weighted** — each `(vuln, entity)` pair contributes one TTR; a vuln on N entities
counts N×. For per-vulnerability weighting, group `by:{vulnerability.id}` and use
`takeMin(open_date)` / `takeMax(resolved_date)` across entities.
- Use `/24h` not `/1d` (`/1d` triggers a calendar-duration deprecation warning); `avg(ttr)`
returns a `duration` value.
---
## DT RVA: Time-Series Trends (7 days, 3h buckets)
Filter for open / non-muted vulnerabilities **after** `dedup` so the trend is
built from deduplicated rows.
> **For trend / "X over time" questions, use `makeTimeseries` — not
> `bin(timestamp, …) + summarize`.** `bin()` produces a flat tabular aggregation
> per bucket; the user asked for a time-series, and downstream chart-tile
> rendering expects a `timeseries`-typed column. See
> [common-patterns.md § Mistakes #9](common-patterns.md#mistakes-to-avoid)
> for the full rationale.
**Open vulnerability count over time:**
```dql
fetch security.events, from:now()-7d
| filter event.provider=="Dynatrace"
| filter in(event.type,{"VULNERABILITY_STATE_REPORT_EVENT",
"VULNERABILITY_STATUS_CHANGE_EVENT",
"VULNERABILITY_TRACKING_LINK_CHANGE_EVENT"})
AND event.level=="ENTITY"
| dedup {timestamp, vulnerability.id}
| filter vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status != "MUTED"
| makeTimeseries {Vulnerabilities=countDistinctExact(vulnerability.id)}, time: timestamp, interval:3h
| fieldsAdd `Open vulnerabilities`=arrayLast(Vulnerabilities)
```
**Affected entity count over time** — same query with two changes:
- Replace `dedup {timestamp, vulnerability.id}` → `dedup {timestamp, affected_entity.id}`
- Replace `countDistinctExact(vulnerability.id)` → `countDistinctExact(affected_entity.id)`
- Remove the trailing `fieldsAdd arrayLast` line
---
## DT RVA: Entity Scoping Workflows
**Entity rankings** ("most vulnerable hosts / K8s workloads / components", "which
entities are affected by CVE X") and the shared "Resolving RVA entity names via
Smartscape" pattern live in
[vulnerabilities-entities.md](vulnerabilities-entities.md).
**External-scanner vulnerability findings** (incl. component rankings) → moved to
[vulnerabilities-external.md](vulnerabilities-external.md).
This section covers **scoping RVA to a known entity** — listing the affected entities
for a specific vulnerability, or the vulnerabilities on a specific host / workload / CVE.
### Named entity list for a specific vulnerability
Use `VULNERABILITY_STATE_REPORT_EVENT` only (not the 3-event union) — state reports carry the full
entity context; STATUS_CHANGE/TRACKING_LINK_CHANGE are not needed for entity listing. Dedup by
`{affected_entity.id, vulnerability.id}` for one row per affected entity.
> **Always include `affected_entity.*` in the projection.** When `affected_entity.type == "HOST"` or
> `"KUBERNETES_NODE"`, the directly-affected entity is itself a host or node and may not appear in
> `related_entities.hosts.*` — it would be silently omitted if you project only the related arrays.
**Shared base (apply both options below to this):**
```dql
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_STATE_REPORT_EVENT"
AND event.provider == "Dynatrace"
AND vulnerability.display_id == "S-2647"
AND isNotNull(affected_entity.id)
| dedup {affected_entity.id, vulnerability.id}
```
**Option A — Simple list** (return the names array as-is, no expand needed):
```dql-snippet
| fields vulnerability.display_id, vulnerability.title,
affected_entity.id, affected_entity.name, affected_entity.type,
related_entities.hosts.names, related_entities.hosts.ids
```
**Option B — One row per host / full detail** (for one-row-per-host fanout or further Smartscape investigation):
```dql-snippet
| expand related_host.id = related_entities.hosts.ids
| lookup [
smartscapeNodes "*"
], sourceField:related_host.id, lookupField:id_classic, fields:{dt.smartscape_source.id=id, related_host.name=name}
| filterOut isNull(dt.smartscape_source.id)
| fields related_host.id, related_host.name, dt.smartscape_source.id,
affected_entity.id, affected_entity.name, affected_entity.type
```
`filterOut isNull(dt.smartscape_source.id)` drops IDs with no active Smartscape node (expired or
decommissioned entities). For other entity types, swap `related_entities.hosts.ids/names` for
`related_entities.kubernetes_workloads.ids/names`, `related_entities.services.ids/names`, etc.
`smartscapeNodes "*"` handles all types without narrowing.
If 30m is empty, use the 24h latest-known-state fallback. To look up by advisory ID instead of
`display_id`, use the multi-field OR filter from [§ Step 2](#step-2--optional-pre-aggregation-filter-insert-after-step-1-before-step-3).
---
### Vulnerabilities on a specific entity (by name or ID)
RVA `VULNERABILITY_STATE_REPORT_EVENT` events embed entity refs directly in the payload — the
generic Smartscape/entity namespaces (`dt.smartscape*`, `dt.entity*`, `dt.source*`) are **null** on
RVA events; [common-patterns.md §5](common-patterns.md#5-wide-entity-scoping-or-chain) does **not**
apply here. Use `affected_entity.*` (directly-affected entity) and
`related_entities.<group>.{ids,names}` (blast-radius entities) instead.
Scope **before the Step 3 summarize** (pre-aggregation) for efficiency. Pick the route based on
what the user supplies:
#### Route 1 — Smartscape node ID (two-step: resolve both IDs, then filter with both)
When the user supplies a Smartscape node ID, RVA events may store either the Smartscape node format
or the classic entity ID format in `related_entities.*` — use `toSmartscapeId()` to look up both,
then include both in the RVA filter.
> **Do not use `iAny(array[] == value)` for array membership** — this is not valid DQL. Use
> `in(value, array)` for a single value, or `in({value1, value2}, array)` for a set-literal
> intersection when checking multiple values.
**Step 1 — resolve both IDs from Smartscape:**
```dql
smartscapeNodes "*"
| filter id == toSmartscapeId("K8S_CLUSTER-74407E507406AE84")
| fields id, id_classic
```
Returns:
- `id` — Smartscape node ID (e.g. `K8S_CLUSTER-74407E507406AE84`)
- `id_classic` — classic entity ID (e.g. `KUBERNETES_CLUSTER-B4A001031F545EE3`)
**Step 2 — scope the RVA snapshot with both IDs:**
```dql
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_STATE_REPORT_EVENT"
AND event.provider == "Dynatrace"
AND (in({"K8S_CLUSTER-74407E507406AE84","KUBERNETES_CLUSTER-B4A001031F545EE3"}, related_entities.kubernetes_clusters.ids)
OR in(affected_entity.id, {"K8S_CLUSTER-74407E507406AE84","KUBERNETES_CLUSTER-B4A001031F545EE3"}))
| dedup vulnerability.display_id, sort:{timestamp desc}
| fields vulnerability.display_id, vulnerability.title,
vulnerability.risk.level, vulnerability.risk.score
| sort vulnerability.risk.score desc
```
Both IDs are included in the set literal because RVA events may store either format. The
`OR in(affected_entity.id, {...})` fallback is included because some entity types (HOST,
KUBERNETES_NODE) can be the directly-affected entity rather than appearing in `related_entities.*`.
> For general `smartscapeNodes` / `id_classic` resolution patterns across entity types see
> [dt-sec-contextualization/references/entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md).
#### Route 2 — direct display name or already-classic ID (no resolution)
When the user supplies a display name or an entity ID that is already in classic form
(`CLOUD_APPLICATION-…`, `HOST-…`, etc.), filter directly — no resolution step needed.
Insert after Step 1 dedup, before Step 3 summarize:
```dql-snippet
// By display name — checks both blast-radius (related) and directly-affected entity:
| filter in("<workload-name>", related_entities.kubernetes_workloads.names)
OR affected_entity.name == "<workload-name>"
// By classic ID:
| filter in("<CLOUD_APPLICATION-XXXXXXXXXXXXXXXX>", related_entities.kubernetes_workloads.ids)
OR affected_entity.id == "<CLOUD_APPLICATION-XXXXXXXXXXXXXXXX>"
```
Always check both `related_entities.<group>.*` **and** `affected_entity.*`. For `hosts` and
`kubernetes_nodes`, the `OR affected_entity.id ==` branch is not merely a safety net — these entity
types frequently appear as the *directly-affected* entity and will not be in `related_entities.hosts.*`
in that case, so omitting the OR silently misses them. For `kubernetes_workloads`, `services`,
`applications`, and `databases`, `affected_entity.*` will not match (those types never appear as
`affected_entity.type`), but including the OR is a safe catch-all that costs nothing.
#### Classic ID prefix gotcha
`related_entities.<group>.ids` carry **classic entity IDs** — the type prefix often differs from the
group name. The headline gotcha: `kubernetes_workloads.ids` stores `CLOUD_APPLICATION-…` IDs (not
`KUBERNETES_WORKLOAD-…`). Observed prefixes:
| Group | Observed classic ID prefix |
|---|---|
| `kubernetes_workloads` | `CLOUD_APPLICATION-` **(not `KUBERNETES_WORKLOAD-`)** |
| `kubernetes_clusters` | `KUBERNETES_CLUSTER-` |
| `hosts` | `HOST-` |
| `services` | `SERVICE-` |
| `applications` | `APPLICATION-` |
| `databases` | `SERVICE-` (same prefix as services — use the group name to distinguish) |
`.names` carry display names and are **positionally paired** with `.ids` — either can match the same
entity, and both routes return identical result sets for the same entity.
#### Choosing the right group
Pick the `related_entities.<group>` matching the entity type:
| Entity type | Use group |
|---|---|
| K8s workload (Deployment, DaemonSet, StatefulSet, …) | `kubernetes_workloads` |
| K8s cluster | `kubernetes_clusters` |
| Host | `hosts` |
| Service | `services` |
| Application (DT-monitored web app) | `applications` |
| Database | `databases` |
For genuinely cross-type "what entities are affected by this CVE?" questions, use the all-types
flat union (`related_entities.ids` / `related_entities.names`) that the canonical Step 3 rollup
builds — see [vulnerabilities-entities.md § Related-entity union](vulnerabilities-entities.md#related-entity-union-blast-radius-across-types).
#### Dedup key when scoping to one entity
When the query is scoped to one entity pre-aggregation, `dedup vulnerability.display_id` alone
counts distinct CVEs on that entity. This intentionally diverges from the canonical
`dedup {vulnerability.display_id, affected_entity.id}` key: with the entity fixed up front,
per-entity dedup would over-count across the blast-radius rows.
#### Typed arrays (Step 2) vs flat rollup (Step 4)
Typed per-group arrays (`related_entities.kubernetes_workloads.{ids,names}`) exist on raw events —
use them for Step 2 pre-aggregation scoping. The flat `related_entities.{ids,names}` arrays exist
only **after** Step 3's `arrayConcat` rollup — use that form only when combining the entity
constraint with post-summarize fields (`risk.level`, `resolution.status`).
---
## DT RVA: Code-Level Vulnerability (CLV) Workflows
CLV findings (`vulnerability.stack == "CODE"`) are detected by OneAgent through
data-flow analysis. They always score 10.0 (Critical) and surface on
**Java 8+, .NET, and Go** processes. Use `vulnerability.code_location.name`
to drill to the source file + line.
### Open CLV findings with code location
Apply **Step 1** + `| filter vulnerability.stack == "CODE"` (Step 2), then a CLV-focused
summarize/projection:
```dql-snippet
| summarize {
vulnerability.title=takeFirst(vulnerability.title),
vulnerability.type=takeFirst(vulnerability.type),
vulnerability.code_location.name=takeFirst(vulnerability.code_location.name),
vulnerability.technology=takeFirst(vulnerability.technology),
resolutionStatuses=collectDistinct(vulnerability.resolution.status),
muteStatuses=collectDistinct(vulnerability.mute.status),
affected_entity.names=collectDistinct(affected_entity.name),
affectedEntities=countDistinctExact(affected_entity.id)
}, by: {vulnerability.display_id, vulnerability.id}
| fieldsAdd vulnerability.resolution.status=if(in("OPEN",resolutionStatuses),"OPEN",else:"RESOLVED"),
vulnerability.mute.status=if(in("NOT_MUTED",muteStatuses),"NOT_MUTED",else:"MUTED")
| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED"
| fields vulnerability.display_id, vulnerability.title, vulnerability.type,
vulnerability.technology, vulnerability.code_location.name,
affected_entity.names, affectedEntities
| sort affectedEntities desc
| limit 50
```
### CLV vs third-party split (count summary)
Apply **Step 1**, then a lighter stack-bucket summary:
```dql-snippet
| summarize { resolutionStatuses=collectDistinct(vulnerability.resolution.status),
muteStatuses=collectDistinct(vulnerability.mute.status),
vulnerability.stack=takeFirst(vulnerability.stack),
riskScore=takeMax(vulnerability.risk.score) },
by: {vulnerability.display_id, vulnerability.id}
| fieldsAdd resolution=if(in("OPEN",resolutionStatuses),"OPEN",else:"RESOLVED"),
mute=if(in("NOT_MUTED",muteStatuses),"NOT_MUTED",else:"MUTED"),
stackBucket=if(vulnerability.stack=="CODE","Code-level (CLV)",
else:if(vulnerability.stack=="CODE_LIBRARY","Third-party library",
else:if(vulnerability.stack=="SOFTWARE","Runtime / OS package",
else:if(vulnerability.stack=="CONTAINER_ORCHESTRATION","Container / K8s",
else:"Other"))))
| filter resolution=="OPEN" AND mute=="NOT_MUTED"
| summarize { Vulnerabilities=count(),
Critical=countIf(riskScore>=9),
High=countIf(riskScore>=7 AND riskScore<9) },
by: {Stack=stackBucket}
| sort Vulnerabilities desc
```
---
## DT RVA: Tracking Links & Remediation
Tracking links are user-attached URLs (Jira tickets, wiki pages, runbooks)
emitted via `VULNERABILITY_TRACKING_LINK_CHANGE_EVENT`. Use them to measure
remediation progress.
> **Do not confuse with `vulnerability.external_url`.** Two fields look related but
> are different:
> - `vulnerability.tracking_link.url` — **user-attached** remediation link (Jira
> ticket, wiki page, runbook). Populated only when someone has actually
> attached a link in the Dynatrace UI. This is the field for
> "do we have a tracking link / Jira ticket?" questions.
> - `vulnerability.external_url` — **provider-emitted** reference URL (NVD page,
> vendor advisory, etc.). Populated by the security scanner for almost every
> finding. Filtering `external_url != ""` will return nearly every
> vulnerability and is **not** an answer to "is this tracked for remediation?".
>
> Same distinction for the IDs: `vulnerability.tracking_link.text` is the
> user-typed display text; `vulnerability.external_id` is the provider's CVE/NVD
> identifier.
### Open vulnerabilities WITH a tracking link
Apply Steps 1–3 with these added to the Step 3 `summarize` block:
```dql-snippet
trackingLinkUrls=collectDistinct(vulnerability.tracking_link.url),
trackingLinkTexts=collectDistinct(vulnerability.tracking_link.text),
```
Then append:
```dql-snippet
| fieldsAdd hasTrackingLink=if(arraySize(trackingLinkUrls)>0,true,else:false)
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
AND hasTrackingLink==true
| fields vulnerability.display_id, vulnerability.title, vulnerability.risk.level,
trackingLinkUrls, trackingLinkTexts
```
### Critical / high vulnerabilities WITHOUT a tracking link (action backlog)
Same as the WITH-link query, but change the filter to `hasTrackingLink==false` and
add `AND in(vulnerability.risk.level, array("CRITICAL","HIGH"))`, then
`| sort vulnerability.risk.score desc`.
### Tracking-link coverage rate
```dql-snippet
| filter vulnerability.resolution.status=="OPEN" AND vulnerability.mute.status!="MUTED"
| summarize { Open=count(), Tracked=countIf(hasTrackingLink==true) }
| fieldsAdd `Tracking coverage %` = round(Tracked*100.0/Open, decimals:1)
```
---
## DT RVA: Mute Audit (who muted what, why, when)
Mute metadata lives on raw events; do **not** collapse to vulnerability-level
when the question is "who muted this and why" — keep the per-entity row. Apply
**Step 1**, then filter to muted rows and project the mute fields:
```dql-snippet
| filter vulnerability.mute.status == "MUTED"
| fields timestamp, vulnerability.display_id, vulnerability.title,
affected_entity.id, affected_entity.name,
vulnerability.mute.reason, vulnerability.mute.user,
vulnerability.mute.comment, vulnerability.mute.change_date
| sort vulnerability.mute.change_date desc
```
**`vulnerability.mute.reason` values:** `FALSE_POSITIVE`, `IGNORE`,
`CONFIGURATION_NOT_AFFECTED`, `OTHER`. (`AFFECTED` always maps to
`mute.status == "NOT_MUTED"` — it's the "not actually muted" reason.)
**Mute-reason breakdown:** on the same Step-1 base + `filter vulnerability.mute.status == "MUTED"`,
replace the `fields` projection with
`| summarize Affected=countDistinctExact(affected_entity.id), Vulnerabilities=countDistinctExact(vulnerability.display_id), by:{vulnerability.mute.reason} | sort Affected desc`.
---
## DT RVA: Fix-Available Filter
`vulnerability.is_fix_available` (boolean) marks vulnerabilities for which
upstream has shipped a fix. Combine with runtime-assessment filters to build a
"fix-now backlog."
Add both the flag and the remediation text to the Step 3 summarize —
`isFixAvailable=takeAny(vulnerability.is_fix_available)` and
`fixRecommendation=takeAny(vulnerability.remediation.description)` — then:
```dql-snippet
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
AND vulnerability.risk.level=="CRITICAL"
AND isFixAvailable == true
| fields vulnerability.display_id, vulnerability.title, vulnerability.references.cve,
vulnerability.risk.level, vulnerability.risk.score, isFixAvailable, fixRecommendation
| sort vulnerability.risk.score desc
```
`vulnerability.remediation.description` carries the human-readable fix guidance (e.g. the
target upgrade version) — surface it so the result is actionable, not just a yes/no flag.
---
## DT RVA: Vulnerable Functions Detail
`affected_entity.vulnerable_functions` is the array of FQCN methods the
OneAgent observed executing. Useful when you need "which exact functions
in my code are reaching the vulnerable library?"
Apply **Step 1**, then filter to in-use functions and expand the array:
```dql-snippet
| filter vulnerability.davis_assessment.vulnerable_function_status == "IN_USE"
| expand fn = affected_entity.vulnerable_functions
| filter isNotNull(fn)
| summarize entities=countDistinctExact(affected_entity.id),
vulnerabilities=countDistinctExact(vulnerability.display_id),
sampleVulns=arraySlice(collectDistinct(vulnerability.display_id), from: 0, to: 5),
by: {function=fn}
| sort entities desc
| limit 20
```
> **Requires** the OneAgent `Java vulnerable function reporting` (or equivalent
> per-language) feature to be enabled. If `vulnerable_functions` is consistently
> empty for IN_USE rows, that feature is off.
---
## DT RVA: Per-Affected-Entity-Type Breakdown
`affected_entity.type` is one of `PROCESS_GROUP`, `HOST`, `KUBERNETES_NODE`
(occasionally `PROCESS_GROUP_INSTANCE`). To roll up "how much of this
vulnerability lives on each kind of entity":
Apply **Step 1** (per-entity question — filtering status pre-Stage-3 is fine here), then:
```dql-snippet
| filter vulnerability.resolution.status == "OPEN"
AND vulnerability.mute.status == "NOT_MUTED"
| summarize Vulnerabilities=countDistinctExact(vulnerability.display_id),
Entities=countDistinctExact(affected_entity.id),
by: {affected_entity.type, vulnerability.stack}
| sort Vulnerabilities desc
```
Note: filtering on `vulnerability.resolution.status` / `vulnerability.mute.status`
**before** Stage 3 is acceptable here because we're answering a per-entity
question — we want to count entities whose row was OPEN, not derive a
vulnerability-level verdict.
---
## External Vulnerability Findings
External SCA / SAST / image-scanner findings (`VULNERABILITY_FINDING`, one-shot
events — no RVA dedup/summarize-to-state pipeline) have their own reference:
**[vulnerabilities-external.md](vulnerabilities-external.md)**. It covers the base
external query, the Vulnerabilities-app SD-compatibility check, top-N external
vulnerabilities, vulnerable container images, the "newly reported this period"
anti-join, and the cross-provider view.
---
For AI-workload findings and extended best-practice guidance, use
**[vulnerabilities-dynatrace-advanced.md](vulnerabilities-dynatrace-advanced.md)**.
references/vulnerabilities-entities.md
# Vulnerability Entity Rankings — `security.events`
"Most vulnerable hosts / K8s workloads / components" and "which entities are affected
by CVE X" for **Dynatrace-native RVA**. These build on the canonical RVA snapshot
pipeline — **Steps 1–3** in
[vulnerabilities-dynatrace.md § DT RVA: Full Snapshot Queries](vulnerabilities-dynatrace.md#dt-rva-full-snapshot-queries)
— and resolve entity identity via `smartscapeNodes` (DT RVA carries
`related_entities.*` / `affected_entity.*`, not the external `object.*` identifiers).
> **Related references:** scoping RVA to a *known* entity (filter, not rank) →
> [vulnerabilities-dynatrace.md § Entity Scoping Workflows](vulnerabilities-dynatrace.md#dt-rva-entity-scoping-workflows) ·
> external-scanner component rankings → [vulnerabilities-external.md](vulnerabilities-external.md) ·
> mapping *external* findings to entities (3-way match / host-by-IP / cloud) →
> [entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md).
## Contents
- [Resolving RVA entity names via Smartscape](#resolving-rva-entity-names-via-smartscape)
- [Top vulnerable components (libraries) — DT RVA](#top-vulnerable-components-libraries--dt-rva)
- [Most vulnerable hosts — DT RVA](#most-vulnerable-hosts--dt-rva)
- [Most vulnerable K8s workloads (UC-V2)](#most-vulnerable-k8s-workloads-uc-v2)
- [Entities indirectly related to a CVE (UC-V5)](#entities-indirectly-related-to-a-cve-uc-v5)
---
## Resolving RVA entity names via Smartscape
**Canonical pattern for ranking/listing RVA findings by entity (host, K8s
workload, service, …).** `related_entities.<group>.ids` and `affected_entity.id`
on RVA events carry *classic* (2nd-gen) entity ids. To rank or list by entity:
1. If the entity type can be the directly-affected entity (HOST, KUBERNETES_NODE),
merge `affected_entity.id` into the typed `ids` array at record grain **before**
the Step-3 summarize (it is not repeated in `related_entities.*`).
2. Collect the typed `related_entities.<group>.ids` array in Step 3 (collect ids,
not names).
3. `expand` the ids array, then `join` / `lookup` `smartscapeNodes` on
`lookupField:id_classic` to resolve the **current** name + Smartscape id.
4. Aggregate by the resolved `{dt.smartscape*, name}` keys.
Rank on the resolved Smartscape identity — never on the raw
`related_entities.<group>.names` array, which can carry stale or duplicate names.
The host and workload recipes below are concrete instances of this pattern;
[vulnerabilities-dynatrace.md § Named entity list for a specific vulnerability](vulnerabilities-dynatrace.md#named-entity-list-for-a-specific-vulnerability)
(Option B) shows the same shape for an arbitrary entity type via `smartscapeNodes "*"`.
## Top vulnerable components (libraries) — DT RVA
Apply Steps 1–3 (with `affected_entity.vulnerable_component.names` collected),
then:
```dql-snippet
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
| expand component = affected_entity.vulnerable_component.names
| filter isNotNull(component)
| summarize vulnerabilities = countDistinctExact(vulnerability.display_id), by: {component}
| sort vulnerabilities desc
| limit 20
```
> **Count distinct vulnerabilities after `expand`.** The Step-3 component arrays
> are collected per affected entity, so the same `(vulnerability, component)`
> pair appears once per entity after `expand`. A plain `count()` here counts
> those duplicate rows and inflates the ranking 3–50×. Always
> `countDistinctExact(vulnerability.display_id)` when ranking components,
> workloads, or hosts after an `expand`.
## Most vulnerable hosts — DT RVA
Do not filter `affected_entity.type == "HOST"` for host ranking. RVA affected
entities are often process groups or process-group instances, with the host
context surfaced under `related_entities.hosts.*`. **But when
`affected_entity.type == "HOST"` the directly-affected host is the
`affected_entity` itself and is NOT repeated in `related_entities.hosts.*`** —
so a query that reads host context only from `related_entities.hosts.*` silently
drops every host that was the direct target. Merge the affected host back into
the typed `ids` array first, then resolve names through Smartscape.
This is the canonical RVA entity-ranking shape (see [§ Resolving RVA entity
names via Smartscape](#resolving-rva-entity-names-via-smartscape)): related
entities store *classic* ids, so expand `related_entities.hosts.ids` and look
them up in `smartscapeNodes` on `id_classic` to get the current name + Smartscape
id — do not rank on the raw `related_entities.hosts.names` array.
> **Do the affected-host merge at record grain, BEFORE the Step-3 summarize —
> never inside it.** A conditional mixed with aggregations inside `summarize`
> (`if(affected_entity.type == "HOST", collectArray(...), else: array())`) is
> rejected with `INVALID_MIX_OF_AGGREGATIONS_AND_OTHER_EXPRESSIONS`. A plain
> record-level `fieldsAdd` after the dedup is valid and is the correct place.
> See [common-patterns.md § Mistakes #45](common-patterns.md#mistakes-to-avoid).
```dql-snippet
// After Step-1 dedup, BEFORE the Step-3 summarize — merge the directly-affected
// host into the typed ids array (record grain, no aggregation):
| fieldsAdd related_entities.hosts.ids = if(affected_entity.type == "HOST",
arrayConcat(array(affected_entity.id), related_entities.hosts.ids),
else: related_entities.hosts.ids)
// Step 3 summarize — collect the typed ids only (names come from Smartscape):
| summarize {
..., // mute/resolution status arrays + risk.score, see shared Step 3 block
hosts.ids = arrayRemoveNulls(collectDistinct(related_entities.hosts.ids, expand:true))
}, by: {vulnerability.display_id, vulnerability.id}
// ... fieldsAdd derives resolution.status / mute.status / risk.level ...
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
| expand host.id = hosts.ids
| filter isNotNull(host.id)
| lookup [
smartscapeNodes HOST
], sourceField:host.id, lookupField:id_classic, fields:{host.name=name, dt.smartscape.host=id}
| summarize {
Vulnerabilities=count(),
Critical=countIf(vulnerability.risk.level=="CRITICAL"),
High=countIf(vulnerability.risk.level=="HIGH")
}, by:{dt.smartscape.host, host.name}
| sort Critical desc, High desc, Vulnerabilities desc
| limit 10
```
The golden merges only `affected_entity.type == "HOST"`. If you also want
hosts that were the direct target as a Kubernetes node, extend the condition to
`in(affected_entity.type, {"HOST", "KUBERNETES_NODE"})` — nodes are hosts and
`smartscapeNodes HOST` resolves them.
> **Why `count()` here is correct (and not a count-distinct best-practice violation).**
> The pipeline is already deduped to one row per vulnerability
> (`{vulnerability.display_id, vulnerability.id}`) before the `expand`, so after
> `expand host.id` each row is a distinct `(vulnerability, host)` pair and
> `count()` per host equals the number of vulnerabilities on that host. The
> `countDistinctExact` rule applies when the grain entering the `expand` is *not*
> already one-row-per-counted-item (e.g. the component ranking above, which
> expands directly off collected arrays).
## Most vulnerable K8s workloads (UC-V2)
> **K8s/host context on RVA events lives ONLY in `related_entities.*`.** The
> generic namespaces (`k8s.namespace.name`, `k8s.cluster.name`, `host.name`,
> `dt.entity.*`, `dt.smartscape*`) are **null** on RVA state/change events — a
> filter on them returns 0 rows. When that happens, do **not** fall back to
> pattern-matching `affected_entity.name` (process-group names are not workload
> identities and the same workload appears under multiple PG name variants).
> Pivot to the typed `related_entities.kubernetes_workloads.ids` array + a
> `smartscapeNodes` lookup on `id_classic` — that is the canonical recovery.
For typed entity questions like "most vulnerable workloads" / "top affected
hosts" / "services hit by this CVE", **use the typed sub-field**
(`related_entities.kubernetes_workloads.ids`) — not the all-types union
`related_entities.ids`. The union mixes cluster ids + host ids +
service ids + workload ids into one array; expanding it for a
workload-specific question silently surfaces hosts/clusters next to workloads,
answering a different question than asked.
Rank on the typed `ids` (not `names`): related entities store *classic* ids, so
expand `related_entities.kubernetes_workloads.ids` and `join smartscapeNodes`
on `id_classic` to resolve the current workload name + Smartscape id (see [§
Resolving RVA entity names via Smartscape](#resolving-rva-entity-names-via-smartscape)).
Apply Steps 1–3 with the typed sub-field collected in the Step 3 summarize:
```dql-snippet
// Step 3 summarize — collect the typed ids sub-field (names come from Smartscape):
| summarize {
..., // mute/resolution status arrays + risk.score, see shared Step 3 block
related_entities.kubernetes_workloads.ids = arrayRemoveNulls(
collectDistinct(related_entities.kubernetes_workloads.ids, expand:true)
)
}, by: {vulnerability.display_id, vulnerability.id}
// ... fieldsAdd derives resolution.status / mute.status / risk.level ...
| filter vulnerability.resolution.status=="OPEN"
AND vulnerability.mute.status!="MUTED"
| expand workload.id = related_entities.kubernetes_workloads.ids
| filter isNotNull(workload.id)
| join [
smartscapeNodes {K8S_DEPLOYMENT, K8S_CRONJOB, K8S_DAEMONSET, K8S_JOB, K8S_STATEFULSET, K8S_REPLICASET}, from:now()-2h
], on:{left[workload.id]==right[id_classic]},
fields:{workload.name=k8s.workload.name, dt.smartscape_source.id=id}
| summarize {
Vulnerabilities=count(),
Critical=countIf(vulnerability.risk.level=="CRITICAL"),
High=countIf(vulnerability.risk.level=="HIGH")
}, by:{dt.smartscape_source.id, workload.name}
| sort Critical desc, High desc
| limit 10
```
(`count()` is correct here for the same reason as the host ranking above — the
grain entering the `expand` is already one row per vulnerability.)
### Related-entity union (blast-radius across types)
Use the `related_entities.names` (all-types union) variant **only** when the
question is genuinely cross-type — e.g. "what entities — services, hosts,
workloads, clusters — are affected by this CVE?" (UC-V5 blast radius). For
typed entity questions, use the typed sub-field above.
## Entities indirectly related to a CVE (UC-V5)
After Steps 1–3, filter by CVE and expand the full related-entity blast radius:
```dql-snippet
// After Steps 1–3:
| filter in("CVE-2021-44228", vulnerability.references.cve)
| filter vulnerability.resolution.status=="OPEN"
| expand relatedEntity = related_entities.names
| filter isNotNull(relatedEntity)
| summarize vulnerabilities=count(), by:{relatedEntity}
| sort vulnerabilities desc
```
For directly affected entities, use `affected_entity.names` instead of
`related_entities.names`.
For CVE membership, prefer `in("<CVE>", vulnerability.references.cve)` before
or after aggregation. If tenant compatibility is uncertain, use the explicit
fallback `expand cve = vulnerability.references.cve | filter cve == "<CVE>"`.
Do not compare the whole CVE array to a string.
references/vulnerabilities-external.md
# External Vulnerability Findings — `security.events`
Ingested vulnerability findings from external SCA / SAST / image scanners (Snyk,
Qualys, Tenable, AWS Inspector, GitHub Advanced Security, etc.). For Dynatrace-native
Runtime Vulnerability Analytics (RVA) see [vulnerabilities-dynatrace.md](vulnerabilities-dynatrace.md).
> **Cross-references:** field reference → [data-model.md § Vulnerability Fields (external)](data-model.md#vulnerability-fields-external--dt-emitted-vulnerability_finding) ·
> provider scoping, double-counting guard, cross-provider summary →
> [all-security-events.md](all-security-events.md) · repository coalescing,
> lifecycle anti-join → [common-patterns.md](common-patterns.md) · mapping findings
> to runtime entities → [dt-sec-contextualization entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md).
## Contents
- [Top vulnerable components (libraries)](#top-vulnerable-components-libraries--external-findings-uc-v9)
- [External vulnerable container images](#external-vulnerable-container-images)
- [Verify external vulnerability findings with RVA](#verify-external-vulnerability-findings-with-rva)
- [External container-image findings also detected by RVA on the same running K8s workload](#external-container-image-findings-also-detected-by-rva-on-the-same-running-k8s-workload)
- [Critical external vulnerabilities newly reported in the last 7d](#critical-external-vulnerabilities-newly-reported-in-the-last-7d-not-in-the-prior-7d)
- [Cross-provider vulnerability view](#cross-provider-vulnerability-view)
- [Mapping external findings to runtime entities](#mapping-external-findings-to-runtime-entities)
External vulnerability findings are one-shot `VULNERABILITY_FINDING` events. They do
**not** need the dedup+summarize-to-state pipeline that DT RVA requires — each row is
already a discrete finding. Dedup is still useful when the same finding is re-ingested.
> **External component fields:** use `software_component.*` as the SD-compatible
> component namespace for external `VULNERABILITY_FINDING` rows (especially
> `software_component.name`, `software_component.purl`, `software_component.type`).
> Some providers still populate legacy `component.name` / `component.version`, so
> use `coalesce(software_component.name, component.name)` for display and dedup
> keys when broad provider compatibility is needed. Do **not** use
> `affected_entity.vulnerable_component.*` on external findings — that namespace is
> Dynatrace RVA-specific.
> **CVE reference field:** use `vulnerability.references.cve` for CVE matching on
> both external `VULNERABILITY_FINDING` rows and DT RVA rows. It is usually an
> array, so prefer `in("CVE-…", vulnerability.references.cve)` or `expand` before
> equality checks. Do **not** query invented fields such as
> `vulnerability.cve.id` or `vulnerability.cve.ids`.
**All external vulnerabilities:**
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
```
**Vulnerabilities-app compatibility check** (validates all SD-required fields are
present):
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| filter isNotNull(event.id)
and isNotNull(event.provider)
and isNotNull(finding.type)
and isNotNull(dt.security.risk.level)
and isNotNull(dt.security.risk.score)
and isNotNull(finding.id)
and isNotNull(finding.title)
and isNotNull(object.id)
and isNotNull(object.type)
and isNotNull(finding.time.created)
```
**Top 10 external vulnerabilities by affected object count:**
```dql
fetch security.events
| filter event.type=="VULNERABILITY_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| fieldsAdd repository=coalesce(artifact.repository, container_image.repository)
| filterOut isNull(finding.id) OR isNull(object.id) OR isNull(vulnerability.id)
| fieldsAdd component_name = coalesce(software_component.name, component.name),
component_version = component.version
| dedup {object.id, vulnerability.id, component_name, component_version}, sort: {timestamp desc}
| summarize {
`Risk score`=toDouble(takeMax(dt.security.risk.score)),
`Affected objects`=countDistinctExact(object.id),
`Vulnerable components`=countDistinctExact(component_name)
}, by:{Vulnerability=vulnerability.title, `Risk level`=dt.security.risk.level}
| sort {`Risk score`, direction:"descending"}
| fields `Risk level`, Vulnerability, `Affected objects`, `Vulnerable components`
| limit 10
```
## Top vulnerable components (libraries) — external findings (UC-V9)
External scanners carry component info primarily in `software_component.*`; use
legacy `component.*` only as a fallback:
```dql
fetch security.events
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider=="Dynatrace" or product.vendor=="Dynatrace"
| fieldsAdd component_name = coalesce(software_component.name, component.name),
component_version = component.version
| filter isNotNull(component_name)
| dedup {component_name, component_version, vulnerability.id}
| summarize {
Findings=count(),
Critical=countIf(dt.security.risk.level=="CRITICAL"),
High=countIf(dt.security.risk.level=="HIGH"),
`Affected objects`=countDistinctExact(object.id),
`Affected images`=countDistinctExact(container_image.digest)
}, by:{Component=component_name, Version=component_version}
| sort Critical desc, High desc
| limit 10
```
## External vulnerable container images
Lists vulnerable container images with identity preserved — repository, image name, and digest are all included in the `by:` keys so findings from different registries or digest versions are never collapsed together. Uses `coalesce(artifact.repository, container_image.repository)` (see [common-patterns.md § 12](common-patterns.md)) because external scanners populate one of the two repository fields. `container_image.digest` is the primary image identity field — `container_image.name` is not reliably populated by external providers.
```dql
fetch security.events, from: -24h
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
// SD-isNotNull guard — mandatory for cross-provider counts
| filter isNotNull(finding.id) AND isNotNull(object.id) AND isNotNull(dt.security.risk.level)
// Coalesce both repository fields
| fieldsAdd repository = coalesce(artifact.repository, container_image.repository)
// Dedup: same finding on same image digest from same provider counts once
| dedup {event.provider, finding.id, object.id, dt.security.risk.level}, sort: {timestamp desc}
// Preserve full identity before ranking — do not group only by image name
| summarize {
findings.count = count(),
Critical = countIf(dt.security.risk.level == "CRITICAL"),
High = countIf(dt.security.risk.level == "HIGH"),
vulnerabilities = collectDistinct(finding.title),
providers = collectDistinct(event.provider)
}, by: {repository, ObjectName = object.name, ImageDigest = container_image.digest, object.type}
| sort Critical desc, High desc
| limit 50
```
## Verify external vulnerability findings with RVA
Use this use case when the user asks whether external vendor vulnerability
findings are confirmed by Dynatrace Runtime Vulnerability Analytics (RVA), for
example: "does this external image finding run in my K8s environment and match
an RVA vulnerability?" or "does a Qualys host finding correspond to a runtime
vulnerability on the same host?"
Verification is a two-stage process:
1. **Same vulnerability:** match external `VULNERABILITY_FINDING` rows to open RVA
rows by CVE using `vulnerability.references.cve` on both sides. Expand the CVE
array first. Do not use `vulnerability.cve.id` / `.ids`.
2. **Related runtime entity/resource/artifact:** prove that the external object is
related to an RVA runtime entity through one of the supported relationship
paths below.
| External evidence | Runtime relationship proof | Use when |
|---|---|---|
| `dt.smartscape_source.id`, DT-style `object.id`, or `dt.entity.*` | Direct ID match to `affected_entity.id` or `related_entities.*.ids` from RVA | External provider already emits Dynatrace/Smartscape IDs |
| `object.type == "CONTAINER_IMAGE"` + `container_image.digest` | `smartscapeNodes CONTAINER` by `container.image.digest`, then container → K8s workload, then same RVA `related_entities.kubernetes_workloads.names` | Image scanners such as Snyk Container, Inspector, registry scanners |
| `host.ip` | Expand `host.ip`, join to `smartscapeNodes HOST.ip`, then same RVA `related_entities.hosts.ids` | Host scanners that report IP addresses but no DT entity ID |
Report both stages separately: "same CVE found" is weaker than "same CVE found
and mapped to the same runtime workload/host/entity". If only stage 1 matches,
state that runtime relatedness was not proven.
### Direct Smartscape / DT entity ID verification
Use this path first when external findings carry DT-style entity IDs. It proves a
same-CVE match and then checks whether the external entity ID is the RVA affected
entity or appears in the RVA related entity arrays.
```dql
fetch security.events, from:now()-24h
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
| filter isNotNull(vulnerability.references.cve)
| fieldsAdd external_entity_ids = arrayRemoveNulls(array(
toString(dt.smartscape_source.id),
toString(dt.entity.host),
toString(dt.entity.process_group),
toString(dt.entity.process_group_instance),
toString(dt.entity.kubernetes_node),
toString(dt.entity.kubernetes_cluster),
object.id))
| filter arraySize(external_entity_ids) > 0
| expand external_entity_id = external_entity_ids
| expand cve = vulnerability.references.cve
| dedup {event.provider, finding.id, object.id, external_entity_id, cve}, sort:{timestamp desc}
| join [
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_STATE_REPORT_EVENT"
OR event.type == "VULNERABILITY_OPEN_EVENT"
OR event.type == "VULNERABILITY_MUTED_EVENT"
| filter event.level == "ENTITY" AND vulnerability.resolution.status == "OPEN"
| filter isNotNull(vulnerability.references.cve)
| expand rva_cve = vulnerability.references.cve
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| fields rva_cve,
rva_display_id = vulnerability.display_id,
rva_title = vulnerability.title,
rva_entity_id = affected_entity.id,
rva_entity_name = affected_entity.name,
rva_hosts = related_entities.hosts.ids,
rva_workloads = related_entities.kubernetes_workloads.ids,
rva_clusters = related_entities.kubernetes_clusters.ids,
rva_services = related_entities.services.ids
], kind:inner, on:{left[cve] == right[rva_cve]},
fields:{rva_display_id, rva_title, rva_entity_id, rva_entity_name,
rva_hosts, rva_workloads, rva_clusters, rva_services}
| fieldsAdd runtime_related = external_entity_id == rva_entity_id
OR in(external_entity_id, rva_hosts)
OR in(external_entity_id, rva_workloads)
OR in(external_entity_id, rva_clusters)
OR in(external_entity_id, rva_services)
| filter runtime_related == true
| summarize {
ExternalFindings = count(),
Providers = collectDistinct(event.provider, maxLength: 10),
ExternalObjects = collectDistinct(object.name, maxLength: 10),
RVAEntities = collectDistinct(rva_entity_name, maxLength: 10),
RVADisplayIds = collectDistinct(rva_display_id, maxLength: 10),
RVATitles = collectDistinct(rva_title, maxLength: 10)
}, by:{cve, external_entity_id}
| sort ExternalFindings desc
```
### Host/IP verification
Use this path when the external vendor reports host IPs but not DT entity IDs.
It resolves `host.ip` to a Smartscape HOST and then requires the same host to be
present in RVA `related_entities.hosts.ids` for the same CVE.
```dql
fetch security.events, from:now()-24h
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
| filter isNotNull(vulnerability.references.cve) AND isNotNull(host.ip)
| expand cve = vulnerability.references.cve
| expand host.ip
| fieldsAdd normalized_ip = ip(host.ip)
| join [
smartscapeNodes HOST
| expand ip
| fields host_id = id, host_name = name, normalized_ip = ip
], kind:inner, on:{normalized_ip}, fields:{host_id, host_name}
| dedup {event.provider, finding.id, object.id, host_id, cve}, sort:{timestamp desc}
| join [
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_STATE_REPORT_EVENT"
OR event.type == "VULNERABILITY_OPEN_EVENT"
OR event.type == "VULNERABILITY_MUTED_EVENT"
| filter event.level == "ENTITY" AND vulnerability.resolution.status == "OPEN"
| filter isNotNull(vulnerability.references.cve)
| expand rva_cve = vulnerability.references.cve
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| fields rva_cve,
rva_display_id = vulnerability.display_id,
rva_title = vulnerability.title,
rva_entity_name = affected_entity.name,
rva_component = affected_entity.vulnerable_component.name,
rva_hosts = related_entities.hosts.ids
], kind:inner, on:{left[cve] == right[rva_cve]},
fields:{rva_display_id, rva_title, rva_entity_name, rva_component, rva_hosts}
| filter in(host_id, rva_hosts)
| summarize {
ExternalFindings = count(),
Providers = collectDistinct(event.provider, maxLength: 10),
ExternalTitles = collectDistinct(coalesce(finding.title, vulnerability.title), maxLength: 10),
RVAEntities = collectDistinct(rva_entity_name, maxLength: 10),
RVAComponents = collectDistinct(rva_component, maxLength: 10),
RVADisplayIds = collectDistinct(rva_display_id, maxLength: 10)
}, by:{cve, host_id, host_name}
| sort ExternalFindings desc
```
## External container-image findings also detected by RVA on the same running K8s workload
For questions like "are external image-scanner vulnerabilities also detected by
RVA, and is the scanned image actually running in Kubernetes?", use a strict
three-way correlation:
1. external `VULNERABILITY_FINDING` rows scoped to `object.type == "CONTAINER_IMAGE"`;
2. runtime `smartscapeNodes CONTAINER` matched by `container_image.digest`;
3. open RVA rows matched by `vulnerability.references.cve` **and** the same
Kubernetes workload name.
Do not use `vulnerability.cve.id` / `.ids`; CVEs are in
`vulnerability.references.cve`. Smartscape reference fields are arrays, so unwrap
them with `arrayFirst(...)` before joining.
```dql
fetch security.events, from:now()-7d
| filter event.type == "VULNERABILITY_FINDING"
| filterOut event.provider == "Dynatrace" OR product.vendor == "Dynatrace"
| filter object.type == "CONTAINER_IMAGE"
| filter isNotNull(container_image.digest) AND isNotNull(vulnerability.references.cve)
| expand cve = vulnerability.references.cve
| fieldsAdd repository = coalesce(artifact.repository, container_image.repository),
image_ref = concat(coalesce(repository, container_image.name, object.name), "@", container_image.digest),
component_name = coalesce(software_component.name, component.name)
| dedup {event.provider, finding.id, object.id, container_image.digest, cve}, sort:{timestamp desc}
| join [
smartscapeNodes CONTAINER
| fields dt.container.id = id, dt.container.name = name, container.image.digest, references
| fieldsAdd dt.k8s.workload.id = coalesce(arrayFirst(references[is_part_of.k8s_deployment]),
coalesce(arrayFirst(references[is_part_of.k8s_daemonset]),
coalesce(arrayFirst(references[is_part_of.k8s_cronjob]),
coalesce(arrayFirst(references[is_part_of.k8s_statefulset]),
coalesce(arrayFirst(references[is_part_of.k8s_job]), arrayFirst(references[is_part_of.k8s_replicaset]))))))
| fieldsKeep dt.container.id, dt.container.name, container.image.digest, dt.k8s.workload.id
], kind:inner, on:{left[container_image.digest] == right[container.image.digest]},
fields:{dt.container.id, dt.container.name, dt.k8s.workload.id}
| join [
smartscapeNodes {K8S_DEPLOYMENT, K8S_DAEMONSET, K8S_CRONJOB, K8S_STATEFULSET, K8S_JOB, K8S_REPLICASET}
| fields dt.k8s.workload.id = id, runtime_workload_name = name
], kind:inner, on:{left[dt.k8s.workload.id] == right[dt.k8s.workload.id]},
fields:{runtime_workload_name}
| join [
fetch security.events, from:now()-30m
| filter event.type == "VULNERABILITY_STATE_REPORT_EVENT"
OR event.type == "VULNERABILITY_OPEN_EVENT"
OR event.type == "VULNERABILITY_MUTED_EVENT"
| filter event.level == "ENTITY"
| filter vulnerability.resolution.status == "OPEN"
| filter isNotNull(vulnerability.references.cve)
| expand rva_cve = vulnerability.references.cve
| expand rva_workload_name = related_entities.kubernetes_workloads.names
| dedup {vulnerability.display_id, affected_entity.id}, sort:{timestamp desc}
| fields rva_cve, rva_workload_name,
rva_display_id = vulnerability.display_id,
rva_title = vulnerability.title,
rva_entity_id = affected_entity.id,
rva_entity_name = affected_entity.name,
rva_component = affected_entity.vulnerable_component.name
], kind:inner, on:{left[cve] == right[rva_cve], left[runtime_workload_name] == right[rva_workload_name]},
fields:{rva_display_id, rva_title, rva_entity_id, rva_entity_name, rva_component}
| summarize {
ExternalFindings = count(),
Providers = collectDistinct(event.provider, maxLength: 10),
Products = collectDistinct(product.name, maxLength: 10),
RiskLevels = collectDistinct(dt.security.risk.level, maxLength: 10),
Images = collectDistinct(image_ref, maxLength: 10),
RunningContainers = countDistinct(dt.container.id),
RuntimeContainers = collectDistinct(dt.container.name, maxLength: 10),
ExternalTitles = collectDistinct(coalesce(finding.title, vulnerability.title), maxLength: 10),
ExternalComponents = collectDistinct(component_name, maxLength: 10),
RVAEntities = countDistinct(rva_entity_id),
RVAEntityNames = collectDistinct(rva_entity_name, maxLength: 10),
RVAComponents = collectDistinct(rva_component, maxLength: 10),
RVADisplayIds = collectDistinct(rva_display_id, maxLength: 10),
RVATitles = collectDistinct(rva_title, maxLength: 10)
}, by:{cve, runtime_workload_name}
| sort ExternalFindings desc, RunningContainers desc
| limit 50
```
## Critical external vulnerabilities newly reported in the last 7d (not in the prior 7d)
"What's genuinely new this week?" — take this period's critical external findings and anti-join
(outer join + `isNull(right…)`) against the prior 7-day period, deduped per `{object.id, vulnerability.id}`:
```dql
// This period's critical external findings
fetch security.events, from:-7d, to:now()
| filter event.type == "VULNERABILITY_FINDING"
AND product.vendor != "Dynatrace" AND event.provider != "Dynatrace"
AND dt.security.risk.level == "CRITICAL"
| dedup {object.id, vulnerability.id}
// Anti-join the previous 7-day window
| join kind:outer, on:{object.id, vulnerability.id}, [
fetch security.events, from:-14d, to:-7d
| filter event.type == "VULNERABILITY_FINDING"
AND product.vendor != "Dynatrace" AND event.provider != "Dynatrace"
AND dt.security.risk.level == "CRITICAL"
| dedup {object.id, vulnerability.id}
| fields object.id, vulnerability.id
]
| filter isNull(right.vulnerability.id) // present now, absent in the prior period
| fields event.provider, product.name, dt.security.risk.level, dt.security.risk.score,
finding.time.created, finding.title, vulnerability.id, object.name,
component.name, repository=coalesce(artifact.repository, container_image.repository)
| sort dt.security.risk.score desc
```
> Adjust `dt.security.risk.level` (or drop it) and the window offsets for other severities / cadences.
## Cross-provider vulnerability view
To combine Dynatrace-native (canonical RVA pattern) with ingested findings using the
normalized fields `dt.security.risk.level`, `finding.id`, `finding.title`,
`object.id`, `object.name`, `object.type`, see
[all-security-events.md](all-security-events.md).
## Mapping external findings to runtime entities
External findings carry `object.*` / `container_image.*` / cloud-resource identifiers,
not Dynatrace runtime entity IDs. To map them to hosts / K8s workloads / cloud entities
via Smartscape, use the recipes in [entity-enrichment.md](../../dt-sec-contextualization/references/entity-enrichment.md).
SKILL.md
---
name: dt-sec-insights
description: >-
Query and analyze Dynatrace security data in security.events with DQL:
vulnerabilities, threat detections, compliance posture, and scan coverage.
Covers Dynatrace-native Runtime Vulnerability Analytics (RVA — CVEs,
reachability, exposure, exploit), Runtime Application Protection (RAP),
Automated Detections, and Security Posture Management
(KSPM/CSPM), plus external security products and tools. Trigger:
"open critical vulnerabilities", "vulnerable functions in use and publicly
exposed", "top vulnerable libraries / K8s workloads", "CIS/DORA compliance
pass rate", "SQL injection detections", "map external findings to workloads",
"hosts not covered by scanning". Do NOT use for explaining existing DQL
(use dt-dql-essentials), Davis problems (dt-obs-problems), logs (dt-obs-logs),
distributed tracing (dt-obs-tracing), service RED metrics (dt-obs-services),
or platform usage/audit telemetry (dt-platform).
license: Apache-2.0
---
# Security Insights Skill
Query and analyze Dynatrace security data in `security.events` using DQL. Events
come from **Dynatrace-native sources** (RVA, RAP, Automated Detections, SPM) or
**external products** ingested via integrations (AWS Security Hub, Amazon
GuardDuty, GitHub Advanced Security, Snyk, Qualys, Tenable, and more).
## What This Skill Covers
- **Vulnerability management** — open CVEs on running code from DT-native RVA
(risk-ranked with Dynatrace Security Score and the four-dimension runtime
assessment: vulnerable-function-in-use, public network exposure, reachable
data assets, public exploit available) plus external SCA / SAST / image scanners.
- **Compliance posture** — DT-native KSPM (Kubernetes-only: CIS, DORA, NIST,
STIG) plus CSPM/VSPM and external compliance/posture providers.
- **Runtime attacks and threats** — DT-native detections (RAP runtime attacks,
Automated Detections rules) plus external detection providers.
- **Threat intelligence** — external threat-intelligence reports (AlienVault OTX
pulses, CrowdStrike Falcon Intelligence) with actor / campaign / targeting context
and indicators of compromise (IOCs); correlate reported IOCs / CVEs / techniques
against your monitored environment. **These are threat intel about the wild — not
findings on your entities — and are queried separately.**
- **Scan coverage analysis** — covered vs. not-covered k8s workloads/hosts/processes, by Dynatrace
scanning feature (`Library Vulnerability Analytics`, `Operating System
Vulnerability Analytics`, `Code-level Vulnerability Analytics`) or by external
product.
- **Entity enrichment** — map external findings to Dynatrace runtime entities
(hosts, K8s workloads, cloud resources) via Smartscape.
- **Dashboards / KPIs** — tiles, top-N tables, trend charts, coverage donuts.
## When to Use This Skill
✅ **Must-first routing rule:** identify user intent first, then load the matching primary reference from **Quick Start: Find Your Use Case** before generating DQL.
Identify the intent, then load the matching reference before writing DQL.
**Cross-cutting (any / all finding types)**
| Intent / example | Reference | Pattern |
|---|---|---|
| Security posture / overview across all products (incl. DT-native) | `all-security-events.md` § Broad-Question Query Decomposition | 3-stream decomposition (external+detections `24h` / RVA `30m` / KSPM `1h`), merged; **lead the count summary with KSPM compliance — CIS first** (other standards + RVA + detections beneath) → `compliance.md` § CIS-Primary Standard Summary |
| Findings on a specific entity — direct or related (blast radius) | **dt-sec-contextualization** `entity-enrichment.md` · `all-security-events.md` | **Broad entity-security questions must decompose**: external `*_FINDING` by `dt.smartscape_source.id` / `dt.entity.*` / `k8s.*` (`24h`) + DT RVA entity scope (`30m`) + DT SPM entity scope (`1h`) |
| Findings from a specific provider | `all-security-events.md` § Scoping to a Specific Provider | `contains(lower(event.provider \| product.vendor), "<p>")` |
| Which **third-party / external** tools are sending data (DT-native excluded) | `all-security-events.md` § Which external integrations are active | external-only enumeration (single query) |
| Which security products are integrated? / what security data do we have? (default: **include DT-native** RVA + KSPM) | `all-security-events.md` § Broad-Question Query Decomposition | 3-stream decomposition; never a single wide `security.events` scan |
| Which products cover a specific entity | **dt-sec-contextualization** `correlation-and-coverage.md` · `all-security-events.md` | summarize by `product.*`; findings-vs-scans split |
> **Routing tie-breaker:** an unqualified "which security products are integrated? / are we covered? / what do we have?" defaults to the **DT-inclusive 3-stream decomposition** (it *must* query DT vulnerabilities and compliance). Take the external-only single query **only** when the user explicitly scopes to external / third-party tools ("which external tools are sending us data?").
**Vulnerabilities (CVE management)**
| Intent / example | Reference | Pattern |
|---|---|---|
| Counts / severity ("how many critical?", by risk + mute status) | `vulnerabilities-dynatrace.md` | RVA snapshot Steps 1–3 |
| Most vulnerable components / hosts / workloads (rankings) | `vulnerabilities-entities.md` | Steps 1–3 + `expand` typed `related_entities.<group>.ids` → `smartscapeNodes` lookup on `id_classic` — ⚠ `k8s.*`/`dt.entity.*` are null on RVA events |
| CVE / library lookup; "am I vulnerable to log4shell?" | `vulnerabilities-dynatrace.md` § Entity Scoping | Step 2 CVE/component filter; scope RVA to a known entity |
| Blast radius — which entities are affected by CVE X | `vulnerabilities-entities.md` | `related_entities.*` indirect-relation expand |
| Lifecycle — new / resolved / open-duration / MTTR | `vulnerabilities-dynatrace.md` | post-derive `resolution.change_date`; MTTR via change-events-only snippet (§ Resolution time) |
| Runtime advanced — function-in-use, exposure, exploit, data-assets | `vulnerabilities-dynatrace.md` | Davis-assessment `fieldsAdd` (Step 3) |
| External scanner vulns — containers / artifacts / components | `vulnerabilities-external.md` | `VULNERABILITY_FINDING` + external routing |
| Verify external vulnerability findings with RVA | `vulnerabilities-external.md` § Verify external vulnerability findings with RVA · **dt-sec-contextualization** `entity-enrichment.md` | First match the same vulnerability by `vulnerability.references.cve`; then prove runtime relatedness via direct `dt.smartscape*` IDs, container-image digest → running `CONTAINER`, or host `host.ip` → Smartscape HOST |
| "Newly reported this period **and not in the previous period**" (external) | `vulnerabilities-external.md` · `common-patterns.md` § 18 | prior-period **anti-join** (`isNull(right.*)`) — a `finding.time.created` filter is NOT equivalent |
| AI/LLM/GenAI workload vulnerabilities; "which AI services have vulnerabilities?" | `vulnerabilities-dynatrace-advanced.md` § AI-workload vulnerabilities | **Probe `smartscapeNodes "GENAI_SERVICE"` first** — zero rows → report cannot be determined (no GenAI-monitored services); non-zero → DT findings + GENAI scope (`30m`, dedup `finding.id`). ⚠ Zero rows = hard stop — **no fallbacks permitted**: entity/namespace/workload name substrings, ML library component lists, process/image/label patterns are all prohibited substitutes. |
| New AI-workload vulnerabilities this period | `vulnerabilities-dynatrace-advanced.md` § UC-AI2 | prior-window **anti-join** on `{genai_service.id, vulnerability.id}` |
**Detections (threats & attacks)**
| Intent / example | Reference | Pattern |
|---|---|---|
| Severity / time-window overview (DT + external) | `detections.md` · `all-security-events.md` | `DETECTION_FINDING` summary; default unqualified timeframe is `2h`, widen to `24h` only if empty |
| By attack type (`SQL injection`, crypto-mining, …) | `detections.md` | `finding.type` substring match |
| Attacker IPs / campaigns | `detections.md` | `expand actor.ips` + `ip()` |
| MITRE technique / sub-technique | `detections.md` | `threat.attack.*` arrays |
| RAP-only / Automated-Detections-only | `detections.md` § Provider Routing | `product.name=="Runtime Application Protection"` / `event.provider=="Dynatrace Automated Detections"` |
| Map detections to entities; repeated firing | `detections.md` · **dt-sec-contextualization** `entity-enrichment.md` | `object.id` grouping / enrichment |
| A specific external provider | `all-security-events.md` § Scoping to a Specific Provider | provider `contains` idiom |
> **MITRE routing tie-breaker:** a MITRE ATT&CK question routes by intent. "Which techniques did we **detect / observe** (on our entities)?" → `detections.md` (`DETECTION_FINDING`). "Which techniques are **reported in threat intel / campaigns in the wild**?" → `threat-intelligence.md` (`THREAT_REPORT`). Don't merge the two — a report tagged T1059 is not evidence T1059 occurred in your environment.
**Threat intelligence (external reports & IOCs)**
| Intent / example | Reference | Pattern |
|---|---|---|
| Show / list / count threat intelligence reports; reports by provider, actor, malware family, targeted country/industry, TLP, report type | `threat-intelligence.md` | `THREAT_REPORT` + **`dedup {threat.report.id}`** (SD guard first); **never** the four-key finding summarize |
| Top IOCs (CVEs / IPs / domains / URLs / emails / hashes) or MITRE techniques across reports | `threat-intelligence.md` § IOC extraction | `dedup` → `expand` observable → `countDistinctExact(threat.report.id)` |
| **Am I exposed to report X / are these IOCs in my environment?** (threat-exposure) | `threat-intelligence.md` § Threat-Exposure Correlation | `join` report IOCs/CVEs/techniques to `VULNERABILITY_FINDING`/RVA/`DETECTION_FINDING`; **logs/spans IoC hunt → `dt-sec-ioc-hunting`** |
**Compliance (policy violations & benchmarks)**
| Intent / example | Reference | Pattern |
|---|---|---|
| Pass-rate / posture (CIS / DORA / NIST / STIG) | `compliance.md` | **Load `compliance.md` first** — SPM Steps 1–2 + passRate |
| Critical misconfigurations | `compliance.md` | **Load `compliance.md` first** — Steps 1–2 + severity filter |
| Compliance / misconfigurations on a **specific entity** | `compliance.md` § Entity Security-Tab View (entity-scoped `${entityIdsOrNames}` filter) · `entity-enrichment.md` | Mirror the entity **Security tab** (CIS default, failed-only): Table 1 DT CIS failed rules → Table 2 other DT standards (overlap caveat) → Table 3 external misconfigs. Broad posture/count questions instead use § CIS-Primary Standard Summary (scorecard). |
| Map control/standard → entities; per-namespace | `compliance.md` | entity scoping via `compliance.standard.short_name` / `compliance.rule.id` (⚠ never `metadata_json`) |
| Cloud / non-K8s (PCI/ISO/HIPAA/GDPR; AWS/Azure/GCP) | `compliance.md` § External | external taxonomy (`compliance.standards`/`policy`/`control`) |
| External violations grouped by standard / framework | `compliance.md` § External | `compliance.standards` is an **array** — `expand` it before `summarize` |
| Config **drift** / newly failing rules vs previous week (DT) | `compliance.md` § Week-over-Week Config Drift | prior-period **anti-join** — a wide fetch window is NOT a substitute |
| External compliance findings new this period, absent in prior | `compliance.md` § External | prior-period anti-join (same rule as drift) |
| KSPM (Kubernetes-only, DT-native) | `compliance.md` | `product.name=="Security Posture Management"` |
**Coverage, enrichment & dashboards**
| Intent / example | Reference | Pattern |
|---|---|---|
| Coverage / "covered vs not covered" / coverage gaps — hosts / processes / workloads | `coverage-and-dashboards.md` (counting logic) · **dt-sec-contextualization** `correlation-and-coverage.md` (match recipes) | ⚠ **MUST start from `smartscapeNodes` + `lookup` scan events** — summarizing scan events alone has no denominator and cannot answer a coverage question |
| Specific entity coverage by a DT capability (RVA, SPM, RAP, other DT-native) | `coverage-and-dashboards.md` | If no relevant findings or scan/completion events exist for that entity in the capability's operational window, answer **not covered** — capability is likely not enabled or not configured for that entity |
| Map external findings → workloads / hosts / cloud | **dt-sec-contextualization** `entity-enrichment.md` | 3-way match (K8s) / host-by-IP / Path-1 (cloud) — ⚠ **always join to Smartscape; never group findings by raw `object.name` / `k8s.namespace.name` / `host.name` / cloud resource IDs alone** |
| One-row-per-entity risk summary | **dt-sec-contextualization** `entity-enrichment.md` · `coverage-and-dashboards.md` | RVA + external merge |
| Dashboards — KPI tiles, top-N, trends, donuts | `coverage-and-dashboards.md` | `makeTimeseries`, summarization recipes |
❌ **Don't use for:**
- Dynatrace-detected problems → `dt-obs-problems`
- Application/infrastructure logs → `dt-obs-logs`
- Distributed tracing → `dt-obs-tracing`
- Service performance/RED metrics → `dt-obs-services`
## Introduction to AppSec Data
All security events are stored in **`security.events`** and are categorized by `event.type`:
- **RVA vulnerabilities** — `VULNERABILITY_STATE_REPORT_EVENT` (15-minute snapshots per entity)
- **KSPM compliance** — `COMPLIANCE_FINDING` (per `(rule, K8s object)`, joined with `COMPLIANCE_SCAN_COMPLETED` on `scan.id` for latest-scan dedup)
- **External compliance (CSPM / VSPM / external posture tools)** — `COMPLIANCE_FINDING` with the external taxonomy (`compliance.standards` / `compliance.policy` / `compliance.control`); `compliance.rule.*` typically null
- **Detections** — `DETECTION_FINDING` (RAP via `product.name == "Runtime Application Protection"`), `SECURITY_EVENT` (RAP events in some tenants), Automated Detections via `event.provider == "Dynatrace Automated Detections"`, external security tools; plus `DETECTION_EXECUTION_SUMMARY` for per-rule-run audit (Automated Detections only)
- **Scan coverage** — `VULNERABILITY_SCAN`, `COMPLIANCE_SCAN`
- **Threat intelligence** — `THREAT_REPORT` (external TI platforms: AlienVault OTX pulses, CrowdStrike Falcon Intelligence). **A separate class of data — not a finding:** no `finding.*` / `object.*` / `dt.security.risk.level`, no affected entity, no scan cycle. **Never folded into the cross-provider finding summary or the posture-overview decomposition** — queried on its own via [threat-intelligence.md](references/threat-intelligence.md). Dedup by `threat.report.id`.
Full taxonomy and field reference → [data-model.md](references/data-model.md)
### Critical Constraint: Snapshot Windows
DT RVA and KSPM are **snapshot tools**, not event streams. The minimum
query window required for each pipeline:
- **RVA**: `30m` fixed window (captures latest 15-min cycle); if a 30m snapshot is empty or clearly stale, use the controlled 24h latest-known-state fallback in [vulnerabilities-dynatrace.md](references/vulnerabilities-dynatrace.md#latest-known-state-fallback-when-30m-is-empty-or-stale)
- **KSPM**: `1h` fixed window (needs latest `COMPLIANCE_SCAN_COMPLETED` marker for the inner-join)
- **External findings (incl. CSPM/VSPM)**: `2h–24h+` (no snapshot semantics — these are one-shot events)
Widening these windows does NOT look back further — they only capture the latest report/scan cycle. For historical trends, use `makeTimeseries` over longer windows.
> **DT-generated `VULNERABILITY_FINDING` vs. RVA state reports.** Dynatrace-generated
> `VULNERABILITY_FINDING` queries (e.g. AI-workload scoping in
> [vulnerabilities-dynatrace-advanced.md § AI-workload vulnerabilities](references/vulnerabilities-dynatrace-advanced.md#ai-workload-vulnerabilities-dynatrace-findings))
> also use a `30m` window, but dedup on **`finding.id`** because DT findings are
> re-emitted on every scan run (~15 min) — distinct from the RVA state-report
> `30m` window, which dedups on **`{vulnerability.display_id, affected_entity.id}`**.
> Do not mix the two dedup grains.
### Default Time Ranges in the Dynatrace Apps
The UI apps show pre-set defaults in their time picker. When a user
references "the app's view" without giving an explicit window, match
these to align query results with what the user sees in the UI:
| App | Default time picker |
|---|---|
| Vulnerabilities app | 30 minutes |
| Threats & Exploits app | 2 hours |
| Security Posture Management app | 2 hours |
These app defaults are *broader* than the minimum snapshot windows above
(e.g. SPM app = 2h vs. KSPM pipeline minimum = 1h). The minimum window is
what the inner-join / latest-cycle dedup needs to function; the app default
is what the user sees on first load. Use the **minimum window** when
generating canonical pipeline DQL; use the **app default** when the user
asks "what does the SPM app show me right now?" or builds a dashboard tile
intended to match the app view.
See [vulnerabilities-dynatrace.md § Snapshot vs. History](references/vulnerabilities-dynatrace.md) for details.
## How This Skill Is Organized
The skill is split into two parts for scalability:
1. **SKILL.md** (this file) — Entry point, quick lookup, routing to the right reference
2. **references/** — Detailed guidance by capability or domain:
- [**data-model.md**](references/data-model.md) — Reference for `fetch security.events` — event types, providers, fields, entity scoping.
- [**common-patterns.md**](references/common-patterns.md) — Cross-cutting patterns, common mistakes to avoid, and query troubleshooting reference.
- [**vulnerabilities-dynatrace.md**](references/vulnerabilities-dynatrace.md) — Dynatrace Runtime Vulnerability Analytics (RVA): snapshot pipeline, counts, lifecycle, runtime assessment, CLV, tracking, mute, and entity scoping.
- [**vulnerabilities-dynatrace-advanced.md**](references/vulnerabilities-dynatrace-advanced.md) — Advanced DT vulnerability guidance: best practices and AI-workload (`VULNERABILITY_FINDING`) query workflows.
- [**vulnerabilities-external.md**](references/vulnerabilities-external.md) — External SCA / SAST / image-scanner vulnerability findings (`VULNERABILITY_FINDING`).
- [**vulnerabilities-entities.md**](references/vulnerabilities-entities.md) — DT RVA entity rankings: "most vulnerable hosts / K8s workloads / components" + CVE blast radius.
- [**compliance.md**](references/compliance.md) — Dynatrace Security Posture Management (SPM / XSPM) compliance findings and external provider compliance findings.
- [**detections.md**](references/detections.md) — Runtime Application Protection (RAP) detections, Automated Detection rules, and external provider detections.
- [**threat-intelligence.md**](references/threat-intelligence.md) — External threat-intelligence reports (`THREAT_REPORT`): AlienVault OTX / CrowdStrike Falcon Intelligence, IOC extraction, and threat-exposure correlation. Not findings — queried separately.
- [**all-security-events.md**](references/all-security-events.md) — Cross-provider queries, double-counting guard, unified summaries
- [**coverage-and-dashboards.md**](references/coverage-and-dashboards.md) — Entity coverage counting logic (`smartscapeNodes` denominator, covered vs. not-covered) and dashboard patterns (KPI tiles, top-N, trend charts, coverage donuts).
- **entity-enrichment** — Moved to **dt-sec-contextualization** `references/entity-enrichment.md` (3-way match / host-by-IP / cloud). Load `dt-sec-contextualization` for any entity-mapping question.
## Universal Best Practices
1. **Always load dt-dql-essentials first** — DQL syntax and function names differ from SQL. Confirm all functions in `dt-dql-essentials` before generating queries.
2. **Ground every query in the routed reference's canonical template — do not improvise DQL.** Identify intent, load the matching reference (per *When to Use*), and build from its canonical pipeline / named building block. Do **not** invent field names, enum values, join syntax, or pipeline shape from SQL habits. Deviate from a template only with syntax explicitly shown in a skill example or validated in `dt-dql-essentials`. If no template covers the request, say so and adapt the closest one — never fabricate fields or values.
3. **No `dt.system.bucket` filters** — security event data may live in any bucket; filtering by bucket risks hiding findings.
4. **Use the correct provenance field for the family** — RVA uses `event.provider == "Dynatrace"`, SPM/detections use `product.vendor == "Dynatrace"`. See [data-model.md § Provider Taxonomy](references/data-model.md).
5. **Always include an explicit `from:` clause — use the correct window for the query class:**
| Query class | Default window | Notes |
|---|---|---|
| DT RVA snapshots | `30m` fixed | Captures latest 15-min state-report cycle — do not widen |
| DT KSPM snapshots | `1h` fixed | Aligned with scan-completion cycle inner-join — do not widen |
| RAP / external detection retrieval or current summary | `2h` first attempt | Matches Threats & Exploits app default. Widen to `24h` only if zero rows returned or if the user explicitly asks for a longer window (see [detections.md § Widen-on-empty fallback](references/detections.md)) |
| Cross-provider summary (aggregated) | `24h` | Summaries aggregate over time; start broad |
Omitting `from:` falls back to a default window that doesn't match snapshot semantics and produces drift between query runs. The 30m / 1h windows are *not* arbitrary — they're tied to the underlying RVA / SPM scan cadence. See [common-patterns.md § 7](references/common-patterns.md) for the full window reference.
**Decompose DT-inclusive broad / posture-overview questions** ("which security products are integrated incl. Dynatrace-native?", posture overview, cross-category counts that include DT vulnerabilities/compliance) — never answer with one wide scan over all of `security.events`. Run three separate queries and merge: Stream A external + DT detections (`24h`, double-counting guard), Stream B DT RVA (`30m`), Stream C DT KSPM (`1h`). This keeps the high-cardinality snapshot streams in their tight windows and avoids double-counting. A narrower "which **external** integrations are sending data?" stays a single external-only query. See [all-security-events.md § Broad-Question Query Decomposition](references/all-security-events.md#broad-question-query-decomposition).
6. **Preserve entity identifiers on raw listings** (top / latest / list / show-me — no `summarize`) so users see which entity each finding is on. The namespaces split by family and are **not** interchangeable: cross-provider `*_FINDING` / scan events use the generic `dt.smartscape*` / `dt.entity*` / `dt.source*` fields; RVA state/change events leave those null and carry refs in `affected_entity*` / `related_entities*`. Not for pure count / pass-rate summaries. Field lists and wildcard reference → [common-patterns.md § 17](references/common-patterns.md#17-entity-identifier-preservation-on-raw-listings).
7. **Broad entity-security questions require the external stream too.** For prompts like "security findings of this host / K8s node / workload / cluster", do not stop after DT RVA and SPM. Also run the external/cross-provider `*_FINDING` stream scoped with the wide entity OR chain (`dt.smartscape_source.id`, `dt.entity.*`, `object.*`, and relevant `k8s.*` fields) in `24h`, then merge with RVA (`30m`) and SPM (`1h`). Treat `dt.source_entity` as a legacy/scan fallback, not a primary cross-provider scoping path. If the external branch returns 0 rows, report "no external findings found" with the scope used. Load **dt-sec-contextualization** → `entity-enrichment.md` for the Smartscape join. See also [all-security-events.md](references/all-security-events.md).
8. **Always bound raw listing and top-N results.** If the user asks for "top X", "last X", or "first X", end with `| sort ... | limit X` (after the ranking/sort). If the user asks to list/show findings but does **not** explicitly ask for all data and the output is not a summary (`summarize` / `makeTimeseries`), add `| limit 50` by default. Do not run unbounded raw projections on security findings. Full rule and exceptions → [common-patterns.md § 16](references/common-patterns.md#16-result-limits-for-top-n-and-raw-listings).
9. **Preserve query shape — do not drop `by:` keys unless the user asks for coarser aggregation.** The canonical cross-provider `summarize` always keys by `{event.provider, product.name, event.type, dt.security.risk.level}`. Dropping any of these silently merges rows from different providers, products, or finding types into a single count and will be penalized by evaluators. Do not replace the four-key grouping with a simpler `by: {dt.security.risk.level}` or `by: {event.type}` unless the user explicitly requests a coarser view. See [common-patterns.md § 15](references/common-patterns.md).
10. **Compliance status uses `compliance.result.status.level`** (`PASSED`/`FAILED`/`MANUAL`/`NOT_RELEVANT`) — never `event.status` or `"PASS"`/`"FAIL"`. Pass rate is computed on per-rule verdicts after the latest-scan dedup join `on: {scan.id}`. Field terminology, the dedup join, and the pass-rate rollup → [compliance.md](references/compliance.md).
11. **Count distinct identities, not rows, after `expand` / `join` / `lookup`** that fan out arrays — use `countDistinctExact(vulnerability.display_id)` / `countDistinctExact(finding.id)`, or `dedup` on identity + group key first. A plain `count()` is safe only when the grain entering the `expand` is already one row per counted item. Examples and the exception → [common-patterns.md § Mistakes #55](references/common-patterns.md#mistakes-to-avoid).
12. **Interpret empty entity-coverage probes as not covered.** When validating whether a specific entity is covered by a Dynatrace security capability (RVA, SPM/KSPM, RAP, or another DT-native capability), absence of the relevant findings and scan/completion events means the entity is **not covered** by that capability. State the likely cause: the capability is not enabled, or it is not configured / deployed to monitor that entity. Do not soften this into "no findings" when the user asked about coverage. Counting logic → [coverage-and-dashboards.md](references/coverage-and-dashboards.md); match recipes → **dt-sec-contextualization** `correlation-and-coverage.md`.
13. **Report empty results truthfully — never fabricate numbers.** 0 rows means "no matching data," stated with the scope and filters used; never invent plausible values. Before relaxing a filter, apply the family's documented recovery (RVA 30m→24h latest-known-state, detections 2h→24h widen, RVA filters on null `k8s.*`/`dt.entity.*`/`dt.smartscape*` → pivot to `affected_entity.*`/`related_entities.*`) and say so explicitly if you adapt. Recovery details → [vulnerabilities-dynatrace.md](references/vulnerabilities-dynatrace.md) / [detections.md](references/detections.md).
For domain-specific best practices and the full diagnostic catalog, see the references listed in the "How This Skill Is Organized" section above.
## External Documentation
- https://docs.dynatrace.com/docs/secure/threat-observability/concepts
- https://docs.dynatrace.com/docs/semantic-dictionary/model/security-events
- https://docs.dynatrace.com/docs/semantic-dictionary/fields
## Related Skills
- **dt-dql-essentials** — Load first. Core DQL syntax, command reference, function catalog, Smartscape patterns.
- **dt-sec-contextualization** — Load for any entity-mapping question: 3-way match (K8s workload), host-by-IP, cloud Path 1, pod→node topology, cross-evidence correlation, coverage match recipes.
- **dt-obs-kubernetes** — K8s topology; useful for security findings scoped to clusters / workloads
- **dt-obs-hosts** — Host inventory, process-level context; useful when a finding's affected entity is a HOST or PROCESS_GROUP
- **dt-obs-services** — Service-scoped queries; useful for UC-G3 "findings affecting `<service-name>`" and tracing from a vulnerable service to RED metrics
- **dt-obs-aws / dt-obs-azure / dt-obs-gcp** — Cloud Smartscape; useful for enriching external cloud-security findings against the provider's resource topology, and for hyperscaler-specific provider field handling (cloud resource IDs, ARNs, account scoping)
- **dt-obs-tracing** — Drill from a vulnerable / attacked entity to representative request traces
- **dt-obs-problems** — Get affected/related entity IDs for a problem before querying security findings (UC-G5)