superencryptx 0.1.0__tar.gz
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.
- superencryptx-0.1.0/LICENSE +21 -0
- superencryptx-0.1.0/PKG-INFO +116 -0
- superencryptx-0.1.0/README.md +69 -0
- superencryptx-0.1.0/pyproject.toml +39 -0
- superencryptx-0.1.0/setup.cfg +4 -0
- superencryptx-0.1.0/superencrypt/__init__.py +2 -0
- superencryptx-0.1.0/superencrypt/__main__.py +5 -0
- superencryptx-0.1.0/superencrypt/cli.py +110 -0
- superencryptx-0.1.0/superencrypt/crypto.py +52 -0
- superencryptx-0.1.0/superencrypt/scanner.py +133 -0
- superencryptx-0.1.0/superencrypt/transform.py +143 -0
- superencryptx-0.1.0/superencryptx.egg-info/PKG-INFO +116 -0
- superencryptx-0.1.0/superencryptx.egg-info/SOURCES.txt +15 -0
- superencryptx-0.1.0/superencryptx.egg-info/dependency_links.txt +1 -0
- superencryptx-0.1.0/superencryptx.egg-info/entry_points.txt +2 -0
- superencryptx-0.1.0/superencryptx.egg-info/requires.txt +1 -0
- superencryptx-0.1.0/superencryptx.egg-info/top_level.txt +3 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Superencrypt Contributors
|
|
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,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: superencryptx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan a repo for secrets and encrypt/decrypt them in-place.
|
|
5
|
+
Author: Superencrypt Contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Superencrypt Contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://example.com/superencrypt
|
|
29
|
+
Project-URL: Repository, https://example.com/superencrypt
|
|
30
|
+
Project-URL: Issues, https://example.com/superencrypt/issues
|
|
31
|
+
Keywords: secrets,encryption,git,security,cli
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Security
|
|
41
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Requires-Dist: cryptography>=41.0.0
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# superencrypt
|
|
49
|
+
|
|
50
|
+
CLI to scan a repo for secrets (including env files), encrypt them in-place, and decrypt them later using a key.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
`superencrypt` helps you keep accidental secrets out of your repo history by encrypting sensitive values in-place while keeping files versionable.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install superencryptx
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### No venv (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pipx install superencryptx
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### System install (no venv)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python3 -m pip install --user superencryptx
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
78
|
+
superencrypt encrypt
|
|
79
|
+
|
|
80
|
+
# Decrypt in-place (use in CI/CD pipelines)
|
|
81
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
88
|
+
superencrypt encrypt
|
|
89
|
+
|
|
90
|
+
# Decrypt in-place (provide key or key file)
|
|
91
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
92
|
+
|
|
93
|
+
# Scan only (no changes)
|
|
94
|
+
superencrypt scan
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Pipeline example
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export SUPERENCRYPT_KEY="$(cat .superencrypt.key)"
|
|
101
|
+
superencrypt decrypt --key "$SUPERENCRYPT_KEY"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Notes
|
|
105
|
+
|
|
106
|
+
- Encrypted values are stored as `ENC[<token>]`.
|
|
107
|
+
- Key file `.superencrypt.key` should be protected and not committed.
|
|
108
|
+
- Use `scan` first to review matches.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m venv .venv
|
|
114
|
+
source .venv/bin/activate
|
|
115
|
+
pip install -e .
|
|
116
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# superencrypt
|
|
2
|
+
|
|
3
|
+
CLI to scan a repo for secrets (including env files), encrypt them in-place, and decrypt them later using a key.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`superencrypt` helps you keep accidental secrets out of your repo history by encrypting sensitive values in-place while keeping files versionable.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install superencryptx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### No venv (recommended)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pipx install superencryptx
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### System install (no venv)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 -m pip install --user superencryptx
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
31
|
+
superencrypt encrypt
|
|
32
|
+
|
|
33
|
+
# Decrypt in-place (use in CI/CD pipelines)
|
|
34
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
41
|
+
superencrypt encrypt
|
|
42
|
+
|
|
43
|
+
# Decrypt in-place (provide key or key file)
|
|
44
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
45
|
+
|
|
46
|
+
# Scan only (no changes)
|
|
47
|
+
superencrypt scan
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Pipeline example
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export SUPERENCRYPT_KEY="$(cat .superencrypt.key)"
|
|
54
|
+
superencrypt decrypt --key "$SUPERENCRYPT_KEY"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Notes
|
|
58
|
+
|
|
59
|
+
- Encrypted values are stored as `ENC[<token>]`.
|
|
60
|
+
- Key file `.superencrypt.key` should be protected and not committed.
|
|
61
|
+
- Use `scan` first to review matches.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m venv .venv
|
|
67
|
+
source .venv/bin/activate
|
|
68
|
+
pip install -e .
|
|
69
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "superencryptx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Scan a repo for secrets and encrypt/decrypt them in-place."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [{ name = "Superencrypt Contributors" }]
|
|
8
|
+
license = { file = "LICENSE" }
|
|
9
|
+
keywords = ["secrets", "encryption", "git", "security", "cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Software Development :: Version Control",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"cryptography>=41.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
superencrypt = "superencrypt.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://example.com/superencrypt"
|
|
31
|
+
Repository = "https://example.com/superencrypt"
|
|
32
|
+
Issues = "https://example.com/superencrypt/issues"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["setuptools>=68.0.0"]
|
|
36
|
+
build-backend = "setuptools.build_meta"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from .crypto import Crypto
|
|
9
|
+
from .scanner import scan_repo, iter_repo_files
|
|
10
|
+
from .transform import encrypt_file, decrypt_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_KEY_FILE = ".superencrypt.key"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_key_from_args(args: argparse.Namespace) -> bytes:
|
|
17
|
+
if args.key:
|
|
18
|
+
return args.key.encode("utf-8")
|
|
19
|
+
if args.key_file:
|
|
20
|
+
return Path(args.key_file).read_bytes().strip()
|
|
21
|
+
raise SystemExit("Missing key: provide --key or --key-file")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_key_file(path: Path, key: bytes) -> None:
|
|
25
|
+
path.write_bytes(key + b"\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
29
|
+
root = Path(args.root).resolve()
|
|
30
|
+
findings = scan_repo(root)
|
|
31
|
+
if not findings:
|
|
32
|
+
print("No secrets found.")
|
|
33
|
+
return 0
|
|
34
|
+
for finding in findings:
|
|
35
|
+
rel = finding.path.relative_to(root)
|
|
36
|
+
key = finding.key or "secret"
|
|
37
|
+
print(f"{rel}:{finding.line_number} {key}={finding.value}")
|
|
38
|
+
print(f"\nFound {len(findings)} potential secrets.")
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_encrypt(args: argparse.Namespace) -> int:
|
|
43
|
+
root = Path(args.root).resolve()
|
|
44
|
+
if args.key or args.key_file:
|
|
45
|
+
key = _load_key_from_args(args)
|
|
46
|
+
else:
|
|
47
|
+
key = Crypto.generate_key()
|
|
48
|
+
print(key.decode("utf-8"))
|
|
49
|
+
_write_key_file(Path(DEFAULT_KEY_FILE), key)
|
|
50
|
+
crypto = Crypto(key)
|
|
51
|
+
|
|
52
|
+
changed_files: List[Path] = []
|
|
53
|
+
for path in iter_repo_files(root):
|
|
54
|
+
result = encrypt_file(path, crypto)
|
|
55
|
+
if result.changed:
|
|
56
|
+
changed_files.append(result.path)
|
|
57
|
+
if changed_files:
|
|
58
|
+
print(f"Encrypted {len(changed_files)} files.")
|
|
59
|
+
else:
|
|
60
|
+
print("No changes made.")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_decrypt(args: argparse.Namespace) -> int:
|
|
65
|
+
root = Path(args.root).resolve()
|
|
66
|
+
key = _load_key_from_args(args)
|
|
67
|
+
crypto = Crypto(key)
|
|
68
|
+
|
|
69
|
+
changed_files: List[Path] = []
|
|
70
|
+
for path in iter_repo_files(root):
|
|
71
|
+
result = decrypt_file(path, crypto)
|
|
72
|
+
if result.changed:
|
|
73
|
+
changed_files.append(result.path)
|
|
74
|
+
if changed_files:
|
|
75
|
+
print(f"Decrypted {len(changed_files)} files.")
|
|
76
|
+
else:
|
|
77
|
+
print("No changes made.")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
82
|
+
parser = argparse.ArgumentParser(prog="superencrypt")
|
|
83
|
+
parser.add_argument("--root", default=".", help="Root directory to scan")
|
|
84
|
+
|
|
85
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
86
|
+
|
|
87
|
+
scan_parser = subparsers.add_parser("scan", help="Scan repo for secrets")
|
|
88
|
+
scan_parser.set_defaults(func=cmd_scan)
|
|
89
|
+
|
|
90
|
+
encrypt_parser = subparsers.add_parser("encrypt", help="Encrypt secrets in-place")
|
|
91
|
+
encrypt_parser.add_argument("--key", help="Base64 key string")
|
|
92
|
+
encrypt_parser.add_argument("--key-file", help="Path to key file")
|
|
93
|
+
encrypt_parser.set_defaults(func=cmd_encrypt)
|
|
94
|
+
|
|
95
|
+
decrypt_parser = subparsers.add_parser("decrypt", help="Decrypt secrets in-place")
|
|
96
|
+
decrypt_parser.add_argument("--key", help="Base64 key string")
|
|
97
|
+
decrypt_parser.add_argument("--key-file", help="Path to key file")
|
|
98
|
+
decrypt_parser.set_defaults(func=cmd_decrypt)
|
|
99
|
+
|
|
100
|
+
return parser
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> int:
|
|
104
|
+
parser = build_parser()
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
return args.func(args)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ENC_PREFIX = "ENC["
|
|
10
|
+
ENC_SUFFIX = "]"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class EncryptionResult:
|
|
15
|
+
token: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CryptoError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Crypto:
|
|
23
|
+
def __init__(self, key: bytes):
|
|
24
|
+
self._fernet = Fernet(key)
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def generate_key() -> bytes:
|
|
28
|
+
return Fernet.generate_key()
|
|
29
|
+
|
|
30
|
+
def encrypt(self, plaintext: str) -> EncryptionResult:
|
|
31
|
+
token = self._fernet.encrypt(plaintext.encode("utf-8")).decode("utf-8")
|
|
32
|
+
return EncryptionResult(token=token)
|
|
33
|
+
|
|
34
|
+
def decrypt(self, token: str) -> str:
|
|
35
|
+
try:
|
|
36
|
+
return self._fernet.decrypt(token.encode("utf-8")).decode("utf-8")
|
|
37
|
+
except InvalidToken as exc:
|
|
38
|
+
raise CryptoError("Invalid decryption key or token") from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_encrypted_value(value: str) -> bool:
|
|
42
|
+
return value.startswith(ENC_PREFIX) and value.endswith(ENC_SUFFIX)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wrap_encrypted(token: str) -> str:
|
|
46
|
+
return f"{ENC_PREFIX}{token}{ENC_SUFFIX}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def unwrap_encrypted(value: str) -> Optional[str]:
|
|
50
|
+
if not is_encrypted_value(value):
|
|
51
|
+
return None
|
|
52
|
+
return value[len(ENC_PREFIX) : -len(ENC_SUFFIX)]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Iterator, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SKIP_DIRS = {
|
|
11
|
+
".git",
|
|
12
|
+
".hg",
|
|
13
|
+
".svn",
|
|
14
|
+
"node_modules",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
SKIP_FILES = {
|
|
23
|
+
".superencrypt.key",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ENV_FILE_PATTERNS = (
|
|
27
|
+
".env",
|
|
28
|
+
".env.",
|
|
29
|
+
".envrc",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SENSITIVE_KEYWORDS = re.compile(
|
|
33
|
+
r"(?i)(password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|db[_-]?user|database[_-]?user)"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Finding:
|
|
39
|
+
path: Path
|
|
40
|
+
line_number: int
|
|
41
|
+
key: Optional[str]
|
|
42
|
+
value: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SecretPattern:
|
|
47
|
+
name: str
|
|
48
|
+
regex: re.Pattern
|
|
49
|
+
group: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SECRET_PATTERNS: List[SecretPattern] = [
|
|
53
|
+
SecretPattern(
|
|
54
|
+
name="aws_access_key_id",
|
|
55
|
+
regex=re.compile(r"\b(AKIA[0-9A-Z]{16})\b"),
|
|
56
|
+
group=1,
|
|
57
|
+
),
|
|
58
|
+
SecretPattern(
|
|
59
|
+
name="aws_secret_access_key",
|
|
60
|
+
regex=re.compile(r"(?i)aws_secret_access_key\s*[:=]\s*([A-Za-z0-9/+=]{40})"),
|
|
61
|
+
group=1,
|
|
62
|
+
),
|
|
63
|
+
SecretPattern(
|
|
64
|
+
name="generic_assignment",
|
|
65
|
+
regex=re.compile(r"(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*([\w\-./+=:@]+)"),
|
|
66
|
+
group=2,
|
|
67
|
+
),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_env_file(path: Path) -> bool:
|
|
72
|
+
name = path.name
|
|
73
|
+
if name == ".env" or name.startswith(".env.") or name.endswith(".env"):
|
|
74
|
+
return True
|
|
75
|
+
return name in ENV_FILE_PATTERNS
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_binary(data: bytes) -> bool:
|
|
79
|
+
return b"\x00" in data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def iter_repo_files(root: Path) -> Iterator[Path]:
|
|
83
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
84
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
85
|
+
for filename in filenames:
|
|
86
|
+
if filename in SKIP_FILES:
|
|
87
|
+
continue
|
|
88
|
+
yield Path(dirpath) / filename
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_env_file(path: Path) -> List[Finding]:
|
|
92
|
+
findings: List[Finding] = []
|
|
93
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
94
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if not stripped or stripped.startswith("#"):
|
|
97
|
+
continue
|
|
98
|
+
if stripped.startswith("export "):
|
|
99
|
+
stripped = stripped[len("export ") :]
|
|
100
|
+
if "=" not in stripped:
|
|
101
|
+
continue
|
|
102
|
+
key, value = stripped.split("=", 1)
|
|
103
|
+
key = key.strip()
|
|
104
|
+
value = value.strip().strip('"').strip("'")
|
|
105
|
+
if SENSITIVE_KEYWORDS.search(key):
|
|
106
|
+
findings.append(Finding(path=path, line_number=idx, key=key, value=value))
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def scan_file_for_patterns(path: Path) -> List[Finding]:
|
|
111
|
+
data = path.read_bytes()
|
|
112
|
+
if _is_binary(data):
|
|
113
|
+
return []
|
|
114
|
+
text = data.decode("utf-8", errors="ignore")
|
|
115
|
+
findings: List[Finding] = []
|
|
116
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
117
|
+
for pattern in SECRET_PATTERNS:
|
|
118
|
+
match = pattern.regex.search(line)
|
|
119
|
+
if not match:
|
|
120
|
+
continue
|
|
121
|
+
value = match.group(pattern.group)
|
|
122
|
+
findings.append(Finding(path=path, line_number=idx, key=pattern.name, value=value))
|
|
123
|
+
return findings
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def scan_repo(root: Path) -> List[Finding]:
|
|
127
|
+
findings: List[Finding] = []
|
|
128
|
+
for path in iter_repo_files(root):
|
|
129
|
+
if _is_env_file(path):
|
|
130
|
+
findings.extend(scan_env_file(path))
|
|
131
|
+
continue
|
|
132
|
+
findings.extend(scan_file_for_patterns(path))
|
|
133
|
+
return findings
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable, List
|
|
7
|
+
|
|
8
|
+
from .crypto import Crypto, is_encrypted_value, wrap_encrypted, unwrap_encrypted
|
|
9
|
+
from .scanner import SECRET_PATTERNS, SENSITIVE_KEYWORDS, _is_env_file, _is_binary
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TransformResult:
|
|
14
|
+
path: Path
|
|
15
|
+
changed: bool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _encrypt_env_lines(text: str, crypto: Crypto) -> str:
|
|
19
|
+
lines = text.splitlines()
|
|
20
|
+
changed = False
|
|
21
|
+
for idx, line in enumerate(lines):
|
|
22
|
+
stripped = line.strip()
|
|
23
|
+
if not stripped or stripped.startswith("#"):
|
|
24
|
+
continue
|
|
25
|
+
export_prefix = ""
|
|
26
|
+
if stripped.startswith("export "):
|
|
27
|
+
export_prefix = "export "
|
|
28
|
+
stripped = stripped[len("export ") :]
|
|
29
|
+
if "=" not in stripped:
|
|
30
|
+
continue
|
|
31
|
+
key, value = stripped.split("=", 1)
|
|
32
|
+
key = key.strip()
|
|
33
|
+
raw_value = value.strip()
|
|
34
|
+
quote = ""
|
|
35
|
+
if raw_value.startswith(('"', "'")) and raw_value.endswith(('"', "'")):
|
|
36
|
+
quote = raw_value[0]
|
|
37
|
+
raw_value = raw_value[1:-1]
|
|
38
|
+
if not SENSITIVE_KEYWORDS.search(key):
|
|
39
|
+
continue
|
|
40
|
+
if is_encrypted_value(raw_value):
|
|
41
|
+
continue
|
|
42
|
+
token = crypto.encrypt(raw_value).token
|
|
43
|
+
new_value = wrap_encrypted(token)
|
|
44
|
+
if quote:
|
|
45
|
+
new_value = f"{quote}{new_value}{quote}"
|
|
46
|
+
lines[idx] = f"{export_prefix}{key}={new_value}"
|
|
47
|
+
changed = True
|
|
48
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decrypt_env_lines(text: str, crypto: Crypto) -> str:
|
|
52
|
+
lines = text.splitlines()
|
|
53
|
+
for idx, line in enumerate(lines):
|
|
54
|
+
stripped = line.strip()
|
|
55
|
+
if not stripped or stripped.startswith("#"):
|
|
56
|
+
continue
|
|
57
|
+
export_prefix = ""
|
|
58
|
+
if stripped.startswith("export "):
|
|
59
|
+
export_prefix = "export "
|
|
60
|
+
stripped = stripped[len("export ") :]
|
|
61
|
+
if "=" not in stripped:
|
|
62
|
+
continue
|
|
63
|
+
key, value = stripped.split("=", 1)
|
|
64
|
+
key = key.strip()
|
|
65
|
+
raw_value = value.strip()
|
|
66
|
+
quote = ""
|
|
67
|
+
if raw_value.startswith(('"', "'")) and raw_value.endswith(('"', "'")):
|
|
68
|
+
quote = raw_value[0]
|
|
69
|
+
raw_value = raw_value[1:-1]
|
|
70
|
+
token = unwrap_encrypted(raw_value)
|
|
71
|
+
if token is None:
|
|
72
|
+
continue
|
|
73
|
+
plaintext = crypto.decrypt(token)
|
|
74
|
+
new_value = plaintext
|
|
75
|
+
if quote:
|
|
76
|
+
new_value = f"{quote}{new_value}{quote}"
|
|
77
|
+
lines[idx] = f"{export_prefix}{key}={new_value}"
|
|
78
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _encrypt_generic(text: str, crypto: Crypto) -> str:
|
|
82
|
+
changed = False
|
|
83
|
+
|
|
84
|
+
def replacer(match: re.Match) -> str:
|
|
85
|
+
nonlocal changed
|
|
86
|
+
for pattern in SECRET_PATTERNS:
|
|
87
|
+
inner = pattern.regex.search(match.group(0))
|
|
88
|
+
if inner:
|
|
89
|
+
value = inner.group(pattern.group)
|
|
90
|
+
if is_encrypted_value(value):
|
|
91
|
+
return match.group(0)
|
|
92
|
+
token = crypto.encrypt(value).token
|
|
93
|
+
replaced = match.group(0).replace(value, wrap_encrypted(token), 1)
|
|
94
|
+
changed = True
|
|
95
|
+
return replaced
|
|
96
|
+
return match.group(0)
|
|
97
|
+
|
|
98
|
+
result = text
|
|
99
|
+
for pattern in SECRET_PATTERNS:
|
|
100
|
+
result = pattern.regex.sub(lambda m: replacer(m), result)
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decrypt_generic(text: str, crypto: Crypto) -> str:
|
|
105
|
+
def replacer(match: re.Match) -> str:
|
|
106
|
+
value = match.group(0)
|
|
107
|
+
token = unwrap_encrypted(value)
|
|
108
|
+
if token is None:
|
|
109
|
+
return value
|
|
110
|
+
plaintext = crypto.decrypt(token)
|
|
111
|
+
return plaintext
|
|
112
|
+
|
|
113
|
+
return re.sub(r"ENC\[[^\]]+\]", replacer, text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def encrypt_file(path: Path, crypto: Crypto) -> TransformResult:
|
|
117
|
+
data = path.read_bytes()
|
|
118
|
+
if _is_binary(data):
|
|
119
|
+
return TransformResult(path=path, changed=False)
|
|
120
|
+
text = data.decode("utf-8", errors="ignore")
|
|
121
|
+
if _is_env_file(path):
|
|
122
|
+
new_text = _encrypt_env_lines(text, crypto)
|
|
123
|
+
else:
|
|
124
|
+
new_text = _encrypt_generic(text, crypto)
|
|
125
|
+
changed = new_text != text
|
|
126
|
+
if changed:
|
|
127
|
+
path.write_text(new_text, encoding="utf-8")
|
|
128
|
+
return TransformResult(path=path, changed=changed)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def decrypt_file(path: Path, crypto: Crypto) -> TransformResult:
|
|
132
|
+
data = path.read_bytes()
|
|
133
|
+
if _is_binary(data):
|
|
134
|
+
return TransformResult(path=path, changed=False)
|
|
135
|
+
text = data.decode("utf-8", errors="ignore")
|
|
136
|
+
if _is_env_file(path):
|
|
137
|
+
new_text = _decrypt_env_lines(text, crypto)
|
|
138
|
+
else:
|
|
139
|
+
new_text = _decrypt_generic(text, crypto)
|
|
140
|
+
changed = new_text != text
|
|
141
|
+
if changed:
|
|
142
|
+
path.write_text(new_text, encoding="utf-8")
|
|
143
|
+
return TransformResult(path=path, changed=changed)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: superencryptx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan a repo for secrets and encrypt/decrypt them in-place.
|
|
5
|
+
Author: Superencrypt Contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Superencrypt Contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://example.com/superencrypt
|
|
29
|
+
Project-URL: Repository, https://example.com/superencrypt
|
|
30
|
+
Project-URL: Issues, https://example.com/superencrypt/issues
|
|
31
|
+
Keywords: secrets,encryption,git,security,cli
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Security
|
|
41
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
License-File: LICENSE
|
|
45
|
+
Requires-Dist: cryptography>=41.0.0
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# superencrypt
|
|
49
|
+
|
|
50
|
+
CLI to scan a repo for secrets (including env files), encrypt them in-place, and decrypt them later using a key.
|
|
51
|
+
|
|
52
|
+
## Why
|
|
53
|
+
|
|
54
|
+
`superencrypt` helps you keep accidental secrets out of your repo history by encrypting sensitive values in-place while keeping files versionable.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install superencryptx
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### No venv (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pipx install superencryptx
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### System install (no venv)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python3 -m pip install --user superencryptx
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
78
|
+
superencrypt encrypt
|
|
79
|
+
|
|
80
|
+
# Decrypt in-place (use in CI/CD pipelines)
|
|
81
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Encrypt in-place (generates a key, prints it, and writes .superencrypt.key)
|
|
88
|
+
superencrypt encrypt
|
|
89
|
+
|
|
90
|
+
# Decrypt in-place (provide key or key file)
|
|
91
|
+
superencrypt decrypt --key-file .superencrypt.key
|
|
92
|
+
|
|
93
|
+
# Scan only (no changes)
|
|
94
|
+
superencrypt scan
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Pipeline example
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export SUPERENCRYPT_KEY="$(cat .superencrypt.key)"
|
|
101
|
+
superencrypt decrypt --key "$SUPERENCRYPT_KEY"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Notes
|
|
105
|
+
|
|
106
|
+
- Encrypted values are stored as `ENC[<token>]`.
|
|
107
|
+
- Key file `.superencrypt.key` should be protected and not committed.
|
|
108
|
+
- Use `scan` first to review matches.
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m venv .venv
|
|
114
|
+
source .venv/bin/activate
|
|
115
|
+
pip install -e .
|
|
116
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
superencrypt/__init__.py
|
|
5
|
+
superencrypt/__main__.py
|
|
6
|
+
superencrypt/cli.py
|
|
7
|
+
superencrypt/crypto.py
|
|
8
|
+
superencrypt/scanner.py
|
|
9
|
+
superencrypt/transform.py
|
|
10
|
+
superencryptx.egg-info/PKG-INFO
|
|
11
|
+
superencryptx.egg-info/SOURCES.txt
|
|
12
|
+
superencryptx.egg-info/dependency_links.txt
|
|
13
|
+
superencryptx.egg-info/entry_points.txt
|
|
14
|
+
superencryptx.egg-info/requires.txt
|
|
15
|
+
superencryptx.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cryptography>=41.0.0
|