leakhound 0.1.0__tar.gz

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,73 @@
1
+ name: PyPI
2
+
3
+ # Build native wheels on every OS (leakhound is a compiled C++ binary, so there
4
+ # is no pure-Python wheel) plus an sdist, then publish to PyPI via Trusted
5
+ # Publishing on a version tag. No API token needed.
6
+ #
7
+ # One-time setup on PyPI: add a Trusted Publisher for project `leakhound` ->
8
+ # owner `Makeph`, repo `leakhound`, workflow `pypi.yml`, environment `pypi`.
9
+
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ wheels:
20
+ name: wheel (${{ matrix.os }})
21
+ runs-on: ${{ matrix.os }}
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ # macos-14 -> arm64, windows -> amd64. Linux and Intel-mac binary wheels
26
+ # are intentionally omitted: `python -m build` on a plain ubuntu runner
27
+ # emits a raw `linux_x86_64` tag that PyPI rejects (it requires
28
+ # manylinux, which needs cibuildwheel), and the macos-13 runner queue
29
+ # stalls for hours. Linux/Intel-mac users install from the sdist.
30
+ os: [macos-14, windows-latest]
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: actions/setup-python@v5
34
+ with:
35
+ python-version: "3.12"
36
+ - name: Build wheel
37
+ run: |
38
+ python -m pip install --upgrade build
39
+ python -m build --wheel
40
+ - uses: actions/upload-artifact@v4
41
+ with:
42
+ name: wheel-${{ matrix.os }}
43
+ path: dist/*.whl
44
+
45
+ sdist:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - uses: actions/setup-python@v5
50
+ with:
51
+ python-version: "3.12"
52
+ - run: |
53
+ python -m pip install --upgrade build
54
+ python -m build --sdist
55
+ - uses: actions/upload-artifact@v4
56
+ with:
57
+ name: sdist
58
+ path: dist/*.tar.gz
59
+
60
+ publish:
61
+ name: publish to PyPI
62
+ needs: [wheels, sdist]
63
+ runs-on: ubuntu-latest
64
+ if: startsWith(github.ref, 'refs/tags/v')
65
+ environment: pypi
66
+ permissions:
67
+ id-token: write
68
+ steps:
69
+ - uses: actions/download-artifact@v4
70
+ with:
71
+ path: dist
72
+ merge-multiple: true
73
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ build/
2
+ out/
3
+ *.exe
4
+ *.obj
5
+ *.pdb
6
+ *.ilk
7
+ *.user
8
+ *.suo
9
+ *.ninja
10
+ CMakeCache.txt
11
+ CMakeFiles/
12
+ Testing/
13
+ _deps/
@@ -0,0 +1,26 @@
1
+ cmake_minimum_required(VERSION 3.15)
2
+ project(leakhound LANGUAGES CXX)
3
+
4
+ add_executable(leakhound src/leakhound.cpp)
5
+ target_compile_features(leakhound PRIVATE cxx_std_17)
6
+ set_target_properties(leakhound PROPERTIES
7
+ CXX_STANDARD 17
8
+ CXX_STANDARD_REQUIRED YES
9
+ CXX_EXTENSIONS NO
10
+ )
11
+
12
+ if(MSVC)
13
+ target_compile_options(leakhound PRIVATE /W4)
14
+ else()
15
+ target_compile_options(leakhound PRIVATE -Wall -Wextra)
16
+ endif()
17
+
18
+ # Install rule -- used by scikit-build-core to place the binary as a pip
19
+ # console script (SKBUILD_SCRIPTS_DIR), and by a normal `cmake --install`
20
+ # otherwise. Harmless to a plain `cmake --build`.
21
+ include(GNUInstallDirs)
22
+ if(DEFINED SKBUILD_SCRIPTS_DIR)
23
+ install(TARGETS leakhound RUNTIME DESTINATION "${SKBUILD_SCRIPTS_DIR}")
24
+ else()
25
+ install(TARGETS leakhound RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
26
+ endif()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Makeph
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.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.1
2
+ Name: leakhound
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency C++17 secret scanner - AWS/GitHub/Slack/Stripe keys, PEM, JWTs, entropy heuristics. Single binary, CI-friendly exit codes.
5
+ Keywords: secrets,scanner,security,secret-detection,credentials,cli
6
+ Author: Makeph
7
+ License: MIT
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: C++
11
+ Classifier: Topic :: Security
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Project-URL: Homepage, https://github.com/Makeph/leakhound
14
+ Project-URL: Repository, https://github.com/Makeph/leakhound
15
+ Project-URL: Issues, https://github.com/Makeph/leakhound/issues
16
+ Requires-Python: >=3.8
17
+ Description-Content-Type: text/markdown
18
+
19
+ # leakhound
20
+
21
+ `leakhound` is a fast local credential and secret scanner. It is a single C++17
22
+ binary with no external dependencies.
23
+
24
+ ## Build
25
+
26
+ With CMake:
27
+
28
+ ```sh
29
+ cmake -S . -B build
30
+ cmake --build build --config Release
31
+ ```
32
+
33
+ On Windows with the MSVC command prompt:
34
+
35
+ ```bat
36
+ build.bat
37
+ ```
38
+
39
+ The batch file runs:
40
+
41
+ ```bat
42
+ cl /std:c++17 /O2 /W4 /EHsc src\leakhound.cpp /Fe:leakhound.exe
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```text
48
+ leakhound [options] <path>...
49
+ --json machine-readable output (one JSON object, findings array)
50
+ --no-entropy disable entropy heuristics (pattern rules only)
51
+ --max-size N skip files larger than N bytes (default 2 MiB)
52
+ --quiet suppress per-finding lines, print summary only
53
+ -h, --help usage
54
+ ```
55
+
56
+ Paths may be files or directories. Directories are scanned recursively, while
57
+ common generated or dependency directories such as `.git`, `node_modules`,
58
+ `vendor`, `dist`, `build`, `target`, `__pycache__`, `.venv`, and `venv` are
59
+ skipped.
60
+
61
+ Default output:
62
+
63
+ ```text
64
+ HIGH aws-access-key path/to/file.txt:12 AKIA12...CDEF
65
+ Summary: files scanned=4, files skipped=1, HIGH=1, MEDIUM=0, LOW=0, wall time=3ms
66
+ ```
67
+
68
+ JSON output:
69
+
70
+ ```sh
71
+ leakhound --json .
72
+ ```
73
+
74
+ ## Rules
75
+
76
+ | Rule ID | Severity | Pattern |
77
+ | --- | --- | --- |
78
+ | `aws-access-key` | HIGH | `AKIA[0-9A-Z]{16}` |
79
+ | `github-token` | HIGH | `gh[pousr]_[A-Za-z0-9]{36,}` |
80
+ | `slack-token` | HIGH | `xox[baprs]-[A-Za-z0-9-]{10,}` |
81
+ | `stripe-live-key` | HIGH | `sk_live_[A-Za-z0-9]{16,}` |
82
+ | `private-key-pem` | HIGH | PEM private key header lines |
83
+ | `google-api-key` | HIGH | `AIza[0-9A-Za-z_-]{35}` |
84
+ | `npm-token` | HIGH | `npm_[A-Za-z0-9]{36}` |
85
+ | `jwt` | MEDIUM | JWT-shaped three-part token |
86
+ | `generic-assignment` | MEDIUM | Secret-like key assigned to a quoted non-placeholder value |
87
+ | `high-entropy-token` | LOW | High-entropy mixed-case token heuristic |
88
+
89
+ The entropy heuristic tokenizes each line into runs of
90
+ `[A-Za-z0-9+/=_-]` from 24 to 128 characters, then flags tokens with Shannon
91
+ entropy greater than 4.5 bits per character that contain at least one digit and
92
+ mixed case. Use `--no-entropy` to disable this tier.
93
+
94
+ ## Exit Codes
95
+
96
+ | Code | Meaning |
97
+ | --- | --- |
98
+ | 0 | Scan completed with no findings |
99
+ | 1 | Scan completed with findings |
100
+ | 2 | Usage or IO error |
101
+
102
+ ## Tests
103
+
104
+ ```sh
105
+ python tests/run_tests.py
106
+ ```
107
+
108
+ The test runner builds temporary fixtures, runs the scanner, checks exit codes,
109
+ validates JSON output, verifies redaction and placeholder rejection, and checks
110
+ binary, directory, and max-size skipping. If no compiler is available, set
111
+ `LEAKHOUND_BIN` to an existing scanner binary.
@@ -0,0 +1,93 @@
1
+ # leakhound
2
+
3
+ `leakhound` is a fast local credential and secret scanner. It is a single C++17
4
+ binary with no external dependencies.
5
+
6
+ ## Build
7
+
8
+ With CMake:
9
+
10
+ ```sh
11
+ cmake -S . -B build
12
+ cmake --build build --config Release
13
+ ```
14
+
15
+ On Windows with the MSVC command prompt:
16
+
17
+ ```bat
18
+ build.bat
19
+ ```
20
+
21
+ The batch file runs:
22
+
23
+ ```bat
24
+ cl /std:c++17 /O2 /W4 /EHsc src\leakhound.cpp /Fe:leakhound.exe
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```text
30
+ leakhound [options] <path>...
31
+ --json machine-readable output (one JSON object, findings array)
32
+ --no-entropy disable entropy heuristics (pattern rules only)
33
+ --max-size N skip files larger than N bytes (default 2 MiB)
34
+ --quiet suppress per-finding lines, print summary only
35
+ -h, --help usage
36
+ ```
37
+
38
+ Paths may be files or directories. Directories are scanned recursively, while
39
+ common generated or dependency directories such as `.git`, `node_modules`,
40
+ `vendor`, `dist`, `build`, `target`, `__pycache__`, `.venv`, and `venv` are
41
+ skipped.
42
+
43
+ Default output:
44
+
45
+ ```text
46
+ HIGH aws-access-key path/to/file.txt:12 AKIA12...CDEF
47
+ Summary: files scanned=4, files skipped=1, HIGH=1, MEDIUM=0, LOW=0, wall time=3ms
48
+ ```
49
+
50
+ JSON output:
51
+
52
+ ```sh
53
+ leakhound --json .
54
+ ```
55
+
56
+ ## Rules
57
+
58
+ | Rule ID | Severity | Pattern |
59
+ | --- | --- | --- |
60
+ | `aws-access-key` | HIGH | `AKIA[0-9A-Z]{16}` |
61
+ | `github-token` | HIGH | `gh[pousr]_[A-Za-z0-9]{36,}` |
62
+ | `slack-token` | HIGH | `xox[baprs]-[A-Za-z0-9-]{10,}` |
63
+ | `stripe-live-key` | HIGH | `sk_live_[A-Za-z0-9]{16,}` |
64
+ | `private-key-pem` | HIGH | PEM private key header lines |
65
+ | `google-api-key` | HIGH | `AIza[0-9A-Za-z_-]{35}` |
66
+ | `npm-token` | HIGH | `npm_[A-Za-z0-9]{36}` |
67
+ | `jwt` | MEDIUM | JWT-shaped three-part token |
68
+ | `generic-assignment` | MEDIUM | Secret-like key assigned to a quoted non-placeholder value |
69
+ | `high-entropy-token` | LOW | High-entropy mixed-case token heuristic |
70
+
71
+ The entropy heuristic tokenizes each line into runs of
72
+ `[A-Za-z0-9+/=_-]` from 24 to 128 characters, then flags tokens with Shannon
73
+ entropy greater than 4.5 bits per character that contain at least one digit and
74
+ mixed case. Use `--no-entropy` to disable this tier.
75
+
76
+ ## Exit Codes
77
+
78
+ | Code | Meaning |
79
+ | --- | --- |
80
+ | 0 | Scan completed with no findings |
81
+ | 1 | Scan completed with findings |
82
+ | 2 | Usage or IO error |
83
+
84
+ ## Tests
85
+
86
+ ```sh
87
+ python tests/run_tests.py
88
+ ```
89
+
90
+ The test runner builds temporary fixtures, runs the scanner, checks exit codes,
91
+ validates JSON output, verifies redaction and placeholder rejection, and checks
92
+ binary, directory, and max-size skipping. If no compiler is available, set
93
+ `LEAKHOUND_BIN` to an existing scanner binary.
@@ -0,0 +1,82 @@
1
+ # leakhound — spec
2
+
3
+ Fast credential/secret scanner. Single static binary, C++17, **zero external
4
+ dependencies** (standard library only). Target compilers: MSVC 2022 (`cl`) and
5
+ g++/clang. Must compile warning-clean at `/W4` / `-Wall -Wextra`.
6
+
7
+ ## CLI
8
+
9
+ ```
10
+ leakhound [options] <path>...
11
+ --json machine-readable output (one JSON object, findings array)
12
+ --no-entropy disable entropy heuristics (pattern rules only)
13
+ --max-size N skip files larger than N bytes (default 2 MiB)
14
+ --quiet suppress per-finding lines, print summary only
15
+ -h, --help usage
16
+ ```
17
+
18
+ - Paths may be files or directories (recursed).
19
+ - Exit code: 0 = no findings, 1 = findings, 2 = usage/IO error.
20
+
21
+ ## Scanning behavior
22
+
23
+ - Skip directories named: `.git`, `node_modules`, `vendor`, `dist`, `build`,
24
+ `target`, `__pycache__`, `.venv`, `venv`.
25
+ - Skip binary files: if the first 4096 bytes contain a NUL byte.
26
+ - Read files in one shot (they are capped by --max-size).
27
+ - Track line numbers for findings.
28
+
29
+ ## Detection rules (pattern tier — high confidence)
30
+
31
+ Implement with hand-rolled matchers or std::regex, whichever is cleaner, but
32
+ each rule gets an id, a human name, and a severity (HIGH/MEDIUM):
33
+
34
+ 1. `aws-access-key` — `AKIA[0-9A-Z]{16}` (HIGH)
35
+ 2. `github-token` — `gh[pousr]_[A-Za-z0-9]{36,}` (HIGH)
36
+ 3. `slack-token` — `xox[baprs]-[A-Za-z0-9-]{10,}` (HIGH)
37
+ 4. `stripe-live-key` — `sk_live_[A-Za-z0-9]{16,}` (HIGH)
38
+ 5. `private-key-pem` — line containing `-----BEGIN` + (`RSA`|`EC`|`OPENSSH`|`DSA`|`PGP`)? + `PRIVATE KEY` (HIGH)
39
+ 6. `google-api-key` — `AIza[0-9A-Za-z_-]{35}` (HIGH)
40
+ 7. `npm-token` — `npm_[A-Za-z0-9]{36}` (HIGH)
41
+ 8. `jwt` — `eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}` (MEDIUM)
42
+ 9. `generic-assignment` — key names (`api_key`, `apikey`, `secret`, `token`,
43
+ `passwd`, `password`, `auth`) followed by `=` or `:` and a quoted literal
44
+ of 12+ chars that is not an obvious placeholder (reject values containing
45
+ `example`, `changeme`, `placeholder`, `xxxx`, `your_`, `<`, `>`, `${`, `%s`) (MEDIUM)
46
+
47
+ ## Entropy tier (heuristic — LOW severity)
48
+
49
+ - Tokenize each line into runs of `[A-Za-z0-9+/=_-]` of length 24–128.
50
+ - Shannon entropy over the token's bytes; flag when > 4.5 bits/char and the
51
+ token contains at least one digit and mixed case (cuts English-word noise).
52
+ - Skip tokens already covered by a pattern finding on the same line.
53
+ - Skip lines that look like lockfile/hash noise: filename ends in
54
+ `.lock`, `-lock.json`, `.sum`, or line contains `integrity` or `sha512-`.
55
+
56
+ ## Output
57
+
58
+ Console (default): `SEVERITY rule-id path:line <match, redacted>`.
59
+ Redaction: show first 6 and last 4 chars of the match, `…` between.
60
+ Summary line: files scanned, files skipped, findings by severity, wall time.
61
+ JSON mode: `{"version":1,"findings":[{"rule","severity","path","line","redacted"}...],"stats":{...}}`.
62
+ No color codes in --json mode. Console colors via ANSI only when stdout is a
63
+ TTY (use `_isatty(_fileno(stdout))` on Windows, `isatty(1)` elsewhere; enable
64
+ VT processing on Windows with SetConsoleMode).
65
+
66
+ ## Layout
67
+
68
+ ```
69
+ src/leakhound.cpp (single translation unit is fine; keep functions small)
70
+ tests/run_tests.py (python3: builds fixtures in a temp dir, runs the binary,
71
+ asserts exit codes, finding counts, JSON validity,
72
+ redaction, placeholder rejection, binary/dir skipping)
73
+ CMakeLists.txt (minimal, C++17, /W4 or -Wall -Wextra)
74
+ build.bat (MSVC one-liner fallback: cl /std:c++17 /O2 /W4 /EHsc src\leakhound.cpp /Fe:leakhound.exe)
75
+ README.md (usage, rules table, exit codes, build instructions)
76
+ .gitignore (build artifacts)
77
+ ```
78
+
79
+ ## Non-goals
80
+
81
+ Do not add: config files, network calls, git history scanning, multithreading
82
+ (keep it simple; it is IO-bound at typical repo sizes).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["scikit-build-core>=0.8"]
3
+ build-backend = "scikit_build_core.build"
4
+
5
+ [project]
6
+ name = "leakhound"
7
+ version = "0.1.0"
8
+ description = "Zero-dependency C++17 secret scanner - AWS/GitHub/Slack/Stripe keys, PEM, JWTs, entropy heuristics. Single binary, CI-friendly exit codes."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Makeph" }]
13
+ keywords = ["secrets", "scanner", "security", "secret-detection", "credentials", "cli"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: C++",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/Makeph/leakhound"
24
+ Repository = "https://github.com/Makeph/leakhound"
25
+ Issues = "https://github.com/Makeph/leakhound/issues"
26
+
27
+ [tool.scikit-build]
28
+ # This wheel ships a compiled binary, not a Python module, so it is
29
+ # Python-version independent (one wheel per platform).
30
+ wheel.py-api = "py3"
31
+ wheel.packages = []
32
+ cmake.version = ">=3.15"
33
+ minimum-version = "0.8"
34
+
35
+ [tool.scikit-build.sdist]
36
+ # Only the sources needed to build; keep prebuilt junk out of the sdist.
37
+ include = ["src/**", "tests/**", "CMakeLists.txt", "README.md", "SPEC.md", "LICENSE"]
38
+ exclude = ["*.exe", "*.obj", "build/**", "build.bat"]
@@ -0,0 +1,683 @@
1
+ #include <chrono>
2
+ #include <cmath>
3
+ #include <cstddef>
4
+ #include <cstdio>
5
+ #include <cstdint>
6
+ #include <filesystem>
7
+ #include <fstream>
8
+ #include <iostream>
9
+ #include <limits>
10
+ #include <regex>
11
+ #include <sstream>
12
+ #include <string>
13
+ #include <vector>
14
+
15
+ #ifdef _WIN32
16
+ #define NOMINMAX
17
+ #include <io.h>
18
+ #include <windows.h>
19
+ #else
20
+ #include <unistd.h>
21
+ #endif
22
+
23
+ namespace fs = std::filesystem;
24
+
25
+ namespace {
26
+
27
+ enum class Severity {
28
+ High,
29
+ Medium,
30
+ Low
31
+ };
32
+
33
+ struct Options {
34
+ bool json = false;
35
+ bool entropy = true;
36
+ bool quiet = false;
37
+ std::uintmax_t max_size = 2U * 1024U * 1024U;
38
+ std::vector<fs::path> paths;
39
+ };
40
+
41
+ struct Rule {
42
+ std::string id;
43
+ std::string name;
44
+ Severity severity;
45
+ std::regex pattern;
46
+ };
47
+
48
+ struct Finding {
49
+ std::string rule;
50
+ Severity severity;
51
+ std::string path;
52
+ std::size_t line = 0;
53
+ std::string redacted;
54
+ };
55
+
56
+ struct Span {
57
+ std::size_t begin = 0;
58
+ std::size_t end = 0;
59
+ };
60
+
61
+ struct Stats {
62
+ std::size_t files_scanned = 0;
63
+ std::size_t files_skipped = 0;
64
+ std::size_t high = 0;
65
+ std::size_t medium = 0;
66
+ std::size_t low = 0;
67
+ long long duration_ms = 0;
68
+ };
69
+
70
+ struct ScanResult {
71
+ std::vector<Finding> findings;
72
+ Stats stats;
73
+ bool io_error = false;
74
+ };
75
+
76
+ std::string severity_to_string(Severity severity) {
77
+ switch (severity) {
78
+ case Severity::High:
79
+ return "HIGH";
80
+ case Severity::Medium:
81
+ return "MEDIUM";
82
+ case Severity::Low:
83
+ return "LOW";
84
+ }
85
+ return "LOW";
86
+ }
87
+
88
+ const char *severity_color(Severity severity) {
89
+ switch (severity) {
90
+ case Severity::High:
91
+ return "\033[31m";
92
+ case Severity::Medium:
93
+ return "\033[33m";
94
+ case Severity::Low:
95
+ return "\033[36m";
96
+ }
97
+ return "";
98
+ }
99
+
100
+ bool stdout_is_tty() {
101
+ #ifdef _WIN32
102
+ return _isatty(_fileno(stdout)) != 0;
103
+ #else
104
+ return isatty(1) != 0;
105
+ #endif
106
+ }
107
+
108
+ void enable_ansi_if_possible() {
109
+ #ifdef _WIN32
110
+ HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
111
+ if (handle == INVALID_HANDLE_VALUE) {
112
+ return;
113
+ }
114
+ DWORD mode = 0;
115
+ if (GetConsoleMode(handle, &mode) == 0) {
116
+ return;
117
+ }
118
+ mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
119
+ static_cast<void>(SetConsoleMode(handle, mode));
120
+ #endif
121
+ }
122
+
123
+ std::string usage() {
124
+ return "leakhound [options] <path>...\n"
125
+ " --json machine-readable output (one JSON object, findings array)\n"
126
+ " --no-entropy disable entropy heuristics (pattern rules only)\n"
127
+ " --max-size N skip files larger than N bytes (default 2 MiB)\n"
128
+ " --quiet suppress per-finding lines, print summary only\n"
129
+ " -h, --help usage\n";
130
+ }
131
+
132
+ bool parse_uintmax(const std::string &text, std::uintmax_t &value) {
133
+ if (text.empty()) {
134
+ return false;
135
+ }
136
+ std::uintmax_t result = 0;
137
+ for (char ch : text) {
138
+ if (ch < '0' || ch > '9') {
139
+ return false;
140
+ }
141
+ const std::uintmax_t digit = static_cast<std::uintmax_t>(ch - '0');
142
+ if (result > (std::numeric_limits<std::uintmax_t>::max() - digit) / 10U) {
143
+ return false;
144
+ }
145
+ result = result * 10U + digit;
146
+ }
147
+ value = result;
148
+ return true;
149
+ }
150
+
151
+ bool parse_args(int argc, char **argv, Options &options, bool &help, std::string &error) {
152
+ help = false;
153
+ for (int i = 1; i < argc; ++i) {
154
+ const std::string arg(argv[i]);
155
+ if (arg == "--json") {
156
+ options.json = true;
157
+ } else if (arg == "--no-entropy") {
158
+ options.entropy = false;
159
+ } else if (arg == "--quiet") {
160
+ options.quiet = true;
161
+ } else if (arg == "-h" || arg == "--help") {
162
+ help = true;
163
+ return true;
164
+ } else if (arg == "--max-size") {
165
+ if (i + 1 >= argc) {
166
+ error = "--max-size requires a value";
167
+ return false;
168
+ }
169
+ ++i;
170
+ if (!parse_uintmax(argv[i], options.max_size)) {
171
+ error = "--max-size must be a non-negative integer";
172
+ return false;
173
+ }
174
+ } else if (!arg.empty() && arg[0] == '-') {
175
+ error = "unknown option: " + arg;
176
+ return false;
177
+ } else {
178
+ options.paths.push_back(fs::path(arg));
179
+ }
180
+ }
181
+ if (options.paths.empty()) {
182
+ error = "at least one path is required";
183
+ return false;
184
+ }
185
+ return true;
186
+ }
187
+
188
+ std::vector<Rule> build_rules() {
189
+ const std::regex::flag_type flags = std::regex::ECMAScript;
190
+ std::vector<Rule> rules;
191
+ rules.push_back({"aws-access-key", "AWS access key", Severity::High,
192
+ std::regex("AKIA[0-9A-Z]{16}", flags)});
193
+ rules.push_back({"github-token", "GitHub token", Severity::High,
194
+ std::regex("gh[pousr]_[A-Za-z0-9]{36,}", flags)});
195
+ rules.push_back({"slack-token", "Slack token", Severity::High,
196
+ std::regex("xox[baprs]-[A-Za-z0-9-]{10,}", flags)});
197
+ rules.push_back({"stripe-live-key", "Stripe live key", Severity::High,
198
+ std::regex("sk_live_[A-Za-z0-9]{16,}", flags)});
199
+ rules.push_back({"private-key-pem", "Private key PEM", Severity::High,
200
+ std::regex("-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY", flags)});
201
+ rules.push_back({"google-api-key", "Google API key", Severity::High,
202
+ std::regex("AIza[0-9A-Za-z_-]{35}", flags)});
203
+ rules.push_back({"npm-token", "npm token", Severity::High,
204
+ std::regex("npm_[A-Za-z0-9]{36}", flags)});
205
+ rules.push_back({"jwt", "JWT", Severity::Medium,
206
+ std::regex("eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{5,}", flags)});
207
+ return rules;
208
+ }
209
+
210
+ bool is_skipped_directory_name(const fs::path &path) {
211
+ const std::string name = path.filename().string();
212
+ static const char *const skipped[] = {
213
+ ".git", "node_modules", "vendor", "dist", "build",
214
+ "target", "__pycache__", ".venv", "venv"};
215
+ for (const char *entry : skipped) {
216
+ if (name == entry) {
217
+ return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ std::string to_lower_ascii(std::string text) {
224
+ for (char &ch : text) {
225
+ if (ch >= 'A' && ch <= 'Z') {
226
+ ch = static_cast<char>(ch - 'A' + 'a');
227
+ }
228
+ }
229
+ return text;
230
+ }
231
+
232
+ bool contains_placeholder(const std::string &value) {
233
+ const std::string lower = to_lower_ascii(value);
234
+ static const char *const placeholders[] = {
235
+ "example", "changeme", "placeholder", "xxxx", "your_", "<", ">", "${", "%s"};
236
+ for (const char *placeholder : placeholders) {
237
+ if (lower.find(placeholder) != std::string::npos) {
238
+ return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+
244
+ std::string redact(const std::string &text) {
245
+ if (text.size() <= 10U) {
246
+ return text;
247
+ }
248
+ return text.substr(0U, 6U) + "\xE2\x80\xA6" + text.substr(text.size() - 4U);
249
+ }
250
+
251
+ std::string json_escape(const std::string &text) {
252
+ std::ostringstream out;
253
+ for (unsigned char ch : text) {
254
+ switch (ch) {
255
+ case '"':
256
+ out << "\\\"";
257
+ break;
258
+ case '\\':
259
+ out << "\\\\";
260
+ break;
261
+ case '\b':
262
+ out << "\\b";
263
+ break;
264
+ case '\f':
265
+ out << "\\f";
266
+ break;
267
+ case '\n':
268
+ out << "\\n";
269
+ break;
270
+ case '\r':
271
+ out << "\\r";
272
+ break;
273
+ case '\t':
274
+ out << "\\t";
275
+ break;
276
+ default:
277
+ if (ch < 0x20U) {
278
+ static const char digits[] = "0123456789abcdef";
279
+ out << "\\u00" << digits[(ch >> 4U) & 0x0FU] << digits[ch & 0x0FU];
280
+ } else {
281
+ out << static_cast<char>(ch);
282
+ }
283
+ break;
284
+ }
285
+ }
286
+ return out.str();
287
+ }
288
+
289
+ void add_finding(ScanResult &result,
290
+ const std::string &rule,
291
+ Severity severity,
292
+ const std::string &path,
293
+ std::size_t line,
294
+ const std::string &match) {
295
+ Finding finding;
296
+ finding.rule = rule;
297
+ finding.severity = severity;
298
+ finding.path = path;
299
+ finding.line = line;
300
+ finding.redacted = redact(match);
301
+ result.findings.push_back(finding);
302
+ if (severity == Severity::High) {
303
+ ++result.stats.high;
304
+ } else if (severity == Severity::Medium) {
305
+ ++result.stats.medium;
306
+ } else {
307
+ ++result.stats.low;
308
+ }
309
+ }
310
+
311
+ bool spans_overlap(std::size_t begin, std::size_t end, const std::vector<Span> &spans) {
312
+ for (const Span &span : spans) {
313
+ if (begin < span.end && end > span.begin) {
314
+ return true;
315
+ }
316
+ }
317
+ return false;
318
+ }
319
+
320
+ bool is_binary_file(const fs::path &path, bool &io_error) {
321
+ std::ifstream input(path, std::ios::binary);
322
+ if (!input) {
323
+ io_error = true;
324
+ return false;
325
+ }
326
+ char buffer[4096];
327
+ input.read(buffer, sizeof(buffer));
328
+ const std::streamsize read_count = input.gcount();
329
+ for (std::streamsize i = 0; i < read_count; ++i) {
330
+ if (buffer[static_cast<std::size_t>(i)] == '\0') {
331
+ return true;
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+
337
+ bool read_file(const fs::path &path, std::uintmax_t size, std::string &content) {
338
+ if (size > static_cast<std::uintmax_t>(std::numeric_limits<std::size_t>::max()) ||
339
+ size > static_cast<std::uintmax_t>(std::numeric_limits<std::streamsize>::max())) {
340
+ return false;
341
+ }
342
+ std::ifstream input(path, std::ios::binary);
343
+ if (!input) {
344
+ return false;
345
+ }
346
+ content.resize(static_cast<std::size_t>(size));
347
+ if (!content.empty()) {
348
+ input.read(&content[0], static_cast<std::streamsize>(content.size()));
349
+ if (input.gcount() != static_cast<std::streamsize>(content.size())) {
350
+ return false;
351
+ }
352
+ }
353
+ return true;
354
+ }
355
+
356
+ bool should_skip_entropy_line(const std::string &path, const std::string &line) {
357
+ const std::string lower_path = to_lower_ascii(path);
358
+ const std::string lower_line = to_lower_ascii(line);
359
+ if (lower_path.size() >= 5U && lower_path.compare(lower_path.size() - 5U, 5U, ".lock") == 0) {
360
+ return true;
361
+ }
362
+ if (lower_path.size() >= 10U && lower_path.compare(lower_path.size() - 10U, 10U, "-lock.json") == 0) {
363
+ return true;
364
+ }
365
+ if (lower_path.size() >= 4U && lower_path.compare(lower_path.size() - 4U, 4U, ".sum") == 0) {
366
+ return true;
367
+ }
368
+ return lower_line.find("integrity") != std::string::npos ||
369
+ lower_line.find("sha512-") != std::string::npos;
370
+ }
371
+
372
+ bool is_token_char(char ch) {
373
+ return (ch >= 'A' && ch <= 'Z') ||
374
+ (ch >= 'a' && ch <= 'z') ||
375
+ (ch >= '0' && ch <= '9') ||
376
+ ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-';
377
+ }
378
+
379
+ double shannon_entropy(const std::string &token) {
380
+ int counts[256] = {};
381
+ for (unsigned char ch : token) {
382
+ ++counts[ch];
383
+ }
384
+ double entropy = 0.0;
385
+ const double length = static_cast<double>(token.size());
386
+ for (int count : counts) {
387
+ if (count > 0) {
388
+ const double probability = static_cast<double>(count) / length;
389
+ entropy -= probability * std::log2(probability);
390
+ }
391
+ }
392
+ return entropy;
393
+ }
394
+
395
+ bool has_digit_and_mixed_case(const std::string &token) {
396
+ bool digit = false;
397
+ bool lower = false;
398
+ bool upper = false;
399
+ for (char ch : token) {
400
+ if (ch >= '0' && ch <= '9') {
401
+ digit = true;
402
+ } else if (ch >= 'a' && ch <= 'z') {
403
+ lower = true;
404
+ } else if (ch >= 'A' && ch <= 'Z') {
405
+ upper = true;
406
+ }
407
+ }
408
+ return digit && lower && upper;
409
+ }
410
+
411
+ void scan_entropy_line(ScanResult &result,
412
+ const std::string &path,
413
+ const std::string &line,
414
+ std::size_t line_number,
415
+ const std::vector<Span> &covered_spans) {
416
+ if (should_skip_entropy_line(path, line)) {
417
+ return;
418
+ }
419
+
420
+ std::size_t index = 0;
421
+ while (index < line.size()) {
422
+ while (index < line.size() && !is_token_char(line[index])) {
423
+ ++index;
424
+ }
425
+ const std::size_t begin = index;
426
+ while (index < line.size() && is_token_char(line[index])) {
427
+ ++index;
428
+ }
429
+ const std::size_t end = index;
430
+ const std::size_t length = end - begin;
431
+ if (length >= 24U && length <= 128U && !spans_overlap(begin, end, covered_spans)) {
432
+ const std::string token = line.substr(begin, length);
433
+ if (has_digit_and_mixed_case(token) && shannon_entropy(token) > 4.5) {
434
+ add_finding(result, "high-entropy-token", Severity::Low, path, line_number, token);
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ void scan_line(ScanResult &result,
441
+ const std::vector<Rule> &rules,
442
+ const Options &options,
443
+ const std::string &path,
444
+ const std::string &line,
445
+ std::size_t line_number) {
446
+ std::vector<Span> covered_spans;
447
+ for (const Rule &rule : rules) {
448
+ std::sregex_iterator it(line.begin(), line.end(), rule.pattern);
449
+ const std::sregex_iterator end;
450
+ for (; it != end; ++it) {
451
+ const std::smatch match = *it;
452
+ const std::size_t begin = static_cast<std::size_t>(match.position(0));
453
+ const std::size_t length = static_cast<std::size_t>(match.length(0));
454
+ covered_spans.push_back({begin, begin + length});
455
+ add_finding(result, rule.id, rule.severity, path, line_number, match.str(0));
456
+ }
457
+ }
458
+
459
+ static const std::regex assignment_pattern(
460
+ "\\b(api_key|apikey|secret|token|passwd|password|auth)\\b[ \\t]*[=:][ \\t]*([\"'])([^\"']{12,})\\2",
461
+ std::regex::ECMAScript | std::regex::icase);
462
+ std::sregex_iterator assignment_it(line.begin(), line.end(), assignment_pattern);
463
+ const std::sregex_iterator assignment_end;
464
+ for (; assignment_it != assignment_end; ++assignment_it) {
465
+ const std::smatch match = *assignment_it;
466
+ const std::string value = match.str(3);
467
+ const std::size_t begin = static_cast<std::size_t>(match.position(3));
468
+ const std::size_t length = static_cast<std::size_t>(match.length(3));
469
+ // a value already matched by a specific pattern rule is reported once,
470
+ // under the more precise rule
471
+ if (!contains_placeholder(value) && !spans_overlap(begin, begin + length, covered_spans)) {
472
+ covered_spans.push_back({begin, begin + length});
473
+ add_finding(result, "generic-assignment", Severity::Medium, path, line_number, value);
474
+ }
475
+ }
476
+
477
+ if (options.entropy) {
478
+ scan_entropy_line(result, path, line, line_number, covered_spans);
479
+ }
480
+ }
481
+
482
+ void scan_content(ScanResult &result,
483
+ const std::vector<Rule> &rules,
484
+ const Options &options,
485
+ const std::string &path,
486
+ const std::string &content) {
487
+ std::size_t line_number = 1;
488
+ std::size_t begin = 0;
489
+ while (begin <= content.size()) {
490
+ std::size_t end = content.find('\n', begin);
491
+ if (end == std::string::npos) {
492
+ end = content.size();
493
+ }
494
+ std::string line = content.substr(begin, end - begin);
495
+ if (!line.empty() && line[line.size() - 1U] == '\r') {
496
+ line.erase(line.size() - 1U);
497
+ }
498
+ scan_line(result, rules, options, path, line, line_number);
499
+ if (end == content.size()) {
500
+ break;
501
+ }
502
+ begin = end + 1U;
503
+ ++line_number;
504
+ }
505
+ }
506
+
507
+ void scan_file(ScanResult &result,
508
+ const std::vector<Rule> &rules,
509
+ const Options &options,
510
+ const fs::path &path) {
511
+ std::error_code ec;
512
+ const std::uintmax_t size = fs::file_size(path, ec);
513
+ if (ec) {
514
+ result.io_error = true;
515
+ return;
516
+ }
517
+ if (size > options.max_size) {
518
+ ++result.stats.files_skipped;
519
+ return;
520
+ }
521
+
522
+ bool binary_io_error = false;
523
+ if (is_binary_file(path, binary_io_error)) {
524
+ ++result.stats.files_skipped;
525
+ return;
526
+ }
527
+ if (binary_io_error) {
528
+ result.io_error = true;
529
+ return;
530
+ }
531
+
532
+ std::string content;
533
+ if (!read_file(path, size, content)) {
534
+ result.io_error = true;
535
+ return;
536
+ }
537
+ ++result.stats.files_scanned;
538
+ scan_content(result, rules, options, path.string(), content);
539
+ }
540
+
541
+ void scan_path(ScanResult &result,
542
+ const std::vector<Rule> &rules,
543
+ const Options &options,
544
+ const fs::path &path) {
545
+ std::error_code ec;
546
+ const fs::file_status status = fs::status(path, ec);
547
+ if (ec || !fs::exists(status)) {
548
+ result.io_error = true;
549
+ return;
550
+ }
551
+
552
+ if (fs::is_regular_file(status)) {
553
+ scan_file(result, rules, options, path);
554
+ return;
555
+ }
556
+
557
+ if (!fs::is_directory(status)) {
558
+ ++result.stats.files_skipped;
559
+ return;
560
+ }
561
+
562
+ fs::recursive_directory_iterator it(path, fs::directory_options::skip_permission_denied, ec);
563
+ if (ec) {
564
+ result.io_error = true;
565
+ return;
566
+ }
567
+ const fs::recursive_directory_iterator end;
568
+ for (; it != end; it.increment(ec)) {
569
+ if (ec) {
570
+ result.io_error = true;
571
+ ec.clear();
572
+ continue;
573
+ }
574
+
575
+ const fs::path current = it->path();
576
+ std::error_code status_ec;
577
+ const fs::file_status current_status = it->symlink_status(status_ec);
578
+ if (status_ec) {
579
+ result.io_error = true;
580
+ continue;
581
+ }
582
+
583
+ if (fs::is_directory(current_status)) {
584
+ if (is_skipped_directory_name(current)) {
585
+ it.disable_recursion_pending();
586
+ }
587
+ continue;
588
+ }
589
+ if (fs::is_regular_file(current_status)) {
590
+ scan_file(result, rules, options, current);
591
+ } else {
592
+ ++result.stats.files_skipped;
593
+ }
594
+ }
595
+ }
596
+
597
+ void print_json(const ScanResult &result) {
598
+ std::cout << "{\"version\":1,\"findings\":[";
599
+ for (std::size_t i = 0; i < result.findings.size(); ++i) {
600
+ const Finding &finding = result.findings[i];
601
+ if (i != 0U) {
602
+ std::cout << ",";
603
+ }
604
+ std::cout << "{\"rule\":\"" << json_escape(finding.rule)
605
+ << "\",\"severity\":\"" << severity_to_string(finding.severity)
606
+ << "\",\"path\":\"" << json_escape(finding.path)
607
+ << "\",\"line\":" << finding.line
608
+ << ",\"redacted\":\"" << json_escape(finding.redacted) << "\"}";
609
+ }
610
+ std::cout << "],\"stats\":{"
611
+ << "\"files_scanned\":" << result.stats.files_scanned
612
+ << ",\"files_skipped\":" << result.stats.files_skipped
613
+ << ",\"findings_high\":" << result.stats.high
614
+ << ",\"findings_medium\":" << result.stats.medium
615
+ << ",\"findings_low\":" << result.stats.low
616
+ << ",\"duration_ms\":" << result.stats.duration_ms
617
+ << "}}\n";
618
+ }
619
+
620
+ void print_console(const ScanResult &result, bool quiet) {
621
+ const bool color = stdout_is_tty();
622
+ if (color) {
623
+ enable_ansi_if_possible();
624
+ }
625
+
626
+ if (!quiet) {
627
+ for (const Finding &finding : result.findings) {
628
+ if (color) {
629
+ std::cout << severity_color(finding.severity);
630
+ }
631
+ std::cout << severity_to_string(finding.severity);
632
+ if (color) {
633
+ std::cout << "\033[0m";
634
+ }
635
+ std::cout << " " << finding.rule << " " << finding.path << ":"
636
+ << finding.line << " " << finding.redacted << "\n";
637
+ }
638
+ }
639
+
640
+ std::cout << "Summary: files scanned=" << result.stats.files_scanned
641
+ << ", files skipped=" << result.stats.files_skipped
642
+ << ", HIGH=" << result.stats.high
643
+ << ", MEDIUM=" << result.stats.medium
644
+ << ", LOW=" << result.stats.low
645
+ << ", wall time=" << result.stats.duration_ms << "ms\n";
646
+ }
647
+
648
+ } // namespace
649
+
650
+ int main(int argc, char **argv) {
651
+ Options options;
652
+ bool help = false;
653
+ std::string error;
654
+ if (!parse_args(argc, argv, options, help, error)) {
655
+ std::cerr << "error: " << error << "\n" << usage();
656
+ return 2;
657
+ }
658
+ if (help) {
659
+ std::cout << usage();
660
+ return 0;
661
+ }
662
+
663
+ const std::vector<Rule> rules = build_rules();
664
+ ScanResult result;
665
+ const auto start = std::chrono::steady_clock::now();
666
+ for (const fs::path &path : options.paths) {
667
+ scan_path(result, rules, options, path);
668
+ }
669
+ const auto finish = std::chrono::steady_clock::now();
670
+ result.stats.duration_ms =
671
+ std::chrono::duration_cast<std::chrono::milliseconds>(finish - start).count();
672
+
673
+ if (options.json) {
674
+ print_json(result);
675
+ } else {
676
+ print_console(result, options.quiet);
677
+ }
678
+
679
+ if (result.io_error) {
680
+ return 2;
681
+ }
682
+ return result.findings.empty() ? 0 : 1;
683
+ }
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ def run(cmd, **kwargs):
15
+ return subprocess.run(
16
+ cmd, text=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs
17
+ )
18
+
19
+
20
+ def compiler_available(name):
21
+ return shutil.which(name) is not None
22
+
23
+
24
+ def build_binary():
25
+ env_bin = os.environ.get("LEAKHOUND_BIN")
26
+ if env_bin:
27
+ path = Path(env_bin)
28
+ if not path.exists():
29
+ raise AssertionError(f"LEAKHOUND_BIN does not exist: {path}")
30
+ return path
31
+
32
+ exe_name = "leakhound.exe" if os.name == "nt" else "leakhound"
33
+ direct_exe = ROOT / exe_name
34
+ if direct_exe.exists():
35
+ return direct_exe
36
+
37
+ build_dir = ROOT / "build" / "tests"
38
+ build_dir.mkdir(parents=True, exist_ok=True)
39
+
40
+ if compiler_available("cmake"):
41
+ cfg = run(["cmake", "-S", str(ROOT), "-B", str(build_dir)])
42
+ if cfg.returncode == 0:
43
+ bld = run(["cmake", "--build", str(build_dir), "--config", "Release"])
44
+ if bld.returncode == 0:
45
+ candidates = [
46
+ build_dir / exe_name,
47
+ build_dir / "Release" / exe_name,
48
+ build_dir / "Debug" / exe_name,
49
+ ]
50
+ for candidate in candidates:
51
+ if candidate.exists():
52
+ return candidate
53
+ else:
54
+ raise AssertionError(bld.stderr or bld.stdout)
55
+ else:
56
+ raise AssertionError(cfg.stderr or cfg.stdout)
57
+
58
+ if os.name == "nt" and compiler_available("cl"):
59
+ out = build_dir / "leakhound.exe"
60
+ cmd = [
61
+ "cl",
62
+ "/std:c++17",
63
+ "/O2",
64
+ "/W4",
65
+ "/EHsc",
66
+ str(ROOT / "src" / "leakhound.cpp"),
67
+ f"/Fe:{out}",
68
+ ]
69
+ result = run(cmd, cwd=ROOT)
70
+ if result.returncode != 0:
71
+ raise AssertionError(result.stderr or result.stdout)
72
+ return out
73
+
74
+ for compiler in ("c++", "g++", "clang++"):
75
+ if compiler_available(compiler):
76
+ out = build_dir / "leakhound"
77
+ cmd = [
78
+ compiler,
79
+ "-std=c++17",
80
+ "-O2",
81
+ "-Wall",
82
+ "-Wextra",
83
+ str(ROOT / "src" / "leakhound.cpp"),
84
+ "-o",
85
+ str(out),
86
+ ]
87
+ result = run(cmd, cwd=ROOT)
88
+ if result.returncode != 0:
89
+ raise AssertionError(result.stderr or result.stdout)
90
+ return out
91
+
92
+ raise AssertionError("no leakhound binary or C++17 compiler found; set LEAKHOUND_BIN")
93
+
94
+
95
+ def write_text(path, text):
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text(text, encoding="utf-8")
98
+
99
+
100
+ def make_fixture(root):
101
+ scan = root / "scan"
102
+ scan.mkdir()
103
+ write_text(
104
+ scan / "secrets.txt",
105
+ "\n".join(
106
+ [
107
+ "const aws = 'AKIA1234567890ABCDEF';",
108
+ "password = \"CorrectHorseBattery99\"",
109
+ "api_key = 'sk_live_4eC39HqLyjWDarjtT1zd'",
110
+ "token = \"example_placeholder_value\"",
111
+ "random = aB3dE5fG7hI9jK1lM2nO4pQ6rS8tU0vW",
112
+ "",
113
+ ]
114
+ ),
115
+ )
116
+ write_text(
117
+ scan / "package-lock.json",
118
+ "integrity sha512-aB3dE5fG7hI9jK1lM2nO4pQ6rS8tU0vW\n",
119
+ )
120
+ write_text(scan / "node_modules" / "ignored.js", "AKIAABCDEFGHIJKLMNOP\n")
121
+ (scan / "binary.bin").write_bytes(b"\x00AKIA1234567890ABCDEF\n")
122
+ return scan
123
+
124
+
125
+ def assert_equal(actual, expected, label):
126
+ if actual != expected:
127
+ raise AssertionError(f"{label}: expected {expected!r}, got {actual!r}")
128
+
129
+
130
+ def test_json_scan(binary):
131
+ with tempfile.TemporaryDirectory() as tmp:
132
+ scan = make_fixture(Path(tmp))
133
+ result = run([str(binary), "--json", str(scan)])
134
+ assert_equal(result.returncode, 1, "json scan exit")
135
+ payload = json.loads(result.stdout)
136
+ findings = payload["findings"]
137
+ rules = [item["rule"] for item in findings]
138
+ assert_equal(rules.count("aws-access-key"), 1, "aws finding count")
139
+ assert_equal(rules.count("stripe-live-key"), 1, "stripe finding count")
140
+ # the stripe value sits in an api_key assignment: reported once, under
141
+ # the specific rule, not duplicated by generic-assignment
142
+ assert_equal(rules.count("generic-assignment"), 1, "generic finding count")
143
+ assert_equal(rules.count("high-entropy-token"), 1, "entropy finding count")
144
+ assert "example_placeholder_value" not in result.stdout
145
+ assert "AKIA12\u2026CDEF" in result.stdout
146
+ assert payload["stats"]["files_skipped"] >= 1
147
+ assert payload["stats"]["findings_high"] == 2
148
+ assert payload["stats"]["findings_medium"] == 1
149
+ assert payload["stats"]["findings_low"] == 1
150
+
151
+
152
+ def test_no_entropy_and_quiet(binary):
153
+ with tempfile.TemporaryDirectory() as tmp:
154
+ scan = make_fixture(Path(tmp))
155
+ result = run([str(binary), "--no-entropy", "--quiet", str(scan)])
156
+ assert_equal(result.returncode, 1, "no entropy exit")
157
+ assert "high-entropy-token" not in result.stdout
158
+ assert "aws-access-key" not in result.stdout
159
+ assert "Summary:" in result.stdout
160
+ assert "LOW=0" in result.stdout
161
+
162
+
163
+ def test_clean_and_usage_exit_codes(binary):
164
+ with tempfile.TemporaryDirectory() as tmp:
165
+ clean = Path(tmp) / "clean.txt"
166
+ write_text(clean, "hello world\n")
167
+ ok = run([str(binary), str(clean)])
168
+ assert_equal(ok.returncode, 0, "clean exit")
169
+
170
+ missing = run([str(binary), str(Path(tmp) / "missing.txt")])
171
+ assert_equal(missing.returncode, 2, "missing path exit")
172
+
173
+ usage = run([str(binary)])
174
+ assert_equal(usage.returncode, 2, "usage exit")
175
+
176
+
177
+ def test_max_size_skip(binary):
178
+ with tempfile.TemporaryDirectory() as tmp:
179
+ large = Path(tmp) / "large.txt"
180
+ write_text(large, "AKIA1234567890ABCDEF\n")
181
+ result = run([str(binary), "--json", "--max-size", "3", str(large)])
182
+ assert_equal(result.returncode, 0, "max size skip exit")
183
+ payload = json.loads(result.stdout)
184
+ assert_equal(len(payload["findings"]), 0, "max size findings")
185
+ assert_equal(payload["stats"]["files_skipped"], 1, "max size skipped")
186
+
187
+
188
+ def main():
189
+ binary = build_binary()
190
+ test_json_scan(binary)
191
+ test_no_entropy_and_quiet(binary)
192
+ test_clean_and_usage_exit_codes(binary)
193
+ test_max_size_skip(binary)
194
+ print("all tests passed")
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()