copysec 0.9.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- copysec-0.9.9/PKG-INFO +157 -0
- copysec-0.9.9/README.md +145 -0
- copysec-0.9.9/pyproject.toml +31 -0
- copysec-0.9.9/pyproject.toml.orig +32 -0
- copysec-0.9.9/src/copysec/__init__.py +1 -0
- copysec-0.9.9/src/copysec/__main__.py +6 -0
- copysec-0.9.9/src/copysec/audit.py +151 -0
- copysec-0.9.9/src/copysec/cli.py +106 -0
- copysec-0.9.9/src/copysec/config.py +47 -0
- copysec-0.9.9/src/copysec/guard.py +295 -0
- copysec-0.9.9/src/copysec/policy.py +61 -0
- copysec-0.9.9/src/copysec/proctree.py +55 -0
- copysec-0.9.9/src/copysec/stats.py +25 -0
- copysec-0.9.9/src/copysec/store.py +229 -0
- copysec-0.9.9/src/copysec/tray.py +59 -0
- copysec-0.9.9/src/copysec/winapi.py +569 -0
copysec-0.9.9/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: copysec
|
|
3
|
+
Version: 0.9.9
|
|
4
|
+
Summary: Clipboard guard: only the active application and its process tree can access the clipboard
|
|
5
|
+
Author: Lunixizm0
|
|
6
|
+
Author-email: Lunixizm0 <copysec@lunixizm.website>
|
|
7
|
+
Requires-Dist: pillow>=12.3.0
|
|
8
|
+
Requires-Dist: psutil>=7.2.2
|
|
9
|
+
Requires-Dist: pystray>=0.19.5
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# CopySec
|
|
14
|
+
|
|
15
|
+
Clipboard protection: only the **active (foreground) application** and its **process tree**
|
|
16
|
+
can read the clipboard. Every unauthorized read attempt receives **empty data** and is
|
|
17
|
+
logged as JSONL.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Windows does not allow ACLs on the clipboard; instead, CopySec takes ownership of the
|
|
22
|
+
clipboard via *delayed rendering*. Whenever any process calls `GetClipboardData`, the
|
|
23
|
+
system forwards the request to CopySec as a `WM_RENDERFORMAT` message; at that moment
|
|
24
|
+
`GetOpenClipboardWindow()` resolves the requester's PID and a decision is made:
|
|
25
|
+
|
|
26
|
+
- Allowed: ourselves, the foreground window's process, descendants of the foreground
|
|
27
|
+
process (child/grandchild), allowlist
|
|
28
|
+
- Denied: no format is rendered, the reader receives `NULL`, and the event is logged
|
|
29
|
+
|
|
30
|
+
The real content is kept in memory; the clipboard always remains owned by CopySec.
|
|
31
|
+
On exit or pause, the real data is written back to the clipboard.
|
|
32
|
+
|
|
33
|
+
## Installation and running
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
uv sync
|
|
37
|
+
uv run copysec # with tray icon
|
|
38
|
+
uv run copysec --no-tray --verbose # console mode + live log
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To run as administrator, open PowerShell via "Run as administrator" and run the same
|
|
42
|
+
command (not required; all APIs used work with normal privileges).
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
`%LOCALAPPDATA%\CopySec\config.json` is created on first run:
|
|
47
|
+
|
|
48
|
+
| Field | Default | Meaning |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `allowlist` | `["svchost.exe"]` | Always-allowed exe names (case-insensitive). `svchost.exe` covers the Win+V clipboard history service (cbdhsvc) |
|
|
51
|
+
| `allow_uwp_frame_host` | `false` | Legacy escape hatch: allows every request from `ApplicationFrameHost.exe` itself. Regular UWP paste works out of the box because CopySec resolves the frame-hosted app's real process; enable only if some packaged app still fails |
|
|
52
|
+
| `deny_unknown_requester` | `true` | Deny when it cannot be determined which window opened the clipboard (spyware can pass NULL) |
|
|
53
|
+
| `log_allows` | `false` | Also log allowed accesses |
|
|
54
|
+
|
|
55
|
+
Logs: `%LOCALAPPDATA%\CopySec\logs\audit-YYYYMMDD.jsonl`. When a file reaches 1 MB
|
|
56
|
+
it is rotated to `audit-YYYYMMDD.1.jsonl`, `.2.jsonl`, and so on for the rest of the day.
|
|
57
|
+
On every startup CopySec also checks the whole logs folder: if it exceeds 10 MB, the
|
|
58
|
+
oldest files are deleted until 5 MB or less remains. A `logs_pruned` record notes how
|
|
59
|
+
many files were removed.
|
|
60
|
+
Note: with `--config <path>` the log directory becomes `<path parent>\logs` instead.
|
|
61
|
+
|
|
62
|
+
Rearm benchmarking: every `rearmed` record carries `dur_ms` (the re-arm operation
|
|
63
|
+
itself), `avg_ms` (rolling mean over the last 64 re-arms), `samples`, and, when the
|
|
64
|
+
clipboard was busy before the success, `settle_ms` (time from first failed attempt to
|
|
65
|
+
success). Watch them live with `--verbose`.
|
|
66
|
+
|
|
67
|
+
## Testing
|
|
68
|
+
|
|
69
|
+
Everything (unit + integration) in one run:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
uv run pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The two integration tests (`matrix_a`, `matrix_b`) drive the real guard end to end
|
|
76
|
+
through the PowerShell scripts in `scripts\` and need an interactive desktop session;
|
|
77
|
+
each adds roughly 15-20 seconds. Useful selections:
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
uv run pytest -m "not integration" -q # unit tests only
|
|
81
|
+
uv run pytest -m integration -q # matrices only
|
|
82
|
+
powershell -File scripts\matrix_a.ps1 # denied reader to empty data, restore on exit
|
|
83
|
+
powershell -File scripts\matrix_b.ps1 # allowlisted reader to real data
|
|
84
|
+
uv run python scripts\spy_sim.py # background "spy" simulator (live monitoring)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Notes:
|
|
88
|
+
|
|
89
|
+
- The matrices take over the clipboard and briefly move window focus; do not run
|
|
90
|
+
pytest sessions in parallel.
|
|
91
|
+
- Without an interactive desktop (SSH, services) they skip automatically.
|
|
92
|
+
|
|
93
|
+
Manual scenario:
|
|
94
|
+
|
|
95
|
+
1. Start CopySec and run spy_sim in the background (it sees empty data).
|
|
96
|
+
2. Write something in Notepad, press Ctrl+A and Ctrl+C, then Ctrl+V in Notepad (works because it is foreground).
|
|
97
|
+
3. spy_sim output must stay empty; `deny` lines accumulate in the audit log.
|
|
98
|
+
4. Tray > Pause: spy_sim now sees the content. Resume closes it again.
|
|
99
|
+
|
|
100
|
+
## Known limitations
|
|
101
|
+
|
|
102
|
+
- Between the user copying and CopySec taking ownership there is a millisecond-scale
|
|
103
|
+
window in which a very fast reader can see the real data once.
|
|
104
|
+
- After an allowed read, the content stays as real clipboard data for a short time;
|
|
105
|
+
CopySec returns it to delayed mode within ~50 ms (re-arm). Other readers racing
|
|
106
|
+
during that window can see the data. Allowlisted components that read continuously,
|
|
107
|
+
such as clipboard history (cbdhsvc), keep triggering this cycle; that is normal.
|
|
108
|
+
- Owner-tied formats (CF_OWNERDISPLAY and CF_DSP*) cannot be carried across ownership
|
|
109
|
+
changes; they are skipped.
|
|
110
|
+
- Brief exposure window: after an allowed app reads the clipboard, Windows keeps the
|
|
111
|
+
real data available until CopySec re-arms delayed rendering (normally well under a
|
|
112
|
+
second, retried aggressively). A process reading inside that window may see the
|
|
113
|
+
data without being checked.
|
|
114
|
+
- UWP paste works automatically: when the foreground window belongs to
|
|
115
|
+
`ApplicationFrameHost.exe`, CopySec resolves the hosted app's process through its
|
|
116
|
+
`CoreWindow` child window and applies the normal rules to it.
|
|
117
|
+
- Rare UIPI quirks are possible with elevated (high IL) readers plus a non-admin guard;
|
|
118
|
+
if you hit issues, run both at the same integrity level.
|
|
119
|
+
|
|
120
|
+
## Troubleshooting
|
|
121
|
+
|
|
122
|
+
- **Pasted content came out empty:** The reading app is not foreground or was denied.
|
|
123
|
+
Check the `deny` lines printed with `--verbose` (the `rule` field explains why:
|
|
124
|
+
`no-match`, `unknown-requester`, ...). If needed add the exe name to `allowlist`,
|
|
125
|
+
or for exotic packaged apps that still fail enable `allow_uwp_frame_host`.
|
|
126
|
+
- **Leave the clipboard cleanly:** Close CopySec with Ctrl+C or tray > Exit (the real
|
|
127
|
+
data is written back to the clipboard). If you force-kill it from Task Manager the
|
|
128
|
+
delayed-render data goes away too and the clipboard ends up empty; that is Windows'
|
|
129
|
+
delayed rendering behavior.
|
|
130
|
+
- **Running elevated (admin PowerShell):** Supported and verified. Windows delivers
|
|
131
|
+
clipboard render messages across integrity levels, so non-elevated apps still go
|
|
132
|
+
through the normal decision path; elevation of the guard itself grants nothing to
|
|
133
|
+
any reader. Verify anytime with `scripts\xil_check.ps1` (spawns a real Medium-IL
|
|
134
|
+
reader via a scheduled task and checks both the deny and allow paths).
|
|
135
|
+
- **Do not run two copies:** Only one guard can take ownership at a time; the second
|
|
136
|
+
one waits pointlessly.
|
|
137
|
+
- **`rearm_failed` / `rearmed` pairs in the log:** Normal when an allowlisted
|
|
138
|
+
background reader (typically the clipboard history service, `cbdhsvc` inside
|
|
139
|
+
`svchost.exe`) keeps the clipboard open right after reading. CopySec re-arms its
|
|
140
|
+
delayed rendering as soon as the clipboard frees up (retried every 500 ms);
|
|
141
|
+
during that gap the real data is briefly readable by anyone (see Known limitations).
|
|
142
|
+
A `consecutive` count above a few would signal something is holding the clipboard
|
|
143
|
+
open for a long time.
|
|
144
|
+
|
|
145
|
+
## Architecture
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
src/copysec/
|
|
149
|
+
winapi.py ctypes Win32 bindings (clipboard, global memory, DIB to HBITMAP)
|
|
150
|
+
store.py real content store + adopt/flush/render
|
|
151
|
+
policy.py decision engine (foreground, process tree, allowlist)
|
|
152
|
+
proctree.py psutil-based PID/exe/ancestor-chain cache
|
|
153
|
+
guard.py hidden window, WndProc, message loop
|
|
154
|
+
audit.py JSONL audit log + rate limit
|
|
155
|
+
tray.py pystray tray icon
|
|
156
|
+
cli.py entry point
|
|
157
|
+
```
|
copysec-0.9.9/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# CopySec
|
|
2
|
+
|
|
3
|
+
Clipboard protection: only the **active (foreground) application** and its **process tree**
|
|
4
|
+
can read the clipboard. Every unauthorized read attempt receives **empty data** and is
|
|
5
|
+
logged as JSONL.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
Windows does not allow ACLs on the clipboard; instead, CopySec takes ownership of the
|
|
10
|
+
clipboard via *delayed rendering*. Whenever any process calls `GetClipboardData`, the
|
|
11
|
+
system forwards the request to CopySec as a `WM_RENDERFORMAT` message; at that moment
|
|
12
|
+
`GetOpenClipboardWindow()` resolves the requester's PID and a decision is made:
|
|
13
|
+
|
|
14
|
+
- Allowed: ourselves, the foreground window's process, descendants of the foreground
|
|
15
|
+
process (child/grandchild), allowlist
|
|
16
|
+
- Denied: no format is rendered, the reader receives `NULL`, and the event is logged
|
|
17
|
+
|
|
18
|
+
The real content is kept in memory; the clipboard always remains owned by CopySec.
|
|
19
|
+
On exit or pause, the real data is written back to the clipboard.
|
|
20
|
+
|
|
21
|
+
## Installation and running
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
uv sync
|
|
25
|
+
uv run copysec # with tray icon
|
|
26
|
+
uv run copysec --no-tray --verbose # console mode + live log
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To run as administrator, open PowerShell via "Run as administrator" and run the same
|
|
30
|
+
command (not required; all APIs used work with normal privileges).
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
`%LOCALAPPDATA%\CopySec\config.json` is created on first run:
|
|
35
|
+
|
|
36
|
+
| Field | Default | Meaning |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `allowlist` | `["svchost.exe"]` | Always-allowed exe names (case-insensitive). `svchost.exe` covers the Win+V clipboard history service (cbdhsvc) |
|
|
39
|
+
| `allow_uwp_frame_host` | `false` | Legacy escape hatch: allows every request from `ApplicationFrameHost.exe` itself. Regular UWP paste works out of the box because CopySec resolves the frame-hosted app's real process; enable only if some packaged app still fails |
|
|
40
|
+
| `deny_unknown_requester` | `true` | Deny when it cannot be determined which window opened the clipboard (spyware can pass NULL) |
|
|
41
|
+
| `log_allows` | `false` | Also log allowed accesses |
|
|
42
|
+
|
|
43
|
+
Logs: `%LOCALAPPDATA%\CopySec\logs\audit-YYYYMMDD.jsonl`. When a file reaches 1 MB
|
|
44
|
+
it is rotated to `audit-YYYYMMDD.1.jsonl`, `.2.jsonl`, and so on for the rest of the day.
|
|
45
|
+
On every startup CopySec also checks the whole logs folder: if it exceeds 10 MB, the
|
|
46
|
+
oldest files are deleted until 5 MB or less remains. A `logs_pruned` record notes how
|
|
47
|
+
many files were removed.
|
|
48
|
+
Note: with `--config <path>` the log directory becomes `<path parent>\logs` instead.
|
|
49
|
+
|
|
50
|
+
Rearm benchmarking: every `rearmed` record carries `dur_ms` (the re-arm operation
|
|
51
|
+
itself), `avg_ms` (rolling mean over the last 64 re-arms), `samples`, and, when the
|
|
52
|
+
clipboard was busy before the success, `settle_ms` (time from first failed attempt to
|
|
53
|
+
success). Watch them live with `--verbose`.
|
|
54
|
+
|
|
55
|
+
## Testing
|
|
56
|
+
|
|
57
|
+
Everything (unit + integration) in one run:
|
|
58
|
+
|
|
59
|
+
```powershell
|
|
60
|
+
uv run pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The two integration tests (`matrix_a`, `matrix_b`) drive the real guard end to end
|
|
64
|
+
through the PowerShell scripts in `scripts\` and need an interactive desktop session;
|
|
65
|
+
each adds roughly 15-20 seconds. Useful selections:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
uv run pytest -m "not integration" -q # unit tests only
|
|
69
|
+
uv run pytest -m integration -q # matrices only
|
|
70
|
+
powershell -File scripts\matrix_a.ps1 # denied reader to empty data, restore on exit
|
|
71
|
+
powershell -File scripts\matrix_b.ps1 # allowlisted reader to real data
|
|
72
|
+
uv run python scripts\spy_sim.py # background "spy" simulator (live monitoring)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
|
|
77
|
+
- The matrices take over the clipboard and briefly move window focus; do not run
|
|
78
|
+
pytest sessions in parallel.
|
|
79
|
+
- Without an interactive desktop (SSH, services) they skip automatically.
|
|
80
|
+
|
|
81
|
+
Manual scenario:
|
|
82
|
+
|
|
83
|
+
1. Start CopySec and run spy_sim in the background (it sees empty data).
|
|
84
|
+
2. Write something in Notepad, press Ctrl+A and Ctrl+C, then Ctrl+V in Notepad (works because it is foreground).
|
|
85
|
+
3. spy_sim output must stay empty; `deny` lines accumulate in the audit log.
|
|
86
|
+
4. Tray > Pause: spy_sim now sees the content. Resume closes it again.
|
|
87
|
+
|
|
88
|
+
## Known limitations
|
|
89
|
+
|
|
90
|
+
- Between the user copying and CopySec taking ownership there is a millisecond-scale
|
|
91
|
+
window in which a very fast reader can see the real data once.
|
|
92
|
+
- After an allowed read, the content stays as real clipboard data for a short time;
|
|
93
|
+
CopySec returns it to delayed mode within ~50 ms (re-arm). Other readers racing
|
|
94
|
+
during that window can see the data. Allowlisted components that read continuously,
|
|
95
|
+
such as clipboard history (cbdhsvc), keep triggering this cycle; that is normal.
|
|
96
|
+
- Owner-tied formats (CF_OWNERDISPLAY and CF_DSP*) cannot be carried across ownership
|
|
97
|
+
changes; they are skipped.
|
|
98
|
+
- Brief exposure window: after an allowed app reads the clipboard, Windows keeps the
|
|
99
|
+
real data available until CopySec re-arms delayed rendering (normally well under a
|
|
100
|
+
second, retried aggressively). A process reading inside that window may see the
|
|
101
|
+
data without being checked.
|
|
102
|
+
- UWP paste works automatically: when the foreground window belongs to
|
|
103
|
+
`ApplicationFrameHost.exe`, CopySec resolves the hosted app's process through its
|
|
104
|
+
`CoreWindow` child window and applies the normal rules to it.
|
|
105
|
+
- Rare UIPI quirks are possible with elevated (high IL) readers plus a non-admin guard;
|
|
106
|
+
if you hit issues, run both at the same integrity level.
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
- **Pasted content came out empty:** The reading app is not foreground or was denied.
|
|
111
|
+
Check the `deny` lines printed with `--verbose` (the `rule` field explains why:
|
|
112
|
+
`no-match`, `unknown-requester`, ...). If needed add the exe name to `allowlist`,
|
|
113
|
+
or for exotic packaged apps that still fail enable `allow_uwp_frame_host`.
|
|
114
|
+
- **Leave the clipboard cleanly:** Close CopySec with Ctrl+C or tray > Exit (the real
|
|
115
|
+
data is written back to the clipboard). If you force-kill it from Task Manager the
|
|
116
|
+
delayed-render data goes away too and the clipboard ends up empty; that is Windows'
|
|
117
|
+
delayed rendering behavior.
|
|
118
|
+
- **Running elevated (admin PowerShell):** Supported and verified. Windows delivers
|
|
119
|
+
clipboard render messages across integrity levels, so non-elevated apps still go
|
|
120
|
+
through the normal decision path; elevation of the guard itself grants nothing to
|
|
121
|
+
any reader. Verify anytime with `scripts\xil_check.ps1` (spawns a real Medium-IL
|
|
122
|
+
reader via a scheduled task and checks both the deny and allow paths).
|
|
123
|
+
- **Do not run two copies:** Only one guard can take ownership at a time; the second
|
|
124
|
+
one waits pointlessly.
|
|
125
|
+
- **`rearm_failed` / `rearmed` pairs in the log:** Normal when an allowlisted
|
|
126
|
+
background reader (typically the clipboard history service, `cbdhsvc` inside
|
|
127
|
+
`svchost.exe`) keeps the clipboard open right after reading. CopySec re-arms its
|
|
128
|
+
delayed rendering as soon as the clipboard frees up (retried every 500 ms);
|
|
129
|
+
during that gap the real data is briefly readable by anyone (see Known limitations).
|
|
130
|
+
A `consecutive` count above a few would signal something is holding the clipboard
|
|
131
|
+
open for a long time.
|
|
132
|
+
|
|
133
|
+
## Architecture
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/copysec/
|
|
137
|
+
winapi.py ctypes Win32 bindings (clipboard, global memory, DIB to HBITMAP)
|
|
138
|
+
store.py real content store + adopt/flush/render
|
|
139
|
+
policy.py decision engine (foreground, process tree, allowlist)
|
|
140
|
+
proctree.py psutil-based PID/exe/ancestor-chain cache
|
|
141
|
+
guard.py hidden window, WndProc, message loop
|
|
142
|
+
audit.py JSONL audit log + rate limit
|
|
143
|
+
tray.py pystray tray icon
|
|
144
|
+
cli.py entry point
|
|
145
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "copysec"
|
|
3
|
+
version = "0.9.9"
|
|
4
|
+
description = "Clipboard guard: only the active application and its process tree can access the clipboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pillow>=12.3.0",
|
|
9
|
+
"psutil>=7.2.2",
|
|
10
|
+
"pystray>=0.19.5",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Lunixizm0"
|
|
15
|
+
email = "copysec@lunixizm.website"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
copysec = "copysec.cli:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=9.1.1",
|
|
27
|
+
"ruff>=0.16.4",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
markers = ["integration: real desktop end-to-end tests via PowerShell matrices (slow)"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "copysec"
|
|
3
|
+
version = "0.9.9"
|
|
4
|
+
description = "Clipboard guard: only the active application and its process tree can access the clipboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Lunixizm0", email = "copysec@lunixizm.website" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pillow>=12.3.0",
|
|
12
|
+
"psutil>=7.2.2",
|
|
13
|
+
"pystray>=0.19.5",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
copysec = "copysec.cli:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=9.1.1",
|
|
26
|
+
"ruff>=0.16.4",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
markers = [
|
|
31
|
+
"integration: real desktop end-to-end tests via PowerShell matrices (slow)",
|
|
32
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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})
|
|
@@ -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
|
|
@@ -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
|