From d86c9663190288cf8eed73e5fc923e80a1759d1e Mon Sep 17 00:00:00 2001 From: Tatu Aalto Date: Thu, 8 May 2025 16:09:57 +0300 Subject: [PATCH] Secret type Fixes: 4537 --- .../keywords/type_conversion/secret.robot | 78 +++++++ .../keywords/type_conversion/secret.py | 34 +++ .../keywords/type_conversion/secret.robot | 200 +++++++++++++++++ .../CreatingTestLibraries.rst | 207 ++++++++++++++++++ src/robot/api/types/__init__.py | 16 ++ src/robot/libdocpkg/standardtypes.py | 45 ++++ src/robot/running/arguments/typeconverters.py | 17 +- src/robot/running/arguments/typeinfo.py | 3 +- src/robot/utils/__init__.py | 1 + src/robot/utils/secret.py | 36 +++ src/robot/variables/scopes.py | 5 +- src/robot/variables/tablesetter.py | 58 ++++- 12 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 atest/robot/keywords/type_conversion/secret.robot create mode 100644 atest/testdata/keywords/type_conversion/secret.py create mode 100644 atest/testdata/keywords/type_conversion/secret.robot create mode 100644 src/robot/api/types/__init__.py create mode 100644 src/robot/utils/secret.py diff --git a/atest/robot/keywords/type_conversion/secret.robot b/atest/robot/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..7e93a85f65e --- /dev/null +++ b/atest/robot/keywords/type_conversion/secret.robot @@ -0,0 +1,78 @@ +*** Settings *** +Resource atest_resource.robot + +Suite Setup Run Tests --variable "CLI: Secret:From command line" keywords/type_conversion/secret.robot + + +*** Test Cases *** +Command line + Check Test Case ${TESTNAME} + +Variable section: Scalar + Check Test Case ${TESTNAME} + +Variable section: List + Check Test Case ${TESTNAME} + +Variable section: Dict + Check Test Case ${TESTNAME} + +Variable section: Invalid syntax + Error In File + ... 0 keywords/type_conversion/secret.robot 18 + ... Setting variable '\&{DICT_LITERAL: secret}' failed: + ... Value 'fails' must have type 'Secret', got string. + Error In File + ... 1 keywords/type_conversion/secret.robot 9 + ... Setting variable '\${FROM_LITERAL: Secret}' failed: + ... Value 'this fails' must have type 'Secret', got string. + Error In File + ... 2 keywords/type_conversion/secret.robot 15 + ... Setting variable '\@{LIST_LITERAL: secret}' failed: + ... Value 'this' must have type 'Secret', got string. + +VAR: Env variable + Check Test Case ${TESTNAME} + +VAR: Join secret + Check Test Case ${TESTNAME} + +VAR: Broken variable + Check Test Case ${TESTNAME} + +Create: List + Check Test Case ${TESTNAME} + +Create: List by extending + Check Test Case ${TESTNAME} + +Create: List of dictionaries + Check Test Case ${TESTNAME} + +Create: Dictionary + Check Test Case ${TESTNAME} + +Return value: Library keyword + Check Test Case ${TESTNAME} + +Return value: User keyword + Check Test Case ${TESTNAME} + +User keyword: Receive not secret + Check Test Case ${TESTNAME} + +User keyword: Receive not secret var + Check Test Case ${TESTNAME} + +Library keyword + Check Test Case ${TESTNAME} + +Library keyword: not secret + Check Test Case ${TESTNAME} 1 + Check Test Case ${TESTNAME} 2 + +Library keyword: TypedDict + Check Test Case ${TESTNAME} + +Library keyword: List of secrets + Check Test Case ${TESTNAME} diff --git a/atest/testdata/keywords/type_conversion/secret.py b/atest/testdata/keywords/type_conversion/secret.py new file mode 100644 index 00000000000..e95580e05a1 --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.py @@ -0,0 +1,34 @@ +from typing import TypedDict + +from robot.utils import Secret + + +class Credential(TypedDict): + username: str + password: Secret + + +def library_get_secret(value: str = "This is a secret") -> Secret: + return Secret(value) + + +def library_not_secret(): + return "This is a string, not a secret" + + +def library_receive_secret(secret: Secret) -> str: + return secret.value + + +def library_receive_credential(credential: Credential) -> str: + return ( + f"Username: {credential['username']}, Password: {credential['password'].value}" + ) + + +def library_list_of_secrets(secrets: "list[Secret]") -> str: + return ", ".join(secret.value for secret in secrets) + + +def get_variables(): + return {"VAR_FILE": Secret("From variable file")} diff --git a/atest/testdata/keywords/type_conversion/secret.robot b/atest/testdata/keywords/type_conversion/secret.robot new file mode 100644 index 00000000000..1098e3ec4cc --- /dev/null +++ b/atest/testdata/keywords/type_conversion/secret.robot @@ -0,0 +1,200 @@ +*** Settings *** +Library Collections +Library OperatingSystem +Library secret.py +Variables secret.py + + +*** Variables *** +${FROM_LITERAL: Secret} this fails +${FROM_EXISTING: secret} ${VAR_FILE} +${FROM_JOIN: secret} abc${VAR_FILE}efg +${FROM_ENV: SECRET} %{SECRET=kala} +${ENV_JOIN: SECRET} qwe%{SECRET=kala}rty +@{LIST: Secret} ${VAR_FILE} %{SECRET=kala} +@{LIST_LITERAL: secret} this fails ${VAR_FILE} %{SECRET=kala} +&{DICT1: Secret} var_file=${VAR_FILE} env=%{SECRET=kala} +&{DICT2: secret=secret} ${VAR_FILE}=%{SECRET=kala} +&{DICT_LITERAL: secret} this=fails ok=${VAR_FILE} + + +*** Test Cases *** +Command line + Should Be Equal ${CLI.value} From command line + +Variable section: Scalar + Should Be Equal ${FROM_EXISTING.value} From variable file + Should Be Equal ${FROM_ENV.value} kala + Should Be Equal ${FROM_JOIN.value} abcFrom variable fileefg + Should Be Equal ${ENV_JOIN.value} qwekalarty + Variable Should Not Exist ${FROM_LITERAL} + +Variable section: List + Should Be Equal ${LIST[0].value} From variable file + Should Be Equal ${LIST[1].value} kala + Variable Should Not Exist ${LIST_LITERAL} + +Variable section: Dict + Should Be Equal ${DICT1.var_file.value} From variable file + Should Be Equal ${DICT1.env.value} kala + Should Be Equal ${{$DICT2[$VAR_FILE].value}} kala + Variable Should Not Exist ${DICT_LITERAL} + +VAR: Env variable + Set Environment Variable SECRET VALUE1 + VAR ${secret: secret} %{SECRET} + Should Be Equal ${secret.value} VALUE1 + VAR ${x} SECRET + Set Environment Variable SECRET VALUE2 + VAR ${secret: secret} %{${x}} + Should Be Equal ${secret.value} VALUE2 + VAR ${secret: secret} %{INLINE_SECRET=inline_secret} + Should Be Equal ${secret.value} inline_secret + +VAR: Join secret + [Documentation] FAIL + ... Setting variable '\${zz: secret}' failed: \ + ... Value '111\${y}222' must have type 'Secret', got string. + ${secret1} Library Get Secret 111 + ${secret2} Library Get Secret 222 + VAR ${x: secret} abc${secret1} + Should Be Equal ${x.value} abc111 + VAR ${y: int} 42 + VAR ${x: secret} ${secret2}${y} + Should Be Equal ${x.value} 22242 + VAR ${x: secret} ${secret1}${secret2} + Should Be Equal ${x.value} 111222 + VAR ${x: secret} -${secret1}--${secret2}--- + Should Be Equal ${x.value} -111--222--- + VAR ${x: secret} -${y}--${secret1}---${y}----${secret2}----- + Should Be Equal + ... ${x.value} + ... -42--111---42----222----- + Set Environment Variable SECRET VALUE10 + VAR ${secret: secret} 11%{SECRET}22 + Should Be Equal ${secret.value} 11VALUE1022 + VAR ${zz: secret} 111${y}222 + +VAR: Broken variable + [Documentation] FAIL + ... Setting variable '\${x: Secret}' failed: Variable '${borken' was not closed properly. + VAR ${x: Secret} ${borken + +Create: List + [Documentation] FAIL + ... Setting variable '\@{x: secret}' failed: \ + ... Value 'this' must have type 'Secret', got string. + ${secret} Library Get Secret + VAR @{x: secret} ${secret} ${secret} + Should Be Equal ${x[0].value} This is a secret + Should Be Equal ${x[1].value} This is a secret + VAR @{x: int|secret} 22 ${secret} 44 + Should Be Equal ${x[0]} 22 type=int + Should Be Equal ${x[1].value} This is a secret + Should Be Equal ${x[2]} 44 type=int + VAR @{x: secret} ${secret} this fails + +Create: List by extending + ${secret} Library Get Secret + VAR @{x: secret} ${secret} ${secret} + VAR @{x} @{x} @{x} + Length Should Be ${x} 4 + Should Be Equal ${x[0].value} This is a secret + Should Be Equal ${x[1].value} This is a secret + Should Be Equal ${x[2].value} This is a secret + Should Be Equal ${x[3].value} This is a secret + +Create: List of dictionaries + ${secret} Library Get Secret + VAR &{dict1: secret} key1=${secret} key2=${secret} + VAR &{dict2: secret} key3=${secret} + VAR @{list} ${dict1} ${dict2} + Length Should Be ${list} 2 + FOR ${d} IN @{list} + Dictionaries Should Be Equal ${d} ${d} + END + +Create: Dictionary + [Documentation] FAIL + ... Setting variable '\&{x: secret}' failed: \ + ... Value 'fails' must have type 'Secret', got string. + ${secret} Library Get Secret + VAR &{x: secret} key=${secret} + Should Be Equal ${x.key.value} This is a secret + VAR &{x: int=secret} 42=${secret} + Should Be Equal ${x[42].value} This is a secret + VAR &{x: secret} this=fails + +Return value: Library keyword + [Documentation] FAIL + ... ValueError: Return value must have type 'Secret', got string. + ${x} Library Get Secret + Should Be Equal ${x.value} This is a secret + ${x: Secret} Library Get Secret value of secret here + Should Be Equal ${x.value} value of secret here + ${x: secret} Library Not Secret + +Return value: User keyword + [Documentation] FAIL + ... ValueError: Return value must have type 'Secret', got string. + ${x} User Keyword: Return secret + Should Be Equal ${x.value} This is a secret + ${x: Secret} User Keyword: Return secret + Should Be Equal ${x.value} This is a secret + ${x: secret} User Keyword: Return string + +User keyword: Receive not secret + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + User Keyword: Receive secret xxx ${None} + +User keyword: Receive not secret var + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + VAR ${x} y + User Keyword: Receive secret ${x} ${None} + +Library keyword + ${secret: secret} Library Get Secret + User Keyword: Receive secret ${secret} This is a secret + +Library keyword: not secret 1 + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got string. + Library receive secret 111 + +Library keyword: not secret 2 + [Documentation] FAIL + ... ValueError: Argument 'secret' must have type 'Secret', got integer. + Library receive secret ${222} + +Library keyword: TypedDict + [Documentation] FAIL + ... ValueError: Argument 'credential' got value \ + ... '{'username': 'login@email.com', 'password': 'This fails'}' (DotDict) that cannot be converted to Credential: \ + ... Item 'password' must have type 'Secret', got string. + ${secret: secret} Library Get Secret + VAR &{credentials} username=login@email.com password=${secret} + ${data} Library Receive Credential ${credentials} + Should Be Equal ${data} Username: login@email.com, Password: This is a secret + VAR &{credentials} username=login@email.com password=This fails + Library Receive Credential ${credentials} + +Library keyword: List of secrets + ${secret: secret} Library Get Secret + VAR @{secrets: secret} ${secret} ${secret} + ${data} Library List Of Secrets ${secrets} + Should Be Equal ${data} This is a secret, This is a secret + + +*** Keywords *** +User Keyword: Receive secret + [Arguments] ${secret: secret} ${expected: str} + Should Be Equal ${secret.value} ${expected} + +User Keyword: Return secret + ${secret} Library Get Secret + RETURN ${secret} + +User Keyword: Return string + RETURN This is a string diff --git a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst index 01279797cf8..82e2664031b 100644 --- a/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst +++ b/doc/userguide/src/ExtendingRobotFramework/CreatingTestLibraries.rst @@ -1412,6 +1412,16 @@ Other types cause conversion failures. | | | | | used earlier. | | | | | | | | | `{'width': 1600, 'enabled': True}` | +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ + | Secret_ | | | | Container to store secret data in variables. The Secret class | .. sourcecode:: python | + | | | | | instance stores the data in `value` attribute and `__str__` | | + | | | | | method is used to mask the real value of the secret. This | from robot.api import Secret | + | | | | | prevents the value from being logged by Robot Framework in the | | + | | | | | output files. Please note that libraries or other tools might | def login(token: Secret): | + | | | | | log the value in some other way, so `Secret` does does not | do_something(token.value) | + | | | | | guarantee to hide secret in all possible ways. | | + | | | | | | | + | | | | | New in Robot Framework 7.4. | | + +--------------+---------------+------------+--------------+----------------------------------------------------------------+--------------------------------------+ .. note:: Starting from Robot Framework 5.0, types that have a converted are automatically shown in Libdoc_ outputs. @@ -1420,6 +1430,13 @@ Other types cause conversion failures. `None`. That support has been removed and `None` conversion is only done if an argument has `None` as an explicit type or as a default value. +.. note:: Secret does not prevent libraries or other tools to log the value in + some way. Therefore `Secret` does does not guaranteed to hide secret + in all possible ways. Example, if Robot Framework log level is set to + DEBUG and SeleniumLibrary is used, then password is visible in the + log.html file, because selenium will log the all communication between + selenium and webdriver (like chromedriver.) + .. _Any: https://docs.python.org/library/typing.html#typing.Any .. _bool: https://docs.python.org/library/functions.html#bool .. _int: https://docs.python.org/library/functions.html#int @@ -1451,11 +1468,201 @@ Other types cause conversion failures. .. _abc.Set: https://docs.python.org/library/collections.abc.html#collections.abc.Set .. _frozenset: https://docs.python.org/library/stdtypes.html#frozenset .. _TypedDict: https://docs.python.org/library/typing.html#typing.TypedDict +.. _Secret: https://github.com/robotframework/robotframework/blob/master/src/robot/utils/secret.py .. _Container: https://docs.python.org/library/collections.abc.html#collections.abc.Container .. _typing: https://docs.python.org/library/typing.html .. _ISO 8601: https://en.wikipedia.org/wiki/ISO_8601 .. _ast.literal_eval: https://docs.python.org/library/ast.html#ast.literal_eval +Secret type +''''''''''' + +The `Secret` type has two purposes. First, it is used to prevent putting the +secret value in Robot Framework test data as plain text. Second, it is used to +hide secret from Robot Framework logs and reports. `Secret` is new feature in +Robot Framework 7.4 + +It is possible to use the `Secret` type to store secret data in variables. The +`Secret` class is defined in the `robot.utils.secret` module and it stores the +data in it's `value` attribute, where consumers of the `Secret` type must read +the value. The `__str__` method of the `Secret` class is used to mask the real +value of the secret, which prevents the value from being logged by Robot +Framework in the output files. However, please note that at some point +libraries or other tools might need to pass the secret as plain text and those +libraries or tools might log the value in some way as clear text, therefore +using `Secret` does not guarantee to hide the secret in all possible scenarios. + +The second aim of the `Secret` type is not to store value, like password or +token, as a plaint text in Robot Framework test data. Therefore creation of +`Secret` type variable is different from other types. The normal variable +types can be created from anywhere, example in variable table, but Secret type +can not be created directly in the Robot Framework test data. With the exception +of environment variables, which can be used to create secrets also in Robot +Framework test data. To create a Secret type of variable, there are four main +ways to create it. + +1) Secret can be created from command line +2) Secret can be created from environment variable in test data +3) Secret can be returned from a library keyword +4) Secret can be created in a variable file +5) Secret can be catenated + + +Creating Secret from command line +''''''''''''''''''''''''''''''''' + +The easiest way to create secrets is to use the :option:`--variable` command line option +with `Secret` type definition when running Robot Framework:: + + $ --variable "TOKEN: Secret:1234567890" + +This creates a variable named `${TOKEN}` which is of type `Secret` and has the value +`1234567890`. + +Create Secret from environment variable +''''''''''''''''''''''''''''''''''''''' + +`Secret` can be read from environment variable in Robot Framework test data in +example following ways. + +.. sourcecode:: robotframework + + *** Variables *** + ${TOKEN: Secret} %{ACCESSTOKEN} + + *** Test Cases *** + Example + VAR ${password: secret} %{USERPASSWORD} + +In the variable section, the `${TOKEN}` variable is created from the environment +variable `ACCESSTOKEN`. In the Example test, `${password}` variable is created +from the environment variable `USERPASSWORD`. + +Secret can be returned from a library keyword +''''''''''''''''''''''''''''''''''''''''''''' + +`Secret` can be returned from a keyword. The keyword must return the `Secret` type +and the value can be read from the `value` attribute of the `Secret` object. + +.. sourcecode:: robotframework + + *** Test Cases *** + Use JWT token + ${jwt_token: Secret} = Get JWT Token + ... + +If the keyword `Get JWT Token` returns a `Secret` type, the `${jwt_token}` variable +will be of type `Secret`. But if the keyword returns example string or some other +type, the variable assignment will fail with an error. + +Secret can be created in a variable file +'''''''''''''''''''''''''''''''''''''''' + +Variables with `Secret` type can be created in a Python `variable files`_. +The following example creates a variable `${TOKEN}` of type `Secret` with the value +that is read from environment variable `TOKEN`. + +.. sourcecode:: python + + import os + + from robot.utils import Secret + + TOKEN = Secret(os.environ['TOKEN']) + +Secret can be catenated +''''''''''''''''''''''' + +`Secret` type can be catenated with other `Secret` types or with other types +variables or strings. This creates a new `Secret` type with the concatenated +value. + +.. sourcecode:: robotframework + + *** Test Cases *** + Use JWT token with pin + ${jwt_token: Secret} = Get JWT Token + VAR ${token1: Secret} 1234${jwt_token} + VAR ${token2: Secret} ${1234}${jwt_token} + + Two part Token + ${part1: Secret} = Get First Part + ${part2: Secret} = Get Second Part + VAR ${token: Secret} ${part1}${part2} + +In the first test case, the `${jwt_token}` variable is of type `Secret` and it is +catenated with string `1234` to create a new `Secret` type variable `${token1}`. +The `${token2}` variable is created by concatenating the integer `1234` with the +`${jwt_token}` variable. Both `${token1}` and `${token2}` are of type `Secret`. +In the second test case, two `Secret` type variables `${part1}` and `${part2}` +are catenated to create a new `Secret` type variable `${token}`. + +Using Secret type in type hints +''''''''''''''''''''''''''''''' + +`Secret` type can be used in keywords argument hints like any other type. The +`Secret` type can be used both user keyword and library keywords. In other +types Robot Framework automatically converts the argument to the specified type, +but for `Secret` type the value is not converted. If value is not `Secret` type, +the keyword will fail with an error. + +.. sourcecode:: python + + from robot.utils import Secret + + def login_to_sut(user: str, token: Secret): + # Login somewhere with the token + SUT.login(user, token.value) + +.. sourcecode:: robotframework + + *** Keywords *** + Login + [Arguments] ${user: str} ${token: Secret} + Login To Sut ${user} ${token} + +In the library keyword example above, the `token` argument must always receive +value which type is `Secret`. If type is something else keyword will fail. Same +logic applies to user keywords, in the example above the `token` argument must +always receive value which type is `Secret`. If the type is something else, the +keyword will also fail. + +`Secret` type can be used in lists or dictionaries as a type hint. Like in the +keyword examples above, the value must be of type `Secret` or declaring the +variable will fail. + +.. sourcecode:: robotframework + + *** Test Cases *** + List and dictionary + VAR @{list: secret} ${TOKEN} ${PASSWORD} + VAR &{dict: secret} username=user password=${SECRET} + +The above example declares a list variable `${list}` and a dictionary variable +`${dict}`. The list variable contains two `Secret` type values, `${TOKEN}` and +`${PASSWORD}`. The dictionary variable contains two values key pairs and the +`password` key contains `Secret` type value. + +.. sourcecode:: python + + from typing import TypedDict + + from robot.utils import Secret + + + class Credential(TypedDict): + username: str + password: Secret + + def login(credentials: Credential): + # Login somewhere with the credentials + SUT.login(credentials['username'], credentials['password'].value) + +Using `Secret` type in complex type hints works similarly as with other types. +The library keyword `login` uses type hint `Credential` which is a `TypedDict`_ +that contains a `Secret` type for the password key. + + Specifying multiple possible types '''''''''''''''''''''''''''''''''' diff --git a/src/robot/api/types/__init__.py b/src/robot/api/types/__init__.py new file mode 100644 index 00000000000..63c36f49628 --- /dev/null +++ b/src/robot/api/types/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from robot.utils import Secret as Secret diff --git a/src/robot/libdocpkg/standardtypes.py b/src/robot/libdocpkg/standardtypes.py index 392bcc2553e..79deb2f305c 100644 --- a/src/robot/libdocpkg/standardtypes.py +++ b/src/robot/libdocpkg/standardtypes.py @@ -23,6 +23,8 @@ except ImportError: # Python < 3.10 NoneType = type(None) +from robot.utils import Secret + STANDARD_TYPE_DOCS = { Any: """\ Any value is accepted. No conversion is done. @@ -198,6 +200,49 @@ Strings are case, space, underscore and hyphen insensitive, but exact matches have precedence over normalized matches. +""", + Secret: """\ +The Secret type has two purposes. First, it is used to +prevent putting the secret value in Robot Framework +test data as plain text. Second, it is used to hide secret +from Robot Framework logs and reports. + +Usage of the Secret type does not fully prevent the value +being from logged in libraries that uses the Secret type, +because example Browser or SeleniumLibrary will need +pass at some the value as plain text in to the corresponding +API calls of those tools and those underlying tools might log +the value in their logs own files or use Python standard +logging that might reveal the value also in the Robot +Framework logs. Also user may access the Secret type +value attribute to get the actual secret and this can +reveal the value in the logs. If value is exposed as plain +text, the Robot Framework logging system will not prevent value +being logged in Robot Framework output files. The only protection +that is provided is the encapsulation of the value in a +Secret class which prevents the value being directly logged in +Robot Framework logs and reports. + +The creation of Secret is more restricted than normal variable +types. Normal variable types can be created from anywhere, +example in variable table, but Secret type can not be created +directly in the Robot Framework test data. With the exception of +environment variables, which can be used to create secrets +also in Robot Framework test data. + +To create a Secret type of variable, there are four ways to +create it. 1) Secret can be created from command line +2) Secret can be returned from a library keyword 3) Secret +can be created in a variable file and 4) Secret can be created +from environment variable. + +The Secret type can be used in user keywords argument types, +like any other standard +[https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html#supported-conversions|supported conversion] +types to enforce that the variable is actually a Secret type. +But to exception to other supported conversion types, if the +variable type is not Secret, an error is raised when keyword +is called. """, } diff --git a/src/robot/running/arguments/typeconverters.py b/src/robot/running/arguments/typeconverters.py index a91b0cc862d..674b36b842a 100644 --- a/src/robot/running/arguments/typeconverters.py +++ b/src/robot/running/arguments/typeconverters.py @@ -27,7 +27,7 @@ from robot.conf import Languages from robot.libraries.DateTime import convert_date, convert_time from robot.utils import ( - eq, get_error_message, plural_or_not as s, safe_str, seq2str, type_name + eq, get_error_message, plural_or_not as s, safe_str, Secret, seq2str, type_name ) if TYPE_CHECKING: @@ -794,6 +794,21 @@ def _convert(self, value): raise ValueError +@TypeConverter.register +class SecretConverter(TypeConverter): + type = Secret + + def _convert(self, value): + raise ValueError + + def _handle_error(self, value, name, kind, error=None): + kind = kind.capitalize() if kind.islower() else kind + typ = type_name(value) + if name is None: + raise ValueError(f"{kind} must have type 'Secret', got {typ}.") + raise ValueError(f"{kind} '{name}' must have type 'Secret', got {typ}.") + + class CustomConverter(TypeConverter): def __init__( diff --git a/src/robot/running/arguments/typeinfo.py b/src/robot/running/arguments/typeinfo.py index 43cbe545e96..7f064ee5f67 100644 --- a/src/robot/running/arguments/typeinfo.py +++ b/src/robot/running/arguments/typeinfo.py @@ -38,7 +38,7 @@ from robot.conf import Languages, LanguagesLike from robot.errors import DataError from robot.utils import ( - is_union, NOT_SET, plural_or_not as s, setter, SetterAwareType, type_name, + is_union, NOT_SET, plural_or_not as s, Secret, setter, SetterAwareType, type_name, type_repr, typeddict_types ) from robot.variables import search_variable, VariableMatch @@ -80,6 +80,7 @@ "frozenset": frozenset, "union": Union, "literal": Literal, + "secret": Secret, } LITERAL_TYPES = (int, str, bytes, bool, Enum, type(None)) diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index 9e619bd12ac..2dc27bbf60d 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -144,6 +144,7 @@ type_repr as type_repr, typeddict_types as typeddict_types, ) +from .secret import Secret as Secret from .setter import setter as setter, SetterAwareType as SetterAwareType from .sortable import Sortable as Sortable from .text import ( diff --git a/src/robot/utils/secret.py b/src/robot/utils/secret.py new file mode 100644 index 00000000000..9e19083ed07 --- /dev/null +++ b/src/robot/utils/secret.py @@ -0,0 +1,36 @@ +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class Secret: + """Represent a secret value that should not be logged or displayed in plain text. + + This class is used to encapsulate sensitive information, such as passwords or + API keys, ensuring that when the value is logged, it is not exposed by + Robot Framework by its original value. Please note when libraries or + tools use this class, they should ensure that the value is not logged + or displayed in any way that could compromise its confidentiality. In some + cases, this is not fully possible, example selenium or Playwright might + still reveal the value in log messages or other outputs. + + Libraries or tools using the Secret class can use the value attribute to + access the actual secret value when necessary. + """ + + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return f"{type(self).__name__}(value=)" diff --git a/src/robot/variables/scopes.py b/src/robot/variables/scopes.py index bd302bab5be..c6e400dc92e 100644 --- a/src/robot/variables/scopes.py +++ b/src/robot/variables/scopes.py @@ -21,6 +21,7 @@ from robot.model import Tags from robot.output import LOGGER from robot.utils import abspath, DotDict, find_file, get_error_details, NormalizedDict +from robot.utils.secret import Secret from .resolvable import GlobalVariableValue from .variables import Variables @@ -211,10 +212,12 @@ def _convert_cli_variable(self, name, typ, value): info = TypeInfo.from_variable(var) except DataError as err: raise DataError(f"Invalid command line variable '{var}': {err}") + if info.type is Secret: + return Secret(value) try: return info.convert(value, var, kind="Command line variable") except ValueError as err: - raise DataError(err) + raise DataError(str(err)) def _set_built_in_variables(self, settings): options = DotDict( diff --git a/src/robot/variables/tablesetter.py b/src/robot/variables/tablesetter.py index 00b21b81658..2baf03cb333 100644 --- a/src/robot/variables/tablesetter.py +++ b/src/robot/variables/tablesetter.py @@ -16,7 +16,9 @@ from typing import Any, Callable, Sequence, TYPE_CHECKING from robot.errors import DataError -from robot.utils import DotDict, split_from_equals +from robot.utils import DotDict, split_from_equals, type_name +from robot.utils.secret import Secret +from robot.utils.unic import safe_str from .resolvable import Resolvable from .search import is_dict_variable, is_list_variable, search_variable @@ -111,6 +113,38 @@ def resolve(self, variables) -> Any: def _replace_variables(self, variables) -> Any: raise NotImplementedError + def _handle_secrets(self, value, replace_scalar, typ=None): + typ = typ or self.type + if not typ or typ.title() != "Secret": + return value + match = search_variable(value, identifiers="$%") + if match.is_variable(): + secret = replace_scalar(match.match) + if match.identifier == "%": + secret = Secret(secret) + else: + secret = self._handle_embedded_secrets(match, replace_scalar) + if isinstance(secret, Secret): + return secret + typ = type_name(value) + raise DataError(f"Value '{value}' must have type 'Secret', got {typ}.") + + def _handle_embedded_secrets(self, match, replace_scalar): + parts = [] + secret_seen = False + while match: + secret = replace_scalar(match.match) + if match.identifier == "%": + secret_seen = True + elif isinstance(secret, Secret): + secret_seen = True + secret = secret.value + parts.extend([match.before, secret]) + match = search_variable(match.after, identifiers="$%") + parts.append(match.string) + value = "".join(safe_str(p) for p in parts) + return Secret(value) if secret_seen else value + def _convert(self, value, type_): from robot.running import TypeInfo @@ -152,7 +186,9 @@ def _get_value_and_separator(self, value, separator): def _replace_variables(self, variables): value, separator = self.value, self.separator if self._is_single_value(value, separator): - return variables.replace_scalar(value[0]) + replace_scalar = variables.replace_scalar + value = self._handle_secrets(value[0], replace_scalar) + return replace_scalar(value) if separator is None: separator = " " else: @@ -167,6 +203,9 @@ def _is_single_value(self, value, separator): class ListVariableResolver(VariableResolver): def _replace_variables(self, variables): + replace_scalar = variables.replace_scalar + value = [self._handle_secrets(value, replace_scalar) for value in self.value] + self.value = tuple(value) return variables.replace_list(self.value) def _convert(self, value, type_): @@ -198,13 +237,22 @@ def _replace_variables(self, variables): raise DataError(f"Creating dictionary variable failed: {err}") def _yield_replaced(self, values, replace_scalar): + if not self.type: + k_type = v_type = None + elif "=" in self.type: + k_type, v_type = self.type.split("=", 1) + else: + k_type, v_type = "Any", self.type for item in values: if isinstance(item, tuple): - key, values = item - yield replace_scalar(key), replace_scalar(values) + key, value = item + yield ( + replace_scalar(self._handle_secrets(key, replace_scalar, k_type)), + replace_scalar(self._handle_secrets(value, replace_scalar, v_type)), + ) else: yield from replace_scalar(item).items() def _convert(self, value, type_): - k_type, v_type = self.type.split("=", 1) if "=" in type_ else ("Any", type_) + k_type, v_type = type_.split("=", 1) if "=" in type_ else ("Any", type_) return super()._convert(value, f"dict[{k_type}, {v_type}]")