Files
hotdog-cms/internal/check/check.go
T

169 lines
4.4 KiB
Go

// Package check reads a built site and reports what would embarrass it in
// production, or break a promise it makes to its visitors: tracking that
// starts before anyone agreed to it, scripts from other sites, pages a
// strict security policy would break, links to nowhere, missing search and
// social metadata, a security.txt past its date. These are the gotchas that
// have bitten real sites, written down so they can't happen twice.
//
// Errors fail the check; warnings are reported. Every rule has an id, which
// site.yaml can switch off (check.ignore) when a site means it.
package check
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Level is how serious a finding is.
type Level string
const (
Error Level = "error"
Warning Level = "warning"
)
// Problem is one finding.
type Problem struct {
Level Level
Rule string
File string
What string
Fix string `json:",omitempty"` // what usually puts it right (fixes.go)
}
func (p Problem) String() string {
return fmt.Sprintf("%-7s %-24s %s: %s", p.Level, p.Rule, p.File, p.What)
}
// Report is what a check found, and the Content-Security-Policy the built
// pages would need.
type Report struct {
Problems []Problem
CSP string
}
// Errors counts the findings that fail the check.
func (r *Report) Errors() int {
n := 0
for _, p := range r.Problems {
if p.Level == Error {
n++
}
}
return n
}
type checker struct {
siteDir, out string
cfg *site.Config
ignore map[string]bool
allow map[string]bool
now time.Time
report Report
}
func (c *checker) add(level Level, rule, file, format string, args ...any) {
if c.ignore[rule] {
return
}
c.report.Problems = append(c.report.Problems, Problem{Level: level, Rule: rule, File: file, What: fmt.Sprintf(format, args...), Fix: Fixes[rule]})
}
// Run checks the built site in out against the site in siteDir.
func Run(siteDir, out string, cfg *site.Config) (*Report, error) {
c := &checker{siteDir: siteDir, out: out, cfg: cfg, ignore: map[string]bool{}, allow: map[string]bool{}, now: time.Now()}
for _, r := range cfg.Check.Ignore {
c.ignore[r] = true
}
for _, h := range cfg.Check.AllowThirdParty {
c.allow[strings.ToLower(h)] = true
}
if err := c.forbidden(); err != nil {
return nil, err
}
pages, err := c.readPages()
if err != nil {
return nil, err
}
for _, pg := range pages {
c.checkPage(pg)
}
c.sitewide(pages)
c.images()
c.look()
c.report.CSP = policy(pages)
sort.SliceStable(c.report.Problems, func(i, j int) bool {
a, b := c.report.Problems[i], c.report.Problems[j]
if a.Level != b.Level {
return a.Level == Error
}
if a.Rule != b.Rule {
return a.Rule < b.Rule
}
return a.File < b.File
})
return &c.report, nil
}
// forbidden looks for the site's banned strings in what people write as well
// as in what gets built: a template or data file can leak a host name as
// easily as a page. site.yaml is left out because it is where the patterns
// are written.
func (c *checker) forbidden() error {
var res []*regexp.Regexp
for _, f := range c.cfg.Check.Forbid {
re, err := regexp.Compile(f)
if err != nil {
return fmt.Errorf("site.yaml check.forbid %q: %w", f, err)
}
res = append(res, re)
}
if len(res) == 0 {
return nil
}
scan := func(root, label string) {
_ = filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() || !textFile(p) {
return nil
}
data, err := os.ReadFile(p)
if err != nil {
return nil
}
rel, _ := filepath.Rel(c.siteDir, p)
if label != "" {
rel, _ = filepath.Rel(c.out, p)
rel = label + filepath.ToSlash(rel)
}
for _, re := range res {
if loc := re.FindIndex(data); loc != nil {
line := 1 + strings.Count(string(data[:loc[0]]), "\n")
c.add(Error, "forbidden-string", fmt.Sprintf("%s:%d", filepath.ToSlash(rel), line), "matches forbidden pattern %q", re.String())
}
}
return nil
})
}
for _, d := range []string{"content", "data", "layouts", "partials", "sections", "icons", "assets", "static", "forms"} {
scan(filepath.Join(c.siteDir, d), "")
}
scan(c.out, "public/")
return nil
}
func textFile(p string) bool {
switch strings.ToLower(filepath.Ext(p)) {
case ".md", ".html", ".htm", ".css", ".js", ".json", ".yaml", ".yml", ".txt", ".xml", ".svg", ".webmanifest":
return true
}
return false
}