@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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/core/Cargo.toml +51 -0
- package/core/src/hook/mod.rs +100 -0
- package/core/src/lib.rs +93 -0
- package/core/src/main.rs +481 -0
- package/core/src/patterns/builtin.rs +366 -0
- package/core/src/patterns/entropy.rs +129 -0
- package/core/src/patterns/mod.rs +83 -0
- package/core/src/redact/mod.rs +69 -0
- package/core/src/store/mod.rs +241 -0
- package/hooks/scan-output.sh +37 -0
- package/package.json +37 -0
- package/plugin/tool.json +50 -0
- package/scripts/build.sh +9 -0
- package/scripts/install.sh +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MASYV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SecretScan
|
|
2
|
+
|
|
3
|
+
**Real-time secret & credential detector for Claude Code.** Blocks API keys, tokens, private keys, and database passwords from ever entering your LLM context window.
|
|
4
|
+
|
|
5
|
+
[](https://www.rust-lang.org/)
|
|
6
|
+
[](https://www.npmjs.com/package/@masyv/secretscan)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
When Claude Code runs tools, their outputs flow directly into the context window. That includes:
|
|
12
|
+
- `cat .env` → your API keys
|
|
13
|
+
- `git log` → commit messages with accidentally committed credentials
|
|
14
|
+
- Database query results → connection strings, hashed passwords
|
|
15
|
+
- `curl` responses → tokens, JWTs, session cookies
|
|
16
|
+
|
|
17
|
+
**SecretScan intercepts every tool output** before it reaches Claude, redacts detected secrets, and logs them locally. Claude sees `[REDACTED:anthropic_api_key:bbfc6912]` — not `sk-ant-api03-...`.
|
|
18
|
+
|
|
19
|
+
## 47 Built-in Patterns
|
|
20
|
+
|
|
21
|
+
| Severity | Providers |
|
|
22
|
+
|----------|-----------|
|
|
23
|
+
| 🔴 Critical | Anthropic, OpenAI, AWS, GitHub, Stripe, Google Service Account, PostgreSQL, MySQL, MongoDB, PEM Private Keys |
|
|
24
|
+
| 🟠 High | GitLab, Slack, npm, SendGrid, Cloudflare, Azure, Redis, Heroku, Vercel, Datadog, HuggingFace, Discord, Shopify |
|
|
25
|
+
| 🟡 Medium | JWT tokens, Twilio, env file secrets, Slack webhooks |
|
|
26
|
+
| 🔵 Low | Test keys, certificates, high-entropy strings |
|
|
27
|
+
|
|
28
|
+
Plus **Shannon entropy analysis** for detecting unknown secrets by statistical pattern.
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Install from source
|
|
34
|
+
git clone https://github.com/Manavarya09/secretscan
|
|
35
|
+
cd secretscan
|
|
36
|
+
./scripts/build.sh && ./scripts/install.sh
|
|
37
|
+
|
|
38
|
+
# Auto-configure Claude Code
|
|
39
|
+
secretscan setup
|
|
40
|
+
|
|
41
|
+
# That's it — restart Claude Code and you're protected.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What It Looks Like
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
$ echo 'ANTHROPIC_API_KEY=sk-ant-api03-...' | secretscan scan
|
|
48
|
+
|
|
49
|
+
🚨 1 secret found:
|
|
50
|
+
|
|
51
|
+
🔴 [CRITICAL] Anthropic API Key fingerprint: bbfc6912
|
|
52
|
+
sk-ant-api03-xxxxx…
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
With the hook active, Claude sees:
|
|
56
|
+
```
|
|
57
|
+
ANTHROPIC_API_KEY=[REDACTED:anthropic_api_key:bbfc6912]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Recovery
|
|
61
|
+
|
|
62
|
+
Originals are stored **locally** in SQLite — never forwarded anywhere:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
secretscan expand bbfc6912
|
|
66
|
+
# → sk-ant-api03-...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Allowlist
|
|
70
|
+
|
|
71
|
+
False positive? Mark it safe:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
secretscan allow bbfc6912 --reason "This is a test key in the repo"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## CLI Reference
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
COMMANDS:
|
|
81
|
+
scan Scan text, file, or stdin for secrets
|
|
82
|
+
hook PostToolUse hook mode (reads hook JSON from stdin)
|
|
83
|
+
expand Retrieve original value by fingerprint
|
|
84
|
+
allow Add fingerprint to allowlist
|
|
85
|
+
unallow Remove fingerprint from allowlist
|
|
86
|
+
stats Show scan statistics
|
|
87
|
+
audit List recent findings
|
|
88
|
+
patterns List all 47 built-in patterns
|
|
89
|
+
setup Auto-install PostToolUse hook into ~/.claude/settings.json
|
|
90
|
+
|
|
91
|
+
OPTIONS:
|
|
92
|
+
--json Output as JSON
|
|
93
|
+
--db-path SQLite path [default: ~/.secretscan/secretscan.db]
|
|
94
|
+
-v, --verbose Verbose logging
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Manual Hook Setup
|
|
98
|
+
|
|
99
|
+
Add to `~/.claude/settings.json`:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"hooks": {
|
|
104
|
+
"PostToolUse": [
|
|
105
|
+
{
|
|
106
|
+
"matcher": "*",
|
|
107
|
+
"hooks": [{ "type": "command", "command": "secretscan hook" }]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Performance
|
|
115
|
+
|
|
116
|
+
- **< 2ms** per scan for typical tool outputs
|
|
117
|
+
- **Zero network calls** — everything is local
|
|
118
|
+
- **< 5MB** binary (release, stripped)
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
package/core/Cargo.toml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "secretscan"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "SecretScan — Real-time secret & credential detector for Claude Code. Blocks API keys, tokens, and private keys from entering your LLM context."
|
|
6
|
+
license = "MIT"
|
|
7
|
+
|
|
8
|
+
[[bin]]
|
|
9
|
+
name = "secretscan"
|
|
10
|
+
path = "src/main.rs"
|
|
11
|
+
|
|
12
|
+
[lib]
|
|
13
|
+
name = "secretscan"
|
|
14
|
+
path = "src/lib.rs"
|
|
15
|
+
|
|
16
|
+
[dependencies]
|
|
17
|
+
# CLI
|
|
18
|
+
clap = { version = "4", features = ["derive"] }
|
|
19
|
+
|
|
20
|
+
# Serialization
|
|
21
|
+
serde = { version = "1", features = ["derive"] }
|
|
22
|
+
serde_json = "1"
|
|
23
|
+
|
|
24
|
+
# Error handling
|
|
25
|
+
anyhow = "1"
|
|
26
|
+
thiserror = "2"
|
|
27
|
+
|
|
28
|
+
# Regex
|
|
29
|
+
regex = "1"
|
|
30
|
+
once_cell = "1"
|
|
31
|
+
|
|
32
|
+
# SQLite (lossless local storage of redacted originals)
|
|
33
|
+
rusqlite = { version = "0.32", features = ["bundled"] }
|
|
34
|
+
|
|
35
|
+
# Hashing
|
|
36
|
+
blake3 = "1"
|
|
37
|
+
|
|
38
|
+
# Logging
|
|
39
|
+
tracing = "0.1"
|
|
40
|
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
41
|
+
|
|
42
|
+
# Time
|
|
43
|
+
chrono = { version = "0.4", features = ["serde"] }
|
|
44
|
+
|
|
45
|
+
# Colors for terminal output
|
|
46
|
+
colored = "2"
|
|
47
|
+
|
|
48
|
+
[profile.release]
|
|
49
|
+
opt-level = 3
|
|
50
|
+
lto = "thin"
|
|
51
|
+
strip = true
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//! Claude Code hook handler.
|
|
2
|
+
//! Reads PostToolUse / PreToolUse JSON from stdin, redacts secrets, writes to stdout.
|
|
3
|
+
|
|
4
|
+
use anyhow::Result;
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
|
|
8
|
+
use crate::redact;
|
|
9
|
+
|
|
10
|
+
/// Claude Code PostToolUse hook input format.
|
|
11
|
+
#[derive(Debug, Deserialize)]
|
|
12
|
+
pub struct HookInput {
|
|
13
|
+
#[serde(default)]
|
|
14
|
+
pub tool_name: String,
|
|
15
|
+
#[serde(default)]
|
|
16
|
+
pub tool_input: serde_json::Value,
|
|
17
|
+
#[serde(default)]
|
|
18
|
+
pub tool_output: String,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Hook output — same shape as input with redacted fields.
|
|
22
|
+
#[derive(Debug, Serialize)]
|
|
23
|
+
pub struct HookOutput {
|
|
24
|
+
pub tool_name: String,
|
|
25
|
+
pub tool_input: serde_json::Value,
|
|
26
|
+
pub tool_output: String,
|
|
27
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
28
|
+
pub secretscan: Option<HookMeta>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[derive(Debug, Serialize)]
|
|
32
|
+
pub struct HookMeta {
|
|
33
|
+
pub secrets_found: usize,
|
|
34
|
+
pub redacted: Vec<String>,
|
|
35
|
+
pub clean: bool,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Process a hook call: scan + redact tool_output and tool_input values.
|
|
39
|
+
pub fn process(raw: &str, db_path: &Path, session_id: &str) -> Result<String> {
|
|
40
|
+
let input: HookInput = match serde_json::from_str(raw) {
|
|
41
|
+
Ok(v) => v,
|
|
42
|
+
Err(_) => {
|
|
43
|
+
// Not a valid hook payload — pass through unchanged
|
|
44
|
+
return Ok(raw.to_string());
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let tool_name = input.tool_name.as_str();
|
|
49
|
+
|
|
50
|
+
// Redact tool_output
|
|
51
|
+
let output_result = redact::scan_and_redact(
|
|
52
|
+
&input.tool_output,
|
|
53
|
+
Some(tool_name),
|
|
54
|
+
db_path,
|
|
55
|
+
session_id,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Redact tool_input (serialized to string for scanning)
|
|
59
|
+
let input_str = serde_json::to_string(&input.tool_input).unwrap_or_default();
|
|
60
|
+
let input_result = redact::scan_and_redact(
|
|
61
|
+
&input_str,
|
|
62
|
+
Some(tool_name),
|
|
63
|
+
db_path,
|
|
64
|
+
session_id,
|
|
65
|
+
);
|
|
66
|
+
let redacted_input: serde_json::Value =
|
|
67
|
+
serde_json::from_str(&input_result.redacted_text).unwrap_or(input.tool_input);
|
|
68
|
+
|
|
69
|
+
let total_found = output_result.findings.len() + input_result.findings.len();
|
|
70
|
+
let mut all_redacted: Vec<String> = output_result
|
|
71
|
+
.findings
|
|
72
|
+
.iter()
|
|
73
|
+
.map(|f| format!("{} ({})", f.pattern_name, f.severity.as_str()))
|
|
74
|
+
.collect();
|
|
75
|
+
all_redacted.extend(
|
|
76
|
+
input_result
|
|
77
|
+
.findings
|
|
78
|
+
.iter()
|
|
79
|
+
.map(|f| format!("{} ({})", f.pattern_name, f.severity.as_str())),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
let meta = if total_found > 0 {
|
|
83
|
+
Some(HookMeta {
|
|
84
|
+
secrets_found: total_found,
|
|
85
|
+
redacted: all_redacted,
|
|
86
|
+
clean: false,
|
|
87
|
+
})
|
|
88
|
+
} else {
|
|
89
|
+
None
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let output = HookOutput {
|
|
93
|
+
tool_name: tool_name.to_string(),
|
|
94
|
+
tool_input: redacted_input,
|
|
95
|
+
tool_output: output_result.redacted_text,
|
|
96
|
+
secretscan: meta,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
Ok(serde_json::to_string(&output)?)
|
|
100
|
+
}
|
package/core/src/lib.rs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
pub mod hook;
|
|
2
|
+
pub mod patterns;
|
|
3
|
+
pub mod redact;
|
|
4
|
+
pub mod store;
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
|
|
8
|
+
/// Secret severity level.
|
|
9
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
10
|
+
#[serde(rename_all = "lowercase")]
|
|
11
|
+
pub enum Severity {
|
|
12
|
+
Low,
|
|
13
|
+
Medium,
|
|
14
|
+
High,
|
|
15
|
+
Critical,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl Severity {
|
|
19
|
+
pub fn as_str(self) -> &'static str {
|
|
20
|
+
match self {
|
|
21
|
+
Self::Low => "low",
|
|
22
|
+
Self::Medium => "medium",
|
|
23
|
+
Self::High => "high",
|
|
24
|
+
Self::Critical => "critical",
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
pub fn emoji(self) -> &'static str {
|
|
29
|
+
match self {
|
|
30
|
+
Self::Low => "🔵",
|
|
31
|
+
Self::Medium => "🟡",
|
|
32
|
+
Self::High => "🟠",
|
|
33
|
+
Self::Critical => "🔴",
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub fn color_code(self) -> &'static str {
|
|
38
|
+
match self {
|
|
39
|
+
Self::Low => "\x1b[34m", // blue
|
|
40
|
+
Self::Medium => "\x1b[33m", // yellow
|
|
41
|
+
Self::High => "\x1b[91m", // bright red
|
|
42
|
+
Self::Critical => "\x1b[31m", // red
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// A single secret detection finding.
|
|
48
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
49
|
+
pub struct Finding {
|
|
50
|
+
pub pattern_id: &'static str,
|
|
51
|
+
pub pattern_name: &'static str,
|
|
52
|
+
pub severity: Severity,
|
|
53
|
+
/// The original matched string (stored locally, never forwarded).
|
|
54
|
+
pub matched: String,
|
|
55
|
+
/// Safe replacement string: `[REDACTED:pattern_id:fingerprint]`
|
|
56
|
+
pub redacted: String,
|
|
57
|
+
/// First 8 hex chars of blake3(matched) — used for recovery + allowlist.
|
|
58
|
+
pub fingerprint: String,
|
|
59
|
+
pub offset: usize,
|
|
60
|
+
pub length: usize,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Result of a scan + redact operation.
|
|
64
|
+
#[derive(Debug, Serialize)]
|
|
65
|
+
pub struct ScanResult {
|
|
66
|
+
pub original_len: usize,
|
|
67
|
+
pub redacted_text: String,
|
|
68
|
+
pub findings: Vec<Finding>,
|
|
69
|
+
pub clean: bool,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
impl ScanResult {
|
|
73
|
+
pub fn summary(&self) -> String {
|
|
74
|
+
if self.clean {
|
|
75
|
+
return "✅ No secrets detected.".to_string();
|
|
76
|
+
}
|
|
77
|
+
let mut lines = vec![format!(
|
|
78
|
+
"🚨 {} secret{} found:",
|
|
79
|
+
self.findings.len(),
|
|
80
|
+
if self.findings.len() == 1 { "" } else { "s" }
|
|
81
|
+
)];
|
|
82
|
+
for f in &self.findings {
|
|
83
|
+
lines.push(format!(
|
|
84
|
+
" {} [{}] {} — {}",
|
|
85
|
+
f.severity.emoji(),
|
|
86
|
+
f.severity.as_str().to_uppercase(),
|
|
87
|
+
f.pattern_name,
|
|
88
|
+
f.fingerprint
|
|
89
|
+
));
|
|
90
|
+
}
|
|
91
|
+
lines.join("\n")
|
|
92
|
+
}
|
|
93
|
+
}
|