pywrit 0.2.5__tar.gz → 0.2.7__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.
- {pywrit-0.2.5/src/pywrit.egg-info → pywrit-0.2.7}/PKG-INFO +57 -2
- {pywrit-0.2.5 → pywrit-0.2.7}/README.md +55 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/pyproject.toml +2 -2
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit/__init__.py +1 -1
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit/cli.py +73 -0
- {pywrit-0.2.5 → pywrit-0.2.7/src/pywrit.egg-info}/PKG-INFO +57 -2
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit.egg-info/SOURCES.txt +1 -0
- pywrit-0.2.7/tests/test_scan_json.py +151 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/LICENSE +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/setup.cfg +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit/client.py +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit/errors.py +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit.egg-info/dependency_links.txt +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit.egg-info/entry_points.txt +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit.egg-info/requires.txt +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/src/pywrit.egg-info/top_level.txt +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/tests/test_client.py +0 -0
- {pywrit-0.2.5 → pywrit-0.2.7}/tests/test_scan_tsjs.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywrit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Python client + CLI for Writ — the gate before the write
|
|
5
|
-
License: MIT
|
|
5
|
+
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
|
|
7
7
|
Project-URL: Documentation, https://withwrit.com/docs
|
|
8
8
|
Requires-Python: >=3.9
|
|
@@ -40,6 +40,18 @@ pip install pywrit
|
|
|
40
40
|
|
|
41
41
|
Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
|
|
42
42
|
|
|
43
|
+
**JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx writ-scan .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The `writ-scan` package wraps the same scanner (including TS/JS support). On
|
|
50
|
+
first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
|
|
51
|
+
(one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
|
|
52
|
+
environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
|
|
53
|
+
install test.
|
|
54
|
+
|
|
43
55
|
## 60-second quickstart: scan → apply → gate
|
|
44
56
|
|
|
45
57
|
**1. Scan.** See which functions write, and how many of them are gated.
|
|
@@ -142,6 +154,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
|
142
154
|
|
|
143
155
|

|
|
144
156
|
|
|
157
|
+
## GitHub Action
|
|
158
|
+
|
|
159
|
+
Scan every pull request for ungated write sites. Findings land as check
|
|
160
|
+
annotations on the exact file and line, plus a Markdown risk report in the
|
|
161
|
+
job summary. The check fails when an ungated finding meets your
|
|
162
|
+
`fail-on-risk` threshold (default: `high`).
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
- uses: actions/checkout@v4
|
|
166
|
+
- uses: AvenueDAdmin/pywrit@v0
|
|
167
|
+
with:
|
|
168
|
+
fail-on-risk: high # high | medium | low | never
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
|
|
172
|
+
example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
|
|
173
|
+
|
|
145
174
|
## Python client
|
|
146
175
|
|
|
147
176
|
```python
|
|
@@ -205,6 +234,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
|
|
|
205
234
|
Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
|
|
206
235
|
`--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
|
|
207
236
|
|
|
237
|
+
## README badge
|
|
238
|
+
|
|
239
|
+
Show that your agent's writes are gated by Writ. Two flavors:
|
|
240
|
+
|
|
241
|
+
**Static** (works today, no API key) — same design, no live count:
|
|
242
|
+
|
|
243
|
+
```md
|
|
244
|
+
[](https://withwrit.com)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
|
|
248
|
+
token, then embed the snippet the API returns:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
curl -s https://api.withwrit.com/v1/badge/tokens \
|
|
252
|
+
-H "Authorization: Bearer writ_..." \
|
|
253
|
+
-H "Content-Type: application/json" \
|
|
254
|
+
-d '{}'
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
|
|
258
|
+
returns 404 and the static badge above is the one to use.
|
|
259
|
+
|
|
260
|
+
See [`badge/`](badge/) for embed docs and the verification model (what the
|
|
261
|
+
badge proves — and what it doesn't).
|
|
262
|
+
|
|
208
263
|
## Docs
|
|
209
264
|
|
|
210
265
|
Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
|
|
@@ -18,6 +18,18 @@ pip install pywrit
|
|
|
18
18
|
|
|
19
19
|
Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
|
|
20
20
|
|
|
21
|
+
**JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx writ-scan .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The `writ-scan` package wraps the same scanner (including TS/JS support). On
|
|
28
|
+
first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
|
|
29
|
+
(one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
|
|
30
|
+
environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
|
|
31
|
+
install test.
|
|
32
|
+
|
|
21
33
|
## 60-second quickstart: scan → apply → gate
|
|
22
34
|
|
|
23
35
|
**1. Scan.** See which functions write, and how many of them are gated.
|
|
@@ -120,6 +132,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
|
120
132
|
|
|
121
133
|

|
|
122
134
|
|
|
135
|
+
## GitHub Action
|
|
136
|
+
|
|
137
|
+
Scan every pull request for ungated write sites. Findings land as check
|
|
138
|
+
annotations on the exact file and line, plus a Markdown risk report in the
|
|
139
|
+
job summary. The check fails when an ungated finding meets your
|
|
140
|
+
`fail-on-risk` threshold (default: `high`).
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
- uses: actions/checkout@v4
|
|
144
|
+
- uses: AvenueDAdmin/pywrit@v0
|
|
145
|
+
with:
|
|
146
|
+
fail-on-risk: high # high | medium | low | never
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
|
|
150
|
+
example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
|
|
151
|
+
|
|
123
152
|
## Python client
|
|
124
153
|
|
|
125
154
|
```python
|
|
@@ -183,6 +212,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
|
|
|
183
212
|
Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
|
|
184
213
|
`--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
|
|
185
214
|
|
|
215
|
+
## README badge
|
|
216
|
+
|
|
217
|
+
Show that your agent's writes are gated by Writ. Two flavors:
|
|
218
|
+
|
|
219
|
+
**Static** (works today, no API key) — same design, no live count:
|
|
220
|
+
|
|
221
|
+
```md
|
|
222
|
+
[](https://withwrit.com)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
|
|
226
|
+
token, then embed the snippet the API returns:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
curl -s https://api.withwrit.com/v1/badge/tokens \
|
|
230
|
+
-H "Authorization: Bearer writ_..." \
|
|
231
|
+
-H "Content-Type: application/json" \
|
|
232
|
+
-d '{}'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
|
|
236
|
+
returns 404 and the static badge above is the one to use.
|
|
237
|
+
|
|
238
|
+
See [`badge/`](badge/) for embed docs and the verification model (what the
|
|
239
|
+
badge proves — and what it doesn't).
|
|
240
|
+
|
|
186
241
|
## Docs
|
|
187
242
|
|
|
188
243
|
Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pywrit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.7"
|
|
8
8
|
description = "Python client + CLI for Writ — the gate before the write"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
license =
|
|
10
|
+
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
dependencies = ["httpx>=0.27"]
|
|
13
13
|
|
|
@@ -1072,6 +1072,68 @@ def _tsjs_scan(root, excludes):
|
|
|
1072
1072
|
res["gated"].update((str(path),) + g for g in gated)
|
|
1073
1073
|
return res
|
|
1074
1074
|
|
|
1075
|
+
# ---------------------------------------------------------------------------
|
|
1076
|
+
# writ scan --format json: machine-readable risk report for CI (GitHub Action,
|
|
1077
|
+
# editor integrations). Pure read-only: prints one JSON document to stdout.
|
|
1078
|
+
# ---------------------------------------------------------------------------
|
|
1079
|
+
_RISK_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _scan_findings_json(root, sites, gated_funcs, tsjs, ts_sites):
|
|
1083
|
+
"""Return the findings list for `writ scan --format json`.
|
|
1084
|
+
|
|
1085
|
+
Each finding is a dict with file (repo-relative, posix), line, function,
|
|
1086
|
+
verb, risk tier, gated flag, kind, language, and a short snippet.
|
|
1087
|
+
"""
|
|
1088
|
+
findings = []
|
|
1089
|
+
for s in sites:
|
|
1090
|
+
rel = _pathlib.Path(s.file).relative_to(root)
|
|
1091
|
+
verb = infer_verb(rel, s.func, s.cls, s.kind)
|
|
1092
|
+
findings.append({
|
|
1093
|
+
"file": rel.as_posix(),
|
|
1094
|
+
"line": s.lineno,
|
|
1095
|
+
"function": s.func or "<module>",
|
|
1096
|
+
"verb": verb,
|
|
1097
|
+
"risk": _risk_tier(verb),
|
|
1098
|
+
"gated": (s.file, s.func) in gated_funcs,
|
|
1099
|
+
"kind": s.kind,
|
|
1100
|
+
"language": "python",
|
|
1101
|
+
"snippet": (s.snippet or "")[:200],
|
|
1102
|
+
})
|
|
1103
|
+
for s, _rid, fkey in ts_sites:
|
|
1104
|
+
rel = _pathlib.Path(s.file).relative_to(root)
|
|
1105
|
+
verb = _tsjs_verb(rel, fkey[0], s.cls, s.kind)
|
|
1106
|
+
findings.append({
|
|
1107
|
+
"file": rel.as_posix(),
|
|
1108
|
+
"line": s.lineno,
|
|
1109
|
+
"function": fkey[0] or "<module>",
|
|
1110
|
+
"verb": verb,
|
|
1111
|
+
"risk": _risk_tier(verb),
|
|
1112
|
+
"gated": (s.file,) + fkey in tsjs["gated"],
|
|
1113
|
+
"kind": s.kind,
|
|
1114
|
+
"language": "tsjs",
|
|
1115
|
+
"snippet": (s.snippet or "")[:200],
|
|
1116
|
+
})
|
|
1117
|
+
findings.sort(key=lambda f: (_RISK_ORDER[f["risk"]], f["file"], f["line"]))
|
|
1118
|
+
return findings
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def print_scan_json(root, files, skipped, sites, gated_funcs, tsjs, ts_sites):
|
|
1122
|
+
"""Print the machine-readable scan report. Read-only: no files written."""
|
|
1123
|
+
from . import __version__
|
|
1124
|
+
doc = {
|
|
1125
|
+
"scanner": "pywrit",
|
|
1126
|
+
"version": __version__,
|
|
1127
|
+
"root": str(root),
|
|
1128
|
+
"files_scanned": len(files) + len(tsjs.get("files", [])),
|
|
1129
|
+
"files_skipped": len(skipped) + len(tsjs.get("skipped", [])),
|
|
1130
|
+
"tsjs_hint": bool(tsjs.get("missing_extra")),
|
|
1131
|
+
"findings": _scan_findings_json(root, sites, gated_funcs, tsjs,
|
|
1132
|
+
ts_sites),
|
|
1133
|
+
}
|
|
1134
|
+
print(json.dumps(doc, indent=2))
|
|
1135
|
+
|
|
1136
|
+
|
|
1075
1137
|
def scan_cmd(args):
|
|
1076
1138
|
import json as _json
|
|
1077
1139
|
root = _pathlib.Path(args.path).resolve()
|
|
@@ -1114,6 +1176,13 @@ def scan_cmd(args):
|
|
|
1114
1176
|
total = len(sites)
|
|
1115
1177
|
pct = (100 * gated_site_count // total) if total else 100
|
|
1116
1178
|
|
|
1179
|
+
if getattr(args, "format", "text") == "json":
|
|
1180
|
+
# Machine-readable risk report for CI. Read-only: no policy file,
|
|
1181
|
+
# no diffs, no prompts.
|
|
1182
|
+
print_scan_json(root, files, skipped, sites, gated_funcs, tsjs,
|
|
1183
|
+
ts_sites)
|
|
1184
|
+
return 0
|
|
1185
|
+
|
|
1117
1186
|
verbs = {}
|
|
1118
1187
|
for (f, func_name, cls_name), ss in sorted(by_func.items()):
|
|
1119
1188
|
rel = _pathlib.Path(f).relative_to(root)
|
|
@@ -1601,6 +1670,10 @@ def main():
|
|
|
1601
1670
|
help="Apply without prompting")
|
|
1602
1671
|
scan.add_argument("--score", action="store_true",
|
|
1603
1672
|
help="Print only the Writ Score risk report (no diff, no policy file)")
|
|
1673
|
+
scan.add_argument("--format", choices=("text", "json"), default="text",
|
|
1674
|
+
help="Output format: human-readable text (default) or "
|
|
1675
|
+
"machine-readable JSON risk report (read-only; no "
|
|
1676
|
+
"policy file, no diffs)")
|
|
1604
1677
|
scan.add_argument("--push-policy", action="store_true",
|
|
1605
1678
|
help="PUT the discovered policy to the gate (needs --key)")
|
|
1606
1679
|
scan.add_argument("--key", default="",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywrit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Python client + CLI for Writ — the gate before the write
|
|
5
|
-
License: MIT
|
|
5
|
+
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
|
|
7
7
|
Project-URL: Documentation, https://withwrit.com/docs
|
|
8
8
|
Requires-Python: >=3.9
|
|
@@ -40,6 +40,18 @@ pip install pywrit
|
|
|
40
40
|
|
|
41
41
|
Requires Python 3.9+. Installs the `writ` command and the `pywrit` Python client.
|
|
42
42
|
|
|
43
|
+
**JavaScript/TypeScript devs:** run the scanner with no Python setup via npm:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx writ-scan .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The `writ-scan` package wraps the same scanner (including TS/JS support). On
|
|
50
|
+
first run it installs `pywrit[polyglot]` from PyPI using your Python 3.9+
|
|
51
|
+
(one-time); afterwards it starts instantly. See [`npm/`](npm/) for details,
|
|
52
|
+
environment overrides (`WRIT_PYTHON`, `WRIT_SCAN_NO_INSTALL`), and the
|
|
53
|
+
install test.
|
|
54
|
+
|
|
43
55
|
## 60-second quickstart: scan → apply → gate
|
|
44
56
|
|
|
45
57
|
**1. Scan.** See which functions write, and how many of them are gated.
|
|
@@ -142,6 +154,23 @@ clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
|
142
154
|
|
|
143
155
|

|
|
144
156
|
|
|
157
|
+
## GitHub Action
|
|
158
|
+
|
|
159
|
+
Scan every pull request for ungated write sites. Findings land as check
|
|
160
|
+
annotations on the exact file and line, plus a Markdown risk report in the
|
|
161
|
+
job summary. The check fails when an ungated finding meets your
|
|
162
|
+
`fail-on-risk` threshold (default: `high`).
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
- uses: actions/checkout@v4
|
|
166
|
+
- uses: AvenueDAdmin/pywrit@v0
|
|
167
|
+
with:
|
|
168
|
+
fail-on-risk: high # high | medium | low | never
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
|
|
172
|
+
example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
|
|
173
|
+
|
|
145
174
|
## Python client
|
|
146
175
|
|
|
147
176
|
```python
|
|
@@ -205,6 +234,32 @@ writ grant --sponsor acme --agent agent-7 --verb payments.refund \
|
|
|
205
234
|
Sponsor-token commands (`revoke`, `reinstate`, `revoked`, `grant`) read
|
|
206
235
|
`--sponsor-token` or `WRIT_SPONSOR_TOKEN`. Run `writ --help` for the full command list.
|
|
207
236
|
|
|
237
|
+
## README badge
|
|
238
|
+
|
|
239
|
+
Show that your agent's writes are gated by Writ. Two flavors:
|
|
240
|
+
|
|
241
|
+
**Static** (works today, no API key) — same design, no live count:
|
|
242
|
+
|
|
243
|
+
```md
|
|
244
|
+
[](https://withwrit.com)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Dynamic** (live 30-day gated-write count from your audit log) — mint a badge
|
|
248
|
+
token, then embed the snippet the API returns:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
curl -s https://api.withwrit.com/v1/badge/tokens \
|
|
252
|
+
-H "Authorization: Bearer writ_..." \
|
|
253
|
+
-H "Content-Type: application/json" \
|
|
254
|
+
-d '{}'
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The dynamic endpoint ships with the gate — until it's live, `POST /v1/badge/tokens`
|
|
258
|
+
returns 404 and the static badge above is the one to use.
|
|
259
|
+
|
|
260
|
+
See [`badge/`](badge/) for embed docs and the verification model (what the
|
|
261
|
+
badge proves — and what it doesn't).
|
|
262
|
+
|
|
208
263
|
## Docs
|
|
209
264
|
|
|
210
265
|
Full docs: [docs.withwrit.com](https://docs.withwrit.com) ·
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for `writ scan --format json` (the GitHub Action's input).
|
|
3
|
+
|
|
4
|
+
Run with: python -m pytest tests/test_scan_json.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import contextlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from pywrit import cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _args(path, **kw):
|
|
19
|
+
ns = argparse.Namespace(path=str(path), exclude=[], policy_out="writ-policy.json",
|
|
20
|
+
apply=False, yes=False, score=False, push_policy=False,
|
|
21
|
+
key="", format="json")
|
|
22
|
+
for k, v in kw.items():
|
|
23
|
+
setattr(ns, k, v)
|
|
24
|
+
return ns
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run_scan_json(root, **kw):
|
|
28
|
+
buf = io.StringIO()
|
|
29
|
+
with contextlib.redirect_stdout(buf):
|
|
30
|
+
code = cli.scan_cmd(_args(root, **kw))
|
|
31
|
+
return code, json.loads(buf.getvalue())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FIXTURE = '''\
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def issue_refund(payment_id, amount_cents):
|
|
39
|
+
"""High risk: money movement, ungated."""
|
|
40
|
+
resp = requests.post(
|
|
41
|
+
"https://api.example.com/refunds",
|
|
42
|
+
json={"payment": payment_id, "amount": amount_cents},
|
|
43
|
+
)
|
|
44
|
+
return resp.json()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def send_receipt(order_id, email):
|
|
48
|
+
"""Medium risk: outbound email, ungated."""
|
|
49
|
+
import smtplib
|
|
50
|
+
|
|
51
|
+
server = smtplib.SMTP("smtp.example.com")
|
|
52
|
+
server.sendmail("noreply@example.com", [email], "receipt %s" % order_id)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cancel_subscription(subscription_id):
|
|
56
|
+
"""Gated: already behind a writ gate, must not be reported."""
|
|
57
|
+
if _writ_check("subscriptions.cancel", subscription_id,
|
|
58
|
+
"user requested cancellation") != "ALLOW":
|
|
59
|
+
raise RuntimeError("denied")
|
|
60
|
+
requests.post("https://api.example.com/subs/cancel",
|
|
61
|
+
json={"id": subscription_id})
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
NOTIFY_FIXTURE = '''\
|
|
65
|
+
import smtplib
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def send_receipt(order_id, email):
|
|
69
|
+
"""Medium risk: outbound email, ungated."""
|
|
70
|
+
server = smtplib.SMTP("smtp.example.com")
|
|
71
|
+
server.sendmail("noreply@example.com", [email], "receipt %s" % order_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def log_event(name):
|
|
75
|
+
"""Low risk: local append-only log, ungated."""
|
|
76
|
+
with open("events.log", "a") as fh:
|
|
77
|
+
fh.write(name + "\\n")
|
|
78
|
+
'''
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ScanJsonTest(unittest.TestCase):
|
|
82
|
+
def _repo(self, td):
|
|
83
|
+
root = pathlib.Path(td) / "repo"
|
|
84
|
+
root.mkdir()
|
|
85
|
+
(root / "billing.py").write_text(FIXTURE, encoding="utf-8")
|
|
86
|
+
(root / "notify.py").write_text(NOTIFY_FIXTURE, encoding="utf-8")
|
|
87
|
+
return root
|
|
88
|
+
|
|
89
|
+
def test_json_document_shape(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as td:
|
|
91
|
+
code, doc = _run_scan_json(self._repo(td))
|
|
92
|
+
self.assertEqual(code, 0)
|
|
93
|
+
for key in ("scanner", "version", "root", "files_scanned",
|
|
94
|
+
"files_skipped", "findings"):
|
|
95
|
+
self.assertIn(key, doc)
|
|
96
|
+
self.assertEqual(doc["scanner"], "pywrit")
|
|
97
|
+
self.assertGreaterEqual(len(doc["findings"]), 3)
|
|
98
|
+
|
|
99
|
+
def test_finding_fields_and_risk_tiers(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as td:
|
|
101
|
+
_code, doc = _run_scan_json(self._repo(td))
|
|
102
|
+
by_func = {f["function"]: f for f in doc["findings"]}
|
|
103
|
+
for name in ("issue_refund", "send_receipt", "log_event",
|
|
104
|
+
"cancel_subscription"):
|
|
105
|
+
self.assertIn(name, by_func, doc["findings"])
|
|
106
|
+
for key in ("file", "line", "function", "verb", "risk", "gated",
|
|
107
|
+
"kind", "language", "snippet"):
|
|
108
|
+
self.assertIn(key, by_func[name])
|
|
109
|
+
# refunds are money movement -> high; email send -> medium;
|
|
110
|
+
# local log append -> low
|
|
111
|
+
self.assertEqual(by_func["issue_refund"]["risk"], "high")
|
|
112
|
+
self.assertEqual(by_func["send_receipt"]["risk"], "medium")
|
|
113
|
+
self.assertEqual(by_func["log_event"]["risk"], "low")
|
|
114
|
+
# gated writes are flagged so the action can skip them
|
|
115
|
+
self.assertTrue(by_func["cancel_subscription"]["gated"])
|
|
116
|
+
self.assertFalse(by_func["issue_refund"]["gated"])
|
|
117
|
+
self.assertEqual(by_func["issue_refund"]["file"], "billing.py")
|
|
118
|
+
self.assertEqual(by_func["send_receipt"]["file"], "notify.py")
|
|
119
|
+
for f in doc["findings"]:
|
|
120
|
+
self.assertIsInstance(f["line"], int)
|
|
121
|
+
self.assertGreater(f["line"], 0)
|
|
122
|
+
|
|
123
|
+
def test_findings_sorted_high_first(self):
|
|
124
|
+
with tempfile.TemporaryDirectory() as td:
|
|
125
|
+
_code, doc = _run_scan_json(self._repo(td))
|
|
126
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
127
|
+
tiers = [order[f["risk"]] for f in doc["findings"]]
|
|
128
|
+
self.assertEqual(tiers, sorted(tiers))
|
|
129
|
+
|
|
130
|
+
def test_json_is_read_only(self):
|
|
131
|
+
# --format json must not write the policy file or anything else.
|
|
132
|
+
with tempfile.TemporaryDirectory() as td:
|
|
133
|
+
root = self._repo(td)
|
|
134
|
+
code, _doc = _run_scan_json(root)
|
|
135
|
+
self.assertEqual(code, 0)
|
|
136
|
+
self.assertEqual(
|
|
137
|
+
sorted(p.name for p in root.iterdir()),
|
|
138
|
+
["billing.py", "notify.py"])
|
|
139
|
+
|
|
140
|
+
def test_empty_repo_gives_empty_findings(self):
|
|
141
|
+
with tempfile.TemporaryDirectory() as td:
|
|
142
|
+
root = pathlib.Path(td) / "repo"
|
|
143
|
+
root.mkdir()
|
|
144
|
+
(root / "clean.py").write_text("X = 1\n", encoding="utf-8")
|
|
145
|
+
code, doc = _run_scan_json(root)
|
|
146
|
+
self.assertEqual(code, 0)
|
|
147
|
+
self.assertEqual(doc["findings"], [])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
unittest.main()
|
|
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
|