@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
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
//! SQLite-backed store for redaction records and allowlist management.
|
|
2
|
+
//! Originals are stored locally only — never sent anywhere.
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
use rusqlite::{params, Connection};
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
|
|
8
|
+
use crate::Finding;
|
|
9
|
+
|
|
10
|
+
pub struct Store {
|
|
11
|
+
conn: Connection,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl Store {
|
|
15
|
+
pub fn open(path: &Path) -> Result<Self> {
|
|
16
|
+
if let Some(parent) = path.parent() {
|
|
17
|
+
std::fs::create_dir_all(parent)?;
|
|
18
|
+
}
|
|
19
|
+
let conn = Connection::open(path).context("failed to open secretscan database")?;
|
|
20
|
+
let store = Self { conn };
|
|
21
|
+
store.init_schema()?;
|
|
22
|
+
Ok(store)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub fn open_in_memory() -> Result<Self> {
|
|
26
|
+
let conn = Connection::open_in_memory()?;
|
|
27
|
+
let store = Self { conn };
|
|
28
|
+
store.init_schema()?;
|
|
29
|
+
Ok(store)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn init_schema(&self) -> Result<()> {
|
|
33
|
+
self.conn.execute_batch(
|
|
34
|
+
"
|
|
35
|
+
-- Each detected secret (before redaction)
|
|
36
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
fingerprint TEXT NOT NULL,
|
|
39
|
+
pattern_id TEXT NOT NULL,
|
|
40
|
+
pattern_name TEXT NOT NULL,
|
|
41
|
+
severity TEXT NOT NULL,
|
|
42
|
+
original TEXT NOT NULL,
|
|
43
|
+
tool_name TEXT,
|
|
44
|
+
session_id TEXT,
|
|
45
|
+
detected_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
46
|
+
);
|
|
47
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_findings_fp ON findings(fingerprint);
|
|
48
|
+
|
|
49
|
+
-- Allowlist: known-safe strings that should not be flagged
|
|
50
|
+
CREATE TABLE IF NOT EXISTS allowlist (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
fingerprint TEXT NOT NULL UNIQUE,
|
|
53
|
+
reason TEXT,
|
|
54
|
+
added_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- Session statistics
|
|
58
|
+
CREATE TABLE IF NOT EXISTS scan_stats (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
session_id TEXT NOT NULL,
|
|
61
|
+
tool_name TEXT,
|
|
62
|
+
scanned_bytes INTEGER DEFAULT 0,
|
|
63
|
+
secrets_found INTEGER DEFAULT 0,
|
|
64
|
+
secrets_redacted INTEGER DEFAULT 0,
|
|
65
|
+
scanned_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
|
|
66
|
+
);
|
|
67
|
+
",
|
|
68
|
+
).context("failed to initialize schema")?;
|
|
69
|
+
Ok(())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Store a finding. Returns false if it's on the allowlist.
|
|
73
|
+
pub fn record_finding(
|
|
74
|
+
&self,
|
|
75
|
+
finding: &Finding,
|
|
76
|
+
tool_name: Option<&str>,
|
|
77
|
+
session_id: &str,
|
|
78
|
+
) -> Result<bool> {
|
|
79
|
+
// Check allowlist first
|
|
80
|
+
if self.is_allowed(&finding.fingerprint)? {
|
|
81
|
+
return Ok(false);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
self.conn.execute(
|
|
85
|
+
"INSERT OR IGNORE INTO findings
|
|
86
|
+
(fingerprint, pattern_id, pattern_name, severity, original, tool_name, session_id)
|
|
87
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
88
|
+
params![
|
|
89
|
+
finding.fingerprint,
|
|
90
|
+
finding.pattern_id,
|
|
91
|
+
finding.pattern_name,
|
|
92
|
+
finding.severity.as_str(),
|
|
93
|
+
finding.matched,
|
|
94
|
+
tool_name,
|
|
95
|
+
session_id,
|
|
96
|
+
],
|
|
97
|
+
)?;
|
|
98
|
+
Ok(true)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Retrieve original text by fingerprint (local recovery).
|
|
102
|
+
pub fn get_original(&self, fingerprint: &str) -> Result<Option<String>> {
|
|
103
|
+
let result = self.conn.query_row(
|
|
104
|
+
"SELECT original FROM findings WHERE fingerprint = ?1 LIMIT 1",
|
|
105
|
+
params![fingerprint],
|
|
106
|
+
|row| row.get(0),
|
|
107
|
+
);
|
|
108
|
+
match result {
|
|
109
|
+
Ok(v) => Ok(Some(v)),
|
|
110
|
+
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
|
111
|
+
Err(e) => Err(e.into()),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Check if a fingerprint is on the allowlist.
|
|
116
|
+
pub fn is_allowed(&self, fingerprint: &str) -> Result<bool> {
|
|
117
|
+
let count: i64 = self.conn.query_row(
|
|
118
|
+
"SELECT COUNT(*) FROM allowlist WHERE fingerprint = ?1",
|
|
119
|
+
params![fingerprint],
|
|
120
|
+
|row| row.get(0),
|
|
121
|
+
)?;
|
|
122
|
+
Ok(count > 0)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Add a fingerprint to the allowlist.
|
|
126
|
+
pub fn allow(&self, fingerprint: &str, reason: Option<&str>) -> Result<()> {
|
|
127
|
+
self.conn.execute(
|
|
128
|
+
"INSERT OR IGNORE INTO allowlist (fingerprint, reason) VALUES (?1, ?2)",
|
|
129
|
+
params![fingerprint, reason],
|
|
130
|
+
)?;
|
|
131
|
+
Ok(())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Remove a fingerprint from the allowlist.
|
|
135
|
+
pub fn unallow(&self, fingerprint: &str) -> Result<bool> {
|
|
136
|
+
let n = self.conn.execute(
|
|
137
|
+
"DELETE FROM allowlist WHERE fingerprint = ?1",
|
|
138
|
+
params![fingerprint],
|
|
139
|
+
)?;
|
|
140
|
+
Ok(n > 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Record a scan event (for stats).
|
|
144
|
+
pub fn record_scan(
|
|
145
|
+
&self,
|
|
146
|
+
session_id: &str,
|
|
147
|
+
tool_name: Option<&str>,
|
|
148
|
+
scanned_bytes: usize,
|
|
149
|
+
found: usize,
|
|
150
|
+
redacted: usize,
|
|
151
|
+
) -> Result<()> {
|
|
152
|
+
self.conn.execute(
|
|
153
|
+
"INSERT INTO scan_stats (session_id, tool_name, scanned_bytes, secrets_found, secrets_redacted)
|
|
154
|
+
VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
155
|
+
params![
|
|
156
|
+
session_id,
|
|
157
|
+
tool_name,
|
|
158
|
+
scanned_bytes as i64,
|
|
159
|
+
found as i64,
|
|
160
|
+
redacted as i64,
|
|
161
|
+
],
|
|
162
|
+
)?;
|
|
163
|
+
Ok(())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Get lifetime stats.
|
|
167
|
+
pub fn stats(&self) -> Result<StoreStats> {
|
|
168
|
+
let total_scans: i64 = self.conn.query_row(
|
|
169
|
+
"SELECT COUNT(*) FROM scan_stats", [], |r| r.get(0))?;
|
|
170
|
+
let total_bytes: i64 = self.conn.query_row(
|
|
171
|
+
"SELECT COALESCE(SUM(scanned_bytes), 0) FROM scan_stats", [], |r| r.get(0))?;
|
|
172
|
+
let total_found: i64 = self.conn.query_row(
|
|
173
|
+
"SELECT COALESCE(SUM(secrets_found), 0) FROM scan_stats", [], |r| r.get(0))?;
|
|
174
|
+
let total_redacted: i64 = self.conn.query_row(
|
|
175
|
+
"SELECT COALESCE(SUM(secrets_redacted), 0) FROM scan_stats", [], |r| r.get(0))?;
|
|
176
|
+
let unique_secrets: i64 = self.conn.query_row(
|
|
177
|
+
"SELECT COUNT(DISTINCT fingerprint) FROM findings", [], |r| r.get(0))?;
|
|
178
|
+
let allowlist_count: i64 = self.conn.query_row(
|
|
179
|
+
"SELECT COUNT(*) FROM allowlist", [], |r| r.get(0))?;
|
|
180
|
+
|
|
181
|
+
// Breakdown by severity
|
|
182
|
+
let mut by_severity = Vec::new();
|
|
183
|
+
let mut stmt = self.conn.prepare(
|
|
184
|
+
"SELECT severity, COUNT(*) as cnt FROM findings GROUP BY severity ORDER BY cnt DESC")?;
|
|
185
|
+
let rows = stmt.query_map([], |row| {
|
|
186
|
+
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
|
187
|
+
})?;
|
|
188
|
+
for row in rows {
|
|
189
|
+
by_severity.push(row?);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Ok(StoreStats {
|
|
193
|
+
total_scans: total_scans as usize,
|
|
194
|
+
total_bytes_scanned: total_bytes as usize,
|
|
195
|
+
total_secrets_found: total_found as usize,
|
|
196
|
+
total_secrets_redacted: total_redacted as usize,
|
|
197
|
+
unique_secrets: unique_secrets as usize,
|
|
198
|
+
allowlist_count: allowlist_count as usize,
|
|
199
|
+
by_severity,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// List recent findings.
|
|
204
|
+
pub fn recent_findings(&self, limit: usize) -> Result<Vec<FindingRecord>> {
|
|
205
|
+
let mut stmt = self.conn.prepare(
|
|
206
|
+
"SELECT fingerprint, pattern_name, severity, tool_name, session_id, detected_at
|
|
207
|
+
FROM findings ORDER BY id DESC LIMIT ?1")?;
|
|
208
|
+
let rows = stmt.query_map(params![limit as i64], |row| {
|
|
209
|
+
Ok(FindingRecord {
|
|
210
|
+
fingerprint: row.get(0)?,
|
|
211
|
+
pattern_name: row.get(1)?,
|
|
212
|
+
severity: row.get(2)?,
|
|
213
|
+
tool_name: row.get(3)?,
|
|
214
|
+
session_id: row.get(4)?,
|
|
215
|
+
detected_at: row.get(5)?,
|
|
216
|
+
})
|
|
217
|
+
})?;
|
|
218
|
+
rows.collect::<Result<Vec<_>, _>>().context("db read failed")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[derive(Debug, serde::Serialize)]
|
|
223
|
+
pub struct StoreStats {
|
|
224
|
+
pub total_scans: usize,
|
|
225
|
+
pub total_bytes_scanned: usize,
|
|
226
|
+
pub total_secrets_found: usize,
|
|
227
|
+
pub total_secrets_redacted: usize,
|
|
228
|
+
pub unique_secrets: usize,
|
|
229
|
+
pub allowlist_count: usize,
|
|
230
|
+
pub by_severity: Vec<(String, i64)>,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[derive(Debug, serde::Serialize)]
|
|
234
|
+
pub struct FindingRecord {
|
|
235
|
+
pub fingerprint: String,
|
|
236
|
+
pub pattern_name: String,
|
|
237
|
+
pub severity: String,
|
|
238
|
+
pub tool_name: Option<String>,
|
|
239
|
+
pub session_id: Option<String>,
|
|
240
|
+
pub detected_at: String,
|
|
241
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SecretScan PostToolUse hook — redacts secrets from tool output before they
|
|
3
|
+
# enter Claude's context window.
|
|
4
|
+
#
|
|
5
|
+
# Add to ~/.claude/settings.json:
|
|
6
|
+
# {
|
|
7
|
+
# "hooks": {
|
|
8
|
+
# "PostToolUse": [
|
|
9
|
+
# { "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/secretscan hook" }] }
|
|
10
|
+
# ]
|
|
11
|
+
# }
|
|
12
|
+
# }
|
|
13
|
+
# Or run: secretscan setup
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SECRETSCAN="${SECRETSCAN_BIN:-secretscan}"
|
|
18
|
+
if ! command -v "$SECRETSCAN" &>/dev/null; then
|
|
19
|
+
for candidate in \
|
|
20
|
+
"$HOME/.cargo/bin/secretscan" \
|
|
21
|
+
"/usr/local/bin/secretscan" \
|
|
22
|
+
"$(dirname "$0")/../core/target/release/secretscan"; do
|
|
23
|
+
if [[ -x "$candidate" ]]; then
|
|
24
|
+
SECRETSCAN="$candidate"
|
|
25
|
+
break
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
SESSION_ID="${CLAUDE_SESSION_ID:-default}"
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
|
|
33
|
+
if command -v "$SECRETSCAN" &>/dev/null || [[ -x "$SECRETSCAN" ]]; then
|
|
34
|
+
echo "$INPUT" | "$SECRETSCAN" hook --session "$SESSION_ID" 2>/dev/null || echo "$INPUT"
|
|
35
|
+
else
|
|
36
|
+
echo "$INPUT"
|
|
37
|
+
fi
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@masyv/secretscan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SecretScan — Real-time secret & credential detector for Claude Code. 47 patterns covering Anthropic, AWS, GitHub, Stripe, database URLs, JWTs and more.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "./scripts/build.sh",
|
|
7
|
+
"install-bin": "./scripts/install.sh"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"claude-code",
|
|
11
|
+
"claude",
|
|
12
|
+
"secrets",
|
|
13
|
+
"security",
|
|
14
|
+
"credentials",
|
|
15
|
+
"api-keys",
|
|
16
|
+
"redaction",
|
|
17
|
+
"hook",
|
|
18
|
+
"mcp",
|
|
19
|
+
"plugin"
|
|
20
|
+
],
|
|
21
|
+
"author": "masyv",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/Manavarya09/secretscan"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/Manavarya09/secretscan#readme",
|
|
28
|
+
"files": [
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"plugin/",
|
|
32
|
+
"hooks/",
|
|
33
|
+
"scripts/",
|
|
34
|
+
"core/src/",
|
|
35
|
+
"core/Cargo.toml"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/plugin/tool.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "secretscan",
|
|
3
|
+
"description": "SecretScan — Real-time secret and credential detector. Scans text for API keys, tokens, private keys, database URLs, and 50+ other secret formats. Redacts findings with safe placeholders.",
|
|
4
|
+
"tools": [
|
|
5
|
+
{
|
|
6
|
+
"name": "secretscan_scan",
|
|
7
|
+
"description": "Scan text for secrets. Returns findings with severity, pattern name, and fingerprint. Does NOT return the actual secret value.",
|
|
8
|
+
"input_schema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"properties": {
|
|
11
|
+
"text": { "type": "string", "description": "Text to scan" },
|
|
12
|
+
"redact": { "type": "boolean", "description": "Return redacted version (default: true)" },
|
|
13
|
+
"min_severity": { "type": "string", "enum": ["low","medium","high","critical"] }
|
|
14
|
+
},
|
|
15
|
+
"required": ["text"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "secretscan_expand",
|
|
20
|
+
"description": "Retrieve the original value of a redacted secret by its fingerprint. Only works if the secret was detected in this session.",
|
|
21
|
+
"input_schema": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"fingerprint": { "type": "string", "description": "Fingerprint from [REDACTED:type:FINGERPRINT]" }
|
|
25
|
+
},
|
|
26
|
+
"required": ["fingerprint"]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "secretscan_allow",
|
|
31
|
+
"description": "Add a fingerprint to the allowlist so it won't be flagged again.",
|
|
32
|
+
"input_schema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"fingerprint": { "type": "string" },
|
|
36
|
+
"reason": { "type": "string", "description": "Why this is safe to ignore" }
|
|
37
|
+
},
|
|
38
|
+
"required": ["fingerprint"]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "secretscan_stats",
|
|
43
|
+
"description": "Show SecretScan statistics: total scans, secrets found, by severity breakdown.",
|
|
44
|
+
"input_schema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
package/scripts/build.sh
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
4
|
+
cd "$SCRIPT_DIR/../core"
|
|
5
|
+
echo "=== SecretScan Build ==="
|
|
6
|
+
cargo build --release
|
|
7
|
+
SIZE=$(du -sh target/release/secretscan | cut -f1)
|
|
8
|
+
echo "✅ Built: target/release/secretscan ($SIZE)"
|
|
9
|
+
echo "Run ./scripts/install.sh to install."
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
4
|
+
BINARY="$SCRIPT_DIR/../core/target/release/secretscan"
|
|
5
|
+
INSTALL_DIR="${INSTALL_DIR:-$HOME/.cargo/bin}"
|
|
6
|
+
|
|
7
|
+
if [[ ! -f "$BINARY" ]]; then
|
|
8
|
+
echo "Building first..."
|
|
9
|
+
"$SCRIPT_DIR/build.sh"
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
echo "=== SecretScan Install ==="
|
|
13
|
+
mkdir -p "$INSTALL_DIR"
|
|
14
|
+
cp "$BINARY" "$INSTALL_DIR/secretscan"
|
|
15
|
+
chmod +x "$INSTALL_DIR/secretscan"
|
|
16
|
+
mkdir -p "$HOME/.secretscan"
|
|
17
|
+
|
|
18
|
+
echo "✅ Installed to $INSTALL_DIR/secretscan"
|
|
19
|
+
echo ""
|
|
20
|
+
echo "Auto-configure Claude Code hook:"
|
|
21
|
+
echo " secretscan setup"
|
|
22
|
+
echo ""
|
|
23
|
+
echo "Or manually add to ~/.claude/settings.json:"
|
|
24
|
+
echo ' { "hooks": { "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "secretscan hook" }] }] } }'
|