// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. package checker import ( "context" "fmt" "mime" sdk "git.happydns.org/checker-sdk-go/checker" ) func init() { RegisterRule(&securityTxtRule{}) } // securityTxtRule reports whether /.well-known/security.txt is published // (RFC 9116). Absence is an Info, not a Warn: many sites legitimately // have no security disclosure pipeline, but it is now the expected place // for researchers to look first. type securityTxtRule struct{} func (r *securityTxtRule) Name() string { return "http.security_txt" } func (r *securityTxtRule) Description() string { return "Reports whether /.well-known/security.txt (RFC 9116) is published." } func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadHTTPData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } wk, ok, err := LoadExtension[WellKnownData](data, ObservationKeyWellKnown) if err != nil { return []sdk.CheckState{{Status: sdk.StatusError, Code: "http.security_txt.decode_error", Message: err.Error()}} } if !ok { return []sdk.CheckState{unknownState("http.security_txt.no_data", "Well-known collector did not run.")} } probe := wk.URIs["/.well-known/security.txt"] valid, defect := checkSecurityTxt(probe) switch { case probe.StatusCode == 200 && probe.Error != "": // The server answered 200 but the body could not be fully read, so // the field counts are unreliable; don't pass a verdict on it. return []sdk.CheckState{{ Status: sdk.StatusWarn, Code: "http.security_txt.read_error", Subject: data.Domain, Message: fmt.Sprintf("/.well-known/security.txt responded 200 but could not be read fully (%s).", probe.Error), }} case probe.StatusCode == 200 && probe.Bytes == 0: return []sdk.CheckState{{ Status: sdk.StatusWarn, Code: "http.security_txt.empty", Subject: data.Domain, Message: "/.well-known/security.txt responded 200 but is empty.", }} case probe.StatusCode == 200 && valid: return []sdk.CheckState{{ Status: sdk.StatusOK, Code: "http.security_txt.ok", Subject: data.Domain, Message: fmt.Sprintf("/.well-known/security.txt is published (%d bytes).", probe.Bytes), }} case probe.StatusCode == 200: // 200 but the body is not a conforming RFC 9116 file. With no Contact // or Expires fields at all it is typically a soft-404 page (e.g. an // HTML 404 served with status 200); when the fields are present it is // a genuine file that is merely non-conforming (wrong Content-Type, // duplicate Expires, …), so don't mislabel it a soft-404. msg := fmt.Sprintf("/.well-known/security.txt responded 200 but is not a valid RFC 9116 file (%s).", defect) if probe.ContactCount == 0 && probe.ExpiresCount == 0 { msg += " It looks like a soft-404 or placeholder rather than a published security.txt." } return []sdk.CheckState{{ Status: sdk.StatusWarn, Code: "http.security_txt.invalid", Subject: data.Domain, Message: msg, Meta: map[string]any{ "content_type": probe.ContentType, "contact_count": probe.ContactCount, "expires_count": probe.ExpiresCount, "fix": "Serve /.well-known/security.txt as text/plain with at least one Contact: field and exactly one Expires: field (RFC 9116). If you do not publish one, return 404 for this path.", }, }} default: return []sdk.CheckState{{ Status: sdk.StatusInfo, Code: "http.security_txt.missing", Subject: data.Domain, Message: fmt.Sprintf("/.well-known/security.txt is not published (status %d).", probe.StatusCode), Meta: map[string]any{"fix": "Publish /.well-known/security.txt per RFC 9116 (Contact:, Expires:, …)."}, }} } } // checkSecurityTxt reports whether a 200 response is a conforming RFC 9116 // file (served as text/plain, with at least one Contact field and exactly one // Expires field) and, when it is not, a short human-readable reason why. func checkSecurityTxt(p WellKnownProbe) (valid bool, defect string) { switch { case !isTextPlain(p.ContentType): ct := p.ContentType if ct == "" { ct = "no Content-Type" } return false, fmt.Sprintf("Content-Type is %q, not text/plain", ct) case p.ContactCount == 0: return false, "missing required Contact field" case p.ExpiresCount == 0: return false, "missing required Expires field" case p.ExpiresCount > 1: return false, fmt.Sprintf("has %d Expires fields, exactly one is required", p.ExpiresCount) default: return true, "" } } // isTextPlain reports whether a Content-Type header value denotes text/plain, // tolerating an optional charset (or other) parameter such as // "text/plain; charset=utf-8". func isTextPlain(contentType string) bool { mediaType, _, _ := mime.ParseMediaType(contentType) return mediaType == "text/plain" }