diff --git a/runner/options.go b/runner/options.go index d12a7dad..5efccdd3 100644 --- a/runner/options.go +++ b/runner/options.go @@ -254,6 +254,7 @@ type Options struct { OutputIP bool OutputCName bool ExtractFqdn bool + SecurityTxt bool Unsafe bool Debug bool DebugRequests bool @@ -417,6 +418,7 @@ func ParseOptions() *Options { flagSet.BoolVar(&options.OutputIP, "ip", false, "display host ip"), flagSet.BoolVar(&options.OutputCName, "cname", false, "display host cname"), flagSet.BoolVarP(&options.ExtractFqdn, "efqdn", "extract-fqdn", false, "get domain and subdomains from response body and header in jsonl/csv output"), + flagSet.BoolVar(&options.SecurityTxt, "security-txt", false, "detect a valid security.txt file on standard paths"), flagSet.BoolVar(&options.Asn, "asn", false, "display host asn information"), flagSet.DynamicVar(&options.OutputCDN, "cdn", "true", "display cdn/waf in use"), flagSet.BoolVar(&options.Probe, "probe", false, "display probe status"), diff --git a/runner/runner.go b/runner/runner.go index d0150661..03f46719 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -330,6 +330,15 @@ func New(options *Options) (*Runner, error) { scanopts.VHostInput = options.VHostInput scanopts.OutputContentType = options.OutputContentType scanopts.RequestBody = options.RequestBody + if options.SecurityTxt { + options.OutputMatchString = append(options.OutputMatchString, "Contact:") + options.OutputMatchStatusCode = appendCommaSeparatedValue(options.OutputMatchStatusCode, "200") + options.RequestURIs = appendCommaSeparatedValue(options.RequestURIs, "/.well-known/security.txt,/security.txt") + options.JSONOutput = true + } + if options.RequestURIs != "" { + options.requestURIs = normalizeRequestURIs(options.RequestURIs) + } scanopts.Unsafe = options.Unsafe scanopts.Pipeline = options.Pipeline scanopts.HTTP2Probe = options.HTTP2Probe @@ -2632,6 +2641,11 @@ retry: } } + securityTxt := false + if r.options.SecurityTxt { + securityTxt = isSecurityTxt(resp) + } + result := Result{ Timestamp: time.Now(), Request: request, @@ -2650,6 +2664,7 @@ retry: StatusCode: resp.StatusCode, Location: resp.GetHeaderPart("Location", ";"), ContentType: resp.GetHeaderPart("Content-Type", ";"), + SecurityTxt: securityTxt, Title: title, str: builder.String(), VHost: isvhost, @@ -2973,6 +2988,48 @@ func (r Result) CSVRow(scanopts *ScanOptions) string { //nolint return res } +func appendCommaSeparatedValue(current string, values string) string { + current = strings.TrimSpace(current) + values = strings.TrimSpace(values) + if current == "" { + return values + } + if values == "" { + return current + } + return current + "," + values +} + +func normalizeRequestURIs(requestURIs string) []string { + items := stringsutil.SplitAny(requestURIs, ",") + cleaned := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + cleaned = append(cleaned, item) + } + return cleaned +} + +func isSecurityTxt(resp *httpx.Response) bool { + if resp == nil || resp.StatusCode != 200 { + return false + } + contentType := strings.ToLower(resp.GetHeaderPart("Content-Type", ";")) + if contentType != "" && !strings.Contains(contentType, "text/plain") { + return false + } + body := string(resp.RawData) + return strings.Contains(body, "Contact:") +} + func (r *Runner) skipCDNPort(host string, port string) bool { // if the option is not enabled we don't skip if !r.scanopts.ExcludeCDN { diff --git a/runner/runner_test.go b/runner/runner_test.go index 415a6f51..5c86cce9 100644 --- a/runner/runner_test.go +++ b/runner/runner_test.go @@ -286,6 +286,63 @@ func TestRunner_urlWithComma_targets(t *testing.T) { require.ElementsMatch(t, expected, got, "could not expected output") } +func TestNormalizeRequestURIs(t *testing.T) { + got := normalizeRequestURIs("/.well-known/security.txt, /security.txt, /.well-known/security.txt,,") + require.Equal(t, []string{"/.well-known/security.txt", "/security.txt"}, got) +} + +func TestAppendCommaSeparatedValue(t *testing.T) { + require.Equal(t, "200", appendCommaSeparatedValue("", "200")) + require.Equal(t, "200,302", appendCommaSeparatedValue("200", "302")) + require.Equal(t, "200", appendCommaSeparatedValue("200", "")) +} + +func TestIsSecurityTxt(t *testing.T) { + t.Run("valid security txt", func(t *testing.T) { + resp := &httpx.Response{ + StatusCode: 200, + Headers: map[string][]string{ + "Content-Type": {"text/plain; charset=utf-8"}, + }, + RawData: []byte("Contact: mailto:security@example.com\nExpires: 2027-01-01T00:00:00.000Z\n"), + } + require.True(t, isSecurityTxt(resp)) + }) + + t.Run("soft 404 html", func(t *testing.T) { + resp := &httpx.Response{ + StatusCode: 200, + Headers: map[string][]string{ + "Content-Type": {"text/html; charset=utf-8"}, + }, + RawData: []byte("Contact: support@example.com"), + } + require.False(t, isSecurityTxt(resp)) + }) + + t.Run("missing contact field", func(t *testing.T) { + resp := &httpx.Response{ + StatusCode: 200, + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + RawData: []byte("Expires: 2027-01-01T00:00:00.000Z\n"), + } + require.False(t, isSecurityTxt(resp)) + }) + + t.Run("wrong status", func(t *testing.T) { + resp := &httpx.Response{ + StatusCode: 404, + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + RawData: []byte("Contact: mailto:security@example.com\n"), + } + require.False(t, isSecurityTxt(resp)) + }) +} + func TestRunner_CSVRow(t *testing.T) { // Create a result with fields that would be vulnerable to CSV injection result := Result{ diff --git a/runner/types.go b/runner/types.go index 1eaa3608..93ccf20b 100644 --- a/runner/types.go +++ b/runner/types.go @@ -57,6 +57,7 @@ type Result struct { ResponseBody string `json:"body,omitempty" csv:"-" md:"-" mapstructure:"body"` BodyPreview string `json:"body_preview,omitempty" csv:"body_preview" md:"body_preview" mapstructure:"body_preview"` ContentType string `json:"content_type,omitempty" csv:"content_type" md:"content_type" mapstructure:"content_type"` + SecurityTxt bool `json:"security_txt,omitempty" csv:"security_txt" md:"security_txt" mapstructure:"security_txt"` Method string `json:"method,omitempty" csv:"method" md:"method" mapstructure:"method"` Host string `json:"host,omitempty" csv:"host" md:"host" mapstructure:"host"` HostIP string `json:"host_ip,omitempty" csv:"host_ip" md:"host_ip" mapstructure:"host_ip"`