Skip to content

Commit 1d495f8

Browse files
committed
Add trigger field discovery, fix EPP paths, verify VT enrichment end-to-end
Add a --fields <category> mode to trigger_search.py that pulls a trigger's full payload field tree from the search_triggers API and prints ready-to-use ${data['Trigger....']} references, so authoring never has to guess a field path. Verified live against the EPP trigger (94 paths, including Trigger.Detection.EPP.Process.SHA256). Correct the EPP trigger field paths in both trigger-types.md copies: the payload namespace is Trigger.Detection.EPP.*, not Trigger.Category.Investigatable.Product.EPP.*, which is rejected at release as an unknown variable. Also fix the EPP event value (Investigatable/EPP) and the severity path (Trigger.Detection.Severity). Fix use-cases/http-actions.md: replace the .HTTP.body. response-reference form (rejected at release) with the direct ${data['Action.field']} form, rename the console button to Schema builder, warn about the runtime 406 from a too-strict output schema, note that _cs_inline_output_schema is required for downstream references, and add a section on formatting an LLM summary for email (Markdown vs HTML vs JSON schema, including the no-code-fences instruction). Ship a live-verified example, enrich-ip-virustotal-llm-email.yaml: an on-demand IP enrichment that reads real VirusTotal fields into a variable, summarizes with Charlotte AI as HTML, and emails the report. Carries both the release-time _cs_inline_output_schema and the runtime inline_configuration.output_schema. Add four unit tests for the new --fields flattening and CLI dispatch (495 tests pass).
1 parent cc79294 commit 1d495f8

9 files changed

Lines changed: 386 additions & 32 deletions

File tree

skills/authoring/SKILL.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ it is a plugin action (needs a `config_id`).
165165
${CLAUDE_PLUGIN_ROOT}/bin/python.sh scripts/trigger_search.py --list
166166
${CLAUDE_PLUGIN_ROOT}/bin/python.sh scripts/trigger_search.py --type "On demand"
167167
${CLAUDE_PLUGIN_ROOT}/bin/python.sh scripts/trigger_search.py --events detection # Signal event: values
168+
${CLAUDE_PLUGIN_ROOT}/bin/python.sh scripts/trigger_search.py --fields Investigatable/EPP # payload field paths
168169
```
169170

170171
Valid trigger types: **On demand**, **Signal**, **Scheduled**, **SubModel**.
@@ -173,7 +174,10 @@ For most automation, use **On demand** (callable via API and the Falcon UI).
173174
A **Signal** trigger MUST carry an `event:` field (the trigger category, e.g.
174175
`Investigatable/NGSIEM`) — without it, import fails with `code 2003: "unknown
175176
trigger event named "`. Find the value with `trigger_search.py --events` and do
176-
NOT add a hex `id` to the trigger. See `references/trigger-types.md`.
177+
NOT add a hex `id` to the trigger. For a Signal trigger, discover the exact
178+
payload field paths (the `${data['Trigger....']}` references you can read
179+
downstream) with `trigger_search.py --fields <category>` — do NOT guess them.
180+
See `references/trigger-types.md`.
177181

178182
### 3. Author the YAML from a template
179183

@@ -226,7 +230,7 @@ via the shared `sys.path` pattern.
226230
| Script | Purpose | Key flags |
227231
|--------|---------|-----------|
228232
| `action_search.py` | Discover actions and vendors | `--search`, `--details`, `--list`, `--vendors`, `--vendor`, `--use-case`, `--limit`, `--offset`, `--json`, `--clear-cache` |
229-
| `trigger_search.py` | List/describe trigger types; list Signal `event:` values | `--list`, `--type`, `--events`, `--json` |
233+
| `trigger_search.py` | List/describe trigger types; list Signal `event:` values; list a trigger's payload field paths | `--list`, `--type`, `--events`, `--fields`, `--json` |
230234
| `validate.py` | Validate workflow YAML | `--preflight-only`, multiple files |
231235

232236
**`action_search.py` cache:** Full-catalog scans (`--vendors`, `--use-case`) are

skills/authoring/examples/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ network containment, notifications, and more.
99

