Skip to content

Commit 7a0072e

Browse files
authored
Merge pull request #61 from Integration-Automation/fix/action-executor-keys
Fix action result key collisions and secret leak in substitute mode
2 parents 479f3fb + d1b4f20 commit 7a0072e

6 files changed

Lines changed: 57 additions & 19 deletions

File tree

automation_file/core/action_executor.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,23 +103,26 @@ def execute_action(
103103
validate_first: bool = False,
104104
substitute: bool = False,
105105
) -> dict[str, Any]:
106-
"""Execute every action; return ``{"execute: <action>": result|repr(error)}``.
106+
"""Execute every action; return ``{"execute[<index>]: <action>": result|repr(error)}``.
107107
108108
``dry_run=True`` logs and records the resolved name without invoking the
109109
command. ``validate_first=True`` runs :meth:`validate` before touching
110110
any action so a typo aborts the whole batch up-front. ``substitute=True``
111111
expands ``${env:...}`` / ``${date:...}`` / ``${uuid}`` / ``${cwd}``
112-
placeholders inside every string in the payload.
112+
placeholders inside every string in the payload — the original
113+
(un-substituted) action is used for log lines and result keys so
114+
secrets pulled in via ``${env:...}`` never reach logs.
113115
"""
114116
actions = self._coerce(action_list)
115-
if substitute:
116-
actions = substitute_payload(actions) # type: ignore[assignment]
117+
executed: list = (
118+
substitute_payload(actions) if substitute else actions # type: ignore[assignment]
119+
)
117120
if validate_first:
118-
self.validate(actions)
121+
self.validate(executed)
119122
results: dict[str, Any] = {}
120-
for action in actions:
121-
key = f"execute: {action}"
122-
results[key] = self._run_one(action, dry_run=dry_run)
123+
for index, (display, action) in enumerate(zip(actions, executed, strict=True)):
124+
key = f"execute[{index}]: {display}"
125+
results[key] = self._run_one(action, dry_run=dry_run, display=display)
123126
return results
124127

125128
def execute_action_parallel(
@@ -154,15 +157,16 @@ def add_command_to_executor(self, command_dict: Mapping[str, Any]) -> None:
154157
self.registry.register_many(command_dict)
155158

156159
# Internals ---------------------------------------------------------
157-
def _run_one(self, action: list, dry_run: bool) -> Any:
160+
def _run_one(self, action: list, dry_run: bool, display: list | None = None) -> Any:
161+
display_action = action if display is None else display
158162
name = _safe_action_name(action)
159163
if dry_run:
160-
return self._run_dry(action)
164+
return self._run_dry(action, display=display_action)
161165
started = time.monotonic()
162166
ok = False
163167
try:
164168
value = self._execute_event(action)
165-
file_automation_logger.info("execute_action: %s", action)
169+
file_automation_logger.info("execute_action: %s", display_action)
166170
ok = True
167171
return value
168172
except ExecuteActionException as error:
@@ -174,19 +178,21 @@ def _run_one(self, action: list, dry_run: bool) -> Any:
174178
finally:
175179
record_action(name, time.monotonic() - started, ok)
176180

177-
def _run_dry(self, action: list) -> Any:
181+
def _run_dry(self, action: list, display: list | None = None) -> Any:
182+
display_action = action if display is None else display
178183
try:
179-
name, kind, payload = self._parse_action(action)
184+
name, _, _ = self._parse_action(action)
180185
if self.registry.resolve(name) is None:
181186
raise ExecuteActionException(f"unknown action: {name!r}")
182187
except ExecuteActionException as error:
183188
file_automation_logger.error("execute_action malformed: %r", error)
184189
return repr(error)
190+
_, display_kind, display_payload = self._parse_action(display_action)
185191
file_automation_logger.info(
186192
"dry_run: %s kind=%s payload=%r",
187193
name,
188-
kind,
189-
payload,
194+
display_kind,
195+
display_payload,
190196
)
191197
return f"dry_run:{name}"
192198

docs/source.zh-CN/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ JSON 动作列表
1313
["FA_name", ["positional", "args"]]
1414
1515
动作列表是动作的数组。执行器按顺序执行并返回
16-
``"execute: <action>" -> result | repr(error)`` 的映射表。
16+
``"execute[<index>]: <action>" -> result | repr(error)`` 的映射表。
1717

1818
.. code-block:: python
1919

docs/source.zh-TW/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ JSON 動作清單
1313
["FA_name", ["positional", "args"]]
1414
1515
動作清單是動作的陣列。執行器依序執行並回傳
16-
``"execute: <action>" -> result | repr(error)`` 的對應表。
16+
``"execute[<index>]: <action>" -> result | repr(error)`` 的對應表。
1717

1818
.. code-block:: python
1919

docs/source/usage.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ An action is one of three shapes:
1313
["FA_name", ["positional", "args"]]
1414
1515
An action list is an array of actions. The executor runs them in order and
16-
returns a mapping of ``"execute: <action>" -> result | repr(error)``.
16+
returns a mapping of ``"execute[<index>]: <action>" -> result | repr(error)``.
1717

1818
.. code-block:: python
1919

tests/test_action_executor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,35 @@ def test_json_store_roundtrip(tmp_path: Path) -> None:
9797
path = tmp_path / "payload.json"
9898
write_action_json(str(path), [["a", 1]])
9999
assert read_action_json(str(path)) == [["a", 1]]
100+
101+
102+
def test_duplicate_actions_do_not_collide() -> None:
103+
"""Two identical actions in one batch must keep both results."""
104+
executor = _fresh_executor()
105+
results = executor.execute_action(
106+
[
107+
["echo", {"value": "first"}],
108+
["echo", {"value": "first"}],
109+
]
110+
)
111+
assert len(results) == 2
112+
assert list(results.values()) == ["first", "first"]
113+
114+
115+
def test_substitute_does_not_leak_into_result_key() -> None:
116+
"""``substitute=True`` must keep the un-expanded literal in result keys."""
117+
import os
118+
119+
os.environ["FA_EXEC_SECRET"] = "TOP_SECRET"
120+
try:
121+
executor = _fresh_executor()
122+
results = executor.execute_action(
123+
[["echo", {"value": "${env:FA_EXEC_SECRET}"}]],
124+
substitute=True,
125+
)
126+
[(key, value)] = results.items()
127+
assert "TOP_SECRET" not in key
128+
assert "${env:FA_EXEC_SECRET}" in key
129+
assert value == "TOP_SECRET"
130+
finally:
131+
os.environ.pop("FA_EXEC_SECRET", None)

tests/test_http_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_http_server_executes_action() -> None:
3939
url = insecure_url("http", f"{host}:{port}/actions")
4040
status, body = _post(url, [["test_http_echo", {"value": "hi"}]])
4141
assert status == 200
42-
assert json.loads(body) == {"execute: ['test_http_echo', {'value': 'hi'}]": "hi"}
42+
assert json.loads(body) == {"execute[0]: ['test_http_echo', {'value': 'hi'}]": "hi"}
4343
finally:
4444
server.shutdown()
4545

0 commit comments

Comments
 (0)