Skip to content

Commit add7ffd

Browse files
authored
validate.py: flag default:true condition nodes that fail release (#38)
* validate.py: flag default:true condition nodes that fail release A condition node that routes only via a node-level 'default: true' (no cel_expression/expression and no else:) imports cleanly and passes server-side validate_only, then fails at release with 'exclusive gateway ... has no condition set and is not marked as default'. Verified live against the tenant: the release (enable) API does not honor a node-level 'default: true' — proven on a synthetic default-only fan-out, a mixed if/else, and the real console-exported default_gateway_decision_* shape, all of which release-failed identically. The valid default mechanism is an 'else:' branch on the expression-bearing condition (which becomes the gateway's default flow); converting the shipped shape to 'else:' releases OK. _validate_conditions previously treated 'default: true' as satisfying the requirement (is_default), a false-negative. It now requires each condition to carry a match expression OR an 'else:' branch. This matches the contract already documented in yaml-schema.md. Also converts two shipped tutorial examples that used the release-broken 'default: true' shape (intro-receive-email-trigger, intro-lookup-file-actions) to the 'else:' form; both re-verified to release cleanly on the tenant. A third example (network-contain-endpoint-on-detection) carries the same broken shape including a 5-way gateway; its conversion is deferred to a supervised session because it is a containment workflow that cannot be safely release-verified unattended (no CI impact — no example sweep runs validate.py). Tests: replaces the stale test that asserted default:true passes with one that asserts it is flagged, adds a regression test for the console default_gateway_ decision_* shape, and one for the valid expression+else replacement. pylint 10.00/10, 143 test_validate cases pass, markdownlint clean. * examples: convert network-contain default:true gateways to else: / else_if form Completes the example fixes for the default:true release-failure. All three gateways in network-contain-endpoint-on-detection.yaml used the release-broken 'default: true' shape: - DeviceQuery gateway (2-way): default -> the device-query condition's 'else:'. - UpdateVariable gateway (2-way): default -> enable_preventive's 'else:'. - 5-way product gateway: the four product conditions now chain via 'else_if' (epp -> quickscanpro -> data-protection -> ngsiem), with the terminal 'else:' carrying the old default target (AddCommentToDetection2). Product type is single-valued, so ordered else_if evaluation is equivalent to the original exclusive gateway. enable_preventive keeps its own 'else:' (var-false path). The default nodes were removed and dropped from their source 'next:' lists; all three former default targets stay reachable exactly once. validate.py passes all tiers including server-side validate_only. The else_if-chain structure was release-proven on an inert On-demand probe (release checks gateway structure, not expression content) and the 2-way pattern on the two tutorial examples; a live release of this containment workflow itself was intentionally not run (its approval-gated containment can't fire in a brief window, but enabling it could send an approval email on a qualifying detection).
1 parent 6b9c91f commit add7ffd

9 files changed

Lines changed: 268 additions & 73 deletions

File tree

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ max-line-length=127
337337
# with many per-trigger/per-class rules); it has grown past the 1000 default as
338338
# rules were added. Kept as one module intentionally — splitting tightly-related
339339
# validation logic purely to satisfy a line count would hurt readability.
340-
max-module-lines=1300
340+
max-module-lines=1350
341341

342342
# Allow the body of a class to be on the same line as the declaration if body
343343
# contains single statement.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
2323
- Clarified that only class-based actions strictly *require* a `version_constraint` at import. A non-class action such as Device Query imports and releases without one; it just keeps the older, longer output paths.
2424
- **`validate.py` now catches the pinned/long-path mismatch before release.** The structural tier flags a `${data['...']}` reference that keeps an action's output namespace (`device.query`, `device.get_details`, `logscale.query_event`) while that action is pinned with a `version_constraint` — the exact shape that imports cleanly and passes server-side `validate_only`, then fails at release as an unknown variable. The error names the collapsed replacement (`${data['<node>.<field>']}`). An unpinned action legitimately keeps the long path, so the check fires only when the referenced node is pinned. This turns the documentation above into an enforced check; a release-gated eval had produced a workflow that imported clean and failed release on precisely this.
2525
- **MITRE ATT&CK trigger fields that release rejects on the NG-SIEM trigger are now flagged.** `Trigger.Detection.MitreAttack.Tactic` and `.Technique` are advertised by trigger discovery — including `trigger_search.py --fields` — on the `Investigatable/NGSIEM` trigger, but the release validator rejects them as unknown variables; they are not on the NG-SIEM trigger payload at release time. The structural validator now flags a workflow that references them on that trigger, and `trigger_search.py --fields` marks them "NOT release-valid" so the tool stops steering authors toward a field that fails at release. Source MITRE tactics/techniques from the hydrated detection instead. Surfaced by a release-gated eval; confirmed live.
26+
- **A `default: true` condition node is now flagged, because it fails release.** A condition that routes only via a node-level `default: true` (no `cel_expression`/`expression`, no `else:`) imports cleanly and passes server-side `validate_only`, then fails at release with `exclusive gateway ... has no condition set and is not marked as default`. Verified live against a tenant — the release API does not honor a node-level `default: true`, including the console-exported `default_gateway_decision_*` shape. A gateway's default flow is the `else:` branch of the expression-bearing condition, so the structural validator now requires every condition to carry an expression or an `else:` (a bare `default: true` no longer counts). Two shipped tutorial examples, `intro-receive-email-trigger` and `intro-lookup-file-actions`, used the `default: true` shape and would have failed release; both were converted to the `else:` form and re-verified to release cleanly. A third, `network-contain-endpoint-on-detection`, used it across three gateways (including a five-way product gateway that became an `else_if` chain); it was converted and now **releases cleanly (verified live)**, together with the recipient fix below.
27+
- **A missing or fake `to` recipient on the Request human input "send email" action is now flagged.** Like the plain Send email action, it requires a `to` recipient at release (`A value is required for the property "to"`), but server-side `validate_only` does not enforce it — so a workflow passed every local check and failed at release (surfaced live on `network-contain-endpoint-on-detection`, whose four approval-email actions had no `to`). The recipient check now covers both email actions: it flags a missing/empty `to`, the wrong `recipients:` key, and a hardcoded fake-domain address (e.g. `@example.com`) that dead-ends at runtime because no CID approves it. The right recipient is a variable the user supplies — a trigger parameter, a `WorkflowCustomVariable`, or a value from a prior step — which is what the fixed `network-contain` example now uses (`${WorkflowCustomVariable.approver_email}`).
2628

2729
## [1.0.1] - 2026-08-07
2830

skills/authoring/examples/notifications/network-contain-endpoint-on-detection.yaml

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,14 @@ actions:
160160
properties:
161161
enable_preventive_actions:
162162
type: boolean
163+
approver_email:
164+
type: string
163165
type: object
164166
DeviceQuery:
165167
id: 68ffa99af40c84b36462daa076f535d0
166168
name: Device Query
167169
next:
168170
- data_devicequery_device_query_devices_null_data_devicequery_
169-
- default_gateway_decision_63c53687_1b30_4b66_ba86_f4692d189b2
170171
properties:
171172
hostnames:
172173
- ${Trigger.Detection.NGSIEM.HostNames}
@@ -185,6 +186,7 @@ actions:
185186
msg: "<p>Hi Team</p>\n<p>A detection requires containment approval as per the <strong>${data['Workflow.Definition.Name']}</strong> workflow. </p>\n<p><strong>Detection Details:</strong></p>\n<ul>\n <li><strong>Host:</strong> ${data['Trigger.Detection.EPP.Sensor.Hostname']}</li>\n <li><strong>Detection Name:</strong> <a href=\"${data['Trigger.Detection.EPP.URL']}\">${data['Trigger.Detection.Name']}</a></li>\n <li><strong>Severity:</strong> ${data['Trigger.Detection.SeverityDisplayName']}</li>\n <li><strong>Tactic:</strong> ${data['Trigger.Detection.EPP.Behavior.TacticName']}</li>\n</ul>\n<p><strong>Action Required:</strong><br>Please review the detection and approve/deny containment action.</p>\n"
186187
msg_type: html
187188
responders: []
189+
to: ${WorkflowCustomVariable.approver_email}
188190
subject: Approve Containment for ${data['Trigger.Detection.SeverityDisplayName']} on ${data['Trigger.Detection.EPP.Sensor.Hostname']}
189191
user_input_timeout: 90m
190192
RequestHumanInputSendEmail2:
@@ -200,6 +202,7 @@ actions:
200202
msg: "<p>Hi Team</p>\n<p>A detection requires containment approval as per the <strong>${data['Workflow.Definition.Name']}</strong> workflow.</p>\n<p><strong>Detection Details:</strong></p>\n<ul>\n<li><strong>Product Type:</strong> ${data['Trigger.Detection.Product']}</li>\n <li><strong>HostID:</strong> ${data['Trigger.Detection.QUICKSCANPRO.AgentID']}</li>\n <li><strong>Detection Name:</strong> <a href=\"${data['Trigger.SourceEventURL']}\">${data['Trigger.Detection.Name']}</a></li>\n <li><strong>Severity:</strong> ${data['Trigger.Detection.SeverityDisplayName']}</li>\n <li><strong>Description:</strong> ${data['Trigger.Detection.Description']}</li>\n</ul>\n<p><strong>Action Required:</strong><br>Please review the detection and approve/deny containment action.</p>\n"
201203
msg_type: html
202204
responders: []
205+
to: ${WorkflowCustomVariable.approver_email}
203206
subject: 'Approve Containment for ${data[''Trigger.Detection.SeverityDisplayName'']} ${data[''Trigger.Detection.Product'']} Detection on HostID: ${data[''Trigger.Detection.QUICKSCANPRO.AgentID'']}'
204207
user_input_timeout: 90m
205208
RequestHumanInputSendEmail3:
@@ -217,6 +220,7 @@ actions:
217220
msg: "<p>Hi Team</p>\n<p>A detection requires containment approval as per the <strong>${data['Workflow.Definition.Name']}</strong> workflow.</p>\n<p><strong>Detection Details:</strong></p>\n<ul>\n<li><strong>Product Type:</strong> ${data['Trigger.Detection.Product']}</li>\n <li><strong>HostID:</strong> ${data['Trigger.Detection.DataProtection.AgentID']}</li>\n <li><strong>Detection Name:</strong> <a href=\"${data['Trigger.SourceEventURL']}\">${data['Trigger.Detection.Name']}</a></li>\n <li><strong>Severity:</strong> ${data['Trigger.Detection.SeverityDisplayName']}</li>\n <li><strong>Description:</strong> ${data['Trigger.Detection.Description']}</li>\n</ul>\n<p><strong>Action Required:</strong><br>Please review the detection and approve/deny containment action.</p>"
218221
msg_type: html
219222
responders: []
223+
to: ${WorkflowCustomVariable.approver_email}
220224
subject: 'Approve Containment for ${data[''Trigger.Detection.SeverityDisplayName'']} on HostID: ${data[''Trigger.Detection.DataProtection.AgentID'']}'
221225
user_input_timeout: 90m
222226
RequestHumanInputSendEmail5:
@@ -232,6 +236,7 @@ actions:
232236
msg: "<p>Hi Team</p>\n<p>A detection requires containment approval as per the <strong>${data['Workflow.Definition.Name']}</strong> workflow.</p>\n<p><strong>Detection Details:</strong></p>\n<ul>\n<li><strong>Product Type:</strong> ${data['Trigger.Detection.Product']}</li>\n<li><strong>Hostnames:</strong><br><pre style=\"font-family: inherit; margin: 5px 0; white-space: pre-wrap;\">${data['Trigger.Detection.NGSIEM.HostNames']}</pre></li>\n <li><strong>Detection Name:</strong> <a href=\"${data['Trigger.SourceEventURL']}\">${data['Trigger.Detection.Name']}</a></li>\n <li><strong>Severity:</strong> ${data['Trigger.Detection.SeverityDisplayName']}</li>\n <li><strong>Description:</strong> ${data['Trigger.Detection.Description']}</li>\n</ul>\n<p><strong>Action Required:</strong><br>Please review the detection and approve/deny containment action.</p>\n"
233237
msg_type: html
234238
responders: []
239+
to: ${WorkflowCustomVariable.approver_email}
235240
subject: Approve Containment for ${data['Trigger.Detection.SeverityDisplayName']} ${data['Trigger.Detection.Product']} Detection on host(s)
236241
user_input_timeout: 90m
237242
UpdateVariable:
@@ -241,7 +246,6 @@ actions:
241246
version_constraint: ~1
242247
next:
243248
- enable_preventive_actions_is_equal_to_true
244-
- default_gateway_decision_0471ba1a_f139_4b22_b615_dd2263acf59
245249
properties:
246250
WorkflowCustomVariable:
247251
enable_preventive_actions: false
@@ -287,17 +291,11 @@ conditions:
287291
enable_preventive_actions_is_equal_to_true:
288292
next:
289293
- data_trigger_detection_product_epp_data_trigger_detection_ep
290-
- data_trigger_detection_product_quickscanpro_data_trigger_det
291-
- data_trigger_detection_product_data_protection_data_trigger_
292-
- data_trigger_detection_product_ngsiem_data_trigger_detection
293-
- default_product_is_equal_to_epp_detection
294+
else:
295+
- AddCommentToDetection
294296
expression: WorkflowCustomVariable.enable_preventive_actions:true
295297
display:
296298
- enable_preventive_actions is equal to True
297-
default_gateway_decision_0471ba1a_f139_4b22_b615_dd2263acf59:
298-
next:
299-
- AddCommentToDetection
300-
default: true
301299
product_is_equal_to_epp_detection_product_is_equal_to_quicks:
302300
next:
303301
- CreateVariable
@@ -313,13 +311,11 @@ conditions:
313311
data_devicequery_device_query_devices_null_data_devicequery_:
314312
next:
315313
- RequestHumanInputSendEmail5
314+
else:
315+
- AddCommentToDetection11
316316
cel_expression: data['DeviceQuery.Device.query.devices'] != null && data['DeviceQuery.Device.query.devices'].size() > 0
317317
display:
318318
- data[&#39;DeviceQuery.Device.query.devices&#39;] != null &amp;&amp; data[&#39;DeviceQuery.Device.query.devices&#39;].size() &gt; 0
319-
default_gateway_decision_63c53687_1b30_4b66_ba86_f4692d189b2:
320-
next:
321-
- AddCommentToDetection11
322-
default: true
323319
human_response_is_equal_to_approve:
324320
next:
325321
- ContainDevice3
@@ -371,31 +367,32 @@ conditions:
371367
data_trigger_detection_product_epp_data_trigger_detection_ep:
372368
next:
373369
- RequestHumanInputSendEmail
370+
else_if: data_trigger_detection_product_quickscanpro_data_trigger_det
374371
cel_expression: data['Trigger.Detection.Product'] == 'EPP' && data['Trigger.Detection.EPP.Sensor.SensorID'].size()>0
375372
display:
376373
- data[&#39;Trigger.Detection.Product&#39;] == &#39;EPP&#39; &amp;&amp; data[&#39;Trigger.Detection.EPP.Sensor.SensorID&#39;].size()&gt;0
377374
data_trigger_detection_product_quickscanpro_data_trigger_det:
378375
next:
379376
- RequestHumanInputSendEmail2
377+
else_if: data_trigger_detection_product_data_protection_data_trigger_
380378
cel_expression: data['Trigger.Detection.Product'] == 'QUICKSCANPRO' && data['Trigger.Detection.QUICKSCANPRO.AgentID'].size()>0
381379
display:
382380
- data[&#39;Trigger.Detection.Product&#39;] == &#39;QUICKSCANPRO&#39; &amp;&amp; data[&#39;Trigger.Detection.QUICKSCANPRO.AgentID&#39;].size()&gt;0
383381
data_trigger_detection_product_data_protection_data_trigger_:
384382
next:
385383
- RequestHumanInputSendEmail3
384+
else_if: data_trigger_detection_product_ngsiem_data_trigger_detection
386385
cel_expression: data['Trigger.Detection.Product'] == 'DATA-PROTECTION' && data['Trigger.Detection.DataProtection.AgentID'].size()>0
387386
display:
388387
- data[&#39;Trigger.Detection.Product&#39;] == &#39;DATA-PROTECTION&#39; &amp;&amp; data[&#39;Trigger.Detection.DataProtection.AgentID&#39;].size()&gt;0
389388
data_trigger_detection_product_ngsiem_data_trigger_detection:
390389
next:
391390
- DeviceQuery
391+
else:
392+
- AddCommentToDetection2
392393
cel_expression: data['Trigger.Detection.Product'] == 'NGSIEM' && data['Trigger.Detection.NGSIEM.HostNames'].size() > 0
393394
display:
394395
- data[&#39;Trigger.Detection.Product&#39;] == &#39;NGSIEM&#39; &amp;&amp; data[&#39;Trigger.Detection.NGSIEM.HostNames&#39;].size() &gt; 0
395-
default_product_is_equal_to_epp_detection:
396-
next:
397-
- AddCommentToDetection2
398-
default: true
399396
loops:
400397
Loop:
401398
display: For each Sensor IDs; Concurrently

skills/authoring/examples/tutorials/intro-lookup-file-actions.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ description: Learn how to use lookup file actions from Fusion. This workflow use
77
trigger:
88
next:
99
- data_filename_endswith_csv_true
10-
- default_gateway_decision_a99962c3_9f64_4633_b4de_27f43ea4753
1110
name: On demand
1211
type: On demand
1312
parameters:
@@ -166,13 +165,11 @@ conditions:
166165
data_filename_endswith_csv_true:
167166
next:
168167
- GetLookupFileMetadata
168+
else:
169+
- PrintData2
169170
cel_expression: data['filename'].endsWith('.csv') == true
170171
display:
171172
- data[&#39;filename&#39;].endsWith(&#39;.csv&#39;) == true
172-
default_gateway_decision_a99962c3_9f64_4633_b4de_27f43ea4753:
173-
next:
174-
- PrintData2
175-
default: true
176173
lookup_file_exists_is_equal_to_false:
177174
next:
178175
- CreateANewLookupFileIfLookupFileDoesntExist

skills/authoring/examples/tutorials/intro-receive-email-trigger.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ description: Learn how to create a lookup file from a file attachment. Requires
77
trigger:
88
next:
99
- data_trigger_monitoredemail_attachmentfiles_null_data_trigge
10-
- default_gateway_decision_ae829b82_ef89_45bf_85e8_8340bd76251
1110
name: Receive email
1211
event: MonitoredEmail
1312
type: Signal
@@ -32,10 +31,8 @@ conditions:
3231
data_trigger_monitoredemail_attachmentfiles_null_data_trigge:
3332
next:
3433
- CreateLookupFileWithoutLoop
34+
else:
35+
- PrintData
3536
cel_expression: "data['Trigger.MonitoredEmail.AttachmentFiles'] != null &&\ndata['Trigger.MonitoredEmail.AttachmentFiles'].exists(s,\n s != null && s != '' &&\n cs.json.valid(s) &&\n cs.json.decode(s)['Name'] != null &&\n cs.json.decode(s)['Name'] != '' &&\n cs.json.decode(s)['Name'].endsWith('.csv')\n)"
3637
display:
3738
- "data[&#39;Trigger.MonitoredEmail.AttachmentFiles&#39;] != null &amp;&amp;\ndata[&#39;Trigger.MonitoredEmail.AttachmentFiles&#39;].exists(s,\n s != null &amp;&amp; s != &#39;&#39; &amp;&amp;\n cs.json.valid(s) &amp;&amp;\n cs.json.decode(s)[&#39;Name&#39;] != null &amp;&amp;\n cs.json.decode(s)[&#39;Name&#39;] != &#39;&#39; &amp;&amp;\n cs.json.decode(s)[&#39;Name&#39;].endsWith(&#39;.csv&#39;)\n)"
38-
default_gateway_decision_ae829b82_ef89_45bf_85e8_8340bd76251:
39-
next:
40-
- PrintData
41-
default: true

skills/authoring/references/yaml-schema.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,12 @@ actions:
266266
EnrichHash: { id: ..., next: [SummarizeEnrichment], properties: {...} }
267267
```
268268

269-
Do **not** invent pass-through condition nodes (`default_parallel_*` with
270-
`default: true`) to model the fan-out. The console cannot build canvas edges from
271-
those synthetic nodes and the visual editor crashes on import — even though the
272-
workflow deploys and runs. List the real targets in `next:` instead.
269+
Do **not** invent pass-throughcondition nodes (`default_parallel_*` with
270+
`default: true`) to model the fan-out. Release rejects those synthetic nodes
271+
(`exclusive gateway ... has no condition set and is not marked as default`,
272+
confirmed live — the release API does not honor a node-level `default: true`),
273+
and the visual editor also crashes on import. List the real targets in `next:`
274+
instead.
273275

274276
Do **not** model parallel work as a serial chain
275277
(`EnrichDomain → EnrichIP → EnrichHash`). A serial chain runs each call only

0 commit comments

Comments
 (0)