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.
- leakhound-0.1.0/.github/workflows/pypi.yml +73 -0
- leakhound-0.1.0/.gitignore +13 -0
- leakhound-0.1.0/CMakeLists.txt +26 -0
- leakhound-0.1.0/LICENSE +21 -0
- leakhound-0.1.0/PKG-INFO +111 -0
- leakhound-0.1.0/README.md +93 -0
- leakhound-0.1.0/SPEC.md +82 -0
- leakhound-0.1.0/pyproject.toml +38 -0
- leakhound-0.1.0/src/leakhound.cpp +683 -0
- leakhound-0.1.0/tests/run_tests.py +198 -0
|
@@ -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,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()
|
leakhound-0.1.0/LICENSE
ADDED
|
@@ -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.
|
leakhound-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
leakhound-0.1.0/SPEC.md
ADDED
|
@@ -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()
|