pywrit 0.2.8__tar.gz → 0.2.9__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: pywrit
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/withwrit/pywrit
@@ -154,7 +154,11 @@ Python scanning never needs it.
154
154
  TS/JS is **scan-only**: findings are listed with verbs for your policy file,
155
155
  but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
156
156
  Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
157
- clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
157
+ clients, Prisma writes, JS SDK calls such as `stripe.refunds.create(...)`, and
158
+ generated REST resource clients: `create` / `update` / `patch` / `remove`
159
+ methods (plus their `...WithHttpInfo` variants) that build request options
160
+ with an explicit write HTTP method and route them through the client request
161
+ pipeline (twilio-style generated clients, e.g. `twilio-node`).
158
162
  (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
159
163
  — see "Not covered" above.)
160
164
 
@@ -177,6 +181,13 @@ job summary. The check fails when an ungated finding meets your
177
181
  No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
178
182
  example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
179
183
 
184
+ ## GitHub App
185
+
186
+ Install the [Writ GitHub App](https://docs.withwrit.com/git-hub-app) on selected
187
+ repositories: every push and pull request is scanned for ungated write-sites,
188
+ with results as a check run on the PR and in the
189
+ [portal](https://app.withwrit.com), where you manage policy, keys, and the audit log.
190
+
180
191
  ## MCP server (`writ-mcp`)
181
192
 
182
193
  Give any MCP-compatible agent point-of-action policy checks. `writ-mcp` is a
@@ -132,7 +132,11 @@ Python scanning never needs it.
132
132
  TS/JS is **scan-only**: findings are listed with verbs for your policy file,
133
133
  but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
134
134
  Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
135
- clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
135
+ clients, Prisma writes, JS SDK calls such as `stripe.refunds.create(...)`, and
136
+ generated REST resource clients: `create` / `update` / `patch` / `remove`
137
+ methods (plus their `...WithHttpInfo` variants) that build request options
138
+ with an explicit write HTTP method and route them through the client request
139
+ pipeline (twilio-style generated clients, e.g. `twilio-node`).
136
140
  (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
137
141
  — see "Not covered" above.)
138
142
 
@@ -155,6 +159,13 @@ job summary. The check fails when an ungated finding meets your
155
159
  No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
156
160
  example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
157
161
 
162
+ ## GitHub App
163
+
164
+ Install the [Writ GitHub App](https://docs.withwrit.com/git-hub-app) on selected
165
+ repositories: every push and pull request is scanned for ungated write-sites,
166
+ with results as a check run on the PR and in the
167
+ [portal](https://app.withwrit.com), where you manage policy, keys, and the audit log.
168
+
158
169
  ## MCP server (`writ-mcp`)
159
170
 
160
171
  Give any MCP-compatible agent point-of-action policy checks. `writ-mcp` is a
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pywrit"
7
- version = "0.2.8"
7
+ version = "0.2.9"
8
8
  description = "Python client + CLI for Writ — the gate before the write"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -860,7 +860,10 @@ _TSJS_HTTP_VERBS = "post|put|patch|delete|del"
860
860
  # Patterns are dotted callee chains (after import aliasing and dropping a
861
861
  # leading `this`): `a|b` alternatives, `*` one segment, `**` any segments.
862
862
  # Conditions: None, "method" (a literal write method in an options object
863
- # or as a leading string arg), "sql" (first arg is a literal SQL write).
863
+ # or as a leading string arg), "sql" (first arg is a literal SQL write),
864
+ # "resource_write" (generated REST resource method: the enclosing
865
+ # create/update/patch/remove-style method routes a request that carries an
866
+ # explicit write HTTP method; reads never match).
864
867
  _TSJS_RULES = (
865
868
  ("child-process", "exec",
866
869
  ("child_process.exec|execSync|execFile|execFileSync|spawn|spawnSync|"
@@ -886,6 +889,14 @@ _TSJS_RULES = (
886
889
  ("http-config-write", "http",
887
890
  ("axios|got|ky|superagent", "axios|got|ky.request",
888
891
  "http|https.request"), "method"),
892
+ # Generated REST resource clients (twilio-style): resource methods named
893
+ # create/update/patch/remove (+ ...WithHttpInfo) that build request
894
+ # options with an explicit write HTTP method and route them through the
895
+ # client request pipeline (Version.request / Domain.request). The
896
+ # callee is the pipeline, so the pattern is unanchored and the condition
897
+ # does the work: it never fires on method: "get" or read-named methods
898
+ # (fetch/list/page/stream/each).
899
+ ("resource-write", "http", ("**",), "resource_write"),
889
900
  )
890
901
 
891
902
 
@@ -1176,7 +1187,37 @@ def _tsjs_has_write_method(call, f):
1176
1187
  return False
1177
1188
 
1178
1189
 
1179
- def _tsjs_classify(call, chain, f):
1190
+ _TSJS_RESOURCE_WRITE_NAMES = frozenset(
1191
+ ("create", "update", "patch", "remove", "del", "delete"))
1192
+ _TSJS_HTTPINFO_SUFFIX = "withhttpinfo"
1193
+
1194
+
1195
+ def _tsjs_resource_write_func(func_name):
1196
+ """True for generated-REST-resource write methods: create/update/patch/
1197
+ remove (plus del/delete variants) and their ...WithHttpInfo forms.
1198
+ Read-style names (fetch/list/page/stream/each/get) never match."""
1199
+ if not func_name:
1200
+ return False
1201
+ n = func_name.lower()
1202
+ if n.endswith(_TSJS_HTTPINFO_SUFFIX):
1203
+ n = n[:-len(_TSJS_HTTPINFO_SUFFIX)]
1204
+ return n in _TSJS_RESOURCE_WRITE_NAMES
1205
+
1206
+
1207
+ def _tsjs_resource_write(call, func_name, f):
1208
+ """Generated-resource write: inside a create/update/patch/remove-style
1209
+ method, a request that carries an explicit write HTTP method (a literal
1210
+ `method: "post"`-style options key, or the method as a leading string
1211
+ arg) routed through a call. The callee is the request pipeline and may
1212
+ be any name (operationVersion.update, this.request, Version.fetch for
1213
+ DELETE-that-returns-a-body), so only the literal decides write-vs-read:
1214
+ `method: "get"` is never a write and read-named enclosing methods never
1215
+ match."""
1216
+ return _tsjs_resource_write_func(func_name) and \
1217
+ _tsjs_has_write_method(call, f)
1218
+
1219
+
1220
+ def _tsjs_classify(call, chain, f, func_name):
1180
1221
  for rid, kind, pats, cond in _TSJS_COMPILED:
1181
1222
  if not any(_tsjs_glob(p, chain) for p in pats):
1182
1223
  continue
@@ -1187,6 +1228,9 @@ def _tsjs_classify(call, chain, f):
1187
1228
  lit = f.literal(args[0]) if args else None
1188
1229
  if lit is None or not _TSJS_SQL_WRITE.match(lit):
1189
1230
  continue
1231
+ if cond == "resource_write" and \
1232
+ not _tsjs_resource_write(call, func_name, f):
1233
+ continue
1190
1234
  return rid, kind
1191
1235
  return None
1192
1236
 
@@ -1229,7 +1273,7 @@ def _tsjs_scan_source(src, filename, parser):
1229
1273
  if chain[-1] in _TSJS_GATE_NAMES:
1230
1274
  gated.add(fkey)
1231
1275
  continue
1232
- hit = _tsjs_classify(node, chain, f)
1276
+ hit = _tsjs_classify(node, chain, f, fkey[0])
1233
1277
  if hit:
1234
1278
  snippet = " ".join(f.text(node).split())[:100]
1235
1279
  sites.append((_WriteSite(filename, fkey[0], cls,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywrit
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Python client + CLI for Writ — the gate before the write
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/withwrit/pywrit
@@ -154,7 +154,11 @@ Python scanning never needs it.
154
154
  TS/JS is **scan-only**: findings are listed with verbs for your policy file,
155
155
  but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
156
156
  Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
157
- clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
157
+ clients, Prisma writes, JS SDK calls such as `stripe.refunds.create(...)`, and
158
+ generated REST resource clients: `create` / `update` / `patch` / `remove`
159
+ methods (plus their `...WithHttpInfo` variants) that build request options
160
+ with an explicit write HTTP method and route them through the client request
161
+ pipeline (twilio-style generated clients, e.g. `twilio-node`).
158
162
  (Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
159
163
  — see "Not covered" above.)
160
164
 
@@ -177,6 +181,13 @@ job summary. The check fails when an ungated finding meets your
177
181
  No API key needed. Full reference: [docs/github-action.md](docs/github-action.md) ·
178
182
  example workflow: [examples/github-action/writ-scan.yml](examples/github-action/writ-scan.yml)
179
183
 
184
+ ## GitHub App
185
+
186
+ Install the [Writ GitHub App](https://docs.withwrit.com/git-hub-app) on selected
187
+ repositories: every push and pull request is scanned for ungated write-sites,
188
+ with results as a check run on the PR and in the
189
+ [portal](https://app.withwrit.com), where you manage policy, keys, and the audit log.
190
+
180
191
  ## MCP server (`writ-mcp`)
181
192
 
182
193
  Give any MCP-compatible agent point-of-action policy checks. `writ-mcp` is a
@@ -113,15 +113,16 @@ class FixtureRulesTest(unittest.TestCase):
113
113
  verbs = __import__("json").loads(policy.read_text())
114
114
  self.assertEqual(code, 0)
115
115
  n = len(_expected())
116
- self.assertIn("ts/js files scanned: 7 skipped: 0 (scan only, no --apply)", out)
117
- self.assertIn("ts/js write sites: %d in 11 function(s)" % n, out)
116
+ self.assertIn("ts/js files scanned: 9 skipped: 0 (scan only, no --apply)", out)
117
+ self.assertIn("ts/js write sites: %d in 18 function(s)" % n, out)
118
118
  self.assertIn("ts/js gated: 1/%d" % n, out) # gated.ts calls writCheck
119
119
  self.assertIn(" payments/refunds.service.ts:10 [sdk] stripe-write "
120
120
  "issueRefund(): stripe.refunds.create({ charge: chargeId })", out)
121
121
  self.assertIn(" http.ts:10 [http] fetch-write createOrder(): fetch(url, "
122
122
  "{ method: \"POST\", body: JSON.stringify(body) })", out)
123
123
  for v in ("payments.refund", "payments.record", "payments.export",
124
- "storage.save", "storage.create", "http.create", "legacy.write"):
124
+ "storage.save", "storage.create", "http.create", "legacy.write",
125
+ "rest.create", "rest.update", "rest.delete"):
125
126
  self.assertEqual(verbs.get(v), "require_grant", v)
126
127
  self.assertIn("nothing to instrument (ts/js findings are scan-only", out)
127
128
 
@@ -238,6 +239,68 @@ class FileWalkTest(unittest.TestCase):
238
239
  self.assertEqual(got, ["src/a.ts"])
239
240
 
240
241
 
242
+ class ResourceWriteTest(unittest.TestCase):
243
+ """Regression for generated REST resource clients (twilio-style): the
244
+ fixture has a version request pipeline plus a resource class and a
245
+ factory-pattern list whose create/update/patch/remove methods route
246
+ requests with an explicit write HTTP method. Only those methods are
247
+ findings; fetch/list/get reads (even through pipeline calls) are not."""
248
+
249
+ EXPECTED = {
250
+ ("rest/account.ts", 52, "resource-write"), # update()
251
+ ("rest/account.ts", 56, "resource-write"), # updateWithHttpInfo()
252
+ ("rest/account.ts", 60, "resource-write"), # remove() via Version.fetch
253
+ ("rest/account.ts", 64, "resource-write"), # removeWithHttpInfo()
254
+ ("rest/account.ts", 68, "resource-write"), # patch()
255
+ ("rest/message.ts", 14, "resource-write"), # factory create()
256
+ ("rest/message.ts", 18, "resource-write"), # factory remove()
257
+ }
258
+
259
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
260
+ def test_fixture_layout_flags_writes_not_reads(self):
261
+ with tempfile.TemporaryDirectory() as td:
262
+ root = _copy_fixtures(pathlib.Path(td) / "repo")
263
+ code, out = _run_scan(root)
264
+ self.assertEqual(code, 0)
265
+ rest = {f for f in _findings(out) if f[0].startswith("rest/")}
266
+ self.assertEqual(rest, self.EXPECTED, out)
267
+
268
+ @unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
269
+ def test_json_mode_reports_resource_finding(self):
270
+ with tempfile.TemporaryDirectory() as td:
271
+ root = pathlib.Path(td) / "repo"
272
+ (root / "rest").mkdir(parents=True)
273
+ (root / "rest" / "account.ts").write_text(
274
+ "class Version {\n"
275
+ " update(opts: any) { return this.request(opts); }\n"
276
+ "}\n"
277
+ "class AccountContext {\n"
278
+ " constructor(private version: Version) {}\n"
279
+ " update() {\n"
280
+ " return this.version.update({ uri: \"/x\", method: \"post\" });\n"
281
+ " }\n"
282
+ " fetch() {\n"
283
+ " return this.version.fetch({ uri: \"/x\", method: \"get\" });\n"
284
+ " }\n"
285
+ "}\n")
286
+ buf = io.StringIO()
287
+ with contextlib.redirect_stdout(buf):
288
+ code = cli.scan_cmd(_args(root, pathlib.Path(td) / "policy.json",
289
+ format="json"))
290
+ out = buf.getvalue()
291
+ self.assertEqual(code, 0)
292
+ doc = __import__("json").loads(out)
293
+ self.assertEqual(doc["files_scanned"], 1)
294
+ findings = doc["findings"]
295
+ self.assertEqual(len(findings), 1, doc)
296
+ f = findings[0]
297
+ self.assertEqual((f["file"], f["line"], f["function"], f["verb"]),
298
+ ("rest/account.ts", 7, "update", "rest.update"))
299
+ self.assertEqual((f["kind"], f["language"], f["gated"]),
300
+ ("http", "tsjs", False))
301
+ self.assertIn('method: "post"', f["snippet"])
302
+
303
+
241
304
  class MissingExtraTest(unittest.TestCase):
242
305
  def test_hint_once_and_python_still_scanned(self):
243
306
  with tempfile.TemporaryDirectory() as td:
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