Fast, multi-source threat intelligence enrichment for IPs, file hashes, and domains.
Query indicators against VirusTotal, AbuseIPDB, ThreatFox, ipapi.is, MalwareBazaar, and GreyNoise β from a local web UI. Ships as a single self-contained binary with no runtime dependencies.
- Features
- How It Works
- Installation
- Quick Start
- Web UI
- API Reference
- Output Format
- Risk Scoring
- Data Sources
- Supported Platforms
- Project Structure
- Adding a New Integration
- Known Limitations
- Development
- Contributing
- License
- IP enrichment β geo, ASN, usage type, abuse score, VirusTotal verdicts, ThreatFox C2 intel, and GreyNoise internet-scanner classification. AbuseIPDB fields include confidence score, report count, distinct reporters, usage type, domain, Tor exit node flag, public/whitelisted status, hostnames, and deduplicated report categories mapped to human-readable names
- Hash enrichment β VirusTotal detections (with last scanned timestamp), MalwareBazaar metadata, code signing validation, Sigma rule hits, sandbox classifications
- Domain enrichment β VirusTotal multi-engine verdict, reputation, registrar, creation date, A records, categories, and ThreatFox C2 intelligence
- Multi-signal risk scoring β
riskLevelcomputed from manifest-driven rules across all integrations; any single signal can escalate the level independently - Plugin architecture β each integration is a self-contained Go file implementing a single interface; adding a new vendor requires one file and one registry line
- Web UI β Tailwind CSS + Vue 3, cards/table views, column visibility toggles, export to CSV/JSON, scan history. Cards show full vendor data including AbuseIPDB categories, VirusTotal last scanned time, and GreyNoise classification. Cards with API errors are hidden automatically β error detail remains in the raw JSON panel
- Bulk scanning β up to 100 IPs, hashes, or domains per request
- Local cache β SQLite-backed caching per integration to avoid redundant API calls
- Rate limiting β built-in token-bucket limiter with 1 MB request body cap
- Single binary β web UI is embedded at compile time, no external files needed
- Minimal dependencies β 3 external Go dependencies total (yaml.v3, x/time/rate, sqlite)
Input (IP, Hash, or Domain)
|
v
+-----------+
| iocscan | Web UI
+-----+-----+
| registry.ForIOCType() -> enabled integrations for this IOC type
+-------+------------------------------------------+
| | | | |
v v v v v
ipapi.is AbuseIPDB VirusTotal ThreatFox GreyNoise
geo/ASN abuse multi-engine C2/botnet internet
VPN/DC score verdicts IOC intel scanner
| | | | |
+-------+------------------------------------------+
| orchestrator collects Results + Errors
| evaluateOverallRisk() reads manifest RiskRules
v
JSON result (Web UI)
|
v
SQLite cache (per-integration table, auto-created)
All integrations are queried concurrently per indicator. Risk scoring is driven by declarative rules in each integration's manifest. Cache tables are created automatically from integration manifests at startup.
Grab the latest pre-built binary for your platform from Releases.
| Platform | File |
|---|---|
| Linux x64 | iocscan_linux_amd64.zip |
| Linux ARM64 | iocscan_linux_arm64.zip |
| macOS x64 | iocscan_darwin_amd64.zip |
| macOS Apple Silicon | iocscan_darwin_arm64.zip |
| Windows x64 | iocscan_windows_amd64.zip |
Verify the download against checksums.txt included in each release.
Requires Go 1.22+.
git clone https://github.com/TwoA2U/iocscan.git
cd iocscan
go mod tidy
go build -o iocscan .iocscan # starts web UI on http://localhost:8080
iocscan -p 9090 # custom port
iocscan --help # show usageOpen your browser. The API Keys panel at the top accepts keys for each vendor. Keys are sent per-scan request.
| Vendor | Key source | Required |
|---|---|---|
| VirusTotal | virustotal.com/gui/my-apikey | Yes (IP + hash + domain) |
| AbuseIPDB | abuseipdb.com/account/api | Yes (IP scans) |
| ipapi.is | ipapi.is/developers.html | No β free tier works without |
| abuse.ch | bazaar.abuse.ch/api | No β MalwareBazaar + ThreatFox |
| GreyNoise | viz.greynoise.io/signup | No β 10 lookups/day without |
Keys are entered in the API Keys panel in the web UI. They are sent per-scan and never stored on disk.
Create ~/.iocscan/config.yaml to set a default port or database path:
server:
port: 8080
database:
path: "" # default: ~/.iocscan/iocscan.dbThe -p flag always overrides the config file port.
IP mode β ipapi.is (geo/ASN) + AbuseIPDB (full report data, categories) + VirusTotal + ThreatFox + GreyNoise. Hash mode β VirusTotal (with last scanned timestamp) + MalwareBazaar + ThreatFox. Domain mode β VirusTotal (verdict, registrar, A records, categories) + ThreatFox.
| Feature | Description |
|---|---|
| Cards view | Per-indicator detail cards with risk badges and source links. Cards with errors are hidden β data still visible in raw JSON panel |
| Table view | Sortable multi-indicator comparison table |
| Column toggles | Show/hide individual fields per section |
| Bulk input | Paste multiple indicators or upload a .txt / .csv file |
| Export | Download results as CSV or JSON |
| Copy | Copy to clipboard as JSON, CSV, or raw indicators only |
| Scan history | Last 20 scans with one-click re-scan |
| Cache toggle | Per-scan control over SQLite result caching |
All API responses use Content-Type: application/json, including errors.
{ "status": "ok", "service": "iocscan" }Returns the full manifest for every registered integration. The frontend fetches this once at boot to drive card layouts, table columns, and risk thresholds.
IP enrichment.
{
"ip": "1.2.3.4",
"vt_key": "...", "abuse_key": "...", "ipapi_key": "...",
"abusech_key": "...", "greynoise_key": "...",
"use_cache": true
}Hash enrichment. Accepts up to 100 hashes (MD5, SHA1, or SHA256).
{
"hashes": ["<hash1>", "<hash2>"],
"vt_key": "...", "abusech_key": "...",
"use_cache": true
}Mixed IOC enrichment. IPs, hashes, and domains are auto-detected and routed to the correct pipeline.
{
"iocs": ["1.2.3.4", "<sha256>", "evil.com"],
"vt_key": "...", "abuse_key": "...", "abusech_key": "...", "greynoise_key": "...",
"use_cache": true
}{ "table": "all" }Current cache tables: VT_IP, ABUSE_IP, IPAPIIS_IP, GN_IP, VT_HASH, MB_HASH, TF_IP, TF_HASH, VT_DOMAIN, TF_DOMAIN.
{ "error": "description of what went wrong" }{
"ipAddress": "45.77.34.87",
"riskLevel": "CRITICAL",
"geo": { "isp": "Vultr", "country": "Singapore", "city": "Singapore", "timezone": "Asia/Singapore" },
"virustotal": {
"malicious": 5, "suspicious": 1, "reputation": -11,
"lastAnalysisDate": "2026-03-20 06:41 UTC"
},
"abuseipdb": {
"confidenceScore": 82, "totalReports": 14, "numDistinctUsers": 9,
"lastReportedAt": "2026-03-19T14:22:00+00:00",
"usageType": "Data Center/Web Hosting/Transit",
"domain": "vultr.com", "isTor": false,
"isPublic": true, "isWhitelisted": false,
"hostnames": [],
"categories": ["Port Scan", "Hacking", "Brute-Force", "SSH"]
},
"threatfox": { "queryStatus": "ok", "malware": "win.cobalt_strike", "confidenceLevel": 100 },
"greynoise": { "classification": "malicious", "noise": true, "riot": false, "name": "unknown", "lastSeen": "2026-03-19" }
}{
"hash": "d55f983c...", "hashType": "SHA256", "riskLevel": "CRITICAL",
"virustotal": { "malicious": 58, "suggestedThreatLabel": "trojan.sodinokibi/revil" },
"malwarebazaar": { "queryStatus": "ok", "signature": "Sodinokibi", "fileName": "revil.exe" },
"threatfox": { "queryStatus": "ok" }
}{
"domain": "evil.com", "riskLevel": "HIGH",
"vtDomain": { "malicious": 8, "registrar": "NameCheap", "suggestedThreatLabel": "malware.generic" },
"threatfox": { "queryStatus": "ok", "malware": "win.emotet", "confidenceLevel": 75 }
}riskLevel is computed from declarative RiskRule definitions in each integration's manifest. The highest severity across all integrations wins.
| Level | AbuseIPDB | VT Malicious | GreyNoise | ThreatFox |
|---|---|---|---|---|
CRITICAL |
>= 75 | >= 5 | β | β |
HIGH |
>= 40 | >= 2 | malicious |
confirmed |
MEDIUM |
>= 10 | >= 1 | suspicious or noise=true |
β |
LOW |
> 0 | β | β | β |
CLEAN |
0 | 0 | benign |
no results |
| Level | VT Malicious | MalwareBazaar | ThreatFox |
|---|---|---|---|
CRITICAL |
>= 15 | β | β |
HIGH |
>= 5 | confirmed | confirmed |
MEDIUM |
>= 1 | β | β |
CLEAN |
0 | not found | no results |
| Level | VT Malicious | ThreatFox |
|---|---|---|
CRITICAL |
>= 5 | β |
HIGH |
>= 2 | confirmed |
MEDIUM |
>= 1 | β |
CLEAN |
0 | no results |
| Source | IOC Types | Free Tier |
|---|---|---|
| ipapi.is | IP | 1,000 req/day without a key |
| AbuseIPDB | IP | 1,000 req/day β verbose mode returns full report list with category IDs |
| VirusTotal | IP, Hash, Domain | 4 req/min, 500 req/day |
| MalwareBazaar | Hash | No hard limit |
| ThreatFox | IP, Hash, Domain | No hard limit |
| GreyNoise | IP | 10 lookups/day without a key |
| OS | amd64 | arm64 |
|---|---|---|
| Linux | β | β |
| macOS | β | β |
| Windows | β | β |
Linux and Windows binaries are compressed with UPX. macOS binaries are left uncompressed to avoid Gatekeeper issues.
iocscan/
βββ main.go β entry point: -p flag, config load, server start
βββ config/
β βββ config.go β load ~/.iocscan/config.yaml via gopkg.in/yaml.v3
βββ server/
β βββ server.go β Start(), HTTP mux, all handlers, rate limiting
βββ integrations/
β βββ integration.go β Integration interface, Manifest types, EvaluateRisk()
β βββ registry.go β All(), ForIOCType(), Manifests(), CacheTables()
β βββ cache.go β RWMutex-protected cache bridge
β βββ abuseipdb.go β AbuseIPDB integration (IP)
β βββ greynoise.go β GreyNoise Community API integration (IP)
β βββ ipapi.go β ipapi.is integration (IP)
β βββ malwarebazaar.go β MalwareBazaar integration (Hash)
β βββ threatfox.go β ThreatFox integrations (IP, Hash, Domain)
β βββ virustotal.go β VirusTotal integrations (IP, Hash, Domain)
βββ internal/
β βββ httpclient/http.go β shared HTTP client with context support
βββ utils/
β βββ common.go β dynamic InitDB(), cache helpers
β βββ orchestrator.go β generic Scan() fan-out, ScanResult, BuildKeys()
β βββ iputil.go β IP output types, IPProcessor, GNResult
β βββ iputil_shim.go β Lookup() -> Scan() -> ComplexResult
β βββ hashutil.go β hash output types and helpers
β βββ hashutil_shim.go β LookupHash() -> Scan() -> HashResult
β βββ domainutil.go β LookupDomain() -> Scan() -> DomainResult
β βββ iocutil.go β IOC type detection (IP, hash, domain)
βββ web/
βββ components/
β βββ ColumnDrawer.js β column visibility drawer
β βββ IntegrationCard.js β generic card renderer (driven by manifests)
β βββ IOCScanner.js β main scanner (IP, Hash, Domain tabs)
β βββ ResultsTable.js β sortable results table
βββ composables/
β βββ useColumnVisibility.js β column toggle state
β βββ useDomainResults.js β domain scan state, table, export
β βββ useHashResults.js β hash scan state, table, export
β βββ useIntegrations.js β manifest fetch at boot
β βββ useIOCScan.js β central scan orchestration
β βββ useIPResults.js β IP scan state, table, export
β βββ useScanHistory.js β scan history management
βββ index.html β main UI entry point
βββ main.js β Vue app bootstrap + loadManifests()
βββ utils.js β shared helpers (highlightJSON, escapeHTML)
| IOC Type | Backend | Frontend | Estimated time |
|---|---|---|---|
| IP | 3 | 2 | ~2h |
| Hash | 3 | 1 | ~1h 30m |
| Domain | 2 | 1 | ~1h 15m |
Backend files (all integration types):
| File | Change |
|---|---|
integrations/yourvendor.go |
New file β fetch function, Manifest(), Run() |
integrations/registry.go |
+1 line |
utils/iputil_shim.go or utils/hashutil_shim.go |
+~10 lines mapping block (IP and hash only) |
Frontend files (required for all integration types):
| File | Change |
|---|---|
web/components/IOCScanner.js |
Add hardcoded card template for the new vendor |
web/composables/useIOCScan.js |
Add key to keys reactive object + scan request body (if vendor requires a key) |
Additional wiring for IP integrations that require a key (e.g. GreyNoise):
| File | Change |
|---|---|
server/server.go |
Add YourVendorKey field to scanRequest struct, pass to NewIPProcessor |
utils/iputil.go |
Add yourvendorKey field to IPProcessor, update NewIPProcessor signature |
utils/iputil_shim.go |
Pass keys["yourvendor"] = p.yourvendorKey |
Note: The frontend card template and key wiring are currently manual steps because the card rendering for IP scans is not yet fully manifest-driven. This will be eliminated when the shim layer is removed β at that point, new integrations will truly require only the 2 backend files.
package integrations
import (
"context"
"encoding/json"
"fmt"
"github.com/TwoA2U/iocscan/internal/httpclient"
)
type YourVendor struct{}
func (y YourVendor) Manifest() Manifest {
return Manifest{
Name: "yourvendor", Label: "Your Vendor", Icon: "π§",
Enabled: true, IOCTypes: []IOCType{IOCTypeIP},
Auth: AuthConfig{KeyRef: "yourvendor", Label: "Your Vendor", Optional: true},
Cache: CacheConfig{Table: "YV_IP", TTLHours: 24},
RiskRules: []RiskRule{
{
Field: "score", Type: RiskThreshold,
Thresholds: []RiskThresholdRule{
{Gte: 75, Level: "HIGH"},
{Gte: 25, Level: "MEDIUM"},
},
},
},
Card: CardDef{
Title: "π§ Your Vendor", Order: 6,
LinkTemplate: "https://yourvendor.com/{ioc}",
LinkLabel: "β Your Vendor",
Fields: []FieldDef{
{Key: "score", Label: "Score", Type: FieldTypeNumber},
{Key: "status", Label: "Status", Type: FieldTypeString},
},
},
TableColumns: []TableColumn{
{Key: "score", Label: "YV Score", DefaultVisible: true},
{Key: "status", Label: "YV Status", DefaultVisible: true},
},
}
}
func (y YourVendor) Run(ctx context.Context, ioc, apiKey string, useCache bool) (*Result, error) {
if useCache {
if raw := cachedGet(ioc, "YV_IP"); raw != "" {
var r yourVendorResponse
if err := json.Unmarshal([]byte(raw), &r); err == nil {
return yvToResult(&r), nil
}
}
}
r, err := fetchYourVendor(ctx, ioc, apiKey)
if err != nil {
return &Result{Error: err.Error()}, nil // soft error β partial result
}
if b, e := json.Marshal(r); e == nil {
cachedPut(ioc, string(b), "YV_IP")
}
return yvToResult(r), nil
}// ββ IP integrations βββββββββββββββββββββββ
&VirusTotalIP{},
&AbuseIPDBIntegration{},
&IPAPIIntegration{},
&ThreatFoxIPIntegration{},
&GreyNoise{},
&YourVendor{}, // <- add this lineDone for domain integrations. For IP and hash integrations, continue to Step 3.
In utils/iputil_shim.go, add inside buildComplexResult():
// ββ Your Vendor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if f, ok := sr.Results["yourvendor"]; ok {
result.YourVendor = &YVResult{
Score: intField(f, "score"),
Status: strField(f, "status"),
}
} else if errMsg, hasErr := sr.Errors["yourvendor"]; hasErr {
result.YourVendor = &YVResult{Error: errMsg}
}Also add the YVResult struct and a YourVendor *YVResult field to ComplexResult in utils/iputil.go.
Why is this step needed? The scan orchestrator returns a generic
map[string]anyresult. The IP and hash API responses use typed structs for backward compatibility with the frontend. This mapping block bridges them. This limitation will be removed in a future release β see Known Limitations.
In web/components/IOCScanner.js, add a card inside the IP cards grid (before the <!-- JSON panel --> comment):
<!-- Your Vendor card -->
<div v-if="activeResult.yourvendor" class="card">
<div class="card-head">
<span class="card-head-left">π§ Your Vendor</span>
<a :href="'https://yourvendor.com/'+activeResultIP"
target="_blank" rel="noopener" class="card-source-link">β Your Vendor</a>
</div>
<div v-if="activeResult.yourvendor.score != null" class="kv">
<span class="kv-key">Score</span>
<span class="kv-val">{{ activeResult.yourvendor.score }}</span>
</div>
<div v-if="activeResult.yourvendor.status" class="kv">
<span class="kv-key">Status</span>
<span class="kv-val">{{ activeResult.yourvendor.status }}</span>
</div>
<div v-if="activeResult.yourvendor.error" class="kv mt-2">
<span class="kv-val" style="color:var(--red);font-size:0.68rem">
{{ activeResult.yourvendor.error }}
</span>
</div>
</div>web/composables/useIOCScan.js β add the key to the reactive state and scan request:
// Add to keys reactive object
export const keys = reactive({ vt: '', abuse: '', ..., yourvendor: '' });
// Add to the /api/scan request body in doIPScan()
yourvendor_key: keys.yourvendor,web/components/IOCScanner.js β add a key input to the API Keys panel:
<div>
<label ... >Your Vendor <span style="color:#2e4060">(optional)</span></label>
<input type="password" v-model="keys.yourvendor" class="key-input"
placeholder="Leave blank for free tierβ¦">
</div>server/server.go β add the key field to the request struct and pass it through:
type scanRequest struct {
// ... existing fields ...
YourVendorKey string `json:"yourvendor_key"`
}
// Pass to NewIPProcessor:
processor := utils.NewIPProcessor(..., req.YourVendorKey)utils/iputil.go β add to IPProcessor and NewIPProcessor:
type IPProcessor struct {
// ... existing fields ...
yourvendorKey string
}
func NewIPProcessor(..., yourvendorKey string) *IPProcessor {
return &IPProcessor{..., yourvendorKey: yourvendorKey}
}utils/iputil_shim.go β pass the key into the keys map:
keys := BuildKeys(p.vtKey, p.abuseKey, p.ipapiKey, p.abusechKey)
keys["yourvendor"] = p.yourvendorKeyNote: This key wiring boilerplate is a known limitation. It will be eliminated when the auth system is implemented β at that point, keys come from the database and the request body only carries the scan target, not credentials.
IP and hash scan results pass through compatibility shims (iputil_shim.go, hashutil_shim.go) that map the generic ScanResult into the typed JSON structs the frontend expects. Every new IP or hash integration requires a manual mapping block (~10 lines) in the relevant shim.
Domain integrations do not have this limitation.
Planned fix: Remove shims when the frontend migrates to consuming ScanResult directly via manifests. Planned alongside the auth system.
Cards that return an error (missing API key, rate limit, network failure) are hidden from the UI to avoid clutter. The raw error message is still present in the JSON panel below the cards for debugging. Cards that return a valid "not found" response (no_results, hash_not_found) still render β only hard errors are suppressed.
The current release has no user authentication. API keys are entered in the browser per session and are not persisted between sessions. Designed for local or trusted LAN use only.
Planned fix: Full auth system β users, httpOnly session cookies, server-side AES-256-GCM encrypted API key storage. See COMBINED_PLAN.md for details.
Each vendor key is a separate named field in the request body (vt_key, abuse_key, greynoise_key, etc.). Adding a new integration that requires a key means adding a new field to the request struct in server/server.go.
Planned fix: Replace with a generic "keys": { "keyref": "value" } map when the auth system is implemented (keys will come from the database, not the request body).
Category data is only returned when the API is queried with verbose=true and maxAgeInDays set. iocscan always uses verbose mode. Category IDs are mapped to names using the official AbuseIPDB category list and deduplicated across all reports before display.
Domain scans query VirusTotal and ThreatFox only. Other integrations (AbuseIPDB, GreyNoise, ipapi.is) are IP-only by design. Future domain integrations (WHOIS, passive DNS, URLScan.io) can be added following the standard plugin pattern with no shim required.
The cache database uses SQLite with WAL mode β appropriate for local and small team use. High-concurrency or multi-server deployments should migrate to PostgreSQL.
- Go 1.22+
- GoReleaser (release builds only)
git clone https://github.com/TwoA2U/iocscan.git
cd iocscan
go mod tidy
go build -o iocscan .
./iocscanOpen http://localhost:8080, enter API keys in the panel, and start scanning.
During development, iocscan reads web/ directly from disk when the directory exists β edit frontend files and refresh without rebuilding.
curl http://localhost:8080/api/health
# {"status":"ok","service":"iocscan"}
curl http://localhost:8080/api/integrations | jq '.[].name'
# "virustotal_ip" "abuseipdb" "ipapi" "threatfox_ip" "greynoise"
# "virustotal_hash" "malwarebazaar" "threatfox_hash"
# "virustotal_domain" "threatfox_domain"goreleaser release --snapshot --cleangit tag -a v1.2.0 -m "v1.2.0"
git push origin v1.2.0- Fork and create a branch off
main - Commit using conventional commit messages (
feat:,fix:,docs:) - Verify:
go vet ./...andgo build ./... - Open a pull request against
main
- New integrations: Shodan InternetDB, OTX, Censys, URLScan β see Adding a New Integration
- Frontend migration to manifest-driven rendering (eliminates the shim layer)
- Auth system implementation
- Additional IOC types (URLs)
- CLI improvements (coloured output, table formatting)
- Breaking existing API response formats
- Hard-coded credentials of any kind
- Changes that require CGO (the project targets CGO-free static builds)
- Adding vendor-specific logic to the orchestrator β use the integration interface instead