1use anyhow::{Context, Result};
18
19use crate::db::SearchFilter;
20use crate::{backup, config, confirm, db, filter};
21
22const REDACTED: &str = "[REDACTED]";
24const REDACTED_COMMAND: &str = "[REDACTED COMMAND]";
26const PREVIEW_SAMPLES: usize = 5;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
31pub enum Category {
32 Password,
33 Token,
34 ApiKey,
35 BearerToken,
36 PrivateKey,
37 CredentialUrl,
38 EnvSecret,
39 UnknownSensitive,
40}
41
42impl Category {
43 pub fn label(self) -> &'static str {
45 match self {
46 Category::Password => "password",
47 Category::Token => "token",
48 Category::ApiKey => "api_key",
49 Category::BearerToken => "bearer_token",
50 Category::PrivateKey => "private_key",
51 Category::CredentialUrl => "credential_url",
52 Category::EnvSecret => "env_secret",
53 Category::UnknownSensitive => "unknown_sensitive",
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct Finding {
61 pub categories: Vec<Category>,
63 pub redacted: String,
65}
66
67impl Finding {
68 pub fn categories_label(&self) -> String {
70 self.categories
71 .iter()
72 .map(|c| c.label())
73 .collect::<Vec<_>>()
74 .join(",")
75 }
76}
77
78fn already_redacted(command: &str) -> bool {
80 command.contains(REDACTED) || command.contains(REDACTED_COMMAND)
81}
82
83fn is_name_char(c: char) -> bool {
85 c.is_ascii_alphanumeric() || c == '_'
86}
87
88fn find_ci(lc: &[char], pat: &[char], from: usize) -> Option<usize> {
92 if pat.is_empty() || lc.len() < pat.len() {
93 return None;
94 }
95 let last = lc.len() - pat.len();
96 (from..=last).find(|&i| lc[i..i + pat.len()] == *pat)
97}
98
99fn read_value(chars: &[char], start: usize) -> (String, usize) {
103 let n = chars.len();
104 if start >= n {
105 return (String::new(), start);
106 }
107 let quote = chars[start];
108 if quote == '"' || quote == '\'' {
109 let mut j = start + 1;
110 let mut value = String::new();
111 while j < n && chars[j] != quote {
112 value.push(chars[j]);
113 j += 1;
114 }
115 let end = if j < n { j + 1 } else { j };
116 (value, end)
117 } else {
118 let mut j = start;
119 let mut value = String::new();
120 while j < n && !chars[j].is_whitespace() {
121 value.push(chars[j]);
122 j += 1;
123 }
124 (value, j)
125 }
126}
127
128fn assignment_category(name: &str) -> Option<Category> {
130 let upper = name.to_ascii_uppercase();
131 if upper.contains("PASSWORD") || upper.contains("PASSWD") || upper == "PASS" {
132 Some(Category::Password)
133 } else if upper.contains("API_KEY") || upper.contains("APIKEY") {
134 Some(Category::ApiKey)
135 } else if upper.contains("TOKEN") {
136 Some(Category::Token)
137 } else if upper.contains("SECRET")
138 || upper.contains("ACCESS_KEY")
139 || upper.contains("PRIVATE_KEY")
140 || upper.contains("CREDENTIAL")
141 {
142 Some(Category::EnvSecret)
143 } else {
144 None
145 }
146}
147
148fn scan_assignments(chars: &[char]) -> Vec<(Category, String)> {
151 let n = chars.len();
152 let mut out = Vec::new();
153 let mut i = 0;
154 while i < n {
155 if chars[i] == '=' {
156 let mut start = i;
157 while start > 0 && is_name_char(chars[start - 1]) {
158 start -= 1;
159 }
160 if start < i {
161 let name: String = chars[start..i].iter().collect();
162 if let Some(cat) = assignment_category(&name) {
163 let (value, end) = read_value(chars, i + 1);
164 if !value.is_empty() {
165 out.push((cat, value));
166 }
167 i = end;
168 continue;
169 }
170 }
171 }
172 i += 1;
173 }
174 out
175}
176
177fn scan_bearer(chars: &[char], lc: &[char]) -> Vec<String> {
180 let pat: Vec<char> = "bearer".chars().collect();
181 let n = chars.len();
182 let mut out = Vec::new();
183 let mut from = 0;
184 while let Some(pos) = find_ci(lc, &pat, from) {
185 let mut j = pos + pat.len();
186 let mut saw_space = false;
188 while j < n && chars[j].is_whitespace() {
189 saw_space = true;
190 j += 1;
191 }
192 if saw_space && j < n {
193 let stop = chars[j];
194 if stop != '"' && stop != '\'' {
195 let start = j;
198 while j < n && !chars[j].is_whitespace() && chars[j] != '"' && chars[j] != '\'' {
199 j += 1;
200 }
201 let value: String = chars[start..j].iter().collect();
202 if !value.is_empty() {
203 out.push(value);
204 }
205 }
206 }
207 from = pos + pat.len();
208 }
209 out
210}
211
212fn scan_credential_urls(chars: &[char], lc: &[char]) -> Vec<String> {
216 let pat: Vec<char> = "://".chars().collect();
217 let n = chars.len();
218 let mut out = Vec::new();
219 let mut from = 0;
220 while let Some(pos) = find_ci(lc, &pat, from) {
221 let userinfo_start = pos + pat.len();
222 let mut j = userinfo_start;
223 let mut at = None;
224 let mut colon = None;
225 while j < n {
226 let c = chars[j];
227 if c == '@' {
228 at = Some(j);
229 break;
230 }
231 if c == '/' || c.is_whitespace() {
232 break;
233 }
234 if c == ':' && colon.is_none() {
235 colon = Some(j);
236 }
237 j += 1;
238 }
239 if let (Some(at), Some(colon)) = (at, colon) {
240 if colon + 1 < at {
241 let password: String = chars[colon + 1..at].iter().collect();
242 if !password.is_empty() {
243 out.push(password);
244 }
245 }
246 }
247 from = userinfo_start;
248 }
249 out
250}
251
252const CLI_OPTIONS: &[(&str, Category)] = &[
254 ("--password", Category::Password),
255 ("--pass", Category::Password),
256 ("--token", Category::Token),
257 ("--access-token", Category::Token),
258 ("--api-key", Category::ApiKey),
259 ("--api_key", Category::ApiKey),
260 ("--apikey", Category::ApiKey),
261 ("--secret", Category::EnvSecret),
262];
263
264fn at_word_start(chars: &[char], i: usize) -> bool {
267 i == 0 || chars[i - 1].is_whitespace()
268}
269
270fn scan_cli_options(chars: &[char], lc: &[char]) -> Vec<(Category, String)> {
272 let n = chars.len();
273 let mut out = Vec::new();
274 for (opt, cat) in CLI_OPTIONS {
275 let pat: Vec<char> = opt.chars().collect();
276 let mut from = 0;
277 while let Some(pos) = find_ci(lc, &pat, from) {
278 from = pos + pat.len();
279 if !at_word_start(chars, pos) {
280 continue;
281 }
282 let after = pos + pat.len();
283 if after < n && chars[after] == '=' {
284 let (value, _) = read_value(chars, after + 1);
285 if !value.is_empty() {
286 out.push((*cat, value));
287 }
288 } else if after < n && chars[after].is_whitespace() {
289 let mut j = after;
290 while j < n && chars[j].is_whitespace() {
291 j += 1;
292 }
293 if j < n && chars[j] != '-' {
295 let (value, _) = read_value(chars, j);
296 if !value.is_empty() {
297 out.push((*cat, value));
298 }
299 }
300 }
301 }
302 }
303 out
304}
305
306fn mentions_db_client(lc: &[char]) -> bool {
308 for client in ["mysql", "mariadb", "psql", "mysqldump"] {
309 let pat: Vec<char> = client.chars().collect();
310 if find_ci(lc, &pat, 0).is_some() {
311 return true;
312 }
313 }
314 false
315}
316
317fn scan_db_password(chars: &[char], lc: &[char]) -> Vec<String> {
321 if !mentions_db_client(lc) {
322 return Vec::new();
323 }
324 let n = chars.len();
325 let mut out = Vec::new();
326 let mut i = 0;
327 while i + 1 < n {
328 if chars[i] == '-' && chars[i + 1] == 'p' && at_word_start(chars, i) {
329 let after = i + 2;
330 if after < n && !chars[after].is_whitespace() && chars[after] != '-' {
332 let (value, end) = read_value(chars, after);
333 if !value.is_empty() {
334 out.push(value);
335 }
336 i = end;
337 continue;
338 }
339 }
340 i += 1;
341 }
342 out
343}
344
345fn contains_private_key(command: &str) -> bool {
347 command.contains("PRIVATE KEY-----")
348}
349
350fn dedup_sorted(mut cats: Vec<Category>) -> Vec<Category> {
352 cats.sort();
353 cats.dedup();
354 cats
355}
356
357pub fn analyze(command: &str, keywords: &[String]) -> Option<Finding> {
362 if command.trim().is_empty() || already_redacted(command) {
363 return None;
364 }
365
366 let chars: Vec<char> = command.chars().collect();
367 let lc: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
368
369 let mut categories: Vec<Category> = Vec::new();
370 let mut values: Vec<String> = Vec::new();
371 let mut force_full = false;
372
373 if contains_private_key(command) {
374 categories.push(Category::PrivateKey);
375 force_full = true;
376 }
377 for value in scan_bearer(&chars, &lc) {
378 categories.push(Category::BearerToken);
379 values.push(value);
380 }
381 for value in scan_credential_urls(&chars, &lc) {
382 categories.push(Category::CredentialUrl);
383 values.push(value);
384 }
385 for (cat, value) in scan_assignments(&chars) {
386 categories.push(cat);
387 values.push(value);
388 }
389 for (cat, value) in scan_cli_options(&chars, &lc) {
390 categories.push(cat);
391 values.push(value);
392 }
393 for value in scan_db_password(&chars, &lc) {
394 categories.push(Category::Password);
395 values.push(value);
396 }
397
398 if categories.is_empty() && filter::is_sensitive(command, keywords) {
400 categories.push(Category::UnknownSensitive);
401 force_full = true;
402 }
403
404 if categories.is_empty() {
405 return None;
406 }
407
408 let redacted = build_redacted(command, &values, force_full);
409
410 Some(Finding {
411 categories: dedup_sorted(categories),
412 redacted,
413 })
414}
415
416fn build_redacted(command: &str, values: &[String], force_full: bool) -> String {
423 if force_full || values.iter().all(|v| v.is_empty()) {
424 return REDACTED_COMMAND.to_string();
425 }
426
427 let mut redacted = command.to_string();
428 for value in values {
429 if !value.is_empty() {
430 redacted = redacted.replace(value.as_str(), REDACTED);
431 }
432 }
433
434 if values
436 .iter()
437 .any(|v| !v.is_empty() && redacted.contains(v.as_str()))
438 {
439 return REDACTED_COMMAND.to_string();
440 }
441 redacted
442}
443
444struct ScanResult {
446 id: i64,
447 created_at: String,
448 finding: Finding,
449}
450
451fn collect(conn: &rusqlite::Connection, keywords: &[String]) -> Result<(usize, Vec<ScanResult>)> {
454 let records = db::all_commands(conn, &SearchFilter::default())?;
455 let scanned = records.len();
456 let mut results = Vec::new();
457 for record in records {
458 if let Some(finding) = analyze(&record.command, keywords) {
459 if finding.redacted != record.command {
460 results.push(ScanResult {
461 id: record.id,
462 created_at: record.created_at,
463 finding,
464 });
465 }
466 }
467 }
468 results.sort_by_key(|r| r.id);
470 Ok((scanned, results))
471}
472
473pub fn run_scan(limit: Option<usize>, json: bool) -> Result<()> {
475 let cfg = config::Config::load()?;
476 let conn = db::open(&config::db_path()?)?;
477 let (_scanned, results) = collect(&conn, &cfg.sensitive_keywords)?;
478
479 let shown: &[ScanResult] = match limit {
480 Some(n) => &results[..n.min(results.len())],
481 None => &results,
482 };
483
484 if json {
485 let items: Vec<_> = shown
486 .iter()
487 .map(|r| {
488 serde_json::json!({
489 "id": r.id,
490 "created_at": r.created_at,
491 "categories": r.finding.categories.iter().map(|c| c.label()).collect::<Vec<_>>(),
492 "redacted_command": r.finding.redacted,
493 })
494 })
495 .collect();
496 let value = serde_json::json!({
497 "suspected": results.len(),
498 "shown": shown.len(),
499 "results": items,
500 });
501 println!("{}", serde_json::to_string_pretty(&value)?);
502 return Ok(());
503 }
504
505 if results.is_empty() {
506 println!("Aucune commande potentiellement sensible détectée.");
507 return Ok(());
508 }
509
510 println!(
511 "Commandes potentiellement sensibles détectées : {}",
512 results.len()
513 );
514 println!();
515 println!("{:<6} {:<19} {:<20} COMMANDE", "ID", "DATE", "TYPE");
516 for r in shown {
517 let date = r.created_at.get(0..19).unwrap_or(&r.created_at);
518 println!(
519 "{:<6} {:<19} {:<20} {}",
520 r.id,
521 date,
522 r.finding.categories_label(),
523 r.finding.redacted
524 );
525 }
526 if shown.len() < results.len() {
527 println!();
528 println!(
529 "... {} résultat(s) supplémentaire(s) masqué(s) (utilisez --limit).",
530 results.len() - shown.len()
531 );
532 }
533 println!();
534 println!("Aucune modification effectuée. Pour nettoyer : mnemo secrets redact --apply.");
535 Ok(())
536}
537
538pub fn run_redact(dry_run: bool, apply: bool, assume_yes: bool, _backup: bool) -> Result<()> {
544 let _ = dry_run; let cfg = config::Config::load()?;
546 let conn = db::open(&config::db_path()?)?;
547 let (scanned, results) = collect(&conn, &cfg.sensitive_keywords)?;
548
549 println!("Commandes scannées : {scanned}");
550 println!("Commandes suspectes : {}", results.len());
551
552 if results.is_empty() {
553 println!("Rien à redacter.");
554 return Ok(());
555 }
556
557 let preview = results.len().min(PREVIEW_SAMPLES);
558 println!("Aperçu :");
559 for r in &results[..preview] {
560 println!(
561 " {:<6} {:<20} {}",
562 r.id,
563 r.finding.categories_label(),
564 r.finding.redacted
565 );
566 }
567 if results.len() > preview {
568 println!(" ... et {} autre(s).", results.len() - preview);
569 }
570
571 if !apply {
572 println!();
573 println!("[dry-run] Aucune modification effectuée. Relancez avec --apply pour redacter.");
574 return Ok(());
575 }
576
577 if !confirm::confirm(
578 &format!("Redacter {} commande(s) suspecte(s) ?", results.len()),
579 assume_yes,
580 )? {
581 println!("Redaction annulée.");
582 return Ok(());
583 }
584
585 let safety = backup::create_backup(None)
588 .context("sauvegarde préalable impossible : redaction annulée")?;
589 println!("Sauvegarde automatique : {}", safety.path.display());
590
591 let items: Vec<(i64, String)> = results
592 .iter()
593 .map(|r| (r.id, r.finding.redacted.clone()))
594 .collect();
595 let modified = db::apply_redactions(&conn, &items)?;
596
597 println!();
598 println!("Résumé :");
599 println!(" Commandes scannées : {scanned}");
600 println!(" Commandes suspectes: {}", results.len());
601 println!(" Commandes modifiées: {modified}");
602 println!(" Sauvegarde : {}", safety.path.display());
603 Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn kw() -> Vec<String> {
611 [
612 "password",
613 "token",
614 "secret",
615 "api_key",
616 "bearer",
617 "private_key",
618 ]
619 .iter()
620 .map(|s| s.to_string())
621 .collect()
622 }
623
624 fn assert_no_leak(redacted: &str, secrets: &[&str]) {
626 for s in secrets {
627 assert!(
628 !redacted.contains(s),
629 "fuite du secret {s:?} dans {redacted:?}"
630 );
631 }
632 }
633
634 #[test]
635 fn detecte_bearer_token() {
636 let f = analyze(
637 "curl -H \"Authorization: Bearer abcdef123456\" https://api.example.com",
638 &kw(),
639 )
640 .unwrap();
641 assert!(f.categories.contains(&Category::BearerToken));
642 assert_eq!(
643 f.redacted,
644 "curl -H \"Authorization: Bearer [REDACTED]\" https://api.example.com"
645 );
646 assert_no_leak(&f.redacted, &["abcdef123456"]);
647 }
648
649 #[test]
650 fn detecte_affectation_password() {
651 let f = analyze("export DB_PASSWORD=s3cr3t", &kw()).unwrap();
652 assert!(f.categories.contains(&Category::Password));
653 assert_eq!(f.redacted, "export DB_PASSWORD=[REDACTED]");
654 assert_no_leak(&f.redacted, &["s3cr3t"]);
655 }
656
657 #[test]
658 fn detecte_aws_secret_access_key() {
659 let f = analyze("export AWS_SECRET_ACCESS_KEY=abcd1234", &kw()).unwrap();
660 assert!(f.categories.contains(&Category::EnvSecret));
661 assert_eq!(f.redacted, "export AWS_SECRET_ACCESS_KEY=[REDACTED]");
662 assert_no_leak(&f.redacted, &["abcd1234"]);
663 }
664
665 #[test]
666 fn detecte_url_avec_identifiants() {
667 let f = analyze(
668 "git clone https://user:p4ssw0rd@example.com/repo.git",
669 &kw(),
670 )
671 .unwrap();
672 assert!(f.categories.contains(&Category::CredentialUrl));
673 assert_eq!(
674 f.redacted,
675 "git clone https://user:[REDACTED]@example.com/repo.git"
676 );
677 assert_no_leak(&f.redacted, &["p4ssw0rd"]);
678 }
679
680 #[test]
681 fn detecte_option_cli_password() {
682 let f = analyze("kubectl login --password hunter2 --user admin", &kw()).unwrap();
683 assert!(f.categories.contains(&Category::Password));
684 assert_eq!(
685 f.redacted,
686 "kubectl login --password [REDACTED] --user admin"
687 );
688 assert_no_leak(&f.redacted, &["hunter2"]);
689 }
690
691 #[test]
692 fn detecte_option_cli_avec_egal() {
693 let f = analyze("tool --token=abc123xyz", &kw()).unwrap();
694 assert!(f.categories.contains(&Category::Token));
695 assert_eq!(f.redacted, "tool --token=[REDACTED]");
696 assert_no_leak(&f.redacted, &["abc123xyz"]);
697 }
698
699 #[test]
700 fn detecte_mot_de_passe_mysql_attache() {
701 let f = analyze("mysql -u root -psup3rs3cret", &kw()).unwrap();
702 assert!(f.categories.contains(&Category::Password));
703 assert_eq!(f.redacted, "mysql -u root -p[REDACTED]");
704 assert_no_leak(&f.redacted, &["sup3rs3cret"]);
705 }
706
707 #[test]
708 fn detecte_fragment_cle_privee() {
709 let f = analyze("echo \"-----BEGIN OPENSSH PRIVATE KEY-----\" > id", &kw()).unwrap();
710 assert!(f.categories.contains(&Category::PrivateKey));
711 assert_eq!(f.redacted, "[REDACTED COMMAND]");
712 }
713
714 #[test]
715 fn commande_non_sensible_inchangee() {
716 assert!(analyze("ls -la", &kw()).is_none());
717 assert!(analyze("git commit -m 'fix'", &kw()).is_none());
718 assert!(analyze("mkdir -p build/out", &kw()).is_none());
719 }
720
721 #[test]
722 fn commande_deja_redactee_inchangee() {
723 assert!(analyze("export DB_PASSWORD=[REDACTED]", &kw()).is_none());
724 assert!(analyze("[REDACTED COMMAND]", &kw()).is_none());
725 }
726
727 #[test]
728 fn repli_mot_cle_redacte_toute_la_commande() {
729 let f = analyze("sshpass -e ssh host", &["sshpass".to_string()]).unwrap();
731 assert_eq!(f.categories, vec![Category::UnknownSensitive]);
732 assert_eq!(f.redacted, "[REDACTED COMMAND]");
733 }
734
735 #[test]
736 fn valeur_quotee_avec_espace() {
737 let f = analyze("env PASSWORD=\"a b c\" run", &kw()).unwrap();
738 assert_eq!(f.redacted, "env PASSWORD=\"[REDACTED]\" run");
739 assert_no_leak(&f.redacted, &["a b c"]);
740 }
741}