Add vendor assessment agent

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-04-22 22:36:14 +02:00
parent 25c590ffe6
commit 509d0c88b1
108 changed files with 9445 additions and 645 deletions

View File

@@ -0,0 +1,122 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
)
type (
corsParams struct {
URL string `json:"url" jsonschema:"The URL to check CORS headers for"`
Origin string `json:"origin" jsonschema:"The Origin header value to send in the preflight request (e.g. https://evil.com)"`
}
corsResult struct {
AllowOrigin string `json:"access_control_allow_origin,omitempty"`
AllowMethods []string `json:"access_control_allow_methods,omitempty"`
AllowHeaders []string `json:"access_control_allow_headers,omitempty"`
AllowCredentials bool `json:"access_control_allow_credentials"`
ExposeHeaders []string `json:"access_control_expose_headers,omitempty"`
MaxAge string `json:"access_control_max_age,omitempty"`
WildcardOrigin bool `json:"wildcard_origin"`
ReflectsOrigin bool `json:"reflects_origin"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func splitTrimmed(s, sep string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, sep)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func CheckCORSTool() agent.Tool {
return agent.FunctionTool(
"check_cors",
"Send a CORS preflight (OPTIONS) request to a URL with a given Origin and analyze the Access-Control-* response headers, flagging wildcard origins and origin reflection.",
func(ctx context.Context, p corsParams) (agent.ToolResult, error) {
if err := netcheck.ValidatePublicURL(p.URL); err != nil {
return agent.ResultJSON(corsResult{
ErrorDetail: fmt.Sprintf("URL not allowed: %s", err),
}), nil
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodOptions,
p.URL,
nil,
)
if err != nil {
return agent.ResultJSON(corsResult{
ErrorDetail: fmt.Sprintf("cannot build request: %s", err),
}), nil
}
req.Header.Set("Origin", p.Origin)
req.Header.Set("Access-Control-Request-Method", "GET")
resp, err := client.Do(req)
if err != nil {
return agent.ResultJSON(corsResult{
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", p.URL, err),
}), nil
}
defer func() { _ = resp.Body.Close() }()
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
result := corsResult{
AllowOrigin: allowOrigin,
AllowMethods: splitTrimmed(resp.Header.Get("Access-Control-Allow-Methods"), ","),
AllowHeaders: splitTrimmed(resp.Header.Get("Access-Control-Allow-Headers"), ","),
AllowCredentials: strings.EqualFold(resp.Header.Get("Access-Control-Allow-Credentials"), "true"),
ExposeHeaders: splitTrimmed(resp.Header.Get("Access-Control-Expose-Headers"), ","),
MaxAge: resp.Header.Get("Access-Control-Max-Age"),
WildcardOrigin: allowOrigin == "*",
ReflectsOrigin: p.Origin != "" && allowOrigin == p.Origin,
}
return agent.ResultJSON(result), nil
},
)
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplitTrimmed(t *testing.T) {
t.Parallel()
t.Run(
"splits and trims values",
func(t *testing.T) {
t.Parallel()
result := splitTrimmed("GET, POST, PUT", ",")
require.Len(t, result, 3)
assert.Equal(t, "GET", result[0])
assert.Equal(t, "POST", result[1])
assert.Equal(t, "PUT", result[2])
},
)
t.Run(
"returns nil for empty string",
func(t *testing.T) {
t.Parallel()
assert.Nil(t, splitTrimmed("", ","))
},
)
t.Run(
"skips empty parts",
func(t *testing.T) {
t.Parallel()
result := splitTrimmed("GET,,POST", ",")
require.Len(t, result, 2)
assert.Equal(t, "GET", result[0])
assert.Equal(t, "POST", result[1])
},
)
t.Run(
"single value",
func(t *testing.T) {
t.Parallel()
result := splitTrimmed("GET", ",")
require.Len(t, result, 1)
assert.Equal(t, "GET", result[0])
},
)
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"go.probo.inc/probo/pkg/agent"
)
type (
cspParams struct {
URL string `json:"url" jsonschema:"The URL to analyze the Content-Security-Policy header for"`
}
cspDirective struct {
Name string `json:"name"`
Values []string `json:"values"`
}
cspResult struct {
Present bool `json:"present"`
ReportOnly bool `json:"report_only"`
RawHeader string `json:"raw_header,omitempty"`
Directives []cspDirective `json:"directives,omitempty"`
HasUnsafeEval bool `json:"has_unsafe_eval"`
HasUnsafeInline bool `json:"has_unsafe_inline"`
HasWildcard bool `json:"has_wildcard"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func parseCSPDirectives(raw string) []cspDirective {
var directives []cspDirective
for part := range strings.SplitSeq(raw, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tokens := strings.Fields(part)
if len(tokens) == 0 {
continue
}
directives = append(
directives,
cspDirective{
Name: tokens[0],
Values: tokens[1:],
},
)
}
return directives
}
func AnalyzeCSPTool() agent.Tool {
return agent.FunctionTool(
"analyze_csp",
"Analyze the Content-Security-Policy header for a URL, parsing directives and flagging unsafe patterns like unsafe-eval, unsafe-inline, and wildcard sources.",
func(ctx context.Context, p cspParams) (agent.ToolResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
if err != nil {
return agent.ResultJSON(cspResult{
ErrorDetail: fmt.Sprintf("cannot create request for %s: %s", p.URL, err),
}), nil
}
resp, err := client.Do(req)
if err != nil {
return agent.ResultJSON(cspResult{
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", p.URL, err),
}), nil
}
defer func() { _ = resp.Body.Close() }()
raw := resp.Header.Get("Content-Security-Policy")
reportOnly := false
if raw == "" {
raw = resp.Header.Get("Content-Security-Policy-Report-Only")
if raw != "" {
reportOnly = true
}
}
if raw == "" {
return agent.ResultJSON(cspResult{Present: false}), nil
}
directives := parseCSPDirectives(raw)
var hasUnsafeEval, hasUnsafeInline, hasWildcard bool
for _, d := range directives {
for _, v := range d.Values {
switch v {
case "'unsafe-eval'":
hasUnsafeEval = true
case "'unsafe-inline'":
hasUnsafeInline = true
case "*":
hasWildcard = true
}
}
}
result := cspResult{
Present: true,
ReportOnly: reportOnly,
RawHeader: raw,
Directives: directives,
HasUnsafeEval: hasUnsafeEval,
HasUnsafeInline: hasUnsafeInline,
HasWildcard: hasWildcard,
}
return agent.ResultJSON(result), nil
},
)
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseCSPDirectives(t *testing.T) {
t.Parallel()
t.Run(
"parses multiple directives",
func(t *testing.T) {
t.Parallel()
raw := "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'unsafe-inline'"
directives := parseCSPDirectives(raw)
require.Len(t, directives, 3)
assert.Equal(t, "default-src", directives[0].Name)
assert.Equal(t, []string{"'self'"}, directives[0].Values)
assert.Equal(t, "script-src", directives[1].Name)
assert.Equal(t, []string{"'self'", "https://cdn.example.com"}, directives[1].Values)
assert.Equal(t, "style-src", directives[2].Name)
assert.Equal(t, []string{"'unsafe-inline'"}, directives[2].Values)
},
)
t.Run(
"handles empty string",
func(t *testing.T) {
t.Parallel()
directives := parseCSPDirectives("")
assert.Empty(t, directives)
},
)
t.Run(
"handles directive without values",
func(t *testing.T) {
t.Parallel()
raw := "upgrade-insecure-requests"
directives := parseCSPDirectives(raw)
require.Len(t, directives, 1)
assert.Equal(t, "upgrade-insecure-requests", directives[0].Name)
assert.Empty(t, directives[0].Values)
},
)
t.Run(
"ignores trailing semicolons",
func(t *testing.T) {
t.Parallel()
raw := "default-src 'self';"
directives := parseCSPDirectives(raw)
require.Len(t, directives, 1)
assert.Equal(t, "default-src", directives[0].Name)
},
)
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"strings"
"codeberg.org/miekg/dns"
"go.probo.inc/probo/pkg/agent"
)
type (
dmarcParams struct {
Domain string `json:"domain" jsonschema:"The domain to check DMARC record for (e.g. example.com)"`
}
dmarcResult struct {
Found bool `json:"found"`
RawRecord string `json:"raw_record,omitempty"`
Policy string `json:"policy,omitempty"`
Percentage string `json:"pct,omitempty"`
RUA string `json:"rua,omitempty"`
RUF string `json:"ruf,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func parseDMARCTag(record, tag string) string {
for part := range strings.SplitSeq(record, ";") {
part = strings.TrimSpace(part)
if after, ok := strings.CutPrefix(part, tag+"="); ok {
return after
}
}
return ""
}
func CheckDMARCTool() agent.Tool {
return agent.FunctionTool(
"check_dmarc",
"Check the DMARC DNS record for a domain, returning the policy, percentage, and reporting addresses.",
func(ctx context.Context, p dmarcParams) (agent.ToolResult, error) {
fqdn := "_dmarc." + p.Domain
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
client := dns.NewClient()
answers, err := queryDNS(
ctx,
client,
&dns.TXT{
Hdr: dns.Header{
Name: fqdn,
Class: dns.ClassINET,
},
},
)
if err != nil {
return agent.ResultJSON(dmarcResult{
Found: false,
ErrorDetail: fmt.Sprintf("cannot lookup DMARC record: %s", err),
}), nil
}
for _, answer := range answers {
txt, ok := answer.(*dns.TXT)
if !ok {
continue
}
record := strings.Join(txt.Txt, "")
if !strings.HasPrefix(record, "v=DMARC1") {
continue
}
result := dmarcResult{
Found: true,
RawRecord: record,
Policy: parseDMARCTag(record, "p"),
Percentage: parseDMARCTag(record, "pct"),
RUA: parseDMARCTag(record, "rua"),
RUF: parseDMARCTag(record, "ruf"),
}
return agent.ResultJSON(result), nil
}
return agent.ResultJSON(dmarcResult{Found: false}), nil
},
)
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseDMARCTag(t *testing.T) {
t.Parallel()
t.Run(
"extracts policy tag",
func(t *testing.T) {
t.Parallel()
record := "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"
assert.Equal(t, "reject", parseDMARCTag(record, "p"))
},
)
t.Run(
"extracts rua tag",
func(t *testing.T) {
t.Parallel()
record := "v=DMARC1; p=none; rua=mailto:reports@example.com"
assert.Equal(t, "mailto:reports@example.com", parseDMARCTag(record, "rua"))
},
)
t.Run(
"returns empty string for missing tag",
func(t *testing.T) {
t.Parallel()
record := "v=DMARC1; p=quarantine"
assert.Equal(t, "", parseDMARCTag(record, "ruf"))
},
)
t.Run(
"extracts pct tag",
func(t *testing.T) {
t.Parallel()
record := "v=DMARC1; p=reject; pct=50; rua=mailto:d@example.com"
assert.Equal(t, "50", parseDMARCTag(record, "pct"))
},
)
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"strings"
"codeberg.org/miekg/dns"
"go.probo.inc/probo/pkg/agent"
)
type (
dnsRecordsParams struct {
Domain string `json:"domain" jsonschema:"The domain to query DNS records for (e.g. example.com)"`
}
dnsRecordsResult struct {
A []string `json:"a_records,omitempty"`
AAAA []string `json:"aaaa_records,omitempty"`
MX []string `json:"mx_records,omitempty"`
CNAME []string `json:"cname_records,omitempty"`
TXT []string `json:"txt_records,omitempty"`
NS []string `json:"ns_records,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
queryOption func(*dns.MsgHeader)
)
func CheckDNSRecordsTool() agent.Tool {
return agent.FunctionTool(
"check_dns_records",
"Query DNS records for a domain (A, AAAA, MX, CNAME, TXT, NS). Reveals hosting provider, email provider, and additional security signals.",
func(ctx context.Context, p dnsRecordsParams) (agent.ToolResult, error) {
fqdn := p.Domain
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
hdr := dns.Header{Name: fqdn, Class: dns.ClassINET}
client := dns.NewClient()
var result dnsRecordsResult
var errs []string
// A records.
if answers, err := queryDNS(ctx, client, &dns.A{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("A query failed: %s", err))
} else {
for _, rr := range answers {
if a, ok := rr.(*dns.A); ok {
result.A = append(result.A, a.A.String())
}
}
}
// AAAA records.
if answers, err := queryDNS(ctx, client, &dns.AAAA{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("AAAA query failed: %s", err))
} else {
for _, rr := range answers {
if aaaa, ok := rr.(*dns.AAAA); ok {
result.AAAA = append(result.AAAA, aaaa.AAAA.String())
}
}
}
// MX records.
if answers, err := queryDNS(ctx, client, &dns.MX{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("MX query failed: %s", err))
} else {
for _, rr := range answers {
if mx, ok := rr.(*dns.MX); ok {
result.MX = append(result.MX, fmt.Sprintf("%d %s", mx.Preference, strings.TrimSuffix(mx.Mx, ".")))
}
}
}
// CNAME records.
if answers, err := queryDNS(ctx, client, &dns.CNAME{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("CNAME query failed: %s", err))
} else {
for _, rr := range answers {
if cname, ok := rr.(*dns.CNAME); ok {
result.CNAME = append(result.CNAME, strings.TrimSuffix(cname.Target, "."))
}
}
}
// TXT records.
if answers, err := queryDNS(ctx, client, &dns.TXT{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("TXT query failed: %s", err))
} else {
for _, rr := range answers {
if txt, ok := rr.(*dns.TXT); ok {
result.TXT = append(result.TXT, strings.Join(txt.Txt, ""))
}
}
}
// NS records.
if answers, err := queryDNS(ctx, client, &dns.NS{Hdr: hdr}); err != nil {
errs = append(errs, fmt.Sprintf("NS query failed: %s", err))
} else {
for _, rr := range answers {
if ns, ok := rr.(*dns.NS); ok {
result.NS = append(result.NS, strings.TrimSuffix(ns.Ns, "."))
}
}
}
if len(errs) > 0 {
result.ErrorDetail = strings.Join(errs, "; ")
}
return agent.ResultJSON(result), nil
},
)
}
func withDNSSEC() queryOption {
return func(h *dns.MsgHeader) {
h.UDPSize = 4096
h.Security = true
}
}
func queryDNS(ctx context.Context, client *dns.Client, question dns.RR, opts ...queryOption) ([]dns.RR, error) {
msg := &dns.Msg{
MsgHeader: dns.MsgHeader{
ID: dns.ID(),
RecursionDesired: true,
},
}
for _, opt := range opts {
opt(&msg.MsgHeader)
}
msg.Question = []dns.RR{question}
resp, _, err := client.Exchange(ctx, msg, "udp", defaultResolverAddr)
if err == nil && resp.Truncated {
resp, _, err = client.Exchange(ctx, msg, "tcp", defaultResolverAddr)
}
if err != nil {
return nil, err
}
if resp.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("cannot execute DNS query: %s", dns.RcodeToString[resp.Rcode])
}
return resp.Answer, nil
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"strings"
"codeberg.org/miekg/dns"
"go.probo.inc/probo/pkg/agent"
)
type (
dnssecParams struct {
Domain string `json:"domain" jsonschema:"The domain to check DNSSEC for (e.g. example.com)"`
}
dnssecResult struct {
Enabled bool `json:"enabled"`
HasDNSKEY bool `json:"has_dnskey"`
KeyCount int `json:"key_count,omitempty"`
Details string `json:"details,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func CheckDNSSECTool() agent.Tool {
return agent.FunctionTool(
"check_dnssec",
"Check if DNSSEC is enabled for a domain by looking up DNSKEY records.",
func(ctx context.Context, p dnssecParams) (agent.ToolResult, error) {
fqdn := p.Domain
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
client := dns.NewClient()
answers, err := queryDNS(
ctx,
client,
&dns.DNSKEY{
Hdr: dns.Header{
Name: fqdn,
Class: dns.ClassINET,
},
},
withDNSSEC(),
)
if err != nil {
return agent.ResultJSON(dnssecResult{
Enabled: false,
ErrorDetail: fmt.Sprintf("cannot query DNSKEY records: %s", err),
}), nil
}
var keyCount int
var keyDetails []string
for _, answer := range answers {
if key, ok := answer.(*dns.DNSKEY); ok {
keyCount++
flags := "ZSK"
// SEP (Secure Entry Point) flag is bit 15 (value 1)
if key.Flags&0x0001 != 0 {
flags = "KSK"
}
keyDetails = append(
keyDetails,
fmt.Sprintf("%s (algorithm=%d, flags=%d)", flags, key.Algorithm, key.Flags),
)
}
}
hasDNSKEY := keyCount > 0
result := dnssecResult{
Enabled: hasDNSKEY,
HasDNSKEY: hasDNSKEY,
KeyCount: keyCount,
Details: strings.Join(keyDetails, "; "),
}
if !hasDNSKEY {
result.Details = "no DNSKEY records found"
}
return agent.ResultJSON(result), nil
},
)
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
)
type (
headersParams struct {
URL string `json:"url" jsonschema:"The URL to check security headers for (e.g. https://example.com)"`
}
headerCheck struct {
Present bool `json:"present"`
Value string `json:"value,omitempty"`
}
headersResult struct {
HSTS headerCheck `json:"strict_transport_security"`
CSP headerCheck `json:"content_security_policy"`
XFrameOptions headerCheck `json:"x_frame_options"`
XContentTypeOptions headerCheck `json:"x_content_type_options"`
ReferrerPolicy headerCheck `json:"referrer_policy"`
PermissionsPolicy headerCheck `json:"permissions_policy"`
CrossOriginOpenerPolicy headerCheck `json:"cross_origin_opener_policy"`
CrossOriginEmbedderPolicy headerCheck `json:"cross_origin_embedder_policy"`
CrossOriginResourcePolicy headerCheck `json:"cross_origin_resource_policy"`
RedirectsToHTTPS bool `json:"redirects_to_https"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func checkHeader(h http.Header, name string) headerCheck {
v := h.Get(name)
return headerCheck{
Present: v != "",
Value: v,
}
}
func headersFromResponse(resp *http.Response) headersResult {
return headersResult{
HSTS: checkHeader(resp.Header, "Strict-Transport-Security"),
CSP: checkHeader(resp.Header, "Content-Security-Policy"),
XFrameOptions: checkHeader(resp.Header, "X-Frame-Options"),
XContentTypeOptions: checkHeader(resp.Header, "X-Content-Type-Options"),
ReferrerPolicy: checkHeader(resp.Header, "Referrer-Policy"),
PermissionsPolicy: checkHeader(resp.Header, "Permissions-Policy"),
CrossOriginOpenerPolicy: checkHeader(resp.Header, "Cross-Origin-Opener-Policy"),
CrossOriginEmbedderPolicy: checkHeader(resp.Header, "Cross-Origin-Embedder-Policy"),
CrossOriginResourcePolicy: checkHeader(resp.Header, "Cross-Origin-Resource-Policy"),
}
}
func CheckSecurityHeadersTool() agent.Tool {
return agent.FunctionTool(
"check_security_headers",
"Check security-related HTTP headers for a URL (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-*-Policy). Also checks if HTTP redirects to HTTPS.",
func(ctx context.Context, p headersParams) (agent.ToolResult, error) {
if err := netcheck.ValidatePublicURL(p.URL); err != nil {
return agent.ResultJSON(headersResult{
ErrorDetail: fmt.Sprintf("URL not allowed: %s", err),
}), nil
}
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
// First check the HTTP version to detect HTTP→HTTPS redirect.
redirectsToHTTPS := false
httpURL := p.URL
if after, ok := strings.CutPrefix(httpURL, "https://"); ok {
httpURL = "http://" + after
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil)
if err == nil {
httpResp, err := client.Do(httpReq)
if err == nil {
_ = httpResp.Body.Close()
if httpResp.StatusCode >= 300 && httpResp.StatusCode < 400 {
loc := httpResp.Header.Get("Location")
if strings.HasPrefix(loc, "https://") {
redirectsToHTTPS = true
}
}
}
}
// Now check the HTTPS version for the actual security headers.
httpsURL := p.URL
if after, ok := strings.CutPrefix(httpsURL, "http://"); ok {
httpsURL = "https://" + after
}
followClient := &http.Client{Timeout: 10 * time.Second}
httpsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, httpsURL, nil)
if err != nil {
return agent.ResultJSON(headersResult{
ErrorDetail: fmt.Sprintf("cannot create request for %s: %s", httpsURL, err),
}), nil
}
resp, err := followClient.Do(httpsReq)
if err != nil {
return agent.ResultJSON(headersResult{
ErrorDetail: fmt.Sprintf("cannot fetch %s: %s", httpsURL, err),
}), nil
}
defer func() { _ = resp.Body.Close() }()
result := headersFromResponse(resp)
result.RedirectsToHTTPS = redirectsToHTTPS
return agent.ResultJSON(result), nil
},
)
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCheckHeader(t *testing.T) {
t.Parallel()
t.Run(
"present header returns present true and value",
func(t *testing.T) {
t.Parallel()
h := http.Header{}
h.Set("X-Frame-Options", "DENY")
result := checkHeader(h, "X-Frame-Options")
assert.True(t, result.Present)
assert.Equal(t, "DENY", result.Value)
},
)
t.Run(
"missing header returns present false",
func(t *testing.T) {
t.Parallel()
h := http.Header{}
result := checkHeader(h, "X-Frame-Options")
assert.False(t, result.Present)
assert.Equal(t, "", result.Value)
},
)
t.Run(
"empty header map returns present false",
func(t *testing.T) {
t.Parallel()
result := checkHeader(http.Header{}, "Strict-Transport-Security")
assert.False(t, result.Present)
assert.Equal(t, "", result.Value)
},
)
t.Run(
"header lookup is case insensitive",
func(t *testing.T) {
t.Parallel()
h := http.Header{}
h.Set("content-security-policy", "default-src 'self'")
result := checkHeader(h, "Content-Security-Policy")
assert.True(t, result.Present)
assert.Equal(t, "default-src 'self'", result.Value)
},
)
}
func TestHeadersFromResponse(t *testing.T) {
t.Parallel()
t.Run(
"all security headers present",
func(t *testing.T) {
t.Parallel()
resp := &http.Response{
Header: http.Header{
"Strict-Transport-Security": {"max-age=31536000; includeSubDomains"},
"Content-Security-Policy": {"default-src 'self'"},
"X-Frame-Options": {"DENY"},
"X-Content-Type-Options": {"nosniff"},
"Referrer-Policy": {"strict-origin-when-cross-origin"},
"Permissions-Policy": {"camera=(), microphone=()"},
"Cross-Origin-Opener-Policy": {"same-origin"},
"Cross-Origin-Embedder-Policy": {"require-corp"},
"Cross-Origin-Resource-Policy": {"same-origin"},
},
}
result := headersFromResponse(resp)
assert.True(t, result.HSTS.Present)
assert.Equal(t, "max-age=31536000; includeSubDomains", result.HSTS.Value)
assert.True(t, result.CSP.Present)
assert.Equal(t, "default-src 'self'", result.CSP.Value)
assert.True(t, result.XFrameOptions.Present)
assert.Equal(t, "DENY", result.XFrameOptions.Value)
assert.True(t, result.XContentTypeOptions.Present)
assert.Equal(t, "nosniff", result.XContentTypeOptions.Value)
assert.True(t, result.ReferrerPolicy.Present)
assert.Equal(t, "strict-origin-when-cross-origin", result.ReferrerPolicy.Value)
assert.True(t, result.PermissionsPolicy.Present)
assert.Equal(t, "camera=(), microphone=()", result.PermissionsPolicy.Value)
assert.True(t, result.CrossOriginOpenerPolicy.Present)
assert.Equal(t, "same-origin", result.CrossOriginOpenerPolicy.Value)
assert.True(t, result.CrossOriginEmbedderPolicy.Present)
assert.Equal(t, "require-corp", result.CrossOriginEmbedderPolicy.Value)
assert.True(t, result.CrossOriginResourcePolicy.Present)
assert.Equal(t, "same-origin", result.CrossOriginResourcePolicy.Value)
},
)
t.Run(
"no security headers present",
func(t *testing.T) {
t.Parallel()
resp := &http.Response{
Header: http.Header{},
}
result := headersFromResponse(resp)
assert.False(t, result.HSTS.Present)
assert.False(t, result.CSP.Present)
assert.False(t, result.XFrameOptions.Present)
assert.False(t, result.XContentTypeOptions.Present)
assert.False(t, result.ReferrerPolicy.Present)
assert.False(t, result.PermissionsPolicy.Present)
assert.False(t, result.CrossOriginOpenerPolicy.Present)
assert.False(t, result.CrossOriginEmbedderPolicy.Present)
assert.False(t, result.CrossOriginResourcePolicy.Present)
assert.False(t, result.RedirectsToHTTPS)
},
)
t.Run(
"partial headers present",
func(t *testing.T) {
t.Parallel()
resp := &http.Response{
Header: http.Header{
"Strict-Transport-Security": {"max-age=86400"},
"X-Content-Type-Options": {"nosniff"},
},
}
result := headersFromResponse(resp)
assert.True(t, result.HSTS.Present)
assert.Equal(t, "max-age=86400", result.HSTS.Value)
assert.False(t, result.CSP.Present)
assert.False(t, result.XFrameOptions.Present)
assert.True(t, result.XContentTypeOptions.Present)
assert.Equal(t, "nosniff", result.XContentTypeOptions.Value)
assert.False(t, result.ReferrerPolicy.Present)
assert.False(t, result.PermissionsPolicy.Present)
assert.False(t, result.CrossOriginOpenerPolicy.Present)
assert.False(t, result.CrossOriginEmbedderPolicy.Present)
assert.False(t, result.CrossOriginResourcePolicy.Present)
},
)
t.Run(
"does not set redirects to https",
func(t *testing.T) {
t.Parallel()
resp := &http.Response{
Header: http.Header{
"Strict-Transport-Security": {"max-age=31536000"},
},
}
result := headersFromResponse(resp)
assert.False(t, result.RedirectsToHTTPS)
},
)
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"go.probo.inc/probo/pkg/agent"
)
type (
hibpParams struct {
Domain string `json:"domain" jsonschema:"The domain to check for known data breaches (e.g. example.com)"`
}
breach struct {
Name string `json:"Name"`
BreachDate string `json:"BreachDate"`
PwnCount int `json:"PwnCount"`
DataClasses []string `json:"DataClasses"`
Description string `json:"Description"`
IsVerified bool `json:"IsVerified"`
IsSensitive bool `json:"IsSensitive"`
IsRetired bool `json:"IsRetired"`
IsSpamList bool `json:"IsSpamList"`
IsMalware bool `json:"IsMalware"`
IsSubscFree bool `json:"IsSubscriptionFree"`
IsFabricated bool `json:"IsFabricated"`
}
hibpResult struct {
Found bool `json:"found"`
Count int `json:"count"`
Breaches []breach `json:"breaches,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func CheckBreachesTool() agent.Tool {
return agent.FunctionTool(
"check_breaches",
"Check if a domain has been involved in known data breaches using the Have I Been Pwned API.",
func(ctx context.Context, p hibpParams) (agent.ToolResult, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://haveibeenpwned.com/api/v3/breaches?domain="+url.QueryEscape(p.Domain),
nil,
)
if err != nil {
return agent.ResultJSON(hibpResult{
ErrorDetail: fmt.Sprintf("cannot create request: %s", err),
}), nil
}
req.Header.Set("User-Agent", "Probo-Vendor-Assessment")
resp, err := client.Do(req)
if err != nil {
return agent.ResultJSON(hibpResult{
ErrorDetail: fmt.Sprintf("cannot fetch breaches: %s", err),
}), nil
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return agent.ResultJSON(hibpResult{
ErrorDetail: fmt.Sprintf("cannot read response: %s", err),
}), nil
}
if resp.StatusCode == http.StatusNotFound {
return agent.ResultJSON(hibpResult{Found: false, Count: 0}), nil
}
if resp.StatusCode != http.StatusOK {
return agent.ResultJSON(hibpResult{
ErrorDetail: fmt.Sprintf("HIBP API returned status %d", resp.StatusCode),
}), nil
}
var breaches []breach
if err := json.Unmarshal(body, &breaches); err != nil {
return agent.ResultJSON(hibpResult{
ErrorDetail: fmt.Sprintf("cannot parse response: %s", err),
}), nil
}
return agent.ResultJSON(hibpResult{
Found: len(breaches) > 0,
Count: len(breaches),
Breaches: breaches,
}), nil
},
)
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"os"
"go.probo.inc/probo/pkg/agent"
)
var defaultResolverAddr = resolverAddr()
func resolverAddr() string {
if addr := os.Getenv("DNS_RESOLVER_ADDR"); addr != "" {
return addr
}
return "8.8.8.8:53"
}
// Toolset provides all security assessment tools.
type Toolset struct{}
// NewToolset creates a security toolset.
func NewToolset() *Toolset { return &Toolset{} }
func (t *Toolset) Tools() []agent.Tool {
return []agent.Tool{
CheckSSLCertificateTool(),
CheckSecurityHeadersTool(),
CheckDMARCTool(),
CheckSPFTool(),
CheckBreachesTool(),
CheckDNSSECTool(),
AnalyzeCSPTool(),
CheckCORSTool(),
CheckWhoisTool(),
CheckDNSRecordsTool(),
}
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"fmt"
"strings"
"codeberg.org/miekg/dns"
"go.probo.inc/probo/pkg/agent"
)
type (
spfParams struct {
Domain string `json:"domain" jsonschema:"The domain to check SPF record for (e.g. example.com)"`
}
spfResult struct {
Found bool `json:"found"`
RawRecord string `json:"raw_record,omitempty"`
Policy string `json:"policy,omitempty"`
Mechanisms string `json:"mechanisms,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func parseSPFPolicy(record string) string {
for part := range strings.FieldsSeq(strings.ToLower(record)) {
switch part {
case "-all":
return "fail"
case "~all":
return "softfail"
case "?all":
return "neutral"
case "+all":
return "pass"
}
}
return ""
}
func CheckSPFTool() agent.Tool {
return agent.FunctionTool(
"check_spf",
"Check the SPF (Sender Policy Framework) DNS record for a domain, returning the raw record and its policy qualifier.",
func(ctx context.Context, p spfParams) (agent.ToolResult, error) {
fqdn := p.Domain
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
client := dns.NewClient()
answers, err := queryDNS(
ctx,
client,
&dns.TXT{
Hdr: dns.Header{
Name: fqdn,
Class: dns.ClassINET,
},
},
)
if err != nil {
return agent.ResultJSON(spfResult{
Found: false,
ErrorDetail: fmt.Sprintf("cannot lookup SPF record: %s", err),
}), nil
}
var spfRecords []string
for _, answer := range answers {
txt, ok := answer.(*dns.TXT)
if !ok {
continue
}
record := strings.Join(txt.Txt, "")
if !strings.HasPrefix(strings.ToLower(record), "v=spf1") {
continue
}
spfRecords = append(spfRecords, record)
}
if len(spfRecords) > 1 {
return agent.ResultJSON(spfResult{
Found: true,
ErrorDetail: fmt.Sprintf("multiple SPF records found (%d); this is an invalid configuration per RFC 7208", len(spfRecords)),
}), nil
}
if len(spfRecords) == 1 {
record := spfRecords[0]
return agent.ResultJSON(spfResult{
Found: true,
RawRecord: record,
Policy: parseSPFPolicy(record),
Mechanisms: record,
}), nil
}
return agent.ResultJSON(spfResult{Found: false}), nil
},
)
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseSPFPolicy(t *testing.T) {
t.Parallel()
t.Run(
"detects hard fail",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "fail", parseSPFPolicy("v=spf1 include:_spf.google.com -all"))
},
)
t.Run(
"detects soft fail",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "softfail", parseSPFPolicy("v=spf1 include:spf.example.com ~all"))
},
)
t.Run(
"detects neutral",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "neutral", parseSPFPolicy("v=spf1 ?all"))
},
)
t.Run(
"detects pass all",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "pass", parseSPFPolicy("v=spf1 +all"))
},
)
t.Run(
"returns empty for no all qualifier",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "", parseSPFPolicy("v=spf1 include:_spf.google.com"))
},
)
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"time"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
)
type (
sslParams struct {
Domain string `json:"domain" jsonschema:"The domain to check the SSL certificate for (e.g. example.com)"`
}
sslResult struct {
Valid bool `json:"valid"`
Issuer string `json:"issuer"`
Subject string `json:"subject"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
DaysLeft int `json:"days_left"`
Protocol string `json:"protocol"`
DNSNames []string `json:"dns_names"`
IsExpired bool `json:"is_expired"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func protocolName(version uint16) string {
switch version {
case tls.VersionTLS10:
return "TLS 1.0"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS13:
return "TLS 1.3"
default:
return fmt.Sprintf("unknown (0x%04x)", version)
}
}
func CheckSSLCertificateTool() agent.Tool {
return agent.FunctionTool(
"check_ssl_certificate",
"Check the SSL/TLS certificate for a domain, returning issuer, expiry, protocol version, and validity.",
func(ctx context.Context, p sslParams) (agent.ToolResult, error) {
if err := netcheck.ValidatePublicDomain(p.Domain); err != nil {
return agent.ResultJSON(sslResult{
Valid: false,
ErrorDetail: fmt.Sprintf("domain not allowed: %s", err),
}), nil
}
// This is a certificate inspection tool: we intentionally
// connect to servers whose certificates may be expired,
// self-signed, or otherwise invalid, because the whole
// point is to report back on the certificate state.
// InsecureSkipVerify disables the handshake's built-in
// verification; we then perform the verification manually
// below (x509.Verify) and surface the result in Valid.
// This pattern is safe here because we never send any
// credentials or confidential data over the connection.
dialer := &tls.Dialer{
NetDialer: &net.Dialer{Timeout: 10 * time.Second},
Config: &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // cert inspector; verification happens manually below
ServerName: p.Domain,
},
}
netConn, err := dialer.DialContext(ctx, "tcp", p.Domain+":443")
var conn *tls.Conn
if netConn != nil {
conn = netConn.(*tls.Conn)
}
if err != nil {
return agent.ResultJSON(sslResult{
Valid: false,
ErrorDetail: err.Error(),
}), nil
}
defer func() { _ = conn.Close() }()
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return agent.ResultJSON(sslResult{
Valid: false,
ErrorDetail: "no peer certificates",
}), nil
}
cert := state.PeerCertificates[0]
now := time.Now()
// Manually verify the certificate since we connected
// with InsecureSkipVerify to retrieve cert details
// even for expired/invalid certificates.
valid := now.Before(cert.NotAfter) && now.After(cert.NotBefore)
if valid {
opts := x509.VerifyOptions{
DNSName: p.Domain,
Intermediates: x509.NewCertPool(),
}
for _, ic := range state.PeerCertificates[1:] {
opts.Intermediates.AddCert(ic)
}
if _, err := cert.Verify(opts); err != nil {
valid = false
}
}
result := sslResult{
Valid: valid,
Issuer: cert.Issuer.String(),
Subject: cert.Subject.String(),
NotBefore: cert.NotBefore.Format(time.RFC3339),
NotAfter: cert.NotAfter.Format(time.RFC3339),
DaysLeft: int(time.Until(cert.NotAfter).Hours() / 24),
Protocol: protocolName(state.Version),
DNSNames: cert.DNSNames,
IsExpired: now.After(cert.NotAfter),
}
return agent.ResultJSON(result), nil
},
)
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"crypto/tls"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProtocolName(t *testing.T) {
t.Parallel()
t.Run(
"known protocols",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "TLS 1.0", protocolName(tls.VersionTLS10))
assert.Equal(t, "TLS 1.1", protocolName(tls.VersionTLS11))
assert.Equal(t, "TLS 1.2", protocolName(tls.VersionTLS12))
assert.Equal(t, "TLS 1.3", protocolName(tls.VersionTLS13))
},
)
t.Run(
"unknown protocol",
func(t *testing.T) {
t.Parallel()
result := protocolName(0x9999)
assert.Contains(t, result, "unknown")
},
)
}

