opsentry 1.8.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.
- opsentry/VERSION +1 -0
- opsentry/__init__.py +5 -0
- opsentry/cli.py +295 -0
- opsentry/config/__init__.py +0 -0
- opsentry/config/generate.py +414 -0
- opsentry/config/hook_templates/block-dangerous-commands.sh.j2 +102 -0
- opsentry/config/hook_templates/block-data-exfiltration.sh.j2 +94 -0
- opsentry/config/hook_templates/block-environment-escape.sh.j2 +99 -0
- opsentry/config/hook_templates/block-git-commands.sh.j2 +46 -0
- opsentry/config/hook_templates/block-package-install.sh.j2 +95 -0
- opsentry/config/hook_templates/block-pii-leakage.sh.j2 +100 -0
- opsentry/config/hook_templates/block-scope-escape.sh.j2 +115 -0
- opsentry/config/hook_templates/block-sensitive-files.sh.j2 +97 -0
- opsentry/config/init_wizard.py +201 -0
- opsentry/config/rule_texts/01_sensitive_files.md +15 -0
- opsentry/config/rule_texts/02_client_data.md +10 -0
- opsentry/config/rule_texts/03_credentials.md +17 -0
- opsentry/config/rule_texts/04_sql_safety.md +33 -0
- opsentry/config/rule_texts/05_dangerous_code.md +29 -0
- opsentry/config/rule_texts/06_frontend_xss.md +9 -0
- opsentry/config/rule_texts/07_git_operations_block_all.md +13 -0
- opsentry/config/rule_texts/07_git_operations_read_only.md +18 -0
- opsentry/config/rule_texts/08_destructive_commands.md +12 -0
- opsentry/config/rule_texts/09_database_safety.md +17 -0
- opsentry/config/rule_texts/10_network_requests.md +10 -0
- opsentry/config/rule_texts/11_code_quality.md +13 -0
- opsentry/config/rule_texts/12_data_exfiltration.md +10 -0
- opsentry/config/rule_texts/13_package_install.md +9 -0
- opsentry/config/rule_texts/14_scope_boundaries.md +8 -0
- opsentry/config/rule_texts/15_env_isolation.md +9 -0
- opsentry/config/rule_texts/16_resource_limits.md +8 -0
- opsentry/config/rule_texts/17_compliance_privacy.md +9 -0
- opsentry/config/rule_texts/18_when_in_doubt.md +10 -0
- opsentry/config/schema.py +142 -0
- opsentry/config/templates/generic.yaml +84 -0
- opsentry/deployment/.claude/commands/code-health.md +42 -0
- opsentry/deployment/.claude/commands/governance-check.md +29 -0
- opsentry/deployment/.claude/commands/security-audit.md +41 -0
- opsentry/deployment/baseline.py +440 -0
- opsentry/deployment/blocklog_audit.py +497 -0
- opsentry/deployment/claude/CLAUDE.md +300 -0
- opsentry/deployment/claude/hooks/block-dangerous-commands.sh +106 -0
- opsentry/deployment/claude/hooks/block-data-exfiltration.sh +99 -0
- opsentry/deployment/claude/hooks/block-environment-escape.sh +96 -0
- opsentry/deployment/claude/hooks/block-git-commands.sh +37 -0
- opsentry/deployment/claude/hooks/block-package-install.sh +103 -0
- opsentry/deployment/claude/hooks/block-pii-leakage.sh +86 -0
- opsentry/deployment/claude/hooks/block-scope-escape.sh +210 -0
- opsentry/deployment/claude/hooks/block-sensitive-files.sh +97 -0
- opsentry/deployment/claude/settings.json +72 -0
- opsentry/deployment/install.sh +343 -0
- opsentry/deployment/patrol.sh +266 -0
- opsentry/deployment/test.sh +728 -0
- opsentry/deployment/update.sh +19 -0
- opsentry/deployment/verify.sh +201 -0
- opsentry-1.8.0.dist-info/METADATA +158 -0
- opsentry-1.8.0.dist-info/RECORD +61 -0
- opsentry-1.8.0.dist-info/WHEEL +5 -0
- opsentry-1.8.0.dist-info/entry_points.txt +2 -0
- opsentry-1.8.0.dist-info/licenses/LICENSE +190 -0
- opsentry-1.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Block log pattern analyzer for OpSentry.
|
|
4
|
+
Parses ~/.claude/guardrail-blocks.log (JSON-lines) and runs pattern detectors
|
|
5
|
+
that look for repeat-offender, rapid-burst, multi-hook coverage, exfiltration
|
|
6
|
+
cluster, and self-modification attempt signatures so patrol.sh can flag
|
|
7
|
+
suspicious activity beyond simple block counts. Each detector emits a
|
|
8
|
+
structured finding (info/warn/fail) consumed by patrol.sh in the same format
|
|
9
|
+
as baseline.py for visual consistency.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import datetime
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Detection thresholds
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
# Repeat offender: same hook firing N+ times in the last hour.
|
|
26
|
+
REPEAT_OFFENDER_WARN = 5
|
|
27
|
+
REPEAT_OFFENDER_FAIL = 20
|
|
28
|
+
|
|
29
|
+
# Rapid burst: any 30-second window with N+ blocks.
|
|
30
|
+
RAPID_BURST_WINDOW_SECONDS = 30
|
|
31
|
+
RAPID_BURST_WARN = 5
|
|
32
|
+
|
|
33
|
+
# Multi-hook coverage: distinct hooks fired in the last hour.
|
|
34
|
+
MULTI_HOOK_WARN = 3
|
|
35
|
+
MULTI_HOOK_FAIL = 5
|
|
36
|
+
|
|
37
|
+
# Exfiltration cluster: sensitive-files + data-exfiltration within 5 minutes.
|
|
38
|
+
EXFIL_CLUSTER_WINDOW_SECONDS = 300
|
|
39
|
+
|
|
40
|
+
# Self-modification attempts (Attack 3 — guardrail self-destruction).
|
|
41
|
+
SELF_MOD_WARN = 1
|
|
42
|
+
SELF_MOD_FAIL = 3
|
|
43
|
+
|
|
44
|
+
DEFAULT_WINDOW_HOURS = 24
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Log parsing
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LogEntry:
|
|
54
|
+
"""One parsed line from guardrail-blocks.log."""
|
|
55
|
+
|
|
56
|
+
timestamp: datetime.datetime
|
|
57
|
+
hook: str
|
|
58
|
+
reason: str
|
|
59
|
+
detail: str
|
|
60
|
+
user: str
|
|
61
|
+
raw: dict = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_timestamp(value: str) -> datetime.datetime | None:
|
|
65
|
+
"""Parse an ISO-8601 UTC timestamp like 2026-04-11T14:30:00Z."""
|
|
66
|
+
if not value:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
# Python 3.10's fromisoformat doesn't accept the trailing Z; strip it.
|
|
70
|
+
cleaned = value.rstrip("Z")
|
|
71
|
+
dt = datetime.datetime.fromisoformat(cleaned)
|
|
72
|
+
if dt.tzinfo is None:
|
|
73
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
74
|
+
return dt
|
|
75
|
+
except (ValueError, TypeError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_log(log_path: Path) -> list[LogEntry]:
|
|
80
|
+
"""
|
|
81
|
+
Read a JSON-lines guardrail block log and return parsed entries.
|
|
82
|
+
|
|
83
|
+
Lines that are empty, not valid JSON, or missing a parseable timestamp
|
|
84
|
+
are silently skipped — the goal is best-effort analysis, not strict
|
|
85
|
+
validation.
|
|
86
|
+
"""
|
|
87
|
+
entries: list[LogEntry] = []
|
|
88
|
+
if not log_path.is_file():
|
|
89
|
+
return entries
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
raw = log_path.read_text(errors="ignore")
|
|
93
|
+
except OSError:
|
|
94
|
+
return entries
|
|
95
|
+
|
|
96
|
+
for line in raw.splitlines():
|
|
97
|
+
line = line.strip()
|
|
98
|
+
if not line:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
obj = json.loads(line)
|
|
102
|
+
except (ValueError, json.JSONDecodeError):
|
|
103
|
+
continue
|
|
104
|
+
if not isinstance(obj, dict):
|
|
105
|
+
continue
|
|
106
|
+
ts = _parse_timestamp(obj.get("timestamp", ""))
|
|
107
|
+
if ts is None:
|
|
108
|
+
continue
|
|
109
|
+
entries.append(
|
|
110
|
+
LogEntry(
|
|
111
|
+
timestamp=ts,
|
|
112
|
+
hook=str(obj.get("hook", "")),
|
|
113
|
+
reason=str(obj.get("reason", "")),
|
|
114
|
+
detail=str(obj.get("detail", "")),
|
|
115
|
+
user=str(obj.get("user", "")),
|
|
116
|
+
raw=obj,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
entries.sort(key=lambda e: e.timestamp)
|
|
120
|
+
return entries
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Detectors
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _within_window(
|
|
129
|
+
entries: list[LogEntry], now: datetime.datetime, window_seconds: int
|
|
130
|
+
) -> list[LogEntry]:
|
|
131
|
+
"""Return entries whose timestamp is within `window_seconds` of `now`."""
|
|
132
|
+
cutoff = now - datetime.timedelta(seconds=window_seconds)
|
|
133
|
+
return [e for e in entries if e.timestamp >= cutoff]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect_total_stats(
|
|
137
|
+
entries: list[LogEntry], now: datetime.datetime, window_hours: int
|
|
138
|
+
) -> dict:
|
|
139
|
+
"""INFO finding with total count and last-window count."""
|
|
140
|
+
last_window = _within_window(entries, now, window_hours * 3600)
|
|
141
|
+
return {
|
|
142
|
+
"status": "info",
|
|
143
|
+
"check": "total_blocks",
|
|
144
|
+
"message": (
|
|
145
|
+
f"{len(entries)} total blocks logged "
|
|
146
|
+
f"({len(last_window)} in last {window_hours}h)"
|
|
147
|
+
),
|
|
148
|
+
"details": {
|
|
149
|
+
"total": len(entries),
|
|
150
|
+
"last_window": len(last_window),
|
|
151
|
+
"window_hours": window_hours,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def detect_repeat_offender(
|
|
157
|
+
entries: list[LogEntry], now: datetime.datetime
|
|
158
|
+
) -> list[dict]:
|
|
159
|
+
"""Same hook firing N+ times in the last hour."""
|
|
160
|
+
findings: list[dict] = []
|
|
161
|
+
last_hour = _within_window(entries, now, 3600)
|
|
162
|
+
if not last_hour:
|
|
163
|
+
return findings
|
|
164
|
+
|
|
165
|
+
counts: dict[str, int] = {}
|
|
166
|
+
for entry in last_hour:
|
|
167
|
+
counts[entry.hook] = counts.get(entry.hook, 0) + 1
|
|
168
|
+
|
|
169
|
+
flagged = False
|
|
170
|
+
for hook, count in sorted(counts.items(), key=lambda kv: -kv[1]):
|
|
171
|
+
if count >= REPEAT_OFFENDER_FAIL:
|
|
172
|
+
findings.append(
|
|
173
|
+
{
|
|
174
|
+
"status": "fail",
|
|
175
|
+
"check": "repeat_offender",
|
|
176
|
+
"message": (
|
|
177
|
+
f"hook '{hook}' fired {count} times in last 1h "
|
|
178
|
+
f"(>= {REPEAT_OFFENDER_FAIL} = sustained attack signature)"
|
|
179
|
+
),
|
|
180
|
+
"details": {"hook": hook, "count": count},
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
flagged = True
|
|
184
|
+
elif count >= REPEAT_OFFENDER_WARN:
|
|
185
|
+
findings.append(
|
|
186
|
+
{
|
|
187
|
+
"status": "warn",
|
|
188
|
+
"check": "repeat_offender",
|
|
189
|
+
"message": (
|
|
190
|
+
f"hook '{hook}' fired {count} times in last 1h "
|
|
191
|
+
f"(>= {REPEAT_OFFENDER_WARN} = repeated bypass attempts)"
|
|
192
|
+
),
|
|
193
|
+
"details": {"hook": hook, "count": count},
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
flagged = True
|
|
197
|
+
|
|
198
|
+
if not flagged:
|
|
199
|
+
findings.append(
|
|
200
|
+
{
|
|
201
|
+
"status": "pass",
|
|
202
|
+
"check": "repeat_offender",
|
|
203
|
+
"message": f"no hook fired more than {REPEAT_OFFENDER_WARN} times in last 1h",
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return findings
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def detect_rapid_burst(entries: list[LogEntry]) -> list[dict]:
|
|
210
|
+
"""Sliding 30-second window with N+ blocks anywhere in the log."""
|
|
211
|
+
findings: list[dict] = []
|
|
212
|
+
if len(entries) < RAPID_BURST_WARN:
|
|
213
|
+
findings.append(
|
|
214
|
+
{
|
|
215
|
+
"status": "pass",
|
|
216
|
+
"check": "rapid_burst",
|
|
217
|
+
"message": f"no {RAPID_BURST_WINDOW_SECONDS}s burst of {RAPID_BURST_WARN}+ blocks detected",
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
return findings
|
|
221
|
+
|
|
222
|
+
# Sliding window over sorted entries (parse_log sorts by timestamp).
|
|
223
|
+
left = 0
|
|
224
|
+
max_burst = 0
|
|
225
|
+
burst_start: datetime.datetime | None = None
|
|
226
|
+
burst_end: datetime.datetime | None = None
|
|
227
|
+
for right in range(len(entries)):
|
|
228
|
+
while (
|
|
229
|
+
entries[right].timestamp - entries[left].timestamp
|
|
230
|
+
).total_seconds() > RAPID_BURST_WINDOW_SECONDS:
|
|
231
|
+
left += 1
|
|
232
|
+
window_size = right - left + 1
|
|
233
|
+
if window_size > max_burst:
|
|
234
|
+
max_burst = window_size
|
|
235
|
+
burst_start = entries[left].timestamp
|
|
236
|
+
burst_end = entries[right].timestamp
|
|
237
|
+
|
|
238
|
+
if max_burst >= RAPID_BURST_WARN and burst_start is not None and burst_end is not None:
|
|
239
|
+
delta = (burst_end - burst_start).total_seconds()
|
|
240
|
+
findings.append(
|
|
241
|
+
{
|
|
242
|
+
"status": "warn",
|
|
243
|
+
"check": "rapid_burst",
|
|
244
|
+
"message": (
|
|
245
|
+
f"{max_burst} blocks within {delta:.1f}s "
|
|
246
|
+
f"(>= {RAPID_BURST_WARN} in {RAPID_BURST_WINDOW_SECONDS}s = automated bypass attempt)"
|
|
247
|
+
),
|
|
248
|
+
"details": {
|
|
249
|
+
"burst_size": max_burst,
|
|
250
|
+
"window_seconds": delta,
|
|
251
|
+
"first": burst_start.isoformat(),
|
|
252
|
+
"last": burst_end.isoformat(),
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
findings.append(
|
|
258
|
+
{
|
|
259
|
+
"status": "pass",
|
|
260
|
+
"check": "rapid_burst",
|
|
261
|
+
"message": f"no {RAPID_BURST_WINDOW_SECONDS}s burst of {RAPID_BURST_WARN}+ blocks detected",
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
return findings
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def detect_multi_hook_coverage(
|
|
268
|
+
entries: list[LogEntry], now: datetime.datetime
|
|
269
|
+
) -> list[dict]:
|
|
270
|
+
"""Number of distinct hooks fired in the last hour."""
|
|
271
|
+
findings: list[dict] = []
|
|
272
|
+
last_hour = _within_window(entries, now, 3600)
|
|
273
|
+
distinct = sorted({e.hook for e in last_hour if e.hook})
|
|
274
|
+
count = len(distinct)
|
|
275
|
+
|
|
276
|
+
if count >= MULTI_HOOK_FAIL:
|
|
277
|
+
findings.append(
|
|
278
|
+
{
|
|
279
|
+
"status": "fail",
|
|
280
|
+
"check": "multi_hook_coverage",
|
|
281
|
+
"message": (
|
|
282
|
+
f"{count} distinct hooks fired in last 1h "
|
|
283
|
+
f"(>= {MULTI_HOOK_FAIL} = systematic exploration): {', '.join(distinct)}"
|
|
284
|
+
),
|
|
285
|
+
"details": {"hooks": distinct, "count": count},
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
elif count >= MULTI_HOOK_WARN:
|
|
289
|
+
findings.append(
|
|
290
|
+
{
|
|
291
|
+
"status": "warn",
|
|
292
|
+
"check": "multi_hook_coverage",
|
|
293
|
+
"message": (
|
|
294
|
+
f"{count} distinct hooks fired in last 1h "
|
|
295
|
+
f"(>= {MULTI_HOOK_WARN} = unusual breadth): {', '.join(distinct)}"
|
|
296
|
+
),
|
|
297
|
+
"details": {"hooks": distinct, "count": count},
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
findings.append(
|
|
302
|
+
{
|
|
303
|
+
"status": "pass",
|
|
304
|
+
"check": "multi_hook_coverage",
|
|
305
|
+
"message": f"{count} distinct hook(s) in last 1h (below threshold)",
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
return findings
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def detect_exfil_cluster(entries: list[LogEntry]) -> list[dict]:
|
|
312
|
+
"""sensitive-files + data-exfiltration within 5 minutes = exfil primitive."""
|
|
313
|
+
findings: list[dict] = []
|
|
314
|
+
sensitive = [e for e in entries if "sensitive-files" in e.hook]
|
|
315
|
+
exfil = [e for e in entries if "data-exfiltration" in e.hook]
|
|
316
|
+
|
|
317
|
+
if not sensitive or not exfil:
|
|
318
|
+
findings.append(
|
|
319
|
+
{
|
|
320
|
+
"status": "pass",
|
|
321
|
+
"check": "exfil_cluster",
|
|
322
|
+
"message": "no sensitive-files + data-exfiltration cluster detected",
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
return findings
|
|
326
|
+
|
|
327
|
+
# O(n*m) but n,m are bounded by log size; for huge logs we'd switch to a
|
|
328
|
+
# merge-walk on the sorted streams. Acceptable v1.
|
|
329
|
+
for s in sensitive:
|
|
330
|
+
for x in exfil:
|
|
331
|
+
delta = abs((s.timestamp - x.timestamp).total_seconds())
|
|
332
|
+
if delta <= EXFIL_CLUSTER_WINDOW_SECONDS:
|
|
333
|
+
findings.append(
|
|
334
|
+
{
|
|
335
|
+
"status": "fail",
|
|
336
|
+
"check": "exfil_cluster",
|
|
337
|
+
"message": (
|
|
338
|
+
f"sensitive-files ({s.timestamp.isoformat()}) + "
|
|
339
|
+
f"data-exfiltration ({x.timestamp.isoformat()}) within "
|
|
340
|
+
f"{delta:.0f}s (canonical exfil primitive)"
|
|
341
|
+
),
|
|
342
|
+
"details": {
|
|
343
|
+
"sensitive_at": s.timestamp.isoformat(),
|
|
344
|
+
"exfil_at": x.timestamp.isoformat(),
|
|
345
|
+
"delta_seconds": delta,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
return findings # one finding is enough; don't spam
|
|
350
|
+
|
|
351
|
+
findings.append(
|
|
352
|
+
{
|
|
353
|
+
"status": "pass",
|
|
354
|
+
"check": "exfil_cluster",
|
|
355
|
+
"message": "sensitive-files and data-exfiltration both seen but never within 5 minutes",
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return findings
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def detect_self_mod_attempts(entries: list[LogEntry]) -> list[dict]:
|
|
362
|
+
"""block-scope-escape blocks tagged with Self-modification (Attack 3)."""
|
|
363
|
+
findings: list[dict] = []
|
|
364
|
+
matches = [
|
|
365
|
+
e
|
|
366
|
+
for e in entries
|
|
367
|
+
if "scope-escape" in e.hook and "self-modification" in e.reason.lower()
|
|
368
|
+
]
|
|
369
|
+
count = len(matches)
|
|
370
|
+
|
|
371
|
+
if count >= SELF_MOD_FAIL:
|
|
372
|
+
findings.append(
|
|
373
|
+
{
|
|
374
|
+
"status": "fail",
|
|
375
|
+
"check": "self_mod_attempts",
|
|
376
|
+
"message": (
|
|
377
|
+
f"{count} block-scope-escape self-modification attempts logged "
|
|
378
|
+
f"(>= {SELF_MOD_FAIL} = active Attack 3 — guardrail self-destruction)"
|
|
379
|
+
),
|
|
380
|
+
"details": {"count": count},
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
elif count >= SELF_MOD_WARN:
|
|
384
|
+
findings.append(
|
|
385
|
+
{
|
|
386
|
+
"status": "warn",
|
|
387
|
+
"check": "self_mod_attempts",
|
|
388
|
+
"message": (
|
|
389
|
+
f"{count} block-scope-escape self-modification attempt(s) logged — "
|
|
390
|
+
"review the agent's recent actions"
|
|
391
|
+
),
|
|
392
|
+
"details": {"count": count},
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
findings.append(
|
|
397
|
+
{
|
|
398
|
+
"status": "pass",
|
|
399
|
+
"check": "self_mod_attempts",
|
|
400
|
+
"message": "no guardrail self-modification attempts logged",
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
return findings
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Top-level analyze
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def analyze(
|
|
412
|
+
entries: list[LogEntry],
|
|
413
|
+
now: datetime.datetime | None = None,
|
|
414
|
+
window_hours: int = DEFAULT_WINDOW_HOURS,
|
|
415
|
+
) -> tuple[bool, list[dict]]:
|
|
416
|
+
"""
|
|
417
|
+
Run all detectors against `entries` and return (any_fail, findings).
|
|
418
|
+
|
|
419
|
+
`now` defaults to the latest entry's timestamp (or wall-clock UTC if the
|
|
420
|
+
log is empty), so the analyzer is deterministic for replayed logs and
|
|
421
|
+
test fixtures.
|
|
422
|
+
"""
|
|
423
|
+
if now is None:
|
|
424
|
+
if entries:
|
|
425
|
+
now = entries[-1].timestamp
|
|
426
|
+
else:
|
|
427
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
428
|
+
|
|
429
|
+
findings: list[dict] = [detect_total_stats(entries, now, window_hours)]
|
|
430
|
+
findings.extend(detect_repeat_offender(entries, now))
|
|
431
|
+
findings.extend(detect_rapid_burst(entries))
|
|
432
|
+
findings.extend(detect_multi_hook_coverage(entries, now))
|
|
433
|
+
findings.extend(detect_exfil_cluster(entries))
|
|
434
|
+
findings.extend(detect_self_mod_attempts(entries))
|
|
435
|
+
|
|
436
|
+
any_fail = any(f["status"] == "fail" for f in findings)
|
|
437
|
+
return any_fail, findings
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# CLI
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _print_findings(findings: list[dict], verbose: bool = False) -> None:
|
|
446
|
+
for f in findings:
|
|
447
|
+
status = f["status"]
|
|
448
|
+
if not verbose and status == "pass":
|
|
449
|
+
continue
|
|
450
|
+
check = f["check"]
|
|
451
|
+
message = f["message"]
|
|
452
|
+
print(f" {status.upper()} {check} — {message}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def main() -> int:
|
|
456
|
+
parser = argparse.ArgumentParser(
|
|
457
|
+
description="OpSentry block log pattern analyzer.",
|
|
458
|
+
)
|
|
459
|
+
parser.add_argument(
|
|
460
|
+
"command",
|
|
461
|
+
choices=["analyze"],
|
|
462
|
+
help="The only supported command for now.",
|
|
463
|
+
)
|
|
464
|
+
parser.add_argument(
|
|
465
|
+
"--log-file",
|
|
466
|
+
required=True,
|
|
467
|
+
type=Path,
|
|
468
|
+
help="Path to guardrail-blocks.log (JSON-lines).",
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--window-hours",
|
|
472
|
+
type=int,
|
|
473
|
+
default=DEFAULT_WINDOW_HOURS,
|
|
474
|
+
help=f"Recent-window size for stats (default {DEFAULT_WINDOW_HOURS}).",
|
|
475
|
+
)
|
|
476
|
+
parser.add_argument(
|
|
477
|
+
"--quiet",
|
|
478
|
+
action="store_true",
|
|
479
|
+
help="Hide PASS findings; only show INFO/WARN/FAIL.",
|
|
480
|
+
)
|
|
481
|
+
parser.add_argument(
|
|
482
|
+
"--verbose",
|
|
483
|
+
action="store_true",
|
|
484
|
+
help="Show all findings including PASS.",
|
|
485
|
+
)
|
|
486
|
+
args = parser.parse_args()
|
|
487
|
+
|
|
488
|
+
entries = parse_log(args.log_file)
|
|
489
|
+
any_fail, findings = analyze(entries, window_hours=args.window_hours)
|
|
490
|
+
|
|
491
|
+
# Default behavior: hide PASS, show others. --verbose forces PASS too.
|
|
492
|
+
_print_findings(findings, verbose=args.verbose)
|
|
493
|
+
return 1 if any_fail else 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
if __name__ == "__main__":
|
|
497
|
+
sys.exit(main())
|