errgrade 0.1.0__tar.gz → 0.2.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.
- {errgrade-0.1.0 → errgrade-0.2.0}/PKG-INFO +48 -2
- {errgrade-0.1.0 → errgrade-0.2.0}/README.md +47 -1
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade/__init__.py +1 -1
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade/analyze.py +3 -3
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade/cli.py +21 -12
- errgrade-0.2.0/errgrade/responses.py +219 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/PKG-INFO +48 -2
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/SOURCES.txt +3 -1
- {errgrade-0.1.0 → errgrade-0.2.0}/pyproject.toml +9 -1
- errgrade-0.2.0/tests/test_responses.py +93 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/LICENSE +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade/grade.py +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/dependency_links.txt +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/entry_points.txt +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/requires.txt +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/errgrade.egg-info/top_level.txt +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/setup.cfg +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/tests/test_analyze.py +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/tests/test_cli.py +0 -0
- {errgrade-0.1.0 → errgrade-0.2.0}/tests/test_grade.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: errgrade
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Grade the error messages your code raises: what broke, where, and what to do next.
|
|
5
5
|
Author: mgkvibez
|
|
6
6
|
License: MIT
|
|
@@ -108,6 +108,40 @@ The values come from the syntax tree, not the text: an f-string, `.format()` or
|
|
|
108
108
|
counts as interpolation because a value genuinely flows in. `"{name}"` written as a plain
|
|
109
109
|
string counts as a bug, because the reader sees the braces.
|
|
110
110
|
|
|
111
|
+
## Grading the bodies you return
|
|
112
|
+
|
|
113
|
+
`check` grades what you raise. `responses` grades what the caller receives, which is a
|
|
114
|
+
different failure: a handler can raise a good message and still answer with `{"error":
|
|
115
|
+
"failed"}`, or with the exception's text pasted in.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
$ errgrade responses examples/api_errors.py
|
|
119
|
+
errgrade: 3 finding(s) across 5 body(ies) in 1 file(s), worst high
|
|
120
|
+
3 of 5 graded body(ies) fall below C
|
|
121
|
+
|
|
122
|
+
[HIGH] examples/api_errors.py:15 (leaked-internals)
|
|
123
|
+
body: '<computed>'
|
|
124
|
+
why: the body embeds the exception's own text, which discloses internals and changes shape between failures
|
|
125
|
+
missing: what, where, next
|
|
126
|
+
grade: D (0/100)
|
|
127
|
+
|
|
128
|
+
[HIGH] examples/api_errors.py:22 (leaked-internals)
|
|
129
|
+
body: 'failed'
|
|
130
|
+
why: the body carries a traceback field, which is for the developer, not the caller
|
|
131
|
+
missing: what, where, next
|
|
132
|
+
grade: D (0/100)
|
|
133
|
+
|
|
134
|
+
[MEDIUM] examples/api_errors.py:26 (internal-path)
|
|
135
|
+
body: 'could not write /srv/app/exports/%s.csv'
|
|
136
|
+
why: an absolute server path in a response tells an attacker the layout and is useless to a client
|
|
137
|
+
missing: where, next
|
|
138
|
+
grade: D (40/100)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Those are the four shapes worth failing a build over: an exception's own text leaking out,
|
|
142
|
+
an absolute server path disclosing the deployment layout, a message too vague to act on,
|
|
143
|
+
and a body with no machine-readable code for a client to branch on.
|
|
144
|
+
|
|
111
145
|
## Rules
|
|
112
146
|
|
|
113
147
|
| Severity | Rule | What it means |
|
|
@@ -121,6 +155,18 @@ string counts as a bug, because the reader sees the braces.
|
|
|
121
155
|
One primary finding per message: a vague message is not also reported for context and
|
|
122
156
|
next steps, because stacking three findings on one word is noise, not help.
|
|
123
157
|
|
|
158
|
+
The response rules, used by `errgrade responses`:
|
|
159
|
+
|
|
160
|
+
| Severity | Rule | What it means |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| high | `leaked-internals` | `str(exc)`, `repr(exc)`, `traceback.format_exc()`, `exc.args`, or a `traceback`/`stack`/`debug` field |
|
|
163
|
+
| medium | `internal-path` | an absolute server path inside a response body |
|
|
164
|
+
| medium | `vague-error-body` | the returned message is too vague to act on |
|
|
165
|
+
| medium | `no-machine-code` | no code a client can branch on, so consumers must match your prose |
|
|
166
|
+
|
|
167
|
+
A nested `{"error": {"code": ..., "message": ...}}` body is read as a whole, so the shape
|
|
168
|
+
callers actually want is not flagged for the code hidden one level down.
|
|
169
|
+
|
|
124
170
|
## Suppressing a message
|
|
125
171
|
|
|
126
172
|
Some messages are deliberately terse. Say so on the line:
|
|
@@ -147,7 +193,7 @@ Only that line is suppressed. There is no config file to fall out of sync.
|
|
|
147
193
|
## Tests
|
|
148
194
|
|
|
149
195
|
```bash
|
|
150
|
-
python -m pytest -q #
|
|
196
|
+
python -m pytest -q # 68 tests
|
|
151
197
|
bash scripts/verify.sh # the release gate, 6 checks
|
|
152
198
|
```
|
|
153
199
|
|
|
@@ -88,6 +88,40 @@ The values come from the syntax tree, not the text: an f-string, `.format()` or
|
|
|
88
88
|
counts as interpolation because a value genuinely flows in. `"{name}"` written as a plain
|
|
89
89
|
string counts as a bug, because the reader sees the braces.
|
|
90
90
|
|
|
91
|
+
## Grading the bodies you return
|
|
92
|
+
|
|
93
|
+
`check` grades what you raise. `responses` grades what the caller receives, which is a
|
|
94
|
+
different failure: a handler can raise a good message and still answer with `{"error":
|
|
95
|
+
"failed"}`, or with the exception's text pasted in.
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
$ errgrade responses examples/api_errors.py
|
|
99
|
+
errgrade: 3 finding(s) across 5 body(ies) in 1 file(s), worst high
|
|
100
|
+
3 of 5 graded body(ies) fall below C
|
|
101
|
+
|
|
102
|
+
[HIGH] examples/api_errors.py:15 (leaked-internals)
|
|
103
|
+
body: '<computed>'
|
|
104
|
+
why: the body embeds the exception's own text, which discloses internals and changes shape between failures
|
|
105
|
+
missing: what, where, next
|
|
106
|
+
grade: D (0/100)
|
|
107
|
+
|
|
108
|
+
[HIGH] examples/api_errors.py:22 (leaked-internals)
|
|
109
|
+
body: 'failed'
|
|
110
|
+
why: the body carries a traceback field, which is for the developer, not the caller
|
|
111
|
+
missing: what, where, next
|
|
112
|
+
grade: D (0/100)
|
|
113
|
+
|
|
114
|
+
[MEDIUM] examples/api_errors.py:26 (internal-path)
|
|
115
|
+
body: 'could not write /srv/app/exports/%s.csv'
|
|
116
|
+
why: an absolute server path in a response tells an attacker the layout and is useless to a client
|
|
117
|
+
missing: where, next
|
|
118
|
+
grade: D (40/100)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Those are the four shapes worth failing a build over: an exception's own text leaking out,
|
|
122
|
+
an absolute server path disclosing the deployment layout, a message too vague to act on,
|
|
123
|
+
and a body with no machine-readable code for a client to branch on.
|
|
124
|
+
|
|
91
125
|
## Rules
|
|
92
126
|
|
|
93
127
|
| Severity | Rule | What it means |
|
|
@@ -101,6 +135,18 @@ string counts as a bug, because the reader sees the braces.
|
|
|
101
135
|
One primary finding per message: a vague message is not also reported for context and
|
|
102
136
|
next steps, because stacking three findings on one word is noise, not help.
|
|
103
137
|
|
|
138
|
+
The response rules, used by `errgrade responses`:
|
|
139
|
+
|
|
140
|
+
| Severity | Rule | What it means |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| high | `leaked-internals` | `str(exc)`, `repr(exc)`, `traceback.format_exc()`, `exc.args`, or a `traceback`/`stack`/`debug` field |
|
|
143
|
+
| medium | `internal-path` | an absolute server path inside a response body |
|
|
144
|
+
| medium | `vague-error-body` | the returned message is too vague to act on |
|
|
145
|
+
| medium | `no-machine-code` | no code a client can branch on, so consumers must match your prose |
|
|
146
|
+
|
|
147
|
+
A nested `{"error": {"code": ..., "message": ...}}` body is read as a whole, so the shape
|
|
148
|
+
callers actually want is not flagged for the code hidden one level down.
|
|
149
|
+
|
|
104
150
|
## Suppressing a message
|
|
105
151
|
|
|
106
152
|
Some messages are deliberately terse. Say so on the line:
|
|
@@ -127,7 +173,7 @@ Only that line is suppressed. There is no config file to fall out of sync.
|
|
|
127
173
|
## Tests
|
|
128
174
|
|
|
129
175
|
```bash
|
|
130
|
-
python -m pytest -q #
|
|
176
|
+
python -m pytest -q # 68 tests
|
|
131
177
|
bash scripts/verify.sh # the release gate, 6 checks
|
|
132
178
|
```
|
|
133
179
|
|
|
@@ -85,7 +85,8 @@ def _string_parts(node: ast.AST) -> Optional[Tuple[str, bool]]:
|
|
|
85
85
|
return node.value, False
|
|
86
86
|
if isinstance(node, ast.JoinedStr):
|
|
87
87
|
text = "".join(
|
|
88
|
-
part.value if isinstance(part, ast.Constant)
|
|
88
|
+
part.value if isinstance(part, ast.Constant)
|
|
89
|
+
and isinstance(part.value, str) else "<value>"
|
|
89
90
|
for part in node.values
|
|
90
91
|
)
|
|
91
92
|
has_value = any(isinstance(part, ast.FormattedValue) for part in node.values)
|
|
@@ -124,7 +125,7 @@ def _message_node(exc: ast.AST) -> Optional[ast.AST]:
|
|
|
124
125
|
return None
|
|
125
126
|
|
|
126
127
|
|
|
127
|
-
def _docstring_lines(tree: ast.AST) -> set:
|
|
128
|
+
def _docstring_lines(tree: ast.AST) -> set: # noqa: F811
|
|
128
129
|
"""Lines occupied by docstrings, so a message quoted in prose is not graded."""
|
|
129
130
|
lines = set()
|
|
130
131
|
for node in ast.walk(tree):
|
|
@@ -145,7 +146,6 @@ def analyze_source(source: str, path: str = "<src>") -> List[Finding]:
|
|
|
145
146
|
return []
|
|
146
147
|
|
|
147
148
|
ignored = {i + 1 for i, line in enumerate(source.splitlines()) if PRAGMA in line}
|
|
148
|
-
doc_lines = _docstring_lines(tree)
|
|
149
149
|
findings: List[Finding] = []
|
|
150
150
|
|
|
151
151
|
for node in ast.walk(tree):
|
|
@@ -15,35 +15,38 @@ from typing import List, Optional, Sequence
|
|
|
15
15
|
|
|
16
16
|
from . import __version__
|
|
17
17
|
from .analyze import SEVERITY_ORDER, Report, analyze_files
|
|
18
|
+
from .responses import analyze_files as analyze_response_files
|
|
18
19
|
|
|
19
20
|
EXIT_OK = 0
|
|
20
21
|
EXIT_FINDINGS = 1
|
|
21
22
|
EXIT_USAGE = 2
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
def _report(rep: Report, args: argparse.Namespace) -> str:
|
|
25
|
+
def _report(rep: Report, args: argparse.Namespace, verb: str = "check") -> str:
|
|
26
|
+
noun = "body(ies)" if verb == "responses" else "message(s)"
|
|
25
27
|
findings = rep.sorted_findings()
|
|
26
28
|
if args.top:
|
|
27
29
|
findings = findings[: args.top]
|
|
28
30
|
|
|
29
31
|
if not findings:
|
|
30
32
|
return (
|
|
31
|
-
"No issues found by errgrade v%s across %d
|
|
33
|
+
"No issues found by errgrade v%s across %d %s in %d file(s).\n"
|
|
32
34
|
"That means no known weak pattern matched; it is not a guarantee."
|
|
33
|
-
% (__version__, rep.graded, rep.files)
|
|
35
|
+
% (__version__, rep.graded, noun, rep.files)
|
|
34
36
|
)
|
|
35
37
|
|
|
36
38
|
worst = SEVERITY_ORDER[findings[0].severity]
|
|
37
39
|
worst_name = {0: "high", 1: "medium", 2: "low"}[worst]
|
|
38
40
|
lines = [
|
|
39
|
-
"errgrade: %d finding(s) across %d
|
|
40
|
-
% (len(findings), rep.graded, rep.files, worst_name),
|
|
41
|
-
"%d of %d graded
|
|
41
|
+
"errgrade: %d finding(s) across %d %s in %d file(s), worst %s"
|
|
42
|
+
% (len(findings), rep.graded, noun, rep.files, worst_name),
|
|
43
|
+
"%d of %d graded %s fall below C" % (rep.below_c, rep.graded, noun),
|
|
42
44
|
"",
|
|
43
45
|
]
|
|
44
46
|
for f in findings:
|
|
45
47
|
lines.append("[%s] %s:%d (%s)" % (f.severity.upper(), f.path, f.line, f.rule))
|
|
46
|
-
|
|
48
|
+
label = "body" if verb == "responses" else "message"
|
|
49
|
+
lines.append(" %s: %r" % (label, f.message))
|
|
47
50
|
lines.append(" why: %s" % f.detail)
|
|
48
51
|
if f.grade.missing:
|
|
49
52
|
lines.append(" missing: %s" % ", ".join(f.grade.missing))
|
|
@@ -55,10 +58,11 @@ def _report(rep: Report, args: argparse.Namespace) -> str:
|
|
|
55
58
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
56
59
|
parser = argparse.ArgumentParser(
|
|
57
60
|
prog="errgrade",
|
|
58
|
-
description="Grade the
|
|
61
|
+
description="Grade the messages your code raises, or the error bodies it returns.",
|
|
59
62
|
)
|
|
60
63
|
parser.add_argument("--version", action="version", version="errgrade %s" % __version__)
|
|
61
|
-
parser.add_argument("paths", nargs="*",
|
|
64
|
+
parser.add_argument("paths", nargs="*",
|
|
65
|
+
help="files or directories (default: current directory)")
|
|
62
66
|
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
63
67
|
parser.add_argument("--fail-on", choices=["high", "medium", "low", "none"], default="medium",
|
|
64
68
|
help="lowest severity that fails the run (default: medium)")
|
|
@@ -68,7 +72,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
68
72
|
args = parser.parse_args(argv)
|
|
69
73
|
|
|
70
74
|
raw: List[str] = list(args.paths)
|
|
71
|
-
|
|
75
|
+
verb = "check"
|
|
76
|
+
if raw and raw[0] in {"check", "responses", "bodies"}:
|
|
77
|
+
verb = "responses" if raw[0] in {"responses", "bodies"} else "check"
|
|
72
78
|
raw = raw[1:]
|
|
73
79
|
if not raw:
|
|
74
80
|
raw = ["."]
|
|
@@ -79,7 +85,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
79
85
|
print("errgrade: no such path: %s" % ", ".join(missing), file=sys.stderr)
|
|
80
86
|
return EXIT_USAGE
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
if verb == "responses":
|
|
89
|
+
rep = analyze_response_files(targets, include_tests=args.include_tests)
|
|
90
|
+
else:
|
|
91
|
+
rep = analyze_files(targets, include_tests=args.include_tests)
|
|
83
92
|
|
|
84
93
|
if args.json:
|
|
85
94
|
payload = {
|
|
@@ -94,7 +103,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
94
103
|
}
|
|
95
104
|
print(json.dumps(payload, indent=2))
|
|
96
105
|
else:
|
|
97
|
-
print(_report(rep, args))
|
|
106
|
+
print(_report(rep, args, verb=verb))
|
|
98
107
|
|
|
99
108
|
if args.fail_on == "none":
|
|
100
109
|
return EXIT_OK
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Grade the error bodies your API sends, not only the strings you raise.
|
|
2
|
+
|
|
3
|
+
A handler can raise a perfect message and still answer with ``{"error": "failed"}`` or,
|
|
4
|
+
worse, with the exception's text pasted in. This module finds the bodies that reach the
|
|
5
|
+
caller: dict literals that are returned, handed to ``jsonify``, or passed as an exception's
|
|
6
|
+
``detail``/``message`` keyword.
|
|
7
|
+
|
|
8
|
+
One verdict per body, worst first. Four things are worth failing a build over: an internal
|
|
9
|
+
detail leaking out, an absolute path disclosing the server's layout, a message too vague to
|
|
10
|
+
act on, and a body with no machine-readable code at all.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
from .analyze import ( # noqa: F401 (Report is re-exported for the CLI)
|
|
20
|
+
SKIP_DIRS,
|
|
21
|
+
Finding,
|
|
22
|
+
Report,
|
|
23
|
+
_looks_like_artifact,
|
|
24
|
+
_looks_like_test,
|
|
25
|
+
)
|
|
26
|
+
from .grade import grade_message
|
|
27
|
+
|
|
28
|
+
#: Keys that make a dict an error body in the eyes of a caller.
|
|
29
|
+
ERROR_KEYS = frozenset({"error", "detail", "message", "errors", "msg", "reason"})
|
|
30
|
+
|
|
31
|
+
#: What a machine can branch on. A human string alone forces string matching.
|
|
32
|
+
CODE_KEYS = frozenset({"code", "error_code", "errorcode", "type", "errno", "kind", "status"})
|
|
33
|
+
|
|
34
|
+
#: Keys that exist only to help the developer who wrote the handler.
|
|
35
|
+
LEAK_KEYS = frozenset({"traceback", "stack", "stacktrace", "debug", "exc", "exception"})
|
|
36
|
+
|
|
37
|
+
#: Calls that take the exception as an argument.
|
|
38
|
+
LEAK_WITH_ARG = frozenset({"str", "repr"})
|
|
39
|
+
|
|
40
|
+
#: Calls that leak on their own, with no argument at all.
|
|
41
|
+
LEAK_NO_ARG = frozenset({"format_exc", "print_exc"})
|
|
42
|
+
|
|
43
|
+
PATH_MARKERS = ("/home/", "/Users/", "/srv/", "/var/www", "/app/", "C:\\\\", "\\\\Users\\\\")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dict_keys(node: ast.Dict) -> set:
|
|
47
|
+
"""The literal string keys of a dict, ignoring computed ones."""
|
|
48
|
+
out = set()
|
|
49
|
+
for key in node.keys:
|
|
50
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
|
51
|
+
out.add(key.value.strip().lower())
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _value_text(node: ast.AST) -> Optional[str]:
|
|
56
|
+
"""The literal text of a value, or None when it is computed at runtime."""
|
|
57
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
58
|
+
return node.value
|
|
59
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
|
|
60
|
+
return _value_text(node.left) # "…/exports/%s.csv" % name
|
|
61
|
+
if isinstance(node, ast.JoinedStr):
|
|
62
|
+
parts = []
|
|
63
|
+
for piece in node.values:
|
|
64
|
+
if isinstance(piece, ast.Constant) and isinstance(piece.value, str):
|
|
65
|
+
parts.append(piece.value)
|
|
66
|
+
else:
|
|
67
|
+
parts.append("{}")
|
|
68
|
+
return "".join(parts)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _leaks_exception(node: ast.AST) -> bool:
|
|
73
|
+
"""True when the body embeds an exception's own text: str(exc), repr(err), a traceback."""
|
|
74
|
+
for child in ast.walk(node):
|
|
75
|
+
if isinstance(child, ast.Call):
|
|
76
|
+
func = child.func
|
|
77
|
+
name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", "")
|
|
78
|
+
if name in LEAK_NO_ARG:
|
|
79
|
+
return True
|
|
80
|
+
if name in LEAK_WITH_ARG and child.args:
|
|
81
|
+
return True
|
|
82
|
+
if isinstance(child, ast.Attribute) and child.attr == "args":
|
|
83
|
+
return True
|
|
84
|
+
if isinstance(child, ast.Attribute) and child.attr in {"__dict__", "__traceback__"}:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_path(text: str) -> bool:
|
|
90
|
+
return any(marker in text for marker in PATH_MARKERS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _bodies(tree: ast.AST) -> List[Tuple[ast.Dict, str]]:
|
|
94
|
+
"""Every dict that can reach a caller, with the reason it qualifies."""
|
|
95
|
+
found: List[Tuple[ast.Dict, str]] = []
|
|
96
|
+
|
|
97
|
+
for node in ast.walk(tree):
|
|
98
|
+
if isinstance(node, ast.Return) and isinstance(node.value, ast.Dict):
|
|
99
|
+
found.append((node.value, "returned"))
|
|
100
|
+
elif isinstance(node, ast.Call):
|
|
101
|
+
func = node.func
|
|
102
|
+
name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", "")
|
|
103
|
+
if name == "jsonify":
|
|
104
|
+
for arg in node.args:
|
|
105
|
+
if isinstance(arg, ast.Dict):
|
|
106
|
+
found.append((arg, "jsonify"))
|
|
107
|
+
for kw in node.keywords:
|
|
108
|
+
if kw.arg in {"detail", "message", "msg", "reason"} \
|
|
109
|
+
and isinstance(kw.value, ast.Dict):
|
|
110
|
+
found.append((kw.value, "exception detail"))
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _verdict(node: ast.Dict, path: str, line: int) -> Optional[Finding]:
|
|
115
|
+
"""The single most important thing wrong with this body, if anything."""
|
|
116
|
+
keys = _dict_keys(node)
|
|
117
|
+
if not keys & ERROR_KEYS:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# {"error": {"code": ..., "message": ...}} is the shape callers want, so look inside:
|
|
121
|
+
# judging the outer dict alone would flag the best body in the file.
|
|
122
|
+
payload = node
|
|
123
|
+
for key, value in zip(node.keys, node.values):
|
|
124
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str) \
|
|
125
|
+
and key.value.strip().lower() in ERROR_KEYS \
|
|
126
|
+
and isinstance(value, ast.Dict):
|
|
127
|
+
payload = value
|
|
128
|
+
keys |= _dict_keys(value)
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
message_node, message_text = None, None
|
|
132
|
+
for key, value in zip(payload.keys, payload.values):
|
|
133
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
|
134
|
+
if key.value.strip().lower() in ERROR_KEYS:
|
|
135
|
+
message_node = value
|
|
136
|
+
message_text = _value_text(value)
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if message_node is not None and _leaks_exception(message_node):
|
|
140
|
+
return Finding(path=path, line=line, rule="leaked-internals", severity="high",
|
|
141
|
+
message=message_text or "<computed>",
|
|
142
|
+
detail="the body embeds the exception's own text, which discloses "
|
|
143
|
+
"internals and changes shape between failures",
|
|
144
|
+
grade=grade_message(message_text or ""))
|
|
145
|
+
|
|
146
|
+
if keys & LEAK_KEYS:
|
|
147
|
+
return Finding(path=path, line=line, rule="leaked-internals", severity="high",
|
|
148
|
+
message=message_text or "<computed>",
|
|
149
|
+
detail="the body carries a %s field, which is for the developer, "
|
|
150
|
+
"not the caller" % ", ".join(sorted(keys & LEAK_KEYS)),
|
|
151
|
+
grade=grade_message(message_text or ""))
|
|
152
|
+
|
|
153
|
+
if message_text and _has_path(message_text):
|
|
154
|
+
return Finding(path=path, line=line, rule="internal-path", severity="medium",
|
|
155
|
+
message=message_text,
|
|
156
|
+
detail="an absolute server path in a response tells an attacker the "
|
|
157
|
+
"layout and is useless to a client",
|
|
158
|
+
grade=grade_message(message_text))
|
|
159
|
+
|
|
160
|
+
if message_text:
|
|
161
|
+
grade = grade_message(message_text)
|
|
162
|
+
if grade.letter == "D":
|
|
163
|
+
return Finding(path=path, line=line, rule="vague-error-body", severity="medium",
|
|
164
|
+
message=message_text, detail="a caller cannot act on this: "
|
|
165
|
+
+ ", ".join(grade.missing) + " missing", grade=grade)
|
|
166
|
+
|
|
167
|
+
if not keys & CODE_KEYS:
|
|
168
|
+
return Finding(path=path, line=line, rule="no-machine-code", severity="medium",
|
|
169
|
+
message=message_text or "<computed>",
|
|
170
|
+
detail="no code a client can branch on, so every consumer has to "
|
|
171
|
+
"match your prose and breaks when it is reworded",
|
|
172
|
+
grade=grade_message(message_text or ""))
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def analyze_responses(source: str, path: str = "<src>") -> List[Finding]:
|
|
177
|
+
"""Every body in this source that a caller will actually receive."""
|
|
178
|
+
try:
|
|
179
|
+
tree = ast.parse(source)
|
|
180
|
+
except SyntaxError:
|
|
181
|
+
return []
|
|
182
|
+
out: List[Finding] = []
|
|
183
|
+
for node, _why in _bodies(tree):
|
|
184
|
+
finding = _verdict(node, path, getattr(node, "lineno", 0))
|
|
185
|
+
if finding is not None:
|
|
186
|
+
out.append(finding)
|
|
187
|
+
out.sort(key=lambda f: (0 if f.severity == "high" else 1, f.line))
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def analyze_files(files: Sequence[Path], *, include_tests: bool = False) -> Report:
|
|
192
|
+
"""Walk files and directories, skipping build artifacts and test code."""
|
|
193
|
+
report = Report()
|
|
194
|
+
sources: List[Path] = []
|
|
195
|
+
for entry in files:
|
|
196
|
+
entry = Path(entry)
|
|
197
|
+
if entry.is_file():
|
|
198
|
+
sources.append(entry)
|
|
199
|
+
elif entry.is_dir():
|
|
200
|
+
sources.extend(sorted(entry.rglob("*.py")))
|
|
201
|
+
for source_path in sources:
|
|
202
|
+
if _looks_like_artifact(source_path) or any(p in SKIP_DIRS for p in source_path.parts):
|
|
203
|
+
continue
|
|
204
|
+
if not include_tests and _looks_like_test(source_path):
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
text = source_path.read_text(encoding="utf-8", errors="replace")
|
|
208
|
+
except OSError:
|
|
209
|
+
continue
|
|
210
|
+
file_findings = analyze_responses(text, str(source_path))
|
|
211
|
+
report.files += 1
|
|
212
|
+
if text.strip():
|
|
213
|
+
try:
|
|
214
|
+
report.graded += len(_bodies(ast.parse(text))) # 'graded' = bodies examined
|
|
215
|
+
except SyntaxError:
|
|
216
|
+
pass
|
|
217
|
+
report.below_c += sum(1 for f in file_findings if f.grade.letter in {"C", "D"})
|
|
218
|
+
report.findings.extend(file_findings)
|
|
219
|
+
return report
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: errgrade
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Grade the error messages your code raises: what broke, where, and what to do next.
|
|
5
5
|
Author: mgkvibez
|
|
6
6
|
License: MIT
|
|
@@ -108,6 +108,40 @@ The values come from the syntax tree, not the text: an f-string, `.format()` or
|
|
|
108
108
|
counts as interpolation because a value genuinely flows in. `"{name}"` written as a plain
|
|
109
109
|
string counts as a bug, because the reader sees the braces.
|
|
110
110
|
|
|
111
|
+
## Grading the bodies you return
|
|
112
|
+
|
|
113
|
+
`check` grades what you raise. `responses` grades what the caller receives, which is a
|
|
114
|
+
different failure: a handler can raise a good message and still answer with `{"error":
|
|
115
|
+
"failed"}`, or with the exception's text pasted in.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
$ errgrade responses examples/api_errors.py
|
|
119
|
+
errgrade: 3 finding(s) across 5 body(ies) in 1 file(s), worst high
|
|
120
|
+
3 of 5 graded body(ies) fall below C
|
|
121
|
+
|
|
122
|
+
[HIGH] examples/api_errors.py:15 (leaked-internals)
|
|
123
|
+
body: '<computed>'
|
|
124
|
+
why: the body embeds the exception's own text, which discloses internals and changes shape between failures
|
|
125
|
+
missing: what, where, next
|
|
126
|
+
grade: D (0/100)
|
|
127
|
+
|
|
128
|
+
[HIGH] examples/api_errors.py:22 (leaked-internals)
|
|
129
|
+
body: 'failed'
|
|
130
|
+
why: the body carries a traceback field, which is for the developer, not the caller
|
|
131
|
+
missing: what, where, next
|
|
132
|
+
grade: D (0/100)
|
|
133
|
+
|
|
134
|
+
[MEDIUM] examples/api_errors.py:26 (internal-path)
|
|
135
|
+
body: 'could not write /srv/app/exports/%s.csv'
|
|
136
|
+
why: an absolute server path in a response tells an attacker the layout and is useless to a client
|
|
137
|
+
missing: where, next
|
|
138
|
+
grade: D (40/100)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Those are the four shapes worth failing a build over: an exception's own text leaking out,
|
|
142
|
+
an absolute server path disclosing the deployment layout, a message too vague to act on,
|
|
143
|
+
and a body with no machine-readable code for a client to branch on.
|
|
144
|
+
|
|
111
145
|
## Rules
|
|
112
146
|
|
|
113
147
|
| Severity | Rule | What it means |
|
|
@@ -121,6 +155,18 @@ string counts as a bug, because the reader sees the braces.
|
|
|
121
155
|
One primary finding per message: a vague message is not also reported for context and
|
|
122
156
|
next steps, because stacking three findings on one word is noise, not help.
|
|
123
157
|
|
|
158
|
+
The response rules, used by `errgrade responses`:
|
|
159
|
+
|
|
160
|
+
| Severity | Rule | What it means |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| high | `leaked-internals` | `str(exc)`, `repr(exc)`, `traceback.format_exc()`, `exc.args`, or a `traceback`/`stack`/`debug` field |
|
|
163
|
+
| medium | `internal-path` | an absolute server path inside a response body |
|
|
164
|
+
| medium | `vague-error-body` | the returned message is too vague to act on |
|
|
165
|
+
| medium | `no-machine-code` | no code a client can branch on, so consumers must match your prose |
|
|
166
|
+
|
|
167
|
+
A nested `{"error": {"code": ..., "message": ...}}` body is read as a whole, so the shape
|
|
168
|
+
callers actually want is not flagged for the code hidden one level down.
|
|
169
|
+
|
|
124
170
|
## Suppressing a message
|
|
125
171
|
|
|
126
172
|
Some messages are deliberately terse. Say so on the line:
|
|
@@ -147,7 +193,7 @@ Only that line is suppressed. There is no config file to fall out of sync.
|
|
|
147
193
|
## Tests
|
|
148
194
|
|
|
149
195
|
```bash
|
|
150
|
-
python -m pytest -q #
|
|
196
|
+
python -m pytest -q # 68 tests
|
|
151
197
|
bash scripts/verify.sh # the release gate, 6 checks
|
|
152
198
|
```
|
|
153
199
|
|
|
@@ -5,6 +5,7 @@ errgrade/__init__.py
|
|
|
5
5
|
errgrade/analyze.py
|
|
6
6
|
errgrade/cli.py
|
|
7
7
|
errgrade/grade.py
|
|
8
|
+
errgrade/responses.py
|
|
8
9
|
errgrade.egg-info/PKG-INFO
|
|
9
10
|
errgrade.egg-info/SOURCES.txt
|
|
10
11
|
errgrade.egg-info/dependency_links.txt
|
|
@@ -13,4 +14,5 @@ errgrade.egg-info/requires.txt
|
|
|
13
14
|
errgrade.egg-info/top_level.txt
|
|
14
15
|
tests/test_analyze.py
|
|
15
16
|
tests/test_cli.py
|
|
16
|
-
tests/test_grade.py
|
|
17
|
+
tests/test_grade.py
|
|
18
|
+
tests/test_responses.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "errgrade"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Grade the error messages your code raises: what broke, where, and what to do next."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -29,3 +29,11 @@ errgrade = "errgrade.cli:main"
|
|
|
29
29
|
|
|
30
30
|
[tool.setuptools]
|
|
31
31
|
packages = ["errgrade"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py39"
|
|
35
|
+
line-length = 100
|
|
36
|
+
# Explicit so lint means the same thing here as in CI: pyupgrade rules are absent because
|
|
37
|
+
# they want `list[X]`/`X | None`, which mislead about the 3.9 floor.
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "W", "I"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for the response-body rules, including the two bugs this mode had on day one."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from errgrade.responses import analyze_files, analyze_responses
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _rules(src):
|
|
9
|
+
return [f.rule for f in analyze_responses(src)]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_embedding_the_exception_text_is_high():
|
|
13
|
+
src = ('def h():\n try:\n go()\n'
|
|
14
|
+
' except Exception as exc:\n return {"error": str(exc)}\n')
|
|
15
|
+
found = analyze_responses(src)
|
|
16
|
+
assert [f.rule for f in found] == ["leaked-internals"]
|
|
17
|
+
assert found[0].severity == "high"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("expr", ["repr(exc)", "traceback.format_exc()", "exc.args[0]"])
|
|
21
|
+
def test_other_ways_the_internals_leak(expr):
|
|
22
|
+
src = ('def h():\n try:\n go()\n'
|
|
23
|
+
' except Exception as exc:\n return {"error": %s}\n' % expr)
|
|
24
|
+
assert _rules(src) == ["leaked-internals"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_a_traceback_field_is_high():
|
|
28
|
+
src = 'def h():\n return {"error": "failed", "traceback": "see logs"}\n'
|
|
29
|
+
found = analyze_responses(src)
|
|
30
|
+
assert found[0].rule == "leaked-internals"
|
|
31
|
+
assert "traceback" in found[0].detail
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("body", [
|
|
35
|
+
'{"detail": "could not write /srv/app/x.csv"}',
|
|
36
|
+
'{"detail": "could not write /srv/app/%s.csv" % name}',
|
|
37
|
+
])
|
|
38
|
+
def test_an_absolute_path_in_a_body_is_medium(body):
|
|
39
|
+
"""The percent-formatted form was invisible until _value_text learned BinOp."""
|
|
40
|
+
src = "def h(name):\n return %s\n" % body
|
|
41
|
+
found = analyze_responses(src)
|
|
42
|
+
assert [f.rule for f in found] == ["internal-path"]
|
|
43
|
+
assert found[0].severity == "medium"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_a_body_a_caller_cannot_act_on_is_flagged():
|
|
47
|
+
src = 'def h():\n return {"error": "failed"}\n'
|
|
48
|
+
assert _rules(src) == ["vague-error-body"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_a_nested_error_object_with_a_code_is_left_alone():
|
|
52
|
+
"""This was flagged on day one: the code lived inside the nested object."""
|
|
53
|
+
src = ('def h():\n return {"error": {"code": "card_declined",\n'
|
|
54
|
+
' "message": "the issuer declined this card; ask for another"}}\n')
|
|
55
|
+
assert analyze_responses(src) == []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_message_without_any_code_is_flagged():
|
|
59
|
+
src = 'def h():\n return {"message": "the issuer declined this card; ask for another"}\n'
|
|
60
|
+
assert _rules(src) == ["no-machine-code"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_flat_body_with_a_code_key_is_clean():
|
|
64
|
+
src = 'def h():\n return {"error": "card declined by issuer", "code": "card_declined"}\n'
|
|
65
|
+
assert analyze_responses(src) == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_jsonify_and_exception_detail_are_both_read():
|
|
69
|
+
src = ('def a():\n return jsonify({"error": "failed"})\n\n'
|
|
70
|
+
'def b():\n raise HTTPException(detail={"error": "failed"})\n')
|
|
71
|
+
assert _rules(src) == ["vague-error-body", "vague-error-body"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_a_dict_that_is_not_an_error_is_ignored():
|
|
75
|
+
src = 'def h():\n return {"ok": True, "charge_id": "ch_1"}\n'
|
|
76
|
+
assert analyze_responses(src) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_unparseable_source_is_skipped_not_crashed():
|
|
80
|
+
assert analyze_responses("def h(:\n") == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_one_verdict_per_body_however_many_rules_apply():
|
|
84
|
+
src = 'def h():\n return {"error": str(exc), "traceback": "x"}\n'
|
|
85
|
+
assert len(analyze_responses(src)) == 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_test_files_are_skipped_unless_asked(tmp_path):
|
|
89
|
+
(tmp_path / "tests").mkdir()
|
|
90
|
+
(tmp_path / "tests" / "test_a.py").write_text('def h():\n return {"error": "failed"}\n')
|
|
91
|
+
(tmp_path / "app.py").write_text('def h():\n return {"error": "failed"}\n')
|
|
92
|
+
assert analyze_files([tmp_path]).files == 1
|
|
93
|
+
assert analyze_files([tmp_path], include_tests=True).files == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|