Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,8 @@ dist/
.ruff_cache/

# Logs
*.log
*.log

# Temporary files
.tmp/
tmp/
30 changes: 30 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## [3.4.0] - 2026-02-05

### Features

- Add `s1.validin.reputation` command for domain and IP reputation checks
- Add IP reputation ingestion with ASN and geolocation data
- Add domain reputation ingestion with ranking data (Majestic, Tranco, Umbrella, Anchors)
- Add new data model extensions:
- `inet:fqdn._s1:validin:verdict` - Reputation verdict for domains
- `inet:fqdn._s1:validin:reputation` - Reputation score for domains
- `inet:fqdn._s1:validin:rank:majestic` - Majestic Million rank
- `inet:fqdn._s1:validin:rank:tranco` - Tranco Top 1M rank
- `inet:fqdn._s1:validin:rank:umbrella` - Umbrella Top 1M rank
- `inet:fqdn._s1:validin:rank:anchors` - Validin Top Anchors rank
- `inet:ipv4._s1:validin:verdict` - Reputation verdict for IPv4
- `inet:ipv4._s1:validin:reputation` - Reputation score for IPv4
- `inet:ipv4._s1:validin:rank:pivot_count` - Pivot count for IPv4
- `inet:ipv4._s1:validin:rank:top_a` - Validin Top A rank for IPv4
- `inet:ipv6._s1:validin:verdict` - Reputation verdict for IPv6
- `inet:ipv6._s1:validin:reputation` - Reputation score for IPv6
- `inet:ipv6._s1:validin:rank:pivot_count` - Pivot count for IPv6
- `inet:ipv6._s1:validin:rank:top_a` - Validin Top A rank for IPv6

### Improvements

- IP reputation now always parses ASN and geolocation information
- Enhanced ASN modeling using `inet:asnet4` and `inet:asnet6` forms for CIDR-to-ASN relationships
- Improved ownership data processing to handle multiple ownership entries
- Added comprehensive test coverage for reputation functionality

## [3.3.1] - 2025-11-19

### Fixes
Expand Down
34 changes: 33 additions & 1 deletion s1-validin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: s1-validin
version: 3.3.2
version: 3.4.0

synapse_version: '>=2.144.0,<3.0.0'

Expand Down Expand Up @@ -42,6 +42,7 @@ modules:
- name: s1.validin.ingest.dns
- name: s1.validin.ingest.pivot
- name: s1.validin.ingest.registration
- name: s1.validin.ingest.reputation
- name: s1.validin.model
- name: s1.validin.privsep
asroot:perms:
Expand Down Expand Up @@ -282,6 +283,33 @@ commands:
default: pilot.validin.com
help: The hostname to use.

- name: s1.validin.reputation
descr: |
Get reputation information for domains and IPs.

This command accepts the following input node forms:
- inet:fqdn
- inet:ipv4
- inet:ipv6

// get reputation for a domain
inet:fqdn=example.com | s1.validin.reputation

// get reputation for an IP address
inet:ipv4=8.8.8.8 | s1.validin.reputation

// get reputation with IP location and ASN data
inet:ipv4=8.8.8.8 | s1.validin.reputation

// get reputation and yield the enriched nodes
inet:fqdn=example.com | s1.validin.reputation --yield

cmdargs:
- - --yield
- type: bool
action: store_true
help: Yield created nodes.

- name: s1.validin.whois
descr: |
Get WHOIS records.
Expand Down Expand Up @@ -347,6 +375,10 @@ optic:
storm: s1.validin.http.pivot
descr: Pivot from HTTP content or a hash to related artifacts.
forms: [inet:http:request, hash:md5, hash:sha1, hash:sha256]
- name: reputation
storm: s1.validin.reputation
descr: Get reputation information for domains and IPs.
forms: [inet:fqdn, inet:ipv4, inet:ipv6]
- name: whois
storm: s1.validin.whois
descr: Get WHOIS records for a domain or email.
Expand Down
2 changes: 2 additions & 0 deletions storm/commands/s1.validin.reputation.storm
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
$validin = $lib.import(s1.validin)
divert $cmdopts.yield $validin.reputation($node, $cmdopts)
13 changes: 13 additions & 0 deletions storm/modules/s1.validin.api.storm
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,17 @@ function ip_crawl_history(
limit=$limit
).records.crawlr
)
}


