permitprobe 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """PermitProbe: explicit boundaries, reproducible checks, scoped conclusions."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,3 @@
1
+ from permitprobe.cli import main
2
+
3
+ raise SystemExit(main())
permitprobe/api.py ADDED
@@ -0,0 +1,266 @@
1
+ """Overstep plans/classifies; PermitProbe adds strict delivery and data contracts.
2
+
3
+ Only the compiled GET-only REST subset is accepted. No upstream fixtures, auth
4
+ providers, MCP commands, waivers, or raw upstream reports are executed/written.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ from importlib.metadata import version
11
+
12
+ import httpx
13
+ from jsonschema import Draft202012Validator
14
+ from overstep.matrix import Matrix
15
+ from overstep.models import Effect, Observation
16
+ from overstep.pipeline import run_pipeline
17
+ from overstep.planner import plan
18
+
19
+ from permitprobe.policy import API
20
+ from permitprobe.report import Report
21
+
22
+ OVERSTEP_VERSION = "1.5.0"
23
+ DENIAL_STATUSES = {401, 403, 404}
24
+
25
+
26
+ async def fetch(url: str, headers: dict[str, str], config: API) -> tuple[int, str]:
27
+ # Absolute deadline includes connect, headers, and a trickling response body.
28
+ # A per-read socket timeout alone cannot bound a slow-drip server.
29
+ async with asyncio.timeout(config.timeout_seconds):
30
+ # New client per case: cookies cannot cross identity boundaries.
31
+ async with httpx.AsyncClient(
32
+ trust_env=False, follow_redirects=False, timeout=config.timeout_seconds
33
+ ) as client:
34
+ async with client.stream("GET", url, headers=headers) as response:
35
+ if response.headers.get("content-encoding", "identity") != "identity":
36
+ raise ValueError("encoded response")
37
+ body = bytearray()
38
+ async for chunk in response.aiter_raw():
39
+ if len(body) + len(chunk) > config.max_response_bytes:
40
+ raise ValueError("response budget exceeded")
41
+ body.extend(chunk)
42
+ return response.status_code, body.decode("utf-8")
43
+
44
+
45
+ def compile_matrix(config: API, *, resolve_tokens: bool = False) -> dict:
46
+ subjects = []
47
+ seen_tokens = set()
48
+ for s in config.subjects:
49
+ token = None
50
+ if s.token_env:
51
+ token = "${" + s.token_env + "}"
52
+ if resolve_tokens:
53
+ token = os.environ.get(s.token_env, "")
54
+ if (
55
+ not token
56
+ or token in seen_tokens
57
+ or len(token) > 8192
58
+ or any(ord(c) < 33 or ord(c) > 126 for c in token)
59
+ ):
60
+ raise ValueError("missing, duplicated, or invalid subject credential")
61
+ seen_tokens.add(token)
62
+ subjects.append(
63
+ {
64
+ "name": s.name,
65
+ "role": s.role,
66
+ "token": token,
67
+ "attributes": s.attributes,
68
+ "marker": s.marker,
69
+ }
70
+ )
71
+ resources = []
72
+ for r in config.resources:
73
+ item = {"name": r.name, "type": r.kind, "request": {"method": "GET", "path": r.path}}
74
+ if r.kind == "object":
75
+ item.update(owner=r.owner_param, owner_attr=r.owner_attr)
76
+ resources.append(item)
77
+ return {
78
+ "roles": list(dict.fromkeys(["anonymous", *[s.role for s in config.subjects]])),
79
+ "modules": {"rest": {"base_url": config.base_url}},
80
+ "subjects": subjects,
81
+ "resources": resources,
82
+ "policy": {r.name: {"allow": [a.model_dump() for a in r.allow]} for r in config.resources},
83
+ }
84
+
85
+
86
+ def pointer_value(data, pointer: str):
87
+ value = data
88
+ for key in pointer[1:].split("/"):
89
+ key = key.replace("~1", "/").replace("~0", "~")
90
+ if isinstance(value, list):
91
+ if not key.isdecimal():
92
+ raise KeyError(key)
93
+ value = value[int(key)]
94
+ elif isinstance(value, dict):
95
+ value = value[key]
96
+ else:
97
+ raise KeyError(key)
98
+ return value
99
+
100
+
101
+ def check_api(config: API, report: Report) -> None:
102
+ report.configured.extend(["api", "data"])
103
+ report.engines["overstep"] = version("overstep")
104
+ if report.engines["overstep"] != OVERSTEP_VERSION:
105
+ report.add("api.engine_version", "inconclusive", "api", "Untested Overstep version.")
106
+ return
107
+ try:
108
+ matrix = Matrix.model_validate(compile_matrix(config, resolve_tokens=True))
109
+ cases = plan(matrix)
110
+ except Exception:
111
+ report.add(
112
+ "api.configuration",
113
+ "inconclusive",
114
+ "api",
115
+ "Cannot compile matrix; check distinct credentials and resource declarations.",
116
+ )
117
+ return
118
+ if not cases or len(cases) > config.max_cases or not any(c.is_negative for c in cases):
119
+ report.add(
120
+ "api.coverage",
121
+ "inconclusive",
122
+ "api",
123
+ "Expected a bounded plan containing both allowed and denied requests.",
124
+ )
125
+ return
126
+ # A public endpoint cannot establish that an authenticated credential works.
127
+ anonymous = next(s.name for s in config.subjects if s.role == "anonymous")
128
+ protected = {
129
+ r.name for r in config.resources if not any(a.role == "anonymous" for a in r.allow)
130
+ }
131
+ for s in config.subjects:
132
+ if s.name != anonymous and not any(
133
+ c.subject == s.name and c.is_positive_control and c.resource in protected for c in cases
134
+ ):
135
+ report.add(
136
+ "api.positive_control",
137
+ "inconclusive",
138
+ s.name,
139
+ "Each authenticated subject needs an allowed request on a protected resource.",
140
+ )
141
+ return
142
+ resources = {r.name: r for r in config.resources}
143
+ subjects = {s.name: s for s in config.subjects}
144
+ validators = {r.name: Draft202012Validator(r.response_schema) for r in config.resources}
145
+ denial_validators = {r.name: Draft202012Validator(r.denial_schema) for r in config.resources}
146
+
147
+ def executor(base_url, engine_subjects, planned, **_):
148
+ tokens = {s.name: s.token for s in engine_subjects}
149
+ observations = []
150
+ for case in planned:
151
+ target = f"{case.resource}/{case.subject}/{case.variant.value}"
152
+ headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
153
+ if tokens[case.subject]:
154
+ headers["Authorization"] = "Bearer " + tokens[case.subject]
155
+ try:
156
+ status, text = asyncio.run(fetch(base_url.rstrip("/") + case.path, headers, config))
157
+ except Exception:
158
+ report.add(
159
+ "api.delivery",
160
+ "inconclusive",
161
+ target,
162
+ "Request failed, response encoding unsupported, or budget exceeded.",
163
+ )
164
+ observations.append(
165
+ Observation(
166
+ test_id=case.id, status=0, effect=Effect.DENY, error="delivery failed"
167
+ )
168
+ )
169
+ continue
170
+ effect = Effect.ALLOW if 200 <= status < 300 else Effect.DENY
171
+ observations.append(
172
+ Observation(
173
+ test_id=case.id,
174
+ status=status,
175
+ effect=effect,
176
+ matched_markers=[m for m in case.expect_markers if m and m in text],
177
+ )
178
+ )
179
+ if not 200 <= status < 300 and status not in DENIAL_STATUSES:
180
+ report.add(
181
+ "api.unexpected_status",
182
+ "inconclusive",
183
+ target,
184
+ f"HTTP {status} does not establish an access-control decision.",
185
+ )
186
+ continue
187
+ if case.expected == effect:
188
+ report.add(
189
+ "api.authorization",
190
+ "pass",
191
+ target,
192
+ f"Expected {case.expected.value}; received HTTP {status}.",
193
+ )
194
+ elif case.expected == Effect.ALLOW:
195
+ report.add(
196
+ "api.positive_control",
197
+ "inconclusive",
198
+ target,
199
+ f"Allowed control returned HTTP {status}; identity is unverified.",
200
+ )
201
+ # Unexpected allows are classified by Overstep below.
202
+ try:
203
+ data = json.loads(
204
+ text, parse_constant=lambda _: (_ for _ in ()).throw(ValueError())
205
+ )
206
+ except (ValueError, RecursionError):
207
+ report.add(
208
+ "data.json",
209
+ "inconclusive",
210
+ target,
211
+ "Response is not valid JSON; fields were not verified.",
212
+ )
213
+ continue
214
+ validator = (validators if effect == Effect.ALLOW else denial_validators)[case.resource]
215
+ errors = list(validator.iter_errors(data))
216
+ schema_code = "data.schema" if effect == Effect.ALLOW else "data.denial_schema"
217
+ if errors:
218
+ keywords = ", ".join(sorted({str(e.validator) for e in errors}))
219
+ report.add(
220
+ schema_code,
221
+ "fail",
222
+ target,
223
+ f"Response violates declared schema ({keywords}); values omitted.",
224
+ )
225
+ else:
226
+ report.add(schema_code, "pass", target, "Response matches declared schema.")
227
+ resource = resources[case.resource]
228
+ if (
229
+ resource.kind == "object"
230
+ and case.expected == Effect.ALLOW
231
+ and effect == Effect.ALLOW
232
+ ):
233
+ owner = subjects[case.victim or case.subject]
234
+ try:
235
+ actual = pointer_value(data, resource.identity_pointer)
236
+ valid_identity = actual == owner.attributes[resource.owner_attr]
237
+ except (KeyError, IndexError, TypeError, ValueError):
238
+ valid_identity = False
239
+ report.add(
240
+ "api.object_control",
241
+ "pass" if valid_identity else "inconclusive",
242
+ target,
243
+ "Expected object identity returned."
244
+ if valid_identity
245
+ else "Allowed control did not return its declared object identity.",
246
+ )
247
+ return observations
248
+
249
+ try:
250
+ result = run_pipeline(
251
+ matrix, executor=executor, concurrency=1, read_only=True, max_retries=0
252
+ )
253
+ except Exception:
254
+ report.add("api.engine", "inconclusive", "api", "Overstep did not complete the run.")
255
+ return
256
+ if result.coverage.unprobed:
257
+ report.add("api.coverage", "inconclusive", "api", "Some object boundaries were not probed.")
258
+ if len(result.observations) != len(cases):
259
+ report.add("api.coverage", "inconclusive", "api", "Some planned observations are missing.")
260
+ for finding in result.vulnerabilities:
261
+ report.add(
262
+ "api." + finding.vuln_class.value,
263
+ "fail",
264
+ f"{finding.resource}/{finding.subject}/{finding.variant.value}",
265
+ f"Expected denial; HTTP {finding.status}. Evidence: {finding.confidence}.",
266
+ )
permitprobe/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Public CLI. Inspect, test and bundle; never deploy, upload or alter a target."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from permitprobe import __version__
8
+ from permitprobe.api import check_api, compile_matrix
9
+ from permitprobe.demo import example_policy, run_demo
10
+ from permitprobe.handoff import check_handoff, write_bundle
11
+ from permitprobe.policy import Policy, PolicyError, load_policy
12
+ from permitprobe.report import Report
13
+
14
+
15
+ def emit(report: Report, fmt: str, output: str | None = None) -> int:
16
+ payload = report.to_dict()
17
+ if output:
18
+ try:
19
+ with Path(output).open("x", encoding="utf-8") as f:
20
+ json.dump(payload, f, indent=2)
21
+ f.write("\n")
22
+ except OSError:
23
+ report.add(
24
+ "report.write",
25
+ "inconclusive",
26
+ "report",
27
+ "Cannot create report; no file overwritten.",
28
+ )
29
+ payload = report.to_dict()
30
+ if fmt == "json":
31
+ print(json.dumps(payload, indent=2))
32
+ else:
33
+ c = payload["counts"]
34
+ print(
35
+ f"PermitProbe {payload['status'].upper()}: "
36
+ f"{c['pass']} passed, {c['fail']} failed, {c['inconclusive']} inconclusive"
37
+ )
38
+ for item in report.checks:
39
+ if item.outcome != "pass":
40
+ print(f" {item.outcome.upper()} {item.code} [{item.target}]: {item.detail}")
41
+ if payload["unconfigured_surfaces"]:
42
+ print(" Not configured: " + ", ".join(payload["unconfigured_surfaces"]))
43
+ return report.exit_code
44
+
45
+
46
+ def parser() -> argparse.ArgumentParser:
47
+ root = argparse.ArgumentParser(
48
+ description="Check declared API, data and AI handoff boundaries."
49
+ )
50
+ root.add_argument("--version", action="version", version=__version__)
51
+ commands = root.add_subparsers(dest="command", required=True)
52
+ for name in ("check", "bundle"):
53
+ p = commands.add_parser(name)
54
+ p.add_argument("policy", type=Path)
55
+ p.add_argument("--gitleaks", help="Path to Gitleaks 8.30.1; defaults to PATH lookup")
56
+ p.add_argument("--format", choices=("text", "json"), default="text")
57
+ p.add_argument("--report", help="Create a new JSON report (never overwrite)")
58
+ if name == "bundle":
59
+ p.add_argument(
60
+ "--output", required=True, type=Path, help="Create a checked ZIP locally"
61
+ )
62
+ demo = commands.add_parser("demo", help="Run synthetic fixtures on loopback")
63
+ demo.add_argument(
64
+ "--scenario",
65
+ choices=("safe", "leaky", "schema-leak", "expired", "server-error"),
66
+ default="safe",
67
+ )
68
+ demo.add_argument("--gitleaks")
69
+ demo.add_argument("--format", choices=("text", "json"), default="text")
70
+ demo.add_argument("--report")
71
+ init = commands.add_parser("init", help="Create a starter in a NEW directory")
72
+ init.add_argument("directory", type=Path)
73
+ export = commands.add_parser(
74
+ "export-overstep", help="Export an auth-only matrix, without secrets"
75
+ )
76
+ export.add_argument("policy", type=Path)
77
+ export.add_argument("--output", required=True, type=Path)
78
+ commands.add_parser("schema", help="Print the policy JSON Schema")
79
+ return root
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ args = parser().parse_args(argv)
84
+ try:
85
+ if args.command == "schema":
86
+ print(json.dumps(Policy.model_json_schema(), indent=2))
87
+ return 0
88
+ if args.command == "init":
89
+ args.directory.mkdir(parents=True, exist_ok=False)
90
+ (args.directory / "permitprobe.json").write_text(
91
+ json.dumps(example_policy(), indent=2) + "\n"
92
+ )
93
+ (args.directory / "review.txt").write_text(
94
+ "Replace with the text you intend to hand off.\n"
95
+ )
96
+ print(
97
+ "Starter created. Set your staging origin, object IDs, response schema and token environment variables."
98
+ )
99
+ return 0
100
+ if args.command == "demo":
101
+ return emit(run_demo(args.scenario, args.gitleaks), args.format, args.report)
102
+ policy = load_policy(args.policy)
103
+ if args.command == "export-overstep":
104
+ if not policy.api:
105
+ raise PolicyError("policy has no API surface")
106
+ with args.output.open("x", encoding="utf-8") as f:
107
+ json.dump(compile_matrix(policy.api), f, indent=2)
108
+ f.write("\n")
109
+ print(
110
+ "Auth-only matrix exported. PermitProbe data/control checks are not part of this export."
111
+ )
112
+ return 0
113
+ report = Report()
114
+ snapshot = {}
115
+ if args.command == "check" and policy.api:
116
+ check_api(policy.api, report)
117
+ if policy.handoff:
118
+ snapshot = check_handoff(policy.handoff, args.policy.parent, report, args.gitleaks)
119
+ elif args.command == "bundle":
120
+ raise PolicyError("policy has no handoff surface")
121
+ if args.command == "bundle" and report.exit_code == 0:
122
+ try:
123
+ write_bundle(snapshot, args.output)
124
+ report.add(
125
+ "handoff.bundle",
126
+ "pass",
127
+ "handoff",
128
+ "Checked bytes written to a local ZIP; nothing sent.",
129
+ )
130
+ except OSError:
131
+ report.add(
132
+ "handoff.bundle",
133
+ "inconclusive",
134
+ "handoff",
135
+ "Cannot create bundle; no existing file overwritten.",
136
+ )
137
+ return emit(report, args.format, args.report)
138
+ except (PolicyError, OSError, ValueError, RecursionError):
139
+ # No exception text: parsers and network libraries may quote secret input.
140
+ report = Report()
141
+ report.add(
142
+ "configuration",
143
+ "inconclusive",
144
+ "policy",
145
+ "Invalid or unreadable policy, unsupported operation, or existing output path.",
146
+ )
147
+ return emit(report, getattr(args, "format", "text"))
permitprobe/demo.py ADDED
@@ -0,0 +1,161 @@
1
+ """Synthetic, loopback-only fixtures; no product data or live service dependency."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ from contextlib import contextmanager
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from urllib.parse import urlsplit
11
+
12
+ from permitprobe.api import check_api
13
+ from permitprobe.handoff import check_handoff
14
+ from permitprobe.policy import Policy
15
+ from permitprobe.report import Report
16
+
17
+ DEMO_TOKENS = {"PP_ALICE_TOKEN": "demo-only-alice-token", "PP_BOB_TOKEN": "demo-only-bob-token"}
18
+
19
+
20
+ def example_policy(base_url: str = "https://staging.example.invalid") -> dict:
21
+ return {
22
+ "version": 1,
23
+ "api": {
24
+ "base_url": base_url,
25
+ "subjects": [
26
+ {"name": "anon", "role": "anonymous"},
27
+ {
28
+ "name": "alice",
29
+ "role": "user",
30
+ "token_env": "PP_ALICE_TOKEN",
31
+ "attributes": {"document_id": "alice"},
32
+ "marker": "alice",
33
+ },
34
+ {
35
+ "name": "bob",
36
+ "role": "user",
37
+ "token_env": "PP_BOB_TOKEN",
38
+ "attributes": {"document_id": "bob"},
39
+ "marker": "bob",
40
+ },
41
+ ],
42
+ "resources": [
43
+ {
44
+ "name": "documents",
45
+ "kind": "object",
46
+ "path": "/documents/{id}",
47
+ "owner_param": "id",
48
+ "owner_attr": "document_id",
49
+ "identity_pointer": "/id",
50
+ "allow": [{"role": "user", "scope": "own"}],
51
+ "response_schema": {
52
+ "type": "object",
53
+ "required": ["id", "title"],
54
+ "properties": {"id": {"type": "string"}, "title": {"type": "string"}},
55
+ "additionalProperties": False,
56
+ },
57
+ "denial_schema": {
58
+ "type": "object",
59
+ "required": ["error"],
60
+ "properties": {"error": {"enum": ["denied"]}},
61
+ "additionalProperties": False,
62
+ },
63
+ }
64
+ ],
65
+ },
66
+ "handoff": {"root": ".", "files": ["review.txt"], "allow": ["*.txt", "docs/*.md"]},
67
+ }
68
+
69
+
70
+ @contextmanager
71
+ def fixture_server(scenario: str = "safe"):
72
+ requests = []
73
+
74
+ class Handler(BaseHTTPRequestHandler):
75
+ def log_message(self, *_):
76
+ pass
77
+
78
+ def do_GET(self):
79
+ owner = urlsplit(self.path).path.rsplit("/", 1)[-1]
80
+ token = self.headers.get("Authorization", "").removeprefix("Bearer ")
81
+ subject = {v: k.split("_")[1].lower() for k, v in DEMO_TOKENS.items()}.get(token)
82
+ if scenario == "expired" and subject == "alice":
83
+ subject = None
84
+ requests.append((subject, owner, dict(self.headers)))
85
+ status = 200 if subject and (subject == owner or scenario == "leaky") else 403
86
+ if scenario == "server-error" and subject and subject != owner:
87
+ status = 500
88
+ if scenario == "redirect" and subject and subject != owner:
89
+ status = 302
90
+ if scenario == "cookie" and self.headers.get("Cookie"):
91
+ status = 200
92
+ body = (
93
+ {"id": owner, "title": "Synthetic document"}
94
+ if status == 200
95
+ else {"error": "denied"}
96
+ )
97
+ if scenario == "denied-leak" and status == 403:
98
+ body["private_email"] = "synthetic@example.invalid"
99
+ if scenario in ("leaky", "schema-leak") and status == 200:
100
+ body["private_email"] = "synthetic@example.invalid"
101
+ if scenario == "wrong-object" and status == 200:
102
+ body["id"] = "unrelated"
103
+ if scenario == "nested-leak" and status == 200:
104
+ body["metadata"] = {"private": "synthetic-sensitive-value"}
105
+ raw = json.dumps(body).encode()
106
+ if scenario == "html" and status == 200:
107
+ raw = b"<html>login page</html>"
108
+ if scenario == "oversize" and status == 200:
109
+ raw = b"x" * 2048
110
+ self.send_response(status)
111
+ self.send_header("Content-Type", "application/json")
112
+ if scenario == "cookie":
113
+ self.send_header("Set-Cookie", "privileged=alice; Path=/")
114
+ if status == 302:
115
+ self.send_header("Location", "http://127.0.0.1:1/never-follow")
116
+ self.send_header("Content-Length", str(len(raw)))
117
+ self.end_headers()
118
+ self.wfile.write(raw)
119
+
120
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
121
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
122
+ thread.start()
123
+ try:
124
+ yield f"http://127.0.0.1:{server.server_port}", requests
125
+ finally:
126
+ server.shutdown()
127
+ server.server_close()
128
+ thread.join(timeout=5)
129
+
130
+
131
+ @contextmanager
132
+ def demo_environment():
133
+ old = {key: os.environ.get(key) for key in DEMO_TOKENS}
134
+ os.environ.update(DEMO_TOKENS)
135
+ try:
136
+ yield
137
+ finally:
138
+ for key, value in old.items():
139
+ if value is None:
140
+ os.environ.pop(key, None)
141
+ else:
142
+ os.environ[key] = value
143
+
144
+
145
+ def run_demo(scenario: str, binary: str | None = None) -> Report:
146
+ report = Report()
147
+ with (
148
+ fixture_server(scenario) as (url, _),
149
+ demo_environment(),
150
+ tempfile.TemporaryDirectory() as tmp,
151
+ ):
152
+ directory = Path(tmp)
153
+ text = "Review only these synthetic notes.\n"
154
+ if scenario == "leaky":
155
+ # Intentionally fake credential assembled for an actual scanner test.
156
+ text += "github_token = '" + "ghp_" + "7Qx4Kp9Vn2Ms8Rt6Wj3Yz5Bc1Df0Ha9Lu4Se" + "'\n"
157
+ (directory / "review.txt").write_text(text)
158
+ policy = Policy.model_validate(example_policy(url))
159
+ check_api(policy.api, report)
160
+ check_handoff(policy.handoff, directory, report, binary)
161
+ return report