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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: errgrade
3
- Version: 0.1.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 # 52 tests
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 # 52 tests
176
+ python -m pytest -q # 68 tests
131
177
  bash scripts/verify.sh # the release gate, 6 checks
132
178
  ```
133
179
 
@@ -1,3 +1,3 @@
1
1
  """errgrade: grade the error messages your code raises."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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) and isinstance(part.value, str) else "<value>"
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 message(s) in %d file(s).\n"
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 message(s) in %d file(s), worst %s"
40
- % (len(findings), rep.graded, rep.files, worst_name),
41
- "%d of %d graded message(s) fall below C" % (rep.below_c, rep.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
- lines.append(" message: %r" % f.message)
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 error messages your code raises: what broke, where, what to do next.",
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="*", help="files or directories (default: current directory)")
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
- if raw and raw[0] == "check": # optional verb, so `errgrade check .` works
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
- rep = analyze_files(targets, include_tests=args.include_tests)
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.1.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 # 52 tests
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.1.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