checker-http/checker/header_rule.go
Pierre-Olivier Mercier 603e93355b 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.
2026-04-28 18:43:07 +07:00

173 lines
5.5 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// HeaderResult is one observation produced by a HeaderRuleSpec callback.
// Suffix is appended to spec.Code (with a dot separator) to form the
// final CheckState code, e.g. "http.hsts" + "short_max_age" →
// "http.hsts.short_max_age". An empty Suffix uses spec.Code verbatim.
type HeaderResult struct {
Status sdk.Status
Suffix string
Message string
Meta map[string]any
}
// HeaderRuleSpec declares a per-HTTPS-probe rule built around a single
// response header. It supersedes the per-rule Evaluate boilerplate that
// every "load HTTPData → iterate successful HTTPS probes → inspect one
// header → emit one CheckState" rule used to repeat.
//
// Three callbacks cover the spectrum, from simplest to most expressive:
//
// - Validate: the header is present and a single boolean verdict is
// enough. Returns (Status, message); the rule emits ".ok" on
// StatusOK or ".invalid" otherwise. Used by the modern privacy
// headers (Referrer-Policy, COOP/COEP/CORP, Permissions-Policy).
//
// - Inspect: the header is present and may produce any number of
// findings with arbitrary suffixes. Used by HSTS (".short_max_age"),
// CSP (".unsafe_inline" / ".wildcard_script_src" / …) and the
// legacy X-XSS-Protection rule which reports custom suffixes
// (".disabled", ".enabled").
//
// - OnMissing: the header is absent and the default ".missing"
// emitter is wrong — either an alternative satisfies the
// requirement (CSP frame-ancestors standing in for X-Frame-Options),
// or absence has non-default severity (X-XSS-Protection emits
// Info ".absent", not Warn ".missing"), or the severity depends
// on a CheckerOption (HSTS/CSP gate "missing" on a configurable
// "required" flag).
//
// Validate and Inspect are mutually exclusive. OnMissing can be combined
// with either. Specs that omit all three behave as a pure presence check
// (".ok" when set, default ".missing" when not).
type HeaderRuleSpec struct {
Code string
Description string
Header string
// Required toggles the severity of the default ".missing" emitter
// (Warn when true, Info when false). Ignored when OnMissing is set.
Required bool
// FixHint, when set, populates Meta.fix on the default ".missing"
// emitter. Ignored when OnMissing is set (callbacks must build
// their own Meta).
FixHint string
Validate func(value string) (sdk.Status, string)
Inspect func(value string, p HTTPProbe, opts sdk.CheckerOptions) []HeaderResult
OnMissing func(p HTTPProbe, opts sdk.CheckerOptions) []HeaderResult
}
// HeaderRule constructs a self-contained sdk.CheckRule from a spec.
// Intended to be wired in init() via RegisterRule.
func HeaderRule(spec HeaderRuleSpec) sdk.CheckRule {
if spec.Validate != nil && spec.Inspect != nil {
panic("checker: HeaderRuleSpec " + spec.Code + " sets both Validate and Inspect")
}
return &headerRule{spec: spec}
}
type headerRule struct{ spec HeaderRuleSpec }
func (r *headerRule) Name() string { return r.spec.Code }
func (r *headerRule) Description() string { return r.spec.Description }
func (r *headerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
probes := successfulHTTPSProbes(data.Probes)
if len(probes) == 0 {
return []sdk.CheckState{unknownState(r.spec.Code+".no_https", "No successful HTTPS probe to evaluate.")}
}
headerKey := strings.ToLower(r.spec.Header)
out := make([]sdk.CheckState, 0, len(probes))
for _, p := range probes {
for _, res := range r.evaluateProbe(p, opts, headerKey) {
out = append(out, r.toCheckState(p, res))
}
}
return out
}
func (r *headerRule) evaluateProbe(p HTTPProbe, opts sdk.CheckerOptions, headerKey string) []HeaderResult {
v := strings.TrimSpace(p.Headers[headerKey])
if v == "" {
if r.spec.OnMissing != nil {
return ensureNonEmpty(r.spec.OnMissing(p, opts), r.defaultPresent())
}
return []HeaderResult{r.defaultMissing()}
}
switch {
case r.spec.Inspect != nil:
return ensureNonEmpty(r.spec.Inspect(v, p, opts), r.defaultPresent())
case r.spec.Validate != nil:
status, msg := r.spec.Validate(v)
suffix := "invalid"
if status == sdk.StatusOK {
suffix = "ok"
}
return []HeaderResult{{Status: status, Suffix: suffix, Message: msg}}
default:
return []HeaderResult{r.defaultPresent()}
}
}
func (r *headerRule) defaultMissing() HeaderResult {
status := sdk.StatusInfo
if r.spec.Required {
status = sdk.StatusWarn
}
res := HeaderResult{
Status: status,
Suffix: "missing",
Message: r.spec.Header + " is not set.",
}
if r.spec.FixHint != "" {
res.Meta = map[string]any{"fix": r.spec.FixHint}
}
return res
}
func (r *headerRule) defaultPresent() HeaderResult {
return HeaderResult{
Status: sdk.StatusOK,
Suffix: "ok",
Message: r.spec.Header + " is set.",
}
}
func (r *headerRule) toCheckState(p HTTPProbe, res HeaderResult) sdk.CheckState {
code := r.spec.Code
if res.Suffix != "" {
code = code + "." + res.Suffix
}
return sdk.CheckState{
Status: res.Status,
Code: code,
Subject: p.Address,
Message: res.Message,
Meta: res.Meta,
}
}
func ensureNonEmpty(results []HeaderResult, fallback HeaderResult) []HeaderResult {
if len(results) == 0 {
return []HeaderResult{fallback}
}
return results
}