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.
- secret_template_renderer-0.2.0.dist-info/LICENSE +21 -0
- secret_template_renderer-0.2.0.dist-info/METADATA +145 -0
- secret_template_renderer-0.2.0.dist-info/RECORD +15 -0
- secret_template_renderer-0.2.0.dist-info/WHEEL +4 -0
- secret_template_renderer-0.2.0.dist-info/entry_points.txt +3 -0
- src/__init__.py +0 -0
- src/app.py +275 -0
- src/logger.py +251 -0
- src/plugins/__init__.py +0 -0
- src/plugins/encryptions/__init__.py +0 -0
- src/plugins/encryptions/basic.py +85 -0
- src/plugins/secrets/1password.py +14 -0
- src/plugins/secrets/__init__.py +0 -0
- src/plugins/secrets/bitwarden.py +24 -0
- src/plugins/secrets/pass.py +12 -0
|
@@ -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,,
|
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)
|
src/plugins/__init__.py
ADDED
|
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
|