Add vendor assessment agent
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
122
pkg/agent/tools/security/cors.go
Normal file
122
pkg/agent/tools/security/cors.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
71
pkg/agent/tools/security/cors_test.go
Normal file
71
pkg/agent/tools/security/cors_test.go
Normal 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])
|
||||
},
|
||||
)
|
||||
}
|
||||
140
pkg/agent/tools/security/csp.go
Normal file
140
pkg/agent/tools/security/csp.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
81
pkg/agent/tools/security/csp_test.go
Normal file
81
pkg/agent/tools/security/csp_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
106
pkg/agent/tools/security/dmarc.go
Normal file
106
pkg/agent/tools/security/dmarc.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
65
pkg/agent/tools/security/dmarc_test.go
Normal file
65
pkg/agent/tools/security/dmarc_test.go
Normal 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"))
|
||||
},
|
||||
)
|
||||
}
|
||||
166
pkg/agent/tools/security/dns_records.go
Normal file
166
pkg/agent/tools/security/dns_records.go
Normal 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
|
||||
}
|
||||
101
pkg/agent/tools/security/dnssec.go
Normal file
101
pkg/agent/tools/security/dnssec.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
141
pkg/agent/tools/security/headers.go
Normal file
141
pkg/agent/tools/security/headers.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
197
pkg/agent/tools/security/headers_test.go
Normal file
197
pkg/agent/tools/security/headers_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
117
pkg/agent/tools/security/hibp.go
Normal file
117
pkg/agent/tools/security/hibp.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
51
pkg/agent/tools/security/security.go
Normal file
51
pkg/agent/tools/security/security.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
120
pkg/agent/tools/security/spf.go
Normal file
120
pkg/agent/tools/security/spf.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
70
pkg/agent/tools/security/spf_test.go
Normal file
70
pkg/agent/tools/security/spf_test.go
Normal 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"))
|
||||
},
|
||||
)
|
||||
}
|
||||
147
pkg/agent/tools/security/ssl.go
Normal file
147
pkg/agent/tools/security/ssl.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
48
pkg/agent/tools/security/ssl_test.go
Normal file
48
pkg/agent/tools/security/ssl_test.go
Normal 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")
|
||||
},
|
||||
)
|
||||
}
|
||||
253
pkg/agent/tools/security/whois.go
Normal file
253
pkg/agent/tools/security/whois.go
Normal 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
|
||||
}
|
||||
271
pkg/agent/tools/security/whois_test.go
Normal file
271
pkg/agent/tools/security/whois_test.go
Normal 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)
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user