copysec 0.9.9__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.
- copysec/__init__.py +1 -0
- copysec/__main__.py +6 -0
- copysec/audit.py +151 -0
- copysec/cli.py +106 -0
- copysec/config.py +47 -0
- copysec/guard.py +295 -0
- copysec/policy.py +61 -0
- copysec/proctree.py +55 -0
- copysec/stats.py +25 -0
- copysec/store.py +229 -0
- copysec/tray.py +59 -0
- copysec/winapi.py +569 -0
- copysec-0.9.9.dist-info/METADATA +157 -0
- copysec-0.9.9.dist-info/RECORD +16 -0
- copysec-0.9.9.dist-info/WHEEL +4 -0
- copysec-0.9.9.dist-info/entry_points.txt +3 -0
copysec/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
copysec/__main__.py
ADDED
copysec/audit.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as _dt
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Audit:
|
|
10
|
+
MIN_INTERVAL = 1.0
|
|
11
|
+
MAX_FILE_BYTES = 1_000_000
|
|
12
|
+
MAX_DIR_BYTES = 10_000_000
|
|
13
|
+
TARGET_DIR_BYTES = 5_000_000
|
|
14
|
+
|
|
15
|
+
def __init__(self, log_dir: Path, log_allows: bool = False, verbose: bool = False):
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
self.log_allows = log_allows
|
|
19
|
+
self.verbose = verbose
|
|
20
|
+
self.debug = os.environ.get("COPYSEC_DEBUG") == "1"
|
|
21
|
+
if self.debug:
|
|
22
|
+
self.log_allows = True
|
|
23
|
+
self._log_dir = Path(log_dir)
|
|
24
|
+
try:
|
|
25
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
except OSError:
|
|
27
|
+
self._log_dir = None
|
|
28
|
+
self._last: dict[tuple, float] = {}
|
|
29
|
+
self._dropped: dict[tuple, int] = {}
|
|
30
|
+
self._prune()
|
|
31
|
+
|
|
32
|
+
def _prune(self) -> None:
|
|
33
|
+
if self._log_dir is None:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
sized = []
|
|
37
|
+
total = 0
|
|
38
|
+
for path in sorted(self._log_dir.glob("audit-*.jsonl")):
|
|
39
|
+
try:
|
|
40
|
+
st = path.stat()
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
|
43
|
+
sized.append((st.st_mtime, path.name, st.st_size, path))
|
|
44
|
+
total += st.st_size
|
|
45
|
+
if total <= self.MAX_DIR_BYTES:
|
|
46
|
+
return
|
|
47
|
+
freed = 0
|
|
48
|
+
removed = 0
|
|
49
|
+
for _, _, size, path in sorted(sized):
|
|
50
|
+
if total - freed <= self.TARGET_DIR_BYTES:
|
|
51
|
+
break
|
|
52
|
+
try:
|
|
53
|
+
path.unlink()
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
freed += size
|
|
57
|
+
removed += 1
|
|
58
|
+
if removed:
|
|
59
|
+
self.event("logs_pruned", removed=removed, freed_bytes=freed)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def _path(self) -> Path | None:
|
|
64
|
+
if self._log_dir is None:
|
|
65
|
+
return None
|
|
66
|
+
day = _dt.datetime.now().astimezone().date().strftime("%Y%m%d")
|
|
67
|
+
base = f"audit-{day}"
|
|
68
|
+
path = self._log_dir / f"{base}.jsonl"
|
|
69
|
+
seq = 0
|
|
70
|
+
try:
|
|
71
|
+
while path.exists() and path.stat().st_size >= self.MAX_FILE_BYTES:
|
|
72
|
+
seq += 1
|
|
73
|
+
path = self._log_dir / f"{base}.{seq}.jsonl"
|
|
74
|
+
except OSError:
|
|
75
|
+
return None
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
def _write(self, record: dict) -> None:
|
|
79
|
+
record.setdefault("ts", _dt.datetime.now().astimezone().isoformat(timespec="milliseconds"))
|
|
80
|
+
line = json.dumps(record, ensure_ascii=False)
|
|
81
|
+
path = self._path()
|
|
82
|
+
if path is not None:
|
|
83
|
+
try:
|
|
84
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
85
|
+
fh.write(line + "\n")
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
if self.verbose:
|
|
89
|
+
try:
|
|
90
|
+
print(line, flush=True)
|
|
91
|
+
except (OSError, UnicodeEncodeError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def _rate_ok(self, key: tuple) -> bool:
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
last = self._last.get(key)
|
|
97
|
+
if last is not None and now - last < self.MIN_INTERVAL:
|
|
98
|
+
self._dropped[key] = self._dropped.get(key, 0) + 1
|
|
99
|
+
return False
|
|
100
|
+
self._last[key] = now
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _proc_dict(info) -> dict | None:
|
|
105
|
+
if info is None:
|
|
106
|
+
return None
|
|
107
|
+
record = {"pid": info.pid, "exe": info.exe}
|
|
108
|
+
if getattr(info, "host_pid", None):
|
|
109
|
+
record["host_pid"] = info.host_pid
|
|
110
|
+
return record
|
|
111
|
+
|
|
112
|
+
def access(
|
|
113
|
+
self,
|
|
114
|
+
allowed: bool,
|
|
115
|
+
rule: str,
|
|
116
|
+
fmt: int,
|
|
117
|
+
fmt_name: str,
|
|
118
|
+
requester=None,
|
|
119
|
+
foreground=None,
|
|
120
|
+
) -> None:
|
|
121
|
+
if allowed and not self.log_allows:
|
|
122
|
+
return
|
|
123
|
+
key = ("access", allowed, getattr(requester, "pid", None), fmt)
|
|
124
|
+
if not self._rate_ok(key):
|
|
125
|
+
return
|
|
126
|
+
record = {
|
|
127
|
+
"event": "allow" if allowed else "deny",
|
|
128
|
+
"rule": rule,
|
|
129
|
+
"fmt": fmt,
|
|
130
|
+
"fmt_name": fmt_name,
|
|
131
|
+
"requester": self._proc_dict(requester),
|
|
132
|
+
"foreground": self._proc_dict(foreground),
|
|
133
|
+
}
|
|
134
|
+
dropped = self._dropped.pop(key, None)
|
|
135
|
+
if dropped:
|
|
136
|
+
record["suppressed_repeats"] = dropped
|
|
137
|
+
self._write(record)
|
|
138
|
+
|
|
139
|
+
def event(self, name: str, rate_key: str | None = None, **fields) -> None:
|
|
140
|
+
if rate_key is not None:
|
|
141
|
+
key = ("event", rate_key)
|
|
142
|
+
if not self._rate_ok(key):
|
|
143
|
+
return
|
|
144
|
+
dropped = self._dropped.pop(key, None)
|
|
145
|
+
if dropped:
|
|
146
|
+
fields["suppressed_repeats"] = dropped
|
|
147
|
+
self._write({"event": name, **fields})
|
|
148
|
+
|
|
149
|
+
def probe(self, **fields) -> None:
|
|
150
|
+
if self.debug:
|
|
151
|
+
self._write({"event": "probe", **fields})
|
copysec/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .audit import Audit
|
|
10
|
+
from .config import default_config_path, load_or_create
|
|
11
|
+
from .guard import ClipboardGuard
|
|
12
|
+
from .policy import Policy
|
|
13
|
+
from .proctree import PsutilProcTree
|
|
14
|
+
from .store import ContentStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="copysec",
|
|
20
|
+
description="Clipboard guard: only the active application and its process tree can access the clipboard.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--no-tray", action="store_true", help="Run without the tray icon")
|
|
23
|
+
parser.add_argument("--verbose", action="store_true", help="Also echo log records to the console")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--config",
|
|
26
|
+
type=Path,
|
|
27
|
+
default=None,
|
|
28
|
+
help="Path to config.json (default: %%LOCALAPPDATA%%\\CopySec\\config.json)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--smoke-seconds",
|
|
32
|
+
type=float,
|
|
33
|
+
default=None,
|
|
34
|
+
help=argparse.SUPPRESS,
|
|
35
|
+
)
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv=None) -> int:
|
|
40
|
+
try:
|
|
41
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
42
|
+
except (AttributeError, OSError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
args = _build_parser().parse_args(argv)
|
|
46
|
+
config_path = args.config or default_config_path()
|
|
47
|
+
cfg = load_or_create(config_path)
|
|
48
|
+
log_dir = config_path.parent / "logs"
|
|
49
|
+
|
|
50
|
+
audit = Audit(log_dir, log_allows=cfg.log_allows, verbose=args.verbose)
|
|
51
|
+
tree = PsutilProcTree()
|
|
52
|
+
|
|
53
|
+
def resolve_name(pid):
|
|
54
|
+
info = tree.resolve(pid)
|
|
55
|
+
return info.exe if info else None
|
|
56
|
+
|
|
57
|
+
policy = Policy(
|
|
58
|
+
tree,
|
|
59
|
+
allowlist=set(cfg.allowlist),
|
|
60
|
+
allow_uwp_frame_host=cfg.allow_uwp_frame_host,
|
|
61
|
+
deny_unknown_requester=cfg.deny_unknown_requester,
|
|
62
|
+
)
|
|
63
|
+
store = ContentStore(audit, resolve_name)
|
|
64
|
+
guard = ClipboardGuard(policy, store, audit)
|
|
65
|
+
|
|
66
|
+
audit.event(
|
|
67
|
+
"started",
|
|
68
|
+
pid=os.getpid(),
|
|
69
|
+
python=sys.version.split()[0],
|
|
70
|
+
config=str(config_path),
|
|
71
|
+
)
|
|
72
|
+
guard.start()
|
|
73
|
+
if not guard.hwnd:
|
|
74
|
+
print("[copysec] Failed to create guard window. See logs for details.", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
guard.request_adopt()
|
|
77
|
+
|
|
78
|
+
print(f"[copysec] protection active | pid={os.getpid()}", flush=True)
|
|
79
|
+
print(f"[copysec] config : {config_path}", flush=True)
|
|
80
|
+
print(f"[copysec] logs : {log_dir}", flush=True)
|
|
81
|
+
print("[copysec] to stop: Ctrl+C (console) or tray > Exit", flush=True)
|
|
82
|
+
|
|
83
|
+
exit_code = 0
|
|
84
|
+
try:
|
|
85
|
+
if args.no_tray or args.smoke_seconds is not None:
|
|
86
|
+
deadline = (
|
|
87
|
+
time.monotonic() + args.smoke_seconds
|
|
88
|
+
if args.smoke_seconds is not None
|
|
89
|
+
else None
|
|
90
|
+
)
|
|
91
|
+
while guard.is_alive:
|
|
92
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
93
|
+
break
|
|
94
|
+
time.sleep(0.2)
|
|
95
|
+
else:
|
|
96
|
+
from .tray import TrayApp
|
|
97
|
+
|
|
98
|
+
TrayApp(guard, log_dir).run()
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
print("\n[copysec] shutting down...", flush=True)
|
|
101
|
+
finally:
|
|
102
|
+
guard.stop()
|
|
103
|
+
if guard.thread:
|
|
104
|
+
guard.thread.join(timeout=5)
|
|
105
|
+
audit.event("stopped")
|
|
106
|
+
return exit_code
|
copysec/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
allowlist: list[str] = field(default_factory=lambda: ["svchost.exe"])
|
|
12
|
+
allow_uwp_frame_host: bool = False
|
|
13
|
+
deny_unknown_requester: bool = True
|
|
14
|
+
log_allows: bool = False
|
|
15
|
+
|
|
16
|
+
def apply_dict(self, data: dict) -> None:
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
return
|
|
19
|
+
if isinstance(data.get("allowlist"), list):
|
|
20
|
+
self.allowlist = [str(x) for x in data["allowlist"]]
|
|
21
|
+
for key in ("allow_uwp_frame_host", "deny_unknown_requester", "log_allows"):
|
|
22
|
+
if key in data and isinstance(data[key], bool):
|
|
23
|
+
setattr(self, key, data[key])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_config_path() -> Path:
|
|
27
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
28
|
+
base = Path(local) if local else Path.home() / "AppData" / "Local"
|
|
29
|
+
return base / "CopySec" / "config.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_or_create(path: Path) -> Config:
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
cfg = Config()
|
|
35
|
+
if path.exists():
|
|
36
|
+
try:
|
|
37
|
+
cfg.apply_dict(json.loads(path.read_text(encoding="utf-8-sig")))
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
try:
|
|
42
|
+
path.write_text(
|
|
43
|
+
json.dumps(asdict(cfg), indent=2) + "\n", encoding="utf-8"
|
|
44
|
+
)
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
return cfg
|
copysec/guard.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
from . import winapi
|
|
8
|
+
from .stats import RollingStats
|
|
9
|
+
from .winapi import (
|
|
10
|
+
MSG,
|
|
11
|
+
WM_APP,
|
|
12
|
+
WM_CLIPBOARDUPDATE,
|
|
13
|
+
WM_DESTROY,
|
|
14
|
+
WM_QUIT,
|
|
15
|
+
WM_RENDERALLFORMATS,
|
|
16
|
+
WM_RENDERFORMAT,
|
|
17
|
+
WM_TIMER,
|
|
18
|
+
WNDCLASSW,
|
|
19
|
+
byref,
|
|
20
|
+
c_wchar_p,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClipboardGuard:
|
|
25
|
+
CMD_PAUSE = WM_APP + 1
|
|
26
|
+
CMD_RESUME = WM_APP + 2
|
|
27
|
+
CMD_ADOPT = WM_APP + 3
|
|
28
|
+
CMD_REARM = WM_APP + 4
|
|
29
|
+
|
|
30
|
+
WINDOW_CLASS = "CopySecGuardWindowClass"
|
|
31
|
+
TIMER_ID = 1
|
|
32
|
+
TIMER_INTERVAL_MS = 2000
|
|
33
|
+
REARM_TIMER_ID = 2
|
|
34
|
+
REARM_RETRY_MS = 500
|
|
35
|
+
|
|
36
|
+
def __init__(self, policy, store, audit):
|
|
37
|
+
self.policy = policy
|
|
38
|
+
self.store = store
|
|
39
|
+
self.audit = audit
|
|
40
|
+
self.paused = False
|
|
41
|
+
self.hwnd = None
|
|
42
|
+
self.thread = None
|
|
43
|
+
self._tid = 0
|
|
44
|
+
self._ready = threading.Event()
|
|
45
|
+
self._rearm_fail_streak = 0
|
|
46
|
+
self.rearm_stats = RollingStats()
|
|
47
|
+
self._fail_started: float | None = None
|
|
48
|
+
self._proc = winapi.WNDPROC(self._dispatch)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_alive(self) -> bool:
|
|
52
|
+
return bool(self.thread and self.thread.is_alive())
|
|
53
|
+
|
|
54
|
+
def start(self) -> threading.Thread:
|
|
55
|
+
self.thread = threading.Thread(target=self._run, name="copysec-guard", daemon=True)
|
|
56
|
+
self.thread.start()
|
|
57
|
+
self._ready.wait(timeout=5.0)
|
|
58
|
+
return self.thread
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
if self._tid:
|
|
62
|
+
winapi.user32.PostThreadMessageW(self._tid, WM_QUIT, 0, 0)
|
|
63
|
+
|
|
64
|
+
def pause(self) -> None:
|
|
65
|
+
self._post(self.CMD_PAUSE)
|
|
66
|
+
|
|
67
|
+
def resume(self) -> None:
|
|
68
|
+
self._post(self.CMD_RESUME)
|
|
69
|
+
|
|
70
|
+
def request_adopt(self) -> None:
|
|
71
|
+
self._post(self.CMD_ADOPT)
|
|
72
|
+
|
|
73
|
+
def _post(self, cmd: int) -> bool:
|
|
74
|
+
if not self.hwnd:
|
|
75
|
+
return False
|
|
76
|
+
return bool(winapi.user32.PostMessageW(self.hwnd, cmd, 0, 0))
|
|
77
|
+
|
|
78
|
+
def _run(self) -> None:
|
|
79
|
+
self._tid = winapi.kernel32.GetCurrentThreadId()
|
|
80
|
+
instance = winapi.kernel32.GetModuleHandleW(None)
|
|
81
|
+
wc = WNDCLASSW()
|
|
82
|
+
wc.lpfnWndProc = self._proc
|
|
83
|
+
wc.hInstance = instance
|
|
84
|
+
wc.lpszClassName = c_wchar_p(self.WINDOW_CLASS)
|
|
85
|
+
if not winapi.user32.RegisterClassW(byref(wc)):
|
|
86
|
+
self.audit.event("fatal", reason="register-class-failed")
|
|
87
|
+
self._ready.set()
|
|
88
|
+
return
|
|
89
|
+
hwnd = winapi.user32.CreateWindowExW(
|
|
90
|
+
0,
|
|
91
|
+
c_wchar_p(self.WINDOW_CLASS),
|
|
92
|
+
c_wchar_p("CopySec"),
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
0,
|
|
96
|
+
0,
|
|
97
|
+
0,
|
|
98
|
+
None,
|
|
99
|
+
None,
|
|
100
|
+
instance,
|
|
101
|
+
None,
|
|
102
|
+
)
|
|
103
|
+
if not hwnd:
|
|
104
|
+
self.audit.event("fatal", reason="create-window-failed")
|
|
105
|
+
self._ready.set()
|
|
106
|
+
return
|
|
107
|
+
self.hwnd = hwnd
|
|
108
|
+
if not winapi.user32.AddClipboardFormatListener(hwnd):
|
|
109
|
+
self.audit.event("fatal", reason="listener-failed")
|
|
110
|
+
self._ready.set()
|
|
111
|
+
return
|
|
112
|
+
winapi.user32.SetTimer(hwnd, self.TIMER_ID, self.TIMER_INTERVAL_MS, None)
|
|
113
|
+
self._ready.set()
|
|
114
|
+
msg = MSG()
|
|
115
|
+
try:
|
|
116
|
+
while True:
|
|
117
|
+
if winapi.user32.GetMessageW(byref(msg), None, 0, 0) <= 0:
|
|
118
|
+
break
|
|
119
|
+
winapi.user32.TranslateMessage(byref(msg))
|
|
120
|
+
winapi.user32.DispatchMessageW(byref(msg))
|
|
121
|
+
except BaseException as exc: # noqa: BLE001
|
|
122
|
+
try:
|
|
123
|
+
self.audit.event("fatal", reason="pump-crash", error=repr(exc))
|
|
124
|
+
except Exception: # noqa: BLE001, S110
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
winapi.user32.KillTimer(hwnd, self.TIMER_ID)
|
|
128
|
+
winapi.user32.KillTimer(hwnd, self.REARM_TIMER_ID)
|
|
129
|
+
winapi.user32.RemoveClipboardFormatListener(hwnd)
|
|
130
|
+
if not winapi.user32.DestroyWindow(hwnd) and self.store.active:
|
|
131
|
+
self.store.flush_back()
|
|
132
|
+
self.hwnd = None
|
|
133
|
+
|
|
134
|
+
def _dispatch(self, hwnd, msg, wparam, lparam) -> int:
|
|
135
|
+
try:
|
|
136
|
+
if msg == WM_CLIPBOARDUPDATE:
|
|
137
|
+
self._on_update()
|
|
138
|
+
return 0
|
|
139
|
+
if msg == WM_RENDERFORMAT:
|
|
140
|
+
self._on_render(int(wparam))
|
|
141
|
+
return 0
|
|
142
|
+
if msg == WM_RENDERALLFORMATS:
|
|
143
|
+
self._on_renderall()
|
|
144
|
+
return 0
|
|
145
|
+
if msg == WM_TIMER:
|
|
146
|
+
if wparam == self.TIMER_ID:
|
|
147
|
+
self._on_timer()
|
|
148
|
+
return 0
|
|
149
|
+
if wparam == self.REARM_TIMER_ID:
|
|
150
|
+
winapi.user32.KillTimer(hwnd, self.REARM_TIMER_ID)
|
|
151
|
+
self._on_rearm()
|
|
152
|
+
return 0
|
|
153
|
+
if msg == WM_DESTROY:
|
|
154
|
+
self._on_destroy()
|
|
155
|
+
return 0
|
|
156
|
+
if msg == self.CMD_PAUSE:
|
|
157
|
+
self._on_pause()
|
|
158
|
+
return 0
|
|
159
|
+
if msg == self.CMD_RESUME:
|
|
160
|
+
self._on_resume()
|
|
161
|
+
return 0
|
|
162
|
+
if msg == self.CMD_ADOPT:
|
|
163
|
+
self._on_update(force=True)
|
|
164
|
+
return 0
|
|
165
|
+
if msg == self.CMD_REARM:
|
|
166
|
+
self._on_rearm()
|
|
167
|
+
return 0
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
traceback.print_exc()
|
|
170
|
+
try:
|
|
171
|
+
self.audit.event("handler_error", msg=hex(msg), error=repr(exc))
|
|
172
|
+
except Exception: # noqa: BLE001, S110
|
|
173
|
+
pass
|
|
174
|
+
return winapi.user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
|
175
|
+
|
|
176
|
+
def _owner_is_us(self) -> bool:
|
|
177
|
+
return self.hwnd is not None and winapi.clipboard_owner_hwnd() == self.hwnd
|
|
178
|
+
|
|
179
|
+
def _on_update(self, force: bool = False) -> None:
|
|
180
|
+
if self.paused and not force:
|
|
181
|
+
return
|
|
182
|
+
if self._owner_is_us():
|
|
183
|
+
return
|
|
184
|
+
if not self.store.adopt(self.hwnd):
|
|
185
|
+
self.audit.event("adopt_failed", rate_key="adopt_failed")
|
|
186
|
+
|
|
187
|
+
def _on_timer(self) -> None:
|
|
188
|
+
if self.paused:
|
|
189
|
+
return
|
|
190
|
+
if self.store.pending_rearm:
|
|
191
|
+
self._post(self.CMD_REARM)
|
|
192
|
+
return
|
|
193
|
+
if not self._owner_is_us():
|
|
194
|
+
self._on_update()
|
|
195
|
+
|
|
196
|
+
def _on_rearm(self) -> None:
|
|
197
|
+
if self.paused:
|
|
198
|
+
self._fail_started = None
|
|
199
|
+
return
|
|
200
|
+
if winapi.open_clipboard_window_hwnd() is not None:
|
|
201
|
+
self._rearm_retry("clipboard-busy")
|
|
202
|
+
return
|
|
203
|
+
t0 = time.perf_counter()
|
|
204
|
+
ok = self.store.rearm_delayed(self.hwnd)
|
|
205
|
+
dur_ms = (time.perf_counter() - t0) * 1000.0
|
|
206
|
+
if ok:
|
|
207
|
+
fails = self._rearm_fail_streak
|
|
208
|
+
self._rearm_fail_streak = 0
|
|
209
|
+
settle_ms = None
|
|
210
|
+
if self._fail_started is not None:
|
|
211
|
+
settle_ms = round((time.monotonic() - self._fail_started) * 1000.0, 1)
|
|
212
|
+
self._fail_started = None
|
|
213
|
+
if self.hwnd:
|
|
214
|
+
winapi.user32.KillTimer(self.hwnd, self.REARM_TIMER_ID)
|
|
215
|
+
last_ms, avg_ms = self.rearm_stats.add(dur_ms)
|
|
216
|
+
fields = {
|
|
217
|
+
"failed_attempts": fails,
|
|
218
|
+
"dur_ms": last_ms,
|
|
219
|
+
"avg_ms": avg_ms,
|
|
220
|
+
"samples": self.rearm_stats.count(),
|
|
221
|
+
}
|
|
222
|
+
if settle_ms is not None:
|
|
223
|
+
fields["settle_ms"] = settle_ms
|
|
224
|
+
self.audit.event("rearmed", rate_key="rearmed", **fields)
|
|
225
|
+
else:
|
|
226
|
+
self._rearm_retry("open-failed")
|
|
227
|
+
|
|
228
|
+
def _rearm_retry(self, reason: str) -> None:
|
|
229
|
+
self._rearm_fail_streak += 1
|
|
230
|
+
if self._fail_started is None:
|
|
231
|
+
self._fail_started = time.monotonic()
|
|
232
|
+
n = self._rearm_fail_streak
|
|
233
|
+
if n == 1 or n % 20 == 0:
|
|
234
|
+
self.audit.event(
|
|
235
|
+
"rearm_failed", rate_key="rearm_failed", consecutive=n, reason=reason
|
|
236
|
+
)
|
|
237
|
+
if self.hwnd:
|
|
238
|
+
winapi.user32.SetTimer(self.hwnd, self.REARM_TIMER_ID, self.REARM_RETRY_MS, None)
|
|
239
|
+
|
|
240
|
+
def _on_render(self, fmt: int) -> None:
|
|
241
|
+
self.audit.probe(stage="render-start", fmt=fmt)
|
|
242
|
+
requester_hwnd = winapi.open_clipboard_window_hwnd()
|
|
243
|
+
requester_pid = winapi.window_pid(requester_hwnd)
|
|
244
|
+
decision = self.policy.decide(requester_pid)
|
|
245
|
+
name = winapi.format_name(fmt)
|
|
246
|
+
rendered = None
|
|
247
|
+
if decision.allowed:
|
|
248
|
+
rendered = self.store.render(fmt)
|
|
249
|
+
if not rendered:
|
|
250
|
+
self.audit.event(
|
|
251
|
+
"unsupported_format",
|
|
252
|
+
fmt=fmt,
|
|
253
|
+
fmt_name=name,
|
|
254
|
+
rule=decision.rule,
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
self._post(self.CMD_REARM)
|
|
258
|
+
self.audit.probe(
|
|
259
|
+
stage="render-done",
|
|
260
|
+
fmt=fmt,
|
|
261
|
+
requester_pid=requester_pid,
|
|
262
|
+
rule=decision.rule,
|
|
263
|
+
allowed=decision.allowed,
|
|
264
|
+
rendered=rendered,
|
|
265
|
+
)
|
|
266
|
+
self.audit.access(
|
|
267
|
+
allowed=decision.allowed,
|
|
268
|
+
rule=decision.rule,
|
|
269
|
+
fmt=fmt,
|
|
270
|
+
fmt_name=name,
|
|
271
|
+
requester=decision.requester,
|
|
272
|
+
foreground=decision.foreground,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _on_renderall(self) -> None:
|
|
276
|
+
self.audit.probe(stage="renderall")
|
|
277
|
+
if self.store.active:
|
|
278
|
+
self.store.flush_back(self.hwnd)
|
|
279
|
+
|
|
280
|
+
def _on_destroy(self) -> None:
|
|
281
|
+
self.audit.probe(stage="destroy")
|
|
282
|
+
if not self.paused and self._owner_is_us() and self.store.active:
|
|
283
|
+
self.store.flush_back(self.hwnd)
|
|
284
|
+
|
|
285
|
+
def _on_pause(self) -> None:
|
|
286
|
+
self.paused = True
|
|
287
|
+
if self._owner_is_us() and self.store.active:
|
|
288
|
+
self.store.flush_back(self.hwnd)
|
|
289
|
+
self.audit.event("paused")
|
|
290
|
+
|
|
291
|
+
def _on_resume(self) -> None:
|
|
292
|
+
self.paused = False
|
|
293
|
+
self.audit.event("resumed")
|
|
294
|
+
if not self._owner_is_us():
|
|
295
|
+
self._on_update()
|
copysec/policy.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ProcInfo:
|
|
9
|
+
pid: int
|
|
10
|
+
exe: str
|
|
11
|
+
host_pid: int | None = None
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def exe_lower(self) -> str:
|
|
15
|
+
return self.exe.lower()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Decision:
|
|
20
|
+
allowed: bool
|
|
21
|
+
rule: str
|
|
22
|
+
requester: ProcInfo | None
|
|
23
|
+
foreground: ProcInfo | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Policy:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
tree,
|
|
30
|
+
allowlist: set[str] | None = None,
|
|
31
|
+
allow_uwp_frame_host: bool = False,
|
|
32
|
+
deny_unknown_requester: bool = True,
|
|
33
|
+
self_pid: int | None = None,
|
|
34
|
+
):
|
|
35
|
+
self.tree = tree
|
|
36
|
+
self.allowlist = {a.lower() for a in (allowlist or set())}
|
|
37
|
+
self.allow_uwp_frame_host = allow_uwp_frame_host
|
|
38
|
+
self.deny_unknown_requester = deny_unknown_requester
|
|
39
|
+
self.self_pid = os.getpid() if self_pid is None else self_pid
|
|
40
|
+
|
|
41
|
+
def decide(self, requester_pid: int | None) -> Decision:
|
|
42
|
+
fg = self.tree.foreground()
|
|
43
|
+
if requester_pid is None:
|
|
44
|
+
if self.deny_unknown_requester:
|
|
45
|
+
return Decision(False, "unknown-requester", None, fg)
|
|
46
|
+
return Decision(True, "unknown-requester-allowed", None, fg)
|
|
47
|
+
if requester_pid == self.self_pid:
|
|
48
|
+
return Decision(True, "self", None, fg)
|
|
49
|
+
req = self.tree.resolve(requester_pid)
|
|
50
|
+
if req is None:
|
|
51
|
+
return Decision(False, "unresolvable-requester", None, fg)
|
|
52
|
+
if req.exe_lower in self.allowlist:
|
|
53
|
+
return Decision(True, "allowlist", req, fg)
|
|
54
|
+
if fg is not None:
|
|
55
|
+
if requester_pid == fg.pid:
|
|
56
|
+
return Decision(True, "foreground-window", req, fg)
|
|
57
|
+
if fg.pid in self.tree.ancestors(requester_pid):
|
|
58
|
+
return Decision(True, "descendant-of-foreground", req, fg)
|
|
59
|
+
if self.allow_uwp_frame_host and req.exe_lower == "applicationframehost.exe":
|
|
60
|
+
return Decision(True, "uwp-frame-host", req, fg)
|
|
61
|
+
return Decision(False, "no-match", req, fg)
|