1010
| Category | Count | Description |
1111
|----------|-------|-------------|
12-
| `threat-intel/` | 4 | IOC enrichment with VirusTotal, AbuseIPDB, Pulsedive, and Zscaler blocklisting. |
12+
| `threat-intel/` | 5 | IOC enrichment with VirusTotal, AbuseIPDB, Pulsedive, and Zscaler blocklisting. |
1313
| `identity-response/` | 2 | Identity detection response and phishing remediation with Identity Threat Protection. |
1414
| `notifications/` | 2 | Alert routing, Slack notifications, and human-approved endpoint containment. |
1515
| `ngsiem/` | 1 | Falcon Next-Gen SIEM duplicate-detection management. |
@@ -24,6 +24,7 @@ network containment, notifications, and more.
2424
- [IP Address Enrichment AbuseIPDB](threat-intel/ip-address-enrichment-abuseipdb.yaml) — parallel fan-out (multi-target `next:`) and input gating (`cs.ip.valid`)
2525
- [Enrich URL on-demand with VirusTotal and add to Zscaler blocklist](threat-intel/enrich-url-virustotal-zscaler-blocklist.yaml)
2626
- [Domain Enrichment Pulsedive](threat-intel/domain-enrichment-pulsedive.yaml) — fan-out → converge: parallel Pulsedive lookups store their real response objects in `WorkflowCustomVariable`, read by downstream comment/tag actions
27+
- [Enrich an IP with VirusTotal and email an AI summary](threat-intel/enrich-ip-virustotal-llm-email.yaml) — on-demand `ip` enrichment → stores real VT fields in a variable → Charlotte AI HTML summary → email; shows both response-schema blocks (`_cs_inline_output_schema` for release-time refs, `inline_configuration.output_schema` for runtime 406 avoidance)
2728

