secret_template_renderer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 BenTriByte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.3
2
+ Name: secret_template_renderer
3
+ Version: 0.2.0
4
+ Summary: A tool for templating .env files and more.
5
+ Keywords: template,management,jinja2,secrets,dotenv,.env
6
+ Author: Ben Trinh
7
+ Author-email: huantrinh1802@gmail.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Provides-Extra: crypto
13
+ Requires-Dist: jinja2 (>=3.1.5,<4.0.0)
14
+ Project-URL: Homepage, https://github.com/huantrinh1802/secret_template_renderer
15
+ Project-URL: Issues, https://github.com/huantrinh1802/secret_template_renderer/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Secret Template Renderer
19
+
20
+ This project is a Jinja template renderer that supports fetching secrets from various providers.
21
+
22
+ ## Features
23
+
24
+ - Load built-in and user-defined plugins to extend the functionality
25
+ - Register multiple secret providers
26
+ - Render Jinja templates with secrets
27
+
28
+ ## Installation
29
+
30
+ ### Using pip
31
+
32
+ 1. Clone the repository
33
+ 2. Install the required dependencies
34
+
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ ### Using pipx
40
+
41
+ ```bash
42
+ pipx install secret_template_renderer
43
+
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Template Utilities
49
+
50
+ - `import_env`: import dotenv file
51
+ - `get_secret`: retrieve secret via secret providers
52
+ - `shell`: execute shell command
53
+ - `random`: generate a random string with the following parameters:
54
+ - length: int = 16,
55
+ - type: string | password = 'string',
56
+ - lower_case: bool = True,
57
+ - numbers: bool = True,
58
+ - has_special_chars: bool = False,
59
+ - must_has_special_chars: bool = False,
60
+ - exclude_characters: str = "",
61
+
62
+ ### Command Line Interface
63
+
64
+ - `-d`, `--debug`: Enable debug mode
65
+
66
+ #### Generate
67
+
68
+ ```bash
69
+ str generate [-f <template_path>] [-o <output_path>] [-i <input_string>] [-p <password>]
70
+ ```
71
+
72
+ - `-f`, `--file`: Path to the Jinja template file
73
+ - `-o`, `--output`: Path to the output file
74
+ - `-i`, `--input`: Input string to be rendered
75
+ - `-p`, `--password`: Password to be used for encryption
76
+
77
+ #### Encryption
78
+
79
+ ```bash
80
+ str encrypt|decrypt [-f <template_path>] [-o <output_path>] [-i <input_string>] [-p <password>]
81
+ ```
82
+
83
+ - `-f`, `--file`: Path to the Jinja template file
84
+ - `-o`, `--output`: Path to the output file
85
+ - `-i`, `--input`: Input string to be rendered
86
+ - `-p`, `--password`: Password to be used for encryption
87
+
88
+ #### Example
89
+
90
+ ```dotenv
91
+
92
+ {{ import_env('.default.env') }} # Importing another dotenv file
93
+ DATABASE_PWD={{ get_secret('bitwarden', 'database_system_a', 'login.password') }} # Use the bitwarden plugins to load Bitwarden
94
+ NAME=app-{{ random(10) }} # Generate the randomised strings
95
+ PWD={{ decrypt("SfB505whBisKznrdHKLvQ0hhaESDP0MqvWFsYNkI0to=", "password") }}
96
+ UID={{ shell('echo $UID') }}
97
+
98
+ ```
99
+
100
+ ## Custom plugins
101
+
102
+ To load custom plugins, place your plugin `.py` files in `~/.config/temv/plugins/[plugin_type]/[plugin_name]`. Currently, there are 2 plugins: `secrets` and `encryptions`.
103
+
104
+ Each plugin must have a `register` function that takes a dictionary of secret providers as an argument.
105
+
106
+ Secret example:
107
+
108
+ ```python
109
+ import subprocess
110
+ from collections.abc import Callable
111
+
112
+
113
+ def get_custom_secret(item_name: str, path: str) -> str | None:
114
+ pass
115
+
116
+
117
+ def register(secrets_providers: dict[str, Callable[[str, str], str | None]]):
118
+ """Register secret provider."""
119
+ secrets_providers["custom_provider"] = get_custom_secret
120
+ ```
121
+
122
+ Encryption example:
123
+
124
+ ```python
125
+ import subprocess
126
+ from collections.abc import Callable
127
+
128
+
129
+ def encrypt(value: str, password: str) -> str | None:
130
+ pass
131
+
132
+
133
+ def decrypt(value: str, password: str) -> str | None:
134
+ pass
135
+
136
+
137
+ def register(providers: dict[str, Callable[[str, str], str | None]]):
138
+ """Register encryption provider."""
139
+ providers["custom_provider"] = {"encrypt": encrypt, "decrypt": decrypt}
140
+ ```
141
+
142
+ ## License
143
+
144
+ This project is licensed under the MIT License.
145
+
@@ -0,0 +1,15 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/app.py,sha256=8ILyVcaQgvj3v9mqt2Uyy8lK_tR0otlOuh2zhVy05uA,9633
3
+ src/logger.py,sha256=-vPyiI2ZSxBqfv2Nz6a2aez0R9DgL8ZbtkrRUXJMTLU,10740
4
+ src/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ src/plugins/encryptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ src/plugins/encryptions/basic.py,sha256=ZwtdmC_etR-h15Wr9ZCQ6x4QMTVnnRCM5e0lqk7jFNg,3010
7
+ src/plugins/secrets/1password.py,sha256=p400YHc1e_-loU2bV4lZHl-Wr6C50F4a8r40C3thVLs,483
8
+ src/plugins/secrets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ src/plugins/secrets/bitwarden.py,sha256=bZcVagLdbQxU_raZSsJIeYe-Xa3DzPLvCw433VWi8dM,844
10
+ src/plugins/secrets/pass.py,sha256=yUttAIQxZBmjsiWASr1FVLYjx0YuW8IR8RxWa5NsyOY,433
11
+ secret_template_renderer-0.2.0.dist-info/LICENSE,sha256=mrW2fUttjcRV5BJGl88QcJc3kBQmpD_tjmTj8Rc4GLU,1067
12
+ secret_template_renderer-0.2.0.dist-info/METADATA,sha256=RTOX_ovUv1s4yRsiYX0m3UF-Xgu9VBWWIYPYwJtUnBQ,3853
13
+ secret_template_renderer-0.2.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
14
+ secret_template_renderer-0.2.0.dist-info/entry_points.txt,sha256=_OacEHU8VmTPFNu7TVo8TzDeic3elneOLN7YsEwP3vk,36
15
+ secret_template_renderer-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ str=src.app:main
3
+
src/__init__.py ADDED
File without changes
src/app.py ADDED
@@ -0,0 +1,275 @@
1
+ import argparse
2
+ import importlib
3
+ import importlib.machinery
4
+ import importlib.util
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import secrets
9
+ import string
10
+ import subprocess
11
+ import sys
12
+ import types
13
+ from collections.abc import Callable
14
+ from functools import partial
15
+ from typing import Any, Literal, TypedDict
16
+
17
+ from jinja2 import Environment, FileSystemLoader
18
+
19
+ from src.logger import Logging
20
+
21
+ # Registry for secret providers
22
+ providers: dict[str, dict[str, Any]] = {"secrets": {}, "encryptions": {}}
23
+ logger = Logging(__name__)
24
+
25
+
26
+ type plugins_type = Literal["secrets", "encryptions"]
27
+
28
+
29
+ def load_plugins(type: plugins_type):
30
+ """Load built-in and user plugins."""
31
+ # Load built-in plugins
32
+ _load_plugins_from_dir(pathlib.Path(__file__).parent / "plugins" / type, type)
33
+ cwd_config_plugins = pathlib.Path.cwd() / ".temv" / "plugins" / type
34
+ if cwd_config_plugins.exists():
35
+ _load_plugins_from_dir(cwd_config_plugins, type)
36
+ # Load plugins from TEMV_PLUGIN_DIR
37
+ env_var_plugin_dir = os.getenv("TEMV_PLUGIN_DIR", None)
38
+ if env_var_plugin_dir is None:
39
+ env_var_plugin_dir = os.getenv("XDG_CONFIG_DIR")
40
+ if env_var_plugin_dir:
41
+ env_var_path = pathlib.Path(env_var_plugin_dir) / type
42
+ if env_var_plugin_dir != "" and env_var_path.exists():
43
+ _load_plugins_from_dir(env_var_path, "encryptions")
44
+ logger.debug(
45
+ "Loaded plugins",
46
+ data={
47
+ f"{type}_providers": list(providers[type].keys()),
48
+ },
49
+ )
50
+
51
+
52
+ def _load_plugins_from_dir(directory: pathlib.Path, type: plugins_type = "secrets"):
53
+ """Helper to load plugins from a directory."""
54
+ for file in directory.glob("*.py"):
55
+ if file.name == "__init__.py":
56
+ continue # Skip __init__.py
57
+ if type == "encryptions" and file.name == "basic.py":
58
+ pkg = importlib.util.find_spec("cryptography")
59
+ if pkg is None:
60
+ continue
61
+ spec: importlib.machinery.ModuleSpec | None = (
62
+ importlib.util.spec_from_file_location(file.stem, file)
63
+ )
64
+ if spec and spec.loader:
65
+ module: types.ModuleType = importlib.util.module_from_spec(spec)
66
+ spec.loader.exec_module(module)
67
+ if hasattr(module, "register"):
68
+ register_func: Callable[..., Any] | None = getattr(module, "register")
69
+ if callable(register_func):
70
+ register_func(providers[type])
71
+
72
+
73
+ def get_secret(source: str, key: str, path: str = "") -> str | None:
74
+ if source in providers["secrets"]:
75
+ return providers["secrets"][source](key, path)
76
+ else:
77
+ return None
78
+
79
+
80
+ def import_env(source: str) -> str:
81
+ with open(source, "r") as env_file:
82
+ env = env_file.readlines()
83
+ return "\n".join(env).rstrip("\n")
84
+
85
+
86
+ type StringType = Literal["string", "password"]
87
+
88
+
89
+ def generate_random_string(
90
+ length: int = 16,
91
+ type: StringType = "string",
92
+ lower_case: bool = True,
93
+ numbers: bool = True,
94
+ has_special_chars: bool = False,
95
+ must_has_special_chars: bool = False,
96
+ exclude_characters: str = "",
97
+ ):
98
+ characters = ""
99
+ if not has_special_chars and must_has_special_chars:
100
+ raise ValueError(
101
+ "must_has_special_chars is True cannot use with has_special_chars is False"
102
+ )
103
+ if lower_case:
104
+ characters += string.ascii_lowercase
105
+ if numbers:
106
+ characters += string.digits
107
+ if type == "password" or has_special_chars:
108
+ characters += string.punctuation
109
+
110
+ # Remove excluded characters
111
+ characters = "".join(c for c in characters if c not in exclude_characters)
112
+
113
+ if not characters:
114
+ raise ValueError("No valid characters left to generate a string.")
115
+ output: str | None = None
116
+ while (
117
+ output is None
118
+ or (output and len(output) < length)
119
+ or (must_has_special_chars and all(c not in characters for c in output))
120
+ ):
121
+ try:
122
+ # Try using OpenSSL for better randomness
123
+ result = subprocess.run(
124
+ [
125
+ "openssl",
126
+ "rand",
127
+ "-base64",
128
+ str(length * 2), # Generate more bytes to filter properly
129
+ ],
130
+ capture_output=True,
131
+ text=True,
132
+ check=True,
133
+ )
134
+ random_bytes = result.stdout
135
+
136
+ # Filter to allowed characters
137
+ output = "".join(c for c in random_bytes if c in characters)[:length]
138
+ except (FileNotFoundError, subprocess.CalledProcessError):
139
+ logger.exception("openssl not found")
140
+ # Fallback to Python's secrets module
141
+ output = "".join(secrets.choice(characters) for _ in range(length))
142
+ return output
143
+
144
+
145
+ def decrypt(
146
+ encrypted_value: str, module: str = "basic", password: str | None = None
147
+ ) -> str | None:
148
+ return providers["encryptions"][module]["decrypt"](encrypted_value, password)
149
+
150
+
151
+ def encrypt(
152
+ value: str, module: str = "basic", password: str | None = None
153
+ ) -> str | None:
154
+ return providers["encryptions"][module]["encrypt"](value, password)
155
+
156
+
157
+ def shell(command: str) -> str | None:
158
+ result = subprocess.run(
159
+ command,
160
+ shell=True,
161
+ capture_output=True,
162
+ text=True,
163
+ check=True,
164
+ )
165
+ return result.stdout.rstrip("\n")
166
+
167
+
168
+ # Main rendering function
169
+ def render_template(template_path: str, password: str | None):
170
+ env: Environment = Environment(loader=FileSystemLoader("."))
171
+ env.globals["get_secret"] = get_secret # pyright: ignore [reportArgumentType]
172
+ env.globals["import_env"] = import_env # pyright:ignore [reportArgumentType]
173
+ env.globals["random"] = generate_random_string # pyright:ignore [reportArgumentType]
174
+ decrypt_func = partial(decrypt, password=password)
175
+ env.globals["decrypt"] = decrypt_func # pyright:ignore [reportArgumentType]
176
+ encrypt_func = partial(encrypt, password=password)
177
+ env.globals["encrypt"] = encrypt_func # pyright:ignore [reportArgumentType]
178
+ env.globals["shell"] = shell # pyright:ignore [reportArgumentType]
179
+
180
+ template = env.from_string(template_path)
181
+ return template.render()
182
+
183
+
184
+ def read_input(file_path: str):
185
+ """Reads input from a file."""
186
+ with open(file_path, "r", encoding="utf-8") as f:
187
+ return f.read()
188
+
189
+
190
+ def read_stdin():
191
+ """Reads input from stdin if data is available."""
192
+ if not sys.stdin.isatty(): # Check if stdin is piped
193
+ return sys.stdin.read()
194
+ return None
195
+
196
+
197
+ class Args(TypedDict):
198
+ subcommand: str
199
+ password: str | None
200
+ input: str | None
201
+ file: str | None
202
+ output: str | None
203
+ debug: bool
204
+
205
+
206
+ def main():
207
+ parser = argparse.ArgumentParser(description="Render Jinja template with secrets.")
208
+ _ = parser.add_argument("-d", "--debug", help="Debug mode", action="store_true")
209
+ subparsers = parser.add_subparsers(help="subcommand help", dest="subcommand")
210
+ generate_cmd = subparsers.add_parser("generate", help="Generate file from template")
211
+ _ = generate_cmd.add_argument(
212
+ "-f", "--file", required=True, help="Path to the Jinja template."
213
+ )
214
+ _ = generate_cmd.add_argument(
215
+ "-o", "--output", help="Path to the output file.", default=None
216
+ )
217
+ _ = generate_cmd.add_argument(
218
+ "-p", "--password", help="Password for decrypt", default=None
219
+ )
220
+ encrypt_cmd = subparsers.add_parser("encrypt", help="Encrypt input file or text")
221
+ _ = encrypt_cmd.add_argument("-p", "--password", help="Password", default=None)
222
+ _ = encrypt_cmd.add_argument("-i", "--input", help="Input")
223
+ _ = encrypt_cmd.add_argument("-f", "--file", help="File")
224
+ _ = encrypt_cmd.add_argument("-o", "--output", help="Directory")
225
+
226
+ decrypt_cmd = subparsers.add_parser("decrypt", help="Decrypt input file or text")
227
+ _ = decrypt_cmd.add_argument("-p", "--password", help="Pass", default=None)
228
+ _ = decrypt_cmd.add_argument("-i", "--input", help="Input")
229
+ _ = decrypt_cmd.add_argument("-f", "--file", help="File")
230
+ _ = decrypt_cmd.add_argument("-o", "--output", help="Directory")
231
+ args: Args = vars(parser.parse_args()) #pyright: ignore[reportAssignmentType]
232
+ input_content: str | None = None
233
+ if "input" in args and args['input'] is not None:
234
+ input_content = args['input']
235
+ elif args['file'] is not None:
236
+ try:
237
+ with open(args['file'], "r") as f:
238
+ input_content = "".join(f.readlines())
239
+ except Exception:
240
+ input_content = None
241
+ else:
242
+ input_content = read_stdin()
243
+ if args['debug']:
244
+ logger.logger.setLevel(logging.DEBUG)
245
+ content: str | None = None
246
+ match args.get('subcommand', None):
247
+ case "generate":
248
+ load_plugins("secrets")
249
+ load_plugins("encryptions")
250
+ if input_content:
251
+ content = render_template(input_content, args.get("password", None))
252
+ case "encrypt":
253
+ load_plugins("encryptions")
254
+ if input_content:
255
+ content = providers["encryptions"]["basic"]["encrypt"](
256
+ input_content, args.get('password', None)
257
+ )
258
+ case "decrypt":
259
+ load_plugins("encryptions")
260
+ if input_content:
261
+ content = providers["encryptions"]["basic"]["decrypt"](
262
+ input_content, args.get('password', None)
263
+ )
264
+ case _:
265
+ pass
266
+ if content:
267
+ if (output := args.get('output', None)):
268
+ with open(output, "w") as f:
269
+ _ = f.write(content)
270
+ else:
271
+ print(content)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()
src/logger.py ADDED
@@ -0,0 +1,251 @@
1
+ import contextlib
2
+ import inspect
3
+ import json
4
+ import logging
5
+ import sys
6
+ import traceback
7
+ from contextvars import ContextVar
8
+ from datetime import datetime, timezone
9
+ from functools import wraps
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ correlation_id: ContextVar[str] = ContextVar("correlation_id")
14
+ start_time: ContextVar[datetime] = ContextVar("start_time")
15
+ extras: ContextVar[dict] = ContextVar("extras", default={})
16
+
17
+
18
+ class Logging:
19
+ EXCEPTION_LEVEL = 60
20
+ name = None
21
+
22
+ def __init__(self, name=None):
23
+ if name:
24
+ self.name = name
25
+ self.logger = logging.getLogger(name)
26
+ else:
27
+ self.logger = logging.getLogger()
28
+ console_handler = logging.StreamHandler()
29
+ self.logger.addHandler(console_handler)
30
+ for h in self.logger.handlers:
31
+ h.setFormatter(logging.Formatter("%(message)s"))
32
+ self.logger.setLevel(logging.INFO)
33
+
34
+ def build_log(self, level, message, data, frame_level):
35
+ level_map = {
36
+ 60: "EXCEPTION",
37
+ 50: "CRITICAL",
38
+ 40: "ERROR",
39
+ 30: "WARNING",
40
+ 20: "INFO",
41
+ 10: "DEBUG",
42
+ 0: "NOTSET",
43
+ }
44
+ aFrame = sys._getframe(frame_level)
45
+ info = inspect.getframeinfo(aFrame)
46
+ log = {
47
+ "level": level_map[level],
48
+ }
49
+ if message:
50
+ log["message"] = message
51
+ log["timestamp"] = datetime.now(timezone.utc).isoformat()
52
+ log["path"] = f"{info.filename}:{info.lineno}"
53
+ if id := correlation_id.get(None):
54
+ log["correlation_id"] = id
55
+ if start_time.get(None):
56
+ log["duration"] = str(datetime.now(timezone.utc) - start_time.get())
57
+ if self.name:
58
+ log["module"] = self.name
59
+ if extras_data := extras.get(None):
60
+ log.update(extras_data)
61
+ if data:
62
+ log.update(data)
63
+ if level > logging.WARNING:
64
+ log.update({"traceback": traceback.format_exc()})
65
+ return log
66
+
67
+ def _exception(self, message, data: dict[str, Any] | None | None = None):
68
+ self._log(self.EXCEPTION_LEVEL, message, data)
69
+
70
+ def _log(self, level, message, data: dict | None | None = None, frame_level=2):
71
+ self.logger.log(
72
+ level,
73
+ json.dumps(self.build_log(level, message, data, frame_level), default=str),
74
+ )
75
+
76
+ def info(self, message: str | None = None, data: dict[str, Any] | None = None):
77
+ self._log(logging.INFO, message, data, 3)
78
+
79
+ def warn(self, message: str | None = None, data: dict[str, Any] | None = None):
80
+ self._log(logging.WARNING, message, data, 3)
81
+
82
+ def warning(self, message: str | None = None, data: dict[str, Any] | None = None):
83
+ self._log(logging.WARNING, message, data, 3)
84
+
85
+ def debug(self, message: str | None = None, data: dict[str, Any] | None = None):
86
+ self._log(logging.DEBUG, message, data, 3)
87
+
88
+ def error(self, message: str | None = None, data: dict[str, Any] | None = None):
89
+ self._log(logging.ERROR, message, data, 3)
90
+
91
+ def exception(self, message: str | None = None, data: dict[str, Any] | None = None):
92
+ self._log(self.EXCEPTION_LEVEL, message, data, 3)
93
+
94
+ def add_extras(self, extras_data):
95
+ return extras.set({**extras.get({}), **extras_data})
96
+
97
+ def extract_args(self, func, args, kwargs):
98
+ args_name = inspect.signature(func).parameters.keys()
99
+ return {**dict(zip(args_name, args)), **kwargs}
100
+
101
+ def attach_logger(
102
+ self,
103
+ attach_correlation_id: bool = True,
104
+ attached_fields={},
105
+ log_endpoint: bool = False,
106
+ ):
107
+ """
108
+ This is a Python function that attaches a logger to a given function, with options to include a
109
+ correlation ID, additional fields, and endpoint information.
110
+
111
+ :param attach_correlation_id: A boolean parameter that determines whether or not to attach a
112
+ unique correlation ID to the log messages. If set to True, a new UUID will be generated and
113
+ attached to the log messages. If set to False, no correlation ID will be attached, defaults to
114
+ True
115
+ :type attach_correlation_id: bool (optional)
116
+ :param attached_fields: `attached_fields` is a dictionary that contains additional fields to be
117
+ logged along with the function call. The keys of the dictionary represent the names of the
118
+ fields to be logged, and the values represent the corresponding argument names of the function.
119
+ If the argument is not provided, the default value of the argument
120
+ :param is_lambda: A boolean parameter that indicates whether the function being decorated is a
121
+ Lambda function or not. If it is a Lambda function, the decorator will wrap the function with
122
+ additional functionality to handle Lambda-specific event and context parameters, defaults to
123
+ False
124
+ :type is_lambda: bool (optional)
125
+ :param log_endpoint: log_endpoint is a boolean parameter that determines whether to include
126
+ additional logging information related to the endpoint being called, such as the user agent,
127
+ operation name, IP address, and field names. If set to True, this information will be added to
128
+ the log record as extra data, defaults to False
129
+ :type log_endpoint: bool (optional)
130
+ :return: The function `attach_logger` is returning a decorator function
131
+ `decorator_attach_logger`.
132
+ """
133
+
134
+ def decorator_attach_logger(func):
135
+ @wraps(func)
136
+ def wrapper(*args, **kwargs):
137
+ args_dict = self.extract_args(func, args, kwargs)
138
+ local_extras = {}
139
+ id = None
140
+ if attach_correlation_id:
141
+ id = correlation_id.set(str(uuid4()))
142
+ if len(attached_fields) > 0:
143
+ local_extras.update(
144
+ dict(
145
+ {
146
+ value: args_dict[field]
147
+ if args_dict.get(field, None)
148
+ else (
149
+ inspect.signature(func).parameters[field].default
150
+ if inspect.signature(func).parameters[field].default
151
+ != inspect._empty
152
+ else None
153
+ )
154
+ for field, value in attached_fields.items()
155
+ }
156
+ )
157
+ )
158
+ if log_endpoint:
159
+ info = args_dict.get("info", None)
160
+ if info:
161
+ local_extras.update(
162
+ {
163
+ "source": info.context["request"].headers.get(
164
+ "user-agent"
165
+ ),
166
+ "operation": info.operation.operation.name,
167
+ # Will detect if the name exists x-forwarded-for and REMOTE_ADDR
168
+ "ip_address": info.context["request"].headers.get(
169
+ "x-forwarded-for"
170
+ )
171
+ or info.context["request"].headers.get("REMOTE_ADDR")
172
+ or "n/a",
173
+ "field_names": info.field_name,
174
+ }
175
+ )
176
+ if len(local_extras) > 0:
177
+ self.add_extras(local_extras)
178
+ result = func(*args, **kwargs)
179
+ if id and attach_correlation_id:
180
+ correlation_id.reset(id)
181
+ return result
182
+
183
+ return wrapper
184
+
185
+ return decorator_attach_logger
186
+
187
+ @contextlib.contextmanager
188
+ def logger_context(
189
+ self, attach_correlation_id: bool = True, extras_data: dict[str, Any] | None = None
190
+ ):
191
+ """
192
+ This is a context manager function that attaches a logger context with an optional correlation
193
+ ID.
194
+
195
+ :param attach_correlation_id: A boolean parameter that determines whether or not to attach a
196
+ correlation ID to the logger context. If set to True, a new correlation ID will be generated and
197
+ attached to the logger context. If set to False, no correlation ID will be attached, defaults to
198
+ True
199
+ :type attach_correlation_id: bool (optional)
200
+ """
201
+ id = None
202
+ if attach_correlation_id:
203
+ id = correlation_id.set(str(uuid4()))
204
+ extras_token = None
205
+ if extras_data:
206
+ extras_token = self.add_extras(extras_data)
207
+ try:
208
+ yield
209
+ finally:
210
+ if id and attach_correlation_id:
211
+ correlation_id.reset(id)
212
+ if extras_token and extras_data:
213
+ extras.reset(extras_token)
214
+
215
+ def record_time(self, func):
216
+ """
217
+ This function records the execution time of a given function and returns a wrapper function that
218
+ can be used to call the original function with the added functionality of recording the
219
+ execution time.
220
+
221
+ :param func: `func` is a function that will be timed and recorded. It is the function that will
222
+ be passed as an argument to the `record_time` method
223
+ :param is_lambda: is a boolean parameter that indicates whether the function being decorated is
224
+ a lambda function or not. If it is a lambda function, the decorator will wrap it with a
225
+ lambda_wrapper function that takes in event and context parameters in addition to any other
226
+ arguments. If it is not a lambda function, the decorator will, defaults to False
227
+ :type is_lambda: bool (optional)
228
+ :return: The `record_time` function returns a wrapper function that records the execution time
229
+ of the input function. The returned wrapper function either takes in `event` and `context`
230
+ parameters (if `is_lambda` is True) or `*args` and `**kwargs` parameters (if `is_lambda` is
231
+ False) and returns the output of the input function.
232
+ """
233
+
234
+ @wraps(func)
235
+ def wrapper(*args, **kwargs):
236
+ time_token = start_time.set(datetime.now(timezone.utc))
237
+ cur_func = func(*args, **kwargs)
238
+ start_time.reset(time_token)
239
+ return cur_func
240
+
241
+ return wrapper
242
+
243
+ @contextlib.contextmanager
244
+ def record_time_context(self):
245
+ time_token = None
246
+ try:
247
+ time_token = start_time.set(datetime.now(timezone.utc))
248
+ yield
249
+ finally:
250
+ if time_token:
251
+ start_time.reset(time_token)
File without changes
File without changes
@@ -0,0 +1,85 @@
1
+ import base64
2
+ import hashlib
3
+ import logging
4
+ import os
5
+ from getpass import getpass
6
+ from typing import Callable
7
+
8
+ from cryptography.hazmat.backends import default_backend
9
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
10
+ from cryptography.hazmat.primitives.padding import PKCS7
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def derive_key(password: str, salt: bytes) -> bytes:
16
+ """Derives a 32-byte key from the password using PBKDF2."""
17
+ return hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000, dklen=32)
18
+
19
+
20
+ def encrypt(plaintext: str, password: str | None) -> str:
21
+ """Encrypts plaintext using AES-256-CBC."""
22
+ if password is None:
23
+ password = os.getenv("TEMV_BASIC_PASSWORD")
24
+ if password is None:
25
+ password = getpass()
26
+ salt = os.urandom(16) # Generate a random salt
27
+ key = derive_key(password, salt)
28
+ iv = os.urandom(16) # AES block size is 16 bytes
29
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
30
+ encryptor = cipher.encryptor()
31
+
32
+ # Apply PKCS7 padding
33
+ padder = PKCS7(128).padder()
34
+ padded_data = padder.update(plaintext.encode()) + padder.finalize()
35
+
36
+ ciphertext = encryptor.update(padded_data) + encryptor.finalize()
37
+
38
+ # Encode IV + ciphertext in Base64
39
+ return base64.b64encode(salt + iv + ciphertext).decode()
40
+
41
+
42
+ def decrypt(encrypted: str, password: str | None) -> str | None:
43
+ """Decrypts AES-256-CBC encrypted text."""
44
+ time = 0
45
+ if password is None:
46
+ password = os.getenv("TEMV_BASIC_PASSWORD", None)
47
+ if password is not None:
48
+ time = 2
49
+ plaintext = None
50
+ while time < 3 and plaintext is None:
51
+ if password is None:
52
+ password = getpass()
53
+ try:
54
+ encrypted_bytes = base64.b64decode(encrypted)
55
+ salt = encrypted_bytes[:16] # First 16 bytes are the salt
56
+ key = derive_key(password, salt)
57
+ iv = encrypted_bytes[16:32] # Next 16 bytes are the IV
58
+ ciphertext = encrypted_bytes[32:]
59
+
60
+ cipher = Cipher(
61
+ algorithms.AES(key), modes.CBC(iv), backend=default_backend()
62
+ )
63
+ decryptor = cipher.decryptor()
64
+
65
+ padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
66
+
67
+ # Remove padding
68
+ unpadder = PKCS7(128).unpadder()
69
+ plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
70
+ except Exception:
71
+ if time < 2:
72
+ logger.warning("Cannot decrypt the secret. Try again!")
73
+ else:
74
+ logger.warning("Failed to decrypt the secret")
75
+ time = time + 1
76
+ plaintext = None
77
+ password = None
78
+ if time == 3 or plaintext is None:
79
+ return None
80
+ return plaintext.decode()
81
+
82
+
83
+ def register(registry: dict[str, dict[str, Callable[[str, str | None], str | None]]]):
84
+ """Register the pass secret provider."""
85
+ registry["basic"] = {"encrypt": encrypt, "decrypt": decrypt}
@@ -0,0 +1,14 @@
1
+ import subprocess
2
+ from collections.abc import Callable
3
+
4
+
5
+ def get_1password_secret(item_name: str, *_) -> str | None:
6
+ result = subprocess.run(
7
+ ["op", "read", f"op://{item_name}"], capture_output=True, text=True
8
+ )
9
+ return result.stdout.strip() if result.returncode == 0 else ""
10
+
11
+
12
+ def register(secrets_providers: dict[str, Callable[[str, str], str | None]]):
13
+ """Register the 1Password secret provider."""
14
+ secrets_providers["1password"] = get_1password_secret
File without changes
@@ -0,0 +1,24 @@
1
+ import subprocess
2
+ from collections.abc import Callable
3
+
4
+
5
+ def get_bitwarden_secret(item_name: str, path: str) -> str | None:
6
+ command = f'bw get item "{item_name}"'
7
+ if path and len(path) > 0:
8
+ type, value = path.split(".")
9
+ match type:
10
+ case "field":
11
+ command += (
12
+ f" | jq -r '.fields[] | select(.name == \"{value}\") | .value'"
13
+ )
14
+ case "login":
15
+ command += f" | jq -r '.login.{value}"
16
+ case _:
17
+ return None
18
+ result = subprocess.run(command, capture_output=True, text=True, shell=True)
19
+ return result.stdout.strip()
20
+
21
+
22
+ def register(secrets_providers: dict[str, Callable[[str, str], str | None]]):
23
+ """Register the Bitwarden secret provider."""
24
+ secrets_providers["bitwarden"] = get_bitwarden_secret
@@ -0,0 +1,12 @@
1
+ import subprocess
2
+ from collections.abc import Callable
3
+
4
+
5
+ def get_pass_secret(item_name: str, *_) -> str | None:
6
+ result = subprocess.run(["pass", item_name], capture_output=True, text=True)
7
+ return result.stdout.strip() if result.returncode == 0 else ""
8
+
9
+
10
+ def register(secrets_providers: dict[str, Callable[[str, str], str | None]]):
11
+ """Register the pass secret provider."""
12
+ secrets_providers["pass"] = get_pass_secret