pywrit 0.2.4__tar.gz → 0.2.5__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.4 → pywrit-0.2.5}/PKG-INFO +27 -7
- pywrit-0.2.4/src/pywrit.egg-info/PKG-INFO → pywrit-0.2.5/README.md +17 -19
- {pywrit-0.2.4 → pywrit-0.2.5}/pyproject.toml +10 -1
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit/__init__.py +1 -1
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit/cli.py +495 -1
- pywrit-0.2.4/README.md → pywrit-0.2.5/src/pywrit.egg-info/PKG-INFO +39 -5
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit.egg-info/SOURCES.txt +2 -1
- pywrit-0.2.5/src/pywrit.egg-info/requires.txt +12 -0
- pywrit-0.2.5/tests/test_scan_tsjs.py +243 -0
- pywrit-0.2.4/src/pywrit.egg-info/requires.txt +0 -1
- {pywrit-0.2.4 → pywrit-0.2.5}/LICENSE +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/setup.cfg +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit/client.py +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit/errors.py +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit.egg-info/dependency_links.txt +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit.egg-info/entry_points.txt +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/src/pywrit.egg-info/top_level.txt +0 -0
- {pywrit-0.2.4 → pywrit-0.2.5}/tests/test_client.py +0 -0
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywrit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Python client + CLI for Writ — the gate before the write
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
|
|
7
7
|
Project-URL: Documentation, https://withwrit.com/docs
|
|
8
|
-
Keywords: ai-agents,policy,audit-log,guardrails
|
|
9
8
|
Requires-Python: >=3.9
|
|
10
9
|
Description-Content-Type: text/markdown
|
|
11
10
|
License-File: LICENSE
|
|
12
11
|
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: polyglot
|
|
13
|
+
Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
|
|
14
|
+
Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
|
|
15
|
+
Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
|
|
16
|
+
Provides-Extra: keywords
|
|
17
|
+
Requires-Dist: ai-agents; extra == "keywords"
|
|
18
|
+
Requires-Dist: policy; extra == "keywords"
|
|
19
|
+
Requires-Dist: audit-log; extra == "keywords"
|
|
20
|
+
Requires-Dist: guardrails; extra == "keywords"
|
|
13
21
|
Dynamic: license-file
|
|
14
22
|
|
|
15
23
|
# pywrit
|
|
@@ -114,13 +122,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
|
|
|
114
122
|
calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
|
|
115
123
|
several layers down. The risk report lists these gaps every time.
|
|
116
124
|
|
|
117
|
-
### TypeScript / JavaScript
|
|
125
|
+
### TypeScript / JavaScript (scan-only)
|
|
118
126
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
> development; the command it uses is not available yet.
|
|
127
|
+
`writ scan` also finds write sites in TypeScript and JavaScript. It needs the
|
|
128
|
+
optional extra (tree-sitter based):
|
|
122
129
|
|
|
123
|
-
|
|
130
|
+
pip install 'pywrit[polyglot]'
|
|
131
|
+
writ scan .
|
|
132
|
+
|
|
133
|
+
Without the extra, the scanner prints a one-line hint and keeps going —
|
|
134
|
+
Python scanning never needs it.
|
|
135
|
+
|
|
136
|
+
TS/JS is **scan-only**: findings are listed with verbs for your policy file,
|
|
137
|
+
but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
|
|
138
|
+
Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
|
|
139
|
+
clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
140
|
+
(Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
|
|
141
|
+
— see "Not covered" above.)
|
|
142
|
+
|
|
143
|
+

|
|
124
144
|
|
|
125
145
|
## Python client
|
|
126
146
|
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: pywrit
|
|
3
|
-
Version: 0.2.4
|
|
4
|
-
Summary: Python client + CLI for Writ — the gate before the write
|
|
5
|
-
License: MIT
|
|
6
|
-
Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
|
|
7
|
-
Project-URL: Documentation, https://withwrit.com/docs
|
|
8
|
-
Keywords: ai-agents,policy,audit-log,guardrails
|
|
9
|
-
Requires-Python: >=3.9
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
Requires-Dist: httpx>=0.27
|
|
13
|
-
Dynamic: license-file
|
|
14
|
-
|
|
15
1
|
# pywrit
|
|
16
2
|
|
|
17
3
|
**Find the dangerous writes in your Python agent's code, then gate it.** `pywrit` is the Python
|
|
@@ -114,13 +100,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
|
|
|
114
100
|
calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
|
|
115
101
|
several layers down. The risk report lists these gaps every time.
|
|
116
102
|
|
|
117
|
-
### TypeScript / JavaScript
|
|
103
|
+
### TypeScript / JavaScript (scan-only)
|
|
104
|
+
|
|
105
|
+
`writ scan` also finds write sites in TypeScript and JavaScript. It needs the
|
|
106
|
+
optional extra (tree-sitter based):
|
|
107
|
+
|
|
108
|
+
pip install 'pywrit[polyglot]'
|
|
109
|
+
writ scan .
|
|
110
|
+
|
|
111
|
+
Without the extra, the scanner prints a one-line hint and keeps going —
|
|
112
|
+
Python scanning never needs it.
|
|
118
113
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
TS/JS is **scan-only**: findings are listed with verbs for your policy file,
|
|
115
|
+
but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
|
|
116
|
+
Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
|
|
117
|
+
clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
118
|
+
(Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
|
|
119
|
+
— see "Not covered" above.)
|
|
122
120
|
|
|
123
|
-

