dotguard-cli 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.
dotguard/parser.py ADDED
@@ -0,0 +1,216 @@
1
+ """A small, dependency-free parser for .env files.
2
+
3
+ Supported syntax:
4
+
5
+ # a comment
6
+ KEY=value
7
+ export KEY=value
8
+ KEY="double quoted with \\n escapes" # trailing comment
9
+ KEY='single quoted, taken literally'
10
+ KEY=value # trailing comment (needs a space before #)
11
+ KEY="a quoted value
12
+ can span several lines"
13
+ URL=http://${HOST}:${PORT:-8080}/ # references to earlier keys
14
+
15
+ Annotation comments directly above a key attach metadata to it:
16
+
17
+ # @type port
18
+ # @required
19
+ PORT=8080
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+ from dotguard.models import Entry, EnvFile
27
+
28
+
29
+ class ParseError(ValueError):
30
+ def __init__(self, message: str, path: str, line: int) -> None:
31
+ super().__init__(f"{path}:{line}: {message}")
32
+ self.path = path
33
+ self.line = line
34
+
35
+
36
+ _KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
37
+ _ANNOTATION_RE = re.compile(r"^@([a-z][a-z0-9_-]*)(?:\s+(.*))?$")
38
+ _ESCAPES = {"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\", "$": "$"}
39
+ # ${NAME}, ${NAME:-default}, ${NAME-default} or $NAME
40
+ _REFERENCE_RE = re.compile(
41
+ r"\$(?:\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?:(?P<op>:?-)(?P<default>[^}]*))?\}"
42
+ r"|(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
43
+ )
44
+
45
+
46
+ def parse_file(path: str, interpolate: bool = True) -> EnvFile:
47
+ with open(path, encoding="utf-8") as handle:
48
+ return parse(handle.read(), path=path, interpolate=interpolate)
49
+
50
+
51
+ def parse(text: str, path: str = "<string>", interpolate: bool = True) -> EnvFile:
52
+ """Parse ``text`` into an :class:`EnvFile`.
53
+
54
+ With ``interpolate``, ``$NAME`` and ``${NAME}`` in unquoted and
55
+ double-quoted values are replaced by the value of a key defined earlier in
56
+ the same file. Names that are not defined expand to an empty string and are
57
+ recorded in ``Entry.unresolved``.
58
+ """
59
+ env = EnvFile(path=path)
60
+ annotations: dict[str, str] = {}
61
+ doc: list[str] = []
62
+ lines = text.splitlines()
63
+ index = 0
64
+
65
+ while index < len(lines):
66
+ number = index + 1
67
+ line = lines[index].strip()
68
+ index += 1
69
+
70
+ if not line:
71
+ # A blank line detaches comments from the next key.
72
+ annotations, doc = {}, []
73
+ continue
74
+
75
+ if line.startswith("#"):
76
+ comment = line[1:].strip()
77
+ match = _ANNOTATION_RE.match(comment)
78
+ if match:
79
+ annotations[match.group(1)] = (match.group(2) or "").strip()
80
+ else:
81
+ doc.append(comment)
82
+ continue
83
+
84
+ key, rest = _split_assignment(line, path, number)
85
+
86
+ # A quoted value may continue on the following lines until its
87
+ # closing quote.
88
+ if rest[:1] in ('"', "'"):
89
+ while _find_closing_quote(rest, rest[0]) is None and index < len(lines):
90
+ rest += "\n" + lines[index]
91
+ index += 1
92
+
93
+ known = {entry.key: entry.value for entry in env.entries} if interpolate else None
94
+ unresolved: list[str] = []
95
+ value = _parse_value(rest, path, number, known, unresolved)
96
+
97
+ if env.get(key) is not None:
98
+ raise ParseError(f"duplicate key {key!r}", path, number)
99
+ env.entries.append(
100
+ Entry(
101
+ key=key,
102
+ value=value,
103
+ line=number,
104
+ annotations=annotations,
105
+ doc=doc,
106
+ unresolved=unresolved,
107
+ )
108
+ )
109
+ annotations, doc = {}, []
110
+
111
+ return env
112
+
113
+
114
+ def _split_assignment(line: str, path: str, number: int) -> tuple[str, str]:
115
+ if line.startswith("export "):
116
+ line = line[len("export "):].lstrip()
117
+
118
+ if "=" not in line:
119
+ raise ParseError("expected KEY=value", path, number)
120
+
121
+ key, rest = line.split("=", 1)
122
+ key = key.strip()
123
+ if not _KEY_RE.match(key):
124
+ raise ParseError(f"invalid key {key!r}", path, number)
125
+ return key, rest.strip()
126
+
127
+
128
+ def _parse_value(
129
+ rest: str,
130
+ path: str,
131
+ number: int,
132
+ known: dict[str, str] | None,
133
+ unresolved: list[str],
134
+ ) -> str:
135
+ if rest.startswith("'"):
136
+ # Single quotes: everything is literal, no escapes, no references.
137
+ end = _find_closing_quote(rest, "'")
138
+ if end is None:
139
+ raise ParseError("unterminated quoted value", path, number)
140
+ value, tail = rest[1:end], rest[end + 1:]
141
+ elif rest.startswith('"'):
142
+ end = _find_closing_quote(rest, '"')
143
+ if end is None:
144
+ raise ParseError("unterminated quoted value", path, number)
145
+ value = _unescape(rest[1:end], known, unresolved)
146
+ tail = rest[end + 1:]
147
+ else:
148
+ value = _unescape(_strip_inline_comment(rest), known, unresolved, escapes={"$": "$"})
149
+ tail = ""
150
+
151
+ if tail and not tail.lstrip().startswith("#"):
152
+ raise ParseError("unexpected text after closing quote", path, number)
153
+ return value
154
+
155
+
156
+ def _find_closing_quote(text: str, quote: str) -> int | None:
157
+ i = 1
158
+ while i < len(text):
159
+ char = text[i]
160
+ if char == "\\" and quote == '"':
161
+ i += 2
162
+ continue
163
+ if char == quote:
164
+ return i
165
+ i += 1
166
+ return None
167
+
168
+
169
+ def _unescape(
170
+ value: str,
171
+ known: dict[str, str] | None,
172
+ unresolved: list[str],
173
+ escapes: dict[str, str] = _ESCAPES,
174
+ ) -> str:
175
+ """Apply backslash escapes and, when ``known`` is given, expand references."""
176
+ out = []
177
+ i = 0
178
+ while i < len(value):
179
+ char = value[i]
180
+ if char == "\\" and i + 1 < len(value) and value[i + 1] in escapes:
181
+ out.append(escapes[value[i + 1]])
182
+ i += 2
183
+ continue
184
+ if char == "$" and known is not None:
185
+ match = _REFERENCE_RE.match(value, i)
186
+ if match:
187
+ out.append(_resolve(match, known, unresolved))
188
+ i = match.end()
189
+ continue
190
+ out.append(char)
191
+ i += 1
192
+ return "".join(out)
193
+
194
+
195
+ def _resolve(match: re.Match[str], known: dict[str, str], unresolved: list[str]) -> str:
196
+ name = match.group("braced") or match.group("bare")
197
+ op, default = match.group("op"), match.group("default")
198
+ value = known.get(name)
199
+
200
+ if op == ":-" and not value:
201
+ return default
202
+ if op == "-" and value is None:
203
+ return default
204
+ if value is None:
205
+ if name not in unresolved:
206
+ unresolved.append(name)
207
+ return ""
208
+ return value
209
+
210
+
211
+ def _strip_inline_comment(value: str) -> str:
212
+ # "a#b" stays as-is (common in URLs); only " #" starts a comment.
213
+ match = re.search(r"\s#", value)
214
+ if match:
215
+ value = value[: match.start()]
216
+ return value.strip()
dotguard/report.py ADDED
@@ -0,0 +1,79 @@
1
+ """Render issues for people (text) and for tools (JSON, GitHub annotations)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import TextIO
9
+
10
+ from dotguard.models import Issue, Severity
11
+
12
+ FORMATS = ("text", "json", "github")
13
+
14
+
15
+ def _use_color(stream: TextIO) -> bool:
16
+ if os.environ.get("NO_COLOR"):
17
+ return False
18
+ if os.environ.get("FORCE_COLOR"):
19
+ return True
20
+ return hasattr(stream, "isatty") and stream.isatty()
21
+
22
+
23
+ def _check_mark(stream: TextIO) -> str:
24
+ # Windows consoles often use a legacy code page that cannot encode "✓".
25
+ try:
26
+ "✓".encode(getattr(stream, "encoding", None) or "utf-8")
27
+ except (UnicodeEncodeError, LookupError):
28
+ return "OK:"
29
+ return "✓"
30
+
31
+
32
+ def _paint(text: str, code: str, enabled: bool) -> str:
33
+ return f"\033[{code}m{text}\033[0m" if enabled else text
34
+
35
+
36
+ def render(issues: list[Issue], fmt: str = "text", stream: TextIO | None = None) -> None:
37
+ # Resolve stdout at call time so redirection (and tests) work.
38
+ stream = stream or sys.stdout
39
+ if fmt == "json":
40
+ json.dump({"issues": [i.to_dict() for i in issues], "summary": summary(issues)}, stream, indent=2)
41
+ stream.write("\n")
42
+ elif fmt == "github":
43
+ _render_github(issues, stream)
44
+ else:
45
+ _render_text(issues, stream)
46
+
47
+
48
+ def summary(issues: list[Issue]) -> dict:
49
+ errors = sum(1 for i in issues if i.severity is Severity.ERROR)
50
+ return {"errors": errors, "warnings": len(issues) - errors}
51
+
52
+
53
+ def _render_text(issues: list[Issue], stream: TextIO) -> None:
54
+ color = _use_color(stream)
55
+ for item in issues:
56
+ location = item.path if item.line is None else f"{item.path}:{item.line}"
57
+ if item.severity is Severity.ERROR:
58
+ label = _paint("error", "1;31", color)
59
+ else:
60
+ label = _paint("warning", "1;33", color)
61
+ stream.write(f"{_paint(location, '2', color)} {label} {item.code} {item.message}\n")
62
+
63
+ counts = summary(issues)
64
+ if not issues:
65
+ stream.write(_paint(f"{_check_mark(stream)} no problems found", "32", color) + "\n")
66
+ else:
67
+ stream.write(f"\n{counts['errors']} error(s), {counts['warnings']} warning(s)\n")
68
+
69
+
70
+ def _render_github(issues: list[Issue], stream: TextIO) -> None:
71
+ # https://docs.github.com/actions/reference/workflow-commands-for-github-actions
72
+ for item in issues:
73
+ level = "error" if item.severity is Severity.ERROR else "warning"
74
+ props = f"file={item.path}"
75
+ if item.line is not None:
76
+ props += f",line={item.line}"
77
+ props += f",title=dotguard {item.code}"
78
+ message = item.message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
79
+ stream.write(f"::{level} {props}::{message}\n")
dotguard/secrets.py ADDED
@@ -0,0 +1,90 @@
1
+ """Find real-looking secrets in files that are meant to be committed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter
8
+
9
+ from dotguard.models import EnvFile, Issue, Severity
10
+
11
+ # Well-known credential formats. Each match is an error: these are never
12
+ # placeholders.
13
+ KNOWN_TOKENS: list[tuple[str, re.Pattern[str]]] = [
14
+ ("AWS access key ID", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
15
+ ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
16
+ ("GitHub fine-grained token", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{60,}\b")),
17
+ ("Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
18
+ ("Stripe live key", re.compile(r"\b[sr]k_live_[A-Za-z0-9]{16,}\b")),
19
+ ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
20
+ ("OpenAI-style API key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b")),
21
+ ("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
22
+ ("JSON Web Token", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}")),
23
+ ]
24
+
25
+ SENSITIVE_NAME = re.compile(
26
+ r"(SECRET|TOKEN|PASSWORD|PASSWD|PWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|AUTH)",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ # Values that are obviously documentation, not real credentials.
31
+ PLACEHOLDER = re.compile(
32
+ r"^(?:|changeme|change[-_]me|replace[-_]?me|todo|secret|password|example.*|your[-_].*|"
33
+ r"<.*>|\{\{.*\}\}|\$\{.*\}|x+|\*+|\.\.\.|null|none|test|dummy.*|fake.*|placeholder.*)$",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+ ENTROPY_THRESHOLD = 3.5
38
+ MIN_SECRET_LENGTH = 16
39
+
40
+
41
+ def shannon_entropy(value: str) -> float:
42
+ if not value:
43
+ return 0.0
44
+ counts = Counter(value)
45
+ total = len(value)
46
+ return -sum((n / total) * math.log2(n / total) for n in counts.values())
47
+
48
+
49
+ def scan(env: EnvFile) -> list[Issue]:
50
+ """Report values in ``env`` that look like real credentials."""
51
+ issues: list[Issue] = []
52
+
53
+ for entry in env.entries:
54
+ value = entry.value
55
+
56
+ known = next((name for name, regex in KNOWN_TOKENS if regex.search(value)), None)
57
+ if known:
58
+ issues.append(
59
+ Issue(
60
+ "DS001",
61
+ f"{entry.key} contains what looks like a real {known}",
62
+ Severity.ERROR,
63
+ env.path,
64
+ entry.line,
65
+ entry.key,
66
+ )
67
+ )
68
+ continue
69
+
70
+ if PLACEHOLDER.match(value):
71
+ continue
72
+
73
+ looks_sensitive = SENSITIVE_NAME.search(entry.key) or "secret" in entry.annotations
74
+ if (
75
+ looks_sensitive
76
+ and len(value) >= MIN_SECRET_LENGTH
77
+ and shannon_entropy(value) >= ENTROPY_THRESHOLD
78
+ ):
79
+ issues.append(
80
+ Issue(
81
+ "DS002",
82
+ f"{entry.key} has a high-entropy value; use a placeholder in shared files",
83
+ Severity.WARNING,
84
+ env.path,
85
+ entry.line,
86
+ entry.key,
87
+ )
88
+ )
89
+
90
+ return issues
dotguard/types.py ADDED
@@ -0,0 +1,76 @@
1
+ """Value validators used by the ``@type`` annotation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Callable, Optional
7
+ from urllib.parse import urlparse
8
+
9
+ # A validator returns None when the value is valid, or a short reason otherwise.
10
+ Validator = Callable[[str], Optional[str]]
11
+
12
+ _TRUE = {"1", "true", "yes", "on"}
13
+ _FALSE = {"0", "false", "no", "off"}
14
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
15
+
16
+
17
+ def _string(value: str) -> str | None:
18
+ return None
19
+
20
+
21
+ def _int(value: str) -> str | None:
22
+ try:
23
+ int(value, 10)
24
+ except ValueError:
25
+ return "expected an integer"
26
+ return None
27
+
28
+
29
+ def _float(value: str) -> str | None:
30
+ try:
31
+ float(value)
32
+ except ValueError:
33
+ return "expected a number"
34
+ return None
35
+
36
+
37
+ def _bool(value: str) -> str | None:
38
+ if value.lower() in _TRUE | _FALSE:
39
+ return None
40
+ return "expected a boolean (true/false, yes/no, on/off, 1/0)"
41
+
42
+
43
+ def _port(value: str) -> str | None:
44
+ if _int(value) is not None or not 1 <= int(value) <= 65535:
45
+ return "expected a port number between 1 and 65535"
46
+ return None
47
+
48
+
49
+ def _url(value: str) -> str | None:
50
+ parsed = urlparse(value)
51
+ if parsed.scheme and parsed.netloc:
52
+ return None
53
+ return "expected a URL with a scheme and host, e.g. https://example.com"
54
+
55
+
56
+ def _email(value: str) -> str | None:
57
+ if _EMAIL_RE.match(value):
58
+ return None
59
+ return "expected an email address"
60
+
61
+
62
+ VALIDATORS: dict[str, Validator] = {
63
+ "string": _string,
64
+ "str": _string,
65
+ "int": _int,
66
+ "integer": _int,
67
+ "float": _float,
68
+ "number": _float,
69
+ "bool": _bool,
70
+ "boolean": _bool,
71
+ "port": _port,
72
+ "url": _url,
73
+ "email": _email,
74
+ }
75
+
76
+ NUMERIC_TYPES = {"int", "integer", "float", "number", "port"}