pawnlogic-security 0.1.0__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.
- pawnlogic_security/__init__.py +5 -0
- pawnlogic_security/child_adapter.py +406 -0
- pawnlogic_security/cli.py +182 -0
- pawnlogic_security/evidence.py +211 -0
- pawnlogic_security/extension.py +394 -0
- pawnlogic_security/recon.py +583 -0
- pawnlogic_security/scope.py +437 -0
- pawnlogic_security/scope_file.py +255 -0
- pawnlogic_security/scope_manager.py +267 -0
- pawnlogic_security/tools.py +422 -0
- pawnlogic_security/workflows.py +474 -0
- pawnlogic_security-0.1.0.dist-info/METADATA +232 -0
- pawnlogic_security-0.1.0.dist-info/RECORD +17 -0
- pawnlogic_security-0.1.0.dist-info/WHEEL +5 -0
- pawnlogic_security-0.1.0.dist-info/entry_points.txt +5 -0
- pawnlogic_security-0.1.0.dist-info/licenses/LICENSE +21 -0
- pawnlogic_security-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Optional, fail-closed child-process adapter using a single JSON exchange."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import BinaryIO, Protocol
|
|
18
|
+
|
|
19
|
+
from core.operation_policy import (
|
|
20
|
+
OperationAction,
|
|
21
|
+
OperationDecision,
|
|
22
|
+
classify_shell_command,
|
|
23
|
+
redact_command,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_SENSITIVE_FIELD_RE = re.compile(
|
|
27
|
+
r"(?:api[_-]?key|access[_-]?key|token|password|passwd|secret|private[_-]?key)",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OperationPolicyEvaluator(Protocol):
|
|
33
|
+
"""Callable shape provided by the host Operation Policy."""
|
|
34
|
+
|
|
35
|
+
def __call__(
|
|
36
|
+
self,
|
|
37
|
+
command: str,
|
|
38
|
+
*,
|
|
39
|
+
cwd: str | Path,
|
|
40
|
+
workspace_dir: str | Path | None = None,
|
|
41
|
+
) -> OperationDecision: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ChildAdapterConfig:
|
|
46
|
+
"""Caller-owned process configuration; disabled unless explicitly enabled."""
|
|
47
|
+
|
|
48
|
+
argv: tuple[str, ...] = field(default=(), repr=False)
|
|
49
|
+
enabled: bool = False
|
|
50
|
+
timeout_seconds: float = 10.0
|
|
51
|
+
max_input_bytes: int = 64 * 1024
|
|
52
|
+
max_output_bytes: int = 1024 * 1024
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class ChildAdapterResult:
|
|
57
|
+
"""Deterministic result returned for every adapter outcome."""
|
|
58
|
+
|
|
59
|
+
ok: bool
|
|
60
|
+
code: str
|
|
61
|
+
message: str
|
|
62
|
+
data: object | None = None
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, object]:
|
|
65
|
+
return {
|
|
66
|
+
"ok": self.ok,
|
|
67
|
+
"code": self.code,
|
|
68
|
+
"message": self.message,
|
|
69
|
+
"data": self.data,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ChildProcessAdapter:
|
|
74
|
+
"""Run one configured child process for one bounded JSON request."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
config: ChildAdapterConfig,
|
|
79
|
+
*,
|
|
80
|
+
policy_evaluator: OperationPolicyEvaluator = classify_shell_command,
|
|
81
|
+
) -> None:
|
|
82
|
+
self._config = config
|
|
83
|
+
self._policy_evaluator = policy_evaluator
|
|
84
|
+
|
|
85
|
+
def run(self, request: Mapping[str, object]) -> ChildAdapterResult:
|
|
86
|
+
if not self._config.enabled:
|
|
87
|
+
return ChildAdapterResult(
|
|
88
|
+
ok=False,
|
|
89
|
+
code="disabled",
|
|
90
|
+
message="Child-process adapter is disabled.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
cwd = Path.cwd()
|
|
94
|
+
decision = self._policy_evaluator(
|
|
95
|
+
shlex.join(self._config.argv),
|
|
96
|
+
cwd=cwd,
|
|
97
|
+
workspace_dir=cwd,
|
|
98
|
+
)
|
|
99
|
+
if decision.action is not OperationAction.ALLOW:
|
|
100
|
+
code = (
|
|
101
|
+
"policy_denied"
|
|
102
|
+
if decision.action is OperationAction.DENY
|
|
103
|
+
else "policy_confirmation_required"
|
|
104
|
+
)
|
|
105
|
+
return ChildAdapterResult(
|
|
106
|
+
ok=False,
|
|
107
|
+
code=code,
|
|
108
|
+
message="Host Operation Policy refused child-process execution.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
executable = shutil.which(self._config.argv[0]) if self._config.argv else None
|
|
112
|
+
if executable is None:
|
|
113
|
+
return ChildAdapterResult(
|
|
114
|
+
ok=False,
|
|
115
|
+
code="executable_not_found",
|
|
116
|
+
message="Configured executable was not found.",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
self._config.timeout_seconds <= 0
|
|
121
|
+
or self._config.max_input_bytes <= 0
|
|
122
|
+
or self._config.max_output_bytes <= 0
|
|
123
|
+
):
|
|
124
|
+
return ChildAdapterResult(
|
|
125
|
+
ok=False,
|
|
126
|
+
code="invalid_configuration",
|
|
127
|
+
message="Adapter limits must be positive.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
input_bytes = (
|
|
132
|
+
json.dumps(
|
|
133
|
+
dict(request),
|
|
134
|
+
ensure_ascii=False,
|
|
135
|
+
separators=(",", ":"),
|
|
136
|
+
sort_keys=True,
|
|
137
|
+
allow_nan=False,
|
|
138
|
+
)
|
|
139
|
+
+ "\n"
|
|
140
|
+
).encode("utf-8")
|
|
141
|
+
except (TypeError, ValueError):
|
|
142
|
+
return ChildAdapterResult(
|
|
143
|
+
ok=False,
|
|
144
|
+
code="invalid_request",
|
|
145
|
+
message="Request is not valid JSON.",
|
|
146
|
+
)
|
|
147
|
+
if len(input_bytes) > self._config.max_input_bytes:
|
|
148
|
+
return ChildAdapterResult(
|
|
149
|
+
ok=False,
|
|
150
|
+
code="input_too_large",
|
|
151
|
+
message="JSON request exceeds the configured input limit.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
argv = (executable, *self._config.argv[1:])
|
|
155
|
+
try:
|
|
156
|
+
process = subprocess.Popen(
|
|
157
|
+
argv,
|
|
158
|
+
stdin=subprocess.PIPE,
|
|
159
|
+
stdout=subprocess.PIPE,
|
|
160
|
+
stderr=subprocess.PIPE,
|
|
161
|
+
shell=False,
|
|
162
|
+
env=_scrubbed_environment(),
|
|
163
|
+
start_new_session=os.name == "posix",
|
|
164
|
+
)
|
|
165
|
+
except OSError:
|
|
166
|
+
return ChildAdapterResult(
|
|
167
|
+
ok=False,
|
|
168
|
+
code="process_start_failed",
|
|
169
|
+
message="Configured child process could not be started.",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
exchange_code, stdout = _exchange_json(
|
|
173
|
+
process,
|
|
174
|
+
input_bytes=input_bytes,
|
|
175
|
+
timeout_seconds=self._config.timeout_seconds,
|
|
176
|
+
max_output_bytes=self._config.max_output_bytes,
|
|
177
|
+
)
|
|
178
|
+
if exchange_code == "timeout":
|
|
179
|
+
return ChildAdapterResult(
|
|
180
|
+
ok=False,
|
|
181
|
+
code="timeout",
|
|
182
|
+
message="Child process exceeded its configured timeout.",
|
|
183
|
+
)
|
|
184
|
+
if exchange_code == "output_too_large":
|
|
185
|
+
return ChildAdapterResult(
|
|
186
|
+
ok=False,
|
|
187
|
+
code="output_too_large",
|
|
188
|
+
message="Child-process output exceeds the configured limit.",
|
|
189
|
+
)
|
|
190
|
+
if process.returncode != 0:
|
|
191
|
+
return ChildAdapterResult(
|
|
192
|
+
ok=False,
|
|
193
|
+
code="process_failed",
|
|
194
|
+
message="Child process exited unsuccessfully.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
data = json.loads(
|
|
199
|
+
stdout.decode("utf-8"),
|
|
200
|
+
parse_constant=_reject_json_constant,
|
|
201
|
+
)
|
|
202
|
+
except (UnicodeDecodeError, ValueError):
|
|
203
|
+
return ChildAdapterResult(
|
|
204
|
+
ok=False,
|
|
205
|
+
code="malformed_output",
|
|
206
|
+
message="Child process returned malformed JSON.",
|
|
207
|
+
)
|
|
208
|
+
return ChildAdapterResult(
|
|
209
|
+
ok=True,
|
|
210
|
+
code="ok",
|
|
211
|
+
message="Child process completed successfully.",
|
|
212
|
+
data=_redact_json(data),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _scrubbed_environment() -> dict[str, str]:
|
|
217
|
+
"""Return a minimal environment without inheriting caller secrets."""
|
|
218
|
+
environment = {
|
|
219
|
+
"PATH": os.defpath,
|
|
220
|
+
"PYTHONIOENCODING": "utf-8",
|
|
221
|
+
}
|
|
222
|
+
for name in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"):
|
|
223
|
+
value = os.environ.get(name)
|
|
224
|
+
if value:
|
|
225
|
+
environment[name] = value
|
|
226
|
+
return environment
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _reject_json_constant(value: str) -> None:
|
|
230
|
+
raise ValueError(f"Non-standard JSON constant: {value}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _redact_json(value: object) -> object:
|
|
234
|
+
if isinstance(value, dict):
|
|
235
|
+
redacted: dict[str, object] = {}
|
|
236
|
+
for key in sorted(value):
|
|
237
|
+
item = value[key]
|
|
238
|
+
redacted[key] = (
|
|
239
|
+
"<redacted>"
|
|
240
|
+
if _SENSITIVE_FIELD_RE.fullmatch(key)
|
|
241
|
+
else _redact_json(item)
|
|
242
|
+
)
|
|
243
|
+
return redacted
|
|
244
|
+
if isinstance(value, list):
|
|
245
|
+
return [_redact_json(item) for item in value]
|
|
246
|
+
if isinstance(value, str):
|
|
247
|
+
return redact_command(value)
|
|
248
|
+
return value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _BoundedOutput:
|
|
252
|
+
"""Share one byte budget across stdout and stderr reader threads."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, limit: int) -> None:
|
|
255
|
+
self._limit = limit
|
|
256
|
+
self._total = 0
|
|
257
|
+
self._stdout = bytearray()
|
|
258
|
+
self._lock = threading.Lock()
|
|
259
|
+
self.overflow = threading.Event()
|
|
260
|
+
|
|
261
|
+
def add(self, chunk: bytes, *, capture: bool) -> None:
|
|
262
|
+
with self._lock:
|
|
263
|
+
remaining = self._limit - self._total
|
|
264
|
+
if len(chunk) > remaining:
|
|
265
|
+
if capture and remaining > 0:
|
|
266
|
+
self._stdout.extend(chunk[:remaining])
|
|
267
|
+
self._total = self._limit
|
|
268
|
+
self.overflow.set()
|
|
269
|
+
return
|
|
270
|
+
self._total += len(chunk)
|
|
271
|
+
if capture:
|
|
272
|
+
self._stdout.extend(chunk)
|
|
273
|
+
|
|
274
|
+
def stdout(self) -> bytes:
|
|
275
|
+
with self._lock:
|
|
276
|
+
return bytes(self._stdout)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _exchange_json(
|
|
280
|
+
process: subprocess.Popen[bytes],
|
|
281
|
+
*,
|
|
282
|
+
input_bytes: bytes,
|
|
283
|
+
timeout_seconds: float,
|
|
284
|
+
max_output_bytes: int,
|
|
285
|
+
) -> tuple[str, bytes]:
|
|
286
|
+
assert process.stdin is not None
|
|
287
|
+
assert process.stdout is not None
|
|
288
|
+
assert process.stderr is not None
|
|
289
|
+
|
|
290
|
+
output = _BoundedOutput(max_output_bytes)
|
|
291
|
+
stdin_done = threading.Event()
|
|
292
|
+
stdout_done = threading.Event()
|
|
293
|
+
stderr_done = threading.Event()
|
|
294
|
+
threads = (
|
|
295
|
+
threading.Thread(
|
|
296
|
+
target=_write_input,
|
|
297
|
+
args=(process.stdin, input_bytes, stdin_done),
|
|
298
|
+
daemon=True,
|
|
299
|
+
),
|
|
300
|
+
threading.Thread(
|
|
301
|
+
target=_read_output,
|
|
302
|
+
args=(process.stdout, output, True, stdout_done),
|
|
303
|
+
daemon=True,
|
|
304
|
+
),
|
|
305
|
+
threading.Thread(
|
|
306
|
+
target=_read_output,
|
|
307
|
+
args=(process.stderr, output, False, stderr_done),
|
|
308
|
+
daemon=True,
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
for thread in threads:
|
|
312
|
+
thread.start()
|
|
313
|
+
|
|
314
|
+
deadline = time.monotonic() + timeout_seconds
|
|
315
|
+
exchange_code = "ok"
|
|
316
|
+
while True:
|
|
317
|
+
if output.overflow.is_set():
|
|
318
|
+
exchange_code = "output_too_large"
|
|
319
|
+
break
|
|
320
|
+
process_finished = process.poll() is not None
|
|
321
|
+
streams_finished = stdin_done.is_set() and stdout_done.is_set() and stderr_done.is_set()
|
|
322
|
+
if process_finished and streams_finished:
|
|
323
|
+
break
|
|
324
|
+
remaining = deadline - time.monotonic()
|
|
325
|
+
if remaining <= 0:
|
|
326
|
+
exchange_code = "timeout"
|
|
327
|
+
break
|
|
328
|
+
output.overflow.wait(timeout=min(0.01, remaining))
|
|
329
|
+
|
|
330
|
+
if exchange_code != "ok":
|
|
331
|
+
_terminate_process_group(process)
|
|
332
|
+
else:
|
|
333
|
+
process.wait()
|
|
334
|
+
|
|
335
|
+
for thread in threads:
|
|
336
|
+
thread.join(timeout=0.2)
|
|
337
|
+
return exchange_code, output.stdout()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _write_input(stream: BinaryIO, data: bytes, done: threading.Event) -> None:
|
|
341
|
+
try:
|
|
342
|
+
stream.write(data)
|
|
343
|
+
stream.flush()
|
|
344
|
+
except (BrokenPipeError, OSError):
|
|
345
|
+
pass
|
|
346
|
+
finally:
|
|
347
|
+
try:
|
|
348
|
+
stream.close()
|
|
349
|
+
finally:
|
|
350
|
+
done.set()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _read_output(
|
|
354
|
+
stream: BinaryIO,
|
|
355
|
+
output: _BoundedOutput,
|
|
356
|
+
capture: bool,
|
|
357
|
+
done: threading.Event,
|
|
358
|
+
) -> None:
|
|
359
|
+
try:
|
|
360
|
+
read_chunk = getattr(stream, "read1", stream.read)
|
|
361
|
+
while not output.overflow.is_set():
|
|
362
|
+
chunk = read_chunk(8192)
|
|
363
|
+
if not chunk:
|
|
364
|
+
break
|
|
365
|
+
output.add(chunk, capture=capture)
|
|
366
|
+
except OSError:
|
|
367
|
+
pass
|
|
368
|
+
finally:
|
|
369
|
+
try:
|
|
370
|
+
stream.close()
|
|
371
|
+
finally:
|
|
372
|
+
done.set()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
|
|
376
|
+
"""Terminate the configured process and its process group when supported."""
|
|
377
|
+
if os.name == "posix":
|
|
378
|
+
try:
|
|
379
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
380
|
+
except ProcessLookupError:
|
|
381
|
+
pass
|
|
382
|
+
try:
|
|
383
|
+
process.wait(timeout=0.2)
|
|
384
|
+
except subprocess.TimeoutExpired:
|
|
385
|
+
pass
|
|
386
|
+
try:
|
|
387
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
388
|
+
except ProcessLookupError:
|
|
389
|
+
pass
|
|
390
|
+
if process.poll() is None:
|
|
391
|
+
process.wait()
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
process.terminate()
|
|
395
|
+
try:
|
|
396
|
+
process.wait(timeout=0.2)
|
|
397
|
+
except subprocess.TimeoutExpired:
|
|
398
|
+
process.kill()
|
|
399
|
+
process.wait()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
__all__ = [
|
|
403
|
+
"ChildAdapterConfig",
|
|
404
|
+
"ChildAdapterResult",
|
|
405
|
+
"ChildProcessAdapter",
|
|
406
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The ``pawn-security`` command.
|
|
2
|
+
|
|
3
|
+
This is intentionally thin. It validates scope files and delegates workflow
|
|
4
|
+
execution to the same scope-gated runner used by the PawnLogic Extension. It
|
|
5
|
+
cannot enable the Extension inside a host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pawnlogic_security import __version__
|
|
18
|
+
from pawnlogic_security.evidence import EvidenceLog
|
|
19
|
+
from pawnlogic_security.extension import EXTENSION_NAME, MANIFEST
|
|
20
|
+
from pawnlogic_security.scope import from_scope_file
|
|
21
|
+
from pawnlogic_security.scope_file import load as load_scope_file
|
|
22
|
+
from pawnlogic_security.scope_manager import ScopeManager
|
|
23
|
+
from pawnlogic_security.workflows import WorkflowRunner, WorkflowRunStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="pawn-security",
|
|
29
|
+
description=(
|
|
30
|
+
"Inspect the PawnLogic security Extension. Enable it from inside "
|
|
31
|
+
"PawnLogic with: /extension enable " + EXTENSION_NAME
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--version",
|
|
36
|
+
action="version",
|
|
37
|
+
version=f"pawnlogic-security {__version__}",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--manifest",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Print the Extension manifest the host will validate.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
46
|
+
|
|
47
|
+
scope_parser = subparsers.add_parser(
|
|
48
|
+
"scope",
|
|
49
|
+
help="Manage Engagement Scope files.",
|
|
50
|
+
)
|
|
51
|
+
scope_sub = scope_parser.add_subparsers(dest="scope_command")
|
|
52
|
+
|
|
53
|
+
validate_parser = scope_sub.add_parser(
|
|
54
|
+
"validate",
|
|
55
|
+
help="Validate a scope file and report any issues.",
|
|
56
|
+
)
|
|
57
|
+
validate_parser.add_argument(
|
|
58
|
+
"file",
|
|
59
|
+
help="Path to the scope JSON file.",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
run_parser = subparsers.add_parser(
|
|
63
|
+
"run",
|
|
64
|
+
help="Run a built-in workflow through the shared scope-gated runner.",
|
|
65
|
+
)
|
|
66
|
+
run_parser.add_argument("workflow", help="Built-in workflow name.")
|
|
67
|
+
run_parser.add_argument(
|
|
68
|
+
"--scope",
|
|
69
|
+
required=True,
|
|
70
|
+
help="Path to the Engagement Scope JSON file.",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cmd_scope_validate(file: str) -> int:
|
|
77
|
+
"""Validate a scope file and print a summary or the errors."""
|
|
78
|
+
path = Path(file).expanduser()
|
|
79
|
+
if not path.is_file():
|
|
80
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
try:
|
|
83
|
+
scope_file = load_scope_file(path)
|
|
84
|
+
except ValueError as error:
|
|
85
|
+
print(f"error: {error}", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
# Also try building a full EngagementScope to catch validation errors
|
|
89
|
+
# in the conversion step (CIDR normalization, etc.).
|
|
90
|
+
try:
|
|
91
|
+
scope = from_scope_file(scope_file, clock=0.0)
|
|
92
|
+
except (ValueError, TypeError) as error:
|
|
93
|
+
print(f"error: scope construction failed: {error}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
print(f"identifier: {scope.identifier}")
|
|
97
|
+
print(f"authorized: {scope.authorized_by}")
|
|
98
|
+
print(f"expires at: {scope.expires_at}")
|
|
99
|
+
print(f"targets: {len(scope.targets)}")
|
|
100
|
+
if scope.exclude:
|
|
101
|
+
print(f"exclude: {len(scope.exclude)} entries")
|
|
102
|
+
if scope.allow_active:
|
|
103
|
+
print("active: yes")
|
|
104
|
+
if scope.max_requests:
|
|
105
|
+
print(f"max_req: {scope.max_requests}")
|
|
106
|
+
if scope.max_concurrency:
|
|
107
|
+
print(f"max_concur: {scope.max_concurrency}")
|
|
108
|
+
if scope.max_duration:
|
|
109
|
+
print(f"max_dur: {scope.max_duration}s")
|
|
110
|
+
if scope.evidence_dir:
|
|
111
|
+
print(f"evidence: {scope.evidence_dir}")
|
|
112
|
+
if scope.ports:
|
|
113
|
+
print(f"ports: {len(scope.ports)} entries")
|
|
114
|
+
if scope_file.actions:
|
|
115
|
+
print(f"actions: {', '.join(sorted(scope_file.actions))}")
|
|
116
|
+
print("scope file is valid")
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_run(workflow: str, scope_file: str) -> int:
|
|
121
|
+
manager = ScopeManager()
|
|
122
|
+
security_home = _runtime_home() / "security"
|
|
123
|
+
try:
|
|
124
|
+
manager.set_scope(Path(scope_file).expanduser())
|
|
125
|
+
evidence_path = manager.evidence_path(
|
|
126
|
+
security_home / "evidence.jsonl"
|
|
127
|
+
)
|
|
128
|
+
runner = WorkflowRunner(
|
|
129
|
+
evidence=EvidenceLog(path=evidence_path),
|
|
130
|
+
run_store=WorkflowRunStore(evidence_path.parent / "runs"),
|
|
131
|
+
)
|
|
132
|
+
result = asyncio.run(runner.run(workflow, manager))
|
|
133
|
+
except (OSError, ValueError) as error:
|
|
134
|
+
print(f"error: {error}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
finally:
|
|
137
|
+
manager.close()
|
|
138
|
+
print(result.render())
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _runtime_home() -> Path:
|
|
143
|
+
configured = os.environ.get("PAWNLOGIC_HOME")
|
|
144
|
+
if configured:
|
|
145
|
+
return Path(configured).expanduser()
|
|
146
|
+
return Path.home() / ".pawnlogic"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
150
|
+
args = build_parser().parse_args(argv)
|
|
151
|
+
|
|
152
|
+
if args.command == "scope":
|
|
153
|
+
if args.scope_command == "validate":
|
|
154
|
+
return _cmd_scope_validate(args.file)
|
|
155
|
+
# `pawn-security scope` with no subcommand shows scope help.
|
|
156
|
+
build_parser().parse_args([*argv, "--help"] if argv else ["scope", "--help"])
|
|
157
|
+
return 0 # pragma: no cover
|
|
158
|
+
|
|
159
|
+
if args.command == "run":
|
|
160
|
+
return _cmd_run(args.workflow, args.scope)
|
|
161
|
+
|
|
162
|
+
if args.manifest:
|
|
163
|
+
print(f"name: {MANIFEST.name}")
|
|
164
|
+
print(f"version: {MANIFEST.version}")
|
|
165
|
+
print(f"core_version_spec: {MANIFEST.core_version_spec}")
|
|
166
|
+
print(f"api_version: {MANIFEST.api_version}")
|
|
167
|
+
print(f"capabilities: {', '.join(sorted(MANIFEST.capabilities))}")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
print(f"pawnlogic-security {__version__}")
|
|
171
|
+
print(f"Extension name: {EXTENSION_NAME}")
|
|
172
|
+
print("This Extension stays disabled until it is explicitly enabled.")
|
|
173
|
+
print(f"Enable it inside PawnLogic with: /extension enable {EXTENSION_NAME}")
|
|
174
|
+
print(
|
|
175
|
+
"Active operations additionally require an Engagement Scope that names "
|
|
176
|
+
"the target and permits active work."
|
|
177
|
+
)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__": # pragma: no cover - module entry point
|
|
182
|
+
raise SystemExit(main())
|