Skip to content

Commit 793015d

Browse files
authored
validate.py: flag references to undeclared WorkflowCustomVariables (#42)
* validate.py: flag references to undeclared WorkflowCustomVariables A workflow that references ${data['WorkflowCustomVariable.<name>']} for a variable that no CreateVariable (or UpdateVariable setter) declares imports and api_validates cleanly, then fails only at release with 'property "..." contains unknown variable "WorkflowCustomVariable.<name>"'. Preflight never caught it because the validator never collected declared variable names. Add _validate_custom_variable_refs to the structural tier: collect declared names from CreateVariable variable_schema properties and WorkflowCustomVariable setter blocks (recursing into loops), scan the raw file for WorkflowCustomVariable.<name> references, and flag any name that is not declared. Reinforce the rule in the authoring and workflows yaml-schema references, and add tests covering the undeclared case, both declaration mechanisms, and de-duplication. Correct three existing fixtures that referenced custom variables they never declared. * pylintrc: raise max-module-lines to 1400 for the new validate.py guard validate.py crossed the 1350 line limit (now 1377) with the undefined-custom-variable check. Consistent with prior guard additions (1200 -> 1300 -> 1350), bump to 1400.
1 parent 67d213e commit 793015d

6 files changed

Lines changed: 235 additions & 2 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=1350
340+
max-module-lines=1400
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
11+
- **`validate.py` now flags `WorkflowCustomVariable.<name>` references to variables that nothing declares** — a release-only failure. A reference to a custom variable that no `CreateVariable` (or `UpdateVariable` setter) declares imports and validates cleanly, then fails at release with `property "..." contains unknown variable "WorkflowCustomVariable.<name>"`. The validator now collects declared variable names and reports an undeclared reference before you deploy.
12+
713
## [1.1.0] - 2026-08-19
814

915
### Added

skills/authoring/references/yaml-schema.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ UpdateVariable:
116116
version_constraint: ~1
117117
```
118118

119+
**Every `WorkflowCustomVariable.<name>` you reference must be declared first** — by a
120+
`CreateVariable` action's `variable_schema.properties` (its keys are the names) or set by an
121+
`UpdateVariable` block. An undeclared name imports and validates fine, then fails at *release* with
122+
`property "..." contains unknown variable "WorkflowCustomVariable.<name>"`. To feed a hydrated
123+
indicator into an enrichment call, reference the producing action's output directly
124+
(`${data['HydrateDetection.results'][0].URL}`) rather than inventing a custom variable you never create.
125+
119126
**Well-known fixed IDs**:
120127
- CreateVariable: `702d15788dbbffdf0b68d8e2f3599aa4`
121128
- UpdateVariable: `6c6eab39063fa3b72d98c82af60deb8a`

skills/authoring/scripts/validate.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,77 @@ def _validate_pinned_data_paths(data, file_path, issues):
10311031
break
10321032

10331033

1034+
def _collect_declared_variables(data):
1035+
"""Collect every WorkflowCustomVariable name the workflow declares.
1036+
1037+
A custom variable becomes real in one of two ways: a ``CreateVariable``
1038+
action declares it under ``properties.variable_schema.properties`` (its keys
1039+
are the names), or any action sets it through a ``properties.WorkflowCustomVariable``
1040+
block (its keys are the names). Recurses into loops so a variable created
1041+
inside a sub-model still counts.
1042+
"""
1043+
declared = set()
1044+
1045+
def _scan(section):
1046+
if not isinstance(section, dict):
1047+
return
1048+
for action in section.values():
1049+
if not isinstance(action, dict):
1050+
continue
1051+
props = action.get("properties", {})
1052+
if isinstance(props, dict):
1053+
schema = props.get("variable_schema", {})
1054+
if isinstance(schema, dict):
1055+
schema_props = schema.get("properties", {})
1056+
if isinstance(schema_props, dict):
1057+
declared.update(schema_props.keys())
1058+
setter = props.get("WorkflowCustomVariable", {})
1059+
if isinstance(setter, dict):
1060+
declared.update(setter.keys())
1061+
1062+
_scan(data.get("actions", {}))
1063+
loops = data.get("loops", {})
1064+
if isinstance(loops, dict):
1065+
for loop_def in loops.values():
1066+
if isinstance(loop_def, dict):
1067+
declared.update(_collect_declared_variables(loop_def))
1068+
return declared
1069+
1070+
1071+
def _validate_custom_variable_refs(data, file_path, issues):
1072+
"""Flag ``WorkflowCustomVariable.<name>`` references to undeclared variables.
1073+
1074+
A reference to a custom variable that no ``CreateVariable``/``UpdateVariable``
1075+
declares imports and api_validates fine, then fails at release with
1076+
``property "..." contains unknown variable "WorkflowCustomVariable.<name>"``.
1077+
Declared names come from the parsed workflow; references are found by scanning
1078+
the raw file, which catches every form uniformly: ``${data['WorkflowCustomVariable.x']}``,
1079+
``${{WorkflowCustomVariable.x}}``, a bare ``WorkflowCustomVariable.x`` in a
1080+
condition expression or loop ``for.input``, and loop-item ``WorkflowCustomVariable.x.#``
1081+
(the name is the first segment). The pattern only matches dotted references, so
1082+
a ``WorkflowCustomVariable:`` setter block or a ``variable_schema`` declaration
1083+
is never mistaken for a reference.
1084+
"""
1085+
try:
1086+
with open(file_path, encoding="utf-8") as handle:
1087+
content = handle.read()
1088+
except OSError:
1089+
return
1090+
referenced = {m.group(1) for m in re.finditer(r"WorkflowCustomVariable\.(\w+)", content)}
1091+
if not referenced:
1092+
return
1093+
declared = _collect_declared_variables(data)
1094+
for name in sorted(referenced - declared):
1095+
issues.append(
1096+
f"ERROR: reference to undefined WorkflowCustomVariable '{name}' — "
1097+
f"declare it in a CreateVariable action's variable_schema (or set it "
1098+
f"via UpdateVariable) before referencing it, or reference a prior "
1099+
f"action's output directly (e.g. ${{data['<Action>.results'][0].<field>}}). "
1100+
f"Release rejects an undeclared variable as an unknown variable. "
1101+
f"Declared: {sorted(declared)}."
1102+
)
1103+
1104+
10341105
def _validate_top_level_shape(data, issues):
10351106
"""Flag off-schema top-level keys and a missing ``actions:`` section.
10361107
@@ -1193,6 +1264,7 @@ def structural_check(file_path):
11931264
_validate_http_output_schema(data, file_path, issues)
11941265
_validate_ngsiem_trigger_fields(trigger, file_path, issues)
11951266
_validate_pinned_data_paths(data, file_path, issues)
1267+
_validate_custom_variable_refs(data, file_path, issues)
11961268

11971269
return issues
11981270

skills/workflows/references/yaml-schema.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ UpdateVariable:
116116
version_constraint: ~1
117117
```
118118

119+
**Every `WorkflowCustomVariable.<name>` you reference must be declared first** — by a
120+
`CreateVariable` action's `variable_schema.properties` (its keys are the names) or set by an
121+
`UpdateVariable` block. An undeclared name imports and validates fine, then fails at *release* with
122+
`property "..." contains unknown variable "WorkflowCustomVariable.<name>"`. To feed a hydrated
123+
indicator into an enrichment call, reference the producing action's output directly
124+
(`${data['HydrateDetection.results'][0].URL}`) rather than inventing a custom variable you never create.
125+
119126
**Well-known fixed IDs**:
120127
- CreateVariable: `702d15788dbbffdf0b68d8e2f3599aa4`
121128
- UpdateVariable: `6c6eab39063fa3b72d98c82af60deb8a`

tests/test_validate.py

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,9 @@ def test_loop_input_from_custom_variable_is_valid(self, tmp_path):
11251125
DoStuff:
11261126
id: aabbccdd11223344aabbccdd11223344
11271127
name: Do stuff
1128+
properties:
1129+
WorkflowCustomVariable:
1130+
my_items: []
11281131
"""
11291132
f.write_text(content)
11301133
issues = validate.structural_check(str(f))
@@ -1412,6 +1415,9 @@ def test_loop_internal_actions_are_not_disjoint(self, tmp_path):
14121415
after:
14131416
id: bbccddee22334455bbccddee22334455
14141417
name: After loop
1418+
properties:
1419+
WorkflowCustomVariable:
1420+
items: []
14151421
"""
14161422
f = tmp_path / "loop_reach.yaml"
14171423
f.write_text(content)
@@ -2086,8 +2092,21 @@ def test_request_human_input_variable_recipient_passes(self, tmp_path):
20862092
trigger:
20872093
type: On demand
20882094
next:
2089-
- AskApproval
2095+
- InitApprover
20902096
actions:
2097+
InitApprover:
2098+
id: 702d15788dbbffdf0b68d8e2f3599aa4
2099+
class: CreateVariable
2100+
name: Create variable
2101+
version_constraint: ~1
2102+
next:
2103+
- AskApproval
2104+
properties:
2105+
variable_schema:
2106+
properties:
2107+
approver_email:
2108+
type: string
2109+
type: object
20912110
AskApproval:
20922111
id: {self._REQUEST_HUMAN_INPUT_EMAIL_ID}
20932112
version_constraint: ~1
@@ -3271,3 +3290,125 @@ def test_nested_schema_type_not_mistaken_for_trigger_type(self, tmp_path):
32713290
)
32723291
issues = validate.preflight_check(str(f))
32733292
assert not any("Invalid trigger type" in i for i in issues)
3293+
3294+
3295+
class TestCustomVariableRefs:
3296+
"""WorkflowCustomVariable.<name> references must resolve to a declared variable.
3297+
3298+
An undeclared reference imports and api_validates fine, then fails at release
3299+
with `unknown variable "WorkflowCustomVariable.<name>"`. These guard the
3300+
structural-tier check that catches it before deploy.
3301+
"""
3302+
3303+
# A CreateVariable declaring `url_enrichment`, then an HTTP action that
3304+
# references it. `{ref}` is swapped per-test to the declared or an undeclared name.
3305+
_BASE = """\
3306+
# Created by the CrowdStrike Falcon Fusion authoring skill
3307+
name: Enrichment
3308+
trigger:
3309+
type: On demand
3310+
name: On demand
3311+
next:
3312+
- InitVars
3313+
actions:
3314+
InitVars:
3315+
id: 702d15788dbbffdf0b68d8e2f3599aa4
3316+
class: CreateVariable
3317+
name: Create variable
3318+
version_constraint: ~1
3319+
next:
3320+
- Enrich
3321+
properties:
3322+
variable_schema:
3323+
properties:
3324+
url_enrichment:
3325+
type: string
3326+
type: object
3327+
Enrich:
3328+
id: 1ba474f407d9228fc8fa02cdce8ae8ef
3329+
class: Inline.HTTPRequest
3330+
name: Cloud HTTP Request
3331+
version_constraint: ~1
3332+
properties:
3333+
http_transaction:
3334+
request_http_method: GET
3335+
request_url: "https://example.com/${data['WorkflowCustomVariable.{ref}']}"
3336+
output_fields: []
3337+
"""
3338+
3339+
def test_undefined_custom_variable_ref_flagged(self, tmp_path):
3340+
# Mirrors the real bug: reference url_indicator, which is never declared
3341+
# (InitVars declares url_enrichment).
3342+
f = tmp_path / "undef.yaml"
3343+
f.write_text(self._BASE.replace("{ref}", "url_indicator"))
3344+
issues = validate.structural_check(str(f))
3345+
assert any(
3346+
"url_indicator" in i and i.startswith("ERROR") for i in issues
3347+
), issues
3348+
3349+
def test_declared_via_create_variable_passes(self, tmp_path):
3350+
# Referencing the declared name produces no undefined-variable error.
3351+
f = tmp_path / "declared.yaml"
3352+
f.write_text(self._BASE.replace("{ref}", "url_enrichment"))
3353+
issues = validate.structural_check(str(f))
3354+
assert not any("undefined WorkflowCustomVariable" in i for i in issues), issues
3355+
3356+
def test_declared_via_update_variable_setter_passes(self, tmp_path):
3357+
# A variable declared only by an UpdateVariable setter block (no
3358+
# CreateVariable) still counts as declared.
3359+
content = """\
3360+
# Created by the CrowdStrike Falcon Fusion authoring skill
3361+
name: Setter
3362+
trigger:
3363+
type: On demand
3364+
name: On demand
3365+
next:
3366+
- SetVar
3367+
actions:
3368+
SetVar:
3369+
id: 6c6eab39063fa3b72d98c82af60deb8a
3370+
class: UpdateVariable
3371+
name: Update variable
3372+
version_constraint: ~1
3373+
next:
3374+
- Email
3375+
properties:
3376+
WorkflowCustomVariable:
3377+
notify_email: soc@example.org
3378+
Email:
3379+
id: 07413ef9ba7c47bf5a242799f59902cc
3380+
name: Send email
3381+
version_constraint: ~1
3382+
properties:
3383+
to:
3384+
- ${data['WorkflowCustomVariable.notify_email']}
3385+
subject: Hi
3386+
msg: Body
3387+
msg_type: html
3388+
output_fields: []
3389+
"""
3390+
f = tmp_path / "setter.yaml"
3391+
f.write_text(content)
3392+
issues = validate.structural_check(str(f))
3393+
assert not any("undefined WorkflowCustomVariable" in i for i in issues), issues
3394+
3395+
def test_undefined_ref_listed_once(self, tmp_path):
3396+
# The same undeclared name referenced twice is reported once.
3397+
content = self._BASE.replace("{ref}", "url_indicator").replace(
3398+
"output_fields: []",
3399+
" Enrich2:\n"
3400+
" id: 1ba474f407d9228fc8fa02cdce8ae8ef\n"
3401+
" class: Inline.HTTPRequest\n"
3402+
" name: Cloud HTTP Request 2\n"
3403+
" version_constraint: ~1\n"
3404+
" properties:\n"
3405+
" http_transaction:\n"
3406+
" request_http_method: GET\n"
3407+
" request_url: \"https://example.com/${data['WorkflowCustomVariable.url_indicator']}\"\n"
3408+
"output_fields: []",
3409+
)
3410+
f = tmp_path / "twice.yaml"
3411+
f.write_text(content)
3412+
issues = validate.structural_check(str(f))
3413+
matches = [i for i in issues if "url_indicator" in i]
3414+
assert len(matches) == 1, matches

0 commit comments

Comments
 (0)