The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
283 lines
8.1 KiB
Go
283 lines
8.1 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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 func() { _ = 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
|
|
}
|