1use std::fs;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19use std::time::Duration;
20
21use serde::{Deserialize, Serialize};
22
23pub const ROTATE_BYTES: u64 = 8 << 20;
26
27const MAX_DETAIL: usize = 300;
29
30const NOTIFY_TIMEOUT: Duration = Duration::from_secs(10);
32
33pub mod kind {
35 pub const REFUSED_PEER: &str = "refused_peer";
37 pub const AUTH_FAILURE: &str = "auth_failure";
39 pub const OVERSIZED: &str = "oversized_request";
41 pub const MALFORMED: &str = "malformed_request";
43 pub const RATE_LIMITED: &str = "rate_limited";
44 pub const OUTSIDE_SANDBOX: &str = "outside_sandbox";
46 pub const HOOK_DENIED: &str = "hook_denied";
48 pub const RECONCILE_ERROR: &str = "reconcile_error";
50 pub const UNMEDIATED: &str = "unmediated_import";
52 pub const FREEZE: &str = "freeze";
54 pub const INTEGRITY_ALARM: &str = "integrity_alarm";
56 pub const TAINT: &str = "taint";
58 pub const SUDO_WITHOUT_PASSWORD: &str = "sudo_without_password";
60 pub const TOKEN_ROTATED: &str = "token_rotated";
62 pub const UNHOOKED: &str = "unhooked";
65
66 pub const ALL: [&str; 15] = [
68 REFUSED_PEER,
69 AUTH_FAILURE,
70 OVERSIZED,
71 MALFORMED,
72 RATE_LIMITED,
73 OUTSIDE_SANDBOX,
74 HOOK_DENIED,
75 RECONCILE_ERROR,
76 UNMEDIATED,
77 FREEZE,
78 INTEGRITY_ALARM,
79 TAINT,
80 SUDO_WITHOUT_PASSWORD,
81 TOKEN_ROTATED,
82 UNHOOKED,
83 ];
84
85 pub fn alarming(k: &str) -> bool {
87 matches!(k, FREEZE | INTEGRITY_ALARM)
88 }
89}
90
91#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
93pub struct Event {
94 pub time_ms: i64,
95 pub kind: String,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub repo: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub session: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub method: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub uid: Option<u32>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub code: Option<String>,
108 #[serde(default, skip_serializing_if = "String::is_empty")]
110 pub detail: String,
111}
112
113impl Event {
114 pub fn new(time_ms: i64, kind: &str) -> Self {
115 Self {
116 time_ms,
117 kind: kind.into(),
118 ..Self::default()
119 }
120 }
121
122 pub fn repo(mut self, repo: &str) -> Self {
123 self.repo = Some(clean(repo, 64));
124 self
125 }
126
127 pub fn session(mut self, session: &str) -> Self {
128 self.session = Some(clean(session, 64));
129 self
130 }
131
132 pub fn method(mut self, method: &str) -> Self {
133 self.method = Some(clean(method, 64));
134 self
135 }
136
137 pub fn uid(mut self, uid: Option<u32>) -> Self {
138 self.uid = uid;
139 self
140 }
141
142 pub fn code(mut self, code: &str) -> Self {
143 self.code = Some(clean(code, 64));
144 self
145 }
146
147 pub fn detail(mut self, detail: &str) -> Self {
148 self.detail = clean(detail, MAX_DETAIL);
149 self
150 }
151}
152
153pub fn clean(text: &str, max: usize) -> String {
157 let kept: String = text
158 .chars()
159 .map(|c| if c.is_control() { ' ' } else { c })
160 .take(max)
161 .collect();
162 mask_tokens(&kept)
163}
164
165fn mask_tokens(text: &str) -> String {
166 let mut out = String::with_capacity(text.len());
167 let mut rest = text;
168 while let Some(at) = rest.find("lonca_") {
169 out.push_str(&rest[..at]);
170 let tail = &rest[at + "lonca_".len()..];
171 let tail = tail.strip_prefix("h_").unwrap_or(tail);
172 let hex = tail.bytes().take_while(u8::is_ascii_hexdigit).count();
173 if hex >= 16 {
174 out.push_str("lonca_[token]");
175 rest = &tail[hex..];
176 } else {
177 out.push_str("lonca_");
178 rest = &rest[at + "lonca_".len()..];
179 }
180 }
181 out.push_str(rest);
182 out
183}
184
185#[derive(Clone, Debug)]
187pub enum Notify {
188 Off,
190 Desktop,
193 Command(PathBuf),
196}
197
198pub struct SecurityLog {
200 path: PathBuf,
201 rotate_bytes: u64,
202 notify: Notify,
203 write: Mutex<()>,
204}
205
206impl SecurityLog {
207 pub fn new(home: &Path, notify: Notify) -> Self {
208 Self {
209 path: home.join("security.log"),
210 rotate_bytes: ROTATE_BYTES,
211 notify,
212 write: Mutex::new(()),
213 }
214 }
215
216 pub fn path(&self) -> &Path {
217 &self.path
218 }
219
220 pub fn record(&self, event: Event) {
223 let alarming = kind::alarming(&event.kind);
224 if let Err(e) = self.append(&event) {
225 eprintln!("lonca: security log {}: {e}", self.path.display());
226 }
227 if alarming {
228 self.tell(&event);
229 }
230 }
231
232 fn append(&self, event: &Event) -> std::io::Result<()> {
233 let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
234 if fs::metadata(&self.path).is_ok_and(|m| m.len() >= self.rotate_bytes) {
235 fs::rename(&self.path, self.path.with_extension("log.1"))?;
236 }
237 let mut opts = fs::OpenOptions::new();
238 opts.create(true).append(true);
239 #[cfg(unix)]
240 {
241 use std::os::unix::fs::OpenOptionsExt;
242 opts.mode(0o600);
243 }
244 let mut f = opts.open(&self.path)?;
245 let mut line = serde_json::to_vec(event).map_err(std::io::Error::other)?;
246 line.push(b'\n');
247 f.write_all(&line)
248 }
249
250 pub fn read_since(&self, since_ms: i64) -> Vec<Event> {
252 let _held = self.write.lock().unwrap_or_else(|e| e.into_inner());
253 let mut out = Vec::new();
254 for p in [self.path.with_extension("log.1"), self.path.clone()] {
255 let Ok(text) = fs::read_to_string(&p) else {
256 continue;
257 };
258 let events = text
259 .lines()
260 .filter_map(|l| serde_json::from_str::<Event>(l).ok());
261 out.extend(events.filter(|e| e.time_ms >= since_ms));
262 }
263 out
264 }
265
266 fn tell(&self, event: &Event) {
269 let message = format!(
270 "{}{}: {}",
271 event.kind,
272 event
273 .repo
274 .as_ref()
275 .map(|r| format!(" in {r}"))
276 .unwrap_or_default(),
277 event.detail
278 );
279 let line = serde_json::to_string(event).unwrap_or_default();
280 let notify = self.notify.clone();
281 std::thread::spawn(move || {
282 let child = match notify {
283 Notify::Off => return,
284 Notify::Desktop => desktop(&message),
285 Notify::Command(p) => command(&p, &line),
286 };
287 if let Some(mut child) = child {
288 let started = std::time::Instant::now();
289 while started.elapsed() < NOTIFY_TIMEOUT {
290 if child.try_wait().ok().flatten().is_some() {
291 return;
292 }
293 std::thread::sleep(Duration::from_millis(50));
294 }
295 let _ = child.kill();
296 let _ = child.wait();
297 }
298 });
299 }
300}
301
302fn desktop(message: &str) -> Option<std::process::Child> {
303 use std::process::{Command, Stdio};
304 let mut cmd = if cfg!(target_os = "macos") {
305 let mut c = Command::new("/usr/bin/osascript");
306 c.args([
308 "-e",
309 "on run argv",
310 "-e",
311 "display notification (item 1 of argv) with title \"Lonca\"",
312 "-e",
313 "end run",
314 message,
315 ]);
316 c
317 } else {
318 let mut c = Command::new("notify-send");
319 c.args(["--app-name=Lonca", "Lonca", message]);
320 c
321 };
322 cmd.stdin(Stdio::null())
323 .stdout(Stdio::null())
324 .stderr(Stdio::null());
325 cmd.spawn().ok()
326}
327
328fn command(program: &Path, line: &str) -> Option<std::process::Child> {
329 use std::process::{Command, Stdio};
330 let mut child = Command::new(program)
331 .stdin(Stdio::piped())
332 .stdout(Stdio::null())
333 .stderr(Stdio::null())
334 .spawn()
335 .ok()?;
336 if let Some(mut stdin) = child.stdin.take() {
337 let _ = writeln!(stdin, "{line}");
338 }
339 Some(child)
340}
341
342pub fn summary(events: &[Event]) -> std::collections::BTreeMap<String, usize> {
344 let mut out = std::collections::BTreeMap::new();
345 for e in events {
346 *out.entry(e.kind.clone()).or_insert(0) += 1;
347 }
348 out
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn free_text_holds_no_token_and_no_control() {
357 let token = format!("lonca_{}", "ab".repeat(32));
358 let human = format!("lonca_h_{}", "cd".repeat(32));
359 let said = clean(&format!("a {token} b {human}\x1b[2J\nc lonca_x"), 300);
360 assert_eq!(said, "a lonca_[token] b lonca_[token] [2J c lonca_x");
361 assert_eq!(clean("abcdef", 3), "abc");
362 }
363
364 #[test]
365 fn events_go_to_a_private_file_that_rotates() {
366 let dir = tempfile::tempdir().unwrap();
367 let mut log = SecurityLog::new(dir.path(), Notify::Off);
368 log.rotate_bytes = 200;
369 for i in 0..10 {
370 log.record(
371 Event::new(i, kind::AUTH_FAILURE)
372 .method("status")
373 .code("unauthenticated"),
374 );
375 }
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::PermissionsExt;
379 let mode = fs::metadata(log.path()).unwrap().permissions().mode();
380 assert_eq!(mode & 0o777, 0o600);
381 }
382 assert!(dir.path().join("security.log.1").exists());
383 let all = log.read_since(0);
384 assert!(!all.is_empty() && all.len() <= 10);
385 assert!(
386 all.windows(2).all(|w| w[0].time_ms < w[1].time_ms),
387 "oldest first"
388 );
389 assert_eq!(log.read_since(9).len(), 1);
390 assert_eq!(summary(&all)[kind::AUTH_FAILURE], all.len());
391 }
392
393 #[test]
394 fn an_alarm_runs_the_notify_command_with_the_event() {
395 let dir = tempfile::tempdir().unwrap();
396 let out = dir.path().join("told");
397 let script = dir.path().join("notify.sh");
398 fs::write(&script, format!("#!/bin/sh\ncat > {}\n", out.display())).unwrap();
399 #[cfg(unix)]
400 {
401 use std::os::unix::fs::PermissionsExt;
402 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
403 }
404 let log = SecurityLog::new(dir.path(), Notify::Command(script));
405 log.record(Event::new(1, kind::AUTH_FAILURE));
406 log.record(
407 Event::new(2, kind::FREEZE)
408 .repo("demo")
409 .detail("refs/heads/main moved"),
410 );
411 let mut told = String::new();
412 for _ in 0..200 {
413 told = fs::read_to_string(&out).unwrap_or_default();
414 if told.ends_with('\n') {
415 break;
416 }
417 std::thread::sleep(Duration::from_millis(10));
418 }
419 let event: Event = serde_json::from_str(told.trim()).unwrap();
420 assert_eq!(event.kind, kind::FREEZE);
421 assert_eq!(event.repo.as_deref(), Some("demo"));
422 }
423}