Skip to content

Commit 495d2c5

Browse files
committed
feat: add securitytrails integration for domain discovery + target expansion
1 parent 5ddfbc6 commit 495d2c5

8 files changed

Lines changed: 598 additions & 4 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ makepkg -si
115115
# shodan host intelligence (requires SHODAN_API_KEY env var)
116116
./sif -u https://example.com -shodan
117117

118+
# securitytrails domain discovery (requires SECURITYTRAILS_API_KEY env var)
119+
# discovers subdomains + associated domains, then scans all of them
120+
./sif -u https://example.com -securitytrails -headers
121+
118122
# sql recon + lfi scanning
119123
./sif -u https://example.com -sql -lfi
120124

@@ -148,6 +152,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended
148152
| `-whois` | whois lookups |
149153
| `-git` | exposed git repository detection |
150154
| `-shodan` | shodan lookup (requires SHODAN_API_KEY) |
155+
| `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) |
151156
| `-sql` | sql recon |
152157
| `-lfi` | local file inclusion |
153158
| `-framework` | framework detection with cve lookup |

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Settings struct {
4242
CloudStorage bool
4343
SubdomainTakeover bool
4444
Shodan bool
45+
SecurityTrails bool
4546
SQL bool
4647
LFI bool
4748
Framework bool
@@ -92,6 +93,7 @@ func Parse() *Settings {
9293
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
9394
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
9495
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
96+
flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"),
9597
flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"),
9698
flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"),
9799
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),

internal/scan/builtin/register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ func Register() {
2121
modules.Register(&FrameworksModule{})
2222
modules.Register(&NucleiModule{})
2323
modules.Register(&WhoisModule{})
24+
modules.Register(&SecurityTrailsModule{})
2425
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2025 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package builtin
14+
15+
import (
16+
"context"
17+
"fmt"
18+
"strings"
19+
20+
"github.com/dropalldatabases/sif/internal/modules"
21+
"github.com/dropalldatabases/sif/internal/scan"
22+
)
23+
24+
type SecurityTrailsModule struct{}
25+
26+
func (m *SecurityTrailsModule) Info() modules.Info {
27+
return modules.Info{
28+
ID: "securitytrails-lookup",
29+
Name: "SecurityTrails Domain Discovery",
30+
Author: "sif",
31+
Severity: "info",
32+
Description: "Queries SecurityTrails API for subdomains and associated domains (requires SECURITYTRAILS_API_KEY)",
33+
Tags: []string{"recon", "osint", "dns", "subdomains"},
34+
}
35+
}
36+
37+
func (m *SecurityTrailsModule) Type() modules.ModuleType {
38+
return modules.TypeScript
39+
}
40+
41+
func (m *SecurityTrailsModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
42+
stResult, err := scan.SecurityTrails(target, opts.Timeout, opts.LogDir)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
result := &modules.Result{
48+
ModuleID: m.Info().ID,
49+
Target: target,
50+
Findings: []modules.Finding{},
51+
}
52+
53+
if stResult == nil {
54+
return result, nil
55+
}
56+
57+
finding := modules.Finding{
58+
URL: target,
59+
Severity: "info",
60+
Evidence: fmt.Sprintf("discovered %d subdomains and %d associated domains",
61+
len(stResult.Subdomains), len(stResult.AssociatedDomains)),
62+
Extracted: map[string]string{
63+
"domain": stResult.Domain,
64+
"subdomain_count": fmt.Sprintf("%d", len(stResult.Subdomains)),
65+
"associated_count": fmt.Sprintf("%d", len(stResult.AssociatedDomains)),
66+
},
67+
}
68+
69+
if len(stResult.Subdomains) > 0 {
70+
finding.Extracted["subdomains"] = strings.Join(stResult.Subdomains, ", ")
71+
}
72+
73+
if len(stResult.AssociatedDomains) > 0 {
74+
finding.Extracted["associated_domains"] = strings.Join(stResult.AssociatedDomains, ", ")
75+
}
76+
77+
result.Findings = append(result.Findings, finding)
78+
return result, nil
79+
}

internal/scan/result.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ type ScanResult interface {
3131

3232
// ResultType implementations for pointer result types.
3333

34-
func (r *ShodanResult) ResultType() string { return "shodan" }
35-
func (r *SQLResult) ResultType() string { return "sql" }
36-
func (r *LFIResult) ResultType() string { return "lfi" }
37-
func (r *CMSResult) ResultType() string { return "cms" }
34+
func (r *ShodanResult) ResultType() string { return "shodan" }
35+
func (r *SQLResult) ResultType() string { return "sql" }
36+
func (r *LFIResult) ResultType() string { return "lfi" }
37+
func (r *CMSResult) ResultType() string { return "cms" }
38+
func (r *SecurityTrailsResult) ResultType() string { return "securitytrails" }
3839

3940
// ResultType implementations for slice result types.
4041

@@ -50,6 +51,7 @@ var (
5051
_ ScanResult = (*SQLResult)(nil)
5152
_ ScanResult = (*LFIResult)(nil)
5253
_ ScanResult = (*CMSResult)(nil)
54+
_ ScanResult = (*SecurityTrailsResult)(nil)
5355
_ ScanResult = HeaderResults(nil)
5456
_ ScanResult = DirectoryResults(nil)
5557
_ ScanResult = CloudStorageResults(nil)

internal/scan/securitytrails.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2025 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package scan
14+
15+
import (
16+
"context"
17+
"encoding/json"
18+
"fmt"
19+
"io"
20+
"net/http"
21+
"net/url"
22+
"os"
23+
"strings"
24+
"time"
25+
26+
"github.com/dropalldatabases/sif/internal/logger"
27+
"github.com/dropalldatabases/sif/internal/output"
28+
)
29+
30+
const securityTrailsBaseURL = "https://api.securitytrails.com/v1"
31+
32+
// SecurityTrailsResult holds discovered domains from SecurityTrails API
33+
type SecurityTrailsResult struct {
34+
Domain string `json:"domain"`
35+
Subdomains []string `json:"subdomains,omitempty"`
36+
AssociatedDomains []string `json:"associated_domains,omitempty"`
37+
}
38+
39+
// stSubdomainsResponse is the raw response from the subdomains endpoint -
40+
// returns prefix labels, not FQDNs
41+
type stSubdomainsResponse struct {
42+
Subdomains []string `json:"subdomains"`
43+
}
44+
45+
type stAssociatedResponse struct {
46+
Records []stAssociatedRecord `json:"records"`
47+
}
48+
49+
type stAssociatedRecord struct {
50+
Hostname string `json:"hostname"`
51+
}
52+
53+
// SecurityTrails queries the SecurityTrails API for subdomains and associated domains.
54+
// API key should be provided via the SECURITYTRAILS_API_KEY environment variable.
55+
func SecurityTrails(targetURL string, timeout time.Duration, logdir string) (*SecurityTrailsResult, error) {
56+
output.ScanStart("SecurityTrails lookup")
57+
58+
spin := output.NewSpinner("querying SecurityTrails API")
59+
spin.Start()
60+
61+
apiKey := os.Getenv("SECURITYTRAILS_API_KEY")
62+
if apiKey == "" {
63+
spin.Stop()
64+
output.Warn("SECURITYTRAILS_API_KEY environment variable not set, skipping SecurityTrails lookup")
65+
return nil, fmt.Errorf("SECURITYTRAILS_API_KEY environment variable not set")
66+
}
67+
68+
parsedURL, err := url.Parse(targetURL)
69+
if err != nil {
70+
spin.Stop()
71+
return nil, fmt.Errorf("failed to parse URL: %w", err)
72+
}
73+
hostname := parsedURL.Hostname()
74+
75+
client := &http.Client{Timeout: timeout}
76+
77+
result := &SecurityTrailsResult{
78+
Domain: hostname,
79+
}
80+
81+
// fetch subdomains
82+
spin.Update("fetching subdomains for " + hostname)
83+
subs, err := querySTSubdomains(client, hostname, apiKey)
84+
if err != nil {
85+
// non-fatal - still try associated domains
86+
output.Warn("SecurityTrails subdomains failed: %v", err)
87+
} else {
88+
result.Subdomains = subs
89+
}
90+
91+
// fetch associated domains
92+
spin.Update("fetching associated domains for " + hostname)
93+
assoc, err := querySTAssociated(client, hostname, apiKey)
94+
if err != nil {
95+
output.Warn("SecurityTrails associated domains failed: %v", err)
96+
} else {
97+
result.AssociatedDomains = assoc
98+
}
99+
100+
spin.Stop()
101+
102+
if logdir != "" {
103+
sanitizedURL := strings.Split(targetURL, "://")[1]
104+
if err := logger.WriteHeader(sanitizedURL, logdir, "SecurityTrails lookup"); err != nil {
105+
output.Error("error writing log header: %v", err)
106+
}
107+
logSecurityTrailsResults(sanitizedURL, logdir, result)
108+
}
109+
110+
printSecurityTrailsResults(result)
111+
112+
total := len(result.Subdomains) + len(result.AssociatedDomains)
113+
output.ScanComplete("SecurityTrails lookup", total, "domains discovered")
114+
115+
return result, nil
116+
}
117+
118+
// DiscoveredURLs returns all discovered domains as https:// URLs.
119+
// used by the orchestration layer for target expansion.
120+
func (r *SecurityTrailsResult) DiscoveredURLs() []string {
121+
seen := make(map[string]struct{})
122+
var urls []string
123+
124+
for _, sub := range r.Subdomains {
125+
fqdn := sub + "." + r.Domain
126+
if _, ok := seen[fqdn]; !ok {
127+
seen[fqdn] = struct{}{}
128+
urls = append(urls, "https://"+fqdn)
129+
}
130+
}
131+
132+
for _, assoc := range r.AssociatedDomains {
133+
if _, ok := seen[assoc]; !ok {
134+
seen[assoc] = struct{}{}
135+
urls = append(urls, "https://"+assoc)
136+
}
137+
}
138+
139+
return urls
140+
}
141+
142+
func querySTSubdomains(client *http.Client, hostname, apiKey string) ([]string, error) {
143+
reqURL := fmt.Sprintf("%s/domain/%s/subdomains", securityTrailsBaseURL, hostname)
144+
body, err := doSTRequest(client, reqURL, apiKey)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
var resp stSubdomainsResponse
150+
if err := json.Unmarshal(body, &resp); err != nil {
151+
return nil, fmt.Errorf("parse subdomains response: %w", err)
152+
}
153+
154+
return resp.Subdomains, nil
155+
}
156+
157+
func querySTAssociated(client *http.Client, hostname, apiKey string) ([]string, error) {
158+
reqURL := fmt.Sprintf("%s/domain/%s/associated", securityTrailsBaseURL, hostname)
159+
body, err := doSTRequest(client, reqURL, apiKey)
160+
if err != nil {
161+
return nil, err
162+
}
163+
164+
var resp stAssociatedResponse
165+
if err := json.Unmarshal(body, &resp); err != nil {
166+
return nil, fmt.Errorf("parse associated response: %w", err)
167+
}
168+
169+
domains := make([]string, 0, len(resp.Records))
170+
for _, rec := range resp.Records {
171+
if rec.Hostname != "" {
172+
domains = append(domains, rec.Hostname)
173+
}
174+
}
175+
176+
return domains, nil
177+
}
178+
179+
// doSTRequest makes an authenticated GET to the SecurityTrails API
180+
func doSTRequest(client *http.Client, reqURL, apiKey string) ([]byte, error) {
181+
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, reqURL, http.NoBody)
182+
if err != nil {
183+
return nil, fmt.Errorf("create request: %w", err)
184+
}
185+
req.Header.Set("APIKEY", apiKey)
186+
req.Header.Set("Accept", "application/json")
187+
188+
resp, err := client.Do(req)
189+
if err != nil {
190+
return nil, fmt.Errorf("SecurityTrails request failed: %w", err)
191+
}
192+
defer resp.Body.Close()
193+
194+
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
195+
return nil, fmt.Errorf("invalid SecurityTrails API key (status %d)", resp.StatusCode)
196+
}
197+
198+
if resp.StatusCode == http.StatusTooManyRequests {
199+
return nil, fmt.Errorf("SecurityTrails rate limit exceeded")
200+
}
201+
202+
if resp.StatusCode != http.StatusOK {
203+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
204+
return nil, fmt.Errorf("SecurityTrails API error (status %d): %s", resp.StatusCode, string(body))
205+
}
206+
207+
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
208+
if err != nil {
209+
return nil, fmt.Errorf("read response: %w", err)
210+
}
211+
212+
return body, nil
213+
}
214+
215+
func printSecurityTrailsResults(result *SecurityTrailsResult) {
216+
output.Info("Domain: %s", output.Highlight.Render(result.Domain))
217+
218+
if len(result.Subdomains) > 0 {
219+
output.Info("Subdomains found: %d", len(result.Subdomains))
220+
for _, sub := range result.Subdomains {
221+
output.Success(" %s.%s", sub, result.Domain)
222+
}
223+
}
224+
225+
if len(result.AssociatedDomains) > 0 {
226+
output.Info("Associated domains found: %d", len(result.AssociatedDomains))
227+
for _, assoc := range result.AssociatedDomains {
228+
output.Success(" %s", assoc)
229+
}
230+
}
231+
}
232+
233+
func logSecurityTrailsResults(sanitizedURL, logdir string, result *SecurityTrailsResult) {
234+
var sb strings.Builder
235+
236+
sb.WriteString(fmt.Sprintf("Domain: %s\n", result.Domain))
237+
238+
if len(result.Subdomains) > 0 {
239+
sb.WriteString(fmt.Sprintf("\nSubdomains (%d):\n", len(result.Subdomains)))
240+
for _, sub := range result.Subdomains {
241+
sb.WriteString(fmt.Sprintf(" %s.%s\n", sub, result.Domain))
242+
}
243+
}
244+
245+
if len(result.AssociatedDomains) > 0 {
246+
sb.WriteString(fmt.Sprintf("\nAssociated Domains (%d):\n", len(result.AssociatedDomains)))
247+
for _, assoc := range result.AssociatedDomains {
248+
sb.WriteString(fmt.Sprintf(" %s\n", assoc))
249+
}
250+
}
251+
252+
logger.Write(sanitizedURL, logdir, sb.String())
253+
}

0 commit comments

Comments
 (0)