@masyv/secretscan 0.1.0

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.
@@ -0,0 +1,481 @@
1
+ use anyhow::Result;
2
+ use clap::{Parser, Subcommand};
3
+ use colored::Colorize;
4
+ use std::io::Read;
5
+ use std::path::PathBuf;
6
+
7
+ use secretscan::{patterns, redact, store::Store};
8
+
9
+ #[derive(Parser)]
10
+ #[command(
11
+ name = "secretscan",
12
+ about = "SecretScan — Real-time secret & credential detector for Claude Code",
13
+ long_about = "Scans text, files, and Claude Code tool outputs for API keys, tokens,\nprivate keys, database URLs, and 50+ other secret formats.\n\nDetected secrets are redacted with [REDACTED:type:fingerprint] placeholders.\nOriginals are stored locally in SQLite for recovery — never sent anywhere.",
14
+ version
15
+ )]
16
+ struct Cli {
17
+ #[command(subcommand)]
18
+ command: Commands,
19
+
20
+ /// Output as JSON
21
+ #[arg(long, global = true)]
22
+ json: bool,
23
+
24
+ /// Verbose logging
25
+ #[arg(long, short, global = true)]
26
+ verbose: bool,
27
+
28
+ /// SQLite database path
29
+ #[arg(long, global = true, default_value = "~/.secretscan/secretscan.db")]
30
+ db_path: String,
31
+ }
32
+
33
+ #[derive(Subcommand)]
34
+ enum Commands {
35
+ /// Scan text, a file, or stdin for secrets
36
+ Scan {
37
+ /// File path or '-' for stdin
38
+ #[arg(default_value = "-")]
39
+ input: String,
40
+
41
+ /// Redact detected secrets in the output
42
+ #[arg(long, short)]
43
+ redact: bool,
44
+
45
+ /// Minimum severity to report (low, medium, high, critical)
46
+ #[arg(long, default_value = "low")]
47
+ severity: String,
48
+ },
49
+
50
+ /// PostToolUse / PreToolUse hook mode (reads hook JSON from stdin)
51
+ Hook {
52
+ /// Session ID
53
+ #[arg(long, default_value = "unknown")]
54
+ session: String,
55
+ },
56
+
57
+ /// Expand a redacted secret by fingerprint (local recovery only)
58
+ Expand {
59
+ /// Fingerprint from [REDACTED:type:FINGERPRINT]
60
+ fingerprint: String,
61
+ },
62
+
63
+ /// Add a fingerprint to the allowlist (mark as safe / not a secret)
64
+ Allow {
65
+ /// Fingerprint to allowlist
66
+ fingerprint: String,
67
+
68
+ /// Reason for allowlisting
69
+ #[arg(long, short)]
70
+ reason: Option<String>,
71
+ },
72
+
73
+ /// Remove a fingerprint from the allowlist
74
+ Unallow {
75
+ /// Fingerprint to remove
76
+ fingerprint: String,
77
+ },
78
+
79
+ /// Show scan statistics
80
+ Stats,
81
+
82
+ /// List recent findings
83
+ Audit {
84
+ /// Number of findings to show
85
+ #[arg(long, default_value = "20")]
86
+ limit: usize,
87
+ },
88
+
89
+ /// List all built-in patterns
90
+ Patterns,
91
+
92
+ /// Auto-install hook into ~/.claude/settings.json
93
+ Setup {
94
+ /// Preview without writing
95
+ #[arg(long)]
96
+ dry_run: bool,
97
+ },
98
+ }
99
+
100
+ fn main() -> Result<()> {
101
+ let cli = Cli::parse();
102
+
103
+ let filter = if cli.verbose { "debug" } else { "warn" };
104
+ tracing_subscriber::fmt()
105
+ .with_env_filter(
106
+ tracing_subscriber::EnvFilter::try_from_default_env()
107
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
108
+ )
109
+ .with_target(false)
110
+ .with_writer(std::io::stderr)
111
+ .init();
112
+
113
+ let db_path = resolve_db_path(&cli.db_path)?;
114
+
115
+ match cli.command {
116
+ Commands::Scan { input, redact, severity } => {
117
+ let text = read_input(&input)?;
118
+ let min_sev = parse_severity(&severity);
119
+
120
+ let findings = patterns::scan_all(&text);
121
+ let filtered: Vec<_> = findings
122
+ .iter()
123
+ .filter(|f| f.severity >= min_sev)
124
+ .collect();
125
+
126
+ if cli.json {
127
+ println!("{}", serde_json::to_string_pretty(&filtered)?);
128
+ return Ok(());
129
+ }
130
+
131
+ if filtered.is_empty() {
132
+ println!("{}", "✅ No secrets detected.".green());
133
+ return Ok(());
134
+ }
135
+
136
+ eprintln!(
137
+ "{}",
138
+ format!("🚨 {} secret{} found:", filtered.len(),
139
+ if filtered.len() == 1 { "" } else { "s" }).red().bold()
140
+ );
141
+ eprintln!();
142
+ for f in &filtered {
143
+ eprintln!(
144
+ " {} {:8} {:<40} fingerprint: {}",
145
+ f.severity.emoji(),
146
+ format!("[{}]", f.severity.as_str().to_uppercase())
147
+ .bold(),
148
+ f.pattern_name,
149
+ f.fingerprint.dimmed()
150
+ );
151
+ let preview = if f.matched.len() > 40 {
152
+ format!("{}…", &f.matched[..37])
153
+ } else {
154
+ f.matched.clone()
155
+ };
156
+ eprintln!(" {}", preview.dimmed());
157
+ eprintln!();
158
+ }
159
+
160
+ if redact {
161
+ let result = redact::scan_and_redact(&text, None, &db_path, "cli");
162
+ print!("{}", result.redacted_text);
163
+ }
164
+
165
+ // Exit code 1 if any High/Critical found
166
+ let has_critical = filtered.iter().any(|f| {
167
+ f.severity >= secretscan::Severity::High
168
+ });
169
+ if has_critical {
170
+ std::process::exit(1);
171
+ }
172
+ }
173
+
174
+ Commands::Hook { session } => {
175
+ let mut raw = String::new();
176
+ std::io::stdin().read_to_string(&mut raw)?;
177
+
178
+ let result = secretscan::hook::process(&raw, &db_path, &session)?;
179
+ print!("{result}");
180
+
181
+ // Log to stderr if secrets were found
182
+ if let Ok(output) = serde_json::from_str::<serde_json::Value>(&result) {
183
+ if let Some(meta) = output.get("secretscan") {
184
+ let found = meta.get("secrets_found")
185
+ .and_then(|v| v.as_u64()).unwrap_or(0);
186
+ if found > 0 {
187
+ eprintln!(
188
+ "[secretscan] 🚨 {} secret{} redacted from {} output",
189
+ found,
190
+ if found == 1 { "" } else { "s" },
191
+ output.get("tool_name")
192
+ .and_then(|v| v.as_str())
193
+ .unwrap_or("tool")
194
+ );
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ Commands::Expand { fingerprint } => {
201
+ let store = Store::open(&db_path)?;
202
+ match store.get_original(&fingerprint)? {
203
+ Some(original) => {
204
+ if cli.json {
205
+ println!("{}", serde_json::json!({ "fingerprint": fingerprint, "original": original }));
206
+ } else {
207
+ println!("{original}");
208
+ }
209
+ }
210
+ None => {
211
+ eprintln!("No record found for fingerprint: {fingerprint}");
212
+ std::process::exit(1);
213
+ }
214
+ }
215
+ }
216
+
217
+ Commands::Allow { fingerprint, reason } => {
218
+ let store = Store::open(&db_path)?;
219
+ store.allow(&fingerprint, reason.as_deref())?;
220
+ if cli.json {
221
+ println!("{}", serde_json::json!({ "allowed": fingerprint, "reason": reason }));
222
+ } else {
223
+ println!("✅ {} added to allowlist.", fingerprint.green());
224
+ }
225
+ }
226
+
227
+ Commands::Unallow { fingerprint } => {
228
+ let store = Store::open(&db_path)?;
229
+ let removed = store.unallow(&fingerprint)?;
230
+ if cli.json {
231
+ println!("{}", serde_json::json!({ "removed": removed, "fingerprint": fingerprint }));
232
+ } else if removed {
233
+ println!("✅ {} removed from allowlist.", fingerprint.green());
234
+ } else {
235
+ println!("ℹ️ {} was not on the allowlist.", fingerprint);
236
+ }
237
+ }
238
+
239
+ Commands::Stats => {
240
+ let store = Store::open(&db_path)?;
241
+ let stats = store.stats()?;
242
+
243
+ if cli.json {
244
+ println!("{}", serde_json::to_string_pretty(&stats)?);
245
+ } else {
246
+ println!("{}", "SecretScan Statistics".bold());
247
+ println!("{}", "─".repeat(45));
248
+ println!(" Total scans: {}", stats.total_scans);
249
+ println!(" Bytes scanned: {}", format_bytes(stats.total_bytes_scanned));
250
+ println!(" Secrets found: {}", stats.total_secrets_found);
251
+ println!(" Secrets redacted: {}", stats.total_secrets_redacted);
252
+ println!(" Unique secrets seen: {}", stats.unique_secrets);
253
+ println!(" Allowlist entries: {}", stats.allowlist_count);
254
+ if !stats.by_severity.is_empty() {
255
+ println!();
256
+ println!(" By severity:");
257
+ for (sev, count) in &stats.by_severity {
258
+ println!(" {:<10} {}", sev, count);
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ Commands::Audit { limit } => {
265
+ let store = Store::open(&db_path)?;
266
+ let findings = store.recent_findings(limit)?;
267
+
268
+ if cli.json {
269
+ println!("{}", serde_json::to_string_pretty(&findings)?);
270
+ return Ok(());
271
+ }
272
+
273
+ if findings.is_empty() {
274
+ println!("No findings recorded yet.");
275
+ return Ok(());
276
+ }
277
+
278
+ println!("{}", format!("Recent {} findings:", findings.len()).bold());
279
+ println!("{}", "─".repeat(70));
280
+ for f in &findings {
281
+ println!(
282
+ " {} {:<12} {:<35} {}",
283
+ f.fingerprint.dimmed(),
284
+ f.severity.bold(),
285
+ f.pattern_name,
286
+ f.detected_at.dimmed()
287
+ );
288
+ }
289
+ }
290
+
291
+ Commands::Patterns => {
292
+ if cli.json {
293
+ let list: Vec<_> = patterns::PATTERNS.iter().map(|p| {
294
+ serde_json::json!({
295
+ "id": p.id,
296
+ "name": p.name,
297
+ "severity": p.severity.as_str()
298
+ })
299
+ }).collect();
300
+ println!("{}", serde_json::to_string_pretty(&list)?);
301
+ return Ok(());
302
+ }
303
+
304
+ println!("{}", format!("Built-in patterns ({}):", patterns::PATTERNS.len()).bold());
305
+ println!("{}", "─".repeat(65));
306
+ for p in patterns::PATTERNS.iter() {
307
+ println!(
308
+ " {} {:8} {:<20} {}",
309
+ p.severity.emoji(),
310
+ format!("[{}]", p.severity.as_str().to_uppercase()),
311
+ p.id,
312
+ p.name
313
+ );
314
+ }
315
+ }
316
+
317
+ Commands::Setup { dry_run } => {
318
+ run_setup(dry_run, &cli.json)?;
319
+ }
320
+ }
321
+
322
+ Ok(())
323
+ }
324
+
325
+ // ─── Setup ────────────────────────────────────────────────────────────────────
326
+
327
+ fn run_setup(dry_run: bool, json: &bool) -> Result<()> {
328
+ let settings_path = {
329
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
330
+ PathBuf::from(home).join(".claude/settings.json")
331
+ };
332
+
333
+ let binary = std::env::current_exe()
334
+ .ok()
335
+ .map(|p| p.to_string_lossy().to_string())
336
+ .unwrap_or_else(|| "secretscan".to_string());
337
+
338
+ let hook_command = format!("{binary} hook --session ${{CLAUDE_SESSION_ID:-default}}");
339
+
340
+ // Read existing settings
341
+ let mut settings = if settings_path.exists() {
342
+ let data = std::fs::read_to_string(&settings_path)?;
343
+ serde_json::from_str::<serde_json::Value>(&data).unwrap_or(serde_json::json!({}))
344
+ } else {
345
+ serde_json::json!({})
346
+ };
347
+
348
+ // Check if already configured
349
+ let already = is_already_configured(&settings);
350
+
351
+ if already {
352
+ let msg = "SecretScan hook already configured — nothing to do.";
353
+ if *json {
354
+ println!("{}", serde_json::json!({ "already_configured": true, "message": msg }));
355
+ } else {
356
+ println!("✅ {msg}");
357
+ }
358
+ return Ok(());
359
+ }
360
+
361
+ if !dry_run {
362
+ // Inject hook
363
+ let hook_entry = serde_json::json!({
364
+ "matcher": "*",
365
+ "hooks": [{ "type": "command", "command": hook_command }]
366
+ });
367
+ let hooks = settings
368
+ .as_object_mut()
369
+ .unwrap()
370
+ .entry("hooks")
371
+ .or_insert(serde_json::json!({}));
372
+ let ptu = hooks
373
+ .as_object_mut()
374
+ .unwrap()
375
+ .entry("PostToolUse")
376
+ .or_insert(serde_json::json!([]));
377
+ if let Some(arr) = ptu.as_array_mut() {
378
+ arr.push(hook_entry);
379
+ }
380
+
381
+ // Atomic write
382
+ if let Some(parent) = settings_path.parent() {
383
+ std::fs::create_dir_all(parent)?;
384
+ }
385
+ let tmp = settings_path.with_extension("json.tmp");
386
+ std::fs::write(&tmp, serde_json::to_string_pretty(&settings)?)?;
387
+ std::fs::rename(&tmp, &settings_path)?;
388
+ }
389
+
390
+ let message = if dry_run {
391
+ format!("[DRY RUN] Would add PostToolUse hook to {}", settings_path.display())
392
+ } else {
393
+ format!(
394
+ "PostToolUse hook added to {}.\nRestart Claude Code to activate.",
395
+ settings_path.display()
396
+ )
397
+ };
398
+
399
+ if *json {
400
+ println!("{}", serde_json::json!({
401
+ "configured": !dry_run,
402
+ "dry_run": dry_run,
403
+ "settings_path": settings_path,
404
+ "hook_command": hook_command,
405
+ "message": message
406
+ }));
407
+ } else {
408
+ println!("✅ {message}");
409
+ println!();
410
+ println!(" Settings: {}", settings_path.display());
411
+ println!(" Command: {hook_command}");
412
+ }
413
+
414
+ Ok(())
415
+ }
416
+
417
+ fn is_already_configured(settings: &serde_json::Value) -> bool {
418
+ let Some(hooks) = settings.get("hooks") else { return false };
419
+ let Some(ptu) = hooks.get("PostToolUse") else { return false };
420
+ let Some(arr) = ptu.as_array() else { return false };
421
+ arr.iter().any(|entry| {
422
+ entry.get("hooks")
423
+ .and_then(|h| h.as_array())
424
+ .map(|hs| hs.iter().any(|h| {
425
+ h.get("command")
426
+ .and_then(|c| c.as_str())
427
+ .map(|c| c.contains("secretscan"))
428
+ .unwrap_or(false)
429
+ }))
430
+ .unwrap_or(false)
431
+ || entry.get("command")
432
+ .and_then(|c| c.as_str())
433
+ .map(|c| c.contains("secretscan"))
434
+ .unwrap_or(false)
435
+ })
436
+ }
437
+
438
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
439
+
440
+ fn resolve_db_path(raw: &str) -> Result<PathBuf> {
441
+ let expanded = if raw.starts_with("~/") {
442
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
443
+ PathBuf::from(home).join(&raw[2..])
444
+ } else {
445
+ PathBuf::from(raw)
446
+ };
447
+ if let Some(parent) = expanded.parent() {
448
+ std::fs::create_dir_all(parent)?;
449
+ }
450
+ Ok(expanded)
451
+ }
452
+
453
+ fn read_input(input: &str) -> Result<String> {
454
+ if input == "-" {
455
+ let mut buf = String::new();
456
+ std::io::stdin().read_to_string(&mut buf)?;
457
+ Ok(buf)
458
+ } else {
459
+ std::fs::read_to_string(input)
460
+ .map_err(|e| anyhow::anyhow!("failed to read {input}: {e}"))
461
+ }
462
+ }
463
+
464
+ fn parse_severity(s: &str) -> secretscan::Severity {
465
+ match s.to_lowercase().as_str() {
466
+ "medium" | "med" => secretscan::Severity::Medium,
467
+ "high" => secretscan::Severity::High,
468
+ "critical" => secretscan::Severity::Critical,
469
+ _ => secretscan::Severity::Low,
470
+ }
471
+ }
472
+
473
+ fn format_bytes(bytes: usize) -> String {
474
+ if bytes < 1024 {
475
+ format!("{bytes} B")
476
+ } else if bytes < 1024 * 1024 {
477
+ format!("{:.1} KB", bytes as f64 / 1024.0)
478
+ } else {
479
+ format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
480
+ }
481
+ }