A command-line tool that computes file hashes and checks them against the VirusTotal API. Computes SHA-256, SHA-1, MD5, and TLSH (locality-sensitive fuzzy hash) in a single pass. Scan a single file, look up a known hash, or sweep an entire directory — with colored terminal output, file filtering, and machine-readable JSON.
- Multi-hash support — SHA-256 (default), SHA-1, MD5, and TLSH computed in a single pass. The
-algoflag selects which cryptographic hash is sent to VirusTotal. Files are streamed so even large files are hashed without loading them entirely into memory. - TLSH (fuzzy hashing) — every file scan also computes a TLSH locality-sensitive hash. Unlike cryptographic hashes, similar files produce similar TLSH values, making it useful for malware family clustering and similarity analysis. TLSH requires at least 256 bytes of diverse content; smaller or uniform files omit the TLSH line. TLSH is informational only and is not sent to VirusTotal.
- VirusTotal lookup — queries the VirusTotal v3 API and reports malicious/suspicious/undetected/harmless engine counts, reputation score, and threat classification. The API natively accepts SHA-256, SHA-1, and MD5 hashes.
- Direct hash lookup — pass a hex hash string instead of a file path to look up a hash you already have (64 chars for SHA-256, 40 for SHA-1, 32 for MD5).
- Bulk hash-list input — use
-f hashes.txtto check a list of IOCs (one hash per line). Supports#comments and mixed hash types (SHA-256, SHA-1, MD5) in the same file — each hash's algorithm is auto-detected from its length. The-algoflag is ignored in this mode since each hash is identified individually. - Directory scanning — point it at a directory to scan all regular files (symlinks are skipped). Use
-rfor recursive scanning with automatic skipping of common non-essential directories (.git,node_modules,__pycache__,vendor, etc.). A bottom-anchored progress bar shows file count and ETA while per-file results scroll above it. - Concurrent processing — directory and hash-list scans use a bounded worker pool (default: one worker per CPU). Workers hash files and query VirusTotal in parallel while output order remains deterministic. The rate limiter is shared across all workers, so API pacing is always respected.
- Progress bar — directory scans display a bottom-anchored progress bar on stderr with file count and ETA. Per-file results scroll above the bar so it always stays at the bottom of the terminal. Automatically suppressed in JSON mode, when stderr is not a TTY (piped/redirected), or when
-no-progressis passed. - File filtering — narrow which files get scanned with glob patterns (
-include,-exclude) and size limits (-min-size,-max-size). Filters are applied before hashing, so excluded files don't waste CPU or API quota. - Rate limiting — the
-freeflag enforces VirusTotal's free-tier limit (4 requests/minute), or use-rate Nfor custom pacing. Uses a token-bucket limiter with random jitter to avoid bot detection. - Colored output — malicious results in red, suspicious in yellow, clean in green. Disable with
-no-coloror theNO_COLORenvironment variable. - JSON output — use
-o jsonfor NDJSON (one JSON object per line), suitable for piping intojqor other tools. - Result caching — caches VirusTotal results locally (
~/.cache/hashchecker/results.json) to avoid redundant API calls. Cached results expire after 7 days by default. Control with-no-cache,-refresh, and-cache-age. Cache writes are atomic (write to temp file, then rename) to prevent corruption. - Graceful cancellation — press Ctrl+C to cleanly interrupt long-running scans. In-flight rate-limit waits, HTTP requests, and directory walks exit immediately, and the cache is flushed before shutdown.
- Contextual error messages — every error is wrapped with
fmt.Errorf("context: %w", err)so messages tell you what operation failed and which file or hash was involved (e.g."hashing /tmp/foo: permission denied"instead of a bare"permission denied"). Error chains are preserved via%w, soerrors.Isanderrors.Asstill work for programmatic error inspection. - File metadata — every file scan displays metadata (name, size, modified/created timestamps, permissions) before the hash lines. Created time uses platform-native APIs (
statxon Linux,Birthtimeon macOS/Windows) and is omitted when unavailable. Metadata is included in both text and JSON output. - Scriptable exit codes — exit 0 for clean, 1 for errors, 2 when malicious files are detected.
-
SOC Triage — When your security operations center receives an alert, analysts can instantly check whether a suspicious file is known malware by scanning it against 70+ antivirus engines via VirusTotal. Batch-scan an entire quarantine folder in one command and get a clear malicious/clean verdict for each file, cutting triage time from minutes to seconds.
-
Ransomware Recovery — During incident response, quickly assess which files on a compromised system are known malicious. Sweep entire directories recursively to identify the ransomware payload, its droppers, and any other known threats — helping your IR team determine scope of compromise and prioritize remediation.
-
Supply Chain Verification — Before deploying third-party binaries, scripts, or vendor-provided software into production, verify that no component is flagged by any of VirusTotal's detection engines. Integrate into your deployment pipeline with JSON output and scriptable exit codes (exit 2 = malicious detected).
-
Threat Hunting — Proactively scan file servers, shared drives, or developer workstations for known indicators of compromise (IOCs). Use file filtering to focus on high-risk file types (executables, DLLs, scripts) and size ranges that match known threat profiles. Bulk-check IOC hash lists from threat intelligence feeds with
-f iocs.txt. -
Compliance & Audit — Generate machine-readable JSON reports of file integrity checks across critical systems. The NDJSON output integrates directly with SIEM platforms, log aggregators, and compliance reporting tools.
- A VirusTotal API key — sign up for a free account at virustotal.com and copy your API key from the API section of your profile.
Download the latest release for your platform from the Releases page. Binaries are available for Linux, macOS, and Windows on both amd64 and arm64. Each release includes a checksums.txt for integrity verification.
# Example: Linux amd64
curl -LO https://github.com/nethoundsh/hashchecker/releases/latest/download/hashchecker_linux_amd64.tar.gz
tar xzf hashchecker_linux_amd64.tar.gz
sudo mv hashchecker /usr/local/bin/Verify the download:
hashchecker -versionRequires Go 1.25.7+:
go install github.com/nethoundsh/hashchecker@latestOr clone and build manually:
git clone https://github.com/nethoundsh/hashchecker.git
cd hashchecker
go build -o hashchecker .Set your VirusTotal API key as an environment variable:
Linux/macOS:
export VIRUSTOTAL_API_KEY="your-api-key-here"Add this to your ~/.bashrc, ~/.zshrc, or equivalent to persist it across sessions.
Windows (Command Prompt):
set VIRUSTOTAL_API_KEY=your-api-key-hereTo persist across sessions, use setx instead of set.
Windows (PowerShell):
$env:VIRUSTOTAL_API_KEY="your-api-key-here"To persist, add this to your PowerShell profile ($PROFILE).
hashchecker [flags] <file | hash | directory | -f hashlist>
| Flag | Description |
|---|---|
-algo sha256|sha1|md5 |
Hash algorithm to use. Default: sha256 |
-free |
Rate-limit API requests to 4/minute (VirusTotal free tier) |
-rate N |
Custom rate limit: max N API requests per minute (overrides -free) |
-r |
Recursively scan subdirectories (skips .git, node_modules, __pycache__, vendor, .venv, .idea, .vscode) |
-o text|json |
Output format. Default: text. Use json for NDJSON output |
-no-color |
Disable colored terminal output |
-no-progress |
Disable progress bar for directory scans |
-no-cache |
Disable cache entirely (don't read or write) |
-refresh |
Ignore cached results but still write new ones |
-cache-age N |
Maximum age of cached results in days (default: 7) |
-include PATTERNS |
Comma-separated glob patterns — only process matching files (e.g. "*.exe,*.dll") |
-exclude PATTERNS |
Comma-separated glob patterns — skip matching files (e.g. "*.tmp,*.log") |
-min-size SIZE |
Minimum file size with units (e.g. "1KB", "10MB") |
-max-size SIZE |
Maximum file size with units (e.g. "100MB", "1GB") |
-version |
Print version and exit |
-f PATH |
Read hashes from a file (one hash per line) and look up each one |
-workers N |
Number of concurrent workers for directory and hash-list scans (default: CPU count) |
hashchecker /path/to/suspicious-file.exe File: suspicious-file.exe
Size: 145 kB
Modified: 2026-01-15 09:23:41 UTC
Created: 2026-01-10 14:05:12 UTC
Permissions: -rwxr-xr-x
* SHA-256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
SHA-1: da39a3ee5e6b4b0d3255bfef95601890afd80709
MD5: d41d8cd98f00b204e9800998ecf8427e
TLSH: T1A12F0E8546A28B5E9734F0400B1F84E82F5D9EF3C47A951441048B50D9DAA44D0B8A1
Name: suspicious-file.exe
Reputation: -47
Malicious: 52
Suspicious: 0
Undetected: 12
Harmless: 0
Threat: trojan.generic/agent
The * marks the hash used for the VirusTotal lookup (SHA-256 by default). All four hashes are computed in a single pass. The TLSH line is omitted for files smaller than 256 bytes.
hashchecker 275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0fNote: If the argument is a valid hex hash (64, 40, or 32 hex characters), hashchecker treats it as a hash lookup, even if a file with that name exists on disk. To force a file scan on a hash-named file, use a path prefix:
./275a021b...or an absolute path.
Check a list of IOCs from a file (one hash per line):
hashchecker -f iocs.txt -freeThe file supports comments (#) and mixed hash types (SHA-256, SHA-1, MD5):
# Emotet samples - 2024-01
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
da39a3ee5e6b4b0d3255bfef95601890afd80709
d41d8cd98f00b204e9800998ecf8427e
Filter JSON output for malicious hits:
hashchecker -f iocs.txt -o json | jq 'select(.result.malicious > 0)'# Hash a file with SHA-1
hashchecker -algo sha1 /path/to/file.exe
# Look up an MD5 hash directly
hashchecker -algo md5 d41d8cd98f00b204e9800998ecf8427e
# Scan a directory using SHA-1
hashchecker -algo sha1 -r ~/Downloadshashchecker -free ~/DownloadsScans every regular file in the directory (non-recursive by default). With -free, requests are paced at 4 per minute using a token-bucket limiter. A bottom-anchored progress bar on stderr shows scan progress and ETA while per-file results scroll above it.
To disable the progress bar:
hashchecker -free -no-progress ~/Downloadshashchecker -r -free ~/projectsWalks the entire directory tree, automatically skipping .git, node_modules, __pycache__, vendor, .venv, .idea, and .vscode.
Filter which files get scanned using glob patterns and size limits. Filters are applied before hashing, so excluded files don't consume CPU or API quota.
Only scan executables and DLLs:
hashchecker -r -include "*.exe,*.dll" -free ~/DownloadsSkip log and temp files:
hashchecker -r -exclude "*.log,*.tmp" -free ~/DownloadsOnly scan files between 1 KB and 100 MB:
hashchecker -r -min-size 1KB -max-size 100MB -free ~/DownloadsCombine filters — executables over 10 KB, skip anything named *.test.exe:
hashchecker -r -include "*.exe" -exclude "*.test.exe" -min-size 10KB -free ~/DownloadsSize units supported: B, KB, MB, GB, TB (parsed by go-humanize).
Note: For single-file scans, glob filters (
-include/-exclude) are not applied since you explicitly named the file. Size filters (-min-size/-max-size) still apply.
# VirusTotal free tier (4 req/min)
hashchecker -free -r ~/Downloads
# Custom rate: 10 requests per minute
hashchecker -rate 10 -r ~/Downloads
# -rate overrides -free if both are set
hashchecker -free -rate 10 -r ~/Downloads # uses 10 req/minDirectory and hash-list scans use a worker pool to process entries in parallel. By default, worker count matches your CPU count.
hashchecker -r -workers 8 /path/to/directory
hashchecker -f iocs.txt -workers 8Output order remains deterministic (input order), regardless of worker count.
The rate limiter still applies globally. For example, -free -workers 8 still allows at most 4 API requests per minute. Cache hits bypass the rate limiter, so concurrency helps most when many lookups are already cached.
hashchecker -o json /path/to/fileEach result is a single NDJSON line containing all computed hashes, the lookup hash/algorithm, and the full VirusTotal result:
{"path":"/path/to/file","file":{"name":"file.exe","size":145408,"size_human":"145 kB","modified":"2026-01-15T09:23:41Z","created":"2026-01-10T14:05:12Z","permissions":"-rwxr-xr-x"},"hashes":{"sha256":"e3b0c44...","sha1":"da39a3e...","md5":"d41d8cd...","tlsh":"T1A12F0..."},"lookup_hash":"e3b0c44...","lookup_algorithm":"sha256","result":{"found":true,"name":"file.exe","reputation":-47,"malicious":52,"suspicious":0,"undetected":12,"harmless":0,"threat_label":"trojan.generic/agent"}}The file object contains name, size (bytes and human-readable), timestamps, and permissions. The created field is omitted when the platform cannot determine birth time. The tlsh field is omitted when the file is too small (<256 bytes) or has insufficient byte diversity.
For raw hash lookups, only the matched algorithm appears in hashes and path is omitted:
{"hashes":{"sha256":"e3b0c44..."},"lookup_hash":"e3b0c44...","lookup_algorithm":"sha256","result":{"found":true,"name":"file.exe","reputation":-47,"malicious":52,...}}For directory scans, each file produces one JSON line followed by a summary line:
{"path":"/path/to/file1","file":{"name":"file1","size":2048,"size_human":"2.0 kB","modified":"2026-01-15T09:23:41Z","permissions":"-rw-r--r--"},"hashes":{"sha256":"abc...","sha1":"def...","md5":"012...","tlsh":"T1A12..."},"lookup_hash":"abc...","lookup_algorithm":"sha256","result":{...}}
{"path":"/path/to/file2","file":{"name":"file2","size":8192,"size_human":"8.2 kB","modified":"2026-02-01T12:00:00Z","permissions":"-rwxr-xr-x"},"hashes":{"sha256":"fed...","sha1":"cba...","md5":"987...","tlsh":"T1B34..."},"lookup_hash":"fed...","lookup_algorithm":"sha256","result":{...}}
{"summary":{"path":"/path/to/dir","scanned":2,"found":2,"malicious":1}}Pipe into jq for filtering:
hashchecker -o json ~/Downloads | jq 'select(.result.malicious > 0)'| Code | Meaning |
|---|---|
0 |
All scanned files are clean (or not found in VirusTotal) |
1 |
An error occurred (missing API key, network failure, file not found, etc.) |
2 |
One or more files were flagged as malicious |
Use in scripts:
hashchecker /path/to/file
if [ $? -eq 2 ]; then
echo "Malicious file detected!"
fiThe EICAR test file is a safe, standardized test string that every antivirus engine flags as malicious. Its SHA-256 hash is:
275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f
Use it to verify hashchecker is working:
hashchecker 275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0fResults are cached locally to reduce API calls. The cache file is stored at:
- Linux:
~/.cache/hashchecker/results.json - macOS:
~/Library/Caches/hashchecker/results.json - Windows:
%LocalAppData%\hashchecker\results.json
Cache writes are atomic — data is written to a temporary file first, then renamed into place. This prevents a crash mid-write from corrupting your cache.
By default, cached results are valid for 7 days. "Not found" results (VirusTotal 404) are also cached — if you upload a file to VirusTotal and want to re-check it before the cache expires, use -refresh.
To force a fresh lookup:
hashchecker -refresh /path/to/fileTo disable caching entirely:
hashchecker -no-cache /path/to/fileTo change the cache expiry (e.g., 1 day):
hashchecker -cache-age 1 /path/to/fileAll errors are wrapped with contextual information using Go's fmt.Errorf("context: %w", err) pattern. This means:
-
Every error message tells you what went wrong and where. Instead of a bare
permission denied, you'll seehashing /tmp/foo: permission deniedoropening cache /home/user/.cache/hashchecker/results.json: permission denied. -
The error chain is preserved. The
%wverb (not%v) ensures thaterrors.Is(err, os.ErrPermission)still works on wrapped errors, so programmatic error handling remains reliable. -
No double-wrapping. Each function adds only the context it owns. For example,
checkVirusTotaladds the hash ("checking virustotal abc123: ..."), so its callerlookupdoes not re-wrap with the same hash.
Example error messages:
Error: hashing /tmp/secret.bin: open /tmp/secret.bin: permission denied
Error: checking virustotal abc123...: unexpected status: 403: {"error": ...}
Error: checking virustotal abc123...: rate limited after 3 retries
Error: locating cache directory: $HOME is not defined
Error: writing cache: write /tmp/results.json.tmp: no space left on device
hashchecker/
main.go Entry point, flag parsing, resource initialization, run() dispatch
main_test.go End-to-end tests for run() and flag validation
testhelpers_test.go Shared test utilities (stdout/stderr capture, VT JSON builder)
internal/runner/
runner.go Application logic: OrderedPool, RunFile, RunDir, RunHash, RunHashList
runner_test.go Unit tests for OrderedPool (ordering, cancellation, edge cases)
pkg/hasher/
hasher.go Multi-hash computation (SHA-256, SHA-1, MD5, TLSH) in a single pass
hasher_test.go Hash algorithm tests and hex validation
pkg/vtclient/
vtclient.go VirusTotal API client, Lookup with caching, rate limiting, retry logic
vtclient_test.go httptest integration tests for API client, caching, rate limiting
pkg/output/
output.go Text and JSON (NDJSON) output formatting, color helpers
output_test.go Output formatting and JSON structure tests
pkg/cache/
cache.go Generic disk cache: Load, Save (atomic write via temp+rename)
cache_test.go Cache load/save/round-trip and file path tests
pkg/filter/
filter.go File filtering by glob pattern and size, directory walking
filter_test.go Filter logic tests (include/exclude, size bounds)
pkg/fileinfo/
fileinfo.go File metadata extraction (name, size, timestamps, permissions)
fileinfo_test.go Metadata extraction tests
birthtime_linux.go Linux birth time via statx() syscall (build-tagged)
birthtime_darwin.go macOS birth time via Birthtimespec (build-tagged)
birthtime_windows.go Windows birth time via CreationTime (build-tagged)
birthtime_other.go Fallback (zero time) for unsupported platforms
go.mod Module definition and dependencies
.golangci.yml Linter configuration (golangci-lint v2)
.goreleaser.yml Cross-platform release build configuration
.github/workflows/ci.yml GitHub Actions CI: golangci-lint + tests on push/PR
.github/workflows/release.yml Automated releases: builds binaries + checksums on tag push
LICENSE MIT license
Every push to main and every pull request runs two GitHub Actions jobs automatically:
| Job | What it does |
|---|---|
| Lint | Runs golangci-lint v2 with errcheck, govet, staticcheck, gosec, errorlint, and bodyclose |
| Test | Runs go build, go vet, and go test -race -cover to catch regressions and data races |
Pushing a version tag (e.g. v1.2.0) triggers an automated release via GoReleaser:
| Job | What it does |
|---|---|
| Release | Builds cross-platform binaries (Linux, macOS, Windows — amd64 and arm64), generates SHA-256 checksums, and publishes a GitHub Release with all assets attached |
Workflows are defined in .github/workflows/. The Go version is read from go.mod so it stays in sync automatically.
go test ./...The test suite has 126 tests (including subtests) across 8 test files.
main_test.go — End-to-end run() tests:
TestRun*— flag validation (version, no args, missing API key, invalid output/patterns/sizes/algo/workers), hash lookups (clean, malicious, MD5), single file scanning (default SHA-256, SHA-1, size filters), directory scanning (flat, recursive, JSON, include/exclude)TestRunHashListMode— hash-list file input (-f): happy path with multiple hashes, malicious exit code 2, mixed algorithm auto-detection (SHA-256/SHA-1/MD5 in one file), comment and blank line skipping, invalid hash warnings, empty file handling, mutual exclusivity with positional args, missing file errorTestRunDirectoryConcurrent*— concurrent processing: basic multi-worker scan, deterministic output ordering with 10 files and 4 workers, malicious exit code propagation, workers=1 matches workers=4 outputTestRunHashListConcurrent— concurrent hash-list processing with 3 workersTestRunConcurrentInterrupt— graceful shutdown under pre-cancelled context
internal/runner/runner_test.go — OrderedPool unit tests:
TestOrderedPoolOrdering— verifies FIFO output ordering despite staggered worker completionTestOrderedPoolSingleWorker/TestOrderedPoolSingleJob— exercises sequential fast-pathTestOrderedPoolEmptyJobs— empty input handlingTestOrderedPoolPreCancelledContext/TestOrderedPoolCancelMidFlight— context cancellationTestOrderedPoolMoreWorkersThanJobs— workers > jobs edge case
pkg/hasher/hasher_test.go — Hash computation:
TestIsHexHash— hash detection for all algorithms (SHA-256/SHA-1/MD5 valid, invalid, cross-rejection)TestFile— multi-hash file computation (known content, empty file, large file with TLSH, nonexistent file)
pkg/vtclient/vtclient_test.go — httptest-based integration tests:
TestCheckVirusTotal— HTTP client against a mock server (200 success, clean file, 404 not found, 429 retry, 429 exhausted, 403 bad key, bad JSON, context cancellation)TestCheckVirusTotalSendsAPIKey— verifies API key header is sentTestLookup— cache + API integration (cache miss, cache hit, expired cache, refresh bypass)TestWaitForRateLimit— rate limiter (nil limiter, fast limiter, cancelled context)TestTruncateRunes— string truncation with multi-byte character safetyTestParseRetryAfter— Retry-After header parsing (integers, zero, negative, garbage, RFC 1123 dates)TestMigrateLegacyCacheKeys— verifies bare-hash keys are migrated toalgo:hashformat
pkg/output/output_test.go — Output formatting:
TestPrintJSON/TestPrintJSONSummary— JSON output structure,omitemptybehavior, file metadata presenceTestPrintResult— human-readable output (file metadata, hash lines, VT result details, threat label)TestColorHelpers— ANSI color selection for reputation, malicious, and suspicious counts
pkg/cache/cache_test.go — Filesystem operations:
TestLoad— missing file returns empty map, valid file round-trip, corrupt JSON errorTestSave— file permissions (0600), JSON validity, save-then-load round-tripTestFilePath— path suffix verification
pkg/filter/filter_test.go — File filtering:
TestShouldProcess— table-driven tests for include/exclude globs, size bounds, combined filters
pkg/fileinfo/fileinfo_test.go — File metadata:
TestNew— metadata extraction (name, size, human-readable size, timestamps in UTC, permissions)
Check coverage:
go test -cover ./...- API key handling — the key is read from an environment variable, never from command-line flags (which are visible in
psoutput and shell history). - Read-only — hashchecker only reads files and makes GET requests. It never modifies files or uploads content.
- Symlink safety — directory scans skip symlinks, preventing path traversal or infinite-read attacks (e.g., a symlink to
/dev/zero). - Input validation — hash arguments are validated as valid hex before being used in API URLs. Glob patterns are validated at startup before scanning begins.
- Cache permissions — the cache directory is created with
0700and the cache file with0600(owner-only access). - Atomic cache writes — cache is written to a temporary file and renamed into place, preventing corruption from crashes or interrupted writes.
| Package | Purpose |
|---|---|
github.com/dustin/go-humanize |
Parse human-readable file sizes ("10MB" to bytes) |
github.com/fatih/color |
ANSI-colored terminal output |
github.com/mattn/go-isatty |
Detect whether a file descriptor is a terminal (TTY) |
github.com/vbauerster/mpb/v8 |
Bottom-anchored terminal progress bar with ETA display |
golang.org/x/sys |
Platform-native syscalls for file birth time (statx on Linux) |
golang.org/x/time/rate |
Token-bucket rate limiter for API call pacing |
github.com/glaslos/tlsh |
TLSH locality-sensitive fuzzy hashing |
MIT