Initial implementation

This commit is contained in:
2026-06-17 16:44:04 +01:00
commit c681cef2b1
26 changed files with 926 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
github: omegaleo
ko_fi: omegaleo
+48
View File
@@ -0,0 +1,48 @@
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: dotnet build SecretSecurity/SecretSecurity.csproj --configuration Release
- name: Pack NuGet tool
run: >
dotnet pack SecretSecurity/SecretSecurity.csproj
--configuration Release
--no-build
-p:Version=${{ steps.version.outputs.VERSION }}
--output ./nupkg
- name: Push to GitHub Packages
run: >
dotnet nuget push ./nupkg/*.nupkg
--api-key ${{ secrets.GITHUB_TOKEN }}
--source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ./nupkg/*.nupkg
generate_release_notes: true
+139
View File
@@ -0,0 +1,139 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.sln.docstates
# Build results
[Dd]ebug/
[Rr]elease/
x64/
[Bb]in/
[Oo]bj/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.log
*.svclog
*.scc
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opensdf
*.sdf
*.cachefile
# Visual Studio profiler
*.psess
*.vsp
*.vspx
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Click-Once directory
publish/
# Publish Web Output
*.Publish.xml
*.pubxml
*.azurePubxml
# NuGet Packages Directory
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
packages/
## TODO: If the tool you use requires repositories.config, also uncomment the next line
!packages/repositories.config
# Windows Azure Build Output
csx/
*.build.csdef
# Windows Store app package directory
AppPackages/
# Others
sql/
*.Cache
ClientBin/
[Ss]tyle[Cc]op.*
![Ss]tyle[Cc]op.targets
~$*
*~
*.dbmdl
*.[Pp]ublish.xml
*.publishsettings
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file to a newer
# Visual Studio version. Backup files are not needed, because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
App_Data/*.mdf
App_Data/*.ldf
# =========================
# Windows detritus
# =========================
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Mac desktop service store files
.DS_Store
_NCrunch*
.vs
.vscode
.rider
test-files/
+6
View File
@@ -0,0 +1,6 @@
node_modules/
bin/
obj/
*.min.js
*.lock
testfiles/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Omega Leo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+96
View File
@@ -0,0 +1,96 @@
# SecretSecurity
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/omegaleo)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20me-FF5E5B?logo=ko-fi&logoColor=white)](https://ko-fi.com/omegaleo)
A .NET CLI tool that scans staged git changes for secrets, API keys, tokens, and sensitive credentials — and blocks commits when critical findings are detected.
## What it detects
| Type | Examples | Severity |
|------|----------|----------|
| Provider tokens | AWS access keys (`AKIA…`), Stripe live keys (`sk_live_…`), GitHub PATs (`ghp_…`), Slack tokens | Critical |
| Private keys | RSA, EC, OpenSSH private key blocks | Critical |
| Azure connection strings | `DefaultEndpointsProtocol`, `AccountKey` | Critical |
| Explicit secret assignments | `api_key=`, `token=`, `secret:` with a value | High |
| Database connection strings | General ADO.NET, Postgres/MySQL/MongoDB/Redis URLs | High |
| Suspicious blobs | High-entropy strings in suspicious context | Warning |
Dev-safe connections (localhost, 127.0.0.1, integrated security) are automatically ignored.
## Installation
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) or later.
```bash
dotnet tool install --global SecretSecurity --add-source https://nuget.pkg.github.com/omegaleo/index.json
```
## Usage
### Install the pre-commit hook
Run once inside your repository:
```bash
secretsecurity install
```
This writes a `pre-commit` hook to `.git/hooks/` that runs `secretsecurity scan` automatically on every commit.
### Scan manually
```bash
secretsecurity scan
```
Scans all currently staged changes (`git diff --cached`). Exits with code `1` if any High or Critical findings are found, blocking the commit.
### Ignore files
Create a `.secretsecurityignore` file in your repo root with one pattern per line:
```
tests/fixtures/
mock_secrets.json
```
Any staged file whose path contains a listed pattern is skipped.
## How it works
1. Runs `git diff --cached -U0` to get only the lines you are about to commit.
2. Each added line is checked against a set of compiled regex patterns ordered by confidence.
3. Matched values are partially masked in the output (e.g. `ghp_****Ab`).
4. If any finding is High or Critical, the scan exits with code `1`, preventing the commit.
## Building from source
```bash
git clone https://github.com/omegaleo/SecretSecurity.git
cd SecretSecurity
dotnet build
dotnet run --project SecretSecurity -- scan
```
## Contributing
Contributions are welcome! To get started:
1. Fork the repository and create a branch from `main`.
2. Make your changes and ensure the project builds (`dotnet build`).
3. Open a pull request describing what you changed and why.
Please open an issue first for any significant change so we can discuss the approach before you invest time in it.
## Support
If SecretSecurity saves you from an accidental secret leak, consider supporting its development:
- [GitHub Sponsors](https://github.com/sponsors/omegaleo) — sponsor directly on GitHub
- [Ko-fi](https://ko-fi.com/omegaleo) — buy me a coffee
## License
MIT — see [LICENSE.md](LICENSE) for details.
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="SecretSecurity/SecretSecurity.csproj" />
</Solution>
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
namespace SecretSecurity.MethodExtensions
{
public static class ListExtensions
{
public static ReadOnlySpan<string> AsReadOnlySpan(this IEnumerable<string> strings)
{
return new ReadOnlySpan<string>(strings.ToArray());
}
}
}
+13
View File
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SecretSecurity.Models
{
public class DiffEntry
{
public string File { get; set; }
public int Line { get; set; }
public string Content { get; set; }
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SecretSecurity.Models
{
public enum FindingType
{
ApiKey,
Token,
PrivateKey,
ConnectionString,
CloudCredential,
SuspiciousBlob
}
public enum Severity
{
Info,
Warning,
High,
Critical
}
public class SecretFinding
{
public string File { get; set; }
public int Line { get; set; }
public FindingType Type { get; set; } // "API Key", "Connection String"
public string ValuePreview { get; set; } // partially masked
public Severity Severity { get; set; }
}
}
+255
View File
@@ -0,0 +1,255 @@
using SecretSecurity;
using SecretSecurity.MethodExtensions;
using SecretSecurity.Models;
using Spectre.Console;
using System.Buffers;
using System.Diagnostics;
class Program
{
static List<string> _ignorePatterns = new();
static void Main(string[] args)
{
if (args.Length == 0)
{
PrintHelp();
return;
}
var arg = args[0].ToLower();
switch (arg)
{
case "scan":
PerformScan();
break;
case "install":
InstallCommand();
break;
default:
PrintHelp();
break;
}
}
static void LoadIgnoreFile()
{
if (!File.Exists(".secretsecurityignore"))
return;
_ignorePatterns = File.ReadAllLines(".secretsecurityignore")
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
}
static void InstallCommand()
{
var root = RunGit("rev-parse --show-toplevel").Trim();
var hookPath = Path.Combine(root, ".git", "hooks", "pre-commit");
var script = """
#!/bin/sh
SecretSecurity scan
if [ $? -ne 0 ]; then
echo "❌ SecretSecurity blocked commit"
exit 1
fi
""";
File.WriteAllText(hookPath, script);
Console.WriteLine("✅ SecretSecurity installed successfully");
}
static void PrintHelp()
{
Console.WriteLine(@"
SecretSecurity CLI
Commands:
scan Scan staged git changes
install Install git pre-commit hook
ignore Add ignore-rule
");
}
static void PerformScan()
{
LoadIgnoreFile();
AnsiConsole.MarkupLine("[cyan]🔍 SecretSecurity scanning...[/]\n");
var findings = GetFindings();
PrintFindings(findings);
bool shouldBlockCommit = findings.Any(f =>
f.Severity == Severity.High ||
f.Severity == Severity.Critical);
if (shouldBlockCommit)
{
AnsiConsole.Write(
new Panel("[red]Commit blocked: High/Critical secrets detected[/]")
.Header("SecretSecurity")
.Border(BoxBorder.Double)
);
var critical = findings
.Where(f => f.Severity is Severity.High or Severity.Critical)
.ToList();
foreach (var f in critical)
{
AnsiConsole.MarkupLine($"[red]• {f.Type}[/] in [grey]{f.File}:{f.Line}[/]");
}
Environment.Exit(1);
}
}
public static void PrintFindings(IEnumerable<SecretFinding> findings)
{
if (findings.Count() == 0)
{
AnsiConsole.MarkupLine("[green]✅ SecretSecurity: No secrets detected.[/]");
return;
}
AnsiConsole.Write(
new Panel($"[red]❌ {findings.Count()} issue(s) detected[/]")
.Border(BoxBorder.Rounded)
.Header("SecretSecurity Report")
);
foreach (var f in findings)
{
var color = f.Severity switch
{
Severity.Critical => "red",
Severity.High => "darkred",
Severity.Warning => "yellow",
_ => "grey"
};
AnsiConsole.MarkupLine($"""
[{color}]{f.Severity}[/]
📄 [bold]{f.File}:{f.Line}[/]
🔎 {f.ValuePreview}
────────────────────────────
""");
}
}
public static void PrintSeverity(Severity severity)
{
switch (severity)
{
case Severity.Critical:
Console.ForegroundColor = ConsoleColor.Red;
break;
case Severity.High:
Console.ForegroundColor = ConsoleColor.DarkRed;
break;
case Severity.Warning:
Console.ForegroundColor = ConsoleColor.Yellow;
break;
default:
Console.ForegroundColor = ConsoleColor.Gray;
break;
}
}
public static IEnumerable<SecretFinding> GetFindings()
{
var findings = new List<SecretFinding>();
var diffLines = GetStagedDiffWithContext();
foreach (var entry in diffLines)
{
var result = Validator.FindSecret(entry.Content);
if (result != null)
{
if (IsIgnored(entry.File))
continue;
if (IsDocumentationFile(entry.File))
continue;
result.File = entry.File;
result.Line = entry.Line;
findings.Add(result);
}
}
return findings;
}
public static List<DiffEntry> GetStagedDiffWithContext()
{
var output = RunGit("diff --cached -U0");
var lines = output.Split('\n');
var results = new List<DiffEntry>();
string currentFile = null;
int lineNumber = 0;
foreach (var line in lines)
{
// detect file
if (line.StartsWith("+++ b/"))
{
currentFile = line.Substring(6);
lineNumber = 0;
}
// detect added line
else if (line.StartsWith("+") && !line.StartsWith("+++"))
{
lineNumber++;
results.Add(new DiffEntry
{
File = currentFile ?? "unknown",
Line = lineNumber,
Content = line.Substring(1) // remove "+"
});
}
}
return results;
}
public static string RunGit(string args)
{
var process = new Process();
process.StartInfo.FileName = "git";
process.StartInfo.Arguments = args;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.UseShellExecute = false;
process.Start();
return process.StandardOutput.ReadToEnd();
}
static bool IsIgnored(string file)
{
return _ignorePatterns.Any(p =>
file.Contains(p.Replace("*", "")));
}
static bool IsDocumentationFile(string file)
{
file = file.ToLowerInvariant();
return file.EndsWith(".md") ||
file.EndsWith(".txt") ||
file.Contains("readme") ||
file.Contains("docs");
}
}
@@ -0,0 +1,8 @@
{
"profiles": {
"SecretSecurity": {
"commandName": "Project",
"commandLineArgs": "scan"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackAsTool>true</PackAsTool>
<ToolCommandName>secretsecurity</ToolCommandName>
<PackageId>SecretSecurity</PackageId>
<Version>1.0.0</Version>
<Authors>Omega Leo</Authors>
<Description>CLI tool for scanning secrets and sensitive data in files.</Description>
<RepositoryUrl>https://github.com/omegaleo/SecretSecurity</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.57.0" />
</ItemGroup>
</Project>
+173
View File
@@ -0,0 +1,173 @@
using SecretSecurity.Models;
using System.Text.RegularExpressions;
namespace SecretSecurity
{
public static class Validator
{
// 1. Explicit secret assignments (HIGH confidence)
static Regex _explicitSecret = new Regex(
@"(?i)(api[_-]?key|secret|token|access[_-]?key|auth[_-]?token)\s*[:=]\s*[\""']?(?<value>[a-z0-9_\-]{16,})[\""']?",
RegexOptions.Compiled);
// 2. Provider-specific tokens (VERY high confidence)
static Regex _providerSecrets = new Regex(
@"(AKIA[0-9A-Z]{16}|sk_live_[0-9a-zA-Z]{24,}|ghp_[0-9a-zA-Z]{36}|xox[baprs]-[0-9a-zA-Z-]{10,})",
RegexOptions.Compiled);
// 3. Private key blocks
static Regex _privateKey = new Regex(
@"-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----",
RegexOptions.Compiled);
// 4. High entropy fallback (LIMITED usage only)
static Regex _randomBlob = new Regex(
@"\b[a-zA-Z0-9_\-]{40,}\b",
RegexOptions.Compiled);
// 5. General Connection Strings
static Regex _generalConnString = new Regex(
"(?i)\\b(server|data source|database|uid|user id|pwd|password)\\s*=\\s*[^;]+",
RegexOptions.Compiled);
// 6. Postgres Connection Strings
static Regex _postgres = new Regex(
"(?i)(postgres|mysql|mongodb|redis):\\/\\/[^\\\\s\\\"']+",
RegexOptions.Compiled);
// 7. Azure Connection Strings
static Regex _azureConnString = new Regex(
"(?i)(DefaultEndpointsProtocol|AccountName|AccountKey)=",
RegexOptions.Compiled);
public static SecretFinding? FindSecret(string line)
{
if (string.IsNullOrWhiteSpace(line))
return null;
// 1. Provider secrets (highest confidence)
if (_providerSecrets.IsMatch(line))
{
return new SecretFinding
{
Type = FindingType.Token,
Severity = Severity.Critical,
ValuePreview = Mask(line)
};
}
// 2. Private keys
if (_privateKey.IsMatch(line))
{
return new SecretFinding
{
Type = FindingType.ApiKey,
Severity = Severity.Critical,
ValuePreview = "-----BEGIN **** KEY-----"
};
}
// 3. Explicit assignment secrets
var explicitMatch = _explicitSecret.Match(line);
if (explicitMatch.Success)
{
return new SecretFinding
{
Type = FindingType.ApiKey | FindingType.Token,
Severity = Severity.High,
ValuePreview = Mask(explicitMatch.Value)
};
}
// 4. Connection strings (general)
if (_generalConnString.IsMatch(line))
{
if (IsSafeDevConnection(line))
{
return null;
}
return new SecretFinding
{
Type = FindingType.ConnectionString,
Severity = Severity.High,
ValuePreview = MaskConnection(line)
};
}
// 5. Postgres / DB URLs
if (_postgres.IsMatch(line))
{
return new SecretFinding
{
Type = FindingType.ConnectionString,
Severity = Severity.High,
ValuePreview = Mask(line)
};
}
// 6. Azure connection strings
if (_azureConnString.IsMatch(line))
{
return new SecretFinding
{
Type = FindingType.ConnectionString,
Severity = Severity.Critical,
ValuePreview = Mask(line)
};
}
// 7. Fallback entropy check (low confidence)
if (_randomBlob.IsMatch(line) && LooksSuspicious(line))
{
return new SecretFinding
{
Type = FindingType.SuspiciousBlob,
Severity = Severity.Warning,
ValuePreview = Mask(line)
};
}
return null;
}
static string Mask(string value)
{
if (string.IsNullOrEmpty(value))
return value;
if (value.Length <= 10)
return "****";
return value.Substring(0, 4) + "****" + value[^2..];
}
static string MaskConnection(string conn)
{
return Regex.Replace(conn,
"(?i)(password|pwd|user id|uid|key)=([^;]+)",
"$1=****");
}
static bool LooksSuspicious(string line)
{
// reduce false positives
return line.Contains("=") ||
line.Contains(":") ||
line.Contains("token") ||
line.Contains("key") ||
line.Contains("secret");
}
static bool IsSafeDevConnection(string line)
{
line = line.ToLowerInvariant();
return line.Contains("localhost") ||
line.Contains("127.0.0.1") ||
line.Contains("::1") ||
line.Contains("database=mydb") && line.Contains("trusted_connection=true") ||
line.Contains("integrated security=true");
}
}
}
+8
View File
@@ -0,0 +1,8 @@
AWS_KEY=AKIAIOSFODNN7EXAMPLE
AWS_SECRET=abcd1234abcd1234abcd1234abcd1234
GITHUB_TOKEN=ghp_ABCDEF1234567890ABCDEF1234567890AB
SLACK_TOKEN=xoxb-1234567890-abcdefghij
DATABASE_URL=postgres://admin:SuperSecret@localhost:5432/prod
+10
View File
@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=GameDb;Trusted_Connection=true;"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=mydb;Database=Prod;User Id=admin;Password=SuperSecret123;"
},
"Stripe": {
"SecretKey": "sk_live_9x8y7z6w5v4u3t2s"
}
}
+5
View File
@@ -0,0 +1,5 @@
public class Config
{
public string ApiKeyName = "access_key";
public string TokenDescription = "used for authentication flow";
}
+14
View File
@@ -0,0 +1,14 @@
public class Startup
{
// safe config name
public const string ApiKeyName = "access_key";
// suspicious placeholder
private string tokenExample = "your_token_here_1234567890abcdef";
// real secret
private string stripeKey = "sk_live_1234567890abcdef";
// connection string
private string db = "Server=prod;Database=Main;User Id=admin;Password=SuperSecret;";
}
+14
View File
@@ -0,0 +1,14 @@
{
"Logging": {
"Level": "Debug"
},
"ApiKeyHint": "use environment variable API_KEY",
"Stripe": {
"Secret": "sk_live_1234567890abcdef"
},
"ConnectionStrings": {
"Default": "postgres://user:pass@localhost/db"
}
}
+9
View File
@@ -0,0 +1,9 @@
This file contains notes about API keys and tokens.
DO NOT COMMIT REAL VALUES.
Example:
API_KEY=example_value_1234567890
Actual production key:
ghp_ABCDEF1234567890ABCDEF1234567890AB
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA7examplefakekeydataaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
-----END RSA PRIVATE KEY-----
+7
View File
@@ -0,0 +1,7 @@
public class Config
{
public string ApiKey = "sk_live_1234567890abcdef";
public string Secret = "ghp_ABCDEF1234567890ABCDEF1234567890AB";
string conn = "Server=db;User Id=admin;Password=123456;";
}
+5
View File
@@ -0,0 +1,5 @@
public class Settings
{
public string ApiKeyPlaceholder = "your_api_key_here_123456789";
public string TokenExample = "example_token_value_1234567890abcdef";
}
+5
View File
@@ -0,0 +1,5 @@
# development environment
DATABASE_URL=postgres://user:password@localhost:5432/db
API_KEY=replace_this_value
SECRET_TOKEN=example_only_but_long_enough_1234567890
+4
View File
@@ -0,0 +1,4 @@
User login failed
Auth token received: abcdef1234567890abcdef1234567890abcdef
Request processed successfully