2829
### identity-response
2930

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Example: Enrich an IP with VirusTotal, summarize with Charlotte AI, email the report
2+
# Category: threat-intel
3+
# Source: Authored with these skills and verified live end-to-end against a CID
4+
# (import + release + on-demand execution). On-demand trigger takes an `ip`
5+
# parameter; the Cloud HTTP Request enriches it with VirusTotal, an UpdateVariable
6+
# stores the REAL response fields, Charlotte AI summarizes them, and Send email
7+
# delivers the report.
8+
#
9+
# Two schema blocks matter here and are easy to confuse:
10+
# - http_transaction._cs_inline_output_schema — lets downstream actions RESOLVE
11+
# ${data['<Action>.data...']} references at RELEASE time.
12+
# - inline_configuration.output_schema — the shape the response is VALIDATED
13+
# against at RUNTIME. If it omits/mistypes a field the API returns, the action
14+
# fails with a 406 ("script output does not validate against the output JSON
15+
# schema") that no local check, import, or release catches. Capture the real
16+
# shape in the console: attach the credential, click Test, then Schema builder.
17+
#
18+
# Credential-less by design: no definition_id. The action imports with
19+
# Authentication = "None"; attach the VirusTotal API key in the console after
20+
# deploy (open the action -> Authentication -> Create new -> API key -> secret key
21+
# -> location Header -> header name x-apikey -> Test -> Schema builder -> Save).
22+
name: 'Enrich IP with VirusTotal and email an AI summary'
23+
description: 'On demand: enrich a supplied IP with VirusTotal, store the real response fields in a variable, summarize with Charlotte AI, and email an HTML report.'
24+
trigger:
25+
next:
26+
- CreateVariable
27+
name: On demand
28+
parameters:
29+
$schema: https://json-schema.org/draft-07/schema
30+
properties:
31+
ip:
32+
type: string
33+
title: IP address
34+
description: IP address to enrich with VirusTotal
35+
notify_email:
36+
type: string
37+
title: Notify email
38+
description: Recipient for the enrichment report (a Falcon user or CID-approved domain)
39+
required:
40+
- ip
41+
- notify_email
42+
type: object
43+
type: On demand
44+
actions:
45+
CreateVariable:
46+
id: 702d15788dbbffdf0b68d8e2f3599aa4
47+
class: CreateVariable
48+
name: Create variable - Initialize enrichment results
49+
next:
50+
- CloudHTTPRequestVirusTotalIPEnrichment
51+
properties:
52+
variable_schema:
53+
properties:
54+
ip_enrichment:
55+
type: string
56+
type: object
57+
version_constraint: ~1
58+
CloudHTTPRequestVirusTotalIPEnrichment:
59+
id: 1ba474f407d9228fc8fa02cdce8ae8ef
60+
class: Inline.HTTPRequest
61+
name: Cloud HTTP Request - VirusTotal IP Enrichment
62+
next:
63+
- UpdateVariableIP
64+
inline_configuration:
65+
output_schema:
66+
$schema: https://json-schema.org/draft-07/schema
67+
properties:
68+
data:
69+
properties:
70+
attributes:
71+
properties:
72+
as_owner:
73+
type: string
74+
country:
75+
type: string
76+
last_analysis_stats:
77+
properties:
78+
harmless:
79+
type: integer
80+
malicious:
81+
type: integer
82+
suspicious:
83+
type: integer
84+
undetected:
85+
type: integer
86+
type: object
87+
reputation:
88+
type: integer
89+
type: object
90+
id:
91+
type: string
92+
type: object
93+
type: object
94+
properties:
95+
http_transaction:
96+
request_http_method: GET
97+
request_url: "https://www.virustotal.com/api/v3/ip_addresses/${data['ip']}"
98+
request_content_type: NONE
99+
request_headers: {}
100+
request_body: '{}'
101+
_cs_inline_output_schema: '{"$schema":"https://json-schema.org/draft-07/schema","properties":{"data":{"properties":{"attributes":{"properties":{"last_analysis_stats":{"properties":{"malicious":{"type":"integer"},"suspicious":{"type":"integer"},"harmless":{"type":"integer"},"undetected":{"type":"integer"}},"type":"object"},"reputation":{"type":"integer"},"country":{"type":"string"},"as_owner":{"type":"string"}},"type":"object"},"id":{"type":"string"}},"type":"object"}},"type":"object"}'
102+
version_constraint: ~1
103+
UpdateVariableIP:
104+
id: 6c6eab39063fa3b72d98c82af60deb8a
105+
class: UpdateVariable
106+
name: Update variable - Store IP enrichment
107+
next:
108+
- SummarizeEnrichment
109+
properties:
110+
WorkflowCustomVariable:
111+
ip_enrichment: "IP: ${data['ip']} | Malicious: ${data['CloudHTTPRequestVirusTotalIPEnrichment.data.attributes.last_analysis_stats.malicious']} | Suspicious: ${data['CloudHTTPRequestVirusTotalIPEnrichment.data.attributes.last_analysis_stats.suspicious']} | Reputation: ${data['CloudHTTPRequestVirusTotalIPEnrichment.data.attributes.reputation']} | Country: ${data['CloudHTTPRequestVirusTotalIPEnrichment.data.attributes.country']} | Owner: ${data['CloudHTTPRequestVirusTotalIPEnrichment.data.attributes.as_owner']}"
112+
version_constraint: ~1
113+
SummarizeEnrichment:
114+
id: bdfecafafdb44919a458fcf51d6b93a7_98dec86072334d24b37dd798098cfd63
115+
name: Charlotte AI - LLM Completion - Summarize TI enrichment
116+
next:
117+
- SendEmail
118+
properties:
119+
data_to_include:
120+
- ${data['WorkflowCustomVariable.ip_enrichment']}
121+
model_name: Claude Sonnet 4
122+
temperature: 0
123+
user_prompt: "You are a CrowdStrike threat analyst. Summarize the VirusTotal enrichment result for the IP below: reputation, malicious/suspicious verdict counts, country, and owner. Give an overall risk assessment and recommended next steps. Be concise and actionable. Respond with raw HTML only (headings, lists, bold) — do NOT wrap the response in markdown code fences such as ```html."
124+
version_constraint: ~0
125+
SendEmail:
126+
id: 07413ef9ba7c47bf5a242799f59902cc
127+
name: Send email - TI enrichment summary
128+
properties:
129+
to:
130+
- ${data['notify_email']}
131+
subject: "[Falcon Fusion] VirusTotal IP Enrichment Report - ${data['ip']}"
132+
msg: "<html><body><h2>VirusTotal IP Enrichment Report</h2><p><strong>IP:</strong> ${data['ip']}</p><hr/><h3>Stored enrichment variable</h3><p>${data['WorkflowCustomVariable.ip_enrichment']}</p><hr/><h3>Charlotte AI Summary</h3><div>${data['SummarizeEnrichment.FaaS.nlpassistantapi.llminvocator_handler.completion']}</div></body></html>"
133+
msg_type: html
134+
version_constraint: ~1
135+
output_fields: []

