Deepen CSP, Permissions-Policy and cookie audits
Detect CSP weaknesses individually (unsafe-inline, unsafe-eval, missing default-src/script-src, permissive sources on script-src or its default-src fallback) instead of a single catch-all "unsafe" code, and honour CSP3 fetch-directive fallback via EffectiveSources/WildcardSource helpers. Validate Permissions-Policy values: warn when a powerful feature (camera, microphone, geolocation, payment, sensors, …) is granted to all origins. Add a SameSite aggregate state on cookie audits so callers get the global ratio alongside per-cookie diagnostics.
This commit is contained in:
parent
27a30638f4
commit
603e93355b
8 changed files with 738 additions and 305 deletions
|
|
@ -5,166 +5,43 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterRule(&hstsRule{})
|
||||
RegisterRule(&cspRule{})
|
||||
RegisterRule(&xFrameOptionsRule{})
|
||||
RegisterRule(&xXSSProtectionRule{})
|
||||
}
|
||||
|
||||
// hstsRule checks the Strict-Transport-Security header on HTTPS responses.
|
||||
type hstsRule struct{}
|
||||
|
||||
func (r *hstsRule) Name() string { return "http.hsts" }
|
||||
func (r *hstsRule) Description() string {
|
||||
return "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses."
|
||||
}
|
||||
|
||||
func (r *hstsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
require := sdk.GetBoolOption(opts, OptionRequireHSTS, true)
|
||||
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
|
||||
minSeconds := int64(minDays) * 86400
|
||||
|
||||
return EvalPerHTTPS(data, "http.hsts", func(p HTTPProbe) sdk.CheckState {
|
||||
h := ParseHSTS(p.Headers["strict-transport-security"])
|
||||
if h == nil {
|
||||
status := sdk.StatusWarn
|
||||
if !require {
|
||||
status = sdk.StatusInfo
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Code: "http.hsts.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Strict-Transport-Security header is missing.",
|
||||
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
|
||||
}
|
||||
}
|
||||
if h.MaxAge < minSeconds {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.hsts.short_max_age",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.hsts.ok",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// cspRule checks for the presence of a Content-Security-Policy header.
|
||||
type cspRule struct{}
|
||||
|
||||
func (r *cspRule) Name() string { return "http.csp" }
|
||||
func (r *cspRule) Description() string {
|
||||
return "Verifies the presence of a Content-Security-Policy header on HTTPS responses."
|
||||
}
|
||||
|
||||
func (r *cspRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
require := sdk.GetBoolOption(opts, OptionRequireCSP, false)
|
||||
|
||||
return EvalPerHTTPS(data, "http.csp", func(p HTTPProbe) sdk.CheckState {
|
||||
csp := ParseCSP(p.Headers["content-security-policy"])
|
||||
if csp == nil {
|
||||
status := sdk.StatusInfo
|
||||
if require {
|
||||
status = sdk.StatusWarn
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Code: "http.csp.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy header is missing.",
|
||||
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
|
||||
}
|
||||
}
|
||||
if csp.HasUnsafe() {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.csp.unsafe",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.csp.ok",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy is set.",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// xFrameOptionsRule checks X-Frame-Options (or frame-ancestors in CSP as
|
||||
// an acceptable substitute).
|
||||
type xFrameOptionsRule struct{}
|
||||
|
||||
func (r *xFrameOptionsRule) Name() string { return "http.x_frame_options" }
|
||||
func (r *xFrameOptionsRule) Description() string {
|
||||
return "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive."
|
||||
}
|
||||
|
||||
func (r *xFrameOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
|
||||
return EvalPerHTTPS(data, "http.x_frame_options", func(p HTTPProbe) sdk.CheckState {
|
||||
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
|
||||
hasFrameAncestors := ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors")
|
||||
|
||||
switch {
|
||||
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.x_frame_options.ok",
|
||||
Subject: p.Address,
|
||||
Message: "Clickjacking protection is in place.",
|
||||
}
|
||||
case xfo != "":
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.x_frame_options.invalid",
|
||||
Subject: p.Address,
|
||||
Message: "X-Frame-Options has an unrecognised value: " + xfo,
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.x_frame_options.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
|
||||
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// All five "core" security-header rules are wired through the HeaderRule
|
||||
// DSL. The richer ones (HSTS, CSP, X-Frame-Options, X-XSS-Protection)
|
||||
// use Inspect / OnMissing to express thresholds, multi-finding outputs,
|
||||
// alternative-source fallbacks and reversed "absent is fine" semantics
|
||||
// without re-implementing the load/iterate/build-state scaffolding.
|
||||
|
||||
func init() {
|
||||
// Showcase: a rule expressed entirely as a HeaderRuleSpec. Compare
|
||||
// with the hand-rolled rules above — the boilerplate vanishes once
|
||||
// the only logic is "is this header present and well-formed?".
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.hsts",
|
||||
Description: "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses.",
|
||||
Header: "Strict-Transport-Security",
|
||||
Inspect: inspectHSTS,
|
||||
OnMissing: missingHSTS,
|
||||
}))
|
||||
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.csp",
|
||||
Description: "Verifies the presence and quality of the Content-Security-Policy header on HTTPS responses.",
|
||||
Header: "Content-Security-Policy",
|
||||
Inspect: inspectCSP,
|
||||
OnMissing: missingCSP,
|
||||
}))
|
||||
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.x_frame_options",
|
||||
Description: "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive.",
|
||||
Header: "X-Frame-Options",
|
||||
Inspect: inspectXFrameOptions,
|
||||
OnMissing: missingXFrameOptions,
|
||||
}))
|
||||
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.x_content_type_options",
|
||||
Description: "Verifies that responses set X-Content-Type-Options: nosniff.",
|
||||
|
|
@ -178,54 +55,188 @@ func init() {
|
|||
return sdk.StatusWarn, "X-Content-Type-Options has an unexpected value: " + strings.ToLower(v)
|
||||
},
|
||||
}))
|
||||
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.x_xss_protection",
|
||||
Description: "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement).",
|
||||
Header: "X-XSS-Protection",
|
||||
Inspect: inspectXXSSProtection,
|
||||
OnMissing: func(_ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusInfo,
|
||||
Suffix: "absent",
|
||||
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
|
||||
}}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// xXSSProtectionRule checks the legacy X-XSS-Protection header. Modern
|
||||
// browsers ignore it, but if present we want it to be sane.
|
||||
type xXSSProtectionRule struct{}
|
||||
// HSTS ----------------------------------------------------------------
|
||||
|
||||
func (r *xXSSProtectionRule) Name() string { return "http.x_xss_protection" }
|
||||
func (r *xXSSProtectionRule) Description() string {
|
||||
return "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement)."
|
||||
func missingHSTS(_ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
|
||||
status := sdk.StatusWarn
|
||||
if !sdk.GetBoolOption(opts, OptionRequireHSTS, true) {
|
||||
status = sdk.StatusInfo
|
||||
}
|
||||
return []HeaderResult{{
|
||||
Status: status,
|
||||
Suffix: "missing",
|
||||
Message: "Strict-Transport-Security header is missing.",
|
||||
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
|
||||
}}
|
||||
}
|
||||
|
||||
func (r *xXSSProtectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
func inspectHSTS(value string, _ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
|
||||
h := ParseHSTS(value)
|
||||
if h == nil {
|
||||
// Defensive: ParseHSTS only returns nil on empty input, which the
|
||||
// DSL has already routed to OnMissing.
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn, Suffix: "invalid",
|
||||
Message: "Strict-Transport-Security header is malformed.",
|
||||
}}
|
||||
}
|
||||
if len(h.Errors) > 0 {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn,
|
||||
Suffix: "invalid",
|
||||
Message: fmt.Sprintf("Strict-Transport-Security header is malformed: %s.", strings.Join(h.Errors, "; ")),
|
||||
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` with a non-negative integer max-age."},
|
||||
}}
|
||||
}
|
||||
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
|
||||
minSeconds := int64(minDays) * 86400
|
||||
if h.MaxAge < minSeconds {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn,
|
||||
Suffix: "short_max_age",
|
||||
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
|
||||
}}
|
||||
}
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusOK,
|
||||
Suffix: "ok",
|
||||
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
|
||||
}}
|
||||
}
|
||||
|
||||
// CSP -----------------------------------------------------------------
|
||||
|
||||
func missingCSP(_ HTTPProbe, opts sdk.CheckerOptions) []HeaderResult {
|
||||
status := sdk.StatusInfo
|
||||
if sdk.GetBoolOption(opts, OptionRequireCSP, false) {
|
||||
status = sdk.StatusWarn
|
||||
}
|
||||
return []HeaderResult{{
|
||||
Status: status,
|
||||
Suffix: "missing",
|
||||
Message: "Content-Security-Policy header is missing.",
|
||||
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
|
||||
}}
|
||||
}
|
||||
|
||||
// inspectCSP surfaces multiple weakness suffixes per probe — see the
|
||||
// historical docstring on evaluateCSP for the rationale (unsafe-inline /
|
||||
// unsafe-eval split, missing default-src, permissive script-src).
|
||||
func inspectCSP(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
||||
csp := ParseCSP(value)
|
||||
if csp == nil {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn, Suffix: "invalid",
|
||||
Message: "Content-Security-Policy header is empty.",
|
||||
}}
|
||||
}
|
||||
var out []HeaderResult
|
||||
add := func(suffix, msg string) {
|
||||
out = append(out, HeaderResult{Status: sdk.StatusWarn, Suffix: suffix, Message: msg})
|
||||
}
|
||||
|
||||
return EvalPerHTTPS(data, "http.x_xss_protection", func(p HTTPProbe) sdk.CheckState {
|
||||
v := strings.TrimSpace(p.Headers["x-xss-protection"])
|
||||
switch {
|
||||
case v == "":
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.absent",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
|
||||
}
|
||||
case strings.HasPrefix(v, "0"):
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.x_xss_protection.disabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is explicitly disabled (recommended).",
|
||||
}
|
||||
case strings.Contains(strings.ToLower(v), "mode=block"):
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.enabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.enabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}
|
||||
hasDefault := csp.HasDirective("default-src")
|
||||
hasScript := csp.HasDirective("script-src")
|
||||
if !hasDefault && !hasScript {
|
||||
add("missing_default",
|
||||
"Content-Security-Policy declares neither default-src nor script-src; script execution is not constrained.")
|
||||
}
|
||||
if csp.HasUnsafeInline() {
|
||||
add("unsafe_inline",
|
||||
"Content-Security-Policy allows 'unsafe-inline' for scripts or styles, which negates most XSS protection.")
|
||||
}
|
||||
if csp.HasUnsafeEval() {
|
||||
add("unsafe_eval",
|
||||
"Content-Security-Policy allows 'unsafe-eval' in script-src, enabling eval()/new Function().")
|
||||
}
|
||||
switch {
|
||||
case hasScript:
|
||||
if w := csp.WildcardSource("script-src"); w != "" {
|
||||
add("wildcard_script_src",
|
||||
"Content-Security-Policy script-src includes the permissive source "+w+", allowing scripts from arbitrary origins.")
|
||||
}
|
||||
})
|
||||
case hasDefault:
|
||||
if w := csp.WildcardSource("default-src"); w != "" {
|
||||
add("wildcard_default_src",
|
||||
"Content-Security-Policy default-src includes the permissive source "+w+" and no script-src overrides it.")
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusOK,
|
||||
Suffix: "ok",
|
||||
Message: "Content-Security-Policy is set with no detected weaknesses.",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// X-Frame-Options -----------------------------------------------------
|
||||
|
||||
func inspectXFrameOptions(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
||||
xfo := strings.ToUpper(value)
|
||||
if xfo == "DENY" || xfo == "SAMEORIGIN" {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusOK, Suffix: "ok",
|
||||
Message: "Clickjacking protection is in place.",
|
||||
}}
|
||||
}
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn, Suffix: "invalid",
|
||||
Message: "X-Frame-Options has an unrecognised value: " + xfo,
|
||||
}}
|
||||
}
|
||||
|
||||
func missingXFrameOptions(p HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
||||
if ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors") {
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusOK, Suffix: "ok",
|
||||
Message: "Clickjacking protection is in place.",
|
||||
}}
|
||||
}
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusWarn,
|
||||
Suffix: "missing",
|
||||
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
|
||||
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
|
||||
}}
|
||||
}
|
||||
|
||||
// X-XSS-Protection ----------------------------------------------------
|
||||
|
||||
func inspectXXSSProtection(value string, _ HTTPProbe, _ sdk.CheckerOptions) []HeaderResult {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "0"):
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusOK, Suffix: "disabled",
|
||||
Message: "X-XSS-Protection is explicitly disabled (recommended).",
|
||||
}}
|
||||
case strings.Contains(strings.ToLower(value), "mode=block"):
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusInfo, Suffix: "enabled",
|
||||
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}}
|
||||
default:
|
||||
return []HeaderResult{{
|
||||
Status: sdk.StatusInfo, Suffix: "enabled",
|
||||
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue