A SharpHound collection is your client's entire directory in clear: every account, every hostname, every group name. That is why it cannot leave the engagement, and why you cannot paste it into a chat window.
shanon remaps the organization-bound identifiers and leaves the structure alone. The output is still SharpHound JSON, still loads in BloodHound, and every graph cross-reference still points where it pointed. The edges are the thing you are actually reasoning about, and they all survive. A local mapping file turns any answer back into real names.
shanon anonymize --input engagement.zip --out ./anonAnywhere a collection has to leave the engagement it came from:
- Ask an LLM about attack paths without handing over the directory.
- Get a second opinion. Share a collection with a colleague or a vendor who is not on the engagement.
- Write it up. Talks, blog posts and screenshots, without a manual redaction pass over every image.
- Teach with it. Real graph shape and real edge density, no client in it, so a lab or a workshop can run on a live collection instead of a toy one.
- File a bug. Attach a reproducer against BloodHound or a collector without an NDA conversation first.
- Keep the graph, drop the client. Retain the structure for later work when the raw collection has to be destroyed.
Because the mapping file stays local, every one of these stays reversible for you and for nobody else.
A run reports what it replaced, what it left alone, and where it put the two files:
The Not anonymized block is the one to read first. It is the honest part: it
tells you what shanon passed through untouched, and shanon inspect will list
every instance.
Below is an excerpt from a run over the synthetic collection in
demo/. Every run picks a fresh random salt, so your pseudonyms will
read differently.
Before
{
"name": "SVC_SQL@CONTOSO.LOCAL",
"distinguishedname": "CN=svc_sql,OU=Service Accounts,DC=CONTOSO,DC=LOCAL",
"email": "svc_sql@contoso.local",
"description": "Runs MSSQLSvc on SQL01. Ticket owner: Helpdesk.",
"serviceprincipalnames": ["MSSQLSvc/sql01.contoso.local:1433"],
"hasspn": true
}After
{
"name": "kjeffersg46lvu6zae6m@fabrikam-cmw5tqv5maqpm.LOCAL",
"distinguishedname": "CN=kjeffersg46lvu6zae6m,OU=ppierce4d4g57h6caryy,DC=fabrikam-cmw5tqv5maqpm,DC=LOCAL",
"email": "kjeffersg46lvu6zae6m@fabrikam-cmw5tqv5maqpm.local",
"description": "[REDACTED:flnnjhdpxlthi]",
"serviceprincipalnames": ["MSSQLSvc/HOST-87-GBWGEMP4OJUII.fabrikam-cmw5tqv5maqpm.local:1433"],
"hasspn": true
}Read what survived, because that is the point:
hasspnis stilltrue, the SPN is still anMSSQLSvc/…:1433, the DN is still a DN. The account is still visibly kerberoastable.- That account appears in four places across three collection members, and every one of them got the same pseudonym, so the graph edges survive.
- Free text becomes an opaque
[REDACTED:…]handle rather than a guess at which part of the sentence was sensitive. Domain Adminskeeps RID 512 and its canonical name, because that is a global constant rather than something about your client, while its SID authority and domain are still remapped.
Prebuilt binary. Pick the current tag from Releases and the target that matches the machine:
| Machine | TARGET |
|---|---|
| Linux x86_64 | x86_64-unknown-linux-gnu |
| Linux x86_64, static (containers, older glibc) | x86_64-unknown-linux-musl |
| Linux arm64 | aarch64-unknown-linux-gnu |
| macOS Apple Silicon | aarch64-apple-darwin |
| macOS Intel | x86_64-apple-darwin |
| Windows x86_64 | x86_64-pc-windows-msvc |
The musl build links statically, so it runs where the glibc build would refuse on a version mismatch: an Alpine or distroless container, or a jump box older than the runner that built it.
VERSION=v0.8.1
TARGET=x86_64-unknown-linux-gnu # from the table above
BASE="https://github.com/Matixx22/shanon/releases/download/$VERSION"
curl -fsSLO "$BASE/shanon-$VERSION-$TARGET.tar.gz"
curl -fsSLO "$BASE/shanon-$VERSION-$TARGET.tar.gz.sha256"
shasum -a 256 -c "shanon-$VERSION-$TARGET.tar.gz.sha256"
tar xzf "shanon-$VERSION-$TARGET.tar.gz"
./shanon --versionWindows ships a .zip instead, with the same <hash> <file> checksum line:
$VERSION = "v0.8.1"
$TARGET = "x86_64-pc-windows-msvc"
$BASE = "https://github.com/Matixx22/shanon/releases/download/$VERSION"
Invoke-WebRequest "$BASE/shanon-$VERSION-$TARGET.zip" -OutFile "shanon-$VERSION-$TARGET.zip"
Invoke-WebRequest "$BASE/shanon-$VERSION-$TARGET.zip.sha256" -OutFile "shanon-$VERSION-$TARGET.zip.sha256"
# compare against the published checksum before unpacking
(Get-FileHash -Algorithm SHA256 "shanon-$VERSION-$TARGET.zip").Hash.ToLower()
Get-Content "shanon-$VERSION-$TARGET.zip.sha256"
Expand-Archive "shanon-$VERSION-$TARGET.zip" -DestinationPath .
.\shanon.exe --versionWith cargo:
cargo install --git https://github.com/Matixx22/shanon shanon-cliFrom source (MSRV 1.97):
git clone https://github.com/Matixx22/shanon
cd shanon
cargo build --release # binary at ./target/release/shanon# 1. dry run: tells you whether it would work, writes absolutely nothing
shanon inspect --input engagement.zip
# 2. the real thing
shanon anonymize --input engagement.zip --out ./anon
# -> ./anon/collection_anon.zip this is the one you share
# -> ./anon/collection.map.json reversal keys, keep local, never ship
# 3. rewrite the real names in your own question to the same pseudonyms
shanon scrub --map ./anon/collection.map.json --input question.md > question_safe.md
# 4. fold the answer back to real identities
shanon restore --map ./anon/collection.map.json --input llm_findings.mdTry it against the committed synthetic collection first:
shanon inspect --input demo/collectionInput can be a .zip, a directory of *.json, or a single .json member. Same
input plus same salt gives byte-identical output. Pass --map once and
--reuse-map after it to keep pseudonyms stable across several collections of
the same environment.
shanon pseudonymizes. It substantially lowers re-identification risk, but a collection is not legally anonymous afterward, and structure itself carries information: a 40,000-user domain with two DCs still looks like a 40,000-user domain with two DCs. SECURITY.md states the exact threat model and residual risks. Read it before you send anything anywhere.
Send only the emitted collection_anon.zip, never its parent output directory,
which also holds the mapping file. That file contains the real mappings in the
clear and is as sensitive as the raw collection.
The collection is not the only thing that leaves your machine. Run the question
you are about to ask through shanon scrub as well, and then read it
once more for the names no map could know about.
No network. No LLM calls. Never mutates your input.
Contributions are welcome. See CONTRIBUTING.md for the build, test, and PR workflow, and CODE_OF_CONDUCT.md. A real identifier that survives a run is a security bug: report it privately per SECURITY.md, never as a public issue.
MIT © Mateusz Suchocki. See LICENSE.
Everything below is detail: the full flag surface, what is transformed and what is kept, and how the safety model works.
shanon anonymize --input <zip|dir|json> --out <dir> [--map PATH] [--reuse-map PATH]
[--verbose-failures] [--keep-undeclared-numbers]
[--redact-os-strings] [--progress | --no-progress]
[--summary | --no-summary]
| flag | required | meaning |
|---|---|---|
--input |
yes | SharpHound collection: a .zip, a directory of *.json, or a single .json member |
--out |
yes | output directory (must not already contain the target) |
--map |
no | where to write the reversal map (default <out>/collection.map.json) |
--reuse-map |
no | reuse salt + prior mappings so pseudonyms stay stable across collections |
--verbose-failures |
no | on an abort, print sanitized detail: every leak-gate finding, or the class, member, path and offender fingerprint of a mapping failure |
--keep-undeclared-numbers |
no | publish numbers at undeclared paths verbatim instead of replacing them. Widens what leaves the machine; read the inspect report first |
--redact-os-strings |
no | redact Properties.operatingsystem instead of preserving a known Windows product string |
--progress |
no | draw the progress bar even when stderr is not a terminal |
--no-progress |
no | never draw the progress bar |
--summary |
no | print the full run report (the default) |
--no-summary |
no | print the terse machine-readable report instead |
On success it writes a report to stdout saying what it did to your collection:
Done. 5752 objects anonymized across 9 files.
Replaced 4118 identifiers, each with a stable pseudonym:
domains 3
SIDs 912
accounts 2264
hostnames 806
certificate templates 14
other identifiers 119
Objects by origin:
built-in Active Directory defaults 118
specific to your organization 5634
Not anonymized:
field paths shanon does not model 21 distinct
numeric values passed through 0 distinct
Run `shanon inspect` to list them; see SECURITY.md for what they can reveal.
Wrote:
collection ./anon/collection_anon.zip
safe to share; policy core-global-defaults
mapping ./anon/collection.map.json
KEEP LOCAL. Reverses every pseudonym above.
Next: `shanon scrub --map <map>` rewrites your own questions into these
pseudonyms, and `shanon restore --map <map>` turns the model's answer back.
Read the two counts under Not anonymized before you share anything. They are
what shanon left alone: field paths it does not model (anonymized opaquely, but
not understood) and numeric values at undeclared paths (published verbatim).
shanon inspect lists both, path by path.
Every figure is a count. No value from your collection appears in the report, so it is safe to paste into a ticket even when the collection is not.
--no-summary prints the terse five-line form instead, unchanged from earlier
releases, for scripts that parse it:
anonymized -> ./anon/collection_anon.zip
mapping (client-sensitive, keep local) -> ./anon/collection.map.json
policy: core-global-defaults
classified: core=118 feature=0 third_party=0 custom=5634 unknown=0
unknown string paths: 21
A run over a real collection takes minutes, so anonymize also draws a progress
bar on stderr showing each phase (discovery, transform+verify, publish) with a
count and an ETA:
discovery \ 48,213 objects 0:41
transform+verify [##########--------------] 43% 41,902/96,426 1:12 eta 1:33
The bar is drawn only when stderr is a terminal, so a redirected or piped stderr is byte-identical to a run without it. The report is not gated that way: it is the run's own output and does not change with who is watching.
# stable pseudonyms across two collections of the same environment
shanon anonymize --input dc1.zip --out ./anon1 --map ./env.map.json
shanon anonymize --input dc2.zip --out ./anon2 --reuse-map ./env.map.jsonOutput members are named member-NNNNN.json: the collector's filenames are
themselves organization-bound, so they do not survive either.
shanon inspect --input <zip|dir|json> [--keep-undeclared-numbers]
[--redact-os-strings] [--format text|json]
[--progress | --no-progress]
A dry run: same discovery, transform and leak-gate verification as anonymize,
then stop. Nothing is written: no output collection, no mapping file, no
staging directory, so it is safe to point at a collection that must not leave
the machine. Exit 0 if the collection would anonymize cleanly, 1 if it would
abort.
shanon inspect --input engagement.zipDry run. Nothing was written.
Read 7 files, all usable.
Found 5752 objects.
Collections:
users User version 6 4000 objects
computers Computer version 6 800 objects
azbase Unknown version 6 12 objects <- unrecognized; contents anonymized opaquely
Would replace 4118 identifiers, each with a stable pseudonym:
domains 3
SIDs 912
accounts 2264
hostnames 806
other identifiers 133
Objects by origin:
built-in Active Directory defaults 118
specific to your organization 5634
Would not anonymize:
field paths shanon does not model 21 distinct
numeric values passed through 0 distinct
Field paths no rule models. Contents are still anonymized, but
shanon does not know what they hold:
800 data[].localgroups[]["[redacted:wx5fedc6e5w56]"]
Audit codes:
malformed-source-value 600
unknown-key-path 33106
Worth checking before you run this:
Missing core collection types: domains.
The graph built from this will have holes.
Declared by more than one file: users.
This may be two collections merged into one directory.
Verdict: this collection would anonymize cleanly.
Read "would not anonymize" above first: that part is advisory,
and the verdict does not cover it.
Next: `shanon anonymize --input <same input> --out <dir>` to write it.
It reports the same things anonymize does, in the conditional, so the dry run
and the real run read alike. The identifier counts are what a real run would
mint: inspect uses a throwaway salt, so the pseudonyms differ but the counts
do not.
The Worth checking block appears only when it has something to say. It is advisory and never changes the verdict or the exit code, and neither does Would not anonymize. The verdict is about the leak gate alone, which is why a clean one still points back at those counts. A collection type declared by more than one file is what a directory holding several collection runs looks like from the inside, where the collector's filenames are deliberately not visible.
--format json prints the same report as one sorted JSON document, for a CI
gate or a ticket attachment:
shanon inspect --input engagement.zip --format json | jq .would_publishEvery line is a count, a synthetic member-NNNNN.json label, a canonical field
path or a salt-keyed BLAKE2b-6 fingerprint, never a source value and never a
source filename, so the report can be shared for a collection that cannot be.
Keyed matters: the token is stable within a run and reversible by whoever holds
the mapping file, and it is not the digest of a guessable value, so nobody can
recover it from a candidate list or use it to link two collections. That
also means the names of unmodeled fields appear as fingerprints rather than
in clear: the path tells you where the drift is, the digest tells you it is the
same field each time.
Reach for it when a run aborts, when a new collector version is in play, or before spending minutes on a collection that will not finish.
shanon scrub --map <map.json> [--input FILE] [--summary | --no-summary]
| flag | meaning |
|---|---|
--map |
the collection.map.json produced by anonymize (required) |
--input |
the text to scrub (omit to read stdin) |
--summary |
print the report even when stderr is not a terminal |
--no-summary |
never print the report |
shanon anonymizes the collection, not the sentences you write around it. Ask
"can SVC_SQL reach DC01?" and you have just handed over two names the collection
no longer contains. scrub runs your own text through the same map first, so
those names arrive as the pseudonyms the model already saw:
shanon scrub --map ./anon/collection.map.json --input question.md
# Can kjeffersg46lvu6zae6m in fabrikam-cmw5tqv5maqpm.LOCAL reach anything?
# scrubbed: 2 replacements
# categories: domains 1, accounts 1
# this replaces only what the map knows; check the rest by handThe scrubbed text goes to stdout and the report to stderr, so you can pipe one
without losing the other. The report is drawn only when stderr is a terminal;
use --summary to force it, --no-summary to suppress it. (anonymize prints
its report to stdout and is not gated that way, because there the report is the
output rather than a decoration on it.)
Matching is case-insensitive for names, SIDs, hostnames and GUIDs, because you
type CONTOSO and the collection stored contoso. Whole words only, so a
mapped alice does not rewrite alicent. Text that already reads in pseudonyms
is left alone, which makes a second pass a no-op and makes it safe to scrub a
draft you have already scrubbed once.
It replaces what the map knows, and cannot certify the rest. A hostname you typed that was never in the collection has no mapping, cannot be substituted, and passes through in the clear. Read the count, and read your own sentence.
shanon restore --map <map.json> [--lookup FAKE | --forward REAL | --input FILE]
| flag | meaning |
|---|---|
--map |
the collection.map.json produced by anonymize (required) |
--lookup |
resolve one pseudonym → real value |
--forward |
resolve one real value → pseudonym |
--input |
bulk mode: substitute every known pseudonym in a file (omit to read stdin) |
# single pseudonym -> real
shanon restore --map ./anon/collection.map.json --lookup kjeffersg46lvu6zae6m
# accounts: svc_sql
# bulk: de-anonymize an answer that quotes pseudonyms back at you
shanon restore --map ./anon/collection.map.json --input llm_findings.md--lookup and --forward resolve one mapped component (an account label, a
domain label, a hostname), because that is the granularity the registry binds.
A composite string such as a full UPN or SPN goes through --input, which
substitutes every component it recognizes.
| code | condition |
|---|---|
0 |
success; for inspect, the collection would anonymize cleanly |
1 |
leak-gate abort, invalid mapping data, or I/O error, no output written; for inspect, the collection would abort |
2 |
pre-flight refusal (e.g. --out already holds a map, or conflicting flags) |
- Names (user/group/computer/OU/GPO/container), UPNs, SPNs, DNS hostnames, emails
- Organization-specific SID authority values and custom GUIDs
- Domain FQDNs and organization-specific DN components
- Role, product and vendor fingerprints, and any OS string that is not a catalog-listed Windows product (see below)
- Custom certificate templates, enterprise OIDs, CA names, and certificate material
- Free text and opaque values → deterministic
[REDACTED:…]mappings - The names of fields no rule models, not only their values. A custom AD attribute is organization-bound in its key as much as its contents
- Numeric values at those same unmodeled paths → a type-stable sentinel
That last one currently catches a few standard SharpHound fields the rule table
does not model yet, including IsDeleted, IsACLProtected,
Properties.whencreated and FailureReason: they come back renamed rather than
dropped. No graph edge depends on them, so the collection still loads and still
reasons correctly, but it is not a byte-for-byte SharpHound document. inspect
lists exactly which paths a given collection hit.
Booleans and nulls are passed through as they are, and so are numbers at the paths shanon models, where a flag, a count or a timestamp carries no identity.
A number at a path no rule declares is a different animal. SharpHound turns any
attribute whose value parses as an integer into a JSON number, so under
--collectallproperties a custom employeeNumber arrives as one, and a numeric
employee ID matched against an HR roster re-identifies the account and every
edge it sits on. Those are replaced with a type-stable sentinel, and shanon inspect reports exactly which paths were affected. See
SECURITY.md.
Only catalog-proven, globally invariant constants are preserved, and only at the specific object type and field path listed in the catalog. Examples:
Domain Adminsretains RID 512 and its canonical account name; its SID authority and domain name are still mapped.- Fixed default-GPO GUIDs, standard protocol/EKU OIDs, and built-in certificate-template names, at catalog-permitted paths only.
Properties.operatingsystem, when the value is exactly one of the listed Windows product strings (Windows Server 2019 Standard,Windows 10 Enterprise, and so on). An unsupported OS is an attack path, and it is a Microsoft constant rather than anything about your client. A branded variant such asWindows Server 2019 Datacenter - CONTOSO GOLD IMAGE, an appliance banner, or a case variant matches nothing and is redacted.Properties.operatingsystemversionstays redacted: build numbers are a long tail no table can close.--redact-os-stringsturns the whole thing off.- Feature/vendor defaults (
Hyper-V Administrators,Vault Administrators) and anything custom are mapped, not preserved.
Because a directory is a graph, not a word list.
- Identifiers are composite.
MSSQLSvc/sql01.contoso.local:1433is a service class, a host, a domain and a port.S-1-5-21-…-1105is an authority plus a RID that may or may not be a global constant. Each piece has to be decomposed and mapped on its own terms, and reassembled in the original shape, or the reader stops recognizing what it is looking at. - The same thing appears under many spellings. A user is a UPN here, a
samaccountnamethere, a DN component, an email local part, aPrincipalSIDin someone else's ACE. Miss the correspondence and the attack path disintegrates; the anonymized graph is then worse than useless, it is misleading. - A missed replacement is silent. That is the failure that matters, and no regex tells you it happened. shanon independently re-derives the expected output for every string leaf and compares before anything is published; a single divergence aborts the run with no output written at all.
- You need it back. A one-way scrub leaves you translating findings by hand, at which point you have re-created the mapping file, worse.
shanon is fail-closed. It classifies every object, freezes a verification state, transforms each member by object type and field path, then independently verifies the result against the frozen registry before writing. Verified members go to a private staging area and are published by atomic rename only after all pass; existing destinations are refused. Errors report a sanitized fingerprint, never the source secret or filename.
See SECURITY.md for the full threat model and what shanon does not protect against.
Linux, macOS and Windows. The anonymization is the same everywhere: same
classification, same pseudonyms, same verification, same output bytes. Two
local filesystem guarantees are weaker on Windows, because it has no openat
and no umask: directory input is not descriptor-anchored (ZIP input is
unaffected), and the mapping file inherits its parent directory's ACL instead of
being owner-only from creation. Keep the map under your user profile.
SECURITY.md spells both out.
crates/shanon-core library: catalog, policy, registry, engine, verification, pipeline
crates/shanon-cli the `shanon` binary (anonymize / inspect / restore)
demo/collection synthetic collection behind the README's before/after
assets/ banner
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace