Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion pkg/lib/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const CapabilityName = "pius-discovery"
// automatically once they register via init().
var pluginNames = []string{
// Domain plugins (passive)
"crt-sh", "apollo", "github-org", "gleif", "passive-dns", "reverse-whois", "whois",
"crt-sh", "apollo", "github-org", "gleif", "passive-dns", "viewdns-reverse-whois", "whois",
"urlscan", // LAB-1339
"wayback", // LAB-1341
"wikidata", // LAB-1346
Expand Down
73 changes: 73 additions & 0 deletions pkg/lib/netutil/ssrf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package netutil

import (
"fmt"
"net"
"net/netip"
"syscall"
)

// SSRFSafeControl is a net.Dialer.Control hook that rejects connections to
// non-public addresses, preventing untrusted referrals from probing internal
// networks.
func SSRFSafeControl(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("ssrf guard: malformed address %q: %w", address, err)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("ssrf guard: non-IP address %q", host)
}
if IsDisallowedIP(ip) {
return fmt.Errorf("ssrf guard: refusing non-public address %s", ip)
}
return nil
}

var v6GlobalUnicast = netip.MustParsePrefix("2000::/3")

var disallowedPrefixes = func() []netip.Prefix {
cidrs := []string{
// IPv4 special-purpose
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24",
"192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24",
"203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
// IPv6 special-purpose
"::1/128", "::/128", "::ffff:0:0/96", "::/96",
"64:ff9b::/96", "64:ff9b:1::/48", "100::/64", "100:0:0:1::/64",
"2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20",
"5f00::/16", "fc00::/7", "fe80::/10", "fec0::/10", "ff00::/8",
}
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
prefixes = append(prefixes, netip.MustParsePrefix(c))
}
return prefixes
}()

// IsDisallowedIP reports whether ip is non-public (loopback, private, CGNAT,
// link-local, etc.) and must not be dialed when following an untrusted referral.
func IsDisallowedIP(ip net.IP) bool {
if ip == nil {
return true
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return true
}
addr = addr.Unmap()
if !addr.IsGlobalUnicast() || addr.IsPrivate() {
return true
}
if addr.Is6() && !v6GlobalUnicast.Contains(addr) {
return true
}
for _, p := range disallowedPrefixes {
if p.Contains(addr) {
return true
}
}
return false
}
64 changes: 64 additions & 0 deletions pkg/lib/strutil/strutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package strutil

import "strings"

// Tokenize lowercases s and splits on non-alphanumeric characters.
func Tokenize(s string) []string {
s = strings.ToLower(s)
var buf strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
buf.WriteRune(c)
} else {
buf.WriteByte(' ')
}
}
return strings.Fields(buf.String())
}

// TokenSimilarity computes the ratio of shared tokens between two strings.
// Uses the shorter set as the denominator so partial matches score well.
func TokenSimilarity(a, b string) float64 {
aT := Tokenize(a)
bT := Tokenize(b)
if len(aT) == 0 || len(bT) == 0 {
return 0
}
shorter, longer := aT, bT
if len(aT) > len(bT) {
shorter, longer = bT, aT
}
inLonger := make(map[string]bool, len(longer))
for _, t := range longer {
inLonger[t] = true
}
matches := 0
for _, t := range shorter {
if inLonger[t] {
matches++
}
}
return float64(matches) / float64(len(shorter))
Comment thread
EvanLeleux marked this conversation as resolved.
}

// UniqueFunc returns a new slice containing only the first occurrence of each
// element as determined by the key function, preserving order.
func UniqueFunc[T any, K comparable](s []T, key func(T) K) []T {
seen := make(map[K]struct{}, len(s))
out := make([]T, 0, len(s))
for _, v := range s {
k := key(v)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, v)
}
return out
}

// Unique returns a new slice containing only the first occurrence of each
// element, preserving order.
func Unique[T comparable](s []T) []T {
return UniqueFunc(s, func(v T) T { return v })
}
33 changes: 33 additions & 0 deletions pkg/plugins/domains/domain_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package domains