|
|
124
122
|
|
|
125
123
|
## Python client
|
|
126
124
|
|
|
@@ -4,12 +4,21 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pywrit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.5"
|
|
8
8
|
description = "Python client + CLI for Writ — the gate before the write"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
dependencies = ["httpx>=0.27"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
# `pip install 'pywrit[polyglot]'`: lets `writ scan` parse TypeScript and
|
|
16
|
+
# JavaScript (scan only). Python scanning never needs it.
|
|
17
|
+
polyglot = [
|
|
18
|
+
"tree-sitter>=0.23",
|
|
19
|
+
"tree-sitter-typescript>=0.23",
|
|
20
|
+
"tree-sitter-javascript>=0.23",
|
|
21
|
+
]
|
|
13
22
|
keywords = ["ai-agents", "policy", "audit-log", "guardrails"]
|
|
14
23
|
|
|
15
24
|
[project.urls]
|
|
@@ -610,6 +610,468 @@ def _apply_edits(lines, edits):
|
|
|
610
610
|
return out
|
|
611
611
|
|
|
612
612
|
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
# writ scan, TypeScript/JavaScript: deterministic and scan-only (TS/JS files
|
|
615
|
+
# are never rewritten). Parsing needs the optional extra
|
|
616
|
+
# `pip install 'pywrit[polyglot]'` (tree-sitter); without it the scanner
|
|
617
|
+
# prints a one-line hint and keeps going. Rules are data (_TSJS_RULES) so
|
|
618
|
+
# other languages can reuse the same (id, kind, callee patterns, condition)
|
|
619
|
+
# shape.
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
import re as _re
|
|
622
|
+
import subprocess as _subprocess
|
|
623
|
+
|
|
624
|
+
_TSJS_EXTS = {".ts": "typescript", ".tsx": "tsx", ".js": "javascript",
|
|
625
|
+
".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript"}
|
|
626
|
+
_TSJS_SKIP_DIR_NAMES = _SKIP_DIR_NAMES | {".next", "vendor", "venv", ".venv"}
|
|
627
|
+
_TSJS_TEST_DIRS = {"tests", "testing", "__tests__"}
|
|
628
|
+
_TSJS_MAX_BYTES = 1000000 # larger files are almost always bundles
|
|
629
|
+
_TSJS_HINT = ("hint: %d TS/JS file(s) not scanned; install the extra: "
|
|
630
|
+
"pip install 'pywrit[polyglot]'")
|
|
631
|
+
_TSJS_WRITE_METHODS = {"post", "put", "patch", "delete"}
|
|
632
|
+
_TSJS_SQL_WRITE = _re.compile(
|
|
633
|
+
r"^\s*\(*\s*(insert|update|delete|drop|alter|truncate|create|replace|"
|
|
634
|
+
r"merge)\b", _re.I)
|
|
635
|
+
_TSJS_GATE_NAMES = {"writ_check", "_writ_check", "writcheck"}
|
|
636
|
+
|
|
637
|
+
# import/require source -> canonical callee prefix
|
|
638
|
+
_TSJS_MODULES = {
|
|
639
|
+
"fs": ("fs",), "fs/promises": ("fs", "promises"), "fs-extra": ("fs",),
|
|
640
|
+
"graceful-fs": ("fs",), "child_process": ("child_process",),
|
|
641
|
+
"http": ("http",), "https": ("https",), "axios": ("axios",),
|
|
642
|
+
"got": ("got",), "ky": ("ky",), "superagent": ("superagent",),
|
|
643
|
+
"node-fetch": ("fetch",), "cross-fetch": ("fetch",),
|
|
644
|
+
"isomorphic-fetch": ("fetch",), "knex": ("knex",), "stripe": ("stripe",),
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
_FS_FNS = ("writeFile", "appendFile", "unlink", "rm", "rmdir", "rename",
|
|
648
|
+
"mkdir", "copyFile", "cp", "truncate", "symlink")
|
|
649
|
+
_TSJS_FS = "|".join(_FS_FNS + tuple(f + "Sync" for f in _FS_FNS)
|
|
650
|
+
+ ("createWriteStream",))
|
|
651
|
+
_TSJS_DB_HINTS = "|".join(sorted(_DB_HINTS | {"knex", "trx", "pool"}))
|
|
652
|
+
_TSJS_DB_METHODS = ("insert|update|del|delete|upsert|truncate|save|remove|"
|
|
653
|
+
"insertOne|insertMany|updateOne|updateMany|deleteOne|"
|
|
654
|
+
"deleteMany|replaceOne|bulkWrite")
|
|
655
|
+
_TSJS_HTTP_HINTS = "|".join(sorted(_HTTP_HINTS | {"httpclient", "$http",
|
|
656
|
+
"request", "agent"}))
|
|
657
|
+
_TSJS_HTTP_VERBS = "post|put|patch|delete|del"
|
|
658
|
+
|
|
659
|
+
# (rule id, kind, callee patterns, condition). First match wins.
|
|
660
|
+
# Patterns are dotted callee chains (after import aliasing and dropping a
|
|
661
|
+
# leading `this`): `a|b` alternatives, `*` one segment, `**` any segments.
|
|
662
|
+
# Conditions: None, "method" (a literal write method in an options object
|
|
663
|
+
# or as a leading string arg), "sql" (first arg is a literal SQL write).
|
|
664
|
+
_TSJS_RULES = (
|
|
665
|
+
("child-process", "exec",
|
|
666
|
+
("child_process.exec|execSync|execFile|execFileSync|spawn|spawnSync|"
|
|
667
|
+
"fork",), None),
|
|
668
|
+
("fs-write", "file",
|
|
669
|
+
("fs." + _TSJS_FS, "fs.promises." + _TSJS_FS), None),
|
|
670
|
+
("sql-write", "db",
|
|
671
|
+
("**.query|execute|raw|exec|run|unsafe|$executeRaw|$executeRawUnsafe|"
|
|
672
|
+
"$queryRaw|$queryRawUnsafe|sql",), "sql"),
|
|
673
|
+
("stripe-write", "sdk",
|
|
674
|
+
("stripe.**.create|update|del|cancel|capture|confirm|pay",), None),
|
|
675
|
+
("prisma-write", "db",
|
|
676
|
+
("prisma.*.create|createMany|update|updateMany|upsert|delete|deleteMany",),
|
|
677
|
+
None),
|
|
678
|
+
("db-write", "db",
|
|
679
|
+
("%s.**.%s" % (_TSJS_DB_HINTS, _TSJS_DB_METHODS),
|
|
680
|
+
"**.%s.%s" % (_TSJS_DB_HINTS, _TSJS_DB_METHODS)), None),
|
|
681
|
+
("fetch-write", "http",
|
|
682
|
+
("fetch", "window|globalThis|self.fetch"), "method"),
|
|
683
|
+
("http-client-write", "http",
|
|
684
|
+
("axios|got|ky|superagent.**." + _TSJS_HTTP_VERBS,
|
|
685
|
+
"**.%s.%s" % (_TSJS_HTTP_HINTS, _TSJS_HTTP_VERBS)), None),
|
|
686
|
+
("http-config-write", "http",
|
|
687
|
+
("axios|got|ky|superagent", "axios|got|ky.request",
|
|
688
|
+
"http|https.request"), "method"),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _tsjs_compile(pattern):
|
|
693
|
+
return tuple(s if s in ("*", "**") else
|
|
694
|
+
frozenset(a.lower() for a in s.split("|"))
|
|
695
|
+
for s in pattern.split("."))
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
_TSJS_COMPILED = tuple((rid, kind, tuple(_tsjs_compile(p) for p in pats), cond)
|
|
699
|
+
for rid, kind, pats, cond in _TSJS_RULES)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _tsjs_glob(segs, chain):
|
|
703
|
+
if not segs:
|
|
704
|
+
return not chain
|
|
705
|
+
s = segs[0]
|
|
706
|
+
if s == "**":
|
|
707
|
+
return any(_tsjs_glob(segs[1:], chain[i:])
|
|
708
|
+
for i in range(len(chain) + 1))
|
|
709
|
+
if not chain or (s != "*" and chain[0] not in s):
|
|
710
|
+
return False
|
|
711
|
+
return _tsjs_glob(segs[1:], chain[1:])
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _tsjs_parsers():
|
|
715
|
+
"""Return {grammar: Parser}, or {} when the polyglot extra is missing."""
|
|
716
|
+
try:
|
|
717
|
+
import tree_sitter as ts
|
|
718
|
+
except ImportError:
|
|
719
|
+
return {}
|
|
720
|
+
try:
|
|
721
|
+
import tree_sitter_typescript as tst
|
|
722
|
+
import tree_sitter_javascript as tsj
|
|
723
|
+
fns = {"typescript": tst.language_typescript,
|
|
724
|
+
"tsx": tst.language_tsx, "javascript": tsj.language}
|
|
725
|
+
except ImportError:
|
|
726
|
+
try:
|
|
727
|
+
from tree_sitter_language_pack import get_language
|
|
728
|
+
except ImportError:
|
|
729
|
+
return {}
|
|
730
|
+
fns = {k: (lambda k=k: get_language(k))
|
|
731
|
+
for k in ("typescript", "tsx", "javascript")}
|
|
732
|
+
parsers = {}
|
|
733
|
+
try:
|
|
734
|
+
for name, fn in fns.items():
|
|
735
|
+
lang = fn()
|
|
736
|
+
if not isinstance(lang, ts.Language):
|
|
737
|
+
lang = ts.Language(lang)
|
|
738
|
+
try:
|
|
739
|
+
parser = ts.Parser(lang)
|
|
740
|
+
except TypeError:
|
|
741
|
+
parser = ts.Parser()
|
|
742
|
+
parser.language = lang
|
|
743
|
+
parsers[name] = parser
|
|
744
|
+
except Exception:
|
|
745
|
+
return {}
|
|
746
|
+
return parsers
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _tsjs_should_skip(path, root, excludes):
|
|
750
|
+
rel = path.relative_to(root)
|
|
751
|
+
if any(p in _TSJS_SKIP_DIR_NAMES or p.startswith(".")
|
|
752
|
+
for p in rel.parts[:-1]):
|
|
753
|
+
return True
|
|
754
|
+
if any(p in _TSJS_TEST_DIRS for p in rel.parts[:-1]):
|
|
755
|
+
return True
|
|
756
|
+
name = path.name
|
|
757
|
+
if ".test." in name or ".spec." in name or name.endswith(
|
|
758
|
+
(".d.ts", ".min.js")):
|
|
759
|
+
return True
|
|
760
|
+
s = str(path)
|
|
761
|
+
return any(e in s for e in excludes)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _tsjs_git_files(root):
|
|
765
|
+
"""Tracked + untracked-not-ignored files under root, or None."""
|
|
766
|
+
try:
|
|
767
|
+
out = _subprocess.run(
|
|
768
|
+
["git", "-C", str(root), "ls-files", "-co", "--exclude-standard",
|
|
769
|
+
"-z"], stdout=_subprocess.PIPE, stderr=_subprocess.DEVNULL,
|
|
770
|
+
timeout=60, check=False)
|
|
771
|
+
except (OSError, _subprocess.SubprocessError):
|
|
772
|
+
return None
|
|
773
|
+
if out.returncode != 0:
|
|
774
|
+
return None
|
|
775
|
+
return [r for r in out.stdout.decode("utf-8", "replace").split("\0") if r]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _tsjs_collect_files(root, excludes):
|
|
779
|
+
rels = _tsjs_git_files(root)
|
|
780
|
+
if rels is None:
|
|
781
|
+
rels = []
|
|
782
|
+
for dp, dns, fns in os.walk(str(root)):
|
|
783
|
+
dns[:] = [d for d in dns if d not in _TSJS_SKIP_DIR_NAMES
|
|
784
|
+
and not d.startswith(".")]
|
|
785
|
+
rels.extend(os.path.relpath(os.path.join(dp, f), str(root))
|
|
786
|
+
for f in fns)
|
|
787
|
+
out = set()
|
|
788
|
+
for r in rels:
|
|
789
|
+
p = root / r
|
|
790
|
+
if p.suffix in _TSJS_EXTS and not _tsjs_should_skip(p, root, excludes) \
|
|
791
|
+
and p.is_file():
|
|
792
|
+
out.add(p)
|
|
793
|
+
return sorted(out)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
_TSJS_FUNC_TYPES = {"function_declaration", "generator_function_declaration",
|
|
797
|
+
"function_expression", "function", "generator_function",
|
|
798
|
+
"arrow_function", "method_definition"}
|
|
799
|
+
_TSJS_CLASS_TYPES = {"class_declaration", "class",
|
|
800
|
+
"abstract_class_declaration"}
|
|
801
|
+
_TSJS_UNWRAP = {"parenthesized_expression", "await_expression",
|
|
802
|
+
"non_null_expression", "as_expression",
|
|
803
|
+
"satisfies_expression", "type_assertion"}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class _TsjsFile:
|
|
807
|
+
def __init__(self, src):
|
|
808
|
+
self.src = src
|
|
809
|
+
|
|
810
|
+
def text(self, node):
|
|
811
|
+
return self.src[node.start_byte:node.end_byte].decode("utf-8",
|
|
812
|
+
"replace")
|
|
813
|
+
|
|
814
|
+
def literal(self, node):
|
|
815
|
+
"""String value of a string/template literal node, else None."""
|
|
816
|
+
if node is None:
|
|
817
|
+
return None
|
|
818
|
+
if node.type == "string":
|
|
819
|
+
return self.text(node)[1:-1]
|
|
820
|
+
if node.type == "template_string":
|
|
821
|
+
return self.text(node)[1:-1] # substitutions stay as ${...}
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
def chain(self, node):
|
|
825
|
+
"""Dotted callee chain, e.g. knex("t").where().del -> [knex(), where(), del]."""
|
|
826
|
+
while node is not None and node.type in _TSJS_UNWRAP:
|
|
827
|
+
kids = node.named_children
|
|
828
|
+
node = kids[-1] if node.type == "await_expression" and kids \
|
|
829
|
+
else (kids[0] if kids else None)
|
|
830
|
+
if node is None:
|
|
831
|
+
return ["?"]
|
|
832
|
+
t = node.type
|
|
833
|
+
if t in ("identifier", "property_identifier",
|
|
834
|
+
"private_property_identifier", "shorthand_property_identifier"):
|
|
835
|
+
return [self.text(node)]
|
|
836
|
+
if t == "this":
|
|
837
|
+
return ["this"]
|
|
838
|
+
if t == "member_expression":
|
|
839
|
+
prop = node.child_by_field_name("property")
|
|
840
|
+
return self.chain(node.child_by_field_name("object")) + \
|
|
841
|
+
[self.text(prop) if prop is not None else "?"]
|
|
842
|
+
if t == "call_expression":
|
|
843
|
+
c = self.chain(node.child_by_field_name("function"))
|
|
844
|
+
c[-1] = c[-1] + "()"
|
|
845
|
+
return c
|
|
846
|
+
return ["?"]
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _tsjs_func_name(node, f):
|
|
850
|
+
name = node.child_by_field_name("name")
|
|
851
|
+
if name is not None and node.type != "arrow_function":
|
|
852
|
+
return f.text(name)
|
|
853
|
+
parent = node.parent
|
|
854
|
+
if parent is None:
|
|
855
|
+
return None
|
|
856
|
+
pt = parent.type
|
|
857
|
+
if pt == "variable_declarator":
|
|
858
|
+
n = parent.child_by_field_name("name")
|
|
859
|
+
return f.text(n) if n is not None and n.type == "identifier" else None
|
|
860
|
+
if pt == "pair":
|
|
861
|
+
k = parent.child_by_field_name("key")
|
|
862
|
+
return (f.literal(k) or f.text(k)) if k is not None else None
|
|
863
|
+
if pt == "assignment_expression":
|
|
864
|
+
left = parent.child_by_field_name("left")
|
|
865
|
+
if left is not None and left.type == "member_expression":
|
|
866
|
+
left = left.child_by_field_name("property")
|
|
867
|
+
return f.text(left) if left is not None else None
|
|
868
|
+
if pt in ("public_field_definition", "field_definition"):
|
|
869
|
+
n = parent.child_by_field_name("name") or \
|
|
870
|
+
parent.child_by_field_name("property")
|
|
871
|
+
return f.text(n) if n is not None else None
|
|
872
|
+
return None
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _tsjs_module_prefix(source):
|
|
876
|
+
if source is None:
|
|
877
|
+
return None
|
|
878
|
+
if source.startswith("node:"):
|
|
879
|
+
source = source[5:]
|
|
880
|
+
return _TSJS_MODULES.get(source)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _tsjs_record_import(node, f, aliases):
|
|
884
|
+
src = node.child_by_field_name("source")
|
|
885
|
+
prefix = _tsjs_module_prefix(f.literal(src))
|
|
886
|
+
if prefix is None:
|
|
887
|
+
return
|
|
888
|
+
for clause in node.named_children:
|
|
889
|
+
if clause.type != "import_clause":
|
|
890
|
+
continue
|
|
891
|
+
for part in clause.named_children:
|
|
892
|
+
if part.type == "identifier": # default import
|
|
893
|
+
aliases[f.text(part)] = prefix
|
|
894
|
+
elif part.type == "namespace_import":
|
|
895
|
+
ids = [c for c in part.named_children if c.type == "identifier"]
|
|
896
|
+
if ids:
|
|
897
|
+
aliases[f.text(ids[-1])] = prefix
|
|
898
|
+
elif part.type == "named_imports":
|
|
899
|
+
for spec in part.named_children:
|
|
900
|
+
if spec.type != "import_specifier":
|
|
901
|
+
continue
|
|
902
|
+
ids = [c for c in spec.named_children
|
|
903
|
+
if c.type in ("identifier", "string")]
|
|
904
|
+
if not ids:
|
|
905
|
+
continue
|
|
906
|
+
imported = f.literal(ids[0]) or f.text(ids[0])
|
|
907
|
+
local = f.text(ids[-1])
|
|
908
|
+
if imported == "default":
|
|
909
|
+
aliases[local] = prefix
|
|
910
|
+
else:
|
|
911
|
+
aliases[local] = prefix + (imported,)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _tsjs_record_require(node, f, aliases):
|
|
915
|
+
"""const x = require("m") / const {a, b: c} = require("m") / .prop"""
|
|
916
|
+
value = node.child_by_field_name("value")
|
|
917
|
+
extra = ()
|
|
918
|
+
if value is not None and value.type == "member_expression":
|
|
919
|
+
prop = value.child_by_field_name("property")
|
|
920
|
+
extra = (f.text(prop),) if prop is not None else ()
|
|
921
|
+
value = value.child_by_field_name("object")
|
|
922
|
+
if value is None or value.type != "call_expression":
|
|
923
|
+
return
|
|
924
|
+
fn = value.child_by_field_name("function")
|
|
925
|
+
if fn is None or f.text(fn) != "require":
|
|
926
|
+
return
|
|
927
|
+
args = value.child_by_field_name("arguments")
|
|
928
|
+
first = args.named_children[0] if args is not None and \
|
|
929
|
+
args.named_children else None
|
|
930
|
+
prefix = _tsjs_module_prefix(f.literal(first))
|
|
931
|
+
if prefix is None:
|
|
932
|
+
return
|
|
933
|
+
prefix = prefix + extra
|
|
934
|
+
name = node.child_by_field_name("name")
|
|
935
|
+
if name is None:
|
|
936
|
+
return
|
|
937
|
+
if name.type == "identifier":
|
|
938
|
+
aliases[f.text(name)] = prefix
|
|
939
|
+
elif name.type == "object_pattern":
|
|
940
|
+
for p in name.named_children:
|
|
941
|
+
if p.type == "shorthand_property_identifier_pattern":
|
|
942
|
+
aliases[f.text(p)] = prefix + (f.text(p),)
|
|
943
|
+
elif p.type == "pair_pattern":
|
|
944
|
+
k = p.child_by_field_name("key")
|
|
945
|
+
v = p.child_by_field_name("value")
|
|
946
|
+
if k is not None and v is not None and v.type == "identifier":
|
|
947
|
+
aliases[f.text(v)] = prefix + (f.text(k),)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _tsjs_args(call):
|
|
951
|
+
args = call.child_by_field_name("arguments")
|
|
952
|
+
if args is None:
|
|
953
|
+
return []
|
|
954
|
+
if args.type == "template_string": # tagged template: sql`...`
|
|
955
|
+
return [args]
|
|
956
|
+
return [a for a in args.named_children if a.type != "comment"]
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _tsjs_has_write_method(call, f):
|
|
960
|
+
for a in _tsjs_args(call)[:2]:
|
|
961
|
+
lit = f.literal(a)
|
|
962
|
+
if lit is not None and lit.lower() in _TSJS_WRITE_METHODS:
|
|
963
|
+
return True
|
|
964
|
+
if a.type != "object":
|
|
965
|
+
continue
|
|
966
|
+
for pair in a.named_children:
|
|
967
|
+
if pair.type != "pair":
|
|
968
|
+
continue
|
|
969
|
+
k = pair.child_by_field_name("key")
|
|
970
|
+
key = (f.literal(k) or f.text(k)) if k is not None else ""
|
|
971
|
+
if key != "method":
|
|
972
|
+
continue
|
|
973
|
+
v = f.literal(pair.child_by_field_name("value"))
|
|
974
|
+
if v is not None and v.lower() in _TSJS_WRITE_METHODS:
|
|
975
|
+
return True
|
|
976
|
+
return False
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _tsjs_classify(call, chain, f):
|
|
980
|
+
for rid, kind, pats, cond in _TSJS_COMPILED:
|
|
981
|
+
if not any(_tsjs_glob(p, chain) for p in pats):
|
|
982
|
+
continue
|
|
983
|
+
if cond == "method" and not _tsjs_has_write_method(call, f):
|
|
984
|
+
continue
|
|
985
|
+
if cond == "sql":
|
|
986
|
+
args = _tsjs_args(call)
|
|
987
|
+
lit = f.literal(args[0]) if args else None
|
|
988
|
+
if lit is None or not _TSJS_SQL_WRITE.match(lit):
|
|
989
|
+
continue
|
|
990
|
+
return rid, kind
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _tsjs_scan_source(src, filename, parser):
|
|
995
|
+
"""Return (sites, gated) where sites = [(_WriteSite, rule_id)] and
|
|
996
|
+
gated = {(func_name, func_line)}. Module-level writes use func ""."""
|
|
997
|
+
f = _TsjsFile(src)
|
|
998
|
+
tree = parser.parse(src)
|
|
999
|
+
aliases, calls, gated = {}, [], set()
|
|
1000
|
+
stack = [(tree.root_node, ("", 0), "")]
|
|
1001
|
+
while stack:
|
|
1002
|
+
node, fkey, cls = stack.pop()
|
|
1003
|
+
t = node.type
|
|
1004
|
+
if t in _TSJS_FUNC_TYPES:
|
|
1005
|
+
name = _tsjs_func_name(node, f)
|
|
1006
|
+
if name:
|
|
1007
|
+
fkey = (name, node.start_point[0] + 1)
|
|
1008
|
+
elif t in _TSJS_CLASS_TYPES:
|
|
1009
|
+
n = node.child_by_field_name("name")
|
|
1010
|
+
cls = f.text(n) if n is not None else cls
|
|
1011
|
+
elif t == "import_statement":
|
|
1012
|
+
_tsjs_record_import(node, f, aliases)
|
|
1013
|
+
elif t == "variable_declarator":
|
|
1014
|
+
_tsjs_record_require(node, f, aliases)
|
|
1015
|
+
elif t == "call_expression":
|
|
1016
|
+
calls.append((node, fkey, cls))
|
|
1017
|
+
stack.extend((c, fkey, cls) for c in reversed(node.children))
|
|
1018
|
+
sites = []
|
|
1019
|
+
for node, fkey, cls in calls:
|
|
1020
|
+
raw = f.chain(node.child_by_field_name("function"))
|
|
1021
|
+
chain = [c[:-2] if c.endswith("()") else c for c in raw]
|
|
1022
|
+
if chain and chain[0] == "this":
|
|
1023
|
+
chain = chain[1:]
|
|
1024
|
+
if chain and chain[0] in aliases:
|
|
1025
|
+
chain = list(aliases[chain[0]]) + chain[1:]
|
|
1026
|
+
chain = [c.lower() for c in chain]
|
|
1027
|
+
if not chain:
|
|
1028
|
+
continue
|
|
1029
|
+
if chain[-1] in _TSJS_GATE_NAMES:
|
|
1030
|
+
gated.add(fkey)
|
|
1031
|
+
continue
|
|
1032
|
+
hit = _tsjs_classify(node, chain, f)
|
|
1033
|
+
if hit:
|
|
1034
|
+
snippet = " ".join(f.text(node).split())[:100]
|
|
1035
|
+
sites.append((_WriteSite(filename, fkey[0], cls,
|
|
1036
|
+
node.start_point[0] + 1, hit[1], snippet),
|
|
1037
|
+
hit[0], fkey))
|
|
1038
|
+
return sites, gated
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _tsjs_verb(rel, func_name, cls_name, kind):
|
|
1042
|
+
"""infer_verb for TS/JS: camelCase names, `orders.service.ts` -> orders."""
|
|
1043
|
+
snake = _re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", func_name)
|
|
1044
|
+
base = rel.with_name(rel.name.split(".")[0] + rel.suffix)
|
|
1045
|
+
return infer_verb(base, snake, cls_name, kind)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _tsjs_scan(root, excludes):
|
|
1049
|
+
"""Return dict with files, skipped, sites, gated, or hint when the
|
|
1050
|
+
extra is missing. Never raises on a bad file."""
|
|
1051
|
+
files = _tsjs_collect_files(root, excludes)
|
|
1052
|
+
res = {"files": files, "skipped": [], "sites": [], "gated": set(),
|
|
1053
|
+
"missing_extra": False}
|
|
1054
|
+
if not files:
|
|
1055
|
+
return res
|
|
1056
|
+
parsers = _tsjs_parsers()
|
|
1057
|
+
if not parsers:
|
|
1058
|
+
res["missing_extra"] = True
|
|
1059
|
+
return res
|
|
1060
|
+
for path in files:
|
|
1061
|
+
try:
|
|
1062
|
+
if path.stat().st_size > _TSJS_MAX_BYTES:
|
|
1063
|
+
res["skipped"].append(str(path))
|
|
1064
|
+
continue
|
|
1065
|
+
src = path.read_bytes()
|
|
1066
|
+
sites, gated = _tsjs_scan_source(
|
|
1067
|
+
src, str(path), parsers[_TSJS_EXTS[path.suffix]])
|
|
1068
|
+
except Exception:
|
|
1069
|
+
res["skipped"].append(str(path))
|
|
1070
|
+
continue
|
|
1071
|
+
res["sites"].extend(sites)
|
|
1072
|
+
res["gated"].update((str(path),) + g for g in gated)
|
|
1073
|
+
return res
|
|
1074
|
+
|
|
613
1075
|
def scan_cmd(args):
|
|
614
1076
|
import json as _json
|
|
615
1077
|
root = _pathlib.Path(args.path).resolve()
|
|
@@ -635,6 +1097,14 @@ def scan_cmd(args):
|
|
|
635
1097
|
sites.extend(v.sites)
|
|
636
1098
|
gated.update((str(path), n, ln) for (n, ln) in v.gated)
|
|
637
1099
|
|
|
1100
|
+
tsjs = _tsjs_scan(root, args.exclude or [])
|
|
1101
|
+
ts_sites = tsjs["sites"]
|
|
1102
|
+
ts_by_func = {}
|
|
1103
|
+
for s, _rid, fkey in ts_sites:
|
|
1104
|
+
ts_by_func.setdefault((s.file,) + fkey, []).append(s)
|
|
1105
|
+
ts_gated = sum(1 for s, _rid, fkey in ts_sites
|
|
1106
|
+
if (s.file,) + fkey in tsjs["gated"])
|
|
1107
|
+
|
|
638
1108
|
by_func = {}
|
|
639
1109
|
for s in sites:
|
|
640
1110
|
by_func.setdefault((s.file, s.func, s.cls), []).append(s)
|
|
@@ -649,6 +1119,10 @@ def scan_cmd(args):
|
|
|
649
1119
|
rel = _pathlib.Path(f).relative_to(root)
|
|
650
1120
|
verbs.setdefault(infer_verb(rel, func_name, cls_name, ss[0].kind),
|
|
651
1121
|
"require_grant")
|
|
1122
|
+
for (f, func_name, _ln), ss in sorted(ts_by_func.items()):
|
|
1123
|
+
rel = _pathlib.Path(f).relative_to(root)
|
|
1124
|
+
verbs.setdefault(_tsjs_verb(rel, func_name, ss[0].cls, ss[0].kind),
|
|
1125
|
+
"require_grant")
|
|
652
1126
|
|
|
653
1127
|
if args.score:
|
|
654
1128
|
print_score_report(root, sites, gated_funcs)
|
|
@@ -658,6 +1132,22 @@ def scan_cmd(args):
|
|
|
658
1132
|
print(" files scanned: %d skipped: %d" % (len(files), len(skipped)))
|
|
659
1133
|
print(" write sites: %d in %d function(s)" % (total, len(by_func)))
|
|
660
1134
|
print(" gated: %d/%d (%d%%)" % (gated_site_count, total, pct))
|
|
1135
|
+
if tsjs["missing_extra"]:
|
|
1136
|
+
print(" " + _TSJS_HINT % len(tsjs["files"]))
|
|
1137
|
+
elif tsjs["files"]:
|
|
1138
|
+
ts_total = len(ts_sites)
|
|
1139
|
+
print(" ts/js files scanned: %d skipped: %d (scan only, no --apply)"
|
|
1140
|
+
% (len(tsjs["files"]), len(tsjs["skipped"])))
|
|
1141
|
+
print(" ts/js write sites: %d in %d function(s)"
|
|
1142
|
+
% (ts_total, len(ts_by_func)))
|
|
1143
|
+
print(" ts/js gated: %d/%d (%d%%)" % (
|
|
1144
|
+
ts_gated, ts_total,
|
|
1145
|
+
(100 * ts_gated // ts_total) if ts_total else 100))
|
|
1146
|
+
for s, rid, _fkey in sorted(ts_sites, key=lambda x: (x[0].file,
|
|
1147
|
+
x[0].lineno)):
|
|
1148
|
+
print(" %s:%d [%s] %s %s(): %s" % (
|
|
1149
|
+
_pathlib.Path(s.file).relative_to(root), s.lineno, s.kind,
|
|
1150
|
+
rid, s.func or "<module>", s.snippet))
|
|
661
1151
|
if verbs:
|
|
662
1152
|
print(" verbs discovered: %d" % len(verbs))
|
|
663
1153
|
for vname in sorted(verbs):
|
|
@@ -692,7 +1182,11 @@ def scan_cmd(args):
|
|
|
692
1182
|
print(" manual: %s %s() [%s]: %s" % (path, func_name, verb, why))
|
|
693
1183
|
|
|
694
1184
|
if not results:
|
|
695
|
-
|
|
1185
|
+
if ts_sites:
|
|
1186
|
+
print("nothing to instrument (ts/js findings are scan-only; "
|
|
1187
|
+
"gate them by hand).")
|
|
1188
|
+
else:
|
|
1189
|
+
print("nothing to instrument.")
|
|
696
1190
|
return 0
|
|
697
1191
|
|
|
698
1192
|
total_gates = sum(r[3] for r in results)
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pywrit
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Python client + CLI for Writ — the gate before the write
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/AvenueDAdmin/pywrit
|
|
7
|
+
Project-URL: Documentation, https://withwrit.com/docs
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: polyglot
|
|
13
|
+
Requires-Dist: tree-sitter>=0.23; extra == "polyglot"
|
|
14
|
+
Requires-Dist: tree-sitter-typescript>=0.23; extra == "polyglot"
|
|
15
|
+
Requires-Dist: tree-sitter-javascript>=0.23; extra == "polyglot"
|
|
16
|
+
Provides-Extra: keywords
|
|
17
|
+
Requires-Dist: ai-agents; extra == "keywords"
|
|
18
|
+
Requires-Dist: policy; extra == "keywords"
|
|
19
|
+
Requires-Dist: audit-log; extra == "keywords"
|
|
20
|
+
Requires-Dist: guardrails; extra == "keywords"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
1
23
|
# pywrit
|
|
2
24
|
|
|
3
25
|
**Find the dangerous writes in your Python agent's code, then gate it.** `pywrit` is the Python
|
|
@@ -100,13 +122,25 @@ Python (`.py`) files, parsed with the stdlib `ast` (no extra dependencies):
|
|
|
100
122
|
calls such as `stripe.Refund.create(...)`, deferred task queues, or shared clients
|
|
101
123
|
several layers down. The risk report lists these gaps every time.
|
|
102
124
|
|
|
103
|
-
### TypeScript / JavaScript
|
|
125
|
+
### TypeScript / JavaScript (scan-only)
|
|
126
|
+
|
|
127
|
+
`writ scan` also finds write sites in TypeScript and JavaScript. It needs the
|
|
128
|
+
optional extra (tree-sitter based):
|
|
129
|
+
|
|
130
|
+
pip install 'pywrit[polyglot]'
|
|
131
|
+
writ scan .
|
|
132
|
+
|
|
133
|
+
Without the extra, the scanner prints a one-line hint and keeps going —
|
|
134
|
+
Python scanning never needs it.
|
|
104
135
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
TS/JS is **scan-only**: findings are listed with verbs for your policy file,
|
|
137
|
+
but `writ scan --apply` never rewrites TS/JS files — gate those by hand.
|
|
138
|
+
Covered patterns: `fetch`/`axios` writes, `fs` writes, SQL through knex-style
|
|
139
|
+
clients, Prisma writes, and JS SDK calls such as `stripe.refunds.create(...)`.
|
|
140
|
+
(Still not covered: the *Python* SDK equivalents like `stripe.Refund.create(...)`
|
|
141
|
+
— see "Not covered" above.)
|
|
108
142
|
|
|
109
|
-

|
|
110
144
|
|
|
111
145
|
## Python client
|
|
112
146
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic tests for `writ scan` TypeScript/JavaScript support.
|
|
3
|
+
|
|
4
|
+
Fixtures live in tests/fixtures/tsjs. Every line tagged `// expect: <rule>`
|
|
5
|
+
must be reported with that rule, and nothing else may be reported.
|
|
6
|
+
Tests that parse TS/JS skip when the optional `polyglot` extra
|
|
7
|
+
(tree-sitter) is not installed; the rest run with zero extra deps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
24
|
+
sys.path.insert(0, str(HERE.parent / "src"))
|
|
25
|
+
|
|
26
|
+
from pywrit import cli # noqa: E402
|
|
27
|
+
|
|
28
|
+
FIXTURES = HERE / "fixtures" / "tsjs"
|
|
29
|
+
HAVE_EXTRA = bool(cli._tsjs_parsers())
|
|
30
|
+
_BLOCK_EXTRA = {m: None for m in ("tree_sitter", "tree_sitter_typescript",
|
|
31
|
+
"tree_sitter_javascript",
|
|
32
|
+
"tree_sitter_language_pack")}
|
|
33
|
+
_EXPECT = re.compile(r"//\s*expect:\s*([a-z0-9-]+)")
|
|
34
|
+
_FINDING = re.compile(
|
|
35
|
+
r"^ (?P<file>\S+):(?P<line>\d+) \[(?P<kind>[a-z]+)\] "
|
|
36
|
+
r"(?P<rule>[a-z0-9-]+) (?P<func>\S+)\(\): (?P<snippet>.+)$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _args(path, policy_out, **kw):
|
|
40
|
+
ns = argparse.Namespace(path=str(path), exclude=[], policy_out=str(policy_out),
|
|
41
|
+
apply=False, yes=False, score=False, push_policy=False,
|
|
42
|
+
key="")
|
|
43
|
+
for k, v in kw.items():
|
|
44
|
+
setattr(ns, k, v)
|
|
45
|
+
return ns
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _run_scan(path, **kw):
|
|
49
|
+
"""Run scan_cmd in-process; return (exit code, stdout)."""
|
|
50
|
+
with tempfile.TemporaryDirectory() as td:
|
|
51
|
+
buf = io.StringIO()
|
|
52
|
+
with contextlib.redirect_stdout(buf):
|
|
53
|
+
code = cli.scan_cmd(_args(path, pathlib.Path(td) / "policy.json", **kw))
|
|
54
|
+
return code, buf.getvalue()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _expected():
|
|
58
|
+
exp = set()
|
|
59
|
+
for p in sorted(FIXTURES.rglob("*")):
|
|
60
|
+
if p.suffix not in cli._TSJS_EXTS:
|
|
61
|
+
continue
|
|
62
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
63
|
+
m = _EXPECT.search(line)
|
|
64
|
+
if m:
|
|
65
|
+
exp.add((str(p.relative_to(FIXTURES)), i, m.group(1)))
|
|
66
|
+
return exp
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _findings(out):
|
|
70
|
+
return {(m.group("file"), int(m.group("line")), m.group("rule"))
|
|
71
|
+
for m in map(_FINDING.match, out.splitlines()) if m}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _copy_fixtures(dst):
|
|
75
|
+
shutil.copytree(str(FIXTURES), str(dst))
|
|
76
|
+
return dst
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FixtureRulesTest(unittest.TestCase):
|
|
80
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
81
|
+
def test_every_expected_finding_and_nothing_else(self):
|
|
82
|
+
with tempfile.TemporaryDirectory() as td:
|
|
83
|
+
root = _copy_fixtures(pathlib.Path(td) / "repo") # outside git
|
|
84
|
+
code, out = _run_scan(root)
|
|
85
|
+
self.assertEqual(code, 0)
|
|
86
|
+
self.assertEqual(_findings(out), _expected(), out)
|
|
87
|
+
|
|
88
|
+
def test_every_rule_has_a_fixture(self):
|
|
89
|
+
covered = {r for (_f, _l, r) in _expected()}
|
|
90
|
+
self.assertEqual(covered, {r[0] for r in cli._TSJS_RULES})
|
|
91
|
+
|
|
92
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
93
|
+
def test_negative_reads_not_flagged(self):
|
|
94
|
+
with tempfile.TemporaryDirectory() as td:
|
|
95
|
+
root = pathlib.Path(td)
|
|
96
|
+
shutil.copy(str(FIXTURES / "reads.tsx"), str(root / "reads.tsx"))
|
|
97
|
+
code, out = _run_scan(root)
|
|
98
|
+
self.assertEqual(code, 0)
|
|
99
|
+
self.assertIn("ts/js files scanned: 1 skipped: 0", out)
|
|
100
|
+
self.assertIn("ts/js write sites: 0 in 0 function(s)", out)
|
|
101
|
+
self.assertEqual(_findings(out), set())
|
|
102
|
+
self.assertIn("nothing to instrument.", out)
|
|
103
|
+
|
|
104
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
105
|
+
def test_output_format_counts_gating_and_verbs(self):
|
|
106
|
+
with tempfile.TemporaryDirectory() as td:
|
|
107
|
+
root = _copy_fixtures(pathlib.Path(td) / "repo")
|
|
108
|
+
policy = pathlib.Path(td) / "p.json"
|
|
109
|
+
buf = io.StringIO()
|
|
110
|
+
with contextlib.redirect_stdout(buf):
|
|
111
|
+
code = cli.scan_cmd(_args(root, policy))
|
|
112
|
+
out = buf.getvalue()
|
|
113
|
+
verbs = __import__("json").loads(policy.read_text())
|
|
114
|
+
self.assertEqual(code, 0)
|
|
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)
|
|
118
|
+
self.assertIn("ts/js gated: 1/%d" % n, out) # gated.ts calls writCheck
|
|
119
|
+
self.assertIn(" payments/refunds.service.ts:10 [sdk] stripe-write "
|
|
120
|
+
"issueRefund(): stripe.refunds.create({ charge: chargeId })", out)
|
|
121
|
+
self.assertIn(" http.ts:10 [http] fetch-write createOrder(): fetch(url, "
|
|
122
|
+
"{ method: \"POST\", body: JSON.stringify(body) })", out)
|
|
123
|
+
for v in ("payments.refund", "payments.record", "payments.export",
|
|
124
|
+
"storage.save", "storage.create", "http.create", "legacy.write"):
|
|
125
|
+
self.assertEqual(verbs.get(v), "require_grant", v)
|
|
126
|
+
self.assertIn("nothing to instrument (ts/js findings are scan-only", out)
|
|
127
|
+
|
|
128
|
+
@unittest.skipUnless(HAVE_EXTRA, "polyglot extra not installed")
|
|
129
|
+
def test_apply_never_rewrites_tsjs_but_still_gates_python(self):
|
|
130
|
+
with tempfile.TemporaryDirectory() as td:
|
|
131
|
+
root = pathlib.Path(td) / "repo"
|
|
132
|
+
(root / "orders").mkdir(parents=True)
|
|
133
|
+
ts = root / "orders" / "api.ts"
|
|
134
|
+
ts.write_text('export async function createOrder(u: string) {\n'
|
|
135
|
+
' await fetch(u, { method: "POST" });\n}\n')
|
|
136
|
+
py = root / "orders" / "store.py"
|
|
137
|
+
py.write_text('def create_order():\n'
|
|
138
|
+
' with open("/tmp/o", "w") as f:\n'
|
|
139
|
+
' f.write("o")\n')
|
|
140
|
+
before = ts.read_bytes()
|
|
141
|
+
code, out = _run_scan(root, apply=True, yes=True)
|
|
142
|
+
self.assertEqual(code, 0)
|
|
143
|
+
self.assertEqual(ts.read_bytes(), before)
|
|
144
|
+
self.assertIn("_writ_check(\"orders.create\")", py.read_text())
|
|
145
|
+
self.assertIn("orders/api.ts:2 [http] fetch-write createOrder()", out)
|
|
146
|
+
self.assertIn("applied 1 gate(s) to 1 file(s).", out)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class FileWalkTest(unittest.TestCase):
|
|
150
|
+
"""File collection is stdlib-only, so these run with or without the extra."""
|
|
151
|
+
|
|
152
|
+
WRITE = 'export function f(){ return fetch("/x", { method: "POST" }); }\n'
|
|
153
|
+
|
|
154
|
+
def _tree(self, root, rels):
|
|
155
|
+
for rel in rels:
|
|
156
|
+
p = root / rel
|
|
157
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
p.write_text(self.WRITE)
|
|
159
|
+
|
|
160
|
+
def test_skip_dirs_and_test_files(self):
|
|
161
|
+
with tempfile.TemporaryDirectory() as td:
|
|
162
|
+
root = pathlib.Path(td)
|
|
163
|
+
skipped = ["node_modules/pkg/index.js", "dist/app.js", "build/app.js",
|
|
164
|
+
".next/server/page.js", ".git/hooks/x.js",
|
|
165
|
+
"vendor/lib.js", "venv/lib/x.js", ".venv/x.js",
|
|
166
|
+
"src/__tests__/a.ts", "tests/a.ts", "src/a.test.ts",
|
|
167
|
+
"src/a.spec.tsx", "types/a.d.ts", "public/app.min.js",
|
|
168
|
+
"src/readme.md", "src/a.py"]
|
|
169
|
+
kept = ["src/a.ts", "src/b.tsx", "src/c.js", "src/d.jsx",
|
|
170
|
+
"src/e.mjs", "src/f.cjs", "lib/distance.ts"]
|
|
171
|
+
self._tree(root, skipped + kept)
|
|
172
|
+
got = [str(p.relative_to(root))
|
|
173
|
+
for p in cli._tsjs_collect_files(root, [])]
|
|
174
|
+
self.assertEqual(sorted(got), sorted(kept))
|
|
175
|
+
|
|
176
|
+
def test_exclude_substring(self):
|
|
177
|
+
with tempfile.TemporaryDirectory() as td:
|
|
178
|
+
root = pathlib.Path(td)
|
|
179
|
+
self._tree(root, ["src/a.ts", "generated/b.ts"])
|
|
180
|
+
got = [str(p.relative_to(root))
|
|
181
|
+
for p in cli._tsjs_collect_files(root, ["generated"])]
|
|
182
|
+
self.assertEqual(got, ["src/a.ts"])
|
|
183
|
+
|
|
184
|
+
@unittest.skipUnless(shutil.which("git"), "git not installed")
|
|
185
|
+
def test_respects_gitignore_inside_git_repo(self):
|
|
186
|
+
with tempfile.TemporaryDirectory() as td:
|
|
187
|
+
root = pathlib.Path(td)
|
|
188
|
+
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
189
|
+
(root / ".gitignore").write_text("generated/\n*.gen.ts\n")
|
|
190
|
+
self._tree(root, ["src/a.ts", "generated/b.ts", "src/c.gen.ts",
|
|
191
|
+
"node_modules/x/y.js"])
|
|
192
|
+
got = [str(p.relative_to(root))
|
|
193
|
+
for p in cli._tsjs_collect_files(root, [])]
|
|
194
|
+
self.assertEqual(got, ["src/a.ts"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class MissingExtraTest(unittest.TestCase):
|
|
198
|
+
def test_hint_once_and_python_still_scanned(self):
|
|
199
|
+
with tempfile.TemporaryDirectory() as td:
|
|
200
|
+
root = pathlib.Path(td)
|
|
201
|
+
(root / "web").mkdir()
|
|
202
|
+
(root / "web" / "a.ts").write_text(FileWalkTest.WRITE)
|
|
203
|
+
(root / "web" / "b.js").write_text(FileWalkTest.WRITE)
|
|
204
|
+
(root / "orders.py").write_text(
|
|
205
|
+
'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
|
|
206
|
+
with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
|
|
207
|
+
self.assertEqual(cli._tsjs_parsers(), {})
|
|
208
|
+
code, out = _run_scan(root)
|
|
209
|
+
self.assertEqual(code, 0)
|
|
210
|
+
hints = [l for l in out.splitlines() if "hint:" in l]
|
|
211
|
+
self.assertEqual(hints, [" hint: 2 TS/JS file(s) not scanned; install "
|
|
212
|
+
"the extra: pip install 'pywrit[polyglot]'"])
|
|
213
|
+
self.assertIn("write sites: 1 in 1 function(s)", out)
|
|
214
|
+
self.assertIn("orders.create", out)
|
|
215
|
+
self.assertNotIn("ts/js files scanned", out)
|
|
216
|
+
|
|
217
|
+
def test_python_only_repo_output_unchanged(self):
|
|
218
|
+
with tempfile.TemporaryDirectory() as td:
|
|
219
|
+
root = pathlib.Path(td)
|
|
220
|
+
(root / "orders.py").write_text(
|
|
221
|
+
'def create_order(db):\n db.execute("INSERT INTO o VALUES (1)")\n')
|
|
222
|
+
with mock.patch.dict(sys.modules, _BLOCK_EXTRA):
|
|
223
|
+
code, out = _run_scan(root)
|
|
224
|
+
self.assertEqual(code, 0)
|
|
225
|
+
self.assertNotIn("hint:", out)
|
|
226
|
+
self.assertNotIn("ts/js", out)
|
|
227
|
+
self.assertTrue(out.startswith("writ scan: %s\n files scanned: 1 skipped: 0\n"
|
|
228
|
+
" write sites: 1 in 1 function(s)\n"
|
|
229
|
+
" gated: 0/1 (0%%)\n verbs discovered: 1\n"
|
|
230
|
+
" orders.create\n" % root.resolve()), out)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class CliSyncTest(unittest.TestCase):
|
|
234
|
+
def test_download_copy_is_byte_identical(self):
|
|
235
|
+
web = HERE.parent.parent / "gate" / "web" / "writ"
|
|
236
|
+
if not web.exists():
|
|
237
|
+
self.skipTest("gate/web/writ not present")
|
|
238
|
+
self.assertEqual(web.read_bytes(),
|
|
239
|
+
(HERE.parent / "src" / "pywrit" / "cli.py").read_bytes())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
unittest.main(verbosity=2)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
httpx>=0.27
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|