LogSpecter
Schema-aware secret scanner for cloud logs. Regex finds candidates; Shannon entropy and a heuristic layer decide whether they are real keys; a JSON-structure layer tells you who leaked what through which field. Streams tens of gigabytes with a flat memory ceiling.
$ logspecter scan cloudtrail-2026-08-30.json.gz --stats
CRITICAL openai-api-key AWS IAM User (Alice) → action: AssumeRole
→ requestParameters.headers.Authorization
cloudtrail-2026-08-30.json.gz:81421 @byte 24118904
sk-p********kAyS (len=64) conf 1.00 / H 5.19
Not "a suspicious string on line 800". The actual identity, the actual API call, the actual JSON path.
Real output
logspecter scan examples/app.log on the bundled sample (run on Windows, first 17 lines; the CLI UI is currently in Chinese):
LogSpecter v0.1.0 · 61 条规则 · JSON 后端 orjson · 分块 4.0 MiB
┌──────────────────────── LogSpecter v0.1.0 扫描摘要 ─────────────────────────┐
│ 唯一泄露 4 条(总出现 4 次) │
│ 等级分布 critical × 2 high × 2 │
│ 扫描量 1 个来源 / 1 个块 / 2.6 KiB / 18 行 │
│ 性能 0.02s · 0.2 MiB/s · 1 进程 │
│ 内存峰值 单进程最高 32.6 MiB(全部进程合计 32.6 MiB) │
└─────────────────────────────────────────────────────────────────────────────┘
CRITICAL private-key-pem-header ×1 conf 1.00 H 3.38
detail
examples\app.log:13 ----********---- (len=31)
CRITICAL stripe-live-secret-key ×1 conf 1.00 H 4.86
examples\app.log:6:91
examples\app.log:6 sk_l********aJ3s (len=33)
HIGH authorization-header-basic ×1 conf 1.00 H 4.50
Why another secret scanner
Most log scanners are a pile of regexes. That fails in production for three reasons, and LogSpecter attacks each one directly.
1. Regex alone cannot tell a key from a session token
A rule like "base64 string of 32+ chars" fires on every pagination cursor, every trace ID, every base64-encoded JSON blob in your logs. LogSpecter runs a second stage on every candidate:
| Check | What it kills |
|---|---|
| Shannon entropy + charset-normalised entropy | low-diversity strings that merely look long |
| Charset coverage (unique chars ÷ achievable unique chars) | aaaa…, ababab…, and it does not penalise 64-char hex hashes |
| Repeat / sequential runs | xxxxxxxx, abcdefgh, 987654321 |
| Natural-language likeness (bigram + vowel ratio) | SpringBootApplicationConfigurationLoader |
| Base64 decode-back | strings that decode to readable text or JSON — encoded data, not keys |
| Placeholder & vendor-doc detection | AKIAIOSFODNN7EXAMPLE, changeme, <your-api-key>, ${VAR} |
| Keyword proximity | a 32-char hex blob only counts near key/secret/hmac |
Every decision is recorded on the finding, so you can audit why something was reported or dropped:
"evidence": ["entropy=5.61/6.00(base64url)", "charset_coverage=0.85",
"non-linguistic", "keyword-nearby", "also-matched:authorization-header-bearer"]
Entropy thresholds are per rule, not global. Authorization: Basic intentionally disables the
decode-back check (Basic auth is base64 text); database URLs relax entropy to catch weak human
passwords while relying on the scheme://user:pass@host shape for precision.
2. Treating logs as plain text throws away all the context
LogSpecter parses records with orjson (Rust-backed) and recognises the schema it is looking at:
- AWS CloudTrail — IAM identity,
eventName, region, source IP, account - GCP Cloud Logging —
principalEmail,methodName, resource, project - Kubernetes audit — user, verb,
objectRef - Azure Activity Log — identity claims,
operationName, result - Elastic Common Schema, Logback / Log4j2 JSON (including MDC)
Nested JSON-inside-a-JSON-string is expanded too, because requestParameters and MDC fields are
full of it. You get a precise path (protoPayload.request.credential) instead of a line number,
plus the actor and action needed to actually respond to the incident.
3. readlines() on a 30 GB log is an OOM
The input layer plans line-aligned byte ranges without reading the file (a few seeks and small tail reads), then hands one range per worker. Compressed files and stdin go through a producer/consumer path with a bounded submission window. Resident memory is a function of chunk size, never of file size:
| Scan | Wall time | Throughput | Main RSS | Peak worker RSS |
|---|---|---|---|---|
| 256 MiB, 1 worker | 20.7 s | 12.4 MiB/s | 45 MiB | — |
| 256 MiB, 8 workers | 5.1 s | 49.9 MiB/s | 42 MiB | 34 MiB |
| 1 GiB, 8 workers | 18.6 s | 55.0 MiB/s | 43 MiB | 35 MiB |
Same memory for 1 GiB as for 256 MiB. Measured with GetProcessMemoryInfo /
/proc/self/status and reported by --stats — not a claim, an output field.
Numbers from logspecter benchmark on 8 cores / Windows / CPython 3.13, scanning a synthetic mix
of CloudTrail records, application logs, and high-entropy-but-harmless noise. Reproduce with
logspecter benchmark --size 1GB -j 8; pure-Python throughput is CPU-bound, so expect it to track
your single-core speed times the worker count.
How it gets there in pure Python (the part that took the most work)
A naive "for each line, for each rule, run the regex" loop benchmarks at 3.4 MiB/s. Three changes took it to 12.4 MiB/s per core:
-
Bytes end to end. Rules compile to
bytespatterns, so there is no per-chunk decode, match offsets are file offsets, and\b/\wget predictable ASCII semantics. -
Occurrence-driven scanning instead of line iteration. Each rule's regex AST is statically analysed for literals that must appear in any match (
\b((?:AKIA|ASIA)[A-Z0-9]{16})\b→AKIA|ASIA). Those literals are located withbytes.find(~3.7 GiB/s) and the regex runs only on the lines that contain them. A monotonic cursor per literal means an absent literal is scanned once, not once per line — getting this wrong cost a 50× slowdown before it was fixed. -
Anchored matching. The analyser also computes how many bytes of the match may precede the literal.
_live_in(?:sk|rk)_live_…is always at offset 2, so instead ofsearch()-ing a whole line the scanner triesmatch()at one exact position. This is what removed most of the remaining cost: "literal present but regex does not match" is the single most common case in real logs (every CloudTrail S3 record contains"key":).
Literals are merged into prefix-tree regexes (key|keystore|kms → k(?:ey(?:store)?|ms)) so
CPython's INFO first-character-set optimisation applies: an 8 MiB buffer with no match at all is
rejected in 2.5 ms. That gives the fast path for clean or binary data.
The analyser only emits a literal when it can prove it is mandatory; otherwise the rule falls
back to a full scan. tests/test_prefilter.py asserts, for every built-in rule, that the prefilter
never rejects an input the regex would have matched, and that anchor windows always contain the
real match offset.
Install
pip install logspecter
From source:
git clone https://github.com/Jeffy123-zhu/logspecter
cd logspecter
pip install -e ".[dev]"
Requires Python 3.10+. Runtime dependencies: typer, rich, PyYAML, orjson.
Usage
# a file, a directory, a compressed archive
logspecter scan /var/log/app.log
logspecter scan /var/log/ --recursive
logspecter scan cloudtrail-2026-08-30.json.gz
# a pipe
kubectl logs deploy/api --since=1h | logspecter scan -
aws logs tail /aws/lambda/api --format short | logspecter scan -
# CI gate: fail only on new critical leaks
logspecter scan ./logs --baseline .logspecter-baseline.json --fail-on critical
# machine-readable output
logspecter scan ./logs -f json -o findings.json
logspecter scan ./logs -f csv -o soc2-evidence.csv
logspecter scan ./logs -f sarif -o results.sarif # GitHub code scanning
Exit codes: 0 clean, 1 findings at or above --fail-on (default high), 2 bad input.
Options worth knowing
| Flag | Effect |
|---|---|
-j, --workers N |
processes; default min(8, cpu), 1 disables multiprocessing |
--chunk-size 4MB |
the memory knob — resident data ≈ 2 × chunk × workers |
--min-entropy 4.5 |
raise the global entropy floor (precision over recall) |
--min-confidence 0.8 |
drop low-confidence findings |
--aggressive |
enable noisy entropy-only rules (recall over precision) |
--pack aws --tag github |
narrow the rule set |
--no-structured |
skip JSON parsing entirely; fastest, loses cloud context |
--show-secrets |
print plaintext (off by default — reports are redacted) |
--stats |
throughput, memory, and the full noise-reduction breakdown |
Other commands
logspecter rules list # 64 built-in rules across 7 packs
logspecter rules show aws-secret-access-key # pattern, entropy gate, prefilter
logspecter rules validate ./my-rules.yaml # lint custom rules
logspecter selftest # 64 positive + 25 negative samples
logspecter benchmark --size 1GB -j 8 # throughput and memory on your box
GitHub Action
# .github/workflows/secret-scan.yml
on: [push, pull_request]
permissions:
contents: read
security-events: write # only needed for the SARIF upload
jobs:
logspecter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Jeffy123-zhu/logspecter@v1
with:
path: logs/
format: sarif
output: logspecter.sarif
fail-on: high # critical | high | medium | low | info | none
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: logspecter.sarif
Inputs: path (default .), format (table/json/csv/sarif), output, fail-on (default high),
baseline, args (extra logspecter scan flags), python-version. Output: exit-code.
pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Jeffy123-zhu/logspecter
rev: v1.0.0 # or any newer tag
hooks:
- id: logspecter
How it compares
Only differences we could verify are filled in; — means "not checked", not "no".
| LogSpecter | gitleaks | trufflehog | detect-secrets | |
|---|---|---|---|---|
| Primary target | log files / streams (.gz, stdin) |
git repos, files | git repos, files, many sources | files in a repo |
| JSON path + identity context per finding (e.g. CloudTrail user → field) | yes | — | — | — |
| Shannon-entropy validation | yes | yes | — | yes |
| Flat-memory streaming of multi-GB logs | yes | — | — | — |
| Live credential verification | no | no | yes | — |
| Git history scanning | no | yes | yes | — |
| SARIF output | yes | yes | — | — |
| Baseline / suppress known findings | yes | yes | — | yes |
| Language | Python | Go | Go | Python |
Custom rules
Rules are plain YAML. A rule with the same id as a built-in one overrides it, which is the
recommended way to retune thresholds for your environment.
version: 1
pack: acme
rules:
- id: acme-internal-token
name: ACME Internal Service Token
severity: critical
confidence: high
pattern: '\bacme_(?:live|prod)_([A-Za-z0-9]{40})\b'
capture: 1
tags: [acme, internal]
entropy:
min_entropy: 4.4
min_normalized: 0.72 # entropy ÷ log2(charset size)
min_length: 40
min_charset_coverage: 0.6 # unique chars ÷ achievable unique chars
reject_encoded_text: true
- id: acme-mdc-secret
name: Secret in ACME MDC field
severity: high
pattern: '\A\s*(\S{12,4096})\s*\Z'
capture: 1
json_keys: [acme_token, acme_signature] # only applied to these JSON keys
entropy:
min_entropy: 3.5
logspecter rules validate acme.yaml
logspecter scan ./logs --rules acme.yaml
Full field reference: docs/rules.md.
Library use
from logspecter import engine
from logspecter.rules import load_ruleset
from logspecter.scanner import ScanOptions
config = engine.ScanConfig(ruleset=load_ruleset(), options=ScanOptions())
result = engine.scan(["/var/log/app.log"], config, workers=4)
for group in result.groups:
f = group.representative
print(f.severity.value, f.rule_id, f.context_summary(), f"×{group.occurrences}")
print(result.stats.throughput_mb_s, result.stats.peak_rss_max_process)
Accuracy
logspecter selftest runs the bundled corpus with every rule enabled:
检出率 64/64 · 负样本零误报 25/25
64 positive samples (one per rule, generated from a fixed seed — no real credentials in the repo)
and 25 negative samples drawn from the shapes that actually fool regex-only scanners: UUID request
IDs, git SHAs, base64-encoded JSON cursors, camel-case class names, template placeholders,
AKIAIOSFODNN7EXAMPLE, ISO timestamps, CSS colours, service-account token paths, and unkeyed
SHA-256 digests. Each of these is a distinct rejection reason in the entropy layer, and each is a
regression test.
--stats reports the funnel on your own data, so the numbers are yours rather than ours:
降噪 正则候选 1,245 → 熵值/上下文层拦下 16 条(1.3%)
Design notes
logspecter/
├── ingest.py byte-range planning, mmap window reads, gz/bz2/xz/stdin streaming
├── engine.py chunk scheduling, bounded-window multiprocessing, line-number prefix sums,
│ fingerprint aggregation
├── prefilter.py regex AST → mandatory literals + prefix widths, trie merging, screen tree
├── scanner.py the detection pipeline
├── entropy.py Shannon entropy and the heuristic gate
├── rules.py YAML loading, validation, bytes compilation
├── cloud.py cloud log schema detection and context extraction
├── structured.py orjson parsing and JSON flattening
├── report/ Rich console, JSON, CSV, SARIF
└── rules/*.yaml built-in rule packs
Two details that are easy to get wrong and are worth knowing about:
Line numbers under multiprocessing. Workers only know their offset in the file, so they report
a chunk-local line number plus that chunk's total line count. The parent computes a prefix sum
over chunk line counts and rewrites the findings. No pre-pass over the file, exact file:line.
Report readability. One leaked key repeated 50 000 times is one finding, not 50 000 rows.
Findings are aggregated by SHA-256(rule_id ‖ secret)[:16] with an occurrence count and a few
sample locations. Overlapping rules on the same value collapse to the most specific one
(openai-api-key beats authorization-header-bearer beats sensitive-json-key-value), with the
others preserved in the evidence chain.
Security
Reports are redacted by default: masked value plus real length, never plaintext, unless you pass
--show-secrets. Baseline files store only fingerprints. The scanner makes no network calls.
If you find a vulnerability, please open a private security advisory rather than a public issue.
Contributing
See CONTRIBUTING.md for the full guide (setup, adding rules, PR flow), and the
good first issue label
for a place to start.
pip install -e ".[dev]"
pytest # 360 tests
ruff check .
logspecter selftest
New rules need a positive sample in src/logspecter/samples.py; the test suite fails if any rule
lacks one, and tests/test_prefilter.py will tell you if your pattern defeats the prefilter.
Chinese documentation: README.zh-CN.md.
License
Apache-2.0.
Metadata
Release files for logspecter 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| logspecter-1.1.0.tar.gz | 117.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| logspecter-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 226.4 kB
Release files / logspecter-1.1.0.tar.gz
| Download URL | logspecter-1.1.0.tar.gz |
|---|---|
| Size | 117.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5b5e8cfdb7b39f1a9c8d66cd067a095f1539cee445218a1991820ae8e5f614a9
|
|
BLAKE2b-256 checksum How to use checksums |
04492a17924d7ab58ca3af1192fdd55c9a120b05789210f77c568b5f2f0eb531
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logRelease files / logspecter-1.1.0-py3-none-any.whl
| Download URL | logspecter-1.1.0-py3-none-any.whl |
|---|---|
| Size | 109.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
31d67bf113f49bacd57284a1bcec87cc83ac268a09469ffaff21590f4d2ccc04
|
|
BLAKE2b-256 checksum How to use checksums |
9b61ab0de7487d969abb6065a59b2ededa32eed9d3f1a2f3c9fa8cc29ba5f7ce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logSecurity for logspecter 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for logspecter-1.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.