Skip to content

Commit 8e0491f

Browse files
authored
New features: inline secrets, custom Jinja filters! (#10)
* fixed *.auto.tfvars variable autodeclaration * updated copyright year * added custom jinja2 filters support * simplified directory_remove * added inline encryption support
1 parent ecfb245 commit 8e0491f

9 files changed

+291
-29
lines changed

src/filters.py

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 Cisco Systems, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
def deepformat(value, params):
18+
if isinstance(value, dict):
19+
return {
20+
deepformat(key, params): deepformat(value, params)
21+
for key, value in value.items()
22+
}
23+
if isinstance(value, list):
24+
return [
25+
deepformat(item, params)
26+
for item in value
27+
]
28+
if isinstance(value, str):
29+
return value.format(**params)
30+
return value
31+
32+
33+
__all__ = [
34+
deepformat,
35+
]

src/helpers.py

+53-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright 2023 Cisco Systems, Inc.
3+
# Copyright 2024 Cisco Systems, Inc.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
import fnmatch
1818
import glob
1919
import json
20+
import os
2021
import pathlib
2122
import shutil
2223
import tempfile
@@ -26,6 +27,9 @@
2627
import jinja2
2728
import yaml
2829

30+
import filters
31+
from tools import encryption_decrypt
32+
2933

3034
def directory_copy(srcpath, dstpath, ignore=[]):
3135
"""Copy the contents of the dir in 'srcpath' to the dir in 'dstpath'.
@@ -62,19 +66,12 @@ def directory_remove(path, keep=[]):
6266
if not path.is_dir():
6367
return
6468

65-
temp = pathlib.Path(tempfile.TemporaryDirectory(dir=pathlib.Path().cwd()).name)
66-
for item in keep:
67-
itempath = path.joinpath(item)
68-
if itempath.exists():
69-
shutil.move(itempath, temp.joinpath(item))
70-
71-
shutil.rmtree(path)
72-
73-
for item in keep:
74-
itempath = temp.joinpath(item)
75-
if itempath.exists():
76-
shutil.move(itempath, path.joinpath(item))
77-
directory_remove(temp)
69+
for item in path.iterdir():
70+
if item.name not in keep:
71+
if item.is_dir():
72+
shutil.rmtree(item)
73+
else:
74+
item.unlink()
7875

7976

8077
def json_read(patterns):
@@ -151,6 +148,31 @@ def hcl2_read(patterns):
151148
continue
152149
with open(path, "r") as f:
153150
data = deepmerge.always_merger.merge(data, hcl2.load(f))
151+
return hcl2_decrypt(data)
152+
153+
154+
def hcl2_decrypt(data):
155+
"""Decrypts all strings in 'data'.
156+
157+
Keyword arguments:
158+
data[any]: any HCL2-sourced data structure
159+
"""
160+
if isinstance(data, str) and data.startswith("ENC[") and data.endswith("]"):
161+
key_path = os.getenv("STACKS_PRIVATE_KEY_PATH")
162+
if not key_path:
163+
raise Exception("could not decrypt data: STACKS_PRIVATE_KEY_PATH is not set")
164+
if not pathlib.Path(key_path).exists():
165+
raise Exception(f"could not decrypt data: STACKS_PRIVATE_KEY_PATH ({key_path}) does not exist")
166+
return encryption_decrypt.main(data, key_path)
167+
168+
elif isinstance(data, list):
169+
for i in range(len(data)):
170+
data[i] = hcl2_decrypt(data[i])
171+
172+
elif isinstance(data, dict):
173+
for k, v in data.items():
174+
data[k] = hcl2_decrypt(v)
175+
154176
return data
155177

156178

@@ -167,8 +189,20 @@ def jinja2_render(patterns, data):
167189
path = pathlib.Path(path)
168190
if not path.is_file():
169191
continue
170-
with open(path, "r") as fin:
171-
template = jinja2.Template(fin.read())
172-
rendered = template.render(data)
173-
with open(path, "w") as fout:
174-
fout.write(rendered)
192+
try:
193+
with open(path, "r") as fin:
194+
template = jinja2.Template(fin.read())
195+
196+
rendered = template.render(data | {
197+
func.__name__: func
198+
for func in filters.__all__
199+
})
200+
201+
with open(path, "w") as fout:
202+
fout.write(rendered)
203+
except jinja2.exceptions.UndefinedError as e:
204+
print(f"Failure to render {path}: {e}", file=sys.stderr)
205+
sys.exit(1)
206+
except jinja2.exceptions.TemplateSyntaxError as e:
207+
print(f"Failure to render {path} at line {e.lineno}, in statement {e.source}: {e}", file=sys.stderr)
208+
sys.exit(1)

src/postinit.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright 2023 Cisco Systems, Inc.
3+
# Copyright 2024 Cisco Systems, Inc.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -17,10 +17,10 @@
1717
import glob
1818
import pathlib
1919

20-
import helpers
21-
2220
import git
2321

22+
import helpers
23+
2424

2525
# define context
2626
cwd = pathlib.Path().cwd() # current working directory

src/preinit.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright 2023 Cisco Systems, Inc.
3+
# Copyright 2024 Cisco Systems, Inc.
44
#
55
# Licensed under the Apache License, Version 2.0 (the "License");
66
# you may not use this file except in compliance with the License.
@@ -87,7 +87,7 @@
8787
list(variable.keys())[0]
8888
for variable in helpers.hcl2_read([workdir.joinpath("*.tf")]).get("variable", [])
8989
]
90-
for variable in {**variables, **helpers.hcl2_read([workdir.joinpath("*.tfvars")])}.keys(): # also autodeclare variables in *.auto.tfvars files
90+
for variable in {**variables, **helpers.hcl2_read([workdir.joinpath("*.auto.tfvars")])}.keys(): # also autodeclare variables in *.auto.tfvars files
9191
if variable not in variables_declared:
9292
universe["variable"][variable] = {}
9393

src/requirements.txt

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
deepmerge==1.0.1
2-
GitPython==3.1.30
3-
Jinja2==3.1.2
4-
python-hcl2==3.0.5
5-
PyYAML==6.0
1+
cryptography==43.0.1
2+
deepmerge==2.0
3+
GitPython==3.1.43
4+
Jinja2==3.1.4
5+
python-hcl2==4.3.5
6+
PyYAML==6.0.2

src/tools/cli_wrapper.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 Cisco Systems, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
def main(func):
18+
import argparse, inspect, json
19+
parser = argparse.ArgumentParser()
20+
for key, value in inspect.signature(func).parameters.items():
21+
parser.add_argument(
22+
f"--{key.replace('_','-')}",
23+
action = argparse.BooleanOptionalAction if isinstance(value.default, bool) else None,
24+
default = value.default,
25+
required = value.default == value.empty,
26+
)
27+
output = func(**vars(parser.parse_args()))
28+
if output is not None:
29+
print(json.dumps(output, indent=2))

src/tools/encryption_decrypt.py

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 Cisco Systems, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
def main(string, private_key_path):
18+
import base64
19+
import cryptography.hazmat.backends
20+
import cryptography.hazmat.primitives.asymmetric.padding
21+
import cryptography.hazmat.primitives.hashes
22+
import cryptography.hazmat.primitives.padding
23+
import cryptography.hazmat.primitives.serialization
24+
25+
symmetric_key_encrypted_base64, encryptor_tag_base64, init_vector_base64, string_encrypted_base64 = string.removeprefix("ENC[").removesuffix("]").split(";")
26+
27+
with open(private_key_path, "rb") as key_file:
28+
symmetric_key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
29+
key_file.read(),
30+
password = None,
31+
backend = cryptography.hazmat.backends.default_backend(),
32+
).decrypt(
33+
base64.b64decode(symmetric_key_encrypted_base64.encode()),
34+
cryptography.hazmat.primitives.asymmetric.padding.OAEP(
35+
mgf = cryptography.hazmat.primitives.asymmetric.padding.MGF1(algorithm=cryptography.hazmat.primitives.hashes.SHA256()),
36+
algorithm = cryptography.hazmat.primitives.hashes.SHA256(),
37+
label = None,
38+
)
39+
)
40+
41+
decryptor = cryptography.hazmat.primitives.ciphers.Cipher(
42+
cryptography.hazmat.primitives.ciphers.algorithms.AES(symmetric_key),
43+
cryptography.hazmat.primitives.ciphers.modes.GCM(
44+
base64.b64decode(init_vector_base64.encode()),
45+
base64.b64decode(encryptor_tag_base64.encode()),
46+
),
47+
backend = cryptography.hazmat.backends.default_backend(),
48+
).decryptor()
49+
padded = decryptor.update(base64.b64decode(string_encrypted_base64.encode())) + decryptor.finalize()
50+
51+
unpadder = cryptography.hazmat.primitives.padding.PKCS7(128).unpadder()
52+
unpadded = unpadder.update(padded) + unpadder.finalize()
53+
54+
return unpadded.decode("utf-8")
55+
56+
57+
if __name__ == "__main__":
58+
import cli_wrapper
59+
cli_wrapper.main(main)

src/tools/encryption_encrypt.py

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 Cisco Systems, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
def main(string, public_key_path):
18+
import base64
19+
import cryptography.hazmat.backends
20+
import cryptography.hazmat.primitives.asymmetric.padding
21+
import cryptography.hazmat.primitives.ciphers
22+
import cryptography.hazmat.primitives.hashes
23+
import cryptography.hazmat.primitives.padding
24+
import cryptography.hazmat.primitives.serialization
25+
import os
26+
27+
padder = cryptography.hazmat.primitives.padding.PKCS7(128).padder()
28+
padded = padder.update(string.encode()) + padder.finalize()
29+
30+
symmetric_key = os.urandom(32)
31+
32+
init_vector = os.urandom(12)
33+
init_vector_base64 = base64.b64encode(init_vector).decode("utf-8")
34+
35+
encryptor = cryptography.hazmat.primitives.ciphers.Cipher(cryptography.hazmat.primitives.ciphers.algorithms.AES(symmetric_key), cryptography.hazmat.primitives.ciphers.modes.GCM(init_vector), backend=cryptography.hazmat.backends.default_backend()).encryptor()
36+
37+
string_encrypted = encryptor.update(padded) + encryptor.finalize()
38+
string_encrypted_base64 = base64.b64encode(string_encrypted).decode("utf-8")
39+
40+
encryptor_tag_base64 = base64.b64encode(encryptor.tag).decode("utf-8")
41+
42+
with open(public_key_path, "rb") as f:
43+
public_key = cryptography.hazmat.primitives.serialization.load_pem_public_key(
44+
f.read(),
45+
backend = cryptography.hazmat.backends.default_backend(),
46+
)
47+
48+
symmetric_key_encrypted_base64 = base64.b64encode(public_key.encrypt(
49+
symmetric_key,
50+
cryptography.hazmat.primitives.asymmetric.padding.OAEP(
51+
mgf = cryptography.hazmat.primitives.asymmetric.padding.MGF1(algorithm=cryptography.hazmat.primitives.hashes.SHA256()),
52+
algorithm = cryptography.hazmat.primitives.hashes.SHA256(),
53+
label = None,
54+
)
55+
)).decode("utf-8")
56+
57+
return f"ENC[{symmetric_key_encrypted_base64};{encryptor_tag_base64};{init_vector_base64};{string_encrypted_base64}]"
58+
59+
60+
if __name__ == "__main__":
61+
import cli_wrapper
62+
cli_wrapper.main(main)

src/tools/encryption_generate_key.py

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2024 Cisco Systems, Inc.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
def main(public_key_path, private_key_path):
18+
import cryptography.hazmat.backends
19+
import cryptography.hazmat.primitives.serialization
20+
import cryptography.hazmat.primitives.asymmetric.rsa
21+
22+
key = cryptography.hazmat.primitives.asymmetric.rsa.generate_private_key(
23+
backend = cryptography.hazmat.backends.default_backend(),
24+
key_size = 2**11,
25+
public_exponent = 2**16+1,
26+
)
27+
with open(private_key_path, "wb") as f:
28+
f.write(key.private_bytes(
29+
encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM,
30+
format = cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8,
31+
encryption_algorithm = cryptography.hazmat.primitives.serialization.NoEncryption(),
32+
))
33+
with open(public_key_path, "wb") as f:
34+
f.write(key.public_key().public_bytes(
35+
encoding = cryptography.hazmat.primitives.serialization.Encoding.PEM,
36+
format = cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo,
37+
))
38+
39+
40+
if __name__ == "__main__":
41+
import cli_wrapper
42+
cli_wrapper.main(main)

0 commit comments

Comments
 (0)