skills/authoring/references/http-actions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ attach a credential there. Then tell the user the console steps:
7474
> In the workflow, open the Cloud HTTP Request action → **Authentication** →
7575
> **Create new** → Configuration name: (e.g. `VirusTotal`) → Authentication type:
7676
> **API key** → API secret key: `<your key>` → API key location: **Header** →
77-
> Header name: `x-apikey` (per the API's docs) → **Test** → **Generate schema** →
77+
> Header name: `x-apikey` (per the API's docs) → **Test** → **Schema builder** →
7878
> Save. (If a matching credential already exists, pick **Use existing** instead.)
7979

8080
Source: [Build API Integrations with Falcon Fusion SOAR HTTP Actions](https://www.crowdstrike.com/tech-hub/ng-siem/build-api-integrations-with-falcon-fusion-soar-http-actions/).

skills/authoring/references/trigger-types.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,30 @@ For an event source not in this table, discover its `event:` (category) with
100100
trigger:
101101
next:
102102
- FirstAction
103-
name: Detection
104-
event: Investigatable
103+
name: Detection > EPP Detection
104+
event: Investigatable/EPP
105105
type: Signal
106106
version_constraint: ~1
107107
```
108108

109-
Available fields: `${Trigger.Category.Investigatable.Product.EPP.Sensor.SensorID}`,
110-
`${Trigger.Category.Investigatable.Product.EPP.Sensor.Hostname}`,
111-
`${Trigger.Category.Investigatable.Product.EPP.URL}`,
112-
`${Trigger.Category.Investigatable.InvestigatableID}`,
113-
`${Trigger.Category.Investigatable.Severity}`
109+
EPP detection payload fields live under the **`Trigger.Detection.EPP.*`** namespace
110+
(release-verified). Discover the full set — 90+ paths — with
111+
`trigger_search.py --fields Investigatable/EPP`; do NOT guess. Common ones:
114112

115-
**Severity is an integer (1-5), not a string.** Use numeric comparison in CEL conditions:
113+
- `${data['Trigger.Detection.EPP.Process.SHA256']}` — offending process hash
114+
(the enrichment target; also `.MD5`). Parent/grandparent hashes live at
115+
`Trigger.Detection.EPP.ParentProcess.SHA256` and `.GrandParentProcess.SHA256`.
116+
- `${data['Trigger.Detection.EPP.Behavior.IOCValue']}` + `.IOCType` — the IOC and its type.
117+
- `${data['Trigger.Detection.EPP.Sensor.Hostname']}` / `.SensorID` / `.ExternalIP` / `.LocalIP`.
118+
- `${data['Trigger.Detection.DetectionID']}`, `${data['Trigger.Detection.Name']}`,
119+
`${data['Trigger.Detection.SeverityDisplayName']}`.
120+
121+
**These are NOT under `Trigger.Category.Investigatable.*`** — that namespace is
122+
rejected at release as "unknown variable" for EPP triggers. Enrich indicators
123+
straight from the trigger payload (e.g. send `Process.SHA256` to VirusTotal);
124+
you do not need an Event Query to hydrate the detection.
125+
126+
**Severity is an integer (1-5) at `Trigger.Detection.Severity`, not a string.** Use numeric comparison in CEL conditions:
116127

117128
| Value | Display Name |
118129
|-------|-------------|
@@ -123,11 +134,11 @@ Available fields: `${Trigger.Category.Investigatable.Product.EPP.Sensor.SensorID
123134
| 5 | Critical |
124135

125136
```yaml
126-
# Correct: numeric comparison
127-
cel_expression: "data['Trigger.Category.Investigatable.Severity'] >= 4"
137+
# Correct: numeric comparison on the release-verified path
138+
cel_expression: "data['Trigger.Detection.Severity'] != null && data['Trigger.Detection.Severity'] >= 4"
128139
129140
# Wrong: string comparison (field is numeric, not a string)
130-
cel_expression: "data['Trigger.Category.Investigatable.Severity'] == 'Critical'"
141+
cel_expression: "data['Trigger.Detection.Severity'] == 'Critical'"
131142
```
132143

133144
#### NG-SIEM detection (`event: Investigatable/NGSIEM`)

skills/authoring/scripts/trigger_search.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
python trigger_search.py --type "On demand" # YAML structure for a type
1010
python trigger_search.py --events # All Signal event values (API)
1111
python trigger_search.py --events detection # Filter event values by text
12+
python trigger_search.py --fields Investigatable/EPP # Payload field paths for a trigger
1213
python trigger_search.py --list --json # Machine-readable output
1314
"""
1415

@@ -60,7 +61,8 @@
6061
"case, identity event, etc.). trigger.type is always 'Signal' and the "
6162
"trigger MUST carry an 'event' field naming the event source "
6263
"(the trigger category, e.g. 'Investigatable/NGSIEM'). Find event "
63-
"values with --events."
64+
"values with --events, and the payload field paths a trigger delivers "
65+
"with --fields <category>."
6466
),
6567
"yaml_example": """\
6668
trigger:
@@ -155,6 +157,72 @@ def search_event_triggers(query=None):
155157
return triggers
156158

157159

160+
def _flatten_trigger_fields(fields, prefix=""):
161+
"""Flatten a trigger's recursive fields[] tree into (path, type, display) rows.
162+
163+
Top-level field `name` values are already fully-qualified dotted paths
164+
(e.g. ``Trigger.Detection.DetectionID``). Nested `fields[]` children carry
165+
only a relative `name` (e.g. ``Tactic`` under ``Trigger.Detection.MitreAttack``),
166+
so those are joined onto the parent path with a dot.
167+
"""
168+
rows = []
169+
for field in fields or []:
170+
name = field.get("name", "")
171+
path = f"{prefix}.{name}" if prefix else name
172+
children = field.get("fields")
173+
if children:
174+
rows.extend(_flatten_trigger_fields(children, path))
175+
else:
176+
rows.append((path, field.get("type", ""), field.get("display", "")))
177+
return rows
178+
179+
180+
def search_trigger_fields(category):
181+
"""Return the payload field paths a trigger delivers, for a given category.
182+
183+
`category` is a Signal `event:` value (e.g. ``Investigatable/EPP``). Returns
184+
a list of {path, type, display} dicts describing every leaf field in the
185+
trigger payload — the exact ``${data['Trigger....']}`` references available
186+
to downstream actions. Returns an empty list if the category is unknown.
187+
"""
188+
try:
189+
client = get_client()
190+
resp = client.search_triggers(filter=f"category:'{category}'")
191+
if not isinstance(resp, dict):
192+
return []
193+
resources = resp.get("body", {}).get("resources", [])
194+
except (ConnectionError, RuntimeError, OSError):
195+
return []
196+
197+
if not resources:
198+
return []
199+
rows = _flatten_trigger_fields(resources[0].get("fields", []))
200+
rows.sort(key=lambda r: r[0])
201+
return [{"path": p, "type": t, "display": d} for p, t, d in rows]
202+
203+
204+
def _print_fields(category, as_json):
205+
"""Print the payload field paths for a trigger category (from the API)."""
206+
fields = search_trigger_fields(category)
207+
if as_json:
208+
print(json.dumps(fields, indent=2))
209+
return
210+
if not fields:
211+
print(
212+
f"No fields found for category '{category}'. Check the value with "
213+
"--events, or verify credentials."
214+
)
215+
return
216+
print(f"\nPayload fields for '{category}' ({len(fields)}):\n")
217+
print(" Reference any of these downstream as ${data['<path>']}.\n")
218+
for field in fields:
219+
print(f" ${{data['{field['path']}']}}")
220+
meta = field["type"] + (f" — {field['display']}" if field["display"] else "")
221+
if meta.strip():
222+
print(f" {meta}")
223+
print()
224+
225+
158226
def list_all_triggers():
159227
"""Return the built-in catalog of the four trigger types."""
160228
return {name: info.copy() for name, info in TRIGGER_CATALOG.items()}
@@ -232,11 +300,21 @@ def main():
232300
help="List Signal event sources (name -> event value) from the API, "
233301
"optionally filtered by QUERY (e.g. --events detection)",
234302
)
303+
group.add_argument(
304+
"--fields",
305+
"-f",
306+
metavar="CATEGORY",
307+
help="List the payload field paths a trigger delivers, for a Signal "
308+
"category (e.g. --fields Investigatable/EPP). Prints ready-to-use "
309+
"${data['Trigger....']} references so you never guess a field path.",
310+
)
235311
parser.add_argument("--json", action="store_true", help="Machine-readable JSON output")
236312
args = parser.parse_args()
237313

238314
if args.events is not None:
239315
_print_events(args.events, args.json)
316+
elif args.fields:
317+
_print_fields(args.fields, args.json)
240318
elif args.list:
241319
_print_list(list_all_triggers(), args.json)
242320
elif args.type:

0 commit comments

Comments
 (0)