Credential and sensitive-data exposure triage for file shares.
When an open share turns up, the question is never "does this repo have a leaked key". It is "what just got exposed, and what do I have to roll before close of business." sift is built for that question: high recall, a fast review queue, and a feedback loop so that anything you spot by eye becomes a rule that finds the other two hundred copies.
Python 3.11+, standard library only. No pip install, no internet, no build step. It runs on a locked-down IR laptop, which is where you need it.
sift survey \\fileserver\openshare # how big is this thing
sift copy \\fileserver\openshare C:\IR\case-4471 # take a throttled copy
sift scan C:\IR\case-4471 # scan it, opens the triage UIScanning in place works too. Try it on a share of fabricated credentials first:
sift demo C:\temp\demosharesift.cmd is a launcher that works from any directory. To type sift instead
of the full path, add C:\Dev\sift to PATH:
setx PATH "%PATH%;C:\Dev\sift"For a machine with no Python at all, python build_portable.py builds
dist/sift-secrets-<version>-portable-win64.zip: the official python.org
embeddable runtime plus this source tree, unzip-and-run via the bundled
sift.cmd. ~11 MB, no install, no admin rights, and nothing in it is
compiled or repacked — see the docstring in build_portable.py for why
that beats a frozen .exe on a locked-down IR laptop.
Both are good tools solving a different problem.
They are precision tools built for CI, where a false positive costs a developer their afternoon, so they fire mainly on things shaped like a known vendor API key. trufflehog goes further and prefers secrets it can verify by calling the vendor's API, which is a genuinely excellent signal that regex cannot reproduce.
Share triage inverts the economics. A human is already reading every hit, so a false positive costs three seconds. What costs you is a miss - and the things that leak on a file share are almost never vendor API keys:
| What actually leaks on a share | Why the CI scanners walk past it |
|---|---|
web.config with a SQL connection string |
Not a known key format, no vendor to verify against |
Map-Drives.ps1 with net use ... /user: |
Just a shell command with a word after it |
New Hire Setup Guide.docx |
Office file, read as binary, skipped entirely |
unattend.xml, GPP Groups.xml |
Windows deployment artefacts nobody wrote a detector for |
confCons.xml, .rdg, WinSCP.ini |
Reversible stored passwords, but not a "secret format" |
passwords.xlsx |
It is a ZIP. Plain-text scanners see binary and move on |
.kdbx, .pfx, id_rsa |
Opaque bytes - the filename is the finding |
A .bak with a connection string inside |
Binary, so never read |
sift covers those, keeps the vendor-key rules too, and will import gitleaks' rule pack and trufflehog's findings so you are not choosing between tools.
Closest prior art is Snaffler, which is excellent at the filename-and-classification half of this and is the direct inspiration for the filename rules. What it does not have - and what turns out to be the actual bottleneck once you have 400 hits - is a review loop.
- Scan the share.
- Work the queue. Each finding shows its surrounding lines with the match highlighted. Arrow keys widen the context; one click opens the whole file in VS Code at that line, or in Notepad.
- Spot a miss. You will. Highlight it in the preview and press
r. - sift proposes patterns and tells you live how many times each would match across everything already read.
- Save it. The cached rescan takes about a second, and the new hits appear in the queue with your existing triage decisions untouched.
Step 5 is the part that makes the rest worth doing. Findings are keyed on
(path, rule, line, value-hash), so a rescan re-inserts the same rows and your
status, notes, and owner ride along. Without that you would re-review the same
300 hits on every iteration and give up on the third one.
# size it up first: file count, total bytes, biggest folders, transfer estimates
sift survey \\fileserver\share
# take a rate-limited local copy (resumable; gentle = 5 MB/s by default)
sift copy \\fileserver\share C:\IR\case-4471 --speed gentle
# scan a share and open the triage UI
sift scan \\fileserver\share
# maximum recall: more noise, but a human is reading anyway
sift scan D:\dfs\dept --tier 3
# re-open the UI over the most recent scan
sift ui
# build a share of fabricated credentials, scan it, open the UI
sift demo C:\temp\demoshare
# inherit gitleaks' vendor-key rules, then use them in the live rescan loop
sift import-rules gitleaks.toml
# pull in what the other scanners found, into the same queue
trufflehog filesystem \\fileserver\share --json > th.json
sift import-findings th.json
# hand off to the incident record (redacted unless you say otherwise)
sift export --fmt pdf --status confirmed --out ir-4471.pdf
sift export --fmt csv --out ir-4471.csv
sift export --fmt pdf --no-redact # plaintext; handle as evidence
sift rules # what is loaded
sift selftest # detection tests against a synthetic shareAnywhere sift appears you can use python -m sift instead, from the
C:\Dev\sift directory.
| Flag | Effect |
|---|---|
--tier 1|2|3 |
Recall dial. 1 = high signal, 2 = default, 3 = miss nothing |
--redact |
Mask values in the store and exports. Use if the DB leaves the incident boundary |
--no-ui |
Populate the store and exit, for scripted runs |
--no-browser |
Start the UI server but do not open a browser (useful over RDP) |
--include/--exclude GLOB |
Narrow the walk |
--no-archives |
Do not open docx/xlsx/zip containers |
--no-strings |
Do not run a strings pass over binaries |
--jobs N |
Worker processes (default: auto) |
--max-size MB |
Skip files above this (default 25) |
--port N |
UI port (default 8973) |
Findings go to a per-target folder under %LOCALAPPDATA%\sift\, never the
working directory - the database holds plaintext credentials, and running the
tool from your home directory should not silently drop one there. Each share
gets its own store, so two engagements never share a triage queue. sift ui
with no arguments reopens the most recent one; --data DIR overrides.
Custom rules are global, at %LOCALAPPDATA%\sift\user-rules.json, so a
pattern you write during one engagement helps on the next share you look at.
Both are in the header of the UI, next to the path box, and on the CLI.
Check size is a stat-only walk. Nothing is opened, so it is cheap even over SMB, and it tells you the file count, total bytes, largest folders, the split by extension, how much sift would actually read, and how long a copy would take at each speed. Pointing a scanner at an unknown DFS root and waiting is how an afternoon disappears.
Copy locally pulls the share to a local folder first. Worth doing because:
- scanning a local copy is far faster than thousands of SMB round trips;
- it is repeatable, so adding a rule and rescanning does not re-hammer the file server;
- the original is left alone, which is the difference between "we looked at it" and "we preserved it" if the incident becomes legal.
The transfer is rate limited, defaulting to 5 MB/s. Saturating the link to a production file server at 2pm turns your investigation into a second incident. Open it up when you know the path is idle.
| Speed | Rate |
|---|---|
| gentle (default) | 5 MB/s |
| normal | 25 MB/s |
| fast | 100 MB/s |
| unlimited | whatever the link gives |
Transfers resume: a destination file with the same size and mtime is skipped, so an acquisition interrupted at 80% picks up where it stopped. Files that are locked or denied are recorded and skipped rather than aborting the run.
PDF, CSV, MD, and JSON, from the UI header or sift export --fmt.
The PDF is the one to hand to an incident record: a cover page with the target, totals by severity and triage state, the most frequent rules, values reused across files, then the findings grouped by severity. It is generated directly, with no PDF library, so it works on a machine that has never seen pip.
Exports are redacted by default. Values are masked, every page is
banner-marked, and the endpoint only disables masking on an explicit
redact=0 - a truncated or malformed request cannot leak. Turning redaction off
in the UI requires confirming a warning dialog, and the resulting file is
banner-marked UNREDACTED - CONTAINS PLAINTEXT CREDENTIALS on every page.
The findings database still holds the real values, because an analyst needs to know which password leaked to know what to roll. The boundary is what leaves the tool.
Everything is clickable. The sort control at the top of the results list reorders by severity, file path, rule, triage status, or most recent, with a button to reverse it. The sidebar facets filter by severity, category, rule, and reused value. Triage, context expansion, opening a file, and rule creation are all buttons.
The keyboard shortcuts below are accelerators for a long queue, not the only way to drive it.
| Key | Action |
|---|---|
j / k |
next / previous finding |
↑ / ↓ |
widen context up / down |
c / f |
confirm / mark false positive |
x |
toggle selection for bulk triage |
o / n |
open in VS Code at the line / open the file in Notepad |
r |
build a rule from the highlighted text |
y |
copy the value |
/ |
search |
The snapshot button renders the highlighted snippet to a PNG for pasting into an incident ticket; copy snippet does the same as markdown.
The UI renders live plaintext credentials in a browser window, so:
- binds
127.0.0.1only, and refuses anything else without--unsafe-bind; - requires a random per-launch token, handed over in the launch URL and then
held in a
SameSite=Strictcookie; - validates the
Hostheader, so a hostile page cannot DNS-rebind to it; - sends a CSP with no external origins at all - nothing on the page can exfiltrate what it renders;
- refuses to read or open any path that is not already in the findings database,
so
/api/contextis not an arbitrary file read and/api/openis not an arbitrary process launch.
The findings database contains plaintext credentials by design - an IR analyst
needs to know which password leaked to know what to roll. Treat
%LOCALAPPDATA%\sift\<target>\findings.db as evidence: same handling as the
share itself, and delete it when the engagement closes. Use --redact if it
will leave the incident boundary.
And the obvious one: run sift only against systems you are authorized to access.
sift/rules_builtin.py - content rules. sift/rules_filename.py - filename
rules. Both are plain Python with raw-string patterns, so they are readable and
diffable; user rules live in JSON at .sift/user-rules.json.
Three tiers let you trade precision for recall:
- Tier 1 - unambiguous. Vendor key formats, PEM blocks, GPP
cpassword, NTLM dumps, LDAP bind passwords. Shape alone is proof. - Tier 2 (default) - proximity rules. "A secret-ish word near a value." Where most real share exposure lives.
- Tier 3 - unlabelled high-entropy strings, long hex, IBANs. Noisy, but when you are scoping a breach you would rather read 400 hits than miss one.
Generic secret rules get abandoned because of noise, so the noise filters are tuned as carefully as the patterns. Measured against a real 6,000-file tree, the suppression rules below cut findings from 1,088 to 257 with no loss of recall on the test corpus:
- Dotted code references.
password: process.env.DB_PASSis a variable, not a value. This one suppression removes most generic-rule noise in source trees. - Type annotations.
def login(user: str, password: str)is a signature. - Values leading with punctuation are a regex that clipped an expression mid-way, not a credential.
- Prose requires a digit or symbol. Without it, the rule reads English about credentials - "a credential in the URL is reported and stripped" yields "reported". 99 false positives on one tree taught that.
- Build and cache directories (
.wrangler,.next,site-packages,node_modules, …) plus minified bundles and source maps are skipped. Machine- generated text produces only machine-generated false positives.
There is deliberately no https://user:pass@host content rule with a loose
tail. The obvious version fired 476 times on one dev tree, because minified JSON
has no whitespace and the pattern ran from one URL across quotes and commas
until it found an unrelated @ hundreds of characters later.
python tests/run_all.pyEight suites, 194 checks: detection against a synthetic share (split into "what the CI scanners already catch", "the gap this tool targets", and "decoys that must stay quiet"), the command line, rule-suggestion ranking, survey and throttled acquisition (including measuring the rate limit against wall clock), the PDF writer (parsed back the way a reader would, to prove redaction reached the page content), the gitleaks/trufflehog importers, the HTTP API including every security guard, and static analysis of the UI.
To get a share to play with:
sift demo C:\temp\demoshareEvery credential in that corpus is fabricated.
- No verification. sift cannot tell you a key is live. That is trufflehog's
real advantage - run it and
import-findings; verified hits sort to the top. - Encrypted containers are filename-only. A
.kdbxor.pfxis reported by name; sift does not attempt to open it. .7z,.rar, and nested archives are flagged but not extracted. Only ZIP- based formats are read inside.- No share enumeration. Point it at a path you already have. Finding open shares is a different tool's job.
.doc/.xls/.pdf(pre-2007 and PDF) go through a strings pass rather than a real parser, so recall on those is lower than on the OOXML formats.
Apache-2.0. See LICENSE.
The detection test corpus (sift/selftest.py, tests/) contains
format-correct but fabricated credentials on purpose; secret-scanning
alerts for those paths are suppressed via .github/secret_scanning.yml.
