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.
- permitprobe/__init__.py +3 -0
- permitprobe/__main__.py +3 -0
- permitprobe/api.py +266 -0
- permitprobe/cli.py +147 -0
- permitprobe/demo.py +161 -0
- permitprobe/handoff.py +267 -0
- permitprobe/policy.py +211 -0
- permitprobe/report.py +47 -0
- permitprobe-0.1.1.dist-info/METADATA +258 -0
- permitprobe-0.1.1.dist-info/RECORD +15 -0
- permitprobe-0.1.1.dist-info/WHEEL +5 -0
- permitprobe-0.1.1.dist-info/entry_points.txt +2 -0
- permitprobe-0.1.1.dist-info/licenses/LICENSE +202 -0
- permitprobe-0.1.1.dist-info/licenses/NOTICE +23 -0
- permitprobe-0.1.1.dist-info/top_level.txt +1 -0
permitprobe/__init__.py
ADDED
permitprobe/__main__.py
ADDED
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
|