// reputation endpoints
function domain_reputation(fqdn) {
$uri = `axon/domain/reputation/quick/{$fqdn}`
return($endpoint($uri))
}


function ip_reputation(ip) {
$uri = `axon/ip/reputation/quick/{$ip}`
return($endpoint($uri))
}
141 changes: 141 additions & 0 deletions storm/modules/s1.validin.ingest.reputation.storm
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
$ingest = $lib.import(s1.validin.ingest)


function extract_asn_int(asn_string) {
$asn_parts = $asn_string.split(" ")
$asn_num = $asn_parts.index(1)
return($lib.cast(int, $asn_num))
}


function create_asn_from_ownership(ip_form, ownership) {
$source = $ingest.get_source()
$asn_int = $extract_asn_int($ownership.asn)

//TODO :org synthesis
[inet:asn=$asn_int :name?=$ownership.owner]

for $cidr in $ownership.cidrs {
if ($ip_form = "inet:ipv4") {
[(inet:asnet4=($asn_int, $cidr))]
} elif ($ip_form = "inet:ipv6") {
[(inet:asnet6=($asn_int, $cidr))]
}
}
}


function domain_reputation(fqdn, data) {
// model domain reputation data from validin

$source = $ingest.get_source()

$domain_annotation_extended_proprty_value_map = ({
'MAGESTIC_MILLION_RANK':'majestic',
'TRANCO_TOP_1M_RANK':'tranco',
'UMBRELLA_TOP_1M_RANK':'umbrella',
'VALIDIN_TOP_ANCHORS_RANK':'anchors',
})

function annotate_fqdn(annotation) {
/*
Validin FQDN Anontation may be keyed with parnet fqdn, so we need to lift the node first
*/
$extended_property_key = $domain_annotation_extended_proprty_value_map.`{$annotation.description}`
if $extended_property_key {
[inet:fqdn=$annotation.key]
$node.data.set(`s1:validin:rank:{$extended_property_key}`, $annotation)
$node.props.set(`_s1:validin:rank:{$extended_property_key}`, $annotation.value)
}
}

try {
// Create/lift the fqdn node
[
inet:fqdn=$fqdn
:_s1:validin:verdict?=$data.verdict
:_s1:validin:reputation?=$data.score
]

// Store raw response data
$node.data.set(s1:validin:reputation, $data)

// Extract ranks from annotations array
if ($data.annotations != $lib.null) {
for $annotation in $data.annotations {
yield $annotate_fqdn($annotation)
}
} | uniq | [ <(seen)+ $source ]
} catch * as error {
$lib.warn(`Failed to model domain reputation for {$fqdn}: {$error}`)
}
}


function ip_reputation(ip, data) {
// model IP reputation data from validin

$source = $ingest.get_source()

try {
$is_ipv6 = $ip.find(":")

if $is_ipv6 {
[ inet:ipv6=$ip ]
} else {
[ inet:ipv4=$ip ]
}

$node.data.set(s1:validin:reputation, $data)

[
:_s1:validin:verdict?=$data.verdict
:_s1:validin:reputation?=$data.score
]

// Extract ranks from annotations array
if ($data.annotations != $lib.null) {{
for $annotation in $data.annotations {
switch $annotation.description {
"PIVOT_COUNT_IP": {
[:_s1:validin:rank:pivot_count?=$annotation.value]
}
"VALIDIN_TOP_A_RANK": {
[:_s1:validin:rank:top_a?=$annotation.value]
}
}
}
}}


// Set location from latitude/longitude
if ($data.informational.location != $lib.null) {
$lat = $lib.cast(float, $data.informational.location.latitude)
$lon = $lib.cast(float, $data.informational.location.longitude)
[
:latlong=($lat, $lon)
:loc?=$data.informational.location.country
]
}

// Extract ASN from ownership
if ($data.informational.ownership != $lib.null) {
$ip_form = $node.form()
// TODO: handle nested ownership whenever synapse starts supporting it (see test/mock/axon_ip_reputation_quick_8.8.8.8.json)
$ownership = $data.informational.ownership.index(0)

if ($ownership.asn != $lib.null) {
[:asn=$extract_asn_int($ownership.asn)]
}

for $owner in $data.informational.ownership {
yield $create_asn_from_ownership($ip_form, $owner)

}
}

| uniq | [<(seen)+ $source]
} catch * as error {
$lib.warn(`Failed to model IP reputation for {$ip}: {$error}`)
}
}
Loading