import (
"strings"

"github.com/praetorian-inc/pius/pkg/lib/strutil"
"github.com/praetorian-inc/pius/pkg/plugins"
"github.com/praetorian-inc/pius/pkg/whois"
)

// domainFindings normalizes, deduplicates, and filters a raw list of domain
// strings into plausible FindingDomain entries with the pivot org attached.
// Shared by the reverse-whois plugins.
func domainFindings(source, pivotOrg string, rawDomains []string) []plugins.Finding {
Comment thread
EvanLeleux marked this conversation as resolved.
normalized := make([]string, 0, len(rawDomains))
for _, raw := range rawDomains {
domain := strings.TrimSuffix(strings.TrimSpace(strings.ToLower(raw)), ".")
if domain != "" && whois.IsPlausibleDomain(domain) {
normalized = append(normalized, domain)
}
}

var findings []plugins.Finding
for _, domain := range strutil.Unique(normalized) {
findings = append(findings, plugins.Finding{
Type: plugins.FindingDomain,
Value: domain,
Source: source,
Data: map[string]any{"pivot_org": pivotOrg},
})
}
return findings
}
43 changes: 2 additions & 41 deletions pkg/plugins/domains/github_org.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

piuscache "github.com/praetorian-inc/pius/pkg/cache"
"github.com/praetorian-inc/pius/pkg/client"
"github.com/praetorian-inc/pius/pkg/lib/strutil"
"github.com/praetorian-inc/pius/pkg/plugins"
)

Expand Down Expand Up @@ -206,7 +207,7 @@ func (p *GitHubOrgPlugin) score(finding *plugins.Finding, org *githubOrg, input
}

// Name similarity: token overlap between org display name and OrgName
if similarity := tokenSimilarity(org.Name, input.OrgName); similarity > 0 {
if similarity := strutil.TokenSimilarity(org.Name, input.OrgName); similarity > 0 {
plugins.AddConfidence(finding, 0.25*similarity,
fmt.Sprintf("GitHub organization name %q matches the target organization %q with %.0f%% token similarity",
org.Name, input.OrgName, similarity*100))
Expand Down Expand Up @@ -294,43 +295,3 @@ func domainContains(rawURL, domain string) bool {
return host == domain || strings.HasSuffix(host, "."+domain)
}

// tokenSimilarity computes the ratio of shared tokens between two strings.
// Uses the shorter string as the denominator so partial matches score well.
// "Praetorian" vs "Praetorian Security" → 1/1 = 1.0 (shorter has 1 token, matches)
// "Praetorian Security" vs "Praetorian Landscaping" → 1/2 = 0.5
func tokenSimilarity(a, b string) float64 {
aT := tokenize(a)
bT := tokenize(b)
if len(aT) == 0 || len(bT) == 0 {
return 0
}
shorter, longer := aT, bT
if len(aT) > len(bT) {
shorter, longer = bT, aT
}
inLonger := make(map[string]bool, len(longer))
for _, t := range longer {
inLonger[t] = true
}
matches := 0
for _, t := range shorter {
if inLonger[t] {
matches++
}
}
return float64(matches) / float64(len(shorter))
}

// tokenize lowercases s and splits on non-alphanumeric characters.
func tokenize(s string) []string {
s = strings.ToLower(s)
var buf strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
buf.WriteRune(c)
} else {
buf.WriteByte(' ')
}
}
return strings.Fields(buf.String())
}
3 changes: 2 additions & 1 deletion pkg/plugins/domains/github_org_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

piuscache "github.com/praetorian-inc/pius/pkg/cache"
"github.com/praetorian-inc/pius/pkg/client"
"github.com/praetorian-inc/pius/pkg/lib/strutil"
"github.com/praetorian-inc/pius/pkg/plugins"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -122,7 +123,7 @@ func TestTokenSimilarity(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) {
got := tokenSimilarity(tt.a, tt.b)
got := strutil.TokenSimilarity(tt.a, tt.b)
assert.GreaterOrEqual(t, got, tt.min, "similarity %q vs %q", tt.a, tt.b)
})
}
Expand Down
122 changes: 0 additions & 122 deletions pkg/plugins/domains/reverse_whois.go

This file was deleted.

Loading