Schema-aware secret scanner for cloud logs with regex filtering, Shannon entropy analysis, and JSON structural tracking.
4
stars
5
commits
Python
primary language
Sep 4, 2026
updated
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.
Most log scanners are a pile of regexes. That fails in production for three reasons, and LogSpecter attacks each one directly.
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.
LogSpecter parses records with orjson (Rust-backed) and recognises the schema it is looking at:
eventName, region, source IP, accountprincipalEmail, methodName, resource, projectobjectRefoperationName, resultNested 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.
readlines() on a 30 GB log is an OOMThe 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.
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 bytes patterns, so there is no per-chunk decode, match
offsets are file offsets, and \b/\w get 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 with bytes.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 of search()-ing a
whole line the scanner tries match() 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.
pip install logspecter
From source:
git clone https://github.com/logspecter/logspecter
cd logspecter
pip install -e ".[dev]"
Requires Python 3.10+. Runtime dependencies: typer, rich, PyYAML, orjson.
# 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.
| 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 |
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
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.
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)
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%)
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.
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.
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.
Apache-2.0.
5 commits
Python
100.0%
Schema-aware secret scanner for cloud logs with regex filtering, Shannon entropy analysis, and JSON structural tracking.
4
stars
5
commits
Python
primary language
Sep 4, 2026
updated
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.
Most log scanners are a pile of regexes. That fails in production for three reasons, and LogSpecter attacks each one directly.
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.
LogSpecter parses records with orjson (Rust-backed) and recognises the schema it is looking at:
eventName, region, source IP, accountprincipalEmail, methodName, resource, projectobjectRefoperationName, resultNested 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.
readlines() on a 30 GB log is an OOMThe 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.
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 bytes patterns, so there is no per-chunk decode, match
offsets are file offsets, and \b/\w get 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 with bytes.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 of search()-ing a
whole line the scanner tries match() 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.
pip install logspecter
From source:
git clone https://github.com/logspecter/logspecter
cd logspecter
pip install -e ".[dev]"
Requires Python 3.10+. Runtime dependencies: typer, rich, PyYAML, orjson.
# 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.
| 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 |
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
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.
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)
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%)
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.
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.
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.
Apache-2.0.
5 commits
Python
100.0%