View File

@@ -0,0 +1,253 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"time"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/internal/netcheck"
)
type (
whoisParams struct {
Domain string `json:"domain" jsonschema:"The domain to perform a WHOIS lookup on (e.g. example.com)"`
}
whoisResult struct {
Registrar string `json:"registrar,omitempty"`
CreationDate string `json:"creation_date,omitempty"`
ExpiryDate string `json:"expiry_date,omitempty"`
UpdatedDate string `json:"updated_date,omitempty"`
RegistrantOrg string `json:"registrant_org,omitempty"`
RegistrantCC string `json:"registrant_country,omitempty"`
NameServers []string `json:"name_servers,omitempty"`
DomainAge string `json:"domain_age,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
)
func CheckWhoisTool() agent.Tool {
return agent.FunctionTool(
"check_whois",
"Perform a WHOIS lookup on a domain to retrieve registration details including registrar, creation date, expiry date, registrant organization, and name servers.",
func(ctx context.Context, p whoisParams) (agent.ToolResult, error) {
if err := netcheck.ValidatePublicDomain(p.Domain); err != nil {
return agent.ResultJSON(whoisResult{
ErrorDetail: fmt.Sprintf("domain not allowed: %s", err),
}), nil
}
// Step 1: query IANA to find the referral WHOIS server.
referral, err := queryWhois(ctx, "whois.iana.org:43", p.Domain)
if err != nil {
return agent.ResultJSON(whoisResult{
ErrorDetail: fmt.Sprintf("cannot query IANA WHOIS: %s", err),
}), nil
}
whoisServer := parseWhoisField(referral, "refer")
if whoisServer == "" {
whoisServer = parseWhoisField(referral, "whois")
}
if whoisServer == "" {
// Try common TLD WHOIS servers as fallback.
parts := strings.Split(p.Domain, ".")
tld := parts[len(parts)-1]
whoisServer = "whois." + tld + ".com"
}
if !strings.Contains(whoisServer, ":") {
whoisServer = whoisServer + ":43"
}
// Validate the referral WHOIS server resolves to a public IP
// to prevent SSRF via crafted IANA responses.
whoisHost, _, _ := net.SplitHostPort(whoisServer)
if whoisHost == "" {
whoisHost = whoisServer
}
if err := netcheck.ValidatePublicDomain(whoisHost); err != nil {
return agent.ResultJSON(whoisResult{
ErrorDetail: fmt.Sprintf("WHOIS referral server not allowed: %s", err),
}), nil
}
// Step 2: query the registrar's WHOIS server.
raw, err := queryWhois(ctx, whoisServer, p.Domain)
if err != nil {
return agent.ResultJSON(whoisResult{
ErrorDetail: fmt.Sprintf("cannot query WHOIS server %s: %s", whoisServer, err),
}), nil
}
result := parseWhoisResponse(raw)
// Compute domain age from creation date.
if result.CreationDate != "" {
for _, layout := range []string{
"2006-01-02T15:04:05Z",
"2006-01-02",
"02-Jan-2006",
"2006-01-02 15:04:05",
time.RFC3339,
} {
if t, err := time.Parse(layout, result.CreationDate); err == nil {
age := time.Since(t)
years := int(age.Hours() / 24 / 365)
months := int(age.Hours()/24/30) % 12
result.DomainAge = fmt.Sprintf("%d years, %d months", years, months)
break
}
}
}
return agent.ResultJSON(result), nil
},
)
}
func queryWhois(ctx context.Context, server, domain string) (string, error) {
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", server)
if err != nil {
return "", fmt.Errorf("cannot connect to %s: %w", server, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
_, err = fmt.Fprintf(conn, "%s\r\n", domain)
if err != nil {
return "", fmt.Errorf("cannot write to %s: %w", server, err)
}
var sb strings.Builder
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
sb.WriteString(scanner.Text())
sb.WriteString("\n")
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("cannot read from %s: %w", server, err)
}
return sb.String(), nil
}
func parseWhoisField(raw, field string) string {
field = strings.ToLower(field)
for line := range strings.SplitSeq(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(k)) == field {
return strings.TrimSpace(v)
}
}
return ""
}
var (
whoisFieldMap = map[string]string{
"registrar": "registrar",
"registrar name": "registrar",
"sponsoring registrar": "registrar",
"creation date": "creation_date",
"created": "creation_date",
"created on": "creation_date",
"registration date": "creation_date",
"domain name commencement date": "creation_date",
"registry expiry date": "expiry_date",
"registrar registration expiration date": "expiry_date",
"expiry date": "expiry_date",
"paid-till": "expiry_date",
"updated date": "updated_date",
"last updated": "updated_date",
"last modified": "updated_date",
"registrant organization": "registrant_org",
"registrant organisation": "registrant_org",
"org": "registrant_org",
"registrant country": "registrant_cc",
"registrant country/economy": "registrant_cc",
"name server": "name_server",
"nserver": "name_server",
}
)
func parseWhoisResponse(raw string) whoisResult {
var result whoisResult
for line := range strings.SplitSeq(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
key := strings.ToLower(strings.TrimSpace(k))
val := strings.TrimSpace(v)
if val == "" {
continue
}
field, ok := whoisFieldMap[key]
if !ok {
continue
}
switch field {
case "registrar":
if result.Registrar == "" {
result.Registrar = val
}
case "creation_date":
if result.CreationDate == "" {
result.CreationDate = val
}
case "expiry_date":
if result.ExpiryDate == "" {
result.ExpiryDate = val
}
case "updated_date":
if result.UpdatedDate == "" {
result.UpdatedDate = val
}
case "registrant_org":
if result.RegistrantOrg == "" {
result.RegistrantOrg = val
}
case "registrant_cc":
if result.RegistrantCC == "" {
result.RegistrantCC = val
}
case "name_server":
result.NameServers = append(result.NameServers, strings.ToLower(val))
}
}
return result
}

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package security
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseWhoisField(t *testing.T) {
t.Parallel()
t.Run(
"extracts known field",
func(t *testing.T) {
t.Parallel()
raw := "refer: whois.verisign-grs.com\nstatus: ACTIVE\n"
assert.Equal(t, "whois.verisign-grs.com", parseWhoisField(raw, "refer"))
},
)
t.Run(
"returns first match",
func(t *testing.T) {
t.Parallel()
raw := "refer: first.example.com\nrefer: second.example.com\n"
assert.Equal(t, "first.example.com", parseWhoisField(raw, "refer"))
},
)
t.Run(
"handles missing field",
func(t *testing.T) {
t.Parallel()
raw := "status: ACTIVE\ncreated: 2020-01-01\n"
assert.Equal(t, "", parseWhoisField(raw, "refer"))
},
)
t.Run(
"handles empty input",
func(t *testing.T) {
t.Parallel()
assert.Equal(t, "", parseWhoisField("", "refer"))
},
)
t.Run(
"case insensitive field matching",
func(t *testing.T) {
t.Parallel()
raw := "Refer: whois.example.com\n"
assert.Equal(t, "whois.example.com", parseWhoisField(raw, "refer"))
},
)
t.Run(
"case insensitive field name argument",
func(t *testing.T) {
t.Parallel()
raw := "refer: whois.example.com\n"
assert.Equal(t, "whois.example.com", parseWhoisField(raw, "REFER"))
},
)
t.Run(
"skips comment lines",
func(t *testing.T) {
t.Parallel()
raw := "% This is a comment\n# Another comment\nrefer: whois.example.com\n"
assert.Equal(t, "whois.example.com", parseWhoisField(raw, "refer"))
},
)
t.Run(
"skips lines without colon",
func(t *testing.T) {
t.Parallel()
raw := "no colon here\nrefer: whois.example.com\n"
assert.Equal(t, "whois.example.com", parseWhoisField(raw, "refer"))
},
)
t.Run(
"trims whitespace around key and value",
func(t *testing.T) {
t.Parallel()
raw := " refer : whois.example.com \n"
assert.Equal(t, "whois.example.com", parseWhoisField(raw, "refer"))
},
)
}
func TestParseWhoisResponse(t *testing.T) {
t.Parallel()
t.Run(
"parses full realistic response",
func(t *testing.T) {
t.Parallel()
raw := `Domain Name: EXAMPLE.COM
Registrar: Example Registrar, Inc.
Sponsoring Registrar: Another Registrar
Creation Date: 2005-03-15T00:00:00Z
Registry Expiry Date: 2030-03-15T00:00:00Z
Updated Date: 2024-01-10T12:00:00Z
Registrant Organization: Example Corp
Registrant Country: US
Name Server: ns1.example.com
Name Server: ns2.example.com
`
result := parseWhoisResponse(raw)
assert.Equal(t, "Example Registrar, Inc.", result.Registrar)
assert.Equal(t, "2005-03-15T00:00:00Z", result.CreationDate)
assert.Equal(t, "2030-03-15T00:00:00Z", result.ExpiryDate)
assert.Equal(t, "2024-01-10T12:00:00Z", result.UpdatedDate)
assert.Equal(t, "Example Corp", result.RegistrantOrg)
assert.Equal(t, "US", result.RegistrantCC)
require.Len(t, result.NameServers, 2)
assert.Equal(t, "ns1.example.com", result.NameServers[0])
assert.Equal(t, "ns2.example.com", result.NameServers[1])
},
)
t.Run(
"uses first value for duplicate fields",
func(t *testing.T) {
t.Parallel()
raw := `Registrar: First Registrar
Registrar: Second Registrar
Creation Date: 2005-01-01
Creation Date: 2010-01-01
`
result := parseWhoisResponse(raw)
assert.Equal(t, "First Registrar", result.Registrar)
assert.Equal(t, "2005-01-01", result.CreationDate)
},
)
t.Run(
"accumulates all name servers",
func(t *testing.T) {
t.Parallel()
raw := `Name Server: NS1.EXAMPLE.COM
Name Server: NS2.EXAMPLE.COM
Name Server: NS3.EXAMPLE.COM
`
result := parseWhoisResponse(raw)
require.Len(t, result.NameServers, 3)
assert.Equal(t, "ns1.example.com", result.NameServers[0])
assert.Equal(t, "ns2.example.com", result.NameServers[1])
assert.Equal(t, "ns3.example.com", result.NameServers[2])
},
)
t.Run(
"maps alternative field names",
func(t *testing.T) {
t.Parallel()
raw := `Registrar Name: Alt Registrar
Created: 2010-06-01
Paid-Till: 2030-06-01
Last Modified: 2024-06-01
Registrant Organisation: Alt Org
nserver: ns1.alt.com
`
result := parseWhoisResponse(raw)
assert.Equal(t, "Alt Registrar", result.Registrar)
assert.Equal(t, "2010-06-01", result.CreationDate)
assert.Equal(t, "2030-06-01", result.ExpiryDate)
assert.Equal(t, "2024-06-01", result.UpdatedDate)
assert.Equal(t, "Alt Org", result.RegistrantOrg)
require.Len(t, result.NameServers, 1)
assert.Equal(t, "ns1.alt.com", result.NameServers[0])
},
)
t.Run(
"empty input returns zero value",
func(t *testing.T) {
t.Parallel()
result := parseWhoisResponse("")
assert.Equal(t, "", result.Registrar)
assert.Equal(t, "", result.CreationDate)
assert.Equal(t, "", result.ExpiryDate)
assert.Equal(t, "", result.UpdatedDate)
assert.Equal(t, "", result.RegistrantOrg)
assert.Equal(t, "", result.RegistrantCC)
assert.Nil(t, result.NameServers)
},
)
t.Run(
"skips comment and blank lines",
func(t *testing.T) {
t.Parallel()
raw := `% WHOIS server comment
# Another comment
Registrar: Good Registrar
Creation Date: 2020-01-01
`
result := parseWhoisResponse(raw)
assert.Equal(t, "Good Registrar", result.Registrar)
assert.Equal(t, "2020-01-01", result.CreationDate)
},
)
t.Run(
"skips lines with empty values",
func(t *testing.T) {
t.Parallel()
raw := `Registrar:
Registrar: Actual Registrar
`
result := parseWhoisResponse(raw)
assert.Equal(t, "Actual Registrar", result.Registrar)
},
)
t.Run(
"handles extra whitespace around keys and values",
func(t *testing.T) {
t.Parallel()
raw := " Registrar : Spaced Registrar \n Creation Date : 2023-05-01 \n"
result := parseWhoisResponse(raw)
assert.Equal(t, "Spaced Registrar", result.Registrar)
assert.Equal(t, "2023-05-01", result.CreationDate)